Files
buzz/desktop/src-tauri/build.rs
Matt Toohey 602e11b605 fix(desktop): embed the Project Canvas seed template in the binary
Seeding a project's first canvas read `resources/project-canvas-template`
from disk at activation time, resolved from the running executable's
directory in dev builds. That directory is the cargo target directory —
shared across worktrees, rewritten by sibling builds, and deleted wholesale
by toolchain cleanups. When it disappeared under a running app the
first-ever activation of `buzz-bugs` failed with `inspect project canvas
path: No such file or directory (os error 2)` and could not self-heal.

Make the seed bytes part of the binary and drop the on-disk copy, so no
runtime lookup exists to go stale. This follows `resources/pocket-voices`,
which already embeds 7.6 MB of `.wav` through `include_bytes!` and is
deliberately absent from `bundle.resources`. The template is consumed only
as seed bytes: agents author canvases from `CANVASES/index.json` →
`sourcePath`, never from the template, so the disk copy had no remaining
consumer.

- New `project_canvas_package/template.rs` embeds the tree with
  `include_dir!` and walks it into a path-keyed map, running every key
  through `validate_relative_path` and skipping `.DS_Store` exactly as the
  on-disk scan does. The validated package is cached in a `OnceLock` so the
  SHA-256 over 2.7 MB runs once per process, and validation happens on the
  blocking task rather than the command's async caller.
- `build.rs` gains `cargo:rerun-if-changed=resources/project-canvas-template`.
  `include_dir!` expands to one `include_bytes!` per file, so rustc notices
  edits but not a file added or removed; without this line the binary can
  ship a stale file list.
- `scan_package` splits into `ensure_package_dir` + `validate_package_files`.
  The latter is now the single validation gate for both disk-read and
  embedded packages — there is no second, weaker path for the template.
  `prepare_snapshot`, `seed_if_missing`, `get_or_activate`, and `activate`
  take `Option<&ValidatedPackage>`; `accept_agent_update` still passes
  `None` so an agent update can never seed a canvas that does not exist.
- Delete `template_path`/`template_path_from`, the `AppHandle` parameter
  they forced onto two commands, and `bundle.resources` in
  `tauri.conf.json`. With no on-disk copy there is no second source of truth
  to go stale, and the `.app` bundle shrinks by 2.7 MB.
- `ensure_package_dir` replaces `ensure_no_symlink` + `Path::is_dir()` with
  one non-following `symlink_metadata`, so an absent directory is named
  instead of dying unnamed inside the metadata call. Strictly stronger: the
  symlink check still runs on non-following metadata, and the directory test
  now reads off that metadata rather than a link-following `is_dir()`.

Tests: `bundled_template_seeds_a_valid_snapshot` and
`first_activation_seeds_the_validated_template` repoint at the embedded
package and an in-memory map. New `bundled_template_contains_the_expected_tree`
compares the embedded key set against a test-time walk of the source tree,
`bundled_template_paths_are_package_safe` asserts every key is relative and
traversal-free, `missing_package_directory_names_the_path` pins the new
error ordering, and `package_root_symlink_is_rejected` covers the package
root itself, which no existing test did. Each was checked falsifiable:
dropping one embedded file fails the first two, restoring the old ordering
fails the third, and removing the symlink arm fails the fourth.

Verified: Tauri suite passes 3077 tests, clippy -D warnings, rustfmt, and
the file-size gate are clean. `cargo build --bin buzz-desktop` emits no
`resources/` directory next to the executable, so the built binary has no
on-disk template to read. A live canvas open was not exercised — that needs
an interactive `just staging` session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
2026-09-01 16:10:14 +10:00

171 lines
8.0 KiB
Rust

