Files
qbz/scripts/qt-run.sh
T
vicrodh 3cfce8a769 feat(qt): the media-server settings panel, driven and verified in the window
ONE component instantiated twice, because it is one form twice. Every
difference between the protocols is a property set at the call site, so
they are visible in one place instead of drifting across two files:
"Test" proves the address for Jellyfin and only "a Subsonic server
answered" for Subsonic (which has no credential-free endpoint), the
password is discarded for Jellyfin and kept for Subsonic (no session,
md5(password+salt) per request), and the sync cost is stated up front —
45.8 s per 4924 tracks against 0.81 s per 6678 — so a long sweep does not
read as a hang. The credential never leaves Rust; the panel gets
`hasCredential`.

Assets: the colour marks for cards, the monochrome ones for rows. Both
mono files arrived as BLACK ink and are invisible on a dark theme
untinted, so they were normalised to the #ffffff master ink and joined
`icon_tint_qt::MASTERS` — the port's only shader-free way to recolour an
SVG per theme. The Jellyfin colour asset had a white rounded-rect plate
behind the mark; it was stripped, because every other brand mark here is
transparent and a white tile beside them reads as a rendering bug.
Rendered with Qt itself and looked at before any of it was wired.

CAVEAT, recorded in SourceIcon: the Subsonic mark is NAVIDROME's logo,
and `subsonic` covers Gonic, Airsonic, Astiga and Ampache too. `ping`
reports the server's own type and it is already persisted, so branching
later is cheap — a known wrongness, not an unnoticed one.

THREE BUGS THAT ONLY THE RUNNING WINDOW COULD SHOW, all found by
driving it under Xvfb rather than by reading the log:

1. `MediaServerSettings is not a type` — a .qml missing from build.rs is
   not in the QML module. It resolves fine on disk, compiles nothing,
   and blanks the WHOLE settings page at runtime. All three existing
   audits passed. `qml_module_registration_audit.py` is the missing
   check and is now in the gate.
2. The toggle would not move. The panel reads its state from the
   settings DOCUMENT, unlike Plex's toggle which reads a bridge
   property, so every mutation has to republish it — connect, enable,
   disconnect and the end of a sweep now do.
3. Every Jellyfin album in the grid drew the LOCAL HARD-DRIVE badge:
   `local_rows::badge_source` had never been taught the two words and
   folded them into `_ => local`. Not cosmetic — that string is what the
   source chips filter on.

`play_single_from_source` also grew the arm it needed: a media-server row
from the recently-played rail has no library.db row to mint a LocalTrack
from, so it resolves through `registry.tracks_of` instead.

END TO END, from the UI, against the real server: toggle on -> type the
address -> Connect -> "jellyfin sync: 500/4924 ... 4924 saved, 0 pruned,
4924 cached" -> Local Library shows 461 albums with their covers and the
Jellyfin badge. The cached quality distribution is identical to what the
server reported in the original protocol sweep.

428/428.

cc
2026-08-20 10:57:28 -06:00

