From bffb4ace85d6bae85120d7d041fe2a28b41cf719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 18 Jun 2026 13:51:56 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20unblock=20main=20CI=20=E2=80=94=20dead-s?= =?UTF-8?q?tripped=20symbol=20+=20cargo-test=20staticlib=20+=20oversized?= =?UTF-8?q?=20link/mod.rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pre-existing CI fragilities on main turning required checks red for PRs: 0. cargo-test runs 'cargo test -p perry-runtime', which never builds the staticlib crate-type, so libperry_runtime.a/libperry_stdlib.a only exist from cache. A PR touching perry-runtime invalidates the cached staticlib and cargo never rebuilds it -> PERRY_NO_AUTO_OPTIMIZE=1 tests fail with 'Could not find libperry_runtime.a'. Fix: add 'cargo build -p perry-runtime -p perry-stdlib' to the cargo-test job before the integration-test loop. 1. js_array_numeric_value_to_raw_f64 (#5291) is #[no_mangle] but called only from generated code, so the linker dead-strips it from libperry_runtime.a -> undefined symbol at link. Fix: #[used] keepalive anchor. 2. link/mod.rs crossed the 2000-line file-size gate (2132) after #5400. Split the ~1770-line build_and_run_link orchestrator into link/build_and_run.rs. --- .github/workflows/test.yml | 11 + CHANGELOG.md | 62 + CLAUDE.md | 2 +- Cargo.lock | 148 +- Cargo.toml | 2 +- crates/perry-runtime/src/array/header.rs | 11 + .../commands/compile/link/build_and_run.rs | 1788 +++++++++++++++++ crates/perry/src/commands/compile/link/mod.rs | 1780 +--------------- 8 files changed, 1950 insertions(+), 1854 deletions(-) create mode 100644 crates/perry/src/commands/compile/link/build_and_run.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3478197d2..072985327 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -305,6 +305,17 @@ jobs: # flakes) and races the GC/threading tests into intermittent SIGSEGV. # Run perry-runtime single-threaded so the tests can't interfere. RUST_TEST_THREADS=1 cargo test -p perry-runtime + # `cargo test` only builds lib/bin/test targets — NOT the `staticlib` + # crate-type — so libperry_runtime.a / libperry_stdlib.a are never + # produced by the steps above; they only exist if restored from the + # cache. A PR that touches perry-runtime/perry-stdlib invalidates the + # cached staticlib and cargo never rebuilds it, so integration tests + # that compile with PERRY_NO_AUTO_OPTIMIZE=1 (e.g. + # functional_batch2_regressions, which link the prebuilt archive + # directly) fail with "Could not find libperry_runtime.a". Build the + # staticlibs explicitly so those tests are deterministic regardless of + # cache state. + cargo build -p perry-runtime -p perry-stdlib find target/debug/deps -maxdepth 1 -type f -perm -111 ! -name '*.so' -delete # The remaining workspace includes large `perry` / `perry-stdlib` # test binaries. Keep Cargo build jobs serialized so the runner diff --git a/CHANGELOG.md b/CHANGELOG.md index 035144adf..da2dce4d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,65 @@ +## v0.5.1184 — fix: unblock main CI — dead-stripped symbol + cargo-test staticlib + oversized link/mod.rs + +Three pre-existing CI fragilities on `main` were turning required checks red for +PRs. Fixed together since all are "make main's CI green again." + +### 0. cargo-test never builds the runtime staticlib (latent; surfaced by fix #1) + +The `cargo-test` job runs `cargo test -p perry-runtime`, which only builds +lib/bin/test targets — **not** the `staticlib` crate-type — so +`libperry_runtime.a` / `libperry_stdlib.a` are never produced by the job and only +exist when restored from the rust-cache. Integration tests that compile with +`PERRY_NO_AUTO_OPTIMIZE=1` (e.g. `functional_batch2_regressions`) link the +prebuilt archive directly, so the moment a PR touches perry-runtime/perry-stdlib +the cached staticlib is invalidated, cargo never rebuilds it, and those tests fail +with `Could not find libperry_runtime.a`. Fix #1 below touches perry-runtime and +hit exactly this. Fix: add an explicit `cargo build -p perry-runtime -p +perry-stdlib` to the cargo-test job before the integration-test loop so the +staticlibs exist regardless of cache state. + +### 1. dead-stripped runtime symbol breaks cold runtime-only compiles (cargo-test) + +`js_array_numeric_value_to_raw_f64` (added by #5291 for representation-aware +numeric array lowering, `crates/perry-runtime/src/array/header.rs`) is +`#[no_mangle]` but is only ever called from generated machine code — nothing in +the runtime crate references it. Without a `#[used]` anchor the linker dead-strips +it from `libperry_runtime.a`, so any cold `PERRY_NO_AUTO_OPTIMIZE=1` compile of a +program that triggers that lowering fails at link with `Undefined symbols: +_js_array_numeric_value_to_raw_f64`. This surfaced as 5 failing +`functional_batch2_regressions` tests. Fix: add the `#[used]` keepalive anchor +(same pattern as the auto-optimize keepalives in `error.rs`; see +project_autoopt_ffi_symbol_link_break). This regression class is normally +CI-invisible because warm staticlib caches mask it. + +### 2. link/mod.rs over the file-size gate (lint) + +`crates/perry/src/commands/compile/link/mod.rs` crossed the 2000-line file-size +gate (2132 lines) after #5400 grew the Windows response-file path, turning the +`lint` job red for every PR branched off main. Split the single ~1770-line +`build_and_run_link` orchestrator (the only oversized item) into a sibling +`link/build_and_run.rs`, leaving mod.rs at 357 lines and the new file at 1785. +Pure mechanical move (`use super::*`; `build_and_run_link` is now `pub(crate)`, +re-exported from mod.rs; five compile-module paths became `super::super::…`). + +Pure mechanical move, no behavior change: + +- The function moved verbatim; `use super::*` pulls in every helper that stays in + the parent `link` module (the `NativeBackendLinkMetadata` selection, the + `resolve_optional_framework_dir` / `find_project_root_for` / + `rewrite_link_with_response_file` / `quote_response_arg` / `response_file_contents` + helpers, and the sibling-module re-exports). +- `build_and_run_link` is now `pub(crate)` (was `pub(super)`) so mod.rs can + re-export it to the parent `compile` module via + `pub(super) use build_and_run::build_and_run_link;`. +- The five fully-qualified paths that reached into the `compile` module + (`library_search::`, `sandbox_buildrs::`, `optimized_libs::`, + `run_lock_verify_for_compile`, `find_perry_workspace_root`) became + `super::super::…` from the new two-level-deep module. + +All 599 perry bin unit tests pass (including the link `response_file_tests` / +`native_package_selection_tests` / `optional_framework_dir_tests`); a hello-world +compile+link round-trips through the moved driver. + ## v0.5.1183 — feat(hir): ambient `require` in compiled compilePackages modules — Tier 1 of #5389 (fixes #5373) A bare or **computed** `require(expr)` inside a `compilePackages`-compiled module diff --git a/CLAUDE.md b/CLAUDE.md index 1ea8f05e8..405341a5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1183 +**Current Version:** 0.5.1184 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index cb6101452..2ef5044b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5283,7 +5283,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "anyhow", "base64", @@ -5340,14 +5340,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "cc", "libc", @@ -5355,7 +5355,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "anyhow", "log", @@ -5370,7 +5370,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "anyhow", "perry-hir", @@ -5379,7 +5379,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "anyhow", "perry-hir", @@ -5387,7 +5387,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "anyhow", "perry-dispatch", @@ -5397,7 +5397,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "anyhow", "perry-hir", @@ -5406,7 +5406,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "anyhow", "base64", @@ -5419,7 +5419,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "anyhow", "perry-hir", @@ -5427,7 +5427,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "anyhow", "async-trait", @@ -5456,14 +5456,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "serde", "serde_json", @@ -5471,7 +5471,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1183" +version = "0.5.1184" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5482,7 +5482,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "anyhow", "clap", @@ -5497,14 +5497,14 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-argon2" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "argon2", "perry-ffi", @@ -5512,7 +5512,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "perry-ffi", "reqwest", @@ -5521,7 +5521,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "bcrypt", "perry-ffi", @@ -5529,7 +5529,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "perry-ffi", "rusqlite", @@ -5537,7 +5537,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "perry-ffi", "scraper", @@ -5545,7 +5545,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "perry-ffi", "perry-runtime", @@ -5553,7 +5553,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "chrono", "cron 0.16.0", @@ -5563,7 +5563,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "chrono", "perry-ffi", @@ -5571,7 +5571,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "perry-ffi", "rust_decimal", @@ -5579,7 +5579,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "perry-ffi", "serde_json", @@ -5587,7 +5587,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "perry-ffi", "rand 0.8.6", @@ -5595,7 +5595,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "perry-ffi", "perry-runtime", @@ -5603,14 +5603,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "bytes", "http-body-util", @@ -5627,7 +5627,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "lazy_static", "perry-ffi", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "lazy_static", "perry-ext-http-server", @@ -5652,7 +5652,7 @@ dependencies = [ [[package]] name = "perry-ext-http-server" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "bytes", "h2", @@ -5675,7 +5675,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "lazy_static", "perry-ffi", @@ -5685,7 +5685,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "base64", "jsonwebtoken", @@ -5696,7 +5696,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "lru", "perry-ffi", @@ -5704,7 +5704,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "chrono", "perry-ffi", @@ -5712,7 +5712,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "bson", "futures-util", @@ -5724,7 +5724,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "chrono", "perry-ffi", @@ -5734,7 +5734,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "nanoid", "perry-ffi", @@ -5743,7 +5743,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "perry-ffi", "perry-runtime", @@ -5755,7 +5755,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "lettre", "perry-ffi", @@ -5765,7 +5765,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "perry-ffi", "printpdf", @@ -5773,7 +5773,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "perry-ffi", "sqlx", @@ -5782,7 +5782,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "governor", "perry-ffi", @@ -5790,7 +5790,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "base64", "image", @@ -5799,14 +5799,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "lazy_static", "perry-ffi", @@ -5815,7 +5815,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "perry-ffi", "uuid", @@ -5823,7 +5823,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "perry-ffi", "regex", @@ -5833,7 +5833,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "futures-util", "lazy_static", @@ -5845,7 +5845,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "brotli", "flate2", @@ -5854,7 +5854,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "dashmap", "once_cell", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "anyhow", "perry-api-manifest", @@ -5881,7 +5881,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "anyhow", "perry-diagnostics", @@ -5893,7 +5893,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "anyhow", "base64", @@ -5926,7 +5926,7 @@ dependencies = [ [[package]] name = "perry-stdlib" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "aes 0.8.4", "aes-gcm", @@ -6018,7 +6018,7 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "anyhow", "perry-hir", @@ -6028,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-types" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "anyhow", "thiserror 1.0.69", @@ -6036,14 +6036,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "base64", "itoa", @@ -6060,7 +6060,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "rand 0.8.6", "serde", @@ -6070,7 +6070,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "base64", "cairo-rs", @@ -6093,7 +6093,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "base64", "block2", @@ -6109,7 +6109,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "base64", "block2", @@ -6124,7 +6124,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1183" +version = "0.5.1184" [[package]] name = "perry-ui-test" @@ -6132,11 +6132,11 @@ version = "0.1.0" [[package]] name = "perry-ui-testkit" -version = "0.5.1183" +version = "0.5.1184" [[package]] name = "perry-ui-tvos" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "base64", "block2", @@ -6152,7 +6152,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "base64", "block2", @@ -6168,7 +6168,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "block2", "libc", @@ -6181,7 +6181,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "base64", "libc", @@ -6198,14 +6198,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "base64", "ed25519-dalek", @@ -6219,7 +6219,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1183" +version = "0.5.1184" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 98b41fa59..007dd0612 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -215,7 +215,7 @@ strip = false codegen-units = 16 [workspace.package] -version = "0.5.1183" +version = "0.5.1184" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/crates/perry-runtime/src/array/header.rs b/crates/perry-runtime/src/array/header.rs index 402629e52..8b86b7482 100644 --- a/crates/perry-runtime/src/array/header.rs +++ b/crates/perry-runtime/src/array/header.rs @@ -744,6 +744,17 @@ pub extern "C" fn js_array_numeric_value_to_raw_f64(value: f64) -> f64 { value_bits_to_number(value.to_bits()).unwrap_or(f64::NAN) } +/// Keepalive anchor for the runtime-only link path (generated-code-only callee; +/// see project_autoopt_ffi_symbol_link_break). Representation-aware numeric array +/// lowering (#5291) emits calls to `js_array_numeric_value_to_raw_f64` from +/// generated machine code only — nothing in the runtime crate references it — so +/// without this `#[used]` anchor the linker dead-strips it from +/// `libperry_runtime.a`, breaking cold `PERRY_NO_AUTO_OPTIMIZE=1` compiles with +/// `Undefined symbols: _js_array_numeric_value_to_raw_f64`. +#[used] +static KEEP_JS_ARRAY_NUMERIC_VALUE_TO_RAW_F64: extern "C" fn(f64) -> f64 = + js_array_numeric_value_to_raw_f64; + #[inline] fn canonical_raw_f64(value: f64) -> f64 { if value.is_nan() { diff --git a/crates/perry/src/commands/compile/link/build_and_run.rs b/crates/perry/src/commands/compile/link/build_and_run.rs new file mode 100644 index 000000000..8390ee206 --- /dev/null +++ b/crates/perry/src/commands/compile/link/build_and_run.rs @@ -0,0 +1,1788 @@ +//! `build_and_run_link` — the executable link driver extracted from +//! `link/mod.rs` to keep that file under the file-size gate (#5400 grew the +//! Windows response-file path past the 2000-line ceiling). This is a verbatim +//! move of the single ~1770-line orchestrator; all helpers it calls remain in +//! the parent `link` module and are pulled in via `use super::*`. + +use super::*; + +/// Construct the platform-specific linker command, append every required +/// argument (object files, libraries, frameworks, system libs, native libs, +/// geisterhand libs), invoke it, and bail on non-zero status. +/// +/// Caller must have already handled the dylib output path; this function +/// only covers executable link. `args_input` is the user-supplied entry +/// `.ts` path (used for objcopy entry-stem matching on watchOS / visionOS / +/// iOS game-loop renames). +pub(crate) fn build_and_run_link( + args_input: &Path, + ctx: &CompilationContext, + target: Option<&str>, + obj_paths: &[PathBuf], + obj_fingerprints: &[Option], + compiled_features: &[String], + runtime_lib: &Path, + stdlib_lib: &Option, + // #466 Phase 4 step 2: well-known native binding archives. Added + // to the link line right after `stdlib_lib`. The matching + // perry-stdlib feature was already stripped during the auto- + // optimize rebuild, so the resulting link contains exactly one + // copy of each `_js__*` symbol — no duplicates. + well_known_libs: &[PathBuf], + // No-auto / auto-fallback keeps the full prebuilt stdlib, so the + // matching perry-stdlib feature was not stripped. Put wrappers first + // in that shape so wrapper-only handles keep using their own surface + // symbols instead of the bundled stdlib copies. + prefer_well_known_before_stdlib: bool, + // Issue #76 — `libperry_wasm_host.a` (wasmi-backed WebAssembly host + // runtime). Only `Some(...)` when the user passed `--enable-wasm-runtime` + // and the archive was located. Appended to the link command after the + // stdlib block so the linker resolves `perry_wasm_host_*` + // symbols referenced by `js_webassembly_*` shims in `perry-runtime`. + wasm_host_lib: &Option, + exe_path: &Path, + format: OutputFormat, + // `--debug-symbols`: keep symbols / emit a PDB so RUST_BACKTRACE + // panics in the compiled app symbolize. Windows-active today. + debug_symbols: bool, +) -> Result { + // #498 - supply-chain gate. Before any prebuilt archive hits the + // linker, hash it and compare against `perry.lock`. First build + // writes the lockfile; subsequent builds verify. Mismatch fails + // with an actionable diagnostic (`perry lock --update `). + // `PERRY_LOCK_FROZEN=1` upgrades the verify to CI mode (refuses + // to extend the lock); `PERRY_LOCK_UPDATE=` deliberately + // bumps the named package's hashes. Wired here so every backend + // (LLVM / WASM / ArkTS / HarmonyOS / Glance / SwiftUI / JS) + // inherits the gate from one chokepoint. + super::super::run_lock_verify_for_compile(ctx, target)?; + + let is_ios = matches!(target, Some("ios-simulator") | Some("ios")); + let is_visionos = matches!(target, Some("visionos-simulator") | Some("visionos")); + // Wear OS links exactly like Android (same triple, NDK, cdylib + TLS model). + let is_android = matches!(target, Some("android") | Some("wearos")); + let is_harmonyos = matches!(target, Some("harmonyos") | Some("harmonyos-simulator")); + let is_linux = matches!(target, Some(t) if t.starts_with("linux")) + || (target.is_none() && cfg!(target_os = "linux")); + // Fully-static musl Linux target (#4826) — a sub-case of is_linux. The + // link command itself is built in select_linker_command (musl driver + + // `-static`); here it only changes which system libs we request. + let is_musl = matches!( + target, + Some("linux-musl") | Some("linux-x86_64-musl") | Some("linux-aarch64-musl") + ); + + // The musl target is meant for headless/serverless binaries (Lambda, + // scratch, distroless). The GTK4 UI backend (perry/ui) links the system + // GTK/glib/webkit stack, which is only shipped for glibc and cannot be + // statically linked into a musl binary. Fail fast with an actionable + // message rather than emitting cryptic undefined-symbol errors (#4826). + if is_musl && ctx.needs_ui { + anyhow::bail!( + "perry/ui is not supported with the static musl Linux target \ + (--libc musl / [linux] libc = \"musl\"): the GTK4 UI backend \ + requires dynamic glibc. Build the GUI app with the default \ + (glibc) Linux target, or drop perry/ui for a headless musl build." + ); + } + let is_windows = matches!(target, Some("windows") | Some("windows-winui")) + || (target.is_none() && cfg!(target_os = "windows")); + let is_cross_windows = is_windows && !cfg!(target_os = "windows"); + let is_cross_ios = is_ios && !cfg!(target_os = "macos"); + let is_cross_visionos = is_visionos && !cfg!(target_os = "macos"); + let is_cross_macos = matches!(target, Some("macos")) && !cfg!(target_os = "macos"); + let is_watchos = matches!(target, Some("watchos") | Some("watchos-simulator")); + let is_tvos = matches!(target, Some("tvos") | Some("tvos-simulator")); + let is_cross_tvos = is_tvos && !cfg!(target_os = "macos"); + + let mut cmd = select_linker_command( + args_input, + ctx, + target, + obj_paths, + compiled_features, + is_ios, + is_visionos, + is_android, + is_harmonyos, + is_linux, + is_windows, + is_cross_windows, + is_cross_ios, + is_cross_visionos, + is_cross_macos, + is_watchos, + is_tvos, + is_cross_tvos, + )?; + + // When ios-game-loop is enabled, rename _main to _perry_user_main in the + // entry object file so the perry runtime's main() (from ios_game_loop.rs) + // becomes the process entry point. It spawns _perry_user_main on a game thread. + if (is_ios || is_tvos || is_visionos) && compiled_features.iter().any(|f| f == "ios-game-loop") + { + // Resolve an objcopy: rust-objcopy / llvm-objcopy from the host Rust + // toolchain (macOS), then llvm-objcopy on Linux builders, then PATH. + let objcopy = std::env::var("HOME").ok() + .map(|h| PathBuf::from(h).join(".rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/aarch64-apple-darwin/bin/rust-objcopy")) + .filter(|p| p.exists()) + .or_else(|| std::env::var("HOME").ok() + .map(|h| PathBuf::from(h).join(".rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/aarch64-apple-darwin/bin/llvm-objcopy")) + .filter(|p| p.exists())) + .or_else(|| ["/usr/lib/llvm-18/bin/llvm-objcopy", "/usr/bin/llvm-objcopy-18", "/usr/bin/llvm-objcopy"] + .iter().map(PathBuf::from).find(|p| p.exists())) + .unwrap_or_else(|| PathBuf::from("rust-objcopy")); + // Rename _main -> __perry_user_main so the perry runtime's main() + // (ios_game_loop.rs) becomes the process entry point and spawns the + // user's main on a game thread. The entry object can't be located by + // filename — with the object cache on it's named by content hash, not + // "main_ts" — so apply the rename to every user object. objcopy + // --redefine-sym is a no-op on objects that don't define _main, so this + // only ever rewrites the single entry object regardless of its name. + for obj in obj_paths.iter() { + let _ = Command::new(&objcopy) + .args(["--redefine-sym", "_main=__perry_user_main"]) + .arg(obj) + .status(); + } + } + + for obj_path in obj_paths { + cmd.arg(obj_path); + } + + // HarmonyOS: pick up native C objects that build.rs scripts emitted + // alongside the Rust artifacts. Rust's staticlib normally bundles these + // into libperry_runtime.a, but on our macOS→OHOS cross-build the + // `libmimalloc.a` wrapper ends up as a zero-member BSD-format archive + // (BSD ar's `__.SYMDEF SORTED` layout — macOS-host `ar` creates it, + // llvm-ar can't read it back), and rustc's "bundle native libs into + // the staticlib" path silently skips it. Without us forwarding the + // loose .o files to the final link, `libentry.so` ends up with + // `mi_malloc_aligned` marked UND, and the OHOS dynamic linker rejects + // dlopen with "symbol not found" at EntryAbility.onCreate time. + // + // We walk `target//release/build/*/out/` and collect every + // loose .o. This is coarser than Rust's per-crate link-lib directive + // walking — it picks up .o files from any transitive C dep, not just + // mimalloc — but that's a feature: the set is tiny in practice + // (mimalloc is the only C dep in perry-runtime's closure today) and + // any that turn out unreferenced are dead-stripped via --gc-sections. + if is_harmonyos { + let triple = super::rust_target_triple(target).unwrap_or("aarch64-unknown-linux-ohos"); + let build_roots: Vec = { + let mut roots: Vec = Vec::new(); + // auto_rebuild emits into a perry-auto- dir; the workspace's + // own target/ is a fallback for non-auto flows. + if let Ok(entries) = std::fs::read_dir("target") { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with("perry-auto-") || name_str == triple { + roots.push(entry.path()); + } + } + } + // When invoked from outside the workspace, auto_rebuild still + // lands under the perry source tree's target/. Add that. + if let Some(ws_root) = super::super::find_perry_workspace_root() { + let ws_target = ws_root.join("target"); + if let Ok(entries) = std::fs::read_dir(&ws_target) { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with("perry-auto-") { + roots.push(entry.path()); + } + } + } + } + roots + }; + let mut native_objs: Vec = Vec::new(); + for root in &build_roots { + let build_dir = root.join(triple).join("release").join("build"); + let entries = match std::fs::read_dir(&build_dir) { + Ok(e) => e, + Err(_) => continue, + }; + for crate_build in entries.flatten() { + let out_dir = crate_build.path().join("out"); + // Walk the out/ dir recursively (cc-rs can nest into source- + // mirror subdirs like c_src/mimalloc/v2/src/). + if let Ok(walker) = walkdir::WalkDir::new(&out_dir) + .into_iter() + .collect::, _>>() + { + for entry in walker { + if entry.file_type().is_file() + && entry.path().extension().and_then(|e| e.to_str()) == Some("o") + { + native_objs.push(entry.path().to_path_buf()); + } + } + } + } + } + if !native_objs.is_empty() && matches!(format, crate::OutputFormat::Text) { + println!( + " harmonyos: linking {} build.rs native object(s)", + native_objs.len() + ); + } + for obj in native_objs { + cmd.arg(obj); + } + } + + // Dead code stripping — safe because compile_init() emits func_addr + // calls for every class method/getter during vtable registration. These + // serve as linker roots that keep dynamically-dispatched methods alive. + if !is_windows { + if is_android || is_linux || is_harmonyos { + cmd.arg("-Wl,--gc-sections"); + } else if is_cross_ios || is_cross_visionos || is_cross_macos || is_cross_tvos { + // ld64.lld called directly — no -Wl, prefix needed + cmd.arg("-dead_strip"); + } else if is_watchos || is_visionos { + cmd.arg("-Xlinker").arg("-dead_strip"); + } else { + // Native macOS/iOS via clang driver + cmd.arg("-Wl,-dead_strip"); + } + } else { + // MSVC link.exe / lld-link equivalents: + // /OPT:REF — drop unreferenced functions/data (= --gc-sections) + // /OPT:ICF — fold identical COMDATs (= --icf=safe) + // These are documented as defaults under /RELEASE, but Perry doesn't + // pass /RELEASE so the linker falls back to /OPT:NOREF, pulling in the + // entire perry-stdlib archive even when only a fraction is used. + cmd.arg("/OPT:REF"); + if debug_symbols { + // `/DEBUG` makes lld-link emit a PDB next to the .exe from the + // debug info already present in the input objects/libs. Without + // it, perry binaries have no symbol table and a RUST_BACKTRACE + // panic is an unreadable list of `` — there is no other + // way to diagnose a runtime crash in a compiled Windows app. + // Skip /OPT:ICF here: COMDAT folding collapses distinct + // identical-bodied functions to one symbol, which would make the + // very backtrace this flag exists to produce ambiguous. + cmd.arg("/DEBUG"); + } else { + cmd.arg("/OPT:ICF"); + } + } + + // Link libraries - stdlib bundles perry-runtime; runtime provides base FFI symbols. + // Note: libperry_stdlib.a may omit some runtime symbols (js_register_class_method, + // js_register_class_getter, etc.) due to Rust DCE on rlib dependencies. We always + // link libperry_runtime.a as a fallback to fill these gaps. On macOS/Linux/ELF the + // linker uses first-definition-wins for archives, so no duplicate symbol errors arise. + // When UI lib is also linked, it bundles its own copy of perry-runtime. + // For Android (ELF), skip the extra runtime when UI provides it. + // On Windows (MSVC), always link the runtime — the UI lib's rlib dependency on + // perry-runtime may not include all symbols (e.g., perry_init_guard_check_and_set). + // watchOS: swiftc treats duplicate symbols as errors (not warnings like clang), + // so skip the standalone runtime when the UI lib already bundles it. + // Note: even when bitcode_linked is true, we still link the .a archives. + // The merged .o contains the crate code but NOT the Rust standard library + // symbols (alloc, std::thread_local, etc.). The .a archive provides those + // as a fallback — the linker only pulls object files from the .a that + // resolve still-undefined symbols (first-definition-wins on macOS). + let skip_runtime = (is_android || is_watchos || is_visionos) + && (ctx.needs_ui || is_watchos) + && find_ui_library(target).is_some(); + let well_known_libs: Vec = if prefer_well_known_before_stdlib { + well_known_libs + .iter() + .map(|wk| { + strip_duplicate_objects_from_well_known_lib(wk).unwrap_or_else(|_| wk.clone()) + }) + .collect() + } else { + well_known_libs.to_vec() + }; + if !skip_runtime { + if ctx.needs_stdlib || is_windows { + // On Windows/MSVC, always try to link stdlib because codegen unconditionally + // declares all stdlib extern functions, creating import references that MSVC + // won't dead-strip. On macOS/Linux, the linker ignores unreferenced archives. + if let Some(ref stdlib) = stdlib_lib { + // Windows: link the standalone perry_runtime.lib FIRST so + // its symbols win lld-link's /FORCE:MULTIPLE "first + // definition wins" rule over the perry-runtime copies + // *bundled* inside perry_stdlib.lib and the + // /WHOLEARCHIVE'd perry_ui_windows.lib. Auto-optimize + // refreshes perry-runtime + perry-stdlib but NOT + // perry-ui-windows, so the UI lib's bundled runtime is + // perpetually stale; the /WHOLEARCHIVE force-includes its + // js_* symbols, and without this the stale copy shadows a + // genuine runtime fix (e.g. the js_shadow_frame_pop bounds + // guard, #880) — the crash it fixes still fires because the + // guarded function never gets linked. The standalone + // runtime_lib is the canonical / auto-optimize-fresh + // source; making it authoritative on Windows matches every + // other platform (all of which already link runtime_lib). + if is_windows { + cmd.arg(runtime_lib); + } + if prefer_well_known_before_stdlib { + for wk in &well_known_libs { + cmd.arg(wk); + } + } + // Tier-3 (tvOS/watchOS) std-duplication dedup; no-op elsewhere. + cmd.arg(&dedup_stdlib_for_tier3(target, stdlib)); + // #466 Phase 4 step 2: well-known bindings normally join the + // link line right after perry-stdlib so they cover the exact + // `_js_*` symbol gap that was just opened by stripping the + // corresponding feature from the perry-stdlib rebuild. + // + // In no-auto/fallback mode the full prebuilt stdlib may still + // contain method-value bridge objects that reference wrapper + // symbols (for example external net Socket helpers). Archives + // are scanned left-to-right, so repeat the well-known libs + // after stdlib as well: the first occurrence lets wrapper + // definitions win over duplicate bundled stdlib functions, + // and the second resolves stdlib bridge references. + for wk in &well_known_libs { + cmd.arg(wk); + } + // Also link runtime for symbols DCE'd from stdlib's bundled + // perry-runtime; on tier-3 it's first stripped of stdlib's objects. + if !is_android && !is_windows { + cmd.arg(&dedup_runtime_for_tier3(target, runtime_lib, stdlib)); + } + } else { + if ctx.needs_stdlib { + eprintln!( + "Warning: stdlib required but {} not found, using runtime-only", + if is_windows { + "perry_stdlib.lib" + } else { + "libperry_stdlib.a" + } + ); + } + cmd.arg(runtime_lib); + } + } else { + // Runtime-only linking — no stdlib needed + cmd.arg(runtime_lib); + } + } else if ctx.needs_stdlib { + // Android + UI: runtime is provided by UI lib, but stdlib must still be linked + // separately (UI lib does not bundle perry-stdlib). + if let Some(ref stdlib) = stdlib_lib { + if prefer_well_known_before_stdlib { + for wk in &well_known_libs { + cmd.arg(wk); + } + } + cmd.arg(stdlib); + // #466 Phase 4 step 2: see the parallel comment in the + // non-Android branch above. + for wk in &well_known_libs { + cmd.arg(wk); + } + } else { + eprintln!("Warning: stdlib required but libperry_stdlib.a not found"); + } + } + + // Issue #76 — wasmi host runtime, opt-in via `--enable-wasm-runtime`. + // Append after stdlib so the linker can resolve `perry_wasm_host_*` + // symbols referenced by the always-present `js_webassembly_*` FFIs in + // perry-runtime. + if let Some(ref wasm_host) = wasm_host_lib { + cmd.arg(wasm_host); + } + + if is_windows { + cmd.arg(format!("/OUT:{}", exe_path.display())); + } else { + cmd.arg("-o").arg(exe_path).arg("-lc"); + } + + // For plugin hosts, export symbols so dlopen'd plugins can resolve them. + // Plugins are dylibs loaded via dlopen — they need to resolve: + // 1. hone_host_api_* (plugin→host calls) + // 2. js_*/perry_* (Perry runtime used by compiled plugin code) + // We use -u to prevent dead_strip from removing these, keeping binary size small. + if ctx.needs_plugins && !is_windows { + #[cfg(target_os = "macos")] + { + // Force-keep all functions from plugin-related native libraries + for native_lib in &ctx.native_libraries { + if native_lib.module.contains("plugin") { + for func in &native_lib.functions { + cmd.arg(format!("-Wl,-u,_{}", func.name)); + } + } + } + // Force-keep Perry runtime symbols that plugin dylibs reference. + // These are collected from the Perry runtime's public API. + // Using -u tells the linker "treat as referenced" so dead_strip keeps them. + let runtime_syms = [ + "js_array_alloc", + "js_array_from_f64", + "js_array_push_f64", + "js_bigint_is_zero", + "js_closure_alloc", + "js_console_log_spread", + "js_dynamic_object_get_property", + "js_dynamic_string_equals", + "js_gc_register_global_root", + "js_is_truthy", + "js_jsvalue_compare", + "js_jsvalue_equals", + "js_nanbox_get_pointer", + "js_nanbox_pointer", + "js_nanbox_string", + "js_native_call_method", + "js_object_alloc_class_with_keys", + "js_object_alloc_with_shape", + "js_register_class_method", + "js_string_char_code_at", + "js_string_from_bytes", + "js_string_length", + "perry_debug_trace_init", + "perry_debug_trace_init_done", + "perry_init_guard_check_and_set", + ]; + for sym in &runtime_syms { + cmd.arg(format!("-Wl,-u,_{}", sym)); + } + } + #[cfg(target_os = "linux")] + { + cmd.arg("-rdynamic"); + } + } + + if is_watchos { + // watchOS frameworks (swiftc auto-links Swift stdlib on the non-game-loop path) + let is_watchos_game_loop = compiled_features.iter().any(|f| f == "watchos-game-loop"); + let is_watchos_swift_app = compiled_features.iter().any(|f| f == "watchos-swift-app"); + if !is_watchos_game_loop { + cmd.arg("-framework").arg("SwiftUI"); + } + cmd.arg("-framework") + .arg("WatchKit") + .arg("-framework") + .arg("Foundation") + .arg("-framework") + .arg("CoreFoundation") + .arg("-framework") + .arg("Security") + .arg("-lSystem") + .arg("-lresolv"); + if is_watchos_game_loop { + // QuartzCore for CAMetalLayer-backed rendering (Metal.framework is NOT + // in the watchOS SDK — the native lib must dlopen it or supply its own + // path to the device's Metal dylib). -lobjc for the dynamic + // WKApplicationDelegate class registered from watchos_game_loop.rs. + cmd.arg("-framework").arg("QuartzCore").arg("-lobjc"); + } + if is_watchos_swift_app { + // SceneKit for SceneView-backed 3D rendering from the native lib's + // `@main struct App: App`. The lib may additionally use Canvas (2D, + // already covered by SwiftUI) or SpriteKit (opt-in via the + // manifest's `frameworks` list). + cmd.arg("-framework").arg("SceneKit"); + } + } else if is_ios { + // iOS frameworks + cmd.arg("-framework") + .arg("UIKit") + .arg("-framework") + .arg("Foundation") + .arg("-framework") + .arg("WebKit") // perry/ui WebView (#658) — WKWebView. + .arg("-framework") + .arg("CoreGraphics") + .arg("-framework") + .arg("Security") + .arg("-framework") + .arg("CoreFoundation") + .arg("-framework") + .arg("SystemConfiguration") + .arg("-framework") + .arg("QuartzCore") + .arg("-framework") + .arg("AVFAudio") // AVAudioEngine for audio capture + .arg("-framework") + .arg("AVFoundation") // Camera capture (AVCaptureSession) + .arg("-framework") + .arg("CoreMedia") // CMSampleBuffer + .arg("-framework") + .arg("CoreVideo") // CVPixelBuffer + .arg("-framework") + .arg("UserNotifications") // UNUserNotificationCenter (perry/system notificationSend) + .arg("-framework") + .arg("CoreLocation") // CLCircularRegion for UNLocationNotificationTrigger (#96) + .arg("-framework") + .arg("MediaPlayer") // perry/media — Now Playing + Remote Command Center + .arg("-framework") + .arg("MapKit") // perry/ui MapView (#517) — MKMapView + .arg("-framework") + .arg("PDFKit") // perry/ui PdfView (#516) — PDFView + .arg("-framework") + .arg("BackgroundTasks") // perry/background BGTaskScheduler (#538) + .arg("-framework") + .arg("Network") // perry/system network reachability (#582) + .arg("-liconv") + .arg("-lresolv") + .arg("-lobjc") + .arg("-lSystem"); + } else if is_visionos { + cmd.arg("-framework") + .arg("SwiftUI") + .arg("-framework") + .arg("UIKit") + .arg("-framework") + .arg("Foundation") + .arg("-framework") + .arg("CoreGraphics") + .arg("-framework") + .arg("Security") + .arg("-framework") + .arg("CoreFoundation") + .arg("-framework") + .arg("SystemConfiguration") + .arg("-framework") + .arg("QuartzCore") + .arg("-framework") + .arg("AVFAudio") + .arg("-framework") + .arg("AVFoundation") + .arg("-framework") + .arg("CoreMedia") + .arg("-framework") + .arg("CoreVideo") + .arg("-framework") + .arg("MediaPlayer") // perry/media — Now Playing + Remote Command Center + .arg("-framework") + .arg("MapKit") // perry/ui MapView (#517) — MKMapView (visionOS) + .arg("-framework") + .arg("PDFKit") // perry/ui PdfView (#516) — PDFView (visionOS) + .arg("-framework") + .arg("BackgroundTasks") // perry/background BGTaskScheduler (#538) + .arg("-liconv") + .arg("-lresolv") + .arg("-lobjc") + .arg("-lSystem"); + } else if is_tvos { + // tvOS frameworks (UIKit-based, like iOS) + cmd.arg("-framework") + .arg("UIKit") + .arg("-framework") + .arg("Foundation") + .arg("-framework") + .arg("CoreGraphics") + .arg("-framework") + .arg("Security") + .arg("-framework") + .arg("CoreFoundation") + .arg("-framework") + .arg("SystemConfiguration") + .arg("-framework") + .arg("QuartzCore") + .arg("-framework") + .arg("AVFoundation") + .arg("-framework") + .arg("GameController") + .arg("-framework") + .arg("Metal") + .arg("-framework") + .arg("MapKit") // perry/ui MapView (#517) — MKMapView (tvOS) + .arg("-framework") + .arg("MediaPlayer") // perry/media — Now Playing + Siri Remote + .arg("-framework") + .arg("BackgroundTasks") // perry/background BGTaskScheduler (#538) + .arg("-liconv") + .arg("-lresolv") + .arg("-lobjc") + .arg("-lSystem"); + } else if is_harmonyos { + // OpenHarmony system libraries. musl folds m/pthread/dl into libc.a so + // the -l flags are no-ops on the toolchain side; we emit them anyway + // because cargo's static archives reference them and the OHOS dynamic + // linker resolves them at load time. + cmd.arg("-Wl,--allow-multiple-definition") + .arg("-lm") + .arg("-lpthread") + .arg("-ldl"); + // `libace_napi.z.so` provides napi_module_register + napi_create_* + // (consumed by perry-runtime/src/ohos_napi.rs). OHOS naming convention + // is `.z.so` — the `-l` flag strips `lib` and `.so` but NOT the + // middle `.z`, so `-lace_napi.z` is the deliberate spelling. + cmd.arg("-lace_napi.z"); + // `libhilog_ndk.z.so` provides OH_LOG_Print, used by Perry's + // `js_console_log_*` family on harmonyos to route compiled-TS + // console output to hilog (so Perry-emitted log lines surface + // in DevEco/hdc the same way ArkTS console.log does), and by the + // arkts_callbacks bridge for diagnostic register/invoke traces. + cmd.arg("-lhilog_ndk.z"); + // `libtime_service_ndk.so` provides OH_TimeService_GetTimeZone, + // referenced by the `iana-time-zone` crate (pulled in transitively + // by `chrono` etc.) when it detects an OHOS target. The OHOS + // dynamic loader rejects libentry.so at app launch if this isn't + // listed in DT_NEEDED. Note: no `.z.` in the soname, unlike the + // ace_napi / hilog_ndk libs above. + cmd.arg("-ltime_service_ndk"); + } else if is_android { + // Android system libraries + cmd.arg("-Wl,--allow-multiple-definition") + .arg("-lm") + .arg("-ldl") + .arg("-llog"); + + // Stub for JNI_GetCreatedJavaVMs: the jni-sys crate declares this extern + // symbol, but Android has no libjvm.so and libnativehelper.so is only + // available at API 31+. Perry gets the JavaVM from JNI_OnLoad and never + // calls this function, so compile a no-op C stub to satisfy the linker. + let stub_dir = std::env::temp_dir().join(format!("perry_jni_stub_{}", std::process::id())); + std::fs::create_dir_all(&stub_dir).ok(); + let stub_c = stub_dir.join("jni_stub.c"); + let stub_o = stub_dir.join("jni_stub.o"); + std::fs::write( + &stub_c, + concat!( + "typedef int jint;\n", + "typedef jint jsize;\n", + "jint JNI_GetCreatedJavaVMs(void **vm_buf, jsize buf_len, jsize *n_vms) {\n", + " if (n_vms) *n_vms = 0;\n", + " return 0;\n", + "}\n", + ), + ) + .ok(); + let ndk_home = std::env::var("ANDROID_NDK_HOME").unwrap_or_default(); + // #1508: see platform_cmd.rs — same host-tag bug. + let host_tag = if cfg!(target_os = "macos") { + "darwin-x86_64" + } else if cfg!(target_os = "windows") { + "windows-x86_64" + } else { + "linux-x86_64" + }; + let ndk_clang = format!( + "{}/toolchains/llvm/prebuilt/{}/bin/aarch64-linux-android24-clang{}", + ndk_home, + host_tag, + if cfg!(target_os = "windows") { + ".cmd" + } else { + "" + } + ); + let stub_ok = Command::new(&ndk_clang) + .args(["-c", "-fPIC", "-target", "aarch64-linux-android24"]) + .arg("-o") + .arg(&stub_o) + .arg(&stub_c) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if stub_ok { + cmd.arg(&stub_o); + } + } else if is_linux { + // Linux system libraries (cross-compile target) + // Allow multiple definitions: stdlib bundles perry-runtime symbols, + // and we also link perry-runtime directly for symbols DCE'd from stdlib. + // macOS Mach-O uses first-definition-wins natively; ELF linkers need this flag. + cmd.arg("-Wl,--allow-multiple-definition") + .arg("-lm") + .arg("-lpthread") + .arg("-ldl"); + + // -lssl/-lcrypto are vestigial — Perry's stdlib is rustls-only (no + // system OpenSSL). On glibc they happen to be present so the link + // tolerates them, but a static musl sysroot has no libssl.a/libcrypto.a + // and the link would fail. Skip them for musl (#4826). + if ctx.needs_stdlib && !is_musl { + cmd.arg("-lssl").arg("-lcrypto"); + } + } else if is_windows { + windows_link::add_system_libs(&mut cmd); + windows_link::embed_app_manifest(&mut cmd, ctx.needs_ui); + } else { + // macOS frameworks for runtime (sysinfo, etc.) and V8. + // Gate on `!is_harmonyos` so the macOS host doesn't leak its + // frameworks into ELF cross-compile targets that fall through this + // `else` branch — `cfg!(target_os = "macos")` is true whenever we're + // running ON macOS, regardless of the actual target. + if (cfg!(target_os = "macos") || is_cross_macos) && !is_harmonyos { + cmd.arg("-framework") + .arg("Security") + .arg("-framework") + .arg("CoreFoundation") + .arg("-framework") + .arg("SystemConfiguration") + .arg("-liconv") + .arg("-lresolv") + .arg("-lobjc"); + } + + // On Linux (native, not cross-compiling to macOS), link against system libraries + if cfg!(target_os = "linux") && !is_cross_macos { + cmd.arg("-lm").arg("-lpthread").arg("-ldl"); + + if ctx.needs_stdlib { + cmd.arg("-lssl").arg("-lcrypto"); + } + } + } + + // Issue #607 — watchOS targets always link the UI lib regardless of + // `ctx.needs_ui`. The watchOS Swift template (`PerryWatchApp.swift`) + // unconditionally references four `@_silgen_name`'d Rust symbols + // (`perry_watchos_tree_version` / `perry_watchos_toggle_changed` / + // `perry_watchos_toast_seq` / `perry_watchos_toast_dismiss`) that + // live in `libperry_ui_watchos.a`. A console-only TS program has + // `needs_ui = false`, so the UI lib was previously not added to the + // link line — leaving those four symbols undefined and the link + // failing. Forcing the UI lib for watchOS adds ~MBs but unblocks + // `console.log("ok")`-only programs from compiling. + let force_ui = is_watchos; + if ctx.needs_ui || force_ui { + // When geisterhand is enabled, prefer the geisterhand-enabled UI lib + // (it contains widget registration calls that the normal lib doesn't have) + let ui_lib_option = if ctx.needs_geisterhand { + find_geisterhand_ui(target).or_else(|| find_ui_library(target)) + } else { + find_ui_library(target) + }; + if let Some(ui_lib) = ui_lib_option { + // The UI staticlib bundles perry_runtime + Rust std. When perry-stdlib + // is also linked (which bundles the same), duplicate symbols cause + // crashes (conflicting static state initialization). Strip duplicates + // on Apple platforms. On Windows/Android, skip strip-dedup because + // perry_runtime objects contain monomorphizations needed by UI code, + // and --allow-multiple-definition (ELF) / /FORCE:MULTIPLE (COFF) + // handles duplicate symbols safely. On Android, skip_runtime=true + // means the UI lib is the sole provider of perry-runtime symbols. + let ui_lib = if is_windows || is_android || is_visionos { + ui_lib + } else { + match strip_duplicate_objects_from_lib(&ui_lib) { + Ok(trimmed) => trimmed, + Err(e) => { + eprintln!("[strip-dedup] skipped for UI lib (non-fatal): {e}"); + ui_lib + } + } + }; + if is_windows { + // lld-link scans archives left-to-right once. The UI lib is + // linked before user code objects, so UI symbols aren't yet + // undefined when the lib is scanned. /WHOLEARCHIVE forces all + // objects from the archive to be included unconditionally. + cmd.arg(format!("/WHOLEARCHIVE:{}", ui_lib.display())); + } else { + cmd.arg(&ui_lib); + } + + if is_watchos { + // SwiftUI/WatchKit already linked above + } else if is_ios || is_visionos || is_tvos { + // UIKit already linked above + } else if is_android { + // Allow multiple definitions from perry-runtime in both UI lib and native libs + cmd.arg("-Wl,--allow-multiple-definition"); + } else if is_linux { + // Allow multiple definitions from perry-runtime in both stdlib and UI lib + cmd.arg("-Wl,--allow-multiple-definition"); + // libperry_ui_gtk4.a's glib::source::trampoline_local + // closures call perry-stdlib's js_stdlib_process_pending / + // js_promise_run_microtasks. When ctx.needs_stdlib is false + // (bare UI program), stdlib isn't linked via the earlier + // path. Force-link it here with --whole-archive so every + // object is pulled unconditionally. --allow-multiple-definition + // above lets it coexist with the runtime stub at + // perry-runtime/src/stdlib_stubs.rs. The async-runtime + // feature is force-enabled for UI builds (see + // build_optimized_libs), so the real js_stdlib_process_pending + // is guaranteed present in libperry_stdlib.a. + let linux_stdlib_for_ui = + stdlib_lib.clone().or_else(|| find_stdlib_library(target)); + if let Some(ref stdlib) = linux_stdlib_for_ui { + cmd.arg("-Wl,--whole-archive") + .arg(stdlib) + .arg("-Wl,--no-whole-archive"); + } + // GTK4 libraries via pkg-config. The fallback fires in two + // distinct cases: pkg-config not installed (spawn fails), OR + // installed but `gtk4.pc` not on the search path (exit != 0 + // — happens e.g. on Ubuntu hosts where libgtk-4-dev is split + // across packages, or when PKG_CONFIG_PATH is locked down). + // Pre-fix the second case silently emitted no GTK link flags + // and the link bombed with hundreds of `g_object_unref` / + // `gtk_widget_*` undefined references (#181). + let mut got_gtk_libs = false; + let pc_out = Command::new("pkg-config").args(["--libs", "gtk4"]).output(); + if let Ok(ref output) = pc_out { + if output.status.success() { + let libs = String::from_utf8_lossy(&output.stdout); + for flag in libs.trim().split_whitespace() { + cmd.arg(flag); + } + got_gtk_libs = true; + } + } + if !got_gtk_libs { + // Mirrors what `pkg-config --libs gtk4` returns on a + // standard libgtk-4-dev install. Pre-fix only listed the + // glib/gio core, which left pango/cairo/gdk_pixbuf + // undefined. + eprintln!( + "Warning: `pkg-config --libs gtk4` did not return GTK4 \ + linker flags ({}). Falling back to a hardcoded GTK4 \ + link set — install `libgtk-4-dev` (Debian/Ubuntu) or \ + `gtk4-devel` (Fedora/RHEL) and ensure pkg-config can \ + find `gtk4.pc` to silence this warning.", + match &pc_out { + Err(e) => format!("pkg-config not runnable: {e}"), + Ok(o) if !o.status.success() => format!( + "pkg-config exited {}: {}", + o.status.code().unwrap_or(-1), + String::from_utf8_lossy(&o.stderr).trim() + ), + Ok(_) => "no output".to_string(), + } + ); + for lib in [ + "-lgtk-4", + "-lgio-2.0", + "-lgobject-2.0", + "-lglib-2.0", + "-lpangocairo-1.0", + "-lpango-1.0", + "-lharfbuzz", + "-lgdk_pixbuf-2.0", + "-lcairo-gobject", + "-lcairo", + "-lgraphene-1.0", + ] { + cmd.arg(lib); + } + } + // PulseAudio for audio capture (only needed with UI) + cmd.arg("-lpulse-simple").arg("-lpulse"); + // GStreamer libs — pulled in by perry-ui-gtk4's gstreamer-rs + // dep (added in v0.5.440 for the perry/media playbin backend). + // GTK4's pkg-config doesn't transitively reference the + // gstreamer-1.0 sonames, so the `-lgstreamer-1.0` (and the + // base/app/video/audio sublibs that gstreamer-rs's playbin + // path touches) have to land on the link line explicitly or ld + // fails with `undefined reference to gst_message_parse_buffering` + // + `DSO missing from command line` (#423). Same pkg-config → + // hardcoded-fallback shape as the GTK4 block above. + let mut got_gst_libs = false; + let gst_pc_out = Command::new("pkg-config") + .args([ + "--libs", + "gstreamer-1.0", + "gstreamer-base-1.0", + "gstreamer-app-1.0", + "gstreamer-video-1.0", + "gstreamer-audio-1.0", + ]) + .output(); + if let Ok(ref output) = gst_pc_out { + if output.status.success() { + let libs = String::from_utf8_lossy(&output.stdout); + for flag in libs.trim().split_whitespace() { + cmd.arg(flag); + } + got_gst_libs = true; + } + } + if !got_gst_libs { + eprintln!( + "Warning: `pkg-config --libs gstreamer-1.0 ...` did not \ + return GStreamer linker flags ({}). Falling back to a \ + hardcoded GStreamer link set — install \ + `libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev` \ + (Debian/Ubuntu) or `gstreamer1-devel \ + gstreamer1-plugins-base-devel` (Fedora/RHEL) to silence \ + this warning.", + match &gst_pc_out { + Err(e) => format!("pkg-config not runnable: {e}"), + Ok(o) if !o.status.success() => format!( + "pkg-config exited {}: {}", + o.status.code().unwrap_or(-1), + String::from_utf8_lossy(&o.stderr).trim() + ), + Ok(_) => "no output".to_string(), + } + ); + for lib in [ + "-lgstreamer-1.0", + "-lgstbase-1.0", + "-lgstapp-1.0", + "-lgstvideo-1.0", + "-lgstaudio-1.0", + ] { + cmd.arg(lib); + } + } + // libshumate — GNOME's GTK4 vector-tile map widget for the + // perry/ui MapView (#517). Same pkg-config → hardcoded + // fallback shape as GTK4 / GStreamer above. + let mut got_shumate_libs = false; + let shumate_pc_out = Command::new("pkg-config") + .args(["--libs", "shumate-1.0"]) + .output(); + if let Ok(ref output) = shumate_pc_out { + if output.status.success() { + let libs = String::from_utf8_lossy(&output.stdout); + for flag in libs.trim().split_whitespace() { + cmd.arg(flag); + } + got_shumate_libs = true; + } + } + if !got_shumate_libs { + eprintln!( + "Warning: `pkg-config --libs shumate-1.0` did not return \ + libshumate linker flags ({}). Falling back to \ + `-lshumate-1.0` — install `libshumate-dev` \ + (Debian/Ubuntu) or `libshumate-devel` (Fedora/RHEL) to \ + silence this warning.", + match &shumate_pc_out { + Err(e) => format!("pkg-config not runnable: {e}"), + Ok(o) if !o.status.success() => format!( + "pkg-config exited {}: {}", + o.status.code().unwrap_or(-1), + String::from_utf8_lossy(&o.stderr).trim() + ), + Ok(_) => "no output".to_string(), + } + ); + cmd.arg("-lshumate-1.0"); + } + // WebKitGTK 6.0 + libsoup-3.0 — perry/ui WebView (#658, v0.5.864). + // perry-ui-gtk4's webkit6/soup3 deps reference symbols like + // `soup_check_version` from libsoup-3.0 transitively; without + // explicit `-lsoup-3.0` ld errors with `DSO missing from + // command line`. Same pkg-config → hardcoded-fallback shape + // as GTK4 / GStreamer / shumate above. + let mut got_webkit_libs = false; + let webkit_pc_out = Command::new("pkg-config") + .args(["--libs", "webkitgtk-6.0", "libsoup-3.0"]) + .output(); + if let Ok(ref output) = webkit_pc_out { + if output.status.success() { + let libs = String::from_utf8_lossy(&output.stdout); + for flag in libs.trim().split_whitespace() { + cmd.arg(flag); + } + got_webkit_libs = true; + } + } + if !got_webkit_libs { + eprintln!( + "Warning: `pkg-config --libs webkitgtk-6.0 libsoup-3.0` \ + did not return WebKitGTK linker flags ({}). Falling \ + back to a hardcoded set — install `libwebkitgtk-6.0-dev` \ + (Debian/Ubuntu) which pulls libsoup-3.0-dev + \ + libjavascriptcoregtk-6.0-dev to silence this warning.", + match &webkit_pc_out { + Err(e) => format!("pkg-config not runnable: {e}"), + Ok(o) if !o.status.success() => format!( + "pkg-config exited {}: {}", + o.status.code().unwrap_or(-1), + String::from_utf8_lossy(&o.stderr).trim() + ), + Ok(_) => "no output".to_string(), + } + ); + for lib in ["-lwebkitgtk-6.0", "-ljavascriptcoregtk-6.0", "-lsoup-3.0"] { + cmd.arg(lib); + } + } + } else if is_windows { + // Win32 system libs already linked above + } else { + if cfg!(target_os = "macos") || is_cross_macos { + cmd.arg("-framework").arg("AppKit"); + // perry/ui WebView (#658) — WKWebView / WKWebViewConfiguration. + cmd.arg("-framework").arg("WebKit"); + cmd.arg("-framework").arg("CoreGraphics"); + cmd.arg("-framework").arg("QuartzCore"); + cmd.arg("-framework").arg("AVFoundation"); + cmd.arg("-framework").arg("Metal"); + cmd.arg("-framework").arg("IOKit"); + cmd.arg("-framework").arg("DiskArbitration"); // needed by CoreGraphics + // perry/media — AVPlayer is in AVFoundation (already linked + // above). CoreMedia provides CMTime + CMTimeGetSeconds / + // CMTimeMakeWithSeconds used for seek + position. MediaPlayer + // provides MPNowPlayingInfoCenter / MPRemoteCommandCenter / + // MPMediaItemArtwork (lock screen + Touch Bar + Now Playing). + cmd.arg("-framework").arg("CoreMedia"); + cmd.arg("-framework").arg("MediaPlayer"); + // perry/ui MapView (#517) — MKMapView lives in MapKit. + cmd.arg("-framework").arg("MapKit"); + // perry/ui PdfView (#516) — PDFView lives in PDFKit, which + // also exposes the PDFDocument / PDFPage classes used for + // page-count + page-navigation queries. + cmd.arg("-framework").arg("PDFKit"); + // perry/system network reachability (#582) — NWPathMonitor. + cmd.arg("-framework").arg("Network"); + } + } + + match format { + OutputFormat::Text => { + println!("Linking perry/ui (native UI) from {}", ui_lib.display()) + } + OutputFormat::Json => {} + } + } else { + let (lib_name, build_cmd) = if is_watchos { + ( + "libperry_ui_watchos.a", + "cargo +nightly build -Z build-std=std,panic_abort --release -p perry-ui-watchos --target aarch64-apple-watchos (or --target aarch64-apple-watchos-sim for the simulator)", + ) + } else if is_tvos { + ( + "libperry_ui_tvos.a", + "cargo build --release -p perry-ui-tvos --target aarch64-apple-tvos", + ) + } else if is_visionos { + ("libperry_ui_visionos.a", "cargo build --release -p perry-ui-visionos --target aarch64-apple-visionos-sim") + } else if is_ios { + ( + "libperry_ui_ios.a", + "cargo build --release -p perry-ui-ios --target aarch64-apple-ios-sim", + ) + } else if is_android { + ( + "libperry_ui_android.a", + // #1529 — TLS model must be global-dynamic for the dlopen'd cdylib. + // `tls-model` is `-Z`-gated on the toolchains we ship against, so + // RUSTC_BOOTSTRAP=1 lets the gated flag through on a stable rustc. + "RUSTC_BOOTSTRAP=1 RUSTFLAGS=\"-Z tls-model=global-dynamic\" cargo build --release -p perry-ui-android --target aarch64-linux-android", + ) + } else if is_linux { + ( + "libperry_ui_gtk4.a", + "cargo build --release -p perry-ui-gtk4 --target x86_64-unknown-linux-gnu", + ) + } else if matches!(target, Some("windows-winui")) { + ( + "perry_ui_windows_winui.lib", + "cargo build --release -p perry-ui-windows-winui --target x86_64-pc-windows-msvc", + ) + } else if is_windows { + ( + "perry_ui_windows.lib", + "cargo build --release -p perry-ui-windows --target x86_64-pc-windows-msvc", + ) + } else { + ( + "libperry_ui_macos.a", + "cargo build --release -p perry-ui-macos", + ) + }; + return Err(anyhow!( + "perry/ui imported but {} not found. Build with: {}", + lib_name, + build_cmd + )); + } + } + + // Link geisterhand libraries if enabled + if ctx.needs_geisterhand { + // Auto-build geisterhand libraries if any are missing + let gh_missing = find_geisterhand_library(target).is_none() + || find_geisterhand_runtime(target).is_none() + || (ctx.needs_stdlib && find_geisterhand_stdlib(target).is_none()) + || (ctx.needs_ui && find_geisterhand_ui(target).is_none()); + if gh_missing { + build_geisterhand_libs(target, format)?; + } + + if let Some(gh_lib) = find_geisterhand_library(target) { + cmd.arg(&gh_lib); + // Link geisterhand-enabled runtime (has the registry + pump functions) + if let Some(gh_runtime) = find_geisterhand_runtime(target) { + cmd.arg(&gh_runtime); + // ELF linkers need --allow-multiple-definition; macOS Mach-O uses first-wins natively + if is_linux || is_android { + cmd.arg("-Wl,--allow-multiple-definition"); + } + } + // On Windows, re-link the stdlib after geisterhand to resolve + // forward references to geisterhand registry functions. + // lld-link scans archives left-to-right once, so the stdlib + // must appear after the geisterhand lib that references it. + // On Windows, force-include geisterhand registry symbols from stdlib. + // lld-link scans archives left-to-right once, so the stdlib's + // geisterhand objects are skipped on first scan (no references yet). + // /INCLUDE forces the linker to pull in the specific symbols. + if is_windows { + cmd.arg("/INCLUDE:perry_geisterhand_queue_action"); + cmd.arg("/INCLUDE:perry_geisterhand_queue_action1"); + cmd.arg("/INCLUDE:perry_geisterhand_queue_state_set"); + cmd.arg("/INCLUDE:perry_geisterhand_request_screenshot"); + cmd.arg("/INCLUDE:perry_geisterhand_register"); + cmd.arg("/INCLUDE:perry_geisterhand_pump"); + cmd.arg("/INCLUDE:perry_geisterhand_start"); + cmd.arg("/INCLUDE:perry_geisterhand_free_string"); + cmd.arg("/INCLUDE:perry_geisterhand_get_closure"); + cmd.arg("/INCLUDE:perry_geisterhand_get_registry_json"); + // Allow duplicate symbols from re-linked stdlib objects + cmd.arg("/FORCE:MULTIPLE"); + } + match format { + OutputFormat::Text => println!("Linking geisterhand (in-process fuzzer)"), + OutputFormat::Json => {} + } + } else { + return Err(anyhow!( + "Failed to build geisterhand libraries. Check that Perry source crates are available." + )); + } + } + + // Build and link external native libraries from perry.nativeLibrary manifests. + // Swift sources are deduplicated across the loop — modules sharing the same + // package.json all see the same swift_sources entries, but each file should + // be compiled + linked once. Without this, swift's mangled symbols for + // structs/classes duplicate N times. + let mut seen_swift_sources: std::collections::HashSet = + std::collections::HashSet::new(); + for native_lib in &ctx.native_libraries { + if let Some(ref target_config) = native_lib.target_config { + if !target_config.available { + if let (OutputFormat::Text, Some(reason)) = + (format, target_config.unavailable_reason.as_deref()) + { + println!( + "Skipping native library {} for this target: {}", + native_lib.module, reason + ); + } + continue; + } + + match format { + OutputFormat::Text => { + println!("Building native library: {} ...", native_lib.module) + } + OutputFormat::Json => {} + } + + // Issue #860 — prebuilt-distribution shortcut. When the + // wrapper's manifest specified a `prebuilt:` path that + // resolved to an on-disk static library, skip the cargo + // build entirely and link the prebuilt archive directly. + // `frameworks` / `libs` / `pkgConfig` / `lib_dirs` are + // still honored below — those are linker flags the host + // toolchain needs regardless of where the `.a` came from. + if let Some(prebuilt) = target_config.prebuilt.as_ref() { + if !prebuilt.exists() { + return Err(anyhow!( + "Prebuilt native library declared by {} not found at {}. \ + If this package is distributed via npm `optionalDependencies` \ + (esbuild/sharp pattern), make sure the per-platform subpackage \ + is installed for the current host/target.", + native_lib.module, + prebuilt.display() + )); + } + cmd.arg(prebuilt); + match format { + OutputFormat::Text => { + println!("Linking prebuilt native library: {}", prebuilt.display()) + } + OutputFormat::Json => {} + } + } else { + // Build the Rust crate + let cargo_toml = target_config.crate_path.join("Cargo.toml"); + if cargo_toml.exists() { + // Tier 3 targets (tvOS, watchOS) need nightly + build-std + let is_tier3 = matches!( + target, + Some("tvos") + | Some("tvos-simulator") + | Some("watchos") + | Some("watchos-simulator") + ); + + // #505: optionally wrap the cargo invocation in + // `sandbox-exec` on macOS to deny network and + // restrict FS writes during `build.rs` execution. + // Off by default for backwards compat; opted into + // via `PERRY_SANDBOX_BUILDRS=1`. Packages listed + // in `perry.allowUnsandboxedBuild` are exempt. + let mut cargo_cmd = + super::super::sandbox_buildrs::wrap_cargo_command(ctx, &native_lib.module); + if is_tier3 { + cargo_cmd.arg("+nightly"); + } + cargo_cmd + .arg("build") + .arg("--release") + .arg("--manifest-path") + .arg(&cargo_toml); + + // perry.toml `[native-library.""]` feature + // forwarding — see native_features.rs. + native_features::apply_native_library_override( + &mut cargo_cmd, + &ctx.project_root, + &native_lib.module, + matches!(format, OutputFormat::Text), + ); + + if let Some(triple) = rust_target_triple(target) { + cargo_cmd.arg("--target").arg(triple); + } + + if is_tier3 { + // Match perry-runtime's std build flags exactly so the std + // rlibs are bit-identical and dedupe at link time. Without + // this, native libs pull in a parallel std with different + // metadata hashes and the final Swift-driven link fails + // with hundreds of duplicate-symbol errors. + cargo_cmd.arg("-Zbuild-std=std,panic_abort"); + } + + // For Android, ensure 16 KB page size alignment (required by Google Play), + // and force the global-dynamic TLS model (#1529): the native lib's TLS + // relocations are baked into the dlopen'd `libperry_app.so`, so an + // Initial-Executable model (rustc's android default) crashes at load. + if is_android { + let tls_flag = + super::super::optimized_libs::android_global_dynamic_tls_rustflag( + &mut cargo_cmd, + ); + cargo_cmd.env( + "CARGO_TARGET_AARCH64_LINUX_ANDROID_RUSTFLAGS", + format!("-C link-arg=-Wl,-z,max-page-size=16384 {tls_flag}"), + ); + } + + // For HarmonyOS, point cargo at the OHOS SDK's clang + sysroot + // so cc-rs and rustc's linker invocation actually use the + // cross-toolchain instead of falling back to the host `cc`. + if is_harmonyos { + if let Some(sdk) = super::super::library_search::find_harmonyos_sdk() { + for (k, v) in + super::super::library_search::harmonyos_cross_env(&sdk, target) + { + cargo_cmd.env(k, v); + } + } + } + // #1508: For Android, do the same with the NDK so cc-rs can + // compile native C deps (libsqlite3-sys / libmimalloc-sys + // / etc.) using the NDK clang. Without this, cc-rs falls + // back to the host `cc` and fails with + // `failed to find tool "clang.exe"` on Windows (and + // architecturally-mismatched objects on Unix). + if is_android { + if let Some(ndk) = std::env::var_os("ANDROID_NDK_HOME") { + for (k, v) in super::super::library_search::android_cross_env( + std::path::Path::new(&ndk), + target, + ) { + cargo_cmd.env(k, v); + } + } + } + + // #1303 — when the wrapper crate's `build.rs` gates an + // optional vendored SDK on an env var (`frameworks_env`) + // and the dev declared a project-relative `framework_dir` + // in `perry.toml [google_auth]`, export the resolved + // absolute path so `build.rs` opts the real SDK in. This + // fires on the local machine AND on the `perry publish` + // worker (where the dev's shell env doesn't transfer, but + // the uploaded perry.toml + dir do). If the env var is + // already set we re-export the same value — idempotent. + if let Some(env_name) = target_config.frameworks_env.as_deref() { + if let Some(dir) = resolve_optional_framework_dir(env_name, args_input) { + if dir.is_dir() { + cargo_cmd.env(env_name, &dir); + } + } + } + + let cargo_status = cargo_cmd.status()?; + if !cargo_status.success() { + return Err(anyhow!( + "Failed to build native library crate for {}: {}", + native_lib.module, + target_config.crate_path.display() + )); + } + } + + // Find and link the static library + let lib_name = &target_config.lib_name; + if !lib_name.is_empty() { + // Search in the crate's target directory first, then standard paths. + // Refs #564: probe both `target/release/` and + // `target//release/` for native builds — cargo + // writes to the triple-prefixed dir when a default target is + // pinned via `[build] target` / `CARGO_BUILD_TARGET` / + // `rust-toolchain.toml`. + let crate_target_dir = target_config.crate_path.join("target"); + let lib_path = super::super::library_search::locate_native_lib_artifact( + &crate_target_dir, + target, + lib_name, + ); + + if let Some(lib) = lib_path { + let lib = dedup_native_lib_for_tier3(target, lib_name, lib); + // For shared libraries (.so) on Android, use -L/-l so the linker + // records just the soname (not the full build path) in DT_NEEDED. + if is_android && lib_name.ends_with(".so") { + if let Some(dir) = lib.parent() { + cmd.arg(format!("-L{}", dir.display())); + } + // Strip "lib" prefix and ".so" suffix for -l flag + let stem = lib_name.strip_prefix("lib").unwrap_or(lib_name); + let stem = stem.strip_suffix(".so").unwrap_or(stem); + cmd.arg(format!("-l{}", stem)); + } else { + // When building a plugin host on macOS, force-load plugin-related native + // libraries so their symbols are available for dlopen'd plugin dylibs. + let force_load = cfg!(target_os = "macos") + && ctx.needs_plugins + && native_lib.module.contains("plugin"); + if force_load { + cmd.arg(format!("-Wl,-force_load,{}", lib.display())); + } else if is_windows && lib.extension().map_or(false, |e| e == "lib") { + // On Windows, link native staticlibs directly — + // /FORCE:MULTIPLE handles duplicate symbols. + cmd.arg(&lib); + } else { + cmd.arg(&lib); + } + } + match format { + OutputFormat::Text => { + println!("Linking native library: {}", lib.display()) + } + OutputFormat::Json => {} + } + } else { + return Err(anyhow!( + "Native library {} not found after building {} crate", + lib_name, + native_lib.module + )); + } + } + } // closes else of `if let Some(prebuilt) = ...` (issue #860) + + // Add platform frameworks + for framework in &target_config.frameworks { + cmd.arg("-framework").arg(framework); + } + + // Issue #1304 — vendored-SDK frameworks (e.g. GoogleSignIn + // for `@perryts/google-auth`). These live in a directory the + // app dev built/downloaded locally, named by the wrapper's + // `frameworks_env` env var. When that var is set and resolves + // to an existing directory, add it as a framework search path + // (`-F `) and emit one `-framework ` per declared + // `optional_frameworks` entry. When it's unset (or points at + // something that isn't a directory) we skip silently: the + // wrapper's `#if canImport(...)` Swift bridge already compiles + // a no-SDK fallback path, so the binary still links and + // returns a runtime "framework not linked" result rather than + // failing with undefined `GID*` symbols. + // + // Contract is static frameworks only — `-framework` links the + // archive directly with no `.app/Frameworks/` embed + rpath. + // + // #1303 — the search dir resolves from the `frameworks_env` env + // var (local) or, when unset, the project-relative + // `perry.toml [google_auth].framework_dir` (so a `perry publish` + // worker build links the real SDK instead of the stub). + if let Some(env_name) = target_config.frameworks_env.as_deref() { + if !target_config.optional_frameworks.is_empty() { + match resolve_optional_framework_dir(env_name, args_input) { + Some(dir) if dir.is_dir() => { + cmd.arg("-F").arg(&dir); + for framework in &target_config.optional_frameworks { + cmd.arg("-framework").arg(framework); + } + if let OutputFormat::Text = format { + println!( + "Linking {} optional framework(s) for {} ({})", + target_config.optional_frameworks.len(), + native_lib.module, + dir.display() + ); + } + } + Some(dir) => { + if let OutputFormat::Text = format { + println!( + "Skipping optional frameworks for {}: {:?} is not a directory", + native_lib.module, + dir.display() + ); + } + } + None => { + // Neither env var nor framework_dir → silent skip + // (the wrapper's canImport fallback keeps linking). + } + } + } + } + + // Add library search paths. MSVC link.exe takes `/LIBPATH:`; + // every other linker we drive (clang/ld on Apple, gcc/ld on + // Linux/Android/HarmonyOS) understands `-L`. Mirror the + // `target_config.libs` branch immediately below so a + // `targets.windows.libDirs` entry actually resolves the + // `{lib}.lib` lookups instead of being a silent no-op. + for lib_dir in &target_config.lib_dirs { + if is_windows { + cmd.arg(format!("/LIBPATH:{}", lib_dir.display())); + } else { + cmd.arg(format!("-L{}", lib_dir.display())); + } + } + + // Add platform libraries + for lib in &target_config.libs { + if is_windows { + cmd.arg(format!("{}.lib", lib)); + } else { + cmd.arg(format!("-l{}", lib)); + } + } + + // Add pkg-config libraries + for pkg in &target_config.pkg_config { + pkg_config::validate_pkg_config_name(pkg)?; + if let Ok(output) = Command::new("pkg-config").args(["--libs", pkg]).output() { + if output.status.success() { + let libs = String::from_utf8_lossy(&output.stdout); + for flag in libs.trim().split_whitespace() { + cmd.arg(flag); + } + } + } + } + + for backend in &target_config.backends { + if !backend.available { + if let (OutputFormat::Text, Some(reason)) = + (format, backend.unavailable_reason.as_deref()) + { + println!( + "Skipping {} backend for {}: {}", + backend.backend.as_str(), + native_lib.module, + reason + ); + } + } + } + + for backend in select_available_backend_link_metadata(target_config) { + if let Some(prebuilt) = backend.prebuilt.as_ref() { + if !prebuilt.exists() { + return Err(anyhow!( + "Prebuilt {} backend library declared by {} not found at {}. \ + Install the matching optional dependency or update \ + perry.nativeLibrary.targets..backends.{}.prebuilt.", + backend.backend.as_str(), + native_lib.module, + prebuilt.display(), + backend.backend.as_str() + )); + } + cmd.arg(prebuilt); + match format { + OutputFormat::Text => println!( + "Linking prebuilt {} backend library: {}", + backend.backend.as_str(), + prebuilt.display() + ), + OutputFormat::Json => {} + } + } + + for framework in &backend.frameworks { + cmd.arg("-framework").arg(framework); + } + for lib_dir in &backend.lib_dirs { + if is_windows { + cmd.arg(format!("/LIBPATH:{}", lib_dir.display())); + } else { + cmd.arg(format!("-L{}", lib_dir.display())); + } + } + for lib in &backend.libs { + if is_windows { + cmd.arg(format!("{}.lib", lib)); + } else { + cmd.arg(format!("-l{}", lib)); + } + } + for pkg in &backend.pkg_config { + pkg_config::validate_pkg_config_name(pkg)?; + if let Ok(output) = Command::new("pkg-config").args(["--libs", pkg]).output() { + if output.status.success() { + let libs = String::from_utf8_lossy(&output.stdout); + for flag in libs.trim().split_whitespace() { + cmd.arg(flag); + } + } + } + } + } + + // Compile manifest-declared Swift sources to object files and + // append them to the link line. Used by `--features watchos-swift-app` + // so a native lib can ship its own `@main struct App: App`. + if !target_config.swift_sources.is_empty() { + if !is_watchos { + return Err(anyhow!( + "perry.nativeLibrary.targets..swift_sources is only supported on watchos/watchos-simulator" + )); + } + let swift_sdk = if target == Some("watchos-simulator") { + "watchsimulator" + } else { + "watchos" + }; + // arm64_32 watchOS (Series 4-8 / SE): opt-in, matches the app + // binary's triple in platform_cmd.rs so the native @main lib + // links against the same arch. + let swift_arm64_32 = + target == Some("watchos") && std::env::var("PERRY_WATCHOS_ARM64_32").is_ok(); + let swift_watchos_min = + std::env::var("PERRY_WATCHOS_MIN").unwrap_or_else(|_| "11.0".to_string()); + let swift_triple_owned; + let swift_triple = if target == Some("watchos-simulator") { + "arm64-apple-watchos10.0-simulator" + } else if swift_arm64_32 { + swift_triple_owned = format!("arm64_32-apple-watchos{}", swift_watchos_min); + swift_triple_owned.as_str() + } else { + "arm64-apple-watchos26.0" + }; + let swift_sysroot = String::from_utf8( + Command::new("xcrun") + .args(["--sdk", swift_sdk, "--show-sdk-path"]) + .output()? + .stdout, + )? + .trim() + .to_string(); + let swiftc = String::from_utf8( + Command::new("xcrun") + .args(["--sdk", swift_sdk, "--find", "swiftc"]) + .output()? + .stdout, + )? + .trim() + .to_string(); + + let swift_obj_dir = + std::env::temp_dir().join(format!("perry_swift_{}", std::process::id())); + std::fs::create_dir_all(&swift_obj_dir).ok(); + + for swift_src in &target_config.swift_sources { + if !swift_src.exists() { + return Err(anyhow!( + "Swift source not found: {} (declared in {}'s nativeLibrary.swift_sources)", + swift_src.display(), + native_lib.module + )); + } + let canonical = swift_src + .canonicalize() + .unwrap_or_else(|_| swift_src.clone()); + if !seen_swift_sources.insert(canonical) { + continue; + } + let stem = swift_src + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("swift_src"); + let obj_out = swift_obj_dir.join(format!("{}.o", stem)); + let status = Command::new(&swiftc) + .arg("-target") + .arg(swift_triple) + .arg("-sdk") + .arg(&swift_sysroot) + .arg("-parse-as-library") + .arg("-emit-object") + .arg("-O") + .arg("-o") + .arg(&obj_out) + .arg(swift_src) + .status()?; + if !status.success() { + return Err(anyhow!( + "Failed to compile Swift source: {}", + swift_src.display() + )); + } + cmd.arg(&obj_out); + match format { + OutputFormat::Text => { + println!("Linking Swift object: {}", obj_out.display()) + } + OutputFormat::Json => {} + } + } + } + + // Metal sources are compiled + packed into .app/default.metallib + // after the `.app` bundle is created below. Just validate the target + // here so we fail early with a clear message instead of silently + // dropping shaders on non-Apple-bundle targets. + if !target_config.metal_sources.is_empty() + && !matches!( + target, + Some("ios") + | Some("ios-simulator") + | Some("tvos") + | Some("tvos-simulator") + | Some("watchos") + | Some("watchos-simulator") + | Some("visionos") + | Some("visionos-simulator") + ) + { + return Err(anyhow!( + "perry.nativeLibrary.targets..metal_sources is only supported on ios / ios-simulator / tvos / tvos-simulator / watchos / watchos-simulator / visionos / visionos-simulator" + )); + } + } + } + + // macOS privacy APIs (including camera/microphone requests made by + // WKWebView) consult the process Info.plist for usage-description keys. + // Perry's direct desktop output is a Mach-O executable, not a .app bundle, + // so embed a minimal Info.plist section when linking native macOS UI apps. + // Without this, WKWebView media capture can be denied by the platform even + // when WKUIDelegate grants the web-origin permission. + let is_macos_executable = + (target.is_none() && cfg!(target_os = "macos")) || matches!(target, Some("macos")); + let mut embedded_info_plist_path: Option = None; + if ctx.needs_ui && is_macos_executable { + let exe_stem = exe_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("perry-app"); + let bundle_id = format!( + "dev.perry.{}", + exe_stem + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect::() + .trim_matches('-') + ); + let info_plist = format!( + r#" + + + + CFBundleIdentifier + {bundle_id} + CFBundleName + {exe_stem} + CFBundleExecutable + {exe_stem} + CFBundlePackageType + APPL + NSCameraUsageDescription + This app uses the camera for WebView video calls. + NSMicrophoneUsageDescription + This app uses the microphone for WebView video calls. + + +"# + ); + let plist_path = std::env::temp_dir().join(format!( + "perry-embedded-info-{}-{}.plist", + std::process::id(), + exe_stem + )); + fs::write(&plist_path, info_plist)?; + embedded_info_plist_path = Some(plist_path.clone()); + if is_cross_macos { + cmd.arg("-sectcreate") + .arg("__TEXT") + .arg("__info_plist") + .arg(&plist_path); + } else { + cmd.arg(format!( + "-Wl,-sectcreate,__TEXT,__info_plist,{}", + plist_path.display() + )); + } + } + + let link_cache_status = prepare_link_cache_status( + &ctx.cache_root, + target, + &cmd, + obj_paths, + obj_fingerprints, + exe_path, + ); + if !link_cache_status.linked { + if let Some(path) = embedded_info_plist_path { + let _ = fs::remove_file(path); + } + return Ok(link_cache_status); + } + + // Windows hosts cap a spawned process's command line (`CreateProcess` ~32 + // KiB); a link with many object files — ≥~450 local modules (e.g. dense + // barrel `export *` re-exports, or the `@earendil-works/pi-ai` module + // graph) — overflows it and fails with `The filename or extension is too + // long. (os error 206)` before the linker even starts. Route the whole + // argument vector through a linker *response file* (`@file`), which + // `link.exe` / `lld-link` (and `clang`/`cc`) read instead of the command + // line, so the invocation no longer scales with module count. Only the + // native-Windows host is affected (other hosts have a multi-MB `ARG_MAX`); + // `PERRY_FORCE_LINK_RESPONSE_FILE=1` forces the path elsewhere for testing. + let mut response_file_to_clean: Option = None; + if cfg!(target_os = "windows") || std::env::var_os("PERRY_FORCE_LINK_RESPONSE_FILE").is_some() { + // MSVC-style quoting for the native-Windows linker (link.exe/lld-link); + // GNU-style for a clang/cc driver (the forced-test path on macOS/Linux). + let msvc_quoting = cfg!(target_os = "windows") && is_windows; + if let Some((rsp_cmd, rsp_path)) = rewrite_link_with_response_file(&cmd, msvc_quoting) { + cmd = rsp_cmd; + response_file_to_clean = Some(rsp_path); + } + } + + let status_result = cmd.status(); + if let Some(path) = response_file_to_clean { + let _ = fs::remove_file(path); + } + if let Some(path) = embedded_info_plist_path { + let _ = fs::remove_file(path); + } + let status = status_result?; + + if !status.success() { + return Err(anyhow!("Linking failed")); + } + + Ok(link_cache_status) +} diff --git a/crates/perry/src/commands/compile/link/mod.rs b/crates/perry/src/commands/compile/link/mod.rs index 8f0602cb5..ab8af1faa 100644 --- a/crates/perry/src/commands/compile/link/mod.rs +++ b/crates/perry/src/commands/compile/link/mod.rs @@ -39,12 +39,14 @@ use super::{ windows_pe_subsystem_flag, windows_subsystem_needs_ui, CompilationContext, }; +mod build_and_run; mod link_cache; mod native_features; mod pkg_config; mod platform_cmd; mod windows_link; +pub(super) use build_and_run::build_and_run_link; use link_cache::prepare_link_cache_status; pub(super) use link_cache::{write_link_cache_manifest, LinkCacheStatus}; pub use platform_cmd::select_linker_command; @@ -218,1784 +220,6 @@ fn resolve_optional_framework_dir(env_name: &str, args_input: &Path) -> Option

, - obj_paths: &[PathBuf], - obj_fingerprints: &[Option], - compiled_features: &[String], - runtime_lib: &Path, - stdlib_lib: &Option, - // #466 Phase 4 step 2: well-known native binding archives. Added - // to the link line right after `stdlib_lib`. The matching - // perry-stdlib feature was already stripped during the auto- - // optimize rebuild, so the resulting link contains exactly one - // copy of each `_js__*` symbol — no duplicates. - well_known_libs: &[PathBuf], - // No-auto / auto-fallback keeps the full prebuilt stdlib, so the - // matching perry-stdlib feature was not stripped. Put wrappers first - // in that shape so wrapper-only handles keep using their own surface - // symbols instead of the bundled stdlib copies. - prefer_well_known_before_stdlib: bool, - // Issue #76 — `libperry_wasm_host.a` (wasmi-backed WebAssembly host - // runtime). Only `Some(...)` when the user passed `--enable-wasm-runtime` - // and the archive was located. Appended to the link command after the - // stdlib block so the linker resolves `perry_wasm_host_*` - // symbols referenced by `js_webassembly_*` shims in `perry-runtime`. - wasm_host_lib: &Option, - exe_path: &Path, - format: OutputFormat, - // `--debug-symbols`: keep symbols / emit a PDB so RUST_BACKTRACE - // panics in the compiled app symbolize. Windows-active today. - debug_symbols: bool, -) -> Result { - // #498 - supply-chain gate. Before any prebuilt archive hits the - // linker, hash it and compare against `perry.lock`. First build - // writes the lockfile; subsequent builds verify. Mismatch fails - // with an actionable diagnostic (`perry lock --update `). - // `PERRY_LOCK_FROZEN=1` upgrades the verify to CI mode (refuses - // to extend the lock); `PERRY_LOCK_UPDATE=` deliberately - // bumps the named package's hashes. Wired here so every backend - // (LLVM / WASM / ArkTS / HarmonyOS / Glance / SwiftUI / JS) - // inherits the gate from one chokepoint. - super::run_lock_verify_for_compile(ctx, target)?; - - let is_ios = matches!(target, Some("ios-simulator") | Some("ios")); - let is_visionos = matches!(target, Some("visionos-simulator") | Some("visionos")); - // Wear OS links exactly like Android (same triple, NDK, cdylib + TLS model). - let is_android = matches!(target, Some("android") | Some("wearos")); - let is_harmonyos = matches!(target, Some("harmonyos") | Some("harmonyos-simulator")); - let is_linux = matches!(target, Some(t) if t.starts_with("linux")) - || (target.is_none() && cfg!(target_os = "linux")); - // Fully-static musl Linux target (#4826) — a sub-case of is_linux. The - // link command itself is built in select_linker_command (musl driver + - // `-static`); here it only changes which system libs we request. - let is_musl = matches!( - target, - Some("linux-musl") | Some("linux-x86_64-musl") | Some("linux-aarch64-musl") - ); - - // The musl target is meant for headless/serverless binaries (Lambda, - // scratch, distroless). The GTK4 UI backend (perry/ui) links the system - // GTK/glib/webkit stack, which is only shipped for glibc and cannot be - // statically linked into a musl binary. Fail fast with an actionable - // message rather than emitting cryptic undefined-symbol errors (#4826). - if is_musl && ctx.needs_ui { - anyhow::bail!( - "perry/ui is not supported with the static musl Linux target \ - (--libc musl / [linux] libc = \"musl\"): the GTK4 UI backend \ - requires dynamic glibc. Build the GUI app with the default \ - (glibc) Linux target, or drop perry/ui for a headless musl build." - ); - } - let is_windows = matches!(target, Some("windows") | Some("windows-winui")) - || (target.is_none() && cfg!(target_os = "windows")); - let is_cross_windows = is_windows && !cfg!(target_os = "windows"); - let is_cross_ios = is_ios && !cfg!(target_os = "macos"); - let is_cross_visionos = is_visionos && !cfg!(target_os = "macos"); - let is_cross_macos = matches!(target, Some("macos")) && !cfg!(target_os = "macos"); - let is_watchos = matches!(target, Some("watchos") | Some("watchos-simulator")); - let is_tvos = matches!(target, Some("tvos") | Some("tvos-simulator")); - let is_cross_tvos = is_tvos && !cfg!(target_os = "macos"); - - let mut cmd = select_linker_command( - args_input, - ctx, - target, - obj_paths, - compiled_features, - is_ios, - is_visionos, - is_android, - is_harmonyos, - is_linux, - is_windows, - is_cross_windows, - is_cross_ios, - is_cross_visionos, - is_cross_macos, - is_watchos, - is_tvos, - is_cross_tvos, - )?; - - // When ios-game-loop is enabled, rename _main to _perry_user_main in the - // entry object file so the perry runtime's main() (from ios_game_loop.rs) - // becomes the process entry point. It spawns _perry_user_main on a game thread. - if (is_ios || is_tvos || is_visionos) && compiled_features.iter().any(|f| f == "ios-game-loop") - { - // Resolve an objcopy: rust-objcopy / llvm-objcopy from the host Rust - // toolchain (macOS), then llvm-objcopy on Linux builders, then PATH. - let objcopy = std::env::var("HOME").ok() - .map(|h| PathBuf::from(h).join(".rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/aarch64-apple-darwin/bin/rust-objcopy")) - .filter(|p| p.exists()) - .or_else(|| std::env::var("HOME").ok() - .map(|h| PathBuf::from(h).join(".rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/aarch64-apple-darwin/bin/llvm-objcopy")) - .filter(|p| p.exists())) - .or_else(|| ["/usr/lib/llvm-18/bin/llvm-objcopy", "/usr/bin/llvm-objcopy-18", "/usr/bin/llvm-objcopy"] - .iter().map(PathBuf::from).find(|p| p.exists())) - .unwrap_or_else(|| PathBuf::from("rust-objcopy")); - // Rename _main -> __perry_user_main so the perry runtime's main() - // (ios_game_loop.rs) becomes the process entry point and spawns the - // user's main on a game thread. The entry object can't be located by - // filename — with the object cache on it's named by content hash, not - // "main_ts" — so apply the rename to every user object. objcopy - // --redefine-sym is a no-op on objects that don't define _main, so this - // only ever rewrites the single entry object regardless of its name. - for obj in obj_paths.iter() { - let _ = Command::new(&objcopy) - .args(["--redefine-sym", "_main=__perry_user_main"]) - .arg(obj) - .status(); - } - } - - for obj_path in obj_paths { - cmd.arg(obj_path); - } - - // HarmonyOS: pick up native C objects that build.rs scripts emitted - // alongside the Rust artifacts. Rust's staticlib normally bundles these - // into libperry_runtime.a, but on our macOS→OHOS cross-build the - // `libmimalloc.a` wrapper ends up as a zero-member BSD-format archive - // (BSD ar's `__.SYMDEF SORTED` layout — macOS-host `ar` creates it, - // llvm-ar can't read it back), and rustc's "bundle native libs into - // the staticlib" path silently skips it. Without us forwarding the - // loose .o files to the final link, `libentry.so` ends up with - // `mi_malloc_aligned` marked UND, and the OHOS dynamic linker rejects - // dlopen with "symbol not found" at EntryAbility.onCreate time. - // - // We walk `target//release/build/*/out/` and collect every - // loose .o. This is coarser than Rust's per-crate link-lib directive - // walking — it picks up .o files from any transitive C dep, not just - // mimalloc — but that's a feature: the set is tiny in practice - // (mimalloc is the only C dep in perry-runtime's closure today) and - // any that turn out unreferenced are dead-stripped via --gc-sections. - if is_harmonyos { - let triple = super::rust_target_triple(target).unwrap_or("aarch64-unknown-linux-ohos"); - let build_roots: Vec = { - let mut roots: Vec = Vec::new(); - // auto_rebuild emits into a perry-auto- dir; the workspace's - // own target/ is a fallback for non-auto flows. - if let Ok(entries) = std::fs::read_dir("target") { - for entry in entries.flatten() { - let name = entry.file_name(); - let name_str = name.to_string_lossy(); - if name_str.starts_with("perry-auto-") || name_str == triple { - roots.push(entry.path()); - } - } - } - // When invoked from outside the workspace, auto_rebuild still - // lands under the perry source tree's target/. Add that. - if let Some(ws_root) = super::find_perry_workspace_root() { - let ws_target = ws_root.join("target"); - if let Ok(entries) = std::fs::read_dir(&ws_target) { - for entry in entries.flatten() { - let name = entry.file_name(); - let name_str = name.to_string_lossy(); - if name_str.starts_with("perry-auto-") { - roots.push(entry.path()); - } - } - } - } - roots - }; - let mut native_objs: Vec = Vec::new(); - for root in &build_roots { - let build_dir = root.join(triple).join("release").join("build"); - let entries = match std::fs::read_dir(&build_dir) { - Ok(e) => e, - Err(_) => continue, - }; - for crate_build in entries.flatten() { - let out_dir = crate_build.path().join("out"); - // Walk the out/ dir recursively (cc-rs can nest into source- - // mirror subdirs like c_src/mimalloc/v2/src/). - if let Ok(walker) = walkdir::WalkDir::new(&out_dir) - .into_iter() - .collect::, _>>() - { - for entry in walker { - if entry.file_type().is_file() - && entry.path().extension().and_then(|e| e.to_str()) == Some("o") - { - native_objs.push(entry.path().to_path_buf()); - } - } - } - } - } - if !native_objs.is_empty() && matches!(format, crate::OutputFormat::Text) { - println!( - " harmonyos: linking {} build.rs native object(s)", - native_objs.len() - ); - } - for obj in native_objs { - cmd.arg(obj); - } - } - - // Dead code stripping — safe because compile_init() emits func_addr - // calls for every class method/getter during vtable registration. These - // serve as linker roots that keep dynamically-dispatched methods alive. - if !is_windows { - if is_android || is_linux || is_harmonyos { - cmd.arg("-Wl,--gc-sections"); - } else if is_cross_ios || is_cross_visionos || is_cross_macos || is_cross_tvos { - // ld64.lld called directly — no -Wl, prefix needed - cmd.arg("-dead_strip"); - } else if is_watchos || is_visionos { - cmd.arg("-Xlinker").arg("-dead_strip"); - } else { - // Native macOS/iOS via clang driver - cmd.arg("-Wl,-dead_strip"); - } - } else { - // MSVC link.exe / lld-link equivalents: - // /OPT:REF — drop unreferenced functions/data (= --gc-sections) - // /OPT:ICF — fold identical COMDATs (= --icf=safe) - // These are documented as defaults under /RELEASE, but Perry doesn't - // pass /RELEASE so the linker falls back to /OPT:NOREF, pulling in the - // entire perry-stdlib archive even when only a fraction is used. - cmd.arg("/OPT:REF"); - if debug_symbols { - // `/DEBUG` makes lld-link emit a PDB next to the .exe from the - // debug info already present in the input objects/libs. Without - // it, perry binaries have no symbol table and a RUST_BACKTRACE - // panic is an unreadable list of `` — there is no other - // way to diagnose a runtime crash in a compiled Windows app. - // Skip /OPT:ICF here: COMDAT folding collapses distinct - // identical-bodied functions to one symbol, which would make the - // very backtrace this flag exists to produce ambiguous. - cmd.arg("/DEBUG"); - } else { - cmd.arg("/OPT:ICF"); - } - } - - // Link libraries - stdlib bundles perry-runtime; runtime provides base FFI symbols. - // Note: libperry_stdlib.a may omit some runtime symbols (js_register_class_method, - // js_register_class_getter, etc.) due to Rust DCE on rlib dependencies. We always - // link libperry_runtime.a as a fallback to fill these gaps. On macOS/Linux/ELF the - // linker uses first-definition-wins for archives, so no duplicate symbol errors arise. - // When UI lib is also linked, it bundles its own copy of perry-runtime. - // For Android (ELF), skip the extra runtime when UI provides it. - // On Windows (MSVC), always link the runtime — the UI lib's rlib dependency on - // perry-runtime may not include all symbols (e.g., perry_init_guard_check_and_set). - // watchOS: swiftc treats duplicate symbols as errors (not warnings like clang), - // so skip the standalone runtime when the UI lib already bundles it. - // Note: even when bitcode_linked is true, we still link the .a archives. - // The merged .o contains the crate code but NOT the Rust standard library - // symbols (alloc, std::thread_local, etc.). The .a archive provides those - // as a fallback — the linker only pulls object files from the .a that - // resolve still-undefined symbols (first-definition-wins on macOS). - let skip_runtime = (is_android || is_watchos || is_visionos) - && (ctx.needs_ui || is_watchos) - && find_ui_library(target).is_some(); - let well_known_libs: Vec = if prefer_well_known_before_stdlib { - well_known_libs - .iter() - .map(|wk| { - strip_duplicate_objects_from_well_known_lib(wk).unwrap_or_else(|_| wk.clone()) - }) - .collect() - } else { - well_known_libs.to_vec() - }; - if !skip_runtime { - if ctx.needs_stdlib || is_windows { - // On Windows/MSVC, always try to link stdlib because codegen unconditionally - // declares all stdlib extern functions, creating import references that MSVC - // won't dead-strip. On macOS/Linux, the linker ignores unreferenced archives. - if let Some(ref stdlib) = stdlib_lib { - // Windows: link the standalone perry_runtime.lib FIRST so - // its symbols win lld-link's /FORCE:MULTIPLE "first - // definition wins" rule over the perry-runtime copies - // *bundled* inside perry_stdlib.lib and the - // /WHOLEARCHIVE'd perry_ui_windows.lib. Auto-optimize - // refreshes perry-runtime + perry-stdlib but NOT - // perry-ui-windows, so the UI lib's bundled runtime is - // perpetually stale; the /WHOLEARCHIVE force-includes its - // js_* symbols, and without this the stale copy shadows a - // genuine runtime fix (e.g. the js_shadow_frame_pop bounds - // guard, #880) — the crash it fixes still fires because the - // guarded function never gets linked. The standalone - // runtime_lib is the canonical / auto-optimize-fresh - // source; making it authoritative on Windows matches every - // other platform (all of which already link runtime_lib). - if is_windows { - cmd.arg(runtime_lib); - } - if prefer_well_known_before_stdlib { - for wk in &well_known_libs { - cmd.arg(wk); - } - } - // Tier-3 (tvOS/watchOS) std-duplication dedup; no-op elsewhere. - cmd.arg(&dedup_stdlib_for_tier3(target, stdlib)); - // #466 Phase 4 step 2: well-known bindings normally join the - // link line right after perry-stdlib so they cover the exact - // `_js_*` symbol gap that was just opened by stripping the - // corresponding feature from the perry-stdlib rebuild. - // - // In no-auto/fallback mode the full prebuilt stdlib may still - // contain method-value bridge objects that reference wrapper - // symbols (for example external net Socket helpers). Archives - // are scanned left-to-right, so repeat the well-known libs - // after stdlib as well: the first occurrence lets wrapper - // definitions win over duplicate bundled stdlib functions, - // and the second resolves stdlib bridge references. - for wk in &well_known_libs { - cmd.arg(wk); - } - // Also link runtime for symbols DCE'd from stdlib's bundled - // perry-runtime; on tier-3 it's first stripped of stdlib's objects. - if !is_android && !is_windows { - cmd.arg(&dedup_runtime_for_tier3(target, runtime_lib, stdlib)); - } - } else { - if ctx.needs_stdlib { - eprintln!( - "Warning: stdlib required but {} not found, using runtime-only", - if is_windows { - "perry_stdlib.lib" - } else { - "libperry_stdlib.a" - } - ); - } - cmd.arg(runtime_lib); - } - } else { - // Runtime-only linking — no stdlib needed - cmd.arg(runtime_lib); - } - } else if ctx.needs_stdlib { - // Android + UI: runtime is provided by UI lib, but stdlib must still be linked - // separately (UI lib does not bundle perry-stdlib). - if let Some(ref stdlib) = stdlib_lib { - if prefer_well_known_before_stdlib { - for wk in &well_known_libs { - cmd.arg(wk); - } - } - cmd.arg(stdlib); - // #466 Phase 4 step 2: see the parallel comment in the - // non-Android branch above. - for wk in &well_known_libs { - cmd.arg(wk); - } - } else { - eprintln!("Warning: stdlib required but libperry_stdlib.a not found"); - } - } - - // Issue #76 — wasmi host runtime, opt-in via `--enable-wasm-runtime`. - // Append after stdlib so the linker can resolve `perry_wasm_host_*` - // symbols referenced by the always-present `js_webassembly_*` FFIs in - // perry-runtime. - if let Some(ref wasm_host) = wasm_host_lib { - cmd.arg(wasm_host); - } - - if is_windows { - cmd.arg(format!("/OUT:{}", exe_path.display())); - } else { - cmd.arg("-o").arg(exe_path).arg("-lc"); - } - - // For plugin hosts, export symbols so dlopen'd plugins can resolve them. - // Plugins are dylibs loaded via dlopen — they need to resolve: - // 1. hone_host_api_* (plugin→host calls) - // 2. js_*/perry_* (Perry runtime used by compiled plugin code) - // We use -u to prevent dead_strip from removing these, keeping binary size small. - if ctx.needs_plugins && !is_windows { - #[cfg(target_os = "macos")] - { - // Force-keep all functions from plugin-related native libraries - for native_lib in &ctx.native_libraries { - if native_lib.module.contains("plugin") { - for func in &native_lib.functions { - cmd.arg(format!("-Wl,-u,_{}", func.name)); - } - } - } - // Force-keep Perry runtime symbols that plugin dylibs reference. - // These are collected from the Perry runtime's public API. - // Using -u tells the linker "treat as referenced" so dead_strip keeps them. - let runtime_syms = [ - "js_array_alloc", - "js_array_from_f64", - "js_array_push_f64", - "js_bigint_is_zero", - "js_closure_alloc", - "js_console_log_spread", - "js_dynamic_object_get_property", - "js_dynamic_string_equals", - "js_gc_register_global_root", - "js_is_truthy", - "js_jsvalue_compare", - "js_jsvalue_equals", - "js_nanbox_get_pointer", - "js_nanbox_pointer", - "js_nanbox_string", - "js_native_call_method", - "js_object_alloc_class_with_keys", - "js_object_alloc_with_shape", - "js_register_class_method", - "js_string_char_code_at", - "js_string_from_bytes", - "js_string_length", - "perry_debug_trace_init", - "perry_debug_trace_init_done", - "perry_init_guard_check_and_set", - ]; - for sym in &runtime_syms { - cmd.arg(format!("-Wl,-u,_{}", sym)); - } - } - #[cfg(target_os = "linux")] - { - cmd.arg("-rdynamic"); - } - } - - if is_watchos { - // watchOS frameworks (swiftc auto-links Swift stdlib on the non-game-loop path) - let is_watchos_game_loop = compiled_features.iter().any(|f| f == "watchos-game-loop"); - let is_watchos_swift_app = compiled_features.iter().any(|f| f == "watchos-swift-app"); - if !is_watchos_game_loop { - cmd.arg("-framework").arg("SwiftUI"); - } - cmd.arg("-framework") - .arg("WatchKit") - .arg("-framework") - .arg("Foundation") - .arg("-framework") - .arg("CoreFoundation") - .arg("-framework") - .arg("Security") - .arg("-lSystem") - .arg("-lresolv"); - if is_watchos_game_loop { - // QuartzCore for CAMetalLayer-backed rendering (Metal.framework is NOT - // in the watchOS SDK — the native lib must dlopen it or supply its own - // path to the device's Metal dylib). -lobjc for the dynamic - // WKApplicationDelegate class registered from watchos_game_loop.rs. - cmd.arg("-framework").arg("QuartzCore").arg("-lobjc"); - } - if is_watchos_swift_app { - // SceneKit for SceneView-backed 3D rendering from the native lib's - // `@main struct App: App`. The lib may additionally use Canvas (2D, - // already covered by SwiftUI) or SpriteKit (opt-in via the - // manifest's `frameworks` list). - cmd.arg("-framework").arg("SceneKit"); - } - } else if is_ios { - // iOS frameworks - cmd.arg("-framework") - .arg("UIKit") - .arg("-framework") - .arg("Foundation") - .arg("-framework") - .arg("WebKit") // perry/ui WebView (#658) — WKWebView. - .arg("-framework") - .arg("CoreGraphics") - .arg("-framework") - .arg("Security") - .arg("-framework") - .arg("CoreFoundation") - .arg("-framework") - .arg("SystemConfiguration") - .arg("-framework") - .arg("QuartzCore") - .arg("-framework") - .arg("AVFAudio") // AVAudioEngine for audio capture - .arg("-framework") - .arg("AVFoundation") // Camera capture (AVCaptureSession) - .arg("-framework") - .arg("CoreMedia") // CMSampleBuffer - .arg("-framework") - .arg("CoreVideo") // CVPixelBuffer - .arg("-framework") - .arg("UserNotifications") // UNUserNotificationCenter (perry/system notificationSend) - .arg("-framework") - .arg("CoreLocation") // CLCircularRegion for UNLocationNotificationTrigger (#96) - .arg("-framework") - .arg("MediaPlayer") // perry/media — Now Playing + Remote Command Center - .arg("-framework") - .arg("MapKit") // perry/ui MapView (#517) — MKMapView - .arg("-framework") - .arg("PDFKit") // perry/ui PdfView (#516) — PDFView - .arg("-framework") - .arg("BackgroundTasks") // perry/background BGTaskScheduler (#538) - .arg("-framework") - .arg("Network") // perry/system network reachability (#582) - .arg("-liconv") - .arg("-lresolv") - .arg("-lobjc") - .arg("-lSystem"); - } else if is_visionos { - cmd.arg("-framework") - .arg("SwiftUI") - .arg("-framework") - .arg("UIKit") - .arg("-framework") - .arg("Foundation") - .arg("-framework") - .arg("CoreGraphics") - .arg("-framework") - .arg("Security") - .arg("-framework") - .arg("CoreFoundation") - .arg("-framework") - .arg("SystemConfiguration") - .arg("-framework") - .arg("QuartzCore") - .arg("-framework") - .arg("AVFAudio") - .arg("-framework") - .arg("AVFoundation") - .arg("-framework") - .arg("CoreMedia") - .arg("-framework") - .arg("CoreVideo") - .arg("-framework") - .arg("MediaPlayer") // perry/media — Now Playing + Remote Command Center - .arg("-framework") - .arg("MapKit") // perry/ui MapView (#517) — MKMapView (visionOS) - .arg("-framework") - .arg("PDFKit") // perry/ui PdfView (#516) — PDFView (visionOS) - .arg("-framework") - .arg("BackgroundTasks") // perry/background BGTaskScheduler (#538) - .arg("-liconv") - .arg("-lresolv") - .arg("-lobjc") - .arg("-lSystem"); - } else if is_tvos { - // tvOS frameworks (UIKit-based, like iOS) - cmd.arg("-framework") - .arg("UIKit") - .arg("-framework") - .arg("Foundation") - .arg("-framework") - .arg("CoreGraphics") - .arg("-framework") - .arg("Security") - .arg("-framework") - .arg("CoreFoundation") - .arg("-framework") - .arg("SystemConfiguration") - .arg("-framework") - .arg("QuartzCore") - .arg("-framework") - .arg("AVFoundation") - .arg("-framework") - .arg("GameController") - .arg("-framework") - .arg("Metal") - .arg("-framework") - .arg("MapKit") // perry/ui MapView (#517) — MKMapView (tvOS) - .arg("-framework") - .arg("MediaPlayer") // perry/media — Now Playing + Siri Remote - .arg("-framework") - .arg("BackgroundTasks") // perry/background BGTaskScheduler (#538) - .arg("-liconv") - .arg("-lresolv") - .arg("-lobjc") - .arg("-lSystem"); - } else if is_harmonyos { - // OpenHarmony system libraries. musl folds m/pthread/dl into libc.a so - // the -l flags are no-ops on the toolchain side; we emit them anyway - // because cargo's static archives reference them and the OHOS dynamic - // linker resolves them at load time. - cmd.arg("-Wl,--allow-multiple-definition") - .arg("-lm") - .arg("-lpthread") - .arg("-ldl"); - // `libace_napi.z.so` provides napi_module_register + napi_create_* - // (consumed by perry-runtime/src/ohos_napi.rs). OHOS naming convention - // is `.z.so` — the `-l` flag strips `lib` and `.so` but NOT the - // middle `.z`, so `-lace_napi.z` is the deliberate spelling. - cmd.arg("-lace_napi.z"); - // `libhilog_ndk.z.so` provides OH_LOG_Print, used by Perry's - // `js_console_log_*` family on harmonyos to route compiled-TS - // console output to hilog (so Perry-emitted log lines surface - // in DevEco/hdc the same way ArkTS console.log does), and by the - // arkts_callbacks bridge for diagnostic register/invoke traces. - cmd.arg("-lhilog_ndk.z"); - // `libtime_service_ndk.so` provides OH_TimeService_GetTimeZone, - // referenced by the `iana-time-zone` crate (pulled in transitively - // by `chrono` etc.) when it detects an OHOS target. The OHOS - // dynamic loader rejects libentry.so at app launch if this isn't - // listed in DT_NEEDED. Note: no `.z.` in the soname, unlike the - // ace_napi / hilog_ndk libs above. - cmd.arg("-ltime_service_ndk"); - } else if is_android { - // Android system libraries - cmd.arg("-Wl,--allow-multiple-definition") - .arg("-lm") - .arg("-ldl") - .arg("-llog"); - - // Stub for JNI_GetCreatedJavaVMs: the jni-sys crate declares this extern - // symbol, but Android has no libjvm.so and libnativehelper.so is only - // available at API 31+. Perry gets the JavaVM from JNI_OnLoad and never - // calls this function, so compile a no-op C stub to satisfy the linker. - let stub_dir = std::env::temp_dir().join(format!("perry_jni_stub_{}", std::process::id())); - std::fs::create_dir_all(&stub_dir).ok(); - let stub_c = stub_dir.join("jni_stub.c"); - let stub_o = stub_dir.join("jni_stub.o"); - std::fs::write( - &stub_c, - concat!( - "typedef int jint;\n", - "typedef jint jsize;\n", - "jint JNI_GetCreatedJavaVMs(void **vm_buf, jsize buf_len, jsize *n_vms) {\n", - " if (n_vms) *n_vms = 0;\n", - " return 0;\n", - "}\n", - ), - ) - .ok(); - let ndk_home = std::env::var("ANDROID_NDK_HOME").unwrap_or_default(); - // #1508: see platform_cmd.rs — same host-tag bug. - let host_tag = if cfg!(target_os = "macos") { - "darwin-x86_64" - } else if cfg!(target_os = "windows") { - "windows-x86_64" - } else { - "linux-x86_64" - }; - let ndk_clang = format!( - "{}/toolchains/llvm/prebuilt/{}/bin/aarch64-linux-android24-clang{}", - ndk_home, - host_tag, - if cfg!(target_os = "windows") { - ".cmd" - } else { - "" - } - ); - let stub_ok = Command::new(&ndk_clang) - .args(["-c", "-fPIC", "-target", "aarch64-linux-android24"]) - .arg("-o") - .arg(&stub_o) - .arg(&stub_c) - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - if stub_ok { - cmd.arg(&stub_o); - } - } else if is_linux { - // Linux system libraries (cross-compile target) - // Allow multiple definitions: stdlib bundles perry-runtime symbols, - // and we also link perry-runtime directly for symbols DCE'd from stdlib. - // macOS Mach-O uses first-definition-wins natively; ELF linkers need this flag. - cmd.arg("-Wl,--allow-multiple-definition") - .arg("-lm") - .arg("-lpthread") - .arg("-ldl"); - - // -lssl/-lcrypto are vestigial — Perry's stdlib is rustls-only (no - // system OpenSSL). On glibc they happen to be present so the link - // tolerates them, but a static musl sysroot has no libssl.a/libcrypto.a - // and the link would fail. Skip them for musl (#4826). - if ctx.needs_stdlib && !is_musl { - cmd.arg("-lssl").arg("-lcrypto"); - } - } else if is_windows { - windows_link::add_system_libs(&mut cmd); - windows_link::embed_app_manifest(&mut cmd, ctx.needs_ui); - } else { - // macOS frameworks for runtime (sysinfo, etc.) and V8. - // Gate on `!is_harmonyos` so the macOS host doesn't leak its - // frameworks into ELF cross-compile targets that fall through this - // `else` branch — `cfg!(target_os = "macos")` is true whenever we're - // running ON macOS, regardless of the actual target. - if (cfg!(target_os = "macos") || is_cross_macos) && !is_harmonyos { - cmd.arg("-framework") - .arg("Security") - .arg("-framework") - .arg("CoreFoundation") - .arg("-framework") - .arg("SystemConfiguration") - .arg("-liconv") - .arg("-lresolv") - .arg("-lobjc"); - } - - // On Linux (native, not cross-compiling to macOS), link against system libraries - if cfg!(target_os = "linux") && !is_cross_macos { - cmd.arg("-lm").arg("-lpthread").arg("-ldl"); - - if ctx.needs_stdlib { - cmd.arg("-lssl").arg("-lcrypto"); - } - } - } - - // Issue #607 — watchOS targets always link the UI lib regardless of - // `ctx.needs_ui`. The watchOS Swift template (`PerryWatchApp.swift`) - // unconditionally references four `@_silgen_name`'d Rust symbols - // (`perry_watchos_tree_version` / `perry_watchos_toggle_changed` / - // `perry_watchos_toast_seq` / `perry_watchos_toast_dismiss`) that - // live in `libperry_ui_watchos.a`. A console-only TS program has - // `needs_ui = false`, so the UI lib was previously not added to the - // link line — leaving those four symbols undefined and the link - // failing. Forcing the UI lib for watchOS adds ~MBs but unblocks - // `console.log("ok")`-only programs from compiling. - let force_ui = is_watchos; - if ctx.needs_ui || force_ui { - // When geisterhand is enabled, prefer the geisterhand-enabled UI lib - // (it contains widget registration calls that the normal lib doesn't have) - let ui_lib_option = if ctx.needs_geisterhand { - find_geisterhand_ui(target).or_else(|| find_ui_library(target)) - } else { - find_ui_library(target) - }; - if let Some(ui_lib) = ui_lib_option { - // The UI staticlib bundles perry_runtime + Rust std. When perry-stdlib - // is also linked (which bundles the same), duplicate symbols cause - // crashes (conflicting static state initialization). Strip duplicates - // on Apple platforms. On Windows/Android, skip strip-dedup because - // perry_runtime objects contain monomorphizations needed by UI code, - // and --allow-multiple-definition (ELF) / /FORCE:MULTIPLE (COFF) - // handles duplicate symbols safely. On Android, skip_runtime=true - // means the UI lib is the sole provider of perry-runtime symbols. - let ui_lib = if is_windows || is_android || is_visionos { - ui_lib - } else { - match strip_duplicate_objects_from_lib(&ui_lib) { - Ok(trimmed) => trimmed, - Err(e) => { - eprintln!("[strip-dedup] skipped for UI lib (non-fatal): {e}"); - ui_lib - } - } - }; - if is_windows { - // lld-link scans archives left-to-right once. The UI lib is - // linked before user code objects, so UI symbols aren't yet - // undefined when the lib is scanned. /WHOLEARCHIVE forces all - // objects from the archive to be included unconditionally. - cmd.arg(format!("/WHOLEARCHIVE:{}", ui_lib.display())); - } else { - cmd.arg(&ui_lib); - } - - if is_watchos { - // SwiftUI/WatchKit already linked above - } else if is_ios || is_visionos || is_tvos { - // UIKit already linked above - } else if is_android { - // Allow multiple definitions from perry-runtime in both UI lib and native libs - cmd.arg("-Wl,--allow-multiple-definition"); - } else if is_linux { - // Allow multiple definitions from perry-runtime in both stdlib and UI lib - cmd.arg("-Wl,--allow-multiple-definition"); - // libperry_ui_gtk4.a's glib::source::trampoline_local - // closures call perry-stdlib's js_stdlib_process_pending / - // js_promise_run_microtasks. When ctx.needs_stdlib is false - // (bare UI program), stdlib isn't linked via the earlier - // path. Force-link it here with --whole-archive so every - // object is pulled unconditionally. --allow-multiple-definition - // above lets it coexist with the runtime stub at - // perry-runtime/src/stdlib_stubs.rs. The async-runtime - // feature is force-enabled for UI builds (see - // build_optimized_libs), so the real js_stdlib_process_pending - // is guaranteed present in libperry_stdlib.a. - let linux_stdlib_for_ui = - stdlib_lib.clone().or_else(|| find_stdlib_library(target)); - if let Some(ref stdlib) = linux_stdlib_for_ui { - cmd.arg("-Wl,--whole-archive") - .arg(stdlib) - .arg("-Wl,--no-whole-archive"); - } - // GTK4 libraries via pkg-config. The fallback fires in two - // distinct cases: pkg-config not installed (spawn fails), OR - // installed but `gtk4.pc` not on the search path (exit != 0 - // — happens e.g. on Ubuntu hosts where libgtk-4-dev is split - // across packages, or when PKG_CONFIG_PATH is locked down). - // Pre-fix the second case silently emitted no GTK link flags - // and the link bombed with hundreds of `g_object_unref` / - // `gtk_widget_*` undefined references (#181). - let mut got_gtk_libs = false; - let pc_out = Command::new("pkg-config").args(["--libs", "gtk4"]).output(); - if let Ok(ref output) = pc_out { - if output.status.success() { - let libs = String::from_utf8_lossy(&output.stdout); - for flag in libs.trim().split_whitespace() { - cmd.arg(flag); - } - got_gtk_libs = true; - } - } - if !got_gtk_libs { - // Mirrors what `pkg-config --libs gtk4` returns on a - // standard libgtk-4-dev install. Pre-fix only listed the - // glib/gio core, which left pango/cairo/gdk_pixbuf - // undefined. - eprintln!( - "Warning: `pkg-config --libs gtk4` did not return GTK4 \ - linker flags ({}). Falling back to a hardcoded GTK4 \ - link set — install `libgtk-4-dev` (Debian/Ubuntu) or \ - `gtk4-devel` (Fedora/RHEL) and ensure pkg-config can \ - find `gtk4.pc` to silence this warning.", - match &pc_out { - Err(e) => format!("pkg-config not runnable: {e}"), - Ok(o) if !o.status.success() => format!( - "pkg-config exited {}: {}", - o.status.code().unwrap_or(-1), - String::from_utf8_lossy(&o.stderr).trim() - ), - Ok(_) => "no output".to_string(), - } - ); - for lib in [ - "-lgtk-4", - "-lgio-2.0", - "-lgobject-2.0", - "-lglib-2.0", - "-lpangocairo-1.0", - "-lpango-1.0", - "-lharfbuzz", - "-lgdk_pixbuf-2.0", - "-lcairo-gobject", - "-lcairo", - "-lgraphene-1.0", - ] { - cmd.arg(lib); - } - } - // PulseAudio for audio capture (only needed with UI) - cmd.arg("-lpulse-simple").arg("-lpulse"); - // GStreamer libs — pulled in by perry-ui-gtk4's gstreamer-rs - // dep (added in v0.5.440 for the perry/media playbin backend). - // GTK4's pkg-config doesn't transitively reference the - // gstreamer-1.0 sonames, so the `-lgstreamer-1.0` (and the - // base/app/video/audio sublibs that gstreamer-rs's playbin - // path touches) have to land on the link line explicitly or ld - // fails with `undefined reference to gst_message_parse_buffering` - // + `DSO missing from command line` (#423). Same pkg-config → - // hardcoded-fallback shape as the GTK4 block above. - let mut got_gst_libs = false; - let gst_pc_out = Command::new("pkg-config") - .args([ - "--libs", - "gstreamer-1.0", - "gstreamer-base-1.0", - "gstreamer-app-1.0", - "gstreamer-video-1.0", - "gstreamer-audio-1.0", - ]) - .output(); - if let Ok(ref output) = gst_pc_out { - if output.status.success() { - let libs = String::from_utf8_lossy(&output.stdout); - for flag in libs.trim().split_whitespace() { - cmd.arg(flag); - } - got_gst_libs = true; - } - } - if !got_gst_libs { - eprintln!( - "Warning: `pkg-config --libs gstreamer-1.0 ...` did not \ - return GStreamer linker flags ({}). Falling back to a \ - hardcoded GStreamer link set — install \ - `libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev` \ - (Debian/Ubuntu) or `gstreamer1-devel \ - gstreamer1-plugins-base-devel` (Fedora/RHEL) to silence \ - this warning.", - match &gst_pc_out { - Err(e) => format!("pkg-config not runnable: {e}"), - Ok(o) if !o.status.success() => format!( - "pkg-config exited {}: {}", - o.status.code().unwrap_or(-1), - String::from_utf8_lossy(&o.stderr).trim() - ), - Ok(_) => "no output".to_string(), - } - ); - for lib in [ - "-lgstreamer-1.0", - "-lgstbase-1.0", - "-lgstapp-1.0", - "-lgstvideo-1.0", - "-lgstaudio-1.0", - ] { - cmd.arg(lib); - } - } - // libshumate — GNOME's GTK4 vector-tile map widget for the - // perry/ui MapView (#517). Same pkg-config → hardcoded - // fallback shape as GTK4 / GStreamer above. - let mut got_shumate_libs = false; - let shumate_pc_out = Command::new("pkg-config") - .args(["--libs", "shumate-1.0"]) - .output(); - if let Ok(ref output) = shumate_pc_out { - if output.status.success() { - let libs = String::from_utf8_lossy(&output.stdout); - for flag in libs.trim().split_whitespace() { - cmd.arg(flag); - } - got_shumate_libs = true; - } - } - if !got_shumate_libs { - eprintln!( - "Warning: `pkg-config --libs shumate-1.0` did not return \ - libshumate linker flags ({}). Falling back to \ - `-lshumate-1.0` — install `libshumate-dev` \ - (Debian/Ubuntu) or `libshumate-devel` (Fedora/RHEL) to \ - silence this warning.", - match &shumate_pc_out { - Err(e) => format!("pkg-config not runnable: {e}"), - Ok(o) if !o.status.success() => format!( - "pkg-config exited {}: {}", - o.status.code().unwrap_or(-1), - String::from_utf8_lossy(&o.stderr).trim() - ), - Ok(_) => "no output".to_string(), - } - ); - cmd.arg("-lshumate-1.0"); - } - // WebKitGTK 6.0 + libsoup-3.0 — perry/ui WebView (#658, v0.5.864). - // perry-ui-gtk4's webkit6/soup3 deps reference symbols like - // `soup_check_version` from libsoup-3.0 transitively; without - // explicit `-lsoup-3.0` ld errors with `DSO missing from - // command line`. Same pkg-config → hardcoded-fallback shape - // as GTK4 / GStreamer / shumate above. - let mut got_webkit_libs = false; - let webkit_pc_out = Command::new("pkg-config") - .args(["--libs", "webkitgtk-6.0", "libsoup-3.0"]) - .output(); - if let Ok(ref output) = webkit_pc_out { - if output.status.success() { - let libs = String::from_utf8_lossy(&output.stdout); - for flag in libs.trim().split_whitespace() { - cmd.arg(flag); - } - got_webkit_libs = true; - } - } - if !got_webkit_libs { - eprintln!( - "Warning: `pkg-config --libs webkitgtk-6.0 libsoup-3.0` \ - did not return WebKitGTK linker flags ({}). Falling \ - back to a hardcoded set — install `libwebkitgtk-6.0-dev` \ - (Debian/Ubuntu) which pulls libsoup-3.0-dev + \ - libjavascriptcoregtk-6.0-dev to silence this warning.", - match &webkit_pc_out { - Err(e) => format!("pkg-config not runnable: {e}"), - Ok(o) if !o.status.success() => format!( - "pkg-config exited {}: {}", - o.status.code().unwrap_or(-1), - String::from_utf8_lossy(&o.stderr).trim() - ), - Ok(_) => "no output".to_string(), - } - ); - for lib in ["-lwebkitgtk-6.0", "-ljavascriptcoregtk-6.0", "-lsoup-3.0"] { - cmd.arg(lib); - } - } - } else if is_windows { - // Win32 system libs already linked above - } else { - if cfg!(target_os = "macos") || is_cross_macos { - cmd.arg("-framework").arg("AppKit"); - // perry/ui WebView (#658) — WKWebView / WKWebViewConfiguration. - cmd.arg("-framework").arg("WebKit"); - cmd.arg("-framework").arg("CoreGraphics"); - cmd.arg("-framework").arg("QuartzCore"); - cmd.arg("-framework").arg("AVFoundation"); - cmd.arg("-framework").arg("Metal"); - cmd.arg("-framework").arg("IOKit"); - cmd.arg("-framework").arg("DiskArbitration"); // needed by CoreGraphics - // perry/media — AVPlayer is in AVFoundation (already linked - // above). CoreMedia provides CMTime + CMTimeGetSeconds / - // CMTimeMakeWithSeconds used for seek + position. MediaPlayer - // provides MPNowPlayingInfoCenter / MPRemoteCommandCenter / - // MPMediaItemArtwork (lock screen + Touch Bar + Now Playing). - cmd.arg("-framework").arg("CoreMedia"); - cmd.arg("-framework").arg("MediaPlayer"); - // perry/ui MapView (#517) — MKMapView lives in MapKit. - cmd.arg("-framework").arg("MapKit"); - // perry/ui PdfView (#516) — PDFView lives in PDFKit, which - // also exposes the PDFDocument / PDFPage classes used for - // page-count + page-navigation queries. - cmd.arg("-framework").arg("PDFKit"); - // perry/system network reachability (#582) — NWPathMonitor. - cmd.arg("-framework").arg("Network"); - } - } - - match format { - OutputFormat::Text => { - println!("Linking perry/ui (native UI) from {}", ui_lib.display()) - } - OutputFormat::Json => {} - } - } else { - let (lib_name, build_cmd) = if is_watchos { - ( - "libperry_ui_watchos.a", - "cargo +nightly build -Z build-std=std,panic_abort --release -p perry-ui-watchos --target aarch64-apple-watchos (or --target aarch64-apple-watchos-sim for the simulator)", - ) - } else if is_tvos { - ( - "libperry_ui_tvos.a", - "cargo build --release -p perry-ui-tvos --target aarch64-apple-tvos", - ) - } else if is_visionos { - ("libperry_ui_visionos.a", "cargo build --release -p perry-ui-visionos --target aarch64-apple-visionos-sim") - } else if is_ios { - ( - "libperry_ui_ios.a", - "cargo build --release -p perry-ui-ios --target aarch64-apple-ios-sim", - ) - } else if is_android { - ( - "libperry_ui_android.a", - // #1529 — TLS model must be global-dynamic for the dlopen'd cdylib. - // `tls-model` is `-Z`-gated on the toolchains we ship against, so - // RUSTC_BOOTSTRAP=1 lets the gated flag through on a stable rustc. - "RUSTC_BOOTSTRAP=1 RUSTFLAGS=\"-Z tls-model=global-dynamic\" cargo build --release -p perry-ui-android --target aarch64-linux-android", - ) - } else if is_linux { - ( - "libperry_ui_gtk4.a", - "cargo build --release -p perry-ui-gtk4 --target x86_64-unknown-linux-gnu", - ) - } else if matches!(target, Some("windows-winui")) { - ( - "perry_ui_windows_winui.lib", - "cargo build --release -p perry-ui-windows-winui --target x86_64-pc-windows-msvc", - ) - } else if is_windows { - ( - "perry_ui_windows.lib", - "cargo build --release -p perry-ui-windows --target x86_64-pc-windows-msvc", - ) - } else { - ( - "libperry_ui_macos.a", - "cargo build --release -p perry-ui-macos", - ) - }; - return Err(anyhow!( - "perry/ui imported but {} not found. Build with: {}", - lib_name, - build_cmd - )); - } - } - - // Link geisterhand libraries if enabled - if ctx.needs_geisterhand { - // Auto-build geisterhand libraries if any are missing - let gh_missing = find_geisterhand_library(target).is_none() - || find_geisterhand_runtime(target).is_none() - || (ctx.needs_stdlib && find_geisterhand_stdlib(target).is_none()) - || (ctx.needs_ui && find_geisterhand_ui(target).is_none()); - if gh_missing { - build_geisterhand_libs(target, format)?; - } - - if let Some(gh_lib) = find_geisterhand_library(target) { - cmd.arg(&gh_lib); - // Link geisterhand-enabled runtime (has the registry + pump functions) - if let Some(gh_runtime) = find_geisterhand_runtime(target) { - cmd.arg(&gh_runtime); - // ELF linkers need --allow-multiple-definition; macOS Mach-O uses first-wins natively - if is_linux || is_android { - cmd.arg("-Wl,--allow-multiple-definition"); - } - } - // On Windows, re-link the stdlib after geisterhand to resolve - // forward references to geisterhand registry functions. - // lld-link scans archives left-to-right once, so the stdlib - // must appear after the geisterhand lib that references it. - // On Windows, force-include geisterhand registry symbols from stdlib. - // lld-link scans archives left-to-right once, so the stdlib's - // geisterhand objects are skipped on first scan (no references yet). - // /INCLUDE forces the linker to pull in the specific symbols. - if is_windows { - cmd.arg("/INCLUDE:perry_geisterhand_queue_action"); - cmd.arg("/INCLUDE:perry_geisterhand_queue_action1"); - cmd.arg("/INCLUDE:perry_geisterhand_queue_state_set"); - cmd.arg("/INCLUDE:perry_geisterhand_request_screenshot"); - cmd.arg("/INCLUDE:perry_geisterhand_register"); - cmd.arg("/INCLUDE:perry_geisterhand_pump"); - cmd.arg("/INCLUDE:perry_geisterhand_start"); - cmd.arg("/INCLUDE:perry_geisterhand_free_string"); - cmd.arg("/INCLUDE:perry_geisterhand_get_closure"); - cmd.arg("/INCLUDE:perry_geisterhand_get_registry_json"); - // Allow duplicate symbols from re-linked stdlib objects - cmd.arg("/FORCE:MULTIPLE"); - } - match format { - OutputFormat::Text => println!("Linking geisterhand (in-process fuzzer)"), - OutputFormat::Json => {} - } - } else { - return Err(anyhow!( - "Failed to build geisterhand libraries. Check that Perry source crates are available." - )); - } - } - - // Build and link external native libraries from perry.nativeLibrary manifests. - // Swift sources are deduplicated across the loop — modules sharing the same - // package.json all see the same swift_sources entries, but each file should - // be compiled + linked once. Without this, swift's mangled symbols for - // structs/classes duplicate N times. - let mut seen_swift_sources: std::collections::HashSet = - std::collections::HashSet::new(); - for native_lib in &ctx.native_libraries { - if let Some(ref target_config) = native_lib.target_config { - if !target_config.available { - if let (OutputFormat::Text, Some(reason)) = - (format, target_config.unavailable_reason.as_deref()) - { - println!( - "Skipping native library {} for this target: {}", - native_lib.module, reason - ); - } - continue; - } - - match format { - OutputFormat::Text => { - println!("Building native library: {} ...", native_lib.module) - } - OutputFormat::Json => {} - } - - // Issue #860 — prebuilt-distribution shortcut. When the - // wrapper's manifest specified a `prebuilt:` path that - // resolved to an on-disk static library, skip the cargo - // build entirely and link the prebuilt archive directly. - // `frameworks` / `libs` / `pkgConfig` / `lib_dirs` are - // still honored below — those are linker flags the host - // toolchain needs regardless of where the `.a` came from. - if let Some(prebuilt) = target_config.prebuilt.as_ref() { - if !prebuilt.exists() { - return Err(anyhow!( - "Prebuilt native library declared by {} not found at {}. \ - If this package is distributed via npm `optionalDependencies` \ - (esbuild/sharp pattern), make sure the per-platform subpackage \ - is installed for the current host/target.", - native_lib.module, - prebuilt.display() - )); - } - cmd.arg(prebuilt); - match format { - OutputFormat::Text => { - println!("Linking prebuilt native library: {}", prebuilt.display()) - } - OutputFormat::Json => {} - } - } else { - // Build the Rust crate - let cargo_toml = target_config.crate_path.join("Cargo.toml"); - if cargo_toml.exists() { - // Tier 3 targets (tvOS, watchOS) need nightly + build-std - let is_tier3 = matches!( - target, - Some("tvos") - | Some("tvos-simulator") - | Some("watchos") - | Some("watchos-simulator") - ); - - // #505: optionally wrap the cargo invocation in - // `sandbox-exec` on macOS to deny network and - // restrict FS writes during `build.rs` execution. - // Off by default for backwards compat; opted into - // via `PERRY_SANDBOX_BUILDRS=1`. Packages listed - // in `perry.allowUnsandboxedBuild` are exempt. - let mut cargo_cmd = - super::sandbox_buildrs::wrap_cargo_command(ctx, &native_lib.module); - if is_tier3 { - cargo_cmd.arg("+nightly"); - } - cargo_cmd - .arg("build") - .arg("--release") - .arg("--manifest-path") - .arg(&cargo_toml); - - // perry.toml `[native-library.""]` feature - // forwarding — see native_features.rs. - native_features::apply_native_library_override( - &mut cargo_cmd, - &ctx.project_root, - &native_lib.module, - matches!(format, OutputFormat::Text), - ); - - if let Some(triple) = rust_target_triple(target) { - cargo_cmd.arg("--target").arg(triple); - } - - if is_tier3 { - // Match perry-runtime's std build flags exactly so the std - // rlibs are bit-identical and dedupe at link time. Without - // this, native libs pull in a parallel std with different - // metadata hashes and the final Swift-driven link fails - // with hundreds of duplicate-symbol errors. - cargo_cmd.arg("-Zbuild-std=std,panic_abort"); - } - - // For Android, ensure 16 KB page size alignment (required by Google Play), - // and force the global-dynamic TLS model (#1529): the native lib's TLS - // relocations are baked into the dlopen'd `libperry_app.so`, so an - // Initial-Executable model (rustc's android default) crashes at load. - if is_android { - let tls_flag = super::optimized_libs::android_global_dynamic_tls_rustflag( - &mut cargo_cmd, - ); - cargo_cmd.env( - "CARGO_TARGET_AARCH64_LINUX_ANDROID_RUSTFLAGS", - format!("-C link-arg=-Wl,-z,max-page-size=16384 {tls_flag}"), - ); - } - - // For HarmonyOS, point cargo at the OHOS SDK's clang + sysroot - // so cc-rs and rustc's linker invocation actually use the - // cross-toolchain instead of falling back to the host `cc`. - if is_harmonyos { - if let Some(sdk) = super::library_search::find_harmonyos_sdk() { - for (k, v) in super::library_search::harmonyos_cross_env(&sdk, target) { - cargo_cmd.env(k, v); - } - } - } - // #1508: For Android, do the same with the NDK so cc-rs can - // compile native C deps (libsqlite3-sys / libmimalloc-sys - // / etc.) using the NDK clang. Without this, cc-rs falls - // back to the host `cc` and fails with - // `failed to find tool "clang.exe"` on Windows (and - // architecturally-mismatched objects on Unix). - if is_android { - if let Some(ndk) = std::env::var_os("ANDROID_NDK_HOME") { - for (k, v) in super::library_search::android_cross_env( - std::path::Path::new(&ndk), - target, - ) { - cargo_cmd.env(k, v); - } - } - } - - // #1303 — when the wrapper crate's `build.rs` gates an - // optional vendored SDK on an env var (`frameworks_env`) - // and the dev declared a project-relative `framework_dir` - // in `perry.toml [google_auth]`, export the resolved - // absolute path so `build.rs` opts the real SDK in. This - // fires on the local machine AND on the `perry publish` - // worker (where the dev's shell env doesn't transfer, but - // the uploaded perry.toml + dir do). If the env var is - // already set we re-export the same value — idempotent. - if let Some(env_name) = target_config.frameworks_env.as_deref() { - if let Some(dir) = resolve_optional_framework_dir(env_name, args_input) { - if dir.is_dir() { - cargo_cmd.env(env_name, &dir); - } - } - } - - let cargo_status = cargo_cmd.status()?; - if !cargo_status.success() { - return Err(anyhow!( - "Failed to build native library crate for {}: {}", - native_lib.module, - target_config.crate_path.display() - )); - } - } - - // Find and link the static library - let lib_name = &target_config.lib_name; - if !lib_name.is_empty() { - // Search in the crate's target directory first, then standard paths. - // Refs #564: probe both `target/release/` and - // `target//release/` for native builds — cargo - // writes to the triple-prefixed dir when a default target is - // pinned via `[build] target` / `CARGO_BUILD_TARGET` / - // `rust-toolchain.toml`. - let crate_target_dir = target_config.crate_path.join("target"); - let lib_path = super::library_search::locate_native_lib_artifact( - &crate_target_dir, - target, - lib_name, - ); - - if let Some(lib) = lib_path { - let lib = dedup_native_lib_for_tier3(target, lib_name, lib); - // For shared libraries (.so) on Android, use -L/-l so the linker - // records just the soname (not the full build path) in DT_NEEDED. - if is_android && lib_name.ends_with(".so") { - if let Some(dir) = lib.parent() { - cmd.arg(format!("-L{}", dir.display())); - } - // Strip "lib" prefix and ".so" suffix for -l flag - let stem = lib_name.strip_prefix("lib").unwrap_or(lib_name); - let stem = stem.strip_suffix(".so").unwrap_or(stem); - cmd.arg(format!("-l{}", stem)); - } else { - // When building a plugin host on macOS, force-load plugin-related native - // libraries so their symbols are available for dlopen'd plugin dylibs. - let force_load = cfg!(target_os = "macos") - && ctx.needs_plugins - && native_lib.module.contains("plugin"); - if force_load { - cmd.arg(format!("-Wl,-force_load,{}", lib.display())); - } else if is_windows && lib.extension().map_or(false, |e| e == "lib") { - // On Windows, link native staticlibs directly — - // /FORCE:MULTIPLE handles duplicate symbols. - cmd.arg(&lib); - } else { - cmd.arg(&lib); - } - } - match format { - OutputFormat::Text => { - println!("Linking native library: {}", lib.display()) - } - OutputFormat::Json => {} - } - } else { - return Err(anyhow!( - "Native library {} not found after building {} crate", - lib_name, - native_lib.module - )); - } - } - } // closes else of `if let Some(prebuilt) = ...` (issue #860) - - // Add platform frameworks - for framework in &target_config.frameworks { - cmd.arg("-framework").arg(framework); - } - - // Issue #1304 — vendored-SDK frameworks (e.g. GoogleSignIn - // for `@perryts/google-auth`). These live in a directory the - // app dev built/downloaded locally, named by the wrapper's - // `frameworks_env` env var. When that var is set and resolves - // to an existing directory, add it as a framework search path - // (`-F

`) and emit one `-framework ` per declared - // `optional_frameworks` entry. When it's unset (or points at - // something that isn't a directory) we skip silently: the - // wrapper's `#if canImport(...)` Swift bridge already compiles - // a no-SDK fallback path, so the binary still links and - // returns a runtime "framework not linked" result rather than - // failing with undefined `GID*` symbols. - // - // Contract is static frameworks only — `-framework` links the - // archive directly with no `.app/Frameworks/` embed + rpath. - // - // #1303 — the search dir resolves from the `frameworks_env` env - // var (local) or, when unset, the project-relative - // `perry.toml [google_auth].framework_dir` (so a `perry publish` - // worker build links the real SDK instead of the stub). - if let Some(env_name) = target_config.frameworks_env.as_deref() { - if !target_config.optional_frameworks.is_empty() { - match resolve_optional_framework_dir(env_name, args_input) { - Some(dir) if dir.is_dir() => { - cmd.arg("-F").arg(&dir); - for framework in &target_config.optional_frameworks { - cmd.arg("-framework").arg(framework); - } - if let OutputFormat::Text = format { - println!( - "Linking {} optional framework(s) for {} ({})", - target_config.optional_frameworks.len(), - native_lib.module, - dir.display() - ); - } - } - Some(dir) => { - if let OutputFormat::Text = format { - println!( - "Skipping optional frameworks for {}: {:?} is not a directory", - native_lib.module, - dir.display() - ); - } - } - None => { - // Neither env var nor framework_dir → silent skip - // (the wrapper's canImport fallback keeps linking). - } - } - } - } - - // Add library search paths. MSVC link.exe takes `/LIBPATH:`; - // every other linker we drive (clang/ld on Apple, gcc/ld on - // Linux/Android/HarmonyOS) understands `-L`. Mirror the - // `target_config.libs` branch immediately below so a - // `targets.windows.libDirs` entry actually resolves the - // `{lib}.lib` lookups instead of being a silent no-op. - for lib_dir in &target_config.lib_dirs { - if is_windows { - cmd.arg(format!("/LIBPATH:{}", lib_dir.display())); - } else { - cmd.arg(format!("-L{}", lib_dir.display())); - } - } - - // Add platform libraries - for lib in &target_config.libs { - if is_windows { - cmd.arg(format!("{}.lib", lib)); - } else { - cmd.arg(format!("-l{}", lib)); - } - } - - // Add pkg-config libraries - for pkg in &target_config.pkg_config { - pkg_config::validate_pkg_config_name(pkg)?; - if let Ok(output) = Command::new("pkg-config").args(["--libs", pkg]).output() { - if output.status.success() { - let libs = String::from_utf8_lossy(&output.stdout); - for flag in libs.trim().split_whitespace() { - cmd.arg(flag); - } - } - } - } - - for backend in &target_config.backends { - if !backend.available { - if let (OutputFormat::Text, Some(reason)) = - (format, backend.unavailable_reason.as_deref()) - { - println!( - "Skipping {} backend for {}: {}", - backend.backend.as_str(), - native_lib.module, - reason - ); - } - } - } - - for backend in select_available_backend_link_metadata(target_config) { - if let Some(prebuilt) = backend.prebuilt.as_ref() { - if !prebuilt.exists() { - return Err(anyhow!( - "Prebuilt {} backend library declared by {} not found at {}. \ - Install the matching optional dependency or update \ - perry.nativeLibrary.targets..backends.{}.prebuilt.", - backend.backend.as_str(), - native_lib.module, - prebuilt.display(), - backend.backend.as_str() - )); - } - cmd.arg(prebuilt); - match format { - OutputFormat::Text => println!( - "Linking prebuilt {} backend library: {}", - backend.backend.as_str(), - prebuilt.display() - ), - OutputFormat::Json => {} - } - } - - for framework in &backend.frameworks { - cmd.arg("-framework").arg(framework); - } - for lib_dir in &backend.lib_dirs { - if is_windows { - cmd.arg(format!("/LIBPATH:{}", lib_dir.display())); - } else { - cmd.arg(format!("-L{}", lib_dir.display())); - } - } - for lib in &backend.libs { - if is_windows { - cmd.arg(format!("{}.lib", lib)); - } else { - cmd.arg(format!("-l{}", lib)); - } - } - for pkg in &backend.pkg_config { - pkg_config::validate_pkg_config_name(pkg)?; - if let Ok(output) = Command::new("pkg-config").args(["--libs", pkg]).output() { - if output.status.success() { - let libs = String::from_utf8_lossy(&output.stdout); - for flag in libs.trim().split_whitespace() { - cmd.arg(flag); - } - } - } - } - } - - // Compile manifest-declared Swift sources to object files and - // append them to the link line. Used by `--features watchos-swift-app` - // so a native lib can ship its own `@main struct App: App`. - if !target_config.swift_sources.is_empty() { - if !is_watchos { - return Err(anyhow!( - "perry.nativeLibrary.targets..swift_sources is only supported on watchos/watchos-simulator" - )); - } - let swift_sdk = if target == Some("watchos-simulator") { - "watchsimulator" - } else { - "watchos" - }; - // arm64_32 watchOS (Series 4-8 / SE): opt-in, matches the app - // binary's triple in platform_cmd.rs so the native @main lib - // links against the same arch. - let swift_arm64_32 = - target == Some("watchos") && std::env::var("PERRY_WATCHOS_ARM64_32").is_ok(); - let swift_watchos_min = - std::env::var("PERRY_WATCHOS_MIN").unwrap_or_else(|_| "11.0".to_string()); - let swift_triple_owned; - let swift_triple = if target == Some("watchos-simulator") { - "arm64-apple-watchos10.0-simulator" - } else if swift_arm64_32 { - swift_triple_owned = format!("arm64_32-apple-watchos{}", swift_watchos_min); - swift_triple_owned.as_str() - } else { - "arm64-apple-watchos26.0" - }; - let swift_sysroot = String::from_utf8( - Command::new("xcrun") - .args(["--sdk", swift_sdk, "--show-sdk-path"]) - .output()? - .stdout, - )? - .trim() - .to_string(); - let swiftc = String::from_utf8( - Command::new("xcrun") - .args(["--sdk", swift_sdk, "--find", "swiftc"]) - .output()? - .stdout, - )? - .trim() - .to_string(); - - let swift_obj_dir = - std::env::temp_dir().join(format!("perry_swift_{}", std::process::id())); - std::fs::create_dir_all(&swift_obj_dir).ok(); - - for swift_src in &target_config.swift_sources { - if !swift_src.exists() { - return Err(anyhow!( - "Swift source not found: {} (declared in {}'s nativeLibrary.swift_sources)", - swift_src.display(), - native_lib.module - )); - } - let canonical = swift_src - .canonicalize() - .unwrap_or_else(|_| swift_src.clone()); - if !seen_swift_sources.insert(canonical) { - continue; - } - let stem = swift_src - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("swift_src"); - let obj_out = swift_obj_dir.join(format!("{}.o", stem)); - let status = Command::new(&swiftc) - .arg("-target") - .arg(swift_triple) - .arg("-sdk") - .arg(&swift_sysroot) - .arg("-parse-as-library") - .arg("-emit-object") - .arg("-O") - .arg("-o") - .arg(&obj_out) - .arg(swift_src) - .status()?; - if !status.success() { - return Err(anyhow!( - "Failed to compile Swift source: {}", - swift_src.display() - )); - } - cmd.arg(&obj_out); - match format { - OutputFormat::Text => { - println!("Linking Swift object: {}", obj_out.display()) - } - OutputFormat::Json => {} - } - } - } - - // Metal sources are compiled + packed into .app/default.metallib - // after the `.app` bundle is created below. Just validate the target - // here so we fail early with a clear message instead of silently - // dropping shaders on non-Apple-bundle targets. - if !target_config.metal_sources.is_empty() - && !matches!( - target, - Some("ios") - | Some("ios-simulator") - | Some("tvos") - | Some("tvos-simulator") - | Some("watchos") - | Some("watchos-simulator") - | Some("visionos") - | Some("visionos-simulator") - ) - { - return Err(anyhow!( - "perry.nativeLibrary.targets..metal_sources is only supported on ios / ios-simulator / tvos / tvos-simulator / watchos / watchos-simulator / visionos / visionos-simulator" - )); - } - } - } - - // macOS privacy APIs (including camera/microphone requests made by - // WKWebView) consult the process Info.plist for usage-description keys. - // Perry's direct desktop output is a Mach-O executable, not a .app bundle, - // so embed a minimal Info.plist section when linking native macOS UI apps. - // Without this, WKWebView media capture can be denied by the platform even - // when WKUIDelegate grants the web-origin permission. - let is_macos_executable = - (target.is_none() && cfg!(target_os = "macos")) || matches!(target, Some("macos")); - let mut embedded_info_plist_path: Option = None; - if ctx.needs_ui && is_macos_executable { - let exe_stem = exe_path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("perry-app"); - let bundle_id = format!( - "dev.perry.{}", - exe_stem - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) - .collect::() - .trim_matches('-') - ); - let info_plist = format!( - r#" - - - - CFBundleIdentifier - {bundle_id} - CFBundleName - {exe_stem} - CFBundleExecutable - {exe_stem} - CFBundlePackageType - APPL - NSCameraUsageDescription - This app uses the camera for WebView video calls. - NSMicrophoneUsageDescription - This app uses the microphone for WebView video calls. - - -"# - ); - let plist_path = std::env::temp_dir().join(format!( - "perry-embedded-info-{}-{}.plist", - std::process::id(), - exe_stem - )); - fs::write(&plist_path, info_plist)?; - embedded_info_plist_path = Some(plist_path.clone()); - if is_cross_macos { - cmd.arg("-sectcreate") - .arg("__TEXT") - .arg("__info_plist") - .arg(&plist_path); - } else { - cmd.arg(format!( - "-Wl,-sectcreate,__TEXT,__info_plist,{}", - plist_path.display() - )); - } - } - - let link_cache_status = prepare_link_cache_status( - &ctx.cache_root, - target, - &cmd, - obj_paths, - obj_fingerprints, - exe_path, - ); - if !link_cache_status.linked { - if let Some(path) = embedded_info_plist_path { - let _ = fs::remove_file(path); - } - return Ok(link_cache_status); - } - - // Windows hosts cap a spawned process's command line (`CreateProcess` ~32 - // KiB); a link with many object files — ≥~450 local modules (e.g. dense - // barrel `export *` re-exports, or the `@earendil-works/pi-ai` module - // graph) — overflows it and fails with `The filename or extension is too - // long. (os error 206)` before the linker even starts. Route the whole - // argument vector through a linker *response file* (`@file`), which - // `link.exe` / `lld-link` (and `clang`/`cc`) read instead of the command - // line, so the invocation no longer scales with module count. Only the - // native-Windows host is affected (other hosts have a multi-MB `ARG_MAX`); - // `PERRY_FORCE_LINK_RESPONSE_FILE=1` forces the path elsewhere for testing. - let mut response_file_to_clean: Option = None; - if cfg!(target_os = "windows") || std::env::var_os("PERRY_FORCE_LINK_RESPONSE_FILE").is_some() { - // MSVC-style quoting for the native-Windows linker (link.exe/lld-link); - // GNU-style for a clang/cc driver (the forced-test path on macOS/Linux). - let msvc_quoting = cfg!(target_os = "windows") && is_windows; - if let Some((rsp_cmd, rsp_path)) = rewrite_link_with_response_file(&cmd, msvc_quoting) { - cmd = rsp_cmd; - response_file_to_clean = Some(rsp_path); - } - } - - let status_result = cmd.status(); - if let Some(path) = response_file_to_clean { - let _ = fs::remove_file(path); - } - if let Some(path) = embedded_info_plist_path { - let _ = fs::remove_file(path); - } - let status = status_result?; - - if !status.success() { - return Err(anyhow!("Linking failed")); - } - - Ok(link_cache_status) -} - /// Quote one linker argument for a response file. `msvc` selects `link.exe` / /// `lld-link` rules (a `"` toggles a quoted run; backslashes are literal Windows /// path separators, so they are NOT escaped — only an embedded `"` is escaped as