// Shared schema, included from the same source the runtime command parses with,
// so the build-time validation below and the runtime parse cannot drift.
include!("src/commands/reconnect_hook_config.rs");
// Same source of truth the runtime filters with, so a baked build env cannot
// carry a reserved key the runtime believes it already rejected.
include!("src/managed_agents/reserved_env_keys.rs");
use base64::Engine as _;
fn main() {
println!("cargo:rerun-if-env-changed=BUZZ_RELAY_URL");
println!("cargo:rerun-if-env-changed=BUZZ_RELAY_HTTP");
println!("cargo:rerun-if-env-changed=BUZZ_UPDATER_PUBLIC_KEY");
println!("cargo:rerun-if-env-changed=BUZZ_UPDATER_ENDPOINT");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_BUZZ_AGENT_PROVIDER");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_BUZZ_AGENT_MODEL");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ENV");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY");
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_DEMO_SLUG");
println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)");
// `include_dir!` embeds the Project Canvas seed template through one
// `include_bytes!` per file, so rustc already tracks edits to existing
// files — but not a file being added or removed. Without this the binary
// can silently ship a stale file list.
println!("cargo:rerun-if-changed=resources/project-canvas-template");
if let Ok(slug) = std::env::var("BUZZ_BUILD_DEMO_SLUG") {
let valid = !slug.is_empty()
&& slug.len() <= 48
&& slug
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
&& slug
.bytes()
.next()
.is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
&& slug
.bytes()
.last()
.is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit());
if !valid {
panic!("BUZZ_BUILD_DEMO_SLUG must be a lowercase ASCII slug");
}
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_DEMO_SLUG={slug}");
}
// Explicit owner-only agent-access capability. Release packaging sets this
// presence-only marker; OSS/custom builds leave agent access configurable.
if std::env::var("BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY").is_ok() {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_ACCESS_OWNER_ONLY=1");
}
if let Ok(relay_url) = std::env::var("BUZZ_RELAY_URL") {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_URL={relay_url}");
}
if let Ok(relay_http) = std::env::var("BUZZ_RELAY_HTTP") {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_HTTP={relay_http}");
}
if let Ok(provider) = std::env::var("BUZZ_BUILD_BUZZ_AGENT_PROVIDER") {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_BUZZ_AGENT_PROVIDER={provider}");
}
if let Ok(model) = std::env::var("BUZZ_BUILD_BUZZ_AGENT_MODEL") {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_BUZZ_AGENT_MODEL={model}");
}
// Generic KEY=VALUE pairs to inject into every spawned agent process.
// Newline-delimited; each line must be non-empty and contain exactly one
// `=` separator with a non-empty key. OSS builds leave this unset.
// The validated value is base64-encoded before emitting so the single-line
// Cargo build-script output carries all pairs (Cargo output is line-oriented;
// a raw multiline value would be silently truncated to the first line).
if let Ok(raw) = std::env::var("BUZZ_BUILD_AGENT_ENV") {
for (line_no, line) in raw.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let eq = line.find('=').unwrap_or_else(|| {
panic!(
"BUZZ_BUILD_AGENT_ENV line {}: missing '=' separator in {:?}",
line_no + 1,
line
)
});
let key = &line[..eq];
if key.is_empty() {
panic!(
"BUZZ_BUILD_AGENT_ENV line {}: key must not be empty in {:?}",
line_no + 1,
line
);
}
// The baked env is written into every spawned agent's environment
// LAST (see `managed_agents/runtime.rs`), after Buzz sets the
// access gates and identity vars. A baked reserved key would
// therefore silently override the gate the UI promises, so reject
// it at build time instead of shipping a binary that bypasses its
// own enforcement.
if is_reserved_env_key(key) {
panic!(
"BUZZ_BUILD_AGENT_ENV line {}: `{}` is reserved by Buzz and cannot be baked \
into a build (it would override Buzz's own identity/access env)",
line_no + 1,
key
);
}
}
let encoded = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_ENV={encoded}");
}
if let Ok(val) = std::env::var("BUZZ_BUILD_RELAY_RECONNECT_CMD") {
let parsed: serde_json::Value = serde_json::from_str(&val)
.unwrap_or_else(|e| panic!("BUZZ_BUILD_RELAY_RECONNECT_CMD is not valid JSON: {e}"));
serde_json::from_value::<ReconnectHookConfig>(parsed).unwrap_or_else(|e| {
panic!("BUZZ_BUILD_RELAY_RECONNECT_CMD doesn't match ReconnectHookConfig: {e}")
});
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_RECONNECT_CMD={val}");
}
// Presence-only release capability: internal desktop builds opt into
// auto-connecting their configured default relay on first run. OSS builds
// leave this unset and retain explicit community selection.
if std::env::var("BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY").is_ok() {
println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1");
}
let updater_public_key = std::env::var("BUZZ_UPDATER_PUBLIC_KEY")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let updater_endpoint = std::env::var("BUZZ_UPDATER_ENDPOINT")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
if updater_public_key.is_some() && updater_endpoint.is_some() {
println!("cargo:rustc-cfg=buzz_updater_enabled");
}
// Cargo test executables get no embedded Windows manifest (tauri_build
// attaches one to bin targets only), so the loader binds comctl32 v5, which
// lacks TaskDialogIndirect (statically imported via tauri-plugin-dialog/rfd)
// and debug test exes die at load with STATUS_ENTRYPOINT_NOT_FOUND. Declaring
// the Common Controls v6 dependency makes link.exe emit a side-by-side
// <exe>.manifest that the loader honors for manifest-less executables;
// binaries with an embedded manifest (the real app) ignore it.
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
&& std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc")
{
println!(
"cargo:rustc-link-arg=/MANIFESTDEPENDENCY:type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'"
);
}
tauri_build::try_build(
tauri_build::Attributes::new().plugin(
"websocket",
tauri_build::InlinedPlugin::new()
.commands(&["connect", "send", "disconnect", "disconnect_all"])
.default_permission(tauri_build::DefaultPermissionRule::AllowAllCommands),
),
)
.expect("failed to build Tauri application");
}