280 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# QBZ Qt/QML — build with cargo, then run the BINARY DIRECTLY.
#
# The Qt-track sibling of slint-run.sh. Same shape (build, learned ETA, exec the
# prebuilt binary so process monitors show `qbz-qt` instead of `cargo`), but the
# constraints are almost the opposite — read the two sections below before
# "improving" this script into a copy of slint-run.sh.
#
# ─── THERE IS NO MEMORY WALL HERE ───────────────────────────────────────────
# slint-run.sh tiers threads/codegen-units/opt-level off MemAvailable and runs
# under a cgroup cap because ONE rustc for `qbz-ui` (the ~1.6 M-line generated
# Slint module) peaks 20-30 GB and can freeze the box.
# `qbz-qt` does NOT depend on `qbz-ui` — verified: zero `qbz-ui` references in
# crates/qbz-qt/Cargo.toml, so the monster compilation unit never enters this
# graph. The UI lives in QML, which does not go through rustc at all; that is
# the entire point of the Qt track. So: no tiering, no cap, ordinary jobs.
# The box-wide "ONE build at a time" rule still applies (a Slint build running
# in another worktree WILL freeze the machine) — hence the guard below.
#
# ─── THE CACHE RULE — WHY THIS SCRIPT SETS NO RUSTFLAGS ─────────────────────
# RUSTFLAGS is part of every unit's fingerprint. This worktree's target dir was
# built on STABLE with rustflags=[] (checked in the .fingerprint json). Exporting
# anything — mold, `-Z threads`, a target-feature — invalidates the whole
# workspace and buys a from-scratch rebuild of ~200 crates to save seconds on a
# ~2-minute incremental. Same reason there is no `+nightly` here: nothing in
# qbz-qt needs it, and switching toolchains is another full rebuild.
# MOLD=1 opts in anyway (it warns first). If you use it, keep using it.
#
# ─── THE QML AUDITS RUN FIRST, BY DESIGN ────────────────────────────────────
# `cargo check` sees NOTHING of the QML: a missing component or a call to a
# bridge member that does not exist compiles clean and throws when the user
# opens that view. The two static audits are ~1s and catch exactly that, so they
# run BEFORE the build — failing in one second beats failing after two minutes,
# or worse, at the owner's smoke. NO_AUDIT=1 skips them.
#
# Usage: ./scripts/qt-run.sh [extra app args]
# DEBUG=1 ./scripts/qt-run.sh # debug profile (the binary the gate uses)
# NORUN=1 ./scripts/qt-run.sh # build only, don't exec
# TEST=1 ./scripts/qt-run.sh # also run `cargo test -p qbz-qt`
# SMOKE=1 ./scripts/qt-run.sh # offscreen gate instead of the GUI
# NO_AUDIT=1 ./scripts/qt-run.sh # skip the two QML audits
# JOBS=4 ./scripts/qt-run.sh # cargo build jobs (default: auto)
# MOLD=1 ./scripts/qt-run.sh # link with mold (forces a full rebuild)
# FORCE=1 ./scripts/qt-run.sh # build even if another cargo/rustc is up
# NO_TICKER=1 ./scripts/qt-run.sh # no live progress ticker
set -euo pipefail
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.."
QT_CRATE="crates/qbz-qt"
AUDIT_DIR="$HOME/Personal/qbz/qbz-nix-docs/qt-frontend/tools"
# --- Portability shims (this runs on the Mac mini too) -----------------------
# Everything below that differs between GNU/Linux and BSD/macOS lives here, so
# the body reads the same on both. On macOS the audits also live elsewhere (the
# docs repo is not synced to the build box), so a missing AUDIT_DIR degrades to
# a warning rather than an error — see the audit block.
UNAME_S="$(uname -s)"
# Available RAM in MB. macOS has no `free`; vm_stat's "free + inactive" pages
# are the honest analogue of MemAvailable (inactive is reclaimable).
mem_avail_mb() {
if command -v free >/dev/null 2>&1; then
free -m | awk '/^Mem:/ {print $7}'
elif [[ "${UNAME_S}" == "Darwin" ]]; then
vm_stat | awk '/page size of/ {ps=$8} /Pages free/ {f=$3} /Pages inactive/ {i=$3}
END { gsub(/\./,"",f); gsub(/\./,"",i); if (ps=="") ps=16384;
printf "%d", (f+i)*ps/1048576 }'
else
echo 0
fi
}
cpu_count() {
if command -v nproc >/dev/null 2>&1; then nproc
elif [[ "${UNAME_S}" == "Darwin" ]]; then sysctl -n hw.ncpu
else echo 4; fi
}
# BSD pgrep has no -c and uses -l where GNU uses -a, so count lines instead.
# The `|| true` is load-bearing: pgrep exits 1 when it matches nothing, which is
# the NORMAL case here, and under `set -euo pipefail` that non-zero status
# propagates out of the command substitution and kills the script — silently,
# before a single line of output, looking exactly like a build that did nothing.
# (It did exactly that once. The tell was an empty log with rc=1.)
other_builds() { { pgrep -x rustc; pgrep -x cargo; } 2>/dev/null || true; }
# `timeout` is GNU coreutils and is simply ABSENT on macOS, where the shell
# answers `command not found` — which, inside the smoke block, produced a
# one-line log that then reported "0 QML complaints" and looked like a pass.
# A smoke that never started must never read as green, so this shims it.
run_timeout() {
local secs=$1; shift
if command -v timeout >/dev/null 2>&1; then timeout "${secs}" "$@"; return $?; fi
if command -v gtimeout >/dev/null 2>&1; then gtimeout "${secs}" "$@"; return $?; fi
"$@" & local pid=$!
( sleep "${secs}"; kill "${pid}" 2>/dev/null ) & local watcher=$!
wait "${pid}" 2>/dev/null; local rc=$?
kill "${watcher}" 2>/dev/null || true
return "${rc}"
}
# cxx-qt-build finds Qt through qmake / CMAKE_PREFIX_PATH. Homebrew keeps it
# off PATH by design (qt is keg-only-ish in practice), so a plain shell has no
# qmake and the build dies in build.rs rather than in the compiler. Add it when
# it is there; say nothing when it is not, so Linux is untouched.
if [[ "${UNAME_S}" == "Darwin" ]]; then
for qtdir in /opt/homebrew/opt/qt /usr/local/opt/qt; do
if [[ -x "${qtdir}/bin/qmake" ]]; then
export PATH="${qtdir}/bin:${PATH}"
export CMAKE_PREFIX_PATH="${qtdir}${CMAKE_PREFIX_PATH:+:${CMAKE_PREFIX_PATH}}"
break
fi
done
fi
# --- Pretty helpers ----------------------------------------------------------
if [[ -t 2 ]]; then C_DIM=$'\033[2m'; C_BOLD=$'\033[1m'; C_GRN=$'\033[32m'
C_RED=$'\033[31m'; C_YEL=$'\033[33m'; C_RST=$'\033[0m'
else C_DIM=""; C_BOLD=""; C_GRN=""; C_RED=""; C_YEL=""; C_RST=""; fi
fmt_dur() { local s=$1; printf '%dm %02ds' $(( s / 60 )) $(( s % 60 )); }
say() { printf '[qt-run] %s\n' "$*"; }
warn() { printf '%s[qt-run] %s%s\n' "${C_YEL}" "$*" "${C_RST}" >&2; }
die() { printf '%s[qt-run] %s%s\n' "${C_RED}" "$*" "${C_RST}" >&2; exit 1; }
if [[ "${DEBUG:-0}" == 1 ]]; then
PROFILE=debug; PROFILE_ARGS=()
else
PROFILE=release; PROFILE_ARGS=(--release)
fi
BIN="crates/target/${PROFILE}/qbz"
# --- ONE BUILD AT A TIME, BOX-WIDE ------------------------------------------
# Not paranoia: a Slint `qbz-ui` rustc in another worktree peaks 20-30 GB on a
# 30 GB box with no hibernation, and the failure mode is a swap-thrash livelock
# that needs a power-cycle — earlyoom does not reliably fire. Cheap to check.
if [[ "${FORCE:-0}" != 1 ]]; then
others=$(other_builds | wc -l | tr -d ' ')
[[ "${others}" =~ ^[0-9]+$ ]] || others=0
if (( others > 0 )); then
die "another cargo/rustc is running (${others}). Builds are serialized box-wide — wait, or FORCE=1 if you know it is harmless."
fi
fi
# --- The QML audits (fail fast; cargo cannot see QML) ------------------------
if [[ "${NO_AUDIT:-0}" != 1 ]]; then
if [[ -d "${AUDIT_DIR}" ]] && command -v python3 >/dev/null 2>&1; then
audit_abs="$(pwd)/${QT_CRATE}"
# `qml_module_registration_audit.py` is the newest and it exists because the
# other three could not see the failure it catches: a .qml on disk that is
# missing from build.rs is not in the QML MODULE, so it resolves fine on
# disk, compiles nothing, and blanks a whole page at runtime with
# "<Component> is not a type" (2026-08-20, the media-server panel).
for a in qml_resolution_audit.py qml_singleton_xref.py qml_eager_tab_audit.py qml_module_registration_audit.py; do
[[ -r "${AUDIT_DIR}/${a}" ]] || { warn "audit ${a} not found — skipped"; continue; }
if ! python3 "${AUDIT_DIR}/${a}" "${audit_abs}"; then
die "${a} FAILED — fix it before burning a build (QML resolves lazily; this is what cargo cannot tell you)."
fi
done
say "QML audits OK"
else
warn "audit tools not found at ${AUDIT_DIR} — skipped"
fi
fi
# --- Build settings ----------------------------------------------------------
# Deliberately NOT setting CARGO_PROFILE_RELEASE_* (opt-level / codegen-units):
# they are fingerprint inputs too, and unlike the Slint track there is no memory
# reason to lower them. The package's own [profile.release] (strip="symbols")
# is the whole story.
if [[ -n "${JOBS:-}" ]]; then
export CARGO_BUILD_JOBS="${JOBS}"
else
# Leave a couple of cores for the desktop; cargo's default is all of them.
ncpu=$(cpu_count)
export CARGO_BUILD_JOBS="$(( ncpu > 3 ? ncpu - 2 : 1 ))"
fi
if [[ "${MOLD:-0}" == 1 ]]; then
command -v mold >/dev/null 2>&1 || die "MOLD=1 but mold is not installed"
warn "MOLD=1 changes RUSTFLAGS, which is a fingerprint input → this rebuilds the WHOLE workspace once. Keep using it, or never use it."
export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-fuse-ld=mold"
fi
# --- Learned ETA: last successful build duration for THIS profile ------------
eta_dir="${XDG_CACHE_HOME:-$HOME/.cache}/qbz-qt"
eta_file="${eta_dir}/last-build-${PROFILE}.secs"
eta_secs=0
[[ -r "${eta_file}" ]] && eta_secs=$(cat "${eta_file}" 2>/dev/null || echo 0)
[[ "${eta_secs}" =~ ^[0-9]+$ ]] || eta_secs=0
avail_mb=$(mem_avail_mb)
say "profile=${PROFILE} jobs=${CARGO_BUILD_JOBS} avail=${avail_mb}MB rustflags='${RUSTFLAGS:-}'"
# --- Start banner ------------------------------------------------------------
build_start=$(date +%s)
if (( eta_secs > 0 )); then eta_txt="~$(fmt_dur "${eta_secs}") (last ${PROFILE})"
else eta_txt="unknown (first ${PROFILE} build)"; fi
printf '%s[qt-run] ▶ build started %s · ETA %s%s\n' \
"${C_BOLD}" "$(date '+%H:%M:%S')" "${eta_txt}" "${C_RST}"
# --- Live ticker -------------------------------------------------------------
# 30s, not slint-run's 15s: a warm qbz-qt build is ~2 min, so 15s would print
# more ticker than build. A cold one (or the first after `cargo clean`) is long
# enough that the ticker earns its place.
tick_pid=""
if [[ "${NO_TICKER:-0}" != 1 ]] && [[ -t 2 ]]; then
(
while true; do
sleep 30
now=$(date +%s); el=$(( now - build_start ))
if (( eta_secs > 0 )); then
pct=$(( el * 100 / eta_secs )); (( pct > 99 )) && pct=99
printf '%s[qt-run] ⏱ %s elapsed · ETA ~%s · ~%d%%%s\n' \
"${C_DIM}" "$(fmt_dur "${el}")" "$(fmt_dur "${eta_secs}")" "${pct}" "${C_RST}" >&2
else
printf '%s[qt-run] ⏱ %s elapsed%s\n' "${C_DIM}" "$(fmt_dur "${el}")" "${C_RST}" >&2
fi
done
) &
tick_pid=$!
trap '[[ -n "${tick_pid}" ]] && kill "${tick_pid}" 2>/dev/null || true' EXIT
fi
# --- The build ---------------------------------------------------------------
cargo build "${PROFILE_ARGS[@]}" --manifest-path crates/Cargo.toml -p qbz-qt
# --- Stop the ticker, record the duration, print the final banner ------------
[[ -n "${tick_pid}" ]] && { kill "${tick_pid}" 2>/dev/null || true; wait "${tick_pid}" 2>/dev/null || true; }
trap - EXIT
build_secs=$(( $(date +%s) - build_start ))
mkdir -p "${eta_dir}" 2>/dev/null && printf '%s\n' "${build_secs}" > "${eta_file}" 2>/dev/null || true
printf '%s[qt-run] ✔ build finished %s · took %s (%s)%s\n' \
"${C_BOLD}${C_GRN}" "$(date '+%H:%M:%S')" "$(fmt_dur "${build_secs}")" "${PROFILE}" "${C_RST}"
# --- Tests (opt-in) ----------------------------------------------------------
if [[ "${TEST:-0}" == 1 ]]; then
say "running cargo test -p qbz-qt"
cargo test --manifest-path crates/Cargo.toml -p qbz-qt
fi
# --- Offscreen smoke gate (opt-in) -------------------------------------------
# The last step of the track's standard gate: boot the real app with no display
# and prove the QML tree actually resolves. A lazily-resolved type error only
# ever shows up here or in front of the owner.
if [[ "${SMOKE:-0}" == 1 ]]; then
# BSD mktemp -t takes a bare prefix, GNU takes a template; give both a full
# template path so the two agree.
log="$(mktemp "${TMPDIR:-/tmp}/qbz-qt-smoke-XXXXXX")"
say "offscreen smoke (75s max) → ${log}"
QT_QPA_PLATFORM=offscreen RUST_LOG=info run_timeout 75 "./${BIN}" > "${log}" 2>&1 || true
# A log this short means the process never really started (the macOS
# `timeout`-not-found case did exactly that). Zero complaints in an empty log
# is not a pass.
if (( $(wc -l < "${log}") < 10 )); then
cat "${log}" >&2
die "smoke produced almost no output — the app did not start; see ${log}"
fi
# `propertyCache` from SettingsButton is known-benign noise — filter it out
# rather than let it mask a real count.
errs=$(grep -av 'propertyCache' "${log}" \
| grep -aciE 'is not a type|unavailable|ReferenceError|TypeError|Cannot read|Unable to assign|Cannot open|no such method|has no' || true)
published=$(grep -ac 'home published' "${log}" || true)
say "smoke: qml complaints=${errs} (want 0) · 'home published'=${published} (want >=1)"
if (( errs > 0 )); then
grep -av 'propertyCache' "${log}" \
| grep -aiE 'is not a type|unavailable|ReferenceError|TypeError|Cannot read|Unable to assign|Cannot open|no such method|has no' | head -20 >&2
die "smoke FAILED — see ${log}"
fi
(( published >= 1 )) || die "smoke FAILED: the app never reached 'home published' — see ${log}"
printf '%s[qt-run] ✔ smoke gate PASSED%s\n' "${C_BOLD}${C_GRN}" "${C_RST}"
exit 0
fi
[[ "${NORUN:-0}" == 1 ]] && { say "build done (NORUN set)."; exit 0; }
# The Slint qbz holds the DAC exclusively — two players cannot open it at once.
if pgrep -x qbz >/dev/null 2>&1; then
warn "the Slint 'qbz' is running — close it first or the audio device stays busy (ALSA exclusive)."
fi
# exec the binary directly — no `cargo run`, so no CARGO_* env / cargo context,
# so process monitors show `qbz-qt` rather than `cargo`.
exec "./${BIN}" "$@"