makepad/tools/makepad_builder/makepad.sh
Admin c43831fe9c builder: apps build on their own menu row while the menu stays usable, with a queue, cancel and the Experiments list inline -- Return on an app downloads and compiles it in the background: its row spins in Makepad orange and shows the step with a short orange bar ("⠴ compiling ━━━━━─────── 50 / 118 crates"), and it opens by itself when done; nothing else is on the row. Return on another app while one compiles queues it ("◌ queued · next", "· 2 to go"); the next starts when the one before ends. Every build and queue entry carries its own open-when-done flag: Return on that app's row sets it (idle, queued or compiling), Compile all never does (its apps are only compiled: "✓ … is compiled."), and s turns it off or on for the selected compiling or queued row. c cancels the selected build (Cargo, its compilers and the downloads stop -- the Windows job object, curl killed, the shell's process tree; what was downloaded and compiled stays and the row says "stopped · continue") or takes a queued app out; on Compile all it takes out its queued apps. The footer shows "s don't start" / "s start when done" and "c cancel", letters in orange, only where they apply, and the line under the rule says what the selected row does or what happens with the build ("Compiling from source. It opens by itself when it is done.", "Queued behind Makepad Amp; it compiles and opens after it.", "... only compiles; press s or ⏎ to open it when done."). Update checks on its own row the same way, beside a build (one checkout writes a snapshot at a time). Experiments is a tree node in the main menu (▸/▾) with Compile all and every experiment indented under it; license labels are beta, licensed and free, and statuses line up at column 30. Everything above the footer, header included, scrolls with the selection; the rule, status lines and keys stay pinned at the bottom with "↑ n above · ↓ n more" above them, and the gap above the footer is gone. Accents are Makepad orange throughout (the .bat's first screen too). Same screens and keys in the Windows TUI and makepad.sh (background jobs there, a 0.2 s tick, a redraw after a window resize). Checked: cargo check for the host, x86_64-pc-windows-gnu and -msvc, the builder lib tests, and both TUIs driven in a pty (OSC 11 dark and light, 80x24 to 110x40) through real downloads and compiles of the public experiments: inline progress, queue and automatic next, Return and s on the flag, cancel of compiling and queued rows with no compiler left running, Compile all and its cancel, the update check beside a build, quitting while compiling, the Experiments node, scrolling in a short window and the Log in page's mark. Not run on a real Windows console
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 23:41:34 +02:00

3822 lines
181 KiB
Bash
Executable file
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.

#!/bin/sh
# Makepad Builder for macOS and Linux.
#
# curl -fsSL https://makepad.nl/builder/unix | sh
#
# You can read this whole file before running it: it is one shell script and
# nothing compiled. It opens the Builder in your terminal, which asks for your
# email (the one identity; empty continues with the free apps), a folder
# (default ~/makepad-commercial) and which Rust to use, then installs Rust
# into that folder only to compile the apps. It copies itself into the folder
# as the `makepad` command:
#
# ~/makepad-commercial/makepad opens the Builder again
# ~/makepad-commercial/makepad build APP compiles an app (for coding agents)
#
# Everything the Builder downloads, builds and records stays in that folder
# (in its builder/ folder: beside `makepad` there are only the apps you
# build); Apple's developer tools or your distribution's development packages are
# the only things installed elsewhere, and it shows and asks about them first.
#
# The Builder itself is the text between the MAKEPAD_BUILDER lines below.
# Everything runs from the last line, so a download cut off halfway does
# nothing at all.
makepad_builder() {
set -eu
[ "$(id -u)" != 0 ] || { echo 'Run the Makepad Builder as your normal user, not as root.' >&2; return 1; }
makepad_dir=$(mktemp -d "${TMPDIR:-/tmp}/makepad-builder.XXXXXX") || return 1
cat > "$makepad_dir/makepad" <<'MAKEPAD_BUILDER'
#!/bin/sh
# Makepad Builder for macOS and Linux, installed as `makepad` in its folder.
#
# ./makepad the Builder: licenses, apps, updates, agents
# ./makepad build APP compile an app after you or an agent changed it
# ./makepad run APP compile it when needed, then open it
# ./makepad list every app, its state and its source folder
# ./makepad env this folder's build environment, for eval
# ./makepad agent-context what coding agents are told about this folder
#
# One shell script and nothing compiled for itself; Rust is installed only to
# build the apps. Every run is a restart: all state is read from the folder,
# and a stopped download continues where it was. Everything stays in this
# folder, in builder/ (beside this command there are only the apps), except Apple's developer tools or your distribution's development
# packages, which it shows and asks about first.
set -eu
umask 077
service=${MAKEPAD_LOADER_SERVICE:-https://makepad.nl/api/loader}
service=${service%/}
# A personalized download names the email here; the Builder still asks.
builder_email='@EMAIL@'
case "$builder_email" in @*@) builder_email= ;; esac
case "$(uname -s)/$(uname -m)" in
Darwin/arm64 | Darwin/aarch64) triple=aarch64-apple-darwin plat=mac ;;
Darwin/x86_64) triple=x86_64-apple-darwin plat=mac ;;
Linux/x86_64) triple=x86_64-unknown-linux-gnu plat=linux ;;
Linux/aarch64 | Linux/arm64) triple=aarch64-unknown-linux-gnu plat=linux ;;
*) printf 'The Makepad Builder supports macOS, and glibc Linux on x86_64 and ARM64.\n' >&2; exit 1 ;;
esac
self=$0
case "$self" in /*) ;; *) self="$(pwd -P)/$self" ;; esac
# The terminal UI reads keys with bash's read builtin; where dash or another
# sh started it, the same script continues under /bin/bash when there is one.
if [ "$#" = 0 ] && [ -z "${BASH_VERSION:-}" ] && [ -z "${MAKEPAD_BUILDER_SH:-}" ] && [ -x /bin/bash ] && [ -f "$self" ]; then
MAKEPAD_BUILDER_SH=1 exec /bin/bash "$self"
fi
# The screen's faint logo strips colour codes with an extended pattern.
[ -z "${BASH_VERSION:-}" ] || shopt -s extglob
self_dir=$(cd -P -- "$(dirname -- "$self")" && pwd -P)
# home: the folder people see, with this command and the apps built there;
# root: the Builder's own folder in it (builder/), which holds everything
# else. Folders from before that layout are moved over on the next start.
home= root=
if [ -f "$self_dir/builder/makepad-builder.json" ] || [ -f "$self_dir/makepad-builder.json" ] || [ -f "$self_dir/makepad-loader.json" ]; then
home=$self_dir root=$self_dir/builder
fi
# ------------------------------------------------------------ utilities ---
nl='
'
ifs_default=$(printf ' \t\n_'); ifs_default=${ifs_default%_}
# Shell pattern ranges ([a-z], [!!-~]) follow the locale's collation: in a
# UTF-8 locale they stop meaning byte ranges, and the email check refused
# every address. Collation alone is pinned to byte order; the character
# type (UTF-8 text and widths) stays the person's.
if [ -n "${LC_ALL:-}" ]; then LC_CTYPE=$LC_ALL; export LC_CTYPE; unset LC_ALL; fi
LC_COLLATE=C; export LC_COLLATE
now() { date +%s; }
short() { case "$1" in "$HOME"/*) printf '~/%s' "${1#"$HOME"/}" ;; *) printf '%s' "$1" ;; esac; }
# pad TEXT WIDTH -> $padded, without forking.
pad() { padded=$1; while [ "${#padded}" -lt "$2" ]; do padded="$padded "; done; }
sha256() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{ print $1 }'
else shasum -a 256 "$1" | awk '{ print $1 }'; fi
}
size_of() { if [ -f "$1" ]; then wc -c < "$1" | tr -d ' '; else printf 0; fi; }
# put FILE TEXT: written through a temporary name, so readers never see half.
put() { printf '%s\n' "$2" > "$1.$$.next" && mv -f "$1.$$.next" "$1"; }
log() { [ -z "${log_file:-}" ] || printf '%s %s\n' "$(date '+%H:%M:%S')" "$*" >> "$log_file"; }
# remove_inside PATH: deletes PATH only when it lies strictly inside the
# Builder's installation folder; anything else is refused and left alone.
remove_inside() {
[ -n "$home" ] || return 1
case "$1" in
"$home"/?*) case "$1" in *'/../'* | *'/..') return 1 ;; esac; rm -rf -- "$1" ;;
*) log "refused to delete $1"; return 1 ;;
esac
}
identifier() { case "$1" in '' | *[!A-Za-z0-9_-]*) return 1 ;; esac; [ "${#1}" -le 128 ]; }
relative() {
case "$1" in '' | /* | */ | *//* | *[!A-Za-z0-9_/-]*) return 1 ;; esac
case "/$1/" in */../* | */./*) return 1 ;; esac
}
hex() { case "$1" in *[!0-9a-f]*) return 1 ;; esac; [ "${#1}" = "$2" ]; }
# email TEXT -> $email_ok (lowercase) or fails: the rules of the Windows
# Builder and the service.
check_email() {
email_ok=$(printf '%s' "$1" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | tr 'A-Z' 'a-z')
case "$email_ok" in *@*@* | @* | *@ | *[!!-~]* | *'<'* | *'>'* | *'"'* | *'\'*) return 1 ;; *@*) ;; *) return 1 ;; esac
em_local=${email_ok%%@*}
[ "${#email_ok}" -le 254 ] && [ "${#em_local}" -le 64 ]
}
# --------------------------------------------------------------- JSON ---
# One awk program reads the service's JSON: "flat" prints path<TAB>value
# per value, "items" each element of a top-level array on one line,
# "release" shell assignments for one release, "apps" one line per app.
json_awk='
function jstr( out, r, p, c) {
out = ""; i++
while (i <= n) {
r = substr(s, i, 4096)
p = match(r, /["\\]/)
if (p == 0) { out = out r; i += length(r); continue }
out = out substr(r, 1, p - 1); i += p - 1
c = substr(s, i, 1)
if (c == "\"") { i++; return out }
c = substr(s, i + 1, 1); i += 2
if (c == "u") { out = out "?"; i += 4 }
else if (c == "n" || c == "t" || c == "r" || c == "b" || c == "f") out = out " "
else out = out c
}
return out
}
function jpath( p, d) {
p = ""
for (d = 1; d <= depth; d++) p = p (d > 1 ? "/" : "") (kind[d] == "a" ? idx[d] : key[d])
return p
}
function jval(v) { np++; P[np] = jpath(); V[np] = v; if (mode == "flat") print P[np] "\t" v }
function q(v) { gsub(sq, sq "\"" sq "\"" sq, v); return sq v sq }
function release_vars( k, a, name, nrep, r, x, y, t, lines, total, pl, feat) {
nrep = 0; pl = ""; feat = ""
for (k = 1; k <= np; k++) {
name = P[k]
if (name ~ /^(id|title|release|rust|package|binary|workspace|license|public|cuda)$/) F[name] = V[k]
else if (name ~ /^platforms\//) pl = pl " " V[k]
else if (name ~ /^features\//) feat = feat (feat == "" ? "" : ",") V[k]
else if (name ~ /^repositories\/[0-9]+\/[a-z0-9]+$/) {
split(name, a, "/"); R[a[2] "," a[3]] = V[k]
if (a[2] + 1 > nrep) nrep = a[2] + 1
}
}
split("id title release rust package binary workspace license public cuda", a, " ")
for (k = 1; k <= 10; k++) print "rel_" a[k] "=" q(F[a[k]])
for (r = 0; r < nrep; r++) o[r] = r
for (x = 1; x < nrep; x++)
for (y = x; y > 0 && length(R[o[y - 1] ",path"]) > length(R[o[y] ",path"]); y--) { t = o[y]; o[y] = o[y - 1]; o[y - 1] = t }
lines = ""; total = 0
for (x = 0; x < nrep; x++) {
r = o[x]
lines = lines (x ? "\n" : "") R[r ",name"] "|" R[r ",path"] "|" R[r ",commit"] "|" R[r ",sha256"] "|" R[r ",bytes"]
total += R[r ",bytes"]
}
print "rel_repos=" q(lines); print "rel_bytes=" (total + 0); print "rel_platforms=" q(pl); print "rel_features=" q(feat)
}
function app_lines( k, a, count, x) {
count = 0
for (k = 1; k <= np; k++) {
split(P[k], a, "/")
if (a[2] == "features") A[a[1] ",features"] = A[a[1] ",features"] (A[a[1] ",features"] == "" ? "" : ",") V[k]
else A[a[1] "," a[2]] = V[k]
if (a[1] + 1 > count) count = a[1] + 1
}
for (x = 0; x < count; x++) print A[x ",id"] "|" A[x ",title"] "|" A[x ",package"] "|" A[x ",binary"] "|" A[x ",workspace"] "|" A[x ",features"] "|" A[x ",menu"]
}
BEGIN { sq = sprintf("%c", 39) }
{ s = s $0 "\n" }
END {
n = length(s); i = 1; depth = 0; np = 0
while (i <= n) {
c = substr(s, i, 1)
if (c == "{" || c == "[") {
if (depth == 1 && kind[1] == "a") start = i
depth++; kind[depth] = (c == "{") ? "o" : "a"; idx[depth] = 0; key[depth] = ""; want[depth] = (c == "{"); i++
} else if (c == "}" || c == "]") {
depth--; i++
if (mode == "items" && depth == 1 && kind[1] == "a") print substr(s, start, i - start)
} else if (c == ",") {
if (kind[depth] == "a") idx[depth]++; else want[depth] = 1
i++
} else if (c == ":") { want[depth] = 0; i++ }
else if (c == "\"") {
v = jstr()
if (kind[depth] == "o" && want[depth]) key[depth] = v; else jval(v)
} else if (c == " " || c == "\t" || c == "\n" || c == "\r") i++
else {
j = i
while (j <= n) { d = substr(s, j, 1); if (d == "," || d == "}" || d == "]" || d == " " || d == "\n" || d == "\t" || d == "\r") break; j++ }
jval(substr(s, i, j - i)); i = j
}
}
if (mode == "release") release_vars()
if (mode == "apps") app_lines()
}'
json() { awk -v mode="$1" "$json_awk"; }
# rel_load FILE: the release's fields as rel_* variables; fails when the file
# is missing or does not describe a release this Builder can use safely.
rel_load() {
[ -f "$1" ] || return 1
rel_id=
eval "$(json release < "$1")" || return 1
rel_valid
}
rel_valid() {
identifier "$rel_id" && identifier "$rel_release" && identifier "$rel_package" && identifier "$rel_binary" || return 1
relative "$rel_workspace" || return 1
case "$rel_rust" in *[!0-9.]* | *..* | .* | *.) return 1 ;; esac
[ -n "$rel_repos" ] || return 1
rel_makepad_path=
while IFS='|' read -r v_name v_path v_commit v_sha v_bytes; do
identifier "$v_name" && relative "$v_path" && hex "$v_commit" 40 && hex "$v_sha" 64 || return 1
case "$v_bytes" in '' | *[!0-9]*) return 1 ;; esac
[ "$v_name" != makepad ] || rel_makepad_path=$v_path
done <<EOF
$rel_repos
EOF
# Every repository and the workspace lie inside the Makepad checkout,
# which the one Cargo workspace needs.
[ -n "$rel_makepad_path" ] || return 1
while IFS='|' read -r v_name v_path v_commit v_sha v_bytes; do
case "$v_path/" in "$rel_makepad_path"/*) ;; *) return 1 ;; esac
done <<EOF
$rel_repos
EOF
case "$rel_workspace/" in "$rel_makepad_path"/*) ;; *) return 1 ;; esac
[ -n "$rel_title" ] || rel_title=$rel_id
[ -n "$rel_license" ] || rel_license=commercial
}
rel_supported() { case "$rel_platforms " in *" $triple "*) return 0 ;; esac; return 1; }
# The app registry: the snapshot's own copy once the Makepad source is here
# (it names packages and features for that commit); before that, the list
# below, only to show the experiments' names.
registry_fallback='wm|Makepad WM|||||
calculator|Calculator|||||other
calendar|Calendar|||||other
clock|Clock|||||other
director|Director|||||other
fab|Fab|||||other
fabric|Fabric|||||other
files|Files|||||other
finance|Finance|||||other
image|Image|||||other
mail|Mail|||||other
mixer|Mixer|||||other
notes|Notes|||||other
photos|Photos|||||other
reminders|Reminders|||||other
route|Route|||||other
score|Score|||||other
sheets|Sheets|||||other
task|Task|||||other
terminal|Terminal|||||other
weather|Weather|||||other'
registry=
load_registry() {
registry=
rel_load "$root/available/makepad.json" 2>/dev/null || return 0
rel_dir
reg_file="$rel_directory/$rel_makepad_path/tools/makepad_builder/apps.json"
[ -f "$reg_file" ] || return 0
registry=$(json apps < "$reg_file")
}
# The experiments list: Makepad WM first, then the registry's "other" apps.
free_apps() {
reg=${registry:-$registry_fallback}
printf '%s\n' "$reg" | awk -F'|' '$1 == "wm" { print $1 "|" $2 }'
printf '%s\n' "$reg" | awk -F'|' '$7 == "other" { print $1 "|" $2 }'
}
is_free() { printf '%s\n' "${registry:-$registry_fallback}" | grep -q "^$1|"; }
# --------------------------------------------------------------- network ---
# curl without redirects; HTTPS only (plain HTTP only to this computer, for
# a local test service). The email travels in a header file, never in a
# process argument.
curl_safe() {
case "$1" in
http://127.0.0.1:* | http://localhost:*) shift; curl --proto '=http' --max-redirs 0 "$@" ;;
*) shift; curl --proto '=https' --tlsv1.2 --max-redirs 0 "$@" ;;
esac
}
# The header file for the email; created once per run, readable only by you.
email_headers() {
email_header_file="$scratch/email-headers"
printf 'X-Makepad-Email: %s\r\nCache-Control: no-store\r\n' "$email" > "$email_header_file"
}
# http_get URL FILE [HEADERFILE]: body to FILE; prints the HTTP status.
http_get() {
if [ -n "${3:-}" ]; then
curl_safe "$1" -sS -o "$2" -w '%{http_code}' -H @"$3" "$1" 2>>"${log_file:-/dev/null}" || printf 000
else
curl_safe "$1" -sS -o "$2" -w '%{http_code}' "$1" 2>>"${log_file:-/dev/null}" || printf 000
fi
}
# The service's {"error": "..."} as one line.
http_error() { json flat < "$1" 2>/dev/null | awk -F'\t' '$1 == "error" { print $2; exit }'; }
# The newest public Makepad release (free apps and the pinned Rust) from the
# public catalog; kept in available/makepad.json.
fetch_public() {
code=$(http_get "$service/public/catalog" "$scratch/public.json")
[ "$code" = 200 ] || { public_error="public catalog: HTTP $code $(http_error "$scratch/public.json")"; return 1; }
json items < "$scratch/public.json" > "$scratch/public.items"
while IFS= read -r item; do
printf '%s\n' "$item" > "$scratch/public.one"
if rel_load "$scratch/public.one" && [ "$rel_public" = true ]; then
mkdir -p "$root/available"
mv -f "$scratch/public.one" "$root/available/makepad.json"
return 0
fi
done < "$scratch/public.items"
public_error='The public Makepad source is unavailable.'
return 1
}
# The licensed apps of $email: available/<id>.json for each, $licensed_ids
# in catalog order. lic_state: none (no email), ok, rejected, error.
check_licenses() {
licensed_ids= lic_error=
if [ -z "$email" ]; then lic_state=none; return 0; fi
email_headers
code=$(http_get "$service/catalog" "$scratch/catalog.json" "$email_header_file")
case "$code" in
200) ;;
401 | 403) lic_state=rejected lic_error=$(http_error "$scratch/catalog.json"); return 1 ;;
*) lic_state=error lic_error="HTTP $code $(http_error "$scratch/catalog.json")"; [ "$code" != 000 ] || lic_error='the license service could not be reached'; return 1 ;;
esac
json items < "$scratch/catalog.json" > "$scratch/catalog.items"
[ -z "$root" ] || mkdir -p "$root/available"
while IFS= read -r item; do
printf '%s\n' "$item" > "$scratch/catalog.one"
rel_load "$scratch/catalog.one" || continue
[ "$rel_public" != true ] || continue
licensed_ids="$licensed_ids $rel_id"
[ -z "$root" ] || cp "$scratch/catalog.one" "$root/available/$rel_id.json"
done < "$scratch/catalog.items"
lic_state=ok
put "$scratch/licensed" "$licensed_ids"
return 0
}
license_count() { set -- $licensed_ids; license_count=$#; }
# ------------------------------------------------------ releases on disk ---
# The release file behind an app's row: cached (available/), installed, or
# the public release projected onto the app's registry entry.
app_release_file() {
for arf in "$root/available/$1.json" "$root/installed/$1.json"; do
[ -f "$arf" ] && { release_file=$arf; return 0; }
done
is_free "$1" || return 1
project_public "$1" "$root/tmp/projected-$1.json" || return 1
release_file="$root/tmp/projected-$1.json"
}
# project_public APP FILE: the public Makepad release as app APP (its
# package, binary, workspace and features from the registry).
project_public() {
rel_load "$root/available/makepad.json" || return 1
line=$(printf '%s\n' "${registry:-}" | awk -F'|' -v id="$1" '$1 == id { print; exit }')
[ -n "$line" ] || return 1
IFS='|' read -r d_id d_title d_package d_binary d_workspace d_features d_menu <<EOF
$line
EOF
[ -n "$d_package" ] || return 1
mp=$(printf '%s\n' "$rel_repos" | awk -F'|' '$1 == "makepad"')
IFS='|' read -r m_name m_path m_commit m_sha m_bytes <<EOF
$mp
EOF
features=
if [ -n "$d_features" ]; then features="\"$(printf '%s' "$d_features" | sed 's/,/","/g')\""; fi
platforms=
for p in $rel_platforms; do platforms="$platforms${platforms:+,}\"$p\""; done
title=$(printf '%s' "$d_title" | tr -d '"\\')
mkdir -p "$(dirname "$2")"
put "$2" "{\"id\":\"$d_id\",\"title\":\"$title\",\"release\":\"$rel_release\",\"rust\":\"$rel_rust\",\"package\":\"$d_package\",\"binary\":\"$d_binary\",\"workspace\":\"$d_workspace\",\"platforms\":[$platforms],\"cuda\":false,\"public\":true,\"features\":[$features],\"repositories\":[{\"name\":\"makepad\",\"path\":\"$m_path\",\"commit\":\"$m_commit\",\"bytes\":$m_bytes,\"sha256\":\"$m_sha\"}]}"
}
# repo_installed DIR NAME PATH COMMIT: the receipt names the commit and the
# checkout is there. The commit identifies the tree, whichever pack it came in.
repo_installed() {
[ -d "$1/$3/.git" ] || return 1
ri_commit=
read -r ri_commit ri_rest < "$1/.builder-repositories/$2" 2>/dev/null || :
[ "$ri_commit" = "$4" ]
}
# rel_dir: the snapshot folder this release builds from, as the Windows
# Builder picks it: sources/<release> when it exists, else an existing
# snapshot holding the same commits (so their build output is shared),
# preferring one an app was built from; else a new sources/<release>.
rel_dir() {
rel_directory="$root/sources/$rel_release"
[ ! -e "$rel_directory" ] || { rel_label=$rel_release; return 0; }
rd_best=
for rd_cand in "$root"/sources/*; do
[ -d "$rd_cand" ] || continue
rd_held=0 rd_usable=1
while IFS='|' read -r v_name v_path v_commit v_sha v_bytes; do
if repo_installed "$rd_cand" "$v_name" "$v_path" "$v_commit"; then rd_held=$((rd_held + 1))
elif [ -e "$rd_cand/$v_path" ]; then rd_usable=0; break; fi
done <<EOF
$rel_repos
EOF
[ "$rd_usable" = 1 ] && [ "$rd_held" -gt 0 ] || continue
rd_built=0; [ ! -f "$rd_cand/.builder-built" ] || rd_built=1
rd_best="$rd_best$rd_built $((1000 + rd_held)) ${rd_cand##*/}|$rd_cand
"
done
if [ -n "$rd_best" ]; then
rel_directory=$(printf '%s' "$rd_best" | sort | tail -n 1 | cut -d'|' -f2-)
fi
rel_label=${rel_directory##*/}
}
rel_installed() {
while IFS='|' read -r v_name v_path v_commit v_sha v_bytes; do
repo_installed "$rel_directory" "$v_name" "$v_path" "$v_commit" || return 1
done <<EOF
$rel_repos
EOF
[ -f "$rel_directory/$rel_workspace/Cargo.toml" ]
}
rel_target() { rel_target_dir="$root/target/sources/$rel_label"; }
# app_state ID: new, partial, compile, update, ready or merge, from the
# folder alone (the state machine of the Windows Builder).
app_state() {
as_id=$1
if [ -f "$root/changes/$as_id.merge" ]; then state=merge; return; fi
app_release_file "$as_id" && rel_load "$release_file" || { state=new; return; }
rel_dir
as_bin="$root/$rel_binary.bin"
as_installed=
if [ -f "$root/installed/$as_id.json" ]; then
as_installed=$(json flat < "$root/installed/$as_id.json" | awk -F'\t' '$1 == "release" { print $2; exit }')
fi
if [ "$as_installed" = "$rel_release" ] && [ -f "$as_bin" ]; then
if sources_changed; then state=compile; else state=ready; fi
elif rel_installed; then state=compile
elif [ -n "$as_installed" ]; then state=update
elif [ -n "$(ls -A "$rel_directory/.builder-repositories" 2>/dev/null)" ] || ls "$root"/cache/*.pack.part* >/dev/null 2>&1; then state=partial
else state=new; fi
}
# Whether a source file Cargo used for the built app (its .d file) is newer
# than the app: an agent or the person edited it, so it compiles again.
sources_changed() {
rel_target
sc_deps="$rel_target_dir/release/$rel_binary.d"
[ -f "$sc_deps" ] || return 1
# Its first line: "target: source source ...", spaces in paths as "\ ".
sc_list=$(awk 'NR == 1 { sub(/^[^:]*: /, ""); gsub(/\\ /, "\001"); n = split($0, a, " "); for (i = 1; i <= n; i++) { gsub(/\001/, " ", a[i]); print a[i] } exit }' "$sc_deps")
while IFS= read -r sc_file; do
[ -n "$sc_file" ] || continue
[ -e "$sc_file" ] || return 0
[ ! "$sc_file" -nt "$as_bin" ] || return 0
done <<EOF
$sc_list
EOF
return 1
}
# ------------------------------------------------------------ compiler ---
# The recorded choice (selected-rust): "private", or the sysroot of your own
# Rust. rust-check below validates it on every start and before each build.
rust_version() {
rv=
if rel_load "$root/available/makepad.json" 2>/dev/null; then rv=$rel_rust; fi
rust_version=$rv
}
private_rust_dir() { private_rust="$root/toolchain/rust/$1-$triple"; }
private_rust_ready() {
private_rust_dir "$1"
[ "$(cat "$private_rust/.toolchain-version" 2>/dev/null || :)" = "$1 $triple" ] &&
[ -x "$private_rust/bin/rustc" ] && [ -x "$private_rust/bin/cargo" ]
}
recorded_rust() { selected_rust=$(cat "$root/selected-rust" 2>/dev/null || :); }
record_rust() { put "$root/selected-rust" "$1"; }
# rust_ready VERSION: sets rust_sysroot when the chosen Rust can build.
rust_ready() {
recorded_rust
rust_sysroot=
case "$selected_rust" in
/*) rust_check --validate "$1" "$selected_rust" && return 0; return 1 ;;
*) private_rust_ready "$1" && rust_sysroot=$private_rust ;;
esac
}
# rust_check --probe MIN | --validate MIN SYSROOT: read-only. Prints nothing;
# sets rust_sysroot, or rust_reason. Only the toolchain's own binaries run,
# so no rustup proxy can download or switch anything.
rust_check() {
rust_reason= rust_sysroot=
if [ "$1" = --probe ]; then
rc_toolchain=$(cd / && unset RUSTUP_TOOLCHAIN && RUSTUP_AUTO_INSTALL=0 rustc --print sysroot 2>/dev/null) || { rust_reason='No installed Rust was found on PATH.'; return 1; }
[ -n "$rc_toolchain" ] || { rust_reason='No installed Rust was found on PATH.'; return 1; }
else
rc_toolchain=$3
fi
rc_min=$2
case "$rc_toolchain" in /*) ;; *) rust_reason='The recorded Rust location is not an absolute path.'; return 1 ;; esac
[ -d "$rc_toolchain" ] || { rust_reason="Rust is no longer installed at $rc_toolchain."; return 1; }
rc_toolchain=$(cd -P -- "$rc_toolchain" && pwd)
[ -x "$rc_toolchain/bin/rustc" ] || { rust_reason="No rustc in $rc_toolchain/bin."; return 1; }
rc_meta=$("$rc_toolchain/bin/rustc" --version --verbose 2>/dev/null) || { rust_reason="rustc in $rc_toolchain/bin does not run."; return 1; }
rc_version=$(printf '%s\n' "$rc_meta" | awk '$1 == "release:" { print $2 }')
rc_host=$(printf '%s\n' "$rc_meta" | awk '$1 == "host:" { print $2 }')
[ "$rc_host" = "$triple" ] || { rust_reason="Installed Rust is built for ${rc_host:-an unknown host}; this Builder needs $triple."; return 1; }
rc_status=0
version_at_least "$rc_version" "$rc_min" || rc_status=$?
case "$rc_status" in
0) ;;
1) rust_reason="Installed Rust $rc_version is not a stable release; the Builder needs $rc_min or newer."; return 1 ;;
*) rust_reason="Installed Rust $rc_version is older than $rc_min."; return 1 ;;
esac
[ -x "$rc_toolchain/bin/cargo" ] || { rust_reason='Installed Rust has no cargo beside rustc.'; return 1; }
rc_cargo=$("$rc_toolchain/bin/cargo" --version 2>/dev/null | awk '$1 == "cargo" { print $2 }') || :
version_at_least "${rc_cargo:-none}" "$rc_min" || { rust_reason="Installed cargo ${rc_cargo:-is unusable and} is older than $rc_min."; return 1; }
[ -d "$rc_toolchain/lib/rustlib/$triple/lib" ] || { rust_reason="Installed Rust has no standard library for $triple."; return 1; }
if [ "$plat" = mac ]; then
# Release builds strip through rustc's bundled rust-objcopy; report a
# copy that cannot load its LLVM library, never repair it.
"$rc_toolchain/lib/rustlib/$triple/bin/rust-objcopy" --version >/dev/null 2>&1 || { rust_reason="Installed Rust's rust-objcopy does not run; release builds could not be stripped."; return 1; }
fi
rust_sysroot=$rc_toolchain
}
# version_at_least ACTUAL MINIMUM: 0 when newer or equal, 1 when not a
# stable x.y.z, 2 when older. Components compare as numbers.
version_at_least() {
awk -v actual="$1" -v minimum="$2" 'BEGIN {
if (actual !~ /^[0-9]+[.][0-9]+[.][0-9]+$/) exit 1
split(actual, a, "."); split(minimum, b, ".")
for (i = 1; i <= 3; i++) { if (a[i] + 0 > b[i] + 0) exit 0; if (a[i] + 0 < b[i] + 0) exit 2 }
exit 0
}'
}
# macOS: rust-objcopy finds LLVM beside rustlib's host tools. A relative
# link keeps the folder movable.
prepare_host_tools() {
[ "$plat" = mac ] || return 0
pht_lib="$1/lib/rustlib/$triple/lib"
if [ -f "$1/lib/libLLVM.dylib" ] && [ ! -e "$pht_lib/libLLVM.dylib" ]; then
mkdir -p "$pht_lib"
ln -s ../../../libLLVM.dylib "$pht_lib/libLLVM.dylib"
fi
}
# System tools: Apple's command line developer tools on macOS, the
# distribution's development packages on Linux. Read-only; tools_problem is
# empty when ready, else missing, license or unsupported.
tools_check() {
tools_problem=
if [ "$plat" = mac ]; then
tc_dev=${DEVELOPER_DIR:-$(/usr/bin/xcode-select -p 2>/dev/null || :)}
if [ -z "$tc_dev" ] || [ ! -d "$tc_dev" ]; then tools_problem=missing; return 1; fi
for tc_probe in 'clang --version' '--show-sdk-path' '--find ld'; do
# shellcheck disable=SC2086 # the probe words are fixed above
if ! tc_detail=$(/usr/bin/xcrun --sdk macosx $tc_probe 2>&1); then
case "$tc_detail" in *license*) tools_problem=license ;; *) tools_problem=missing ;; esac
return 1
fi
done
git --version >/dev/null 2>&1 || { tools_problem=missing; return 1; }
else
getconf GNU_LIBC_VERSION >/dev/null 2>&1 || { tools_problem=unsupported; return 1; }
for tc_tool in cc c++ make cmake git curl tar gzip pkg-config; do
command -v "$tc_tool" >/dev/null 2>&1 || { tools_problem=missing; return 1; }
done
pkg-config --exists openssl x11 xcursor xkbcommon xrandr xi xinerama alsa libpulse \
wayland-client egl gl glx libdrm gbm || { tools_problem=missing; return 1; }
fi
command -v curl >/dev/null 2>&1 || { tools_problem=missing; return 1; }
return 0
}
# Linux: the one command that installs the development packages, shown
# before it runs. Sets packages_cmd (without sudo) and distro_name.
linux_packages() {
ID= ID_LIKE= PRETTY_NAME=
# shellcheck disable=SC1091
[ ! -r /etc/os-release ] || . /etc/os-release
distro_name=${PRETTY_NAME:-your distribution}
case " $ID $ID_LIKE " in
*debian* | *ubuntu*) packages_cmd='apt-get install -y --no-install-recommends build-essential clang cmake pkg-config git curl ca-certificates tar gzip libssl-dev libx11-dev libxcursor-dev libxkbcommon-dev libxrandr-dev libxi-dev libxinerama-dev libasound2-dev libpulse-dev libwayland-dev wayland-protocols libegl-dev libgl-dev libglx-dev libdrm-dev libgbm-dev' ;;
*fedora* | *rhel* | *centos*) packages_cmd='dnf install -y gcc gcc-c++ make clang cmake pkgconf-pkg-config git curl ca-certificates tar gzip openssl-devel libX11-devel libXcursor-devel libxkbcommon-devel libXrandr-devel libXi-devel libXinerama-devel alsa-lib-devel pulseaudio-libs-devel wayland-devel wayland-protocols-devel libglvnd-devel libdrm-devel mesa-libgbm-devel' ;;
*arch*) packages_cmd='pacman -S --needed --noconfirm base-devel clang cmake pkgconf git curl ca-certificates tar gzip openssl libx11 libxcursor libxkbcommon libxrandr libxi libxinerama alsa-lib libpulse wayland wayland-protocols libglvnd mesa libdrm' ;;
*suse*) packages_cmd='zypper --non-interactive install gcc gcc-c++ make clang cmake pkg-config git curl ca-certificates tar gzip libopenssl-devel libX11-devel libXcursor-devel libxkbcommon-devel libXrandr-devel libXi-devel libXinerama-devel alsa-devel libpulse-devel wayland-devel wayland-protocols-devel libglvnd-devel libdrm-devel Mesa-libgbm-devel' ;;
*) packages_cmd= ; return 1 ;;
esac
}
# ------------------------------------------------------- build environment ---
# rustc's parallel frontend (-Zthreads, allowed on the stable compiler by
# RUSTC_BOOTSTRAP=1) compiles about a third faster; the Windows Builder and
# this script use it the same way. The thread count lives in rustc-threads,
# written once per folder: the processor count, at most 8. 1 means one
# thread: the parallel frontend failed here once, and the file says why.
# Every cargo run (the Builder's builds, `makepad build`, agent shells,
# `makepad env`) gets the same flags, so the shared target stays fresh.
rustc_threads() {
rustc_threads=
[ ! -f "$root/rustc-threads" ] || read -r rustc_threads rt_rest < "$root/rustc-threads" || :
case "$rustc_threads" in '' | *[!0-9]* | 0) rustc_threads= ;; esac
if [ -z "$rustc_threads" ]; then
rustc_threads=$(nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 1)
case "$rustc_threads" in '' | *[!0-9]* | 0) rustc_threads=1 ;; esac
[ "$rustc_threads" -le 8 ] || rustc_threads=8
put "$root/rustc-threads" "$rustc_threads"
fi
}
cargo_env() { # cargo_env LABEL APP [THREADS]: exports into the current (sub)shell
# Inherited overrides would move Cargo's output or change its flags from
# one session to the next, rebuilding everything.
unset MAKEPAD_LOADER_EMAIL RUSTUP_TOOLCHAIN CARGO_ENCODED_RUSTFLAGS RUSTC_WRAPPER \
RUSTC_WORKSPACE_WRAPPER CARGO_INCREMENTAL CARGO_BUILD_TARGET CARGO_BUILD_TARGET_DIR \
CARGO_BUILD_RUSTFLAGS CARGO_BUILD_RUSTDOCFLAGS CARGO_BUILD_DEP_INFO_BASEDIR \
CARGO_TARGET_APPLIES_TO_HOST CARGO_UNSTABLE_BUILD_STD RUSTDOC 2>/dev/null || :
for ce_var in $(env | sed -n 's/^\(CARGO_PROFILE_[A-Za-z0-9_]*\)=.*/\1/p'); do unset "$ce_var"; done
mkdir -p "$root/cargo-home" "$root/tmp" "$root/target/sources/$1"
export CARGO_HOME="$root/cargo-home"
export CARGO_TARGET_DIR="$root/target/sources/$1"
export RUSTC="$rust_sysroot/bin/rustc" CARGO="$rust_sysroot/bin/cargo"
export TMPDIR="$root/tmp" TMP="$root/tmp" TEMP="$root/tmp"
export RUSTUP_HOME="$root/rustup-home" RUSTUP_AUTO_INSTALL=0 RUSTUP_TOOLCHAIN=
export MAKEPAD_LOADER_EMAIL= MAKEPAD_PACKAGE_DIR=. MAKEPAD_BUILDER_APP="$2"
export CUDA_PATH= CUDA_HOME= CUDACXX= MAKEPAD_GGML_NO_CUDA=1 MAKEPAD_GGML_REQUIRE_CUDA=0
# Pinned: the shell's RUSTFLAGS would change every fingerprint in the
# shared target.
if [ -n "${3:-}" ]; then ce_threads=$3; else rustc_threads; ce_threads=$rustc_threads; fi
if [ "$ce_threads" -gt 1 ]; then export RUSTC_BOOTSTRAP=1 RUSTFLAGS="-Zthreads=$ce_threads"
else export RUSTC_BOOTSTRAP= RUSTFLAGS=; fi
if [ -x "$rust_sysroot/bin/rustdoc" ]; then export RUSTDOC="$rust_sysroot/bin/rustdoc"; fi
export PATH="$rust_sysroot/bin:$root/cargo-home/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
}
# prepare_target: the snapshot's target directory. A folder from before the
# per-snapshot layout has Cargo's output directly in target/; the snapshot
# built last adopts it, so its apps do not compile again.
prepare_target() {
rel_target
if [ ! -e "$rel_target_dir" ] && { [ -d "$root/target/release" ] || [ -f "$root/target/.rustc_info.json" ]; }; then
pt_newest=$(ls -t "$root"/sources/*/.builder-built 2>/dev/null | head -n 1)
if [ -z "$pt_newest" ] || [ "${pt_newest%/.builder-built}" = "$rel_directory" ]; then
mkdir -p "$rel_target_dir"
for pt_entry in "$root"/target/* "$root"/target/.[!.]*; do
[ -e "$pt_entry" ] || continue
[ "${pt_entry##*/}" != sources ] || continue
mv "$pt_entry" "$rel_target_dir/"
done
fi
fi
mkdir -p "$rel_target_dir"
}
# ------------------------------------------------------------ downloads ---
# Byte ranges over parallel connections for large files, when the host
# offers them; every curl of this run shares one cap of $max_connections.
max_connections=8
slot_take() {
while :; do
st_i=0
while [ "$st_i" -lt "$max_connections" ]; do
if mkdir "$slots/$st_i" 2>/dev/null; then slot=$st_i; return 0; fi
st_i=$((st_i + 1))
done
sleep 0.1
done
}
slot_give() { rmdir "$slots/$1" 2>/dev/null || :; }
# fetch URL FILE SIZE RANGES [CURL-ARGS...]: downloads FILE in the background.
# RANGES=yes splits a known SIZE into parts FILE.part0..N (up to 8, at least
# 8 MB each), each resumed from what an earlier run left; otherwise one
# connection into FILE.part0, resumed when the host allows. FILE.status
# reads "running" until done, then 0 or 1. Sets fetch_parts.
fetch() {
f_url=$1 f_file=$2 f_size=$3 f_ranges=$4; shift 4
fetch_parts=1
if [ "$f_ranges" = yes ] && [ "$f_size" -gt 16777216 ]; then
fetch_parts=$((f_size / 8388608))
[ "$fetch_parts" -le 8 ] || fetch_parts=8
fi
printf 'running\n' > "$f_file.status"
(
f_pids=
f_part_size=$(( (f_size + fetch_parts - 1) / fetch_parts ))
f_i=0
while [ "$f_i" -lt "$fetch_parts" ]; do
f_part="$f_file.part$f_i"
if [ "$fetch_parts" = 1 ]; then
(
slot_take
trap 'slot_give $slot' EXIT
if [ -s "$f_part" ] && [ "$f_size" -gt 0 ] && [ "$(size_of "$f_part")" -ge "$f_size" ]; then exit 0; fi
if [ -s "$f_part" ] && curl_safe "$f_url" -sS -f -C - -o "$f_part" "$@" "$f_url" 2>>"$log_file"; then exit 0; fi
# No resume from this host: from the start. Without -f
# the service's error text lands in the part, for the
# step's reason; the HTTP status says which it is.
: > "$f_part"
f_code=$(curl_safe "$f_url" -sS -o "$f_part" -w '%{http_code}' "$@" "$f_url" 2>>"$log_file") || f_code=000
printf '%s\n' "$f_code" > "$f_file.code"
case "$f_code" in 200 | 206) exit 0 ;; *) exit 1 ;; esac
) &
f_pids="$f_pids $!"
else
f_from=$((f_i * f_part_size))
f_to=$((f_from + f_part_size - 1))
[ "$f_to" -lt "$f_size" ] || f_to=$((f_size - 1))
f_have=$(size_of "$f_part")
if [ "$f_have" -lt $((f_to - f_from + 1)) ]; then
(
slot_take
trap 'slot_give $slot' EXIT
# A part cut off earlier continues after its last byte.
curl_safe "$f_url" -sS -f --range "$((f_from + f_have))-$f_to" "$@" "$f_url" >> "$f_part" 2>>"$log_file"
) &
f_pids="$f_pids $!"
fi
fi
f_i=$((f_i + 1))
done
f_status=0
for f_pid in $f_pids; do wait "$f_pid" || f_status=1; done
printf '%s\n' "$f_status" > "$f_file.status"
) &
}
# fetch_bytes FILE PARTS: bytes of FILE's parts so far.
fetch_bytes() {
fb_sum=0 fb_i=0
while [ "$fb_i" -lt "$2" ]; do
[ ! -f "$1.part$fb_i" ] || fb_sum=$((fb_sum + $(size_of "$1.part$fb_i")))
fb_i=$((fb_i + 1))
done
fetch_bytes=$fb_sum
}
# fetch_join FILE PARTS SIZE: the parts become FILE.
fetch_join() {
if [ "$2" = 1 ]; then mv -f "$1.part0" "$1"; else
fj_i=0
: > "$1.joined"
while [ "$fj_i" -lt "$2" ]; do cat "$1.part$fj_i" >> "$1.joined"; fj_i=$((fj_i + 1)); done
[ "$3" = 0 ] || [ "$(size_of "$1.joined")" = "$3" ] || return 1
mv -f "$1.joined" "$1"
fj_i=0
while [ "$fj_i" -lt "$2" ]; do rm -f "$1.part$fj_i"; fj_i=$((fj_i + 1)); done
fi
rm -f "$1.status" "$1.code"
}
# head_size URL: Content-Length when the host offers byte ranges, else 0.
head_size() {
hs=$(curl_safe "$1" -sS -I "$1" 2>/dev/null | tr -d '\r') || hs=
if printf '%s\n' "$hs" | grep -qi '^accept-ranges: *bytes'; then
printf '%s\n' "$hs" | awk 'tolower($1) == "content-length:" { n = $2 } END { print n + 0 }'
else
printf 0
fi
}
# ----------------------------------------------------------------- steps ---
# A work page lists steps, one per line: ○ next, ● now with its bar, ✓ done
# with a summary, ✗ failed with the reason, ◌ held. Each step's state is one
# line in a file, written by the step itself (background jobs included) and
# drawn about ten times a second:
# now|DONE|TOTAL|UNIT|BYTES|DOING done|SUMMARY failed|REASON held|REASON
step_key() { step_key=$(printf '%s' "$1" | tr -c 'A-Za-z0-9' '_'); }
step() { # step LABEL LINE
step_key "$1"
put "$steps_dir/$step_key" "$2"
}
step_fail() { step "$1" "failed|$2"; log "$1 failed: $2"; }
# ------------------------------------------------------------ Rust job ---
# rustc, rust-std and cargo download side by side, the large ones in byte
# ranges, each checked against its SHA-256 and unpacked in the background as
# soon as it is in. One bar counts every archive once downloaded and once
# unpacked. A stopped run keeps its parts and staging folder.
job_rust() { # job_rust LABEL VERSION
jr_label=$1 jr_version=$2
jr_started=$(now)
private_rust_dir "$jr_version"
mkdir -p "$root/cache" "$root/toolchain/rust" "$root/tmp"
step "$jr_label" "now|0|0|MB|0|reading the manifest"
jr_manifest="$root/cache/channel-rust-$jr_version.toml"
if [ ! -s "$jr_manifest" ]; then
curl_safe "https://static.rust-lang.org" -sS -f -o "$jr_manifest.part" "https://static.rust-lang.org/dist/channel-rust-$jr_version.toml" 2>>"$log_file" ||
{ step_fail "$jr_label" 'Could not download the Rust manifest'; return 1; }
mv -f "$jr_manifest.part" "$jr_manifest"
fi
jr_staging="$root/toolchain/rust/.staging-$jr_version-$triple"
mkdir -p "$jr_staging"
jr_n=0
for jr_component in rustc rust-std cargo; do
jr_n=$((jr_n + 1))
jr_section="[pkg.$jr_component.target.$triple]"
jr_url=$(awk -v section="$jr_section" -v key=url '$0 == section { inside = 1; next } /^\[/ { inside = 0 } inside && $1 == key && $2 == "=" { gsub(/"/, "", $3); print $3; exit }' "$jr_manifest")
jr_hash=$(awk -v section="$jr_section" -v key=hash '$0 == section { inside = 1; next } /^\[/ { inside = 0 } inside && $1 == key && $2 == "=" { gsub(/"/, "", $3); print $3; exit }' "$jr_manifest")
case "$jr_url" in https://static.rust-lang.org/dist/*.tar.gz) ;; *) step_fail "$jr_label" "Unexpected Rust download address for $jr_component"; return 1 ;; esac
hex "$jr_hash" 64 || { step_fail "$jr_label" "Invalid Rust checksum for $jr_component"; return 1; }
jr_archive="$root/cache/$jr_hash.tar.gz"
eval "jr_url_$jr_n=\$jr_url jr_hash_$jr_n=\$jr_hash jr_archive_$jr_n=\$jr_archive jr_name_$jr_n=\$jr_component"
if [ -f "$jr_archive" ] && [ "$(sha256 "$jr_archive")" = "$jr_hash" ]; then
eval "jr_size_$jr_n=$(size_of "$jr_archive") jr_state_$jr_n=ready"
else
jr_size=$(head_size "$jr_url")
fetch "$jr_url" "$jr_archive" "$jr_size" yes
eval "jr_size_$jr_n=\$jr_size jr_parts_$jr_n=\$fetch_parts jr_state_$jr_n=down"
fi
done
while :; do
jr_busy=0 jr_total=0 jr_done=0 jr_down=0 jr_unpack=0
jr_n=0
while [ "$jr_n" -lt 3 ]; do
jr_n=$((jr_n + 1))
eval "jr_state=\$jr_state_$jr_n jr_size=\$jr_size_$jr_n jr_archive=\$jr_archive_$jr_n jr_hash=\$jr_hash_$jr_n jr_url=\$jr_url_$jr_n jr_component=\$jr_name_$jr_n"
case "$jr_state" in
down)
eval "jr_parts=\$jr_parts_$jr_n"
case "$(cat "$jr_archive.status" 2>/dev/null || echo running)" in
running) jr_down=$((jr_down + 1)); fetch_bytes "$jr_archive" "$jr_parts"; jr_done=$((jr_done + fetch_bytes)) ;;
0)
fetch_join "$jr_archive" "$jr_parts" "$jr_size" || { step_fail "$jr_label" "Download failed: $jr_component"; return 1; }
[ "$(sha256 "$jr_archive")" = "$jr_hash" ] || { rm -f "$jr_archive"; step_fail "$jr_label" "The $jr_component download does not match its checksum"; return 1; }
jr_size=$(size_of "$jr_archive")
eval "jr_size_$jr_n=$jr_size jr_state_$jr_n=ready"
jr_done=$((jr_done + jr_size)) ;;
*) step_fail "$jr_label" "Download failed: $jr_component (see builder.log)"; return 1 ;;
esac ;;
ready)
# An official archive holds <name>/<component>/{bin,lib,...};
# only that inner folder is unpacked. cargo's manifest.in is kept.
jr_top=$(basename "$jr_url" .tar.gz)
jr_inner=$jr_component
[ "$jr_component" != rust-std ] || jr_inner=rust-std-$triple
jr_keep=
[ "$jr_component" = cargo ] || jr_keep="--exclude=$jr_top/$jr_inner/manifest.in"
printf 'running\n' > "$root/tmp/rust-$jr_n.unpack"
(
jr_status=0
# shellcheck disable=SC2086 # jr_keep is one word or none
tar -xzf "$jr_archive" -C "$jr_staging" --strip-components=2 $jr_keep "$jr_top/$jr_inner" 2>>"$log_file" || jr_status=1
printf '%s\n' "$jr_status" > "$root/tmp/rust-$jr_n.unpack"
) &
eval "jr_state_$jr_n=unpack"
jr_unpack=$((jr_unpack + 1))
jr_done=$((jr_done + jr_size)) ;;
unpack)
jr_done=$((jr_done + jr_size))
case "$(cat "$root/tmp/rust-$jr_n.unpack")" in
running) jr_unpack=$((jr_unpack + 1)) ;;
0) eval "jr_state_$jr_n=done"; jr_done=$((jr_done + jr_size)) ;;
*) step_fail "$jr_label" "Unpacking $jr_component failed"; return 1 ;;
esac ;;
done) jr_done=$((jr_done + 2 * jr_size)) ;;
esac
jr_total=$((jr_total + 2 * jr_size))
eval "jr_state=\$jr_state_$jr_n"
[ "$jr_state" = done ] || jr_busy=1
done
if [ "$jr_down" -gt 0 ]; then jr_doing=downloading
elif [ "$jr_unpack" -gt 0 ]; then jr_doing=unpacking
else jr_doing=checking; fi
step "$jr_label" "now|$jr_done|$jr_total|MB|$((jr_total / 2))|$jr_doing"
[ "$jr_busy" = 1 ] || break
sleep 0.2
done
wait
prepare_host_tools "$jr_staging"
case "$("$jr_staging/bin/rustc" --version 2>/dev/null)" in
"rustc $jr_version "*) ;;
*) step_fail "$jr_label" 'The private Rust did not pass its version check'; return 1 ;;
esac
"$jr_staging/bin/cargo" --version >/dev/null 2>&1 || { step_fail "$jr_label" 'The private cargo does not run'; return 1; }
printf '%s' "$jr_version $triple" > "$jr_staging/.toolchain-version"
if [ -e "$private_rust" ]; then remove_inside "$private_rust"; fi
mv "$jr_staging" "$private_rust"
log "Rust $jr_version installed: $((jr_total / 2 / 1048576)) MB in $(($(now) - jr_started)) s"
step "$jr_label" "done|$((jr_total / 2 / 1048576)) MB · $(($(now) - jr_started)) s"
}
# ----------------------------------------------------------- source job ---
# One repository of a release: its pack is downloaded (public ones in byte
# ranges when the host offers them), checked against the catalog's size and
# SHA-256, and checked out in a staging folder that is renamed into place;
# a repository nested inside another starts writing once its parent is
# there. Unpacking one pack overlaps the downloads of the others.
job_pack() { # job_pack LABEL DEST NAME PATH COMMIT SHA BYTES APP RELEASE PUBLIC HAVE PARENT
jp_label=$1 jp_dest=$2 jp_name=$3 jp_path=$4 jp_commit=$5 jp_sha=$6 jp_bytes=$7 jp_app=$8 jp_release=$9
shift 9
jp_public=$1 jp_have=$2 jp_parent=$3
jp_started=$(now)
jp_pack="$root/cache/$jp_sha.pack"
mkdir -p "$root/cache" "$jp_dest/.builder-repositories"
if [ -f "$jp_pack" ] && [ "$(size_of "$jp_pack")" = "$jp_bytes" ] && [ "$(sha256 "$jp_pack")" = "$jp_sha" ]; then
:
else
if [ "$jp_public" = true ]; then
jp_url="$service/public/source/$jp_release/makepad.pack"
jp_ranges=no
[ "$(head_size "$jp_url")" = "$jp_bytes" ] && jp_ranges=yes
fetch "$jp_url" "$jp_pack" "$jp_bytes" "$jp_ranges"
else
# Licensed sources: one connection (each request counts toward the
# download limit); an update names the pack it replaces.
jp_url="$service/source/$jp_app/$jp_release/$jp_name.pack"
if [ -n "$jp_have" ]; then
fetch "$jp_url" "$jp_pack" "$jp_bytes" no -X POST -H 'Content-Length: 0' -H @"$email_header_file" -H "X-Makepad-Have: $jp_have"
else
fetch "$jp_url" "$jp_pack" "$jp_bytes" no -X POST -H 'Content-Length: 0' -H @"$email_header_file"
fi
fi
jp_parts=$fetch_parts
while :; do
jp_status=$(cat "$jp_pack.status" 2>/dev/null || echo running)
fetch_bytes "$jp_pack" "$jp_parts"
step "$jp_label" "now|$fetch_bytes|$((2 * jp_bytes))|MB|$jp_bytes|downloading"
[ "$jp_status" = running ] || break
sleep 0.2
done
if [ "$jp_status" != 0 ]; then
if [ "$(cat "$jp_pack.code" 2>/dev/null || echo 200)" != 200 ] && [ -s "$jp_pack.part0" ] && [ "$(size_of "$jp_pack.part0")" -lt 2048 ]; then
jp_reason=$(http_error "$jp_pack.part0"); : > "$jp_pack.part0"
step_fail "$jp_label" "${jp_reason:-Download failed}"
else
step_fail "$jp_label" 'Download failed (see builder.log)'
fi
return 1
fi
fetch_join "$jp_pack" "$jp_parts" "$jp_bytes" || { step_fail "$jp_label" 'Download failed size verification'; return 1; }
step "$jp_label" "now|$jp_bytes|$((2 * jp_bytes))|MB|$jp_bytes|checking"
if [ "$(size_of "$jp_pack")" != "$jp_bytes" ] || [ "$(sha256 "$jp_pack")" != "$jp_sha" ]; then
rm -f "$jp_pack"
step_fail "$jp_label" "$jp_name download failed size/hash verification"
return 1
fi
fi
# A nested repository waits for its parent's checkout.
if [ -n "$jp_parent" ]; then
while [ ! -f "$jp_dest/.builder-repositories/$jp_parent" ]; do
step_key "$jp_parent source"
case "$(cut -d'|' -f1 "$steps_dir/$step_key" 2>/dev/null || :)" in
failed | held) step "$jp_label" "held|stopped because $jp_parent failed"; return 1 ;;
esac
step "$jp_label" "now|$jp_bytes|$((2 * jp_bytes))|MB|$jp_bytes|waiting for $jp_parent"
sleep 0.2
done
fi
jp_checkout="$jp_dest/$jp_path"
if [ -e "$jp_checkout" ]; then
step_fail "$jp_label" "Existing source at $jp_checkout has no matching receipt; it was left unchanged"
return 1
fi
jp_stage="$jp_dest/.$jp_name-staging"
[ ! -e "$jp_stage" ] || remove_inside "$jp_stage"
step "$jp_label" "now|$jp_bytes|$((2 * jp_bytes))|MB|$jp_bytes|unpacking"
git init -q "$jp_stage" 2>>"$log_file"
[ "$jp_name" != makepad ] || git -C "$jp_stage" config remote.origin.url https://github.com/makepad/makepad.git
git -C "$jp_stage" index-pack --stdin < "$jp_pack" >/dev/null 2>>"$log_file" || { step_fail "$jp_label" 'The pack could not be unpacked'; return 1; }
printf '%s\n' "$jp_commit" > "$jp_stage/.git/shallow"
step "$jp_label" "now|$((jp_bytes * 3 / 2))|$((2 * jp_bytes))|MB|$jp_bytes|writing files"
git -C "$jp_stage" -c advice.detachedHead=false checkout -q -f --detach "$jp_commit" 2>>"$log_file" || { step_fail "$jp_label" 'The source could not be checked out'; return 1; }
[ "$(git -C "$jp_stage" rev-parse HEAD)" = "$jp_commit" ] || { step_fail "$jp_label" 'The source is not the expected commit'; return 1; }
mkdir -p "$(dirname "$jp_checkout")"
mv "$jp_stage" "$jp_checkout"
put "$jp_dest/.builder-repositories/$jp_name" "$jp_commit $jp_sha"
log "$jp_name $jp_commit checked out in $jp_dest"
step "$jp_label" "done|$((jp_bytes / 1048576)) MB · $(($(now) - jp_started)) s"
}
# ---------------------------------------------------------------- jobs ---
# jobs_start KEY CMD...: CMD in the background; jobs_wait redraws until all
# are finished and fails when one failed.
running_jobs=
job_start() {
( "$@" ) </dev/null >/dev/null 2>>"$log_file" &
running_jobs="$running_jobs $!"
}
jobs_wait() {
jw_status=0
# Escape stops waiting for Apple's installer (the other jobs go on);
# only then are keys read here.
while :; do
jw_alive=
for jw_pid in $running_jobs; do
if kill -0 "$jw_pid" 2>/dev/null; then jw_alive="$jw_alive $jw_pid"; fi
done
[ -z "${tui:-}" ] || work_redraw
[ -n "$jw_alive" ] || break
if [ -n "${tui:-}" ] && [ -n "${xcode_job:-}" ] && kill -0 "$xcode_job" 2>/dev/null; then
if key_read 1 1 && [ "$kr" = "$e" ]; then
kill_tree "$xcode_job"
step 'Xcode tools' 'held|stopped waiting; select Xcode tools to check again'
fi
else
sleep 0.1
fi
done
for jw_pid in $running_jobs; do wait "$jw_pid" || jw_status=1; done
running_jobs= xcode_job=
# A job that ended without saying so failed unexpectedly.
for jw_key in $work_labels_keys; do
case "$(cut -d'|' -f1 "$steps_dir/$jw_key" 2>/dev/null || :)" in
now) put "$steps_dir/$jw_key" 'failed|stopped unexpectedly (see builder.log)'; jw_status=1 ;;
failed | held) jw_status=1 ;;
esac
done
[ -z "${tui:-}" ] || work_redraw
return "$jw_status"
}
# ---------------------------------------------------------- source sets ---
# checkout_jobs: one job per repository of the loaded release that is not
# here yet, labelled "<Name> source". Sets checkout_labels.
checkout_jobs() {
rel_dir
checkout_labels=
cj_dest=$rel_directory
while IFS='|' read -r v_name v_path v_commit v_sha v_bytes; do
repo_installed "$cj_dest" "$v_name" "$v_path" "$v_commit" && continue
cj_parent=$(printf '%s\n' "$rel_repos" | awk -F'|' -v p="$v_path" '$2 != p && index(p "/", $2 "/") == 1 { n = $1 } END { print n }')
cj_have=
if [ "$rel_public" != true ] && [ -f "$root/installed/$rel_id.json" ]; then
cj_have=$(json flat < "$root/installed/$rel_id.json" | awk -F'\t' -v name="$v_name" '$1 ~ /^repositories\/[0-9]+\/name$/ && $2 == name { split($1, a, "/"); want = a[2] } $1 ~ /\/sha256$/ { split($1, a, "/"); sha[a[2]] = $2 } END { if (want != "") print sha[want] }')
[ "$cj_have" != "$v_sha" ] || cj_have=
fi
cj_title=$(printf '%s' "$v_name" | awk '{ print toupper(substr($0, 1, 1)) substr($0, 2) }')
cj_label="$cj_title source"
checkout_labels="$checkout_labels$cj_label
"
job_start job_pack "$cj_label" "$cj_dest" "$v_name" "$v_path" "$v_commit" "$v_sha" "$v_bytes" "$rel_id" "$rel_release" "$rel_public" "$cj_have" "$cj_parent"
done <<EOF
$rel_repos
EOF
return 0
}
source_labels() { # the labels checkout_jobs will use, without starting anything
rel_dir
source_labels=
while IFS='|' read -r v_name v_path v_commit v_sha v_bytes; do
repo_installed "$rel_directory" "$v_name" "$v_path" "$v_commit" && continue
sl_title=$(printf '%s' "$v_name" | awk '{ print toupper(substr($0, 1, 1)) substr($0, 2) }')
source_labels="$source_labels$sl_title source
"
done <<EOF
$rel_repos
EOF
}
# save_changes APP PREVIOUS-RELEASE-FILE: before an update moves an app off
# the snapshot it was built from, the edits made there are saved as
# changes/<app>-<date>.diff (unified, new files included) and .files (one
# "M|A|D path" line each), and changes/<app>.merge names the diff. Updates
# never merge; the person's coding agent reapplies the diff.
save_changes() {
rel_load "$2" || return 0
rel_dir
sv_dir=$rel_directory
sv_diff="$root/tmp/changes.diff" sv_files="$root/tmp/changes.files"
: > "$sv_diff"; : > "$sv_files"
while IFS='|' read -r v_name v_path v_commit v_sha v_bytes; do
sv_co="$sv_dir/$v_path"
[ -d "$sv_co/.git" ] || continue
# Nested repositories report their own edits.
sv_exclude=$(printf '%s\n' "$rel_repos" | awk -F'|' -v p="$v_path" 'index($2, p "/") == 1 { print ":(exclude)" substr($2, length(p) + 2) }')
# shellcheck disable=SC2086 # one pathspec per line, none with spaces
git -C "$sv_co" diff --name-status HEAD -- . $sv_exclude 2>/dev/null | while IFS=' ' read -r sv_kind sv_path; do
case "$sv_kind" in D*) sv_kind=D ;; A*) sv_kind=A ;; *) sv_kind=M ;; esac
printf '%s|%s/%s\n' "$sv_kind" "$v_path" "$sv_path"
done >> "$sv_files"
# shellcheck disable=SC2086
git -C "$sv_co" diff --binary HEAD --src-prefix="a/$v_path/" --dst-prefix="b/$v_path/" -- . $sv_exclude >> "$sv_diff" 2>/dev/null || :
# shellcheck disable=SC2086
git -C "$sv_co" ls-files --others --exclude-standard -- . $sv_exclude 2>/dev/null | while IFS= read -r sv_path; do
case "$sv_path" in target/* | */target/*) continue ;; esac
printf 'A|%s/%s\n' "$v_path" "$sv_path" >> "$sv_files"
git -C "$sv_co" diff --no-index --binary --src-prefix="a/$v_path/" --dst-prefix="b/$v_path/" /dev/null "$sv_path" >> "$sv_diff" 2>/dev/null || :
done
done <<EOF
$rel_repos
EOF
[ -s "$sv_files" ] || return 0
mkdir -p "$root/changes"
sv_name="$1-$(date +%Y-%m-%d)" sv_n=2
while [ -e "$root/changes/$sv_name.diff" ]; do sv_name="$1-$(date +%Y-%m-%d)-$sv_n"; sv_n=$((sv_n + 1)); done
mv "$sv_diff" "$root/changes/$sv_name.diff"
mv "$sv_files" "$root/changes/$sv_name.files"
put "$root/changes/$1.merge" "$sv_name.diff"
log "Saved your changes to $1 in changes/$sv_name.diff before updating; the edited source stays in $sv_dir"
}
keep_edits() { # keep_edits APP NEW-RELEASE-FILE
[ -f "$root/installed/$1.json" ] || return 0
[ ! -f "$root/changes/$1.merge" ] || return 0
rel_load "$2" || return 0
rel_dir; ke_new_dir=$rel_directory ke_new=$rel_release
rel_load "$root/installed/$1.json" || return 0
rel_dir
[ "$rel_release" != "$ke_new" ] && [ "$rel_directory" != "$ke_new_dir" ] || return 0
# Saved once per update: after the merge the diff is not taken again.
[ "$(cat "$root/changes/$1.saved" 2>/dev/null || :)" != "$rel_release $ke_new" ] || return 0
ke_old=$rel_release
save_changes "$1" "$root/installed/$1.json"
mkdir -p "$root/changes"
put "$root/changes/$1.saved" "$ke_old $ke_new"
}
# ------------------------------------------------------------- compiling ---
# How many crates this build produces, for the bar: the count of its last
# successful build, else Cargo's unit graph (no compiling). 0 when unknown.
expected_crates() {
ec_record="$rel_target_dir/$rel_binary.crates"
if [ -s "$ec_record" ]; then cat "$ec_record"; return; fi
# The same features the build passes (build_features); an unset one
# stopped this under set -u, and the bar never got its end.
build_features
# shellcheck disable=SC2086 # feature_args is fixed words
(cd "$rel_directory/$rel_workspace" && RUSTC_BOOTSTRAP=1 "$CARGO" build --release -p "$rel_package" --bin "$rel_binary" $feature_args --unit-graph -Z unstable-options 2>/dev/null) |
grep -o '"mode":"build"' | wc -l | tr -d ' '
}
# build_features -> $feature_args: the release's features, as cargo takes them.
build_features() {
feature_args=--no-default-features
[ -z "$rel_features" ] || feature_args="$feature_args --features $rel_features"
}
# compile_app: builds the loaded release's app in the background, with its
# progress on the step "Compile"; build log in the snapshot's target.
job_compile() { # job_compile LABEL APP
jc_label=$1
jc_started=$(now)
step "$jc_label" "now|0|0|none|0|resolving"
( cargo_env "$rel_label" "$2" 1 >/dev/null; expected_crates > "$root/tmp/expected-crates" ) || :
jc_expected=$(cat "$root/tmp/expected-crates" 2>/dev/null || echo 0)
build_app "$2" || { step_fail "$jc_label" "$build_error"; return 1; }
publish_app || { step_fail "$jc_label" "$publish_error"; return 1; }
step "$jc_label" "done|$build_crates crates · $(($(now) - jc_started)) s"
}
# build_app APP: cargo build of the loaded release into the snapshot's
# target (log: builder-build.log; the TUI shows the crate count on the step
# "$jc_label"). With the parallel frontend, a crash (an internal compiler
# error, the deadlock detector, a compiler killed by a signal, no memory) or
# 10 minutes without output is retried once with one thread; the parallel
# log is kept as builder-build.parallel.log, and when one thread works,
# rustc-threads says 1 from then on. Ordinary compile errors are not retried.
build_app() {
build_error= build_crates=0
rustc_threads
ba_threads=$rustc_threads
ba_log="$rel_target_dir/builder-build.log"
while :; do
run_cargo_build "$1" "$ba_threads" && break
ba_retry=0
if [ "$ba_threads" -gt 1 ]; then
if [ "$ba_stalled" = 1 ] || grep -Eq "internal compiler error|the compiler unexpectedly panicked|deadlock detected|rustc interrupted by|memory allocation of .* failed|didn.t exit successfully: .*--crate-name.*\(signal: " "$ba_log"; then ba_retry=1; fi
fi
if [ "$ba_retry" = 0 ]; then
build_error=$(grep -m 1 '^error' "$ba_log" 2>/dev/null || :)
cat "$ba_log" >> "$log_file"
build_error="${build_error:-Build failed} (full log in builder.log)"
return 1
fi
if [ "$ba_stalled" = 1 ]; then ba_reason='made no progress for 10 minutes'; else ba_reason='crashed'; fi
cp "$ba_log" "$rel_target_dir/builder-build.parallel.log"
log "The parallel Rust compiler ($ba_threads threads) $ba_reason; compiling again with one thread."
ba_parallel=$ba_threads ba_threads=1
done
if [ "$ba_threads" = 1 ] && [ "${ba_parallel:-1}" -gt 1 ]; then
put "$root/rustc-threads" "1
Serial since the parallel Rust compiler ($ba_parallel threads) $ba_reason and the serial one then compiled the same build. Delete this file to try the parallel compiler again."
log 'Compiled with one thread. Builds here stay on one thread from now on (see rustc-threads).'
fi
build_crates=$(grep -c '"reason":"compiler-artifact"' "$ba_log" 2>/dev/null) || build_crates=0
printf '%s\n' "$build_crates" > "$rel_target_dir/$rel_binary.crates"
}
run_cargo_build() { # run_cargo_build APP THREADS -> status; ba_stalled
ba_stalled=0
(
cargo_env "$rel_label" "$1" "$2" >/dev/null
build_features
cd "$rel_directory/$rel_workspace"
# shellcheck disable=SC2086 # feature_args is fixed words
exec "$CARGO" build --release --message-format=json-render-diagnostics -p "$rel_package" --bin "$rel_binary" $feature_args
) </dev/null > "$ba_log" 2>&1 &
rb_pid=$!
rb_size=-1 rb_quiet=0 rb_ticks=0
while kill -0 "$rb_pid" 2>/dev/null; do
if [ -n "${jc_label:-}" ]; then
rb_crates=$(grep -c '"reason":"compiler-artifact"' "$ba_log" 2>/dev/null) || rb_crates=0
rb_fresh=$(grep '"reason":"compiler-artifact"' "$ba_log" 2>/dev/null | grep -c '"fresh":true') || rb_fresh=0
rb_built=$((rb_crates - rb_fresh)) rb_of=0
if [ "${jc_expected:-0}" -gt 0 ]; then
rb_of=$((jc_expected - rb_fresh))
[ "$rb_of" -gt "$rb_built" ] || rb_of=$((rb_built + 1))
fi
step "$jc_label" "now|$rb_built|$rb_of|crates|0|compiling"
fi
sleep 0.3
rb_ticks=$((rb_ticks + 1))
[ $((rb_ticks % 3)) = 0 ] || continue
rb_now=$(size_of "$ba_log")
if [ "$rb_now" = "$rb_size" ]; then rb_quiet=$((rb_quiet + 1)); else rb_size=$rb_now rb_quiet=0; fi
if [ "$2" -gt 1 ] && [ "$rb_quiet" -ge 600 ]; then
kill_tree "$rb_pid"
ba_stalled=1
fi
done
wait "$rb_pid"
}
# Background jobs ignore Ctrl+C; stopping one stops its children too. Each
# process is paused first, so it cannot start another while its children
# are stopped, then ended.
kill_tree() {
kill -STOP "$1" 2>/dev/null || :
for kt_child in $(pgrep -P "$1" 2>/dev/null); do kill_tree "$kt_child"; done
kill -TERM "$1" 2>/dev/null || :
kill -CONT "$1" 2>/dev/null || :
}
# publish_app: the built executable as builder/<binary>.bin with the map
# from crate names to their source folders beside it (fonts, icons and other
# resources stay in the sources), its <binary> command beside this script,
# and on macOS a real <Title>.app there for the Dock.
publish_app() {
publish_error=
pa_log="$rel_target_dir/builder-build.log"
pa_map="$root/tmp/package-paths"
: > "$pa_map"
awk '
index($0, "\"reason\":\"compiler-artifact\"") {
if (!match($0, /"manifest_path":"[^"]*"/)) next
m = substr($0, RSTART + 17, RLENGTH - 18)
t = substr($0, index($0, "\"target\":{"))
k = substr(t, index(t, "\"kind\":[") + 8); k = substr(k, 1, index(k, "]") - 1)
if (k == "\"custom-build\"") next
if (!match(t, /"name":"[^"]*"/)) next
name = substr(t, RSTART + 8, RLENGTH - 9); gsub(/-/, "_", name)
sub(/\/[^\/]*$/, "", m)
print name "\t" m
}' "$pa_log" | sort -u > "$root/tmp/artifacts"
while IFS=' ' read -r pa_name pa_dir; do
case "$pa_dir" in "$rel_directory"/*) ;; *) [ -d "$pa_dir/resources" ] || continue ;; esac
case "$pa_dir" in "$root"/*) ;; *) publish_error="Resources for $pa_name are outside the portable installation"; return 1 ;; esac
printf '%s\t%s\n' "$pa_name" "${pa_dir#"$root"/}" >> "$pa_map"
done < "$root/tmp/artifacts"
[ -s "$pa_map" ] || { publish_error='Cargo produced no application resource paths'; return 1; }
pa_bin="$root/$rel_binary.bin"
mv -f "$pa_map" "$pa_bin.makepad-package-paths"
# Older runtimes read one shared map: keep other apps' entries.
{ cat "$pa_bin.makepad-package-paths"; cat "$root/makepad-package-paths" 2>/dev/null || :; } |
awk -F'\t' '!seen[$1]++' > "$root/makepad-package-paths.next"
mv -f "$root/makepad-package-paths.next" "$root/makepad-package-paths"
cp "$rel_target_dir/release/$rel_binary" "$pa_bin.next"
chmod 755 "$pa_bin.next"
mv -f "$pa_bin.next" "$pa_bin"
# This snapshot's artifacts are in the shared target now: later releases
# at the same commits build from here.
put "$rel_directory/.builder-built" "$rel_id $rel_release"
write_launcher "$rel_binary"
if [ "$plat" = mac ]; then mac_bundle || return 1; fi
mkdir -p "$root/installed"
cp "$release_file" "$root/installed/$rel_id.json.next"
mv -f "$root/installed/$rel_id.json.next" "$root/installed/$rel_id.json"
cp "$root/installed/$rel_id.json" "$root/installed-release.json"
cp "$root/installed/$rel_id.json" "$rel_directory/$rel_id.json"
}
# write_launcher BINARY: the <binary> command beside this script. It keeps
# the invoking directory as the app's project; on macOS it opens the app's
# bundle, so the shell command and the Dock icon are the same app.
write_launcher() {
cat > "$home/$1.next" <<EOF
#!/bin/sh
# Opens $1, built by the Makepad Builder in this folder (./makepad build $1).
set -eu
builder_script=\$0
while [ -L "\$builder_script" ]; do
builder_directory=\$(cd -P -- "\$(dirname -- "\$builder_script")" && pwd)
builder_link=\$(readlink "\$builder_script")
case "\$builder_link" in /*) builder_script=\$builder_link ;; *) builder_script=\$builder_directory/\$builder_link ;; esac
done
builder_directory=\$(cd -P -- "\$(dirname -- "\$builder_script")" && pwd)
unset MAKEPAD_LOADER_EMAIL MAKEPAD_PACKAGE_DIR
if [ "\$#" -gt 0 ]; then
case "\$1" in -*) ;; *) set -- --cwd "\$@" ;; esac
fi
if [ "\$(uname -s)" = Darwin ]; then
for builder_app in "\$builder_directory/"*.app; do
if [ -x "\$builder_app/Contents/MacOS/$1" ]; then
builder_has_cwd=0
for builder_arg in "\$@"; do
[ "\$builder_arg" != --cwd ] || builder_has_cwd=1
done
if [ "\$builder_has_cwd" = 0 ]; then set -- --cwd "\$PWD" "\$@"; fi
exec "\$builder_app/Contents/MacOS/$1" "\$@"
fi
done
fi
exec "\$builder_directory/builder/$1.bin" "\$@"
EOF
chmod 755 "$home/$1.next"
mv -f "$home/$1.next" "$home/$1"
}
# macos_launcher BINARY: the executable a bundle's Dock launch runs. It
# resolves everything from the bundle, so the whole folder can move.
macos_launcher() {
cat <<EOF
#!/bin/sh
set -eu
app_dir=\$(cd -P -- "\$(dirname -- "\$0")" && pwd)
install_dir=\$(cd -P -- "\$app_dir/../../../builder" && pwd)
unset MAKEPAD_LOADER_EMAIL MAKEPAD_PACKAGE_DIR
has_project=0
for arg in "\$@"; do
[ "\$arg" != --cwd ] || has_project=1
done
if [ "\$has_project" = 0 ]; then
project=\$(cat "\$install_dir/installed/$1.project")
case "\$project" in /*) ;; *) project="\$install_dir/\$project" ;; esac
set -- --cwd "\$project" "\$@"
fi
mkdir -p "\$install_dir/target"
exec "\$app_dir/$1-bin" "\$@" </dev/null >>"\$install_dir/target/$1-app.log" 2>&1
EOF
}
# mac_bundle: "<Title>.app" beside this script with the executable, a
# launcher, a relative link to the Builder's folder (resources stay in the
# sources) and the app's icon, so it can be kept in the Dock.
mac_bundle() {
mb_name=$(printf '%s' "$rel_title" | tr -cd 'A-Za-z0-9 ._-' | sed 's/^[ .]*//; s/ *$//')
[ -n "$mb_name" ] || mb_name=$(printf '%s' "$rel_binary" | awk '{ print toupper(substr($0, 1, 1)) substr($0, 2) }')
mb_bundle="$home/$mb_name.app"
mb_tools="$rel_directory/$rel_makepad_path/tools/makepad_builder"
mb_project="$rel_directory/$rel_makepad_path"
mkdir -p "$root/installed"
put "$root/installed/$rel_binary.project" "${mb_project#"$root"/}"
mb_launcher=$(macos_launcher "$rel_binary")
mb_icon=
if [ "$rel_id" = scope ] && [ -f "$mb_tools/resources/scope.icns" ]; then mb_icon="$mb_tools/resources/scope.icns"
elif [ -f "$rel_directory/$rel_workspace/resources/icon.icns" ]; then mb_icon="$rel_directory/$rel_workspace/resources/icon.icns"
elif [ -f "$rel_directory/$rel_workspace/resources/icon_1024.png" ]; then mb_icon="$rel_directory/$rel_workspace/resources/icon_1024.png"
else mb_icon=$(declared_icon); fi
mb_stamp="bundle-v1 $(cksum < "$root/$rel_binary.bin") $(cksum < "$root/$rel_binary.bin.makepad-package-paths") $rel_title $(printf '%s' "$mb_launcher" | cksum) $mb_icon"
if [ "$(cat "$mb_bundle/Contents/Resources/build-receipt" 2>/dev/null || :)" = "$mb_stamp" ] && [ -x "$mb_bundle/Contents/MacOS/$rel_binary" ]; then
return 0
fi
mb_stage="$home/.$mb_name.app.next"
[ ! -e "$mb_stage" ] || remove_inside "$mb_stage"
mkdir -p "$mb_stage/Contents/MacOS" "$mb_stage/Contents/Resources"
cp "$root/$rel_binary.bin" "$mb_stage/Contents/MacOS/$rel_binary-bin"
printf '%s\n' "$mb_launcher" > "$mb_stage/Contents/MacOS/$rel_binary"
chmod 755 "$mb_stage/Contents/MacOS/$rel_binary"
ln -s ../../../builder "$mb_stage/Contents/MacOS/installation"
sed 's# # installation/#' "$root/$rel_binary.bin.makepad-package-paths" > "$mb_stage/Contents/MacOS/$rel_binary-bin.makepad-package-paths"
mb_icon_key=
case "$mb_icon" in
*.png) cp "$mb_icon" "$mb_stage/Contents/Resources/AppIcon.png"; mb_icon_key='<key>CFBundleIconFile</key><string>AppIcon.png</string>' ;;
?*) cp "$mb_icon" "$mb_stage/Contents/Resources/AppIcon.icns"; mb_icon_key='<key>CFBundleIconFile</key><string>AppIcon.icns</string>' ;;
esac
mb_title=$(printf '%s' "$rel_title" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g')
cat > "$mb_stage/Contents/Info.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleExecutable</key><string>$rel_binary</string>
<key>CFBundleIdentifier</key><string>nl.makepad.$(printf '%s' "$rel_id" | tr '_' '-')</string>
<key>CFBundleName</key><string>$mb_title</string>
<key>CFBundleDisplayName</key><string>$mb_title</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleVersion</key><string>1.0</string>
<key>CFBundleShortVersionString</key><string>1.0</string>
<key>LSUIElement</key><false/>
<key>NSHighResolutionCapable</key><true/>
<key>NSLocationUsageDescription</key><string>Used to show your position on the map.</string>
<key>NSMicrophoneUsageDescription</key><string>Used for audio input in Makepad apps.</string>
<key>NSCameraUsageDescription</key><string>Used for camera input in Makepad apps.</string>
$mb_icon_key
</dict></plist>
EOF
printf 'APPL????' > "$mb_stage/Contents/PkgInfo"
put "$mb_stage/Contents/Resources/build-receipt" "$mb_stamp"
mb_previous="$home/.$mb_name.app.previous"
[ ! -e "$mb_previous" ] || remove_inside "$mb_previous"
[ ! -e "$mb_bundle" ] || mv "$mb_bundle" "$mb_previous"
mv "$mb_stage" "$mb_bundle"
[ ! -e "$mb_previous" ] || remove_inside "$mb_previous"
}
# The icon an app's package declares for desktop bundles (as cargo makepad
# reads it): icon = "..." under [package.metadata.makepad.desktop].
declared_icon() {
find "$rel_directory/$rel_workspace" -maxdepth 4 -name Cargo.toml -not -path '*/target/*' 2>/dev/null | while IFS= read -r di_manifest; do
di_icon=$(awk -v package="$rel_package" '
/^\[/ { table = $0; next }
table == "[package]" && $1 == "name" { v = $0; sub(/^[^"]*"/, "", v); sub(/".*/, "", v); name = v }
table == "[package.metadata.makepad.desktop]" && $1 == "icon" { v = $0; sub(/^[^"]*"/, "", v); sub(/".*/, "", v); icon = v }
END { if (name == package && icon != "") print icon }' "$di_manifest")
if [ -n "$di_icon" ] && [ -f "$(dirname "$di_manifest")/$di_icon" ]; then
printf '%s\n' "$(dirname "$di_manifest")/$di_icon"
break
fi
done
}
# launch_app: the built app, detached from this terminal (closing it does
# not stop the app), with its output in the snapshot's target folder.
launch_app() { # after rel_load/rel_dir of the installed release
rel_target
la_project="$rel_directory/$rel_makepad_path"
la_log="$rel_target_dir/$rel_binary-app.log"
mkdir -p "$rel_target_dir"
if [ "$plat" = mac ]; then
la_name=$(printf '%s' "$rel_title" | tr -cd 'A-Za-z0-9 ._-' | sed 's/^[ .]*//; s/ *$//')
[ -n "$la_name" ] || la_name=$(printf '%s' "$rel_binary" | awk '{ print toupper(substr($0, 1, 1)) substr($0, 2) }')
set -- "$home/$la_name.app/Contents/MacOS/$rel_binary" --cwd "$la_project"
[ "$rel_id" != scope ] || set -- "$@" --focus
else
set -- "$home/$rel_binary" --cwd "$la_project"
fi
(
cargo_env "$rel_label" "$rel_id" >/dev/null 2>&1
unset MAKEPAD_LOADER_EMAIL RUSTUP_TOOLCHAIN
export MAKEPAD_FEEDBACK_EMAIL="$email"
cd "$la_project"
if command -v setsid >/dev/null 2>&1; then exec setsid "$@"
elif command -v perl >/dev/null 2>&1; then exec perl -e 'setpgrp(0, 0); exec { $ARGV[0] } @ARGV or die "$!\n"' "$@"
else exec nohup "$@"; fi
) </dev/null >"$la_log" 2>&1 &
log "Running $rel_title"
}
launched_message() {
if [ "$plat" = mac ]; then message="${ok}✓${r0} $1 is running. ${dim}Right-click its Dock icon › Options › Keep in Dock.${r0}"
else message="${ok}✓${r0} $1 is running. ${dim}Run it again from here or from its command.${r0}"; fi
}
# Remove source snapshots nothing refers to any more and that hold no
# edits (git status is clean apart from nested checkouts), with their build
# output. A snapshot you or an agent worked in stays.
prune_snapshots() {
ps_keep=
for ps_file in "$root"/installed/*.json "$root"/available/*.json; do
[ -f "$ps_file" ] || continue
rel_load "$ps_file" 2>/dev/null || continue
rel_dir
ps_keep="$ps_keep|$rel_directory|"
done
for ps_snap in "$root"/sources/*; do
[ -d "$ps_snap" ] || continue
case "$ps_keep" in *"|$ps_snap|"*) continue ;; esac
ps_clean=1
for ps_receipt in "$ps_snap"/.builder-repositories/*; do
[ -f "$ps_receipt" ] || continue
ps_name=${ps_receipt##*/}
ps_path=$ps_name
[ "$ps_name" != makepad ] || ps_path=makepad
for ps_json in "$ps_snap"/*.json; do
[ -f "$ps_json" ] || continue
ps_found=$(json flat < "$ps_json" | awk -F'\t' -v n="$ps_name" '$1 ~ /\/name$/ && $2 == n { split($1, a, "/"); want = a[2] } $1 ~ /\/path$/ { split($1, a, "/"); path[a[2]] = $2 } END { if (want != "") print path[want] }')
[ -z "$ps_found" ] || ps_path=$ps_found
done
[ -d "$ps_snap/$ps_path/.git" ] || { ps_clean=0; break; }
ps_dirty=$(git -C "$ps_snap/$ps_path" status --porcelain 2>/dev/null | grep -v '^?? apps/[A-Za-z0-9_-]*/$' | head -n 1) || ps_dirty=error
[ -z "$ps_dirty" ] || { ps_clean=0; break; }
done
[ "$ps_clean" = 1 ] || continue
remove_inside "$ps_snap"
[ ! -e "$root/target/sources/${ps_snap##*/}" ] || remove_inside "$root/target/sources/${ps_snap##*/}"
log "Removed the unused sources ${ps_snap##*/}; nothing was built from them any more and they held no edits."
done
}
# -------------------------------------------------------- coding agents ---
agent_directive='Before editing read the installation instructions in AGENTS.md in this folder (also named by the MAKEPAD_AGENT_CONTEXT environment variable). Rebuild with ../makepad build <app>. Preserve the existing repository instructions and wait for the user to choose a change.'
print_agent_context() {
pac_rust=${rust_version:-pinned}
cat <<EOF
# Makepad apps in this folder
Everything here is Makepad applications as Rust source (the folder above
holds only the \`makepad\` command and the built apps). The person using this
folder wants you to change these apps and rebuild them.
## Build and run
Always use the \`makepad\` command in the folder above (../makepad); do not
call cargo directly.
It uses this folder's compiler (a private Rust $pac_rust in toolchain/, or the
installed Rust recorded in selected-rust), keeps caches and build output in
this folder, and rebuilds the macOS app bundle so Dock icons keep working.
../makepad build <app> compile after you change its source
../makepad run <app> compile if needed, then open it
../makepad list every app, its state and its source folder
A build prints compiler errors in full; fix them and build again. If you need
cargo itself (tests, clippy), first run: eval "\$(../makepad env)"
Do not install another compiler, change the global PATH or run rustup.
## Apps
| app | name | source |
|-----|------|--------|
EOF
list_apps | while IFS='|' read -r pa_id pa_title pa_state pa_source; do
printf '| %s | %s | %s |\n' "$pa_id" "$pa_title" "$pa_source"
done
cat <<EOF
Makepad itself (widgets, platform, shaders) is in the makepad folder of each
source snapshot; read its AGENTS.md before changing it. Each repository keeps
its own instructions. Fonts, icons and other resources stay in the sources;
each built app finds them through <app>.bin.makepad-package-paths.
## After an update
Updates never merge. When an app you changed gets a new release, the old
changes are saved first and the app is replaced by the clean new source:
changes/<app>-<date>.diff unified diff against the release you edited
changes/<app>-<date>.files one "M|A|D path" line per changed file
When asked to merge, reapply that diff onto the new source by editing files,
keep the intent of each change where the new release moved code, then
../makepad build <app> until it compiles. Report any change that no longer
applies.
## Rules
- Keep edits inside sources/. Built apps (<app>.bin, <Title>.app) are regenerated.
- Your licenses do not allow publishing the commercial sources.
- Do not read or expose makepad-builder.json or any email address.
EOF
}
# list_apps: id|title|state|source folder, licensed apps first.
list_apps() {
la_ids=$(cat "$root/tmp/builder/licensed" 2>/dev/null || :)
if [ -z "$la_ids" ]; then
for la_file in "$root"/available/*.json; do
[ -f "$la_file" ] || continue
rel_load "$la_file" 2>/dev/null || continue
[ "$rel_public" = true ] || la_ids="$la_ids $rel_id"
done
fi
for la_id in $la_ids; do
app_state "$la_id"
rel_load "$root/available/$la_id.json" 2>/dev/null || continue
rel_dir
printf '%s|%s|%s|%s\n' "$la_id" "$rel_title" "$state" "${rel_directory#"$root"/}/$rel_workspace"
done
free_apps | while IFS='|' read -r la_id la_title; do
app_state "$la_id"
la_src=sources/makepad/apps/$la_id
if app_release_file "$la_id" 2>/dev/null && rel_load "$release_file" 2>/dev/null; then rel_dir; la_src="${rel_directory#"$root"/}/$rel_workspace"; fi
printf '%s|%s|%s|%s\n' "$la_id" "$la_title" "$state" "$la_src"
done
}
write_agent_files() {
print_agent_context > "$root/AGENTS.md.next" && mv -f "$root/AGENTS.md.next" "$root/AGENTS.md"
cp "$root/AGENTS.md" "$root/CLAUDE.md"
cp "$root/AGENTS.md" "$root/agent-context.txt"
}
# The newest installed release, for the environment agents and shells get.
agent_release() {
for ar_file in "$root"/installed/*.json; do
[ -f "$ar_file" ] || continue
rel_load "$ar_file" 2>/dev/null || continue
rel_dir
rel_installed && return 0
done
return 1
}
# ---------------------------------------------------------------- session ---
# The folder's own records: the email (makepad-builder.json, never sent
# anywhere but the license service), a scratch folder, the activity log.
read_email() {
email=
for re_file in "$root/makepad-builder.json" "$root/makepad-loader.json"; do
[ -f "$re_file" ] || continue
email=$(json flat < "$re_file" | awk -F'\t' '$1 == "email" { print $2; exit }')
break
done
}
save_email() {
put "$root/makepad-builder.json" "{\"version\":1,\"email\":\"$email\",\"app\":\"makepad\"}"
}
session() {
mkdir -p "$root/tmp/builder/steps" "$root/tmp/builder/slots"
scratch="$root/tmp/builder"
steps_dir="$scratch/steps"
slots="$scratch/slots"
log_file="$root/builder.log"
# Connection slots and step records of a stopped run are stale.
for s_old in "$slots"/* "$steps_dir"/*; do [ ! -e "$s_old" ] || remove_inside "$s_old"; done
read_email
load_registry
}
# ----------------------------------------------------------- commands ---
# Plain, line-based output for coding agents: every compiler message, then
# one result line they can check.
die() { printf '%s\n' "$*" >&2; exit 1; }
cmd_setup() { # the app's release, its Rust and system tools, for build/run
session
app_release_file "$1" || die "Unknown app $1; ./makepad list shows every app."
rel_load "$release_file" || die "$1 has no usable release; open ./makepad and update."
rel_supported || die "$rel_title is not available for this platform yet."
rust_ready "$rel_rust" || die "Rust $rel_rust is not set up here; open ./makepad to set it up."
tools_check || die "The system developer tools are not ready ($tools_problem); open ./makepad to set them up."
}
cmd_build() { # cmd_build APP [run]
cmd_setup "$1"
rel_dir
if ! rel_installed; then
[ "$rel_public" = true ] || [ -n "$email" ] || die "Log in with the email that has the $rel_title license: open ./makepad."
[ "$rel_public" = true ] || email_headers
printf 'Downloading the %s source\n' "$rel_title"
keep_edits "$1" "$release_file"
rel_load "$release_file"
work_labels_keys=
source_labels
while IFS= read -r cb_label; do
[ -n "$cb_label" ] || continue
step_key "$cb_label"; work_labels_keys="$work_labels_keys $step_key"
done <<EOF
$source_labels
EOF
checkout_jobs
if ! jobs_wait; then
for cb_key in $work_labels_keys; do cut -d'|' -f2 "$steps_dir/$cb_key" >&2; done
die 'Download failed.'
fi
rel_load "$release_file"
rel_dir
fi
prepare_target
printf 'Compiling %s with Rust %s\n' "$rel_title" "$rel_rust"
jc_label=
if ! build_app "$rel_id"; then
# Every compiler message for the agent, then the result.
grep -v '^{' "$rel_target_dir/builder-build.log" >&2 || :
die "Build failed: $rel_title"
fi
grep -v '^{' "$rel_target_dir/builder-build.log" | grep -v '^ *Compiling ' || :
publish_app || die "$publish_error"
printf 'Built %s: %s\n' "$rel_title" "$root/$rel_binary.bin"
if [ "$plat" = mac ]; then printf 'Bundled %s\n' "$mb_bundle"; fi
if [ "${2:-}" = run ]; then launch_app; printf 'Opened %s\n' "$rel_title"; fi
}
print_env() {
session
rust_version
rust_ready "${rust_version:-0.0.0}" || die 'Rust is not set up here; open ./makepad to set it up.'
rel_label=
if agent_release; then :; elif rel_load "$root/available/makepad.json" 2>/dev/null; then rel_dir; fi
[ -n "$rel_label" ] || die 'Download an app first; ./makepad list shows them.'
(
cargo_env "$rel_label" "${rel_id:-makepad}" >/dev/null
for pe_var in CARGO_HOME CARGO_TARGET_DIR RUSTC CARGO RUSTDOC RUSTC_BOOTSTRAP TMPDIR TMP TEMP RUSTUP_HOME RUSTUP_AUTO_INSTALL RUSTUP_TOOLCHAIN MAKEPAD_LOADER_EMAIL MAKEPAD_PACKAGE_DIR CUDA_PATH CUDA_HOME CUDACXX MAKEPAD_GGML_NO_CUDA MAKEPAD_GGML_REQUIRE_CUDA RUSTFLAGS PATH; do
eval "pe_value=\${$pe_var-}"
printf "export %s='%s'\n" "$pe_var" "$(printf '%s' "$pe_value" | sed "s/'/'\\\\''/g")"
done
)
}
# migrate_layout: an earlier Builder kept everything in the folder itself.
# Its records, sources, toolchains and built binaries move into builder/
# (renames within one folder: nothing is copied or downloaded again), the
# app commands and bundles are rewritten to look there, and the folder
# keeps only this command and the apps.
migrate_layout() {
[ -d "$home/installed" ] || [ -d "$home/toolchain" ] || [ -d "$home/sources" ] || [ -f "$home/makepad-builder.json" ] || [ -f "$home/makepad-loader.json" ] || return 0
mkdir -p "$root"
for ml_name in installed available sources target cache toolchain cargo-home rustup-home tmp changes selected-rust \
latest.json installed-release.json agreements-accepted .login-asked graphics-notice-read makepad-builder.json \
makepad-loader.json builder.log rustc-threads makepad-package-paths AGENTS.md CLAUDE.md agent-context.txt \
makepad-builder .builder-version bootstrap-builder.sh run-builder.sh check-tools.sh rust-tools.sh; do
if { [ -e "$home/$ml_name" ] || [ -L "$home/$ml_name" ]; } && [ ! -e "$root/$ml_name" ]; then
mv "$home/$ml_name" "$root/$ml_name"
fi
done
for ml_bin in "$home"/*.bin; do
[ -f "$ml_bin" ] || continue
ml_name=${ml_bin##*/}; ml_name=${ml_name%.bin}
identifier "$ml_name" || continue
mv -f "$ml_bin" "$root/$ml_name.bin"
[ ! -f "$ml_bin.makepad-package-paths" ] || mv -f "$ml_bin.makepad-package-paths" "$root/$ml_name.bin.makepad-package-paths"
[ ! -f "$home/$ml_name" ] || write_launcher "$ml_name"
done
for ml_app in "$home"/*.app; do
[ -L "$ml_app/Contents/MacOS/installation" ] || continue
ln -sfn ../../../builder "$ml_app/Contents/MacOS/installation"
for ml_file in "$ml_app"/Contents/MacOS/*; do
ml_name=${ml_file##*/}
case "$ml_name" in *-bin | *.makepad-package-paths | installation) continue ;; esac
identifier "$ml_name" || continue
macos_launcher "$ml_name" > "$ml_file.next" && chmod 755 "$ml_file.next" && mv -f "$ml_file.next" "$ml_file"
done
# Rebuilt from the moved binary on the next build.
rm -f "$ml_app/Contents/Resources/build-receipt"
done
}
[ -z "$home" ] || migrate_layout
case "${1:-}" in
build | run)
[ -n "$root" ] || die "Run this command from your Makepad folder: ./makepad $1 APP"
[ -n "${2:-}" ] || die "Usage: ./makepad $1 APP (./makepad list shows every app)"
cmd_build "$2" "$1"; exit 0 ;;
list)
[ -n "$root" ] || die 'Run this command from your Makepad folder.'
session
list_apps | awk -F'|' '{ printf "%-14s %-22s %-9s %s\n", $1, $2, $3, $4 }'
exit 0 ;;
env)
[ -n "$root" ] || die 'Run this command from your Makepad folder.'
print_env; exit 0 ;;
agent-context)
[ -n "$root" ] || die 'Run this command from your Makepad folder.'
session; rust_version; print_agent_context; exit 0 ;;
rust-check)
# The read-only Rust probe the Rust Builder shares:
# rust-check --probe MINIMUM TRIPLE | --validate MINIMUM TRIPLE SYSROOT
# prints the sysroot; exit 1 without output when no Rust is found,
# exit 2 with the reason when one is found but cannot be used.
triple=${4:-$triple}
[ "${2:-}" = --probe ] || [ "${2:-}" = --validate ] || die 'Usage: makepad rust-check --probe MIN TRIPLE | --validate MIN TRIPLE SYSROOT'
if rust_check "$2" "$3" "${5:-}"; then printf '%s\n' "$rust_sysroot"; exit 0; fi
[ "$rust_reason" != 'No installed Rust was found on PATH.' ] || exit 1
printf '%s\n' "$rust_reason"; exit 2 ;;
tools-check)
# Exit 0 when the system developer tools are ready; else 1 and the
# problem (missing, license or unsupported).
if tools_check; then exit 0; fi
printf '%s\n' "$tools_problem"; exit 1 ;;
tools-setup)
# Line-based setup of the system tools, for the Rust Builder.
if tools_check; then printf ' The system developer tools are ready.\n'; exit 0; fi
if [ "$plat" = mac ]; then
if [ "$tools_problem" = license ]; then
printf ' Xcode is installed, but its license has not been accepted yet.\n Read and accept it with: sudo xcodebuild -license\n'
else
printf " Opening Apple's installer for the command line developer tools.\n When it has finished, run the Builder again.\n"
/usr/bin/xcode-select --install >/dev/null 2>&1 || :
fi
exit 1
fi
linux_packages || die 'Install C/C++, git, curl, CMake, pkg-config and the OpenSSL, X11, Xcursor, xkbcommon, ALSA, PulseAudio, Wayland, EGL/GLX and GBM development packages, then run the Builder again.'
printf ' Makepad compiles from source and needs development packages from %s:\n\n sudo %s\n\n Press Return to run it, or Ctrl+C to stop. ' "$distro_name" "$packages_cmd"
IFS= read -r ts_answer || exit 1
# shellcheck disable=SC2086
sudo $packages_cmd
tools_check || die 'Some development packages are still missing; see the package manager output above.'
exit 0 ;;
-h | --help) sed -n '2,15p' "$self"; exit 0 ;;
'') ;;
*) die "Unknown command $1; ./makepad --help lists them." ;;
esac
# ------------------------------------------------------------ terminal UI ---
exec 3<>/dev/tty || die 'The Makepad Builder needs an interactive terminal.'
exec 1>&3
tui=1
e=$(printf '\033')
dim="${e}[2m" b="${e}[1m" ok="${e}[32m" acc="${e}[38;2;255;92;57m" warn="${e}[33m" red="${e}[31m" inv="${e}[7m" r0="${e}[0m"
# Makepad orange: the ▌ on the selected row and the filled part of progress
# bars; never a background or an action. The
# selection band and the logo behind the rows are faint greys, for a dark or
# a light background (asked once at the start, see light_background).
mark="${e}[38;2;255;92;57m"
# A key letter in the footer (c cancel, s start when done): bold orange.
key_c="${e}[1;38;2;255;92;57m"
band_dark="${e}[48;2;42;44;48m" band_light="${e}[48;2;228;229;231m"
logo_dark="${e}[38;2;44;47;52m" logo_light="${e}[38;2;226;227;229m"
band=$band_dark logo_color=$logo_dark
# The Makepad mark, large (44 x 13) and small (30 x 9), with the column each
# line's dots start at (tools/makepad_builder/logo.txt, which the Windows
# Builder draws the same way).
logo_big=' ⢀⣾⡄ ⣰⣷⡀
⢠⣿⣿⣿⣆ ⣰⣿⣿⣷⡀
⢠⣿⣿⣿⣿⣿⣆ ⣴⣿⣿⣿⣿⣿⡄
⣰⣿⣿⣿⣿⣿⣿⣿⣧ ⣼⣿⣿⣿⣿⣿⣿⣿⡄
⣰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧ ⢀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣆
⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀⢀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣆
⡨⣭⣭⣭⣭⣭⣭⣭⣭⣭⣭⣭⣭⣭⡅⢩⣭⣭⣭⣭⣭⣭⣭⣭⣭⣭⣭⣭⣭⢅
⢀⣼⣷⡹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟ ⠈⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢣⣿⣧
⢀⣾⣿⣿⣿⡜⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏ ⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⢣⣿⣿⣿⣧⡀
⢠⣾⣿⣿⣿⣿⣿⣜⣿⣿⣿⣿⣿⣿⣿⠏ ⠹⣿⣿⣿⣿⣿⣿⡿⣱⣿⣿⣿⣿⣿⣷⡀
⢠⣿⣿⣿⣿⣿⣿⣿⣿⣎⢿⣿⣿⣿⣿⠋ ⠹⣿⣿⣿⣿⡿⣱⣿⣿⣿⣿⣿⣿⣿⣷⡄
⣰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣎⢿⣿⣿⠃ ⠘⣿⣿⡟⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄
⣰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⠻⠁ ⠘⢟⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣆'
logo_big_ink='13 12 11 10 9 8 7 5 4 3 2 1 0'
logo_small=' ⣼⣆ ⣼⣆
⣼⣿⣿⣧ ⣼⣿⣿⣧
⢀⣾⣿⣿⣿⣿⣧⡀ ⢀⣾⣿⣿⣿⣿⣧
⢀⣾⣿⣿⣿⣿⣿⣿⣷⡀⢀⣾⣿⣿⣿⣿⣿⣿⣷⡀
⢀⣼⣿⣿⣿⣿⣿⣿⣿⣿⡇⢼⣿⣿⣿⣿⣿⣿⣿⣿⣇⡀
⢠⣿⣯⣻⣿⣿⣿⣿⣿⣿⡟ ⠈⢻⣿⣿⣿⣿⣿⣿⣟⣾⣷⡀
⢠⣿⣿⣿⣷⣻⣿⣿⣿⣿⡟ ⢻⣿⣿⣿⣿⢟⣾⣿⣿⣿⡄
⣰⣿⣿⣿⣿⣿⣷⡿⣿⣿⠏ ⠻⣿⣿⢯⣿⣿⣿⣿⣿⣿⡄
⣰⣿⣿⣿⣿⣿⣿⣿⣿⡽⠏ ⠹⣯⣿⣿⣿⣿⣿⣿⣿⣿⣆'
logo_small_ink='9 8 6 5 4 3 2 1 0'
tty_saved=$(stty -g <&3)
# The window is titled "Makepad Builder" while it runs; the terminal's own
# title comes back afterwards.
screen_on() { printf '%s[22;0t%s]0;Makepad Builder\007%s[?1049h%s[?25l' "$e" "$e" "$e" "$e"; stty -icanon -echo min 1 time 0 <&3 2>/dev/null; }
screen_off() { stty "$tty_saved" <&3 2>/dev/null || :; printf '%s[?25h%s[?1049l%s]0;\007%s[23;0t' "$e" "$e" "$e" "$e"; }
# Background jobs ignore Ctrl+C (the shell starts them that way), so every
# exit stops them and all their children; their partial downloads stay for
# the next run.
on_exit() {
oe_status=$?
for oe_pid in $running_jobs ${b_pid:-} ${u_pid:-}; do kill_tree "$oe_pid"; done
screen_off
if [ -n "${quiet_exit:-}" ]; then printf '%s\n' "$quiet_exit"
elif [ "$oe_status" = 0 ]; then
if [ -n "$home" ] && [ -x "$home/makepad" ]; then printf 'Makepad Builder closed. Start it again with %s/makepad\n' "$home"
else printf 'Makepad Builder closed.\n'; fi
elif [ "$oe_status" != 130 ]; then
printf 'The Makepad Builder stopped unexpectedly; the details are in %s\n' "${log_file:-builder.log}" >&2
fi
}
trap on_exit EXIT
trap 'exit 130' INT TERM HUP
screen_on
# The terminal's background colour (OSC 11), or COLORFGBG: light terminals
# get light greys for the band and the logo. Dark when nobody says.
light_background() {
case "${COLORFGBG:-}" in *';7' | *';15') band=$band_light logo_color=$logo_light; return 0 ;; *';'*) return 0 ;; esac
[ -n "${BASH_VERSION:-}" ] || return 0
printf '%s]11;?\007' "$e"
lb_reply=
IFS= read -rs -d "$(printf '\007')" -t 1 lb_reply <&3 || :
case "$lb_reply" in *rgb:*) ;; *) return 0 ;; esac
lb_rgb=${lb_reply#*rgb:}
lb_r=${lb_rgb%%/*}; lb_rgb=${lb_rgb#*/}; lb_g=${lb_rgb%%/*}; lb_b=${lb_rgb#*/}
lb_r=${lb_r:0:2} lb_g=${lb_g:0:2} lb_b=${lb_b:0:2}
case "$lb_r$lb_g$lb_b" in *[!0-9a-fA-F]* | '') return 0 ;; esac
# Relative luminance on a 0-255 scale: light above the middle.
if [ $(( (2126 * 16#$lb_r + 7152 * 16#$lb_g + 722 * 16#$lb_b) / 10000 )) -gt 127 ]; then
band=$band_light logo_color=$logo_light
fi
}
light_background
size() {
set -- $(stty size <&3 2>/dev/null || echo 24 80)
height=${1:-24} width=${2:-80}
[ "$width" -le 80 ] || width=80
}
# Display width of plain text (UTF-8 continuation bytes do not count).
dwidth() { printf '%s' "$1" | LC_ALL=C tr -d '\200-\277' | wc -c | tr -d ' '; }
polling=0
key_cr=$(printf '\r') key_del=$(printf '\177') key_bs=$(printf '\010') key_nak=$(printf '\025')
# key: one key press as $key. Under bash (macOS's /bin/sh, and the Builder
# re-runs itself under /bin/bash where dash started it) the read builtin
# takes it, so no process runs while the Builder waits; only a system
# without bash at all reads one byte at a time with dd. Polling (a measurement still
# running) returns key=none after about a second.
key_read() { # key_read COUNT WAIT -> kr (WAIT: 0 blocks, tick a fifth of a second, else seconds)
if [ "$2" = tick ]; then
# bash 4 reads with a fractional timeout; bash 3.2 (macOS) and
# plain sh let the terminal time the read out instead.
if [ "${BASH_VERSINFO:-0}" -ge 4 ] 2>/dev/null; then
kr=
IFS= read -rsn "$1" -d '' -t 0.2 kr <&3; kr_status=$?
[ "$kr_status" -le 128 ] || kr_status=142
return "$kr_status"
fi
stty min 0 time 2 <&3 2>/dev/null
kr=$(dd bs=1 count="$1" <&3 2>/dev/null; printf x)
kr=${kr%x}
stty min 1 time 0 <&3 2>/dev/null
[ -n "$kr" ] || return 142
return 0
fi
if [ -n "${BASH_VERSION:-}" ]; then
kr=
if [ "$2" = 0 ]; then IFS= read -rsn "$1" -d '' kr <&3; kr_status=$?
else IFS= read -rsn "$1" -d '' -t "$2" kr <&3; kr_status=$?; fi
[ "$kr_status" -le 128 ] || kr_status=142
return "$kr_status"
fi
if [ "$2" = 0 ]; then stty min 1 time 0 <&3 2>/dev/null; else stty min 0 time $(($2 * 10)) <&3 2>/dev/null; fi
# No bash on this system at all: a byte-exact read (head would take the
# rest of an arrow key's bytes with it).
kr=$(dd bs=1 count="$1" <&3 2>/dev/null; printf x)
kr=${kr%x}
stty min 1 time 0 <&3 2>/dev/null
[ -n "$kr" ] || { [ "$2" = 0 ] && return 1; return 142; }
}
key() {
# Idle, the menu still looks up once a second (a resized window is
# drawn again); while work runs, a few times a second.
kw=0; [ "$polling" != 1 ] || kw=tick; [ "$polling" != idle ] || kw=1
# A timed wait also ends early when the window is resized (bash 3.2
# reports that like closed input); only input that keeps failing at
# once is closed.
key_read 1 "$kw" || {
kr_status=$?
if [ "$kr_status" = 142 ]; then key_fails=0; key=none; return; fi
if [ "$kw" != 0 ] && [ "${key_fails:-0}" -lt 50 ]; then key_fails=$((${key_fails:-0} + 1)); key=none; return; fi
exit 1
}
key_fails=0
k=$kr
case "$k" in
"$e")
key_read 2 1 || kr=
case "$kr" in '[A' | OA) key=up ;; '[B' | OB) key=down ;; '[C' | OC) key=right ;; '[D' | OD) key=left ;; '') key=esc ;; *) key=other ;; esac ;;
'
' | "$key_cr") key=enter ;;
"$key_del" | "$key_bs") key=backspace ;;
"$key_nak") key=clear ;;
'') key=enter ;;
*) key=$k ;;
esac
}
# ---------------------------------------------------------------- drawing ---
# A screen is a list of rows: "head|TITLE", "note|TEXT", "step|LABEL" or
# "item|ID|NAME|LICENSE|STATUS|ACTION"; ACTION "=" means the status already
# says what Return does, so only ⏎ is added.
message= choice= footer_override=
screen_meta() {
back=1 crumb= sub=
case "$screen" in
main) back=0 sub='Shipped as source code, so your coding agent can customize everything.' ;;
terms) crumb=' › License agreements' sub='Return opens an agreement in your browser.' ;;
consent) crumb=" › $consent_title" sub='Please read what you are agreeing to.' ;;
work) crumb=" › $work_title" sub=$work_sub ;;
signin) crumb=' › Log in' sub='Welcome to the Makepad Builder.' ;;
folder) crumb=' › Folder' sub='Everything the Builder installs goes into one folder.' ;;
xcode) crumb=' › Apple developer tools' sub='Needed to compile on macOS.' ;;
gpu) crumb=' › Graphics' sub='Before you continue.' ;;
packages) crumb=' › System packages' sub='Installed outside this folder, so please check them.' ;;
esac
default_footer='↑↓ move ⏎ select esc back q quit'
[ "$back" = 1 ] || default_footer='↑↓ move ⏎ select q quit'
case "$screen" in consent | gpu | xcode | packages) default_footer='↑↓ move ⏎ select esc cancel' ;; esac
}
screen_rows() {
case "$screen" in
main) main_rows ;;
terms) terms_rows ;;
consent) consent_rows ;;
work) printf 'note|\n'; printf '%s' "$work_labels" | sed 's/^/step|/' ;;
signin) signin_rows ;;
folder) folder_rows ;;
xcode) xcode_rows ;;
gpu) gpu_rows ;;
packages) packages_rows ;;
esac
}
# item_line NAME LICENSE STATUS ACTION SELECTED CHILD -> il. A row of an
# open node (CHILD 1) sits two columns in with a name column two shorter;
# on the main screen every row has the license column, so the statuses
# line up.
item_line() {
if [ "${6:-0}" = 1 ]; then il_in=' '; pad "$1" 13; else il_in=; pad "$1" 15; fi
if [ "$5" = 1 ]; then il=" ${mark}▌${r0} ${il_in}${b}${padded}${r0} "; else il=" ${il_in}${padded} "; fi
case "$2" in
commercial) il="${il}licensed " ;;
beta) il="${il}${warn}beta ${r0}" ;;
free) il="${il}${dim}free ${r0}" ;;
*) [ "$screen" != main ] || il="${il} " ;;
esac
il="${il}$3"
if [ "$5" = 1 ]; then
if [ "$4" = = ]; then il="${il} ${ok}⏎${r0}"
elif [ -n "$4" ]; then
if [ -z "$3" ]; then il="${il}${ok}$4 ⏎${r0}"; else il="${il} ${ok}$4 ⏎${r0}"; fi
fi
fi
}
spin_frame=0
# spin -> spin_c: the spinner's frame, one step per redraw.
spin() {
case $((spin_frame % 10)) in 0) spin_c=⠋ ;; 1) spin_c=⠙ ;; 2) spin_c=⠹ ;; 3) spin_c=⠸ ;; 4) spin_c=⠼ ;; 5) spin_c=⠴ ;; 6) spin_c=⠦ ;; 7) spin_c=⠧ ;; 8) spin_c=⠇ ;; *) spin_c=⠏ ;; esac
}
# doing_text STEP-FILE DEFAULT -> ls: the spinner, what the work does now,
# a short bar when its share is known and the amount (the Windows Builder
# draws the same).
doing_text() {
dt_state= dt_a=0 dt_b=0 dt_unit= dt_bytes=0 dt_doing=
IFS='|' read -r dt_state dt_a dt_b dt_unit dt_bytes dt_doing 2>/dev/null < "$1" || :
spin
ls="${acc}${spin_c}${r0} "
if [ "$dt_state" != now ]; then ls="$ls$2"; return 0; fi
case "$dt_doing" in resolving | '') dt_doing=compiling ;; downloading | compiling | finishing) ;; *) [ "$dt_unit" != MB ] || dt_doing=unpacking ;; esac
ls="$ls$dt_doing"
if [ "${dt_b:-0}" -gt 0 ] 2>/dev/null; then
dt_fill=$(( (dt_a * 12 + dt_b / 2) / dt_b )); [ "$dt_fill" -le 12 ] || dt_fill=12
dt_on= dt_off= dt_i=0
while [ "$dt_i" -lt 12 ]; do
if [ "$dt_i" -lt "$dt_fill" ]; then dt_on="${dt_on}━"; else dt_off="${dt_off}─"; fi
dt_i=$((dt_i + 1))
done
ls="$ls ${mark}${dt_on}${r0}${dim}${dt_off}${r0}"
if [ "$dt_unit" = crates ]; then ls="$ls ${dim}$dt_a / $dt_b crates${r0}"
elif [ "$dt_unit" = MB ] && [ "${dt_bytes:-0}" -gt 0 ]; then
ls="$ls ${dim}$((dt_a * dt_bytes / dt_b / 1048576)) / $((dt_bytes / 1048576)) MB${r0}"
fi
elif [ "$dt_unit" = crates ] && [ "${dt_a:-0}" -gt 0 ]; then
ls="$ls ${dim}$dt_a crates${r0}"
fi
}
# live_status ID -> ls: the row's status while background work involves it
# (compiling or downloading with its progress, its place in the queue, the
# update check); fails for any other row.
live_status() {
ls=
case "$1" in
updates) [ -n "$u_pid" ] || return 1; doing_text "$steps_dir/_update" 'checking for updates…' ;;
app:*)
if [ -n "$b_pid" ] && [ "$1" = "app:$b_app" ]; then
if [ "$b_stopping" = 1 ]; then spin; ls="${acc}${spin_c}${r0} stopping…"
else doing_text "$steps_dir/_build" "$b_starting"; fi
else
queue_place "${1#app:}" || return 1
if [ "$qp" = 0 ]; then ls="${acc}◌${r0} queued ${dim}· next${r0}"
else ls="${acc}◌${r0} queued ${dim}· $((qp + 1)) to go${r0}"; fi
fi ;;
*) return 1 ;;
esac
}
# info_line ID INFO -> li: the line under the rule when no message shows:
# what the selected row does, or, while an app compiles, what happens with it.
info_line() {
li=$2
[ -n "$b_pid" ] || return 0
if [ "$1" = "app:$b_app" ]; then
if [ "$b_stopping" = 1 ]; then li='Stopping the compiler; what was downloaded and compiled is kept.'
elif [ "$b_open" = 1 ]; then li='Compiling from source. It opens by itself when it is done.'
else li='Compiling from source. It only compiles; press s or ⏎ to open it when done.'; fi
elif case "$1" in app:*) queue_place "${1#app:}" ;; *) false ;; esac; then
if [ "$qp_open" = 1 ]; then li="Queued behind $b_title; it compiles and opens after it."
else li="Queued behind $b_title; it only compiles. Press s or ⏎ to open it when done."; fi
elif [ "$b_stopping" = 1 ]; then li="${dim}$b_title is stopping.${r0}"
elif [ "$b_open" = 1 ]; then li="${dim}$b_title opens by itself when it is done compiling.${r0}"
else li="${dim}$b_title is compiling; it only compiles and does not open.${r0}"; fi
}
# menu_keys ID -> mk: the main menu's keys; s and c only where the selected
# row has work to change, their letters in orange.
menu_keys() {
mk_open= mk_cancel=
if [ -n "$b_pid" ] && [ "$1" = "app:$b_app" ]; then
[ "$b_stopping" = 1 ] || mk_open=$b_open mk_cancel=1
elif case "$1" in app:*) queue_place "${1#app:}" ;; *) false ;; esac; then
mk_open=$qp_open mk_cancel=1
elif [ "$1" = all ]; then
queue_batch
[ "$qb" = 0 ] || mk_cancel=1
fi
mk="${dim}↑↓ move ⏎ select ${r0}"
if [ "$mk_open" = 1 ]; then mk="${mk}${key_c}s${r0}${dim} don't start ${r0}"
elif [ "$mk_open" = 0 ]; then mk="${mk}${key_c}s${r0}${dim} start when done ${r0}"; fi
[ -z "$mk_cancel" ] || mk="${mk}${key_c}c${r0}${dim} cancel ${r0}"
mk="${mk}${dim}q quit${r0}"
}
# wrap_status TEXT WIDTH -> ws_1 ws_2: a status line longer than WIDTH
# broken at a space onto a second line (after a leading ✓ or ✗ the second
# line starts under the text); what still does not fit ends in "…". Plain
# sh (no bash) leaves it as it is.
wrap_status() {
ws_1=$1 ws_2= ws_width=$2
[ -n "${BASH_VERSION:-}" ] || return 0
ws_plain=${1//$e\[*([0-9;])m/}
[ "${#ws_plain}" -gt "$2" ] || return 0
ws_hang=
case "$ws_plain" in '✓ '* | '✗ '*) ws_hang=' ' ;; esac
ws_1= ws_n=0 ws_line=1
set -f
# shellcheck disable=SC2086 # split into words on purpose
set -- $1
set +f
for ws_word in "$@"; do
ws_wp=${ws_word//$e\[*([0-9;])m/}
ws_add=${#ws_wp}; [ "$ws_n" = 0 ] || ws_add=$((ws_add + 1))
if [ "$ws_line" = 1 ] && [ $((ws_n + ws_add)) -gt "$ws_width" ] && [ "$ws_n" -gt 0 ]; then
ws_line=2 ws_n=${#ws_hang}
# The colour in force at the break carries over.
ws_tail=${ws_1##*"$e["}
ws_carry=
[ "$ws_tail" = "$ws_1" ] || { ws_code=${ws_tail%%m*}; [ "$ws_code" = 0 ] || ws_carry="$e[${ws_code}m"; }
ws_2="$ws_hang$ws_carry$ws_word" ws_n=$((ws_n + ${#ws_wp}))
continue
fi
if [ "$ws_line" = 2 ] && [ $((ws_n + ws_add)) -gt "$ws_width" ]; then ws_2="$ws_2…"; break; fi
if [ "$ws_line" = 1 ]; then
if [ "$ws_n" = 0 ]; then ws_1=$ws_word; else ws_1="$ws_1 $ws_word"; fi
else
ws_2="$ws_2 $ws_word"
fi
ws_n=$((ws_n + ws_add))
done
ws_1="$ws_1$r0" ws_2="$ws_2$r0"
}
# The line of a work step, from its state file.
step_line() { # step_line LABEL -> sl
step_key "$1"
sl_state= sl_a= sl_b= sl_unit= sl_bytes= sl_doing=
IFS='|' read -r sl_state sl_a sl_b sl_unit sl_bytes sl_doing < "$steps_dir/$step_key" 2>/dev/null || sl_state=next
pad "$1 " 16
case "$sl_state" in
done) sl=" ${ok}✓${r0} ${padded}${dim}${sl_a}${r0}" ;;
failed) sl=" ${red}✗${r0} ${padded}${red}${sl_a}${r0}" ;;
held) sl=" ${warn}◌${r0} ${padded}${dim}${sl_a}${r0}" ;;
now)
sl=" ${acc}●${r0} ${b}${padded}${r0}"
sl_detail=
if [ "${sl_b:-0}" -gt 0 ]; then
if [ "$sl_unit" = crates ]; then sl_width=30; else sl_width=16; fi
sl_fill=$((sl_a * sl_width / sl_b)); [ "$sl_fill" -le "$sl_width" ] || sl_fill=$sl_width
sl_on= sl_off= sl_i=0
while [ "$sl_i" -lt "$sl_width" ]; do
if [ "$sl_i" -lt "$sl_fill" ]; then sl_on="${sl_on}━"; else sl_off="${sl_off}─"; fi
sl_i=$((sl_i + 1))
done
sl_pct=$((sl_a * 100 / sl_b)); [ "$sl_pct" -le 100 ] || sl_pct=100
sl_p=" $sl_pct"; sl_p=${sl_p#"${sl_p%???}"}
sl="${sl}${mark}${sl_on}${r0}${dim}${sl_off}${r0} ${sl_p}%"
if [ "$sl_unit" = crates ]; then
sl_detail="$sl_a / $sl_b crates"
elif [ "$sl_unit" = MB ] && [ "${sl_bytes:-0}" -gt 0 ]; then
sl_mb_total=$((sl_bytes / 1048576))
sl_mb_done=$((sl_a * sl_mb_total / sl_b))
[ "$sl_mb_total" = 0 ] || sl_detail="$sl_mb_done/$sl_mb_total MB"
# Throughput and time left while it downloads.
sl_t=$(now)
eval "sl_t0=\${rate_t_$step_key:-} sl_b0=\${rate_b_$step_key:-}"
if [ -z "$sl_t0" ] || [ "$sl_doing" != downloading ]; then
eval "rate_t_$step_key=\$sl_t rate_b_$step_key=\$sl_mb_done"
elif [ "$sl_t" -gt "$sl_t0" ]; then
sl_rate=$(( (sl_mb_done - sl_b0) / (sl_t - sl_t0) ))
if [ "$sl_rate" -gt 0 ]; then
sl_left=$(( (sl_mb_total - sl_mb_done) / sl_rate ))
sl_detail="$sl_detail · $sl_rate MB/s"
[ "$sl_left" -lt 1 ] || sl_detail="$sl_detail · $sl_left s left"
fi
fi
fi
else
spin_frame=$((${spin_frame:-0} + 1))
case $((spin_frame % 10)) in 0) sl_c=⠋ ;; 1) sl_c=⠙ ;; 2) sl_c=⠹ ;; 3) sl_c=⠸ ;; 4) sl_c=⠼ ;; 5) sl_c=⠴ ;; 6) sl_c=⠦ ;; 7) sl_c=⠧ ;; 8) sl_c=⠇ ;; *) sl_c=⠏ ;; esac
sl="${sl}${acc}${sl_c}${r0}"
# No total yet: the crates compiled so far still count up.
[ "$sl_unit" != crates ] || [ "${sl_a:-0}" = 0 ] || sl_detail="$sl_a crates"
fi
[ -z "$sl_doing" ] || sl_detail="${sl_detail:+$sl_detail · }$sl_doing"
[ -z "$sl_detail" ] || sl="${sl} ${dim}${sl_detail}${r0}" ;;
*) sl=" ${dim}○ ${padded}${r0}" ;;
esac
}
# logo_lines LINES BAND -> ll_out: LINES as screen lines, each ending in an
# erase to the end of the line; line BAND (0-based, -1 for none) on the
# selection band. The Makepad mark goes faintly at the right of the window
# (up to 100 columns), in the middle of the stretch or as near to it as it
# fits beside every line's text with two columns of air: the large one when
# it fits somewhere, else the small one, else none, so it is never cut into.
# The selected row's "<action> ⏎" does not count (it moves with the
# selection); where it reaches into the mark, the text wins. Plain sh (no
# bash) draws no band and no mark.
logo_lines() { # logo_lines LINES BAND [SIZES] (the last line stays empty)
ll_out= ll_lines=$1 ll_band=$2 ll_sizes=${3:-big small}
if [ -z "${BASH_VERSION:-}" ]; then
while IFS= read -r ll_line; do ll_out="$ll_out$ll_line$e[K
"; done <<EOF
$ll_lines
EOF
return 0
fi
# Where each line's own text ends, plus two columns of air.
ll_clear= ll_count=0
while IFS= read -r ll_line; do
if [ "$ll_count" = "$ll_band" ]; then
case "$ll_line" in *"⏎$r0") ll_line=${ll_line%"$ok"*} ;; esac
fi
ll_plain=${ll_line//$e\[*([0-9;])m/}
ll_plain=${ll_plain%%+( )}
if [ -n "$ll_plain" ]; then ll_clear="$ll_clear $(( ${#ll_plain} + 2 ))"; else ll_clear="$ll_clear 0"; fi
ll_count=$((ll_count + 1))
done <<EOF
$ll_lines
EOF
ll_cols=$(stty size <&3 2>/dev/null); ll_cols=${ll_cols#* }; ll_cols=${ll_cols:-80}
[ "$ll_cols" -le 100 ] || ll_cols=100
ll_art= ll_y0=-1 ll_x0=0
ll_room=$((ll_count - 1))
for ll_size in $ll_sizes; do
if [ "$ll_size" = big ]; then ll_h=13 ll_w=44 ll_ink=$logo_big_ink; else ll_h=9 ll_w=30 ll_ink=$logo_small_ink; fi
ll_x0=$((ll_cols - 2 - ll_w))
[ "$ll_x0" -ge 30 ] && [ "$ll_h" -le "$ll_room" ] || continue
ll_mid=$(( (ll_room - ll_h) / 2 )) ll_d=0
while [ "$ll_d" -le "$ll_room" ]; do
for ll_y in $((ll_mid - ll_d)) $((ll_mid + ll_d)); do
[ "$ll_y" -ge 0 ] && [ $((ll_y + ll_h)) -le "$ll_room" ] || continue
# shellcheck disable=SC2086
set -- $ll_clear
shift "$ll_y"
ll_fit=1
for ll_i in $ll_ink; do
[ "$1" -le $((ll_x0 + ll_i)) ] || { ll_fit=0; break; }
shift
done
if [ "$ll_fit" = 1 ]; then ll_y0=$ll_y; break 3; fi
done
ll_d=$((ll_d + 1))
done
done
if [ "$ll_y0" -ge 0 ]; then
if [ "$ll_size" = big ]; then ll_art=$logo_big; else ll_art=$logo_small; fi
fi
ll_i=0
while IFS= read -r ll_line; do
ll_piece=
if [ -n "$ll_art" ] && [ "$ll_i" -ge "$ll_y0" ] && [ "$ll_i" -lt $((ll_y0 + ll_h)) ]; then
ll_n=$((ll_i - ll_y0))
while IFS= read -r ll_a; do
[ "$ll_n" -gt 0 ] || { ll_piece=$ll_a; break; }
ll_n=$((ll_n - 1))
done <<EOF
$ll_art
EOF
fi
ll_line=${ll_line%"$r0"}; ll_line=${ll_line%%+( )}
ll_plain=${ll_line//$e\[*([0-9;])m/}
[ "$ll_line" = "$ll_plain" ] || ll_line="$ll_line$r0"
# The mark's part of this line: from two columns after the text.
ll_at=$ll_x0
if [ -n "${ll_piece// /}" ]; then
ll_skip=$(( ${#ll_plain} + 2 - ll_x0 )); [ "$ll_skip" -ge 0 ] || ll_skip=0
ll_piece=${ll_piece:$ll_skip}; ll_at=$((ll_x0 + ll_skip))
[ -n "${ll_piece// /}" ] || ll_piece=
else
ll_piece=
fi
if [ "$ll_i" = "$ll_band" ]; then
# The band: every colour reset goes back to the band, which
# reaches the rule's right end; the mark's dots lie on it.
ll_band_end=$((width - 2))
ll_line="${ll_line:0:2}$band${ll_line:2}"
ll_line=${ll_line//$r0/$r0$band}
ll_used=${#ll_plain}
if [ -n "$ll_piece" ]; then
ll_pad=$((ll_at - ll_used)); while [ "$ll_pad" -gt 0 ]; do ll_line="$ll_line "; ll_pad=$((ll_pad - 1)); done
ll_on=$((ll_band_end - ll_at)); [ "$ll_on" -ge 0 ] || ll_on=0
ll_line="$ll_line$logo_color${ll_piece:0:$ll_on}$r0"
ll_rest=${ll_piece:$ll_on}
ll_used=$((ll_at + ${#ll_piece}))
if [ -n "$ll_rest" ]; then ll_line="$ll_line$logo_color$ll_rest"
else ll_line="$ll_line$band"; fi
fi
ll_pad=$((ll_band_end - ll_used)); while [ "$ll_pad" -gt 0 ]; do ll_line="$ll_line "; ll_pad=$((ll_pad - 1)); done
ll_line="$ll_line$r0"
elif [ -n "$ll_piece" ]; then
ll_pad=$((ll_at - ${#ll_plain})); while [ "$ll_pad" -gt 0 ]; do ll_line="$ll_line "; ll_pad=$((ll_pad - 1)); done
ll_line="$ll_line$logo_color$ll_piece$r0"
fi
ll_out="$ll_out$ll_line$e[K
"
ll_i=$((ll_i + 1))
done <<EOF
$ll_lines
EOF
}
draw() {
size
screen_meta
rows_out=$(screen_rows)
spin_frame=$((spin_frame + 1))
# The header, then the rows: everything above the footer scrolls as one.
# The email shows on the Account row, not in the header.
body="
${b}Makepad${r0} Apps${crumb}
${dim}${sub}${r0}
" nbody=3 sel_line=-1 d_item=0 d_first=1 first_item=-1 item_lines= d_sel_id= d_info=
while IFS='|' read -r d_kind d_1 d_2 d_3 d_4 d_5 d_6; do
case "$d_kind" in
head)
if [ "$d_first" = 0 ] || [ "$back" = 0 ]; then body="$body
"; nbody=$((nbody + 1)); fi
body="$body ${dim}${d_1}${r0}
"; nbody=$((nbody + 1)) ;;
note) body="$body ${d_1}
"; nbody=$((nbody + 1)) ;;
step) step_line "$d_1"; body="$body$sl
"; nbody=$((nbody + 1)) ;;
item | sub)
if [ "$d_item" = 0 ] && [ "$back" = 1 ]; then body="$body
"; nbody=$((nbody + 1)); fi
[ "$first_item" -ge 0 ] || first_item=$nbody
item_lines="$item_lines $nbody"
d_sel=0; [ "$d_item" != "$sel" ] || { d_sel=1; sel_line=$nbody; d_sel_id=$d_1 d_info=$d_6; }
# Work in progress on the row: no action; its keys are in the footer.
if [ "$screen" = main ] && live_status "$d_1"; then d_4=$ls d_5=; fi
d_child=0; [ "$d_kind" != sub ] || d_child=1
item_line "$d_2" "$d_3" "$d_4" "$d_5" "$d_sel" "$d_child"
body="$body$il
"; nbody=$((nbody + 1)); d_item=$((d_item + 1)) ;;
esac
d_first=0
done <<EOF
$rows_out
EOF
items=$d_item
# The footer (the rule, two status lines, the keys) keeps the last four
# lines; one line above it stays empty or says what is scrolled away.
room=$((height - 5))
[ "$room" -ge 3 ] || room=3
if [ "$nbody" -le "$room" ]; then top=0; else
if [ "$sel_line" -ge 0 ]; then
# The first row brings the header back into view.
[ "$sel_line" -gt "$first_item" ] || top=0
[ "$sel_line" -ge "$top" ] || top=$sel_line
[ "$sel_line" -lt $((top + room)) ] || top=$((sel_line - room + 1))
fi
[ "$top" -le $((nbody - room)) ] || top=$((nbody - room))
fi
# The lines shown; the mark lies beside the rows, not the header.
d_i=0 d_shown=0 d_head= d_lines= d_band=-1 d_region=0
while IFS= read -r d_line; do
if [ "$d_i" -ge "$top" ] && [ "$d_shown" -lt "$room" ] && [ "$d_i" -lt "$nbody" ]; then
if [ "$top" = 0 ] && [ "$d_i" -lt 3 ]; then
d_head="$d_head$d_line${e}[K
"
else
[ "$d_i" != "$sel_line" ] || d_band=$d_region
d_lines="$d_lines$d_line
"
d_region=$((d_region + 1))
fi
d_shown=$((d_shown + 1))
fi
d_i=$((d_i + 1))
done <<EOF
$body
EOF
d_last=' '
if [ "$nbody" -gt "$room" ]; then
d_above=0 d_below=0
for d_n in $item_lines; do
if [ "$d_n" -lt "$top" ]; then d_above=$((d_above + 1))
elif [ "$d_n" -ge $((top + room)) ]; then d_below=$((d_below + 1)); fi
done
if [ "$d_above" = 0 ]; then d_last=" ${dim}↓ $d_below more${r0}"
elif [ "$d_below" = 0 ]; then d_last=" ${dim}↑ $d_above above${r0}"
else d_last=" ${dim}↑ $d_above above · ↓ $d_below more${r0}"; fi
fi
# The mark sits beside the rows where the large or the small one fits,
# else the rule moves down just far enough for the small one (a short
# page like Log in gets it under its text), else there is none.
logo_lines "$d_lines$d_last" "$d_band"
if [ -n "${BASH_VERSION:-}" ] && [ -z "$ll_art" ]; then
d_more=$d_lines d_n=$d_shown
while [ "$d_n" -lt "$room" ]; do
d_more="$d_more
"; d_n=$((d_n + 1))
logo_lines "$d_more$d_last" "$d_band" small
[ -z "$ll_art" ] || break
done
[ -n "$ll_art" ] || logo_lines "$d_lines$d_last" "$d_band"
fi
d_rule=; d_i=4; while [ "$d_i" -lt "$width" ]; do d_rule="${d_rule}─"; d_i=$((d_i + 1)); done
# A status message longer than the line goes on onto the choice line
# when that is free. With no message, the main menu says what the
# selected row does; its keys depend on the row too.
d_message=$message d_footer=${footer_override:-$default_footer}
if [ "$screen" = main ] && [ -z "$footer_override" ]; then
[ -n "$message" ] || [ -n "$choice" ] || { info_line "$d_sel_id" "$d_info"; d_message=$li; }
menu_keys "$d_sel_id"; d_footer=$mk
else
d_footer="${dim}${d_footer}${r0}"
fi
d_choice=$choice
if [ -z "$choice" ]; then wrap_status "$d_message" $((width - 4)); d_message=$ws_1 d_choice=$ws_2; fi
printf '%s' "${e}[H$d_head$ll_out ${dim}${d_rule}${r0}${e}[K
${d_message}${e}[K
${d_choice}${e}[K
${d_footer}${e}[K${e}[J"
}
# The work page's steps, redrawn in place about ten times a second.
work_redraw() {
[ "$screen" = work ] || return 0
wr_row=5 wr_out=
while IFS= read -r wr_label; do
[ -n "$wr_label" ] || continue
step_line "$wr_label"
wr_out="$wr_out${e}[${wr_row};1H$sl${e}[K"
wr_row=$((wr_row + 1))
done <<EOF
$work_labels
EOF
printf '%s' "$wr_out"
}
busy() { message="${acc}⠋${r0} $1"; draw; }
# choose QUESTION NOTE DEFAULT OPTION...: the question on the status line and
# the options under it; ←→ or a first letter picks, ⏎ accepts, esc backs out.
choose() {
ch_q=$1 ch_note=$2 ch_pick=$3; shift 3
ch_saved_footer=$footer_override
while :; do
choice=
for ch_opt in "$@"; do
if [ "$ch_opt" = "$ch_pick" ]; then choice="${choice}${inv} ${ch_opt} ${r0} "; else choice="${choice} ${ch_opt} "; fi
done
[ -z "$ch_note" ] || choice="${choice} ${dim}${ch_note}${r0}"
message=$ch_q footer_override='←→ choose ⏎ accept esc back'
draw
key
case "$key" in
enter) chosen=$ch_pick; break ;;
esc | q) chosen=; break ;;
left | up | right | down)
ch_prev= ch_next= ch_found=
for ch_opt in "$@"; do
if [ -n "$ch_found" ] && [ -z "$ch_next" ]; then ch_next=$ch_opt; fi
[ "$ch_opt" != "$ch_pick" ] || ch_found=1
[ -n "$ch_found" ] || ch_prev=$ch_opt
done
case "$key" in left | up) [ -z "$ch_prev" ] || ch_pick=$ch_prev ;; *) [ -z "$ch_next" ] || ch_pick=$ch_next ;; esac ;;
*)
ch_hit=
for ch_opt in "$@"; do
case "$ch_opt" in "$key"*) ch_hit=$ch_opt; break ;; esac
done
if [ -n "$ch_hit" ]; then chosen=$ch_hit; break; fi ;;
esac
done
message= choice= footer_override=$ch_saved_footer
[ -n "$chosen" ]
}
# edit PROMPT INITIAL HINT FOOTER: a one-line editor on the status line.
# Escape returns 1. Sets edited.
edit() {
edited=$2
ed_saved_footer=$footer_override
while :; do
message="$1 ${b}${edited}${r0}${inv} ${r0}" choice=$3 footer_override=$4
draw
key
case "$key" in
enter) break ;;
esc) edited=; message= choice= footer_override=$ed_saved_footer; return 1 ;;
backspace) edited=$(printf '%s' "$edited" | sed 's/.$//') ;;
clear) edited= ;;
up | down | left | right | other) ;;
*) [ "${#edited}" -ge 200 ] || edited="$edited$key" ;;
esac
done
edited=$(printf '%s' "$edited" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
message= choice= footer_override=$ed_saved_footer
return 0
}
select_row() {
sr_row=$(printf '%s\n' "$rows_out" | grep -E '^(item|sub)\|' | sed -n "$((sel + 1))p")
IFS='|' read -r sr_kind id title sr_lic sr_status action sr_info <<EOF
$sr_row
EOF
}
# Hand the terminal to Apple, sudo, an agent or a shell, and take it back.
pause_screen() { screen_off; }
resume_screen() { screen_on; }
# ------------------------------------------------------------ work pages ---
work_begin() { # work_begin TITLE SUBTITLE STEP...
w_screen=$screen w_sel=$sel w_top=$top
work_title=$1 work_sub=$2; shift 2
work_labels= work_labels_keys=
for wb_label in "$@"; do work_add "$wb_label"; done
screen=work message= choice= footer_override='working · ctrl+c stops' top=0
draw
}
work_add() {
work_labels="$work_labels$1
"
step_key "$1"; work_labels_keys="$work_labels_keys $step_key"
put "$steps_dir/$step_key" next
[ "$screen" != work ] || draw
}
# Keys pressed and trackpad scrolls (arrow keys in a terminal) during work
# are not answers: throw away what is waiting, so the menu keeps its row.
drain_keys() {
stty min 0 time 0 <&3 2>/dev/null || return 0
dd bs=256 count=64 <&3 >/dev/null 2>&1 || :
stty min 1 time 0 <&3 2>/dev/null || :
}
# work_end ok|failed MESSAGE: a failure stays on the page, marked on its
# step, until Return or Escape.
work_end() {
drain_keys
if [ "$1" != ok ]; then
footer_override='⏎ back'
draw
while :; do key; case "$key" in enter | esc | q) break ;; esac; done
fi
footer_override= screen=$w_screen sel=$w_sel top=$w_top message=$2 choice=
}
# The first failed step's reason, for the status line.
work_failure() {
wf=
for wf_key in $work_labels_keys; do
IFS='|' read -r wf_state wf_reason wf_rest < "$steps_dir/$wf_key" 2>/dev/null || continue
if [ "$wf_state" = failed ]; then wf=$wf_reason; return; fi
done
}
# ---------------------------------------------------------------- screens ---
# wrap STYLE TEXT: note rows of at most 74 columns.
wrap() {
printf '%s\n' "$2" | awk -v style="$1" -v r0="$r0" -v w=74 '{
n = split($0, a, " "); line = ""
for (i = 1; i <= n; i++) {
if (line != "" && length(line) + 1 + length(a[i]) > w) { print "note|" style line (style == "" ? "" : r0); line = a[i] }
else line = (line == "" ? a[i] : line " " a[i])
}
if (line != "") print "note|" style line (style == "" ? "" : r0)
}'
}
signin_rows() {
printf 'note|\n'
wrap '' 'Enter the email address you bought your Makepad product with, or have beta access for. Leave it empty for the free apps.'
}
folder_rows() {
printf 'note|\n'
wrap '' "The apps' sources, their build output and a private Rust go into one folder of their own. Nothing outside it changes, except Apple's developer tools or your distribution's packages, which the Builder asks about first."
printf 'note|\n'
wrap "$dim" 'A folder the Builder used before is reused as it is: its email, apps and choices stay.'
}
gpu_notice='Makepad relies on your GPU to draw its UI and implement AI functionality just like a videogame does. Old hardware and broken drivers can cause your computer to reboot unexpectedly.'
gpu_rows() {
printf 'note|\n'
wrap '' "$gpu_notice"
printf '%s\n' 'item|continue|I understand|||' 'item|cancel|Cancel|||'
}
xcode_rows() {
printf 'note|\n'
if [ "$tools_problem" = license ]; then
wrap '' "Xcode is installed, but its license has not been accepted yet, so Apple's compiler will not run."
printf 'note|\n'
wrap "$dim" 'Apple shows the license here in the terminal and asks for your password; type agree at the end to accept it.'
pad 'Read and accept the Xcode license' 34
printf 'item|license|%s|||sudo xcodebuild -license\n' "$padded"
else
wrap '' "Makepad compiles with Apple's command line developer tools: clang, the macOS SDK and git. They are not installed on this Mac yet."
printf 'note|\n'
wrap "$dim" "Apple's installer opens in its own window (about 1 GB); come back here when it has finished."
pad 'Install the developer tools' 34
printf "item|install|%s|||opens Apple's installer\n" "$padded"
fi
printf '%s\n' 'item|check|Check again|||' 'item|cancel|Cancel|||'
}
packages_rows() {
printf 'note|\n'
set -- $packages_cmd
pr_manager=$1 pr_list= pr_count=0 pr_on=
for pr_word in "$@"; do
case "$pr_word" in
install | -S) pr_on=1 ;;
-*) ;;
*) if [ -n "$pr_on" ]; then pr_list="$pr_list $pr_word"; pr_count=$((pr_count + 1)); fi ;;
esac
done
wrap '' "Makepad compiles from source and needs $pr_count development packages from $distro_name. They are installed system-wide by your package manager:"
printf 'note|\n'
printf '%s\n' $pr_list | paste -d' ' - - - - - | sed "s/^/note|${dim} /; s/\$/${r0}/"
printf 'note|\n'
printf 'item|install|Install with sudo|||sudo %s\n' "$pr_manager"
printf 'item|cancel|Cancel|||\n'
}
# ------------------------------------------------------------ agreements ---
agreements='makepad|Makepad commercial license|https://makepad.nl/commercial-license
rust|Rust|https://www.rust-lang.org/policies/licenses'
accepted_agreements() {
if [ -f "$root/agreements-accepted" ]; then
accepted=$(tr '\n' ' ' < "$root/agreements-accepted")
elif [ "${m_rust_ok:-0}" = 1 ] && [ -z "${tools_problem:-}" ]; then
# Folders set up before this record accepted them when installing.
accepted='makepad rust'
else
accepted=
fi
}
is_accepted() { case " $accepted " in *" $1 "*) return 0 ;; esac; return 1; }
record_agreements() { put "$root/agreements-accepted" "$(printf '%s\n' "$@")"; accepted="$*"; }
host_of() { host=${1#https://}; host=${host%%/*}; }
open_url() {
if [ "$plat" = mac ]; then open "$1" >/dev/null 2>&1 || return 1
else xdg-open "$1" >/dev/null 2>&1 || return 1; fi
}
open_agreement() {
oa_url=$(printf '%s\n' "$agreements" | awk -F'|' -v id="$1" '$1 == id { print $3 }')
if open_url "$oa_url"; then message="${ok}✓${r0} Opened ${oa_url#https://} in your browser."
else message="${warn}Could not open $oa_url${r0}"; fi
}
terms_rows() {
accepted_agreements
while IFS='|' read -r tr_id tr_name tr_url; do
host_of "$tr_url"; pad "$tr_name" 36
if is_accepted "$tr_id"; then tr_state="${ok}✓${r0} accepted ${dim}${host}${r0}"; else tr_state="${warn}not accepted ${r0}${dim}${host}${r0}"; fi
printf 'item|url:%s|%s||%s|open in browser\n' "$tr_id" "$padded" "$tr_state"
done <<EOF
$agreements
EOF
printf '%s\n' 'note|' 'item|agree|Agree to all|||accept every agreement above' 'item|disagree|Disagree|||withdraw acceptance'
}
terms_screen() {
t_screen=$screen t_sel=$sel t_top=$top
screen=terms sel=0 top=0 message=
while :; do
draw; key; message=
case "$key" in
up) sel=$(( (sel + items - 1) % items )) ;;
down) sel=$(( (sel + 1) % items )) ;;
esc) break ;;
q) exit 0 ;;
enter)
select_row
case "$id" in
agree) record_agreements makepad rust; message="${ok}✓${r0} Agreements accepted." ;;
disagree) record_agreements; message="${warn}Agreements withdrawn: installing or updating build tools will ask again.${r0}" ;;
url:*) open_agreement "${id#url:}" ;;
esac ;;
esac
done
t_message=$message
screen=$t_screen sel=$t_sel top=$t_top message=$t_message
}
# consent TITLE INTRO DETAIL ACTION ID...: one screen for the terms a step
# needs; Return opens an agreement, "Agree to all" (selected at the start)
# records them, Cancel or Escape backs out. Already accepted: no screen.
consent() {
accepted_agreements
c_title=$1 c_intro=$2 c_detail=$3 c_action=$4; shift 4
c_missing=
for c_id in "$@"; do is_accepted "$c_id" || c_missing="$c_missing $c_id"; done
[ -n "$c_missing" ] || return 0
c_screen=$screen c_sel=$sel c_top=$top c_message=$message
consent_title=$c_title consent_ids="$*"
screen=consent top=0 message=
sel=$#
c_result=1
while :; do
draw; key; message=
case "$key" in
up) sel=$(( (sel + items - 1) % items )) ;;
down) sel=$(( (sel + 1) % items )) ;;
esc | q) break ;;
enter)
select_row
case "$id" in
agree) record_agreements $accepted $c_missing; c_result=0; break ;;
cancel) break ;;
url:*) open_agreement "${id#url:}" ;;
esac ;;
esac
done
screen=$c_screen sel=$c_sel top=$c_top message=$c_message
return "$c_result"
}
consent_rows() {
printf 'note|\n'
wrap '' "$c_intro"
[ -z "$c_detail" ] || printf 'note|%s%s%s\n' "$dim" "$c_detail" "$r0"
printf 'head|READ THE AGREEMENTS\n'
while IFS='|' read -r cr_id cr_name cr_url; do
case " $consent_ids " in *" $cr_id "*) ;; *) continue ;; esac
host_of "$cr_url"; pad "$cr_name" 36
printf 'item|url:%s|%s||%s%s%s|open in browser\n' "$cr_id" "$padded" "$dim" "$host" "$r0"
done <<EOF
$agreements
EOF
printf 'note|\nitem|agree|Agree to all|||%s\nitem|cancel|Cancel|||\n' "$c_action"
}
# --------------------------------------------------------------- model ---
# scan: every row's state, read from the folder (after each action).
scan() {
rust_version
m_rust_ok=0 m_rust_external=
if [ -n "$rust_version" ] && rust_ready "$rust_version"; then
m_rust_ok=1
case "$selected_rust" in /*) m_rust_external=$rust_sysroot ;; esac
fi
m_license_rows=
if [ "$lic_state" = ok ] || [ -z "$email" ]; then sc_ids=$licensed_ids
else
sc_ids=
for sc_file in "$root"/available/*.json; do
[ -f "$sc_file" ] || continue
rel_load "$sc_file" 2>/dev/null || continue
[ "$rel_public" = true ] || sc_ids="$sc_ids $rel_id"
done
fi
for sc_id in $sc_ids; do
app_state "$sc_id"
rel_load "$root/available/$sc_id.json" 2>/dev/null || continue
state_texts "$state" "$rel_bytes"
if ! rel_supported; then st_text="${dim}not available for this platform yet${r0}" st_act=; fi
if is_stopped "$sc_id"; then st_text="${warn}stopped · continue${r0}" st_act='compile and run'; fi
app_info "$sc_id" "$state"
sc_lic=commercial; [ "$rel_license" != beta ] || sc_lic=beta
m_license_rows="${m_license_rows}item|app:$sc_id|$rel_title|$sc_lic|$st_text|$st_act|$ai
"
done
m_free_rows= m_free_total=0 m_free_ready=0 m_free_todo=
sc_free=$(free_apps)
while IFS='|' read -r sc_id sc_title; do
[ -n "$sc_id" ] || continue
app_state "$sc_id"
m_free_total=$((m_free_total + 1))
# One shared Makepad download: rows show only readiness; the action
# appears on the selected row.
case "$state" in
ready) m_free_ready=$((m_free_ready + 1)); sc_st="${ok}✓${r0} ready" sc_act=run ;;
compile) sc_st='needs compiling' sc_act=compile ;;
merge) sc_st="${warn}updated · your changes to merge${r0}" sc_act='merge with agent' ;;
*) sc_st= sc_act=download ;;
esac
if is_stopped "$sc_id"; then sc_st="${warn}stopped · continue${r0}" sc_act='compile and run'; fi
# What Compile all queues: every one that is not ready.
case "$state" in
ready | merge) if is_stopped "$sc_id"; then m_free_todo="$m_free_todo$sc_id|$sc_title
"; fi ;;
*) m_free_todo="$m_free_todo$sc_id|$sc_title
" ;;
esac
app_info "$sc_id" "$state"
m_free_rows="${m_free_rows}sub|app:$sc_id|$sc_title||$sc_st|$sc_act|$ai
"
done <<EOF
$sc_free
EOF
m_public_ready=0
if rel_load "$root/available/makepad.json" 2>/dev/null; then
rel_dir; m_public_mb=$(( (rel_bytes + 1048575) / 1048576 ))
! rel_installed || m_public_ready=1
fi
}
# The status column says where an app is, in a word or two; the action on
# the selected row says what Return does (each one ends with the app
# running).
state_texts() { # state_texts STATE BYTES -> st_text st_act (menu.txt)
st_mb=$(( (${2:-0} + 1048575) / 1048576 ))
case "$1" in
new) if [ "$st_mb" -gt 0 ]; then st_text="${dim}$st_mb MB${r0}"; else st_text="${dim}not downloaded${r0}"; fi; st_act=download ;;
partial) st_text="${warn}download stopped${r0}" st_act=resume ;;
compile) st_text='needs compiling' st_act=compile ;;
update) st_text="${warn}update available${r0}" st_act=update ;;
ready) st_text="${ok}✓${r0} ready" st_act=run ;;
merge) st_text="${warn}updated · your changes to merge${r0}" st_act='merge with agent' ;;
esac
}
main_rows() {
# Update sits above everything; the menu still opens on the first app.
printf 'note|\n'
case "$checked" in
'') printf 'item|updates|Update||%scheck for updates%s|=\n' "$dim" "$r0" ;;
0*) printf 'item|updates|Update||%s✓%s up to date · %s|check again\n' "$ok" "$r0" "${checked#* }" ;;
*) printf 'item|updates|Update||updates downloaded · %s|check again\n' "${checked#* }" ;;
esac
printf 'head|YOUR APPS\n'
if [ -z "$email" ]; then printf 'item|login|Log in with your email address|||to see your licenses\n'
else
case "$lic_state" in
checking) printf 'note|%schecking licenses for %s…%s\n' "$dim" "$email" "$r0" ;;
ok) if [ -n "$m_license_rows" ]; then printf '%s' "$m_license_rows"; else printf 'note|%snone on this email yet · buy or request beta access at makepad.nl%s\n' "$dim" "$r0"; fi ;;
*) printf 'note|%slicenses not checked: %s%s\n' "$warn" "$lic_error" "$r0"; printf '%s' "$m_license_rows" ;;
esac
fi
printf 'head|MAKEPAD EXPERIMENTS\n'
printf 'item|free|Experiments|free|%s · %s ready|open list\n' "$m_free_total" "$m_free_ready"
printf 'head|CODING AGENTS\n'
[ -z "$agents" ] || printf '%s\n' "$agents" | while IFS='|' read -r mr_cmd mr_title; do printf 'item|agent-%s|%s|||open\n' "$mr_cmd" "$mr_title"; done
printf "item|agent-shell|Shell||%swith this folder's Rust on PATH%s|open\n" "$dim" "$r0"
printf 'head|SETUP\n'
if [ -z "$email" ]; then printf 'item|account|Account||%snot logged in%s|log in\n' "$warn" "$r0"
else
case "$lic_state" in
ok) printf 'item|account|Account||%s✓%s %s|switch or log out\n' "$ok" "$r0" "$email" ;;
checking) printf 'item|account|Account||%s %schecking…%s|switch or log out\n' "$email" "$dim" "$r0" ;;
*) printf 'item|account|Account||%s %s· %s%s|switch or log out\n' "$email" "$warn" "$lic_error" "$r0" ;;
esac
fi
if [ "$plat" = linux ]; then
if [ -f "$root/graphics-notice-read" ]; then printf 'item|gpu|Graphics||%s✓%s driver notice read|\n' "$ok" "$r0"
else printf 'item|gpu|Graphics||%sread the driver notice%s|read\n' "$warn" "$r0"; fi
fi
accepted_agreements
if is_accepted makepad && is_accepted rust; then printf 'item|terms|Agreements||%s✓%s accepted %s· Makepad, Rust%s|read\n' "$ok" "$r0" "$dim" "$r0"
else printf 'item|terms|Agreements||%snot accepted%s|read\n' "$warn" "$r0"; fi
if [ -f "$scratch/disk" ]; then
read -r mr_total mr_build < "$scratch/disk"
mr_gb=$(awk -v t="$mr_total" -v b="$mr_build" 'BEGIN { printf "%.1f %.1f", t / 1048576, b / 1048576 }')
if [ "${mr_build:-0}" -gt 0 ]; then printf 'item|disk|Disk||%s GB used %s· build %s GB%s|clear build data\n' "${mr_gb% *}" "$dim" "${mr_gb#* }" "$r0"
else printf 'item|disk|Disk||%s GB used|\n' "${mr_gb% *}"; fi
else
printf 'item|disk|Disk||%smeasuring…%s|\n' "$dim" "$r0"
fi
}
main_rows() {
# Update sits above everything; the menu still opens on the first app.
printf 'note|\n'
mr_info='Checks your licenses and downloads newer sources of your apps.'
case "$checked" in
'') printf 'item|updates|Update||%scheck for updates%s|=|%s\n' "$dim" "$r0" "$mr_info" ;;
0*) printf 'item|updates|Update||%s✓%s up to date · %s|check again|%s\n' "$ok" "$r0" "${checked#* }" "$mr_info" ;;
*) printf 'item|updates|Update||updates downloaded · %s|check again|%s\n' "${checked#* }" "$mr_info" ;;
esac
printf 'head|YOUR APPS\n'
if [ -z "$email" ]; then printf 'item|login|Log in with your email address|||to see your licenses\n'
else
case "$lic_state" in
checking) printf 'note|%schecking licenses for %s…%s\n' "$dim" "$email" "$r0" ;;
ok) if [ -n "$m_license_rows" ]; then printf '%s' "$m_license_rows"; else printf 'note|%snone on this email yet · buy or request beta access at makepad.nl%s\n' "$dim" "$r0"; fi ;;
*) printf 'note|%slicenses not checked: %s%s\n' "$warn" "$lic_error" "$r0"; printf '%s' "$m_license_rows" ;;
esac
fi
printf 'head|MAKEPAD EXPERIMENTS\n'
# The Experiments node; open, Compile all and every experiment under it.
mr_about='Small open-source Makepad apps.'
[ "$m_public_ready" = 1 ] || mr_about="$mr_about One ${m_public_mb:-0} MB download covers them all."
mr_about="$mr_about ⏎ opens or closes the list."
if [ "$free_open" = 1 ]; then
printf 'item|free|▾ Experiments|free||close|%s\n' "$mr_about"
queue_batch
mr_batch=$qb
[ -z "$b_pid" ] || [ "$b_batch" != 1 ] || [ "$b_open" = 1 ] || mr_batch=$((mr_batch + 1))
mr_info='Queues every experiment that is not ready; each is compiled, not opened.'
if [ "$mr_batch" -gt 0 ]; then printf 'sub|all|Compile all||%s%s queued%s||%s\n' "$dim" "$mr_batch" "$r0" "$mr_info"
else printf 'sub|all|Compile all||%s%s not ready%s|queue all|%s\n' "$dim" "$((m_free_total - m_free_ready))" "$r0" "$mr_info"; fi
printf '%s' "$m_free_rows"
else
printf 'item|free|▸ Experiments|free|%s%s apps · %s ready%s|open|%s\n' "$dim" "$m_free_total" "$m_free_ready" "$r0" "$mr_about"
fi
printf 'head|CODING AGENTS\n'
[ -z "$agents" ] || printf '%s\n' "$agents" | while IFS='|' read -r mr_cmd mr_title; do printf 'item|agent-%s|%s|||open|Ask it to change an app; it can rebuild them here.\n' "$mr_cmd" "$mr_title"; done
printf "item|agent-shell|Shell||%swith this folder's Rust on PATH%s|open|A shell in this folder; type exit to come back.\n" "$dim" "$r0"
printf 'head|SETUP\n'
mr_info='Log in, switch or log out.'
if [ -z "$email" ]; then printf 'item|account|Account||%snot logged in%s|log in|%s\n' "$warn" "$r0" "$mr_info"
else
case "$lic_state" in
ok) printf 'item|account|Account||%s✓%s %s|switch or log out|%s\n' "$ok" "$r0" "$email" "$mr_info" ;;
checking) printf 'item|account|Account||%s %schecking…%s|switch or log out|%s\n' "$email" "$dim" "$r0" "$mr_info" ;;
*) printf 'item|account|Account||%s %s· %s%s|switch or log out|%s\n' "$email" "$warn" "$lic_error" "$r0" "$mr_info" ;;
esac
fi
if [ "$plat" = linux ]; then
if [ -f "$root/graphics-notice-read" ]; then printf 'item|gpu|Graphics||%s✓%s driver notice read||The GPU driver notice.\n' "$ok" "$r0"
else printf 'item|gpu|Graphics||%sread the driver notice%s|read|The GPU driver notice.\n' "$warn" "$r0"; fi
fi
accepted_agreements
mr_info='The licenses you accepted; ⏎ opens them.'
if is_accepted makepad && is_accepted rust; then printf 'item|terms|Agreements||%s✓%s accepted %s· Makepad, Rust%s|read|%s\n' "$ok" "$r0" "$dim" "$r0" "$mr_info"
else printf 'item|terms|Agreements||%snot accepted%s|read|%s\n' "$warn" "$r0" "$mr_info"; fi
mr_info='Clearing build data keeps your apps; the next compile starts from scratch.'
if [ -f "$scratch/disk" ]; then
read -r mr_total mr_build < "$scratch/disk"
mr_gb=$(awk -v t="$mr_total" -v b="$mr_build" 'BEGIN { printf "%.1f %.1f", t / 1048576, b / 1048576 }')
if [ "${mr_build:-0}" -gt 0 ]; then printf 'item|disk|Disk||%s GB used %s· build %s GB%s|clear build data|%s\n' "${mr_gb% *}" "$dim" "${mr_gb#* }" "$r0" "$mr_info"
else printf 'item|disk|Disk||%s GB used||%s\n' "${mr_gb% *}" "$mr_info"; fi
else
printf 'item|disk|Disk||%smeasuring…%s||%s\n' "$dim" "$r0" "$mr_info"
fi
}
# app_info ID STATE -> ai: what Return does on an app's row.
app_info() {
if is_stopped "$1"; then ai='Stopped; what was downloaded and compiled is kept. ⏎ continues.'; return 0; fi
case "$2" in
compile) ai='Compiles it from source and opens it.' ;;
update) ai='Downloads the new source, compiles it and opens it.' ;;
ready) ai='Built; ⏎ opens it.' ;;
merge) ai='Updated; a coding agent reapplies your saved changes.' ;;
*) ai='Downloads its source, compiles it and opens it.' ;;
esac
}
# The index of the item row with ID on the current screen.
select_id() {
si_n=$(screen_rows | grep -E '^(item|sub)\|' | grep -nE "^(item|sub)\|$1\|" | head -n 1 | cut -d: -f1)
[ -z "$si_n" ] || sel=$((si_n - 1))
}
# The menu starts on the first row of YOUR APPS. awk reads all of main_rows'
# output (a reader that stops early would break its pipe).
first_license_row() {
sel=$(main_rows | awk '/^head\|YOUR APPS/ { found = 1 } !found && /^(item|sub)\|/ { n++ } END { print n + 0 }')
}
measure_disk() {
rm -f "$scratch/disk"
(
md_total=$(du -sk "$root" 2>/dev/null | cut -f1)
md_build=0
[ ! -d "$root/target" ] || md_build=$(du -sk "$root/target" 2>/dev/null | cut -f1)
put "$scratch/disk" "${md_total:-0} ${md_build:-0}"
) </dev/null >/dev/null 2>&1 &
}
# -------------------------------------------------------------- account ---
# Checked on its own page or row: a refused email comes straight back to the
# editor with the reason; a network failure keeps the email.
licenses_now() {
lic_state=checking
draw
busy "Checking licenses for $email"
check_licenses || :
message=
}
# log_out: forget the email in this folder. Only the Makepad experiments
# show from then on; licensed apps already built stay on disk untouched and
# come back when you log in again.
log_out() {
la_was=$email
email= licensed_ids= lic_state=none lic_error=
save_email
: > "$root/.login-asked"
: > "$scratch/licensed"
scan
message="${ok}✓${r0} Logged out of $la_was. ${dim}Only the Makepad experiments are shown; log in again from the Account row.${r0}"
}
signin_screen() {
s_screen=${screen:-main}
screen=signin sel=0 top=0 message=
si_hint= si_typed=${builder_email:-}
while :; do
edit 'Email:' "$si_typed" "$si_hint" 'type your email ⏎ continue' || { email=; break; }
[ -n "$edited" ] || { email=${saved_email:-}; break; }
si_typed=$edited
if ! check_email "$edited"; then si_hint="${warn}That does not look like an email address.${r0}"; continue; fi
email=$email_ok
licenses_now
if [ "$lic_state" = rejected ]; then
si_hint="${warn}Email not recognised: $si_typed · fix any typo, then ⏎${r0}"
email=
continue
fi
break
done
screen=$s_screen message=
}
switch_account() {
if [ -n "$email" ]; then
choose "Logged in as $email." '' 'switch email' 'switch email' 'log out' || return 0
if [ "$chosen" = 'log out' ]; then log_out; return 0; fi
edit 'Email for your licenses:' '' "${dim}⏎ keeps $email${r0}" '⏎ log in esc back' || return 0
else
edit 'Email you bought a Makepad app with:' '' '' '⏎ log in esc back' || return 0
fi
[ -n "$edited" ] && [ "$edited" != "$email" ] || return 0
check_email "$edited" || { message="${warn}That does not look like an email address.${r0}"; return 0; }
sa_before=$email sa_typed=$edited
email=$email_ok
while :; do
licenses_now
[ "$lic_state" = rejected ] || break
edit 'Email for your licenses:' "$sa_typed" "${warn}Email not recognised: $sa_typed · fix any typo, then ⏎${r0}" '⏎ check again esc keep' || {
email=$sa_before; licenses_now; scan; return 0
}
sa_typed=$edited
check_email "$edited" || continue
email=$email_ok
done
save_email
: > "$root/.login-asked"
scan
license_count
sa_s=s; [ "$license_count" != 1 ] || sa_s=
if [ "$lic_state" = ok ]; then message="${ok}✓${r0} Switched to $email · $license_count license$sa_s."
else message="${warn}Switched to $email; the licenses could not be checked: $lic_error${r0}"; fi
}
# --------------------------------------------------------------- folder ---
# first_folder: asks where to install, reuses a folder the Builder made, and
# copies this script there as the `makepad` command.
folder_screen() {
screen=folder sel=0 top=0 message=
fs_default=${MAKEPAD_LOADER_ROOT:-$HOME/makepad-commercial}
fs_home=$(cd -P -- "$HOME" && pwd -P)
while :; do
choose "Install into $(short "$fs_default")?" '' install install 'other folder' quit || exit 0
fs_folder=$fs_default
case "$chosen" in
quit) exit 0 ;;
'other folder')
edit 'Folder:' "$(short "$fs_default")" '' '⏎ use this folder esc back' || continue
fs_folder=$edited ;;
esac
case "$fs_folder" in
'~') fs_folder=$HOME ;;
'~/'*) fs_folder=$HOME/${fs_folder#'~/'} ;;
/*) ;;
*) fs_folder=$(pwd -P)/$fs_folder ;;
esac
if mkdir -p -- "$fs_folder" 2>/dev/null && fs_root=$(cd -P -- "$fs_folder" && pwd -P); then
if [ "$fs_root" = / ] || [ "$fs_root" = "$fs_home" ]; then
message="${warn}Please choose a folder of its own, such as $(short "$HOME/makepad-commercial").${r0}"
elif [ -e "$fs_root/builder/makepad-builder.json" ] || [ -e "$fs_root/makepad-builder.json" ] || [ -e "$fs_root/makepad-loader.json" ] || [ -z "$(ls -A "$fs_root")" ]; then
home=$fs_root root=$fs_root/builder
migrate_layout
break
else
message="${warn}$(short "$fs_root") already holds other files; please choose an empty or new folder.${r0}"
fi
else
message="${warn}Could not create $(short "$fs_folder"); please choose another folder.${r0}"
fi
fs_default=$fs_folder
done
message=
}
install_self() {
cp "$self" "$home/makepad.next"
chmod 755 "$home/makepad.next"
mv -f "$home/makepad.next" "$home/makepad"
}
# ------------------------------------------------------------- graphics ---
gpu_screen() { # 0 when read
g_screen=$screen g_sel=$sel
screen=gpu sel=0 top=0 message=
g_ok=1
while :; do
draw; key
case "$key" in
up) sel=0 ;;
down) sel=1 ;;
enter) [ "$sel" != 0 ] || g_ok=0; break ;;
esc | q) break ;;
esac
done
screen=$g_screen sel=$g_sel
[ "$g_ok" = 0 ] || return 1
put "$root/graphics-notice-read" read
}
# ----------------------------------------------------------- build tools ---
# choose_rust VERSION: the one-time offer of a compatible installed Rust, or
# repairing a recorded one that stopped validating. Private is the default.
choose_rust() {
recorded_rust
cr_stale=
case "$selected_rust" in
private) return 0 ;;
/*) rust_check --validate "$1" "$selected_rust" && return 0; cr_stale=$selected_rust; log "The selected Rust at $selected_rust cannot be used: $rust_reason" ;;
esac
if rust_check --probe "$1"; then
cr_candidate=$rust_sysroot
choose "Build with a private Rust $1 in this folder, or your system Rust?" "system: $(short "$cr_candidate") (not modified)" private private system || return 1
if [ "$chosen" = system ]; then record_rust "$cr_candidate"; log 'Using the installed Rust.'; else record_rust private; log 'Using a private Rust in this folder.'; fi
elif [ -n "$cr_stale" ]; then
choose "The selected Rust at $(short "$cr_stale") cannot be used. Switch to a private Rust in this folder?" '' switch switch keep || return 1
[ "$chosen" = switch ] || { message="${warn}Kept the selected Rust at $(short "$cr_stale"). Make it available again, or select the app again to switch.${r0}"; return 1; }
record_rust private
else
log "Installed Rust not used: $rust_reason"
record_rust private
fi
}
change_rust() {
rust_version
if ! rust_check --probe "$rust_version"; then
message="${dim}No other compatible installed Rust was found: $rust_reason${r0}"
return 0
fi
cr_candidate=$rust_sysroot
recorded_rust
cr_pick=private; case "$selected_rust" in /*) cr_pick=system ;; esac
choose "Build with a private Rust $rust_version in this folder, or your system Rust?" "system: $(short "$cr_candidate") (not modified)" "$cr_pick" private system || return 0
if [ "$chosen" = system ]; then record_rust "$cr_candidate"; else record_rust private; fi
scan
if [ "$m_rust_ok" = 1 ]; then message="${ok}✓${r0} Rust changed; the next compile uses it."; else install_build_tools || :; fi
}
# xcode_screen: Apple's installer or its license prompt, Check again or
# Cancel. 0 when the tools are ready or Apple's installer was started.
xcode_screen() {
x_screen=$screen x_sel=$sel
screen=xcode sel=0 top=0 message=
x_result=1
while :; do
draw; key; message=
case "$key" in
up) sel=$(( (sel + items - 1) % items )) ;;
down) sel=$(( (sel + 1) % items )) ;;
esc | q) break ;;
enter)
select_row
case "$id" in
install) /usr/bin/xcode-select --install >/dev/null 2>&1 || :; x_result=0; xcode_waiting=1; break ;;
license)
pause_screen
printf "\n Apple's Xcode license follows. Type agree at the end to accept it.\n\n"
sudo xcodebuild -license </dev/tty || :
resume_screen
if tools_check; then x_result=0; break; fi
message="${warn}Still not ready.${r0}" ;;
check)
busy 'Checking clang, the macOS SDK, the linker and git'
if tools_check; then x_result=0; break; fi
message="${warn}Still not ready.${r0}" ;;
cancel) break ;;
esac ;;
esac
done
screen=$x_screen sel=$x_sel
return "$x_result"
}
packages_screen() { # 0 when sudo is ready to install the packages
p_screen=$screen p_sel=$sel
screen=packages sel=0 top=0 message=
p_result=1
while :; do
draw; key
case "$key" in
up) sel=0 ;;
down) sel=1 ;;
enter) [ "$sel" != 0 ] || p_result=0; break ;;
esc | q) break ;;
esac
done
screen=$p_screen sel=$p_sel
[ "$p_result" = 0 ] || return 1
# sudo asks for the password on the real terminal; the packages then
# install beside the Rust download.
pause_screen
printf '\n Makepad installs development packages from %s with:\n\n sudo %s\n\n' "$distro_name" "$packages_cmd"
if sudo -v </dev/tty; then resume_screen; return 0; fi
resume_screen
message="${warn}sudo did not run; nothing was installed.${r0}"
return 1
}
job_xcode() { # waits for Apple's installer, checking every few seconds
while ! tools_check; do
step "$1" "now|0|0|none|0|waiting for Apple's installer · esc stops waiting"
sleep 3
done
step "$1" 'done|clang, SDK, git'
}
job_packages() {
step "$1" "now|0|0|none|0|installing with ${packages_cmd%% *}"
# shellcheck disable=SC2086
if ! sudo -n $packages_cmd >> "$log_file" 2>&1; then step_fail "$1" 'The package manager failed (see builder.log)'; return 1; fi
tools_check || { step_fail "$1" 'Some development packages are still missing (see builder.log)'; return 1; }
step "$1" 'done|compiler, linker, git'
}
# install_build_tools: what compiling needs, as the Windows Builder does it:
# one consent screen, then every missing piece side by side on one page.
install_build_tools() {
rust_version
if [ -z "$rust_version" ]; then
busy 'Checking the Makepad release'
fetch_public || { message="${warn}The Makepad release could not be checked: $public_error${r0}"; return 1; }
rust_version
fi
ib_version=$rust_version
ib_tools=0; tools_check && ib_tools=1
ib_rust=0; rust_ready "$ib_version" && ib_rust=1
if [ "$ib_rust" = 0 ]; then
choose_rust "$ib_version" || { message="${dim}Nothing was installed.${r0}"; return 1; }
rust_ready "$ib_version" && ib_rust=1
fi
[ "$ib_tools" = 0 ] || [ "$ib_rust" = 0 ] || return 0
if [ "$ib_tools" = 1 ]; then ib_intro='Makepad compiles from source, so it needs a compiler.'
elif [ "$plat" = mac ]; then ib_intro="Makepad compiles from source, so it needs a compiler. Apple's developer tools come from Apple's installer, which shows its own license."
else ib_intro="Makepad compiles from source, so it needs a compiler. Development packages come from your distribution's package manager."; fi
ib_detail=; [ "$ib_rust" = 1 ] || ib_detail="Installed in this folder only: Rust $ib_version"
ib_action='install Rust'; [ "$ib_tools" = 1 ] || ib_action='install the build tools and Rust'
[ "$ib_rust" = 0 ] || ib_action='install the build tools'
consent 'Install build tools' "$ib_intro" "$ib_detail" "$ib_action" makepad rust || { message="${dim}Nothing was installed.${r0}"; return 1; }
ib_steps= xcode_waiting=
if [ "$ib_tools" = 0 ]; then
if [ "$plat" = mac ]; then
xcode_screen || { message="${dim}Nothing was installed.${r0}"; return 1; }
[ -z "$xcode_waiting" ] || ib_steps='Xcode tools'
else
[ "$tools_problem" != unsupported ] || { message="${warn}The Builder needs glibc Linux (x86_64 or ARM64).${r0}"; return 1; }
linux_packages || { message="${warn}Install the development packages with your package manager, then choose System packages.${r0}"; return 1; }
packages_screen || { message="${message:-${dim}Nothing was installed.${r0}}"; return 1; }
ib_steps='System packages'
fi
fi
[ "$ib_rust" = 1 ] || ib_steps="$ib_steps${ib_steps:+|}Rust"
if [ -n "$ib_steps" ]; then
IFS='|'; set -- $ib_steps; IFS=$ifs_default
work_begin 'Install build tools' 'This takes a few minutes; everything goes into this folder.' "$@"
for ib_step in "$@"; do
case "$ib_step" in
'Xcode tools') job_start job_xcode 'Xcode tools'; xcode_job=${running_jobs##* } ;;
'System packages') job_start job_packages 'System packages' ;;
Rust) job_start job_rust Rust "$ib_version" ;;
esac
done
if jobs_wait; then
work_end ok "${ok}✓${r0} Rust $ib_version is ready. Caches and builds stay in $(short "$root")."
else
work_failure
work_end failed "${warn}${wf:-Setup stopped.}${r0}"
scan; tools_check || :
return 1
fi
fi
tools_check || :
measure_disk
scan
}
# ------------------------------------------------------------------ apps ---
# ------------------------------------------------------ background work ---
# An app downloads and compiles in the background, on its own row, while
# the menu stays usable: one at a time, the others queued ("app|open|batch|
# title" lines; open: it opens when done, set by Return on its row and
# turned off by s; batch: Compile all queued it). c cancels: the build's
# process tree stops, what it downloaded and compiled stays, and the row
# says "stopped · continue". The update check runs beside it.
b_pid= b_app= b_title= b_open=0 b_batch=0 b_stopping=0 b_starting=
u_pid= queue= stopped_apps= free_open=0
is_stopped() { case " $stopped_apps " in *" $1 "*) return 0 ;; esac; return 1; }
# queue_place APP -> qp (0 is next) and qp_open; fails when not queued.
queue_place() {
qp=0
while IFS='|' read -r qp_app qp_open qp_batch qp_title; do
[ -n "$qp_app" ] || continue
[ "$qp_app" != "$1" ] || return 0
qp=$((qp + 1))
done <<EOF
$queue
EOF
return 1
}
# queue_batch -> qb: Compile all's apps that still wait to compile only.
queue_batch() {
qb=0
while IFS='|' read -r qb_app qb_open qb_batch qb_title; do
[ "$qb_batch|$qb_open" != '1|0' ] || qb=$((qb + 1))
done <<EOF
$queue
EOF
}
# queue_edit APP open|toggle|remove: set its open flag (and it is no longer
# part of Compile all's batch), flip it, or take it out.
queue_edit() {
qe_new=
while IFS='|' read -r qe_app qe_open qe_batch qe_title; do
[ -n "$qe_app" ] || continue
if [ "$qe_app" = "$1" ]; then
case "$2" in
open) qe_open=1 qe_batch=0 ;;
toggle) qe_open=$((1 - qe_open)) ;;
remove) qe_removed=$qe_title; continue ;;
esac
fi
qe_new="$qe_new$qe_app|$qe_open|$qe_batch|$qe_title
"
done <<EOF
$queue
EOF
queue=$qe_new
}
app_title() { # app_title ID -> at
at=$(free_apps | awk -F'|' -v id="$1" '$1 == id { print $2 }')
[ -n "$at" ] || { rel_load "$root/available/$1.json" 2>/dev/null && at=$rel_title; } || at=$1
}
# open_app ID: merge saved changes, run a built app, or download, compile
# and open it in the background. Return on a row that is compiling or
# queued asks it to open when it is done.
open_app() {
oa_id=$1
if [ -n "$b_pid" ] && [ "$b_app" = "$oa_id" ]; then b_open=1; return 0; fi
if queue_place "$oa_id"; then queue_edit "$oa_id" open; return 0; fi
app_state "$oa_id"
if [ "$state" = merge ]; then merge_changes "$oa_id"; return; fi
if [ "$state" = ready ] && ! is_stopped "$oa_id"; then
rel_load "$root/installed/$oa_id.json" && rel_dir
busy "Opening $rel_title"
launch_app
launched_message "$rel_title"
return
fi
# One compiles at a time; the others wait their turn.
if [ -n "$b_pid" ]; then
app_title "$oa_id"
queue="$queue$oa_id|1|0|$at
"
return 0
fi
start_build "$oa_id" 1 0
}
# compile_all: every experiment that is not ready joins the queue, to be
# compiled only (Return on one of them makes it open when done).
compile_all() {
ca_count=0
while IFS='|' read -r ca_id ca_title; do
[ -n "$ca_id" ] || continue
[ "$ca_id" != "$b_app" ] || [ -z "$b_pid" ] || continue
! queue_place "$ca_id" || continue
queue="$queue$ca_id|0|1|$ca_title
"
ca_count=$((ca_count + 1))
done <<EOF
$m_free_todo
EOF
if [ "$ca_count" = 0 ]; then message="${dim}Every experiment is ready or already queued.${r0}"; return 0; fi
if [ "$ca_count" = 1 ]; then message="${dim}Queued 1 experiment; each is compiled, not opened.${r0}"
else message="${dim}Queued $ca_count experiments; each is compiled, not opened.${r0}"; fi
}
# background_key c|s ID: c cancels the selected row's build or its place in
# the queue (on Compile all, its queued apps); s flips whether it opens.
background_key() {
bk_app=${2#app:}
case "$1" in
s)
if [ -n "$b_pid" ] && [ "$b_app" = "$bk_app" ] && [ "$b_stopping" = 0 ]; then b_open=$((1 - b_open))
elif queue_place "$bk_app"; then queue_edit "$bk_app" toggle; fi ;;
c)
if [ "$2" = all ]; then
bk_new= bk_gone=0
while IFS='|' read -r bk_q bk_open bk_batch bk_title; do
[ -n "$bk_q" ] || continue
if [ "$bk_batch|$bk_open" = '1|0' ]; then bk_gone=$((bk_gone + 1)); continue; fi
bk_new="$bk_new$bk_q|$bk_open|$bk_batch|$bk_title
"
done <<EOF
$queue
EOF
queue=$bk_new
if [ "$bk_gone" = 1 ]; then message="${dim}1 experiment is out of the queue.${r0}"
elif [ "$bk_gone" -gt 1 ]; then message="${dim}$bk_gone experiments are out of the queue.${r0}"; fi
elif [ -n "$b_pid" ] && [ "$b_app" = "$bk_app" ] && [ "$b_stopping" = 0 ]; then
# Its downloads and Cargo stop; the next queued app starts
# once they have.
b_stopping=1
kill_tree "$b_pid"
log "$b_title cancelled."
message="${warn}$b_title cancelled. What was downloaded and compiled is kept; select it to continue.${r0}"
elif queue_place "$bk_app"; then
queue_edit "$bk_app" remove
message="${dim}$qe_removed is out of the queue.${r0}"
fi ;;
esac
}
# start_build ID OPEN BATCH: first, here, what may need the person (the
# build tools with their consent screens, the release, the email); then the
# download and the compile as a background job.
start_build() {
sb_app=$1
stopped_apps=$(printf '%s\n' $stopped_apps | grep -vx "$sb_app" | tr '\n' ' ' || :)
install_build_tools || return 0
app_release_file "$sb_app" || release_file="$root/available/makepad.json"
rel_load "$release_file" || { message="${warn}$sb_app has no usable release; choose Update.${r0}"; return 0; }
rel_supported || { message="${warn}$rel_title is not available for this platform yet.${r0}"; return 0; }
if [ "$rel_public" != true ]; then
[ -n "$email" ] || { message="${warn}Log in with the email that has this app's license.${r0}"; return 0; }
email_headers
fi
sb_title=$rel_title
[ "$rel_id" != makepad ] || sb_title=$(free_apps | awk -F'|' -v id="$sb_app" '$1 == id { print $2 }')
rust_ready "$rel_rust" || { message="${warn}The latest source requires Rust $rel_rust; it could not be set up.${r0}"; return 0; }
source_labels
if [ -n "$source_labels" ]; then b_starting=downloading; else b_starting=compiling; fi
# A stale checkout lock (a Builder that was closed mid-download) goes
# when nothing else runs.
[ -n "$u_pid" ] || rm -rf "$root/tmp/checkout.lock"
rm -f "$steps_dir/_build"
log "$sb_title downloads and compiles in the background."
( build_job "$sb_app" ) </dev/null >/dev/null 2>>"$log_file" &
b_pid=$! b_app=$sb_app b_title=$sb_title b_open=$2 b_batch=$3 b_stopping=0
}
# checkout_wait STEP: download and check out the loaded release's missing
# repositories (the job_pack jobs), one checkout at a time in the folder
# (a build and an update may both want the same snapshot), with all of them
# as one bar on STEP.
checkout_wait() {
mkdir -p "$root/tmp"
until mkdir "$root/tmp/checkout.lock" 2>/dev/null; do
step "$1" 'now|0|0|MB|0|waiting'
sleep 0.3
done
rel_dir
cw_list=
while IFS='|' read -r v_name v_path v_commit v_sha v_bytes; do
[ -n "$v_name" ] || continue
repo_installed "$rel_directory" "$v_name" "$v_path" "$v_commit" && continue
cw_title=$(printf '%s' "$v_name" | awk '{ print toupper(substr($0, 1, 1)) substr($0, 2) }')
step_key "$cw_title source"
rm -f "$steps_dir/$step_key"
cw_list="$cw_list$step_key|$v_bytes
"
done <<EOF
$rel_repos
EOF
running_jobs=
checkout_jobs
cw_status=0
while :; do
cw_alive=
for cw_pid in $running_jobs; do kill -0 "$cw_pid" 2>/dev/null && cw_alive=1; done
cw_done=0 cw_total=0 cw_doing=unpacking cw_failed=
while IFS='|' read -r cw_key cw_bytes; do
[ -n "$cw_key" ] || continue
cw_state= cw_a=0
IFS='|' read -r cw_state cw_a cw_b cw_unit cw_mb cw_now 2>/dev/null < "$steps_dir/$cw_key" || :
cw_total=$((cw_total + 2 * cw_bytes))
case "$cw_state" in
now) cw_done=$((cw_done + cw_a)); [ "$cw_now" != downloading ] || cw_doing=downloading ;;
done) cw_done=$((cw_done + 2 * cw_bytes)) ;;
failed | held) cw_failed=${cw_failed:-$cw_a} ;;
esac
done <<EOF
$cw_list
EOF
step "$1" "now|$cw_done|$cw_total|MB|$((cw_total / 2))|$cw_doing"
[ -n "$cw_alive" ] || break
sleep 0.2
done
for cw_pid in $running_jobs; do wait "$cw_pid" || cw_status=1; done
running_jobs=
rmdir "$root/tmp/checkout.lock" 2>/dev/null || :
if [ -n "$cw_failed" ] || [ "$cw_status" != 0 ]; then
step_fail "$1" "${cw_failed:-The download stopped (see builder.log).}"
return 1
fi
}
# build_job ID (background): the loaded release's source when it is not
# here yet, then Cargo, with its progress on the step _build.
build_job() {
bj_app=$1
log "$(describe_sources)"
keep_edits "$bj_app" "$release_file"
rel_load "$release_file"
checkout_wait _build || exit 1
# A free app's package comes from the registry of the source it builds.
if [ "$rel_id" = makepad ]; then
load_registry
app_release_file "$bj_app" || { step_fail _build "$bj_app is not in this Makepad release"; exit 1; }
rel_load "$release_file"
fi
if [ "$release_file" = "$root/tmp/projected-$bj_app.json" ]; then
mkdir -p "$root/available"; cp "$release_file" "$root/available/$bj_app.json"; release_file="$root/available/$bj_app.json"
fi
rel_dir
prepare_target
job_compile _build "$bj_app" || exit 1
prune_snapshots || :
}
# finish_build: the background build ended; open the app when it was asked
# to, say so, and keep a cancelled one's row at "stopped · continue".
finish_build() {
wait "$b_pid" 2>/dev/null || :
fb_state= fb_reason=
IFS='|' read -r fb_state fb_reason fb_rest 2>/dev/null < "$steps_dir/_build" || :
fb_app=$b_app fb_title=$b_title fb_open=$b_open fb_stopping=$b_stopping
b_pid= b_app= b_title= b_open=0 b_batch=0 b_stopping=0
measure_disk
if [ "$fb_stopping" = 1 ]; then
stopped_apps="$stopped_apps $fb_app"
log "$fb_title stopped; its downloads and compiled crates are kept."
elif [ "$fb_state" != done ]; then
message="${warn}$fb_title: ${fb_reason:-it stopped unexpectedly (see builder.log)}${r0}"
else
# Just downloaded and built: that is up to date, unless an update
# check meanwhile downloaded newer sources.
case "$checked" in 1*) ;; *) checked="0 $(date +%H:%M)" ;; esac
if [ "$fb_open" = 1 ]; then
rel_load "$root/installed/$fb_app.json" && rel_dir
launch_app
launched_message "$rel_title"
if [ -f "$root/changes/$fb_app.merge" ]; then message="${warn}$rel_title is updated. Your edits are in changes/; select it to merge.${r0}"; fi
else
message="${ok}✓${r0} $fb_title is compiled."
fi
fi
scan
}
# service_background: work that ended reports on the status line, then the
# next queued app starts (its preparation may ask something on the way).
service_background() {
if [ -n "$u_pid" ] && ! kill -0 "$u_pid" 2>/dev/null; then finish_update; fi
if [ -n "$b_pid" ] && ! kill -0 "$b_pid" 2>/dev/null; then finish_build; fi
while [ -z "$b_pid" ] && [ -n "$queue" ]; do
IFS='|' read -r sv_app sv_open sv_batch sv_title <<EOF
$queue
EOF
queue=${queue#*"$nl"}
start_build "$sv_app" "$sv_open" "$sv_batch"
done
}
# One line for the log saying where the sources come from, so a full
# recompile can be told apart from a shared build.
describe_sources() {
rel_dir
if [ "$rel_directory" = "$root/sources/$rel_release" ]; then printf 'Sources for %s: %s' "$rel_title" "$rel_directory"
else printf 'Sources for %s shared with %s (same commits, same build artifacts)' "$rel_title" "$rel_directory"; fi
}
merge_changes() { # merge_changes APP
mc_app=$1
mc_diff=$(cat "$root/changes/$mc_app.merge" 2>/dev/null || :)
mc_agents=$(printf '%s\n' "$agents" | grep -v '^grok|' | grep . || :)
mc_count=$(printf '%s\n' "$mc_agents" | grep -c . || :)
case "$mc_count" in
0) message="${warn}Install Claude Code or Codex to merge, or apply changes/$mc_diff yourself.${r0}"; return 0 ;;
1) mc_agent=${mc_agents%%|*} ;;
*)
set -- $(printf '%s\n' "$mc_agents" | cut -d'|' -f1)
choose 'Merge your changes with which agent?' '' "$1" "$@" || return 0
mc_agent=$chosen ;;
esac
mc_title=$(printf '%s\n' "$agents" | awk -F'|' -v id="$mc_agent" '$1 == id { print $2 }')
launch_agent "$mc_agent" "Reapply my changes saved in changes/$mc_diff in this folder onto the new $mc_app source. Keep the intent of each change where the new release moved code, rebuild it with ./makepad build $mc_app until it compiles, and report any change that no longer applies." || return 0
rm -f "$root/changes/$mc_app.merge"
message="${ok}✓${r0} $mc_title finished merging. Select the app to run it."
scan
}
# real_tty: the terminal's own device (/dev/ttys003), for programs started in
# it. Handed /dev/tty, Claude Code (Bun) stops at once on macOS with "EINVAL:
# invalid argument, kqueue", since kqueue cannot watch that alias.
real_tty() {
rt_name=$(ps -o tty= -p $$ 2>/dev/null | tr -d ' ')
case "$rt_name" in
'' | '?' | '??') real_tty=/dev/tty ;;
/dev/*) real_tty=$rt_name ;;
*) real_tty=/dev/$rt_name ;;
esac
[ -c "$real_tty" ] && [ -r "$real_tty" ] && [ -w "$real_tty" ] || real_tty=/dev/tty
}
# launch_agent NAME [TASK]: the agent in this folder, knowing how to change
# and rebuild the apps, with this folder's Rust on PATH.
launch_agent() {
agent_release || { message="${warn}Download an app first; coding agents work in its source.${r0}"; return 1; }
rust_ready "$rel_rust" && tools_check || { message="${warn}Set up the build tools first, so the agent can rebuild apps.${r0}"; return 1; }
write_agent_files
la_title=$(printf '%s\n' "$agents" | awk -F'|' -v id="$1" '$1 == id { print $2 }')
real_tty
pause_screen
# The agent gets the terminal as it is, with its own UI. Keys pressed
# before it is up are dropped, so a second Return cannot answer its first
# question (Claude Code's "trust this folder?" has "No, exit" selected).
printf '\033[H\033[2J%s\n\n' "Starting ${la_title:-$1} in $(short "$root")…"
stty -icanon min 0 time 0 </dev/tty 2>/dev/null && dd bs=4096 count=1 </dev/tty >/dev/null 2>&1 || :
stty "$tty_saved" <&3 2>/dev/null || :
la_started=$(now)
la_status=0
(
cargo_env "$rel_label" "$rel_id" >/dev/null
export MAKEPAD_AGENT_CONTEXT="$root/agent-context.txt"
cd "$root"
case "$1" in
claude) if [ -n "${2:-}" ]; then exec claude --append-system-prompt "$agent_directive" "$2"; else exec claude --append-system-prompt "$agent_directive"; fi ;;
codex) if [ -n "${2:-}" ]; then exec codex -c "developer_instructions='$agent_directive'" "$2"; else exec codex -c "developer_instructions='$agent_directive'"; fi ;;
*) exec "$1" ;;
esac
) <"$real_tty" >"$real_tty" 2>&1 || la_status=$?
resume_screen
# Closed straight away: most often its first question (trust this
# folder?) was answered No, which also exits 1.
if [ $(($(now) - la_started)) -lt 8 ]; then
message="${warn}${la_title:-$1} closed right away.${r0} ${dim}If it asked whether to trust this folder, open it again and choose Yes.${r0}"
return 1
fi
if [ "$la_status" != 0 ]; then message="${warn}${la_title:-$1} exited $la_status. It must already be installed and signed in.${r0}"; return 1; fi
scan
}
open_shell() {
real_tty
pause_screen
printf '\nProject: %s\nThis folder'"'"'s Rust is on PATH. Type exit to return to Makepad.\n\n' "$home"
(
if agent_release && rust_ready "$rel_rust"; then
cargo_env "$rel_label" "$rel_id" >/dev/null
write_agent_files
export MAKEPAD_AGENT_CONTEXT="$root/agent-context.txt"
fi
cd "$home"
exec "${SHELL:-/bin/sh}"
) <"$real_tty" >"$real_tty" 2>&1 || :
resume_screen
scan
}
# ---------------------------------------------------------------- update ---
# Licenses first (new purchases appear, expired ones go), then every
# installed app is compared with its newest release in the background, on
# the Update row: unchanged sources are replaced by a clean download;
# edited ones are first saved as a diff.
check_updates() {
[ -z "$u_pid" ] || return 0
consent Update 'Updates download the newest sources of your apps.' '' 'check for updates' makepad || { message="${dim}Nothing was downloaded.${r0}"; return 0; }
licenses_now
[ -n "$b_pid" ] || rm -rf "$root/tmp/checkout.lock"
rm -f "$steps_dir/_update" "$scratch/update-result"
( update_job ) </dev/null >/dev/null 2>>"$log_file" &
u_pid=$!
}
update_job() {
if ! fetch_public; then put "$scratch/update-result" "failed|$public_error"; exit 1; fi
load_registry
for cu_file in "$root"/available/*.json "$root"/installed/*.json; do
[ -f "$cu_file" ] || continue
cu_id=${cu_file##*/}; cu_id=${cu_id%.json}
[ "$cu_id" != makepad ] || continue
is_free "$cu_id" || continue
project_public "$cu_id" "$root/available/$cu_id.json" || :
done
cu_updated= cu_merges= cu_started=
for cu_file in "$root"/installed/*.json; do
[ -f "$cu_file" ] || continue
cu_id=${cu_file##*/}; cu_id=${cu_id%.json}
rel_load "$cu_file" 2>/dev/null || continue
cu_have=$rel_release
rel_load "$root/available/$cu_id.json" 2>/dev/null || continue
[ "$rel_release" != "$cu_have" ] && rel_supported || continue
if [ "$rel_public" != true ]; then case " $licensed_ids " in *" $cu_id "*) ;; *) continue ;; esac; fi
keep_edits "$cu_id" "$root/available/$cu_id.json"
[ ! -f "$root/changes/$cu_id.merge" ] || cu_merges=1
rel_load "$root/available/$cu_id.json"
release_file="$root/available/$cu_id.json"
[ "$rel_public" = true ] || email_headers
rel_dir
cu_key="$rel_directory|$rel_repos"
case "$cu_started" in *"<$cu_key>"*) cu_updated="$cu_updated${cu_updated:+, }$rel_title"; continue ;; esac
cu_started="$cu_started<$cu_key>"
if ! checkout_wait _update; then
IFS='|' read -r cu_state cu_reason 2>/dev/null < "$steps_dir/_update" || :
put "$scratch/update-result" "failed|${cu_reason:-The update stopped.}"
exit 1
fi
cu_updated="$cu_updated${cu_updated:+, }$rel_title"
done
put "$scratch/update-result" "ok|$cu_updated|$cu_merges"
}
finish_update() {
wait "$u_pid" 2>/dev/null || :
u_pid=
fu_state= fu_what= fu_merges=
IFS='|' read -r fu_state fu_what fu_merges 2>/dev/null < "$scratch/update-result" || :
measure_disk
if [ "$fu_state" != ok ]; then message="${warn}${fu_what:-The update stopped (see builder.log).}${r0}"
elif [ -z "$fu_what" ]; then checked="0 $(date +%H:%M)"; message="${ok}✓${r0} Licenses checked; everything is up to date."
elif [ -n "$fu_merges" ]; then checked="1 $(date +%H:%M)"; message="${ok}✓${r0} Updated $fu_what. Your edits are saved in changes/; select to merge."
else checked="1 $(date +%H:%M)"; message="${ok}✓${r0} Updated $fu_what. Each compiles on its next run."; fi
load_registry
scan
}
clear_build() {
[ -d "$root/target" ] || { message="${dim}There is no build data to delete.${r0}"; return 0; }
choose 'Delete the build data in target/?' 'your apps keep working; the next compile starts from scratch' delete keep delete || return 0
[ "$chosen" = delete ] || return 0
busy 'Deleting build data'
if remove_inside "$root/target"; then message="${ok}✓${r0} Build data cleaned. Built apps are unchanged."
else message="${warn}Could not delete all build data; the activity log says why.${r0}"; fi
measure_disk
scan
}
# ---------------------------------------------------------------- start ---
agents=
for ag in 'claude|Claude Code' 'codex|Codex' 'grok|Grok'; do
if command -v "${ag%%|*}" >/dev/null 2>&1; then agents="$agents${agents:+
}$ag"; fi
done
lic_state=none lic_error= licensed_ids= checked= tools_problem= m_rust_ok=0 m_license_rows= m_free_rows= m_free_total=0 m_free_ready=0
m_free_todo= m_public_ready=0 m_public_mb=0
screen=main sel=0 top=0
# Run again through curl | sh (not from the folder's own `makepad`): an
# installation already in the default folder is opened as it is, with its
# saved email, and its `makepad` command becomes this version. Only a folder
# without one is a first run.
if [ -z "$root" ]; then
rr_home=${MAKEPAD_LOADER_ROOT:-$HOME/makepad-commercial}
if [ -f "$rr_home/builder/makepad-builder.json" ] || [ -f "$rr_home/makepad-builder.json" ] || [ -f "$rr_home/makepad-loader.json" ]; then
home=$(cd -P -- "$rr_home" && pwd -P) root=$home/builder
migrate_layout
install_self
fi
fi
if [ -z "$root" ]; then
# First run: the email, then the folder, then Rust; each once.
scratch=$(mktemp -d "${TMPDIR:-/tmp}/makepad-builder.XXXXXX")
steps_dir="$scratch/steps" slots="$scratch/slots" log_file="$scratch/builder.log"
mkdir -p "$steps_dir" "$slots"
saved_email=
for fr_root in "${MAKEPAD_LOADER_ROOT:-$HOME/makepad-commercial}/builder" "${MAKEPAD_LOADER_ROOT:-$HOME/makepad-commercial}"; do
[ -f "$fr_root/makepad-builder.json" ] || continue
root=$fr_root; read_email; saved_email=$email; root=
break
done
email=
signin_screen
first_email=$email first_state=$lic_state
folder_screen
screen=main
install_self
[ -n "$first_email" ] || { read_email; first_email=$email; first_state=; }
session
email=$first_email
save_email
: > "$root/.login-asked"
log "Makepad Builder installed in $home"
else
session
if [ -z "$email" ] && [ ! -f "$root/.login-asked" ]; then
signin_screen
save_email
: > "$root/.login-asked"
fi
fi
if [ "$plat" = linux ] && [ ! -f "$root/graphics-notice-read" ]; then
gpu_screen || { quiet_exit='Setup cancelled. Nothing was downloaded or installed.'; exit 0; }
fi
# Every start refreshes the licenses, then stops at the menu with the first
# app selected.
[ -f "$root/available/makepad.json" ] || { busy 'Checking the Makepad release'; fetch_public || log "$public_error"; }
load_registry
tools_check || :
scan
draw
if [ -n "$email" ]; then
licenses_now
if [ "$lic_state" = rejected ]; then
sw_before=$email
switch_typed=$email
while [ "$lic_state" = rejected ]; do
edit 'Email for your licenses:' "$switch_typed" "${warn}Email not recognised: $switch_typed · fix any typo, then ⏎${r0}" '⏎ check again esc keep' || break
switch_typed=$edited
check_email "$edited" || continue
email=$email_ok
licenses_now
done
[ "$lic_state" != ok ] || save_email
fi
fi
measure_disk
scan
draw
# Build tools are set up when an app is first built, not at the start.
# Start on the first licensed app, or on logging in.
sel=0
first_license_row
while :; do
# Finished work reports on the status line, and the next queued app
# starts; the screen redraws a few times a second while work runs.
service_background
polling=idle
if [ ! -f "$scratch/disk" ] || [ -n "$b_pid" ] || [ -n "$u_pid" ]; then polling=1; fi
if [ "$polling" = idle ] && [ "${key:-}" = none ]; then
ml_size=$(stty size <&3 2>/dev/null || :)
[ "$ml_size" = "${ml_drawn:-}" ] || { ml_drawn=$ml_size; draw; }
else
ml_drawn=$(stty size <&3 2>/dev/null || :)
draw
fi
key
[ "$key" = none ] && continue
message=
case "$key" in
up) [ "$items" = 0 ] || sel=$(( (sel + items - 1) % items )) ;;
down) [ "$items" = 0 ] || sel=$(( (sel + 1) % items )) ;;
q) exit 0 ;;
esc) exit 0 ;;
c | s) select_row; background_key "$key" "$id" ;;
enter)
select_row
case "$screen/$id" in
main/account | main/login) switch_account ;;
main/gpu) gpu_screen || : ;;
main/terms) terms_screen ;;
main/rust) if [ "$m_rust_ok" = 1 ]; then change_rust; else install_build_tools || :; fi ;;
main/system)
busy 'Checking the system developer tools'
if tools_check; then message="${ok}✓${r0} Compiler, SDK, linker and git are ready."; else install_build_tools || :; fi
scan ;;
main/disk) clear_build ;;
main/updates) check_updates ;;
main/free) free_open=$((1 - free_open)) ;;
main/all) compile_all ;;
main/agent-shell) open_shell ;;
main/agent-*) launch_agent "${id#agent-}" || : ;;
main/app:*) open_app "${id#app:}" ;;
esac ;;
esac
done
MAKEPAD_BUILDER
chmod 700 "$makepad_dir/makepad"
if [ "$#" = 0 ]; then
# The Builder in this terminal (its keys come from the terminal, not
# from the pipe this script arrived through); it copies itself into
# the folder you choose.
exec sh "$makepad_dir/makepad" </dev/null
fi
# A command (the Rust Builder's read-only probes): run it and tidy up.
makepad_status=0
sh "$makepad_dir/makepad" "$@" || makepad_status=$?
rm -f "$makepad_dir/makepad"
rmdir "$makepad_dir" 2>/dev/null || :
return "$makepad_status"
}
makepad_builder "$@"