From 69f27112e8ce2834db8298d2225ed210c1cb2d4d Mon Sep 17 00:00:00 2001 From: Michael Belousov Date: Mon, 7 Sep 2026 18:30:11 -0400 Subject: [PATCH 1/8] Redo the public API around two calls linkVelopack + addVelopackedAppDir now cover the whole flow. Consumers no longer construct the `vpk pack` command line, pin `vpk` in their own `.config/dotnet-tools.json`, or wire up an MSVC toolchain by hand. - linkVelopack gains `handle_libc` (default on): for *-windows-msvc it resolves a --libc manifest, installs MSVC + the Windows SDK when the host has nothing usable, scopes a command-line --libc to the msvc compiles instead of leaving it on host-side tools, and applies the manifest to every reachable *-windows-msvc compile. - addVelopackedAppDir runs `vpk pack` over a staged directory and returns the InstallDir step. addVelopackStep + outputDir are the escape hatch. - vpk is installed into /.velopack-tools, gated behind `install_vpk` so an un-gated build never reaches NuGet, and pinned by default to the release the bundled runtime comes from. - linkVelopack links advapi32; the Rust core needs SystemFunction036. - trim-velopack-lib deletes every compiler_builtins member, not just the first. arm64 ships 237 of them, which broke aarch64-windows-msvc. - gen_zig_libc_msvc makes the SDK usable on case-sensitive filesystems by symlinking the spellings its headers and linker directives actually use. Verified from a Linux host: x86_64/aarch64 linux-gnu and windows-msvc all package end to end. macOS targets need a macOS host; vpk refuses otherwise. --- .gitignore | 1 + README.md | 364 +++++++++++++++++--------- build.zig | 496 +++++++++++++++++++++++++++--------- tools/gen_zig_libc_msvc.zig | 335 ++++++++++++++++++++++++ tools/trim_velopack_lib.zig | 47 ++-- tools/vpk_setup.zig | 105 ++++++++ 6 files changed, 1090 insertions(+), 258 deletions(-) create mode 100644 tools/vpk_setup.zig diff --git a/.gitignore b/.gitignore index ea72e6e..3547c42 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ recents.json *.code-workspace zig-pkg/ .velopack-msvc/ +.velopack-tools/ tools/msvcup-bin/ tools/msvc-libc*.ini .zig-cache/ diff --git a/README.md b/README.md index 6ec0b10..0db3130 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,18 @@ Shared build glue for using [Velopack](https://velopack.io) from Zig projects. It wraps the Velopack runtime C API and build toolset, and builds dependencies with zig where possible. +Two calls in your `build.zig` cover the whole flow: `linkVelopack` puts the +Velopack runtime into your executable, `addVelopackedAppDir` turns a staged +directory into an installed release bundle. Everything they need — the `vpk` +CLI, MSVC + the Windows SDK for cross-compiled Windows builds, `mksquashfs` for +Linux AppImages — is provisioned by this package. + The Velopack release version is `0.0.1589-ga2c5a97`. The runtime has a dependency on libgcc_s (See issue #TODO). -Velopack's cli has a dependency on .NET 9 so must be installed on the -system to use this package. +Velopack's CLI is a .NET tool, so the [.NET SDK](https://dotnet.microsoft.com/download) +must be installed to package (not to link). ## Add this package to your zig project @@ -19,79 +25,235 @@ system to use this package. **`main` targets Zig 0.15.2** (`build.zig.zon` `minimum_zig_version`). -## What this package provides +## Usage -- **`linkVelopack(b, exe, .{ .target = target })`** — Adds the `Velopack.h` include path, picks the correct prebuilt static library for the resolved target, links Winsock + BCrypt on Windows, and sets `$ORIGIN` / `@loader_path` rpath on Linux / macOS. For `*-windows-msvc` it copies the `.lib` into a writable dir and trims Velopack’s bundled Rust `compiler_builtins-*` members (which would otherwise collide with Zig’s `compiler_rt`). Trimming uses `zig ar` from the Zig toolchain; no separate LLVM install is required. Also automatically attaches a cached `dotnet tool restore` step as a dep of the compile, so a fresh checkout / CI runner has `vpk` ready by the time you run packaging — consumers don't need to wire `addDotnetToolRestoreStep` themselves. +```zig +const std = @import("std"); +const velopack = @import("velopack_zig"); -- **`attachMksquashfsToVpkRun(b, vpk_run, target)`** — One-call helper that wires the bundled `mksquashfs` into a `vpk pack` Run for **Linux targets** (no-op for non-Linux targets, since Velopack only needs squashfs for AppImage packaging). Internally calls `buildMksquashfs` and adds its step as a dep + prepends its bin dir to the Run's PATH. **Cross-packaging a Linux AppImage from a non-Linux host** still requires a suitable `mksquashfs` on `PATH` yourself (CI Linux runner, container, etc.) — the bundled build is host-only. +pub fn build(b: *std.Build) !void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); -- **`buildMksquashfs(b)`** — Lower-level helper exposed for advanced uses. Builds `mksquashfs` with Zig (C compilation + bundled zlib) on **Linux hosts only**; returns `null` on macOS/Windows or when the lazy dep is not yet activated. Returns a step and `bin_dir` (under the consumer install prefix). Most consumers should use `attachMksquashfsToVpkRun` instead. + // Gate the one thing that reaches out to the network on demand. Recommended + // over hardcoding `true`, so a plain `zig build` is never surprising. + const install_vpk = b.option(bool, "install-vpk", "Download the Velopack CLI into .velopack-tools/ when missing") orelse false; -- **`addMsvcupSetupStep(b, install_dir)`** — Runs [msvcup](https://github.com/marler8997/msvcup) (via bundled scripts) to install MSVC + Windows SDK into a writable directory under the **project root** and emits Zig `--libc` manifests (`zig-libc-x64.ini`, `zig-libc-arm64.ini`). - - Pass `null` for `install_dir` to use `/.velopack-msvc`. - - Pass e.g. `"my-msvc"` for `/my-msvc` (and use the same string as `install_dir_name` in `resolveWindowsMsvcLibc`). + const exe = b.addExecutable(.{ + .name = "myapp", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }), + }); + exe.linkLibC(); + + velopack.linkVelopack(b, exe, .{ .target = target, .optimize = optimize }); -- **`resolveWindowsMsvcLibc(b, target, .{ ... })`** — Resolves the libc INI path for `*-windows-msvc`. Same behaviour on Windows and on non-Windows hosts: an explicit `-Dwindows-msvc-libc=...`-style path wins; otherwise an installed `/zig-libc-{x64,arm64}.ini` (from `msvcup-setup`) is returned, and otherwise `null` (let Zig auto-detect on Windows; explicit failure on non-Windows). Options include `explicit_path`, `install_dir_name` (must match `addMsvcupSetupStep`), and `fetch_if_missing` (returns `.needs_setup = true` so you can depend on the msvcup step before compiling). On Windows hosts that already have Visual Studio, simply don't run `msvcup-setup` and Zig's auto-detect stays in effect; running `msvcup-setup` once forces the hermetic toolchain instead. + // Stage exactly what ships. Everything in this directory ends up in the + // bundle, so don't point at a directory of build leftovers. + const staging = b.addWriteFiles(); + _ = staging.addCopyFile(exe.getEmittedBin(), if (target.result.os.tag == .windows) "myapp.exe" else "myapp"); + + const packaged = velopack.addVelopackedAppDir(b, .{ + .name = "myapp", + .version = "1.0.0", + .target = target, + .source_dir = staging.getDirectory(), + .icon = b.path("art/icon.png"), + .install_dir = .prefix, + .install_subdir = "desktop", + .install_vpk = install_vpk, + }); -- **`applyWindowsMsvcLibcRecursive(b, roots, libc)`** — Applies a libc INI to the executable and every `*-windows-msvc` compile reachable from it, so C dependencies (SDL, FreeType, etc.) see the same MSVC / UCRT headers and libs. + b.step("package", "Build the release bundle").dependOn(&packaged.step); +} +``` -- **`addDotnetToolRestoreStep(b)`** — Returns a `Run` that executes `dotnet tool restore` in the consumer's build root. `vpk` is shipped as a .NET tool; projects pinning it via `.config/dotnet-tools.json` need this to run before `dotnet vpk …` works on a fresh checkout / CI runner. **You usually don't need to call this directly** — `linkVelopack` already attaches a cached, idempotent restore step to your compile. Exposed for consumers who want to run `dotnet tool restore` independent of linking Velopack (e.g. a top-level `tool-restore` step). +`zig build package -Dinstall-vpk=true` writes setup executables, the `.nupkg` +and the `RELEASES` feed to `zig-out/desktop/`. + +## `linkVelopack(b, compile, opts)` + +Adds the `Velopack.h` include path, picks the correct prebuilt static library +for the resolved target, links Winsock + BCrypt on Windows, and sets `$ORIGIN` / +`@loader_path` rpath on Linux / macOS. For `*-windows-msvc` it copies the `.lib` +into a writable dir and trims Velopack's bundled Rust `compiler_builtins-*` +members, which would otherwise collide with Zig's `compiler_rt`. Trimming uses +`zig ar` from the Zig toolchain; no separate LLVM install is required. + +| Option | Default | Meaning | +|--------|---------|---------| +| `target` | — | The resolved target you are building for. | +| `optimize` | — | The optimize mode you are building with. | +| `handle_libc` | `true` | Take care of MSVC + the Windows SDK for `*-windows-msvc` (see below). No effect on other targets. | +| `windows_msvc_libc` | `null` | An explicit Zig `--libc` manifest, overriding both the command-line `--libc` and the managed toolchain. | +| `msvc_dir` | `".velopack-msvc"` | Where the managed MSVC tree and its `zig-libc-*.ini` live, relative to your build root. | + +### `handle_libc` + +Zig can cross-compile to `*-windows-msvc` — the ABI Velopack's Windows build +requires — but only with MSVC and Windows SDK headers and libraries on hand. +With `handle_libc` set (the default), velopack-zig resolves a `--libc` manifest +in this order: + +1. `opts.windows_msvc_libc`. +2. The build's own `--libc`. It is then *scoped* to the `*-windows-msvc` + compiles — left as `b.libc_file` it would also be handed to host-side build + tools, which breaks them. +3. `//zig-libc-{x64,arm64}.ini`, if it exists. +4. On a **Windows host**: nothing, leaving Zig's Visual Studio auto-detect in + play. Run `msvcup-setup` once (below) to force the managed toolchain instead. +5. Otherwise: install MSVC + the Windows SDK with + [msvcup](https://github.com/marler8997/msvcup) before compiling. This is a + large one-time download. + +Whatever it lands on is applied to your executable **and every `*-windows-msvc` +compile it links**, so C dependencies (SDL, FreeType, tree-sitter, …) see the +same UCRT and MSVC headers. + +Call `linkVelopack` after wiring your executable's dependencies — it walks the +compile graph as it exists at that point. + +Set `handle_libc = false` to do all of this yourself; `resolveWindowsMsvcLibc`, +`addMsvcupSetupStep` and `applyWindowsMsvcLibcRecursive` stay available for that. ### Windows ABI note -Velopack’s Windows prebuilt is **MSVC**. Use `x86_64-windows-msvc` or `aarch64-windows-msvc` (not `*-windows-gnu`) when linking `linkVelopack` for release builds on Windows. +Velopack's Windows prebuilt is **MSVC**. Use `x86_64-windows-msvc` or +`aarch64-windows-msvc` (not `*-windows-gnu`). + +## `addVelopackedAppDir(b, opts)` + +Runs `vpk pack` over a staged directory and installs the release artifacts, +returning the `*std.Build.Step.InstallDir` so you can hang your own `package` +step off it. `install_dir` and `install_subdir` mean what they do in +`b.addInstallDirectory`. + +| Option | Default | `vpk` flag | +|--------|---------|-----------| +| `name` | — | `--packId` | +| `version` | — | `--packVersion` | +| `source_dir` | — | `--packDir` | +| `target` | — | picks the `[win]`/`[osx]`/`[linux]` selector, the `--mainExe` extension and whether mksquashfs is needed | +| `main_exe` | `name` (+ `.exe` on Windows) | `--mainExe` | +| `title` | `null` | `--packTitle` | +| `authors` | `null` | `--packAuthors` | +| `icon` | `null` | `--icon` | +| `channel` | `null` | `--channel` | +| `delta` | `"None"` | `--delta` | +| `extra_args` | `&.{}` | appended verbatim — signing identities, `--releaseNotes`, anything not modelled here | +| `depends_on` | `&.{}` | steps that must finish before `vpk pack` runs | +| `install_dir` / `install_subdir` | `.prefix` / `""` | where the release artifacts are installed | + +`source_dir` already orders the pack run after whatever produced it. `depends_on` +is for work that touches the staged payload without being its producer — running +`strip` over the staged binary, for instance. + +### Getting the `vpk` CLI + +velopack-zig runs `vpk` out of `/.velopack-tools`, a `dotnet tool +--tool-path` install it manages itself. You do not need a +`.config/dotnet-tools.json`. + +| Option | Default | Meaning | +|--------|---------|---------| +| `install_vpk` | `false` | Allow installing `vpk` when it is missing or the wrong version. When false, that situation is a build error explaining what to run. | +| `vpk_version` | `.bundled` | `.bundled` (the release this package's runtime comes from), `.latest` (newest stable on NuGet), or `.{ .pinned = "1.2.0" }`. | +| `vpk_dir` | `".velopack-tools"` | Where the managed install lives, relative to your build root. | +| `vpk_argv` | `null` | Run something else entirely, e.g. `&.{ "dotnet", "vpk" }` for a repo-local tool manifest. Disables the managed install. | + +Wire `install_vpk` to a build option rather than hardcoding `true`, so a plain +`zig build` never downloads anything: -## Prerequisites +```zig +.install_vpk = b.option(bool, "install-vpk", "Download the Velopack CLI when missing") orelse false, +``` -| Requirement | When | -|-------------|------| -| **Zig 0.15.2** | Always (see `minimum_zig_version`). | -| **[.NET SDK](https://dotnet.microsoft.com/download)** | Whenever you run **`vpk`** for packaging (`dotnet tool` / global `vpk`). Velopack’s CLI is distributed as a .NET tool; **`dotnet` is required for packaging, not for `linkVelopack` alone**. | -| **Xcode / CLT** (codesign, `notarytool`) | macOS **signed** / **notarized** releases only. | -| **Apple Developer Program** | Distribution signing + notarization. | +`vpk` stamps its own updater binaries into the bundle, so the CLI and the linked +runtime should come from the same Velopack release — hence `.bundled` as the +default rather than `.latest`. Add `.velopack-tools/` to your `.gitignore`. + +> **TODO:** when resolving `.latest`, skip releases younger than five days, so a +> broken publish upstream can't take builds down with it. -Install the Velopack CLI following [Velopack’s docs](https://docs.velopack.io/) — typically `dotnet tool install -g vpk` **or** a repo-local tool manifest plus `dotnet tool restore` so `dotnet vpk …` works from your project root. +## Escape hatches -Set `DOTNET_ROLL_FORWARD=Major` when running `vpk` if you use a newer runtime than the tool was built against (common in `build.zig` via `Run.setEnvironmentVariable`). +`linkVelopack` and `addVelopackedAppDir` drive all of these; they stay public +for consumers doing something the options above don't cover. -## Packaging overview (all platforms) +- **`addVelopackStep(b, opts)`** — the `vpk pack` `Run` on its own, taking every + option `addVelopackedAppDir` does except `install_dir` / `install_subdir`. + Pair it with **`outputDir(run)`** for the release directory as a `LazyPath`. + Use it when you want to do something with that directory other than install it. -1. **Build** your app for the intended target (`zig build …` with the right `-Dtarget=…`). -2. **Install** or stage the built executable so `vpk` can see a directory containing the app binary and assets (Velopack expects a “pack dir”; layout depends on your integration). -3. Run **`vpk pack`** (via `dotnet vpk pack …`) with `--packId`, `--packVersion`, `--mainExe`, `--packDir`, `--outputDir`, etc. -4. **Linux AppImage / `mksquashfs`:** on a **Linux** build host, call `attachMksquashfsToVpkRun(b, vpk_run, target)` once on your `vpk` Run — it's a no-op for non-Linux targets and otherwise wires the bundled `mksquashfs` into the Run's PATH. From macOS/Windows hosts targeting Linux, ensure a compatible `mksquashfs` is on `PATH` yourself (for example run packaging on a Linux CI job). -5. **Cross-OS packaging from one machine:** when the **host OS ≠ package OS**, `vpk` needs an OS selector prefix, e.g. `vpk [win] pack …`, `vpk [linux] pack …`, `vpk [osx] pack …`, because it otherwise infers the platform from the host. +- **`addMsvcupSetupStep(b, install_dir)`** — installs MSVC + the Windows SDK + into `/` (`null` → `.velopack-msvc`) and emits + `zig-libc-x64.ini` / `zig-libc-arm64.ini`. Cached per build root and + directory, so exposing it as your own step and letting `linkVelopack` depend on + it yields one install, not two: -`velopack-zig` does **not** invoke `vpk` for you; your `build.zig` (or shell scripts / CI) wires the `Run` step. This keeps signing secrets and product-specific flags in **your** project. + ```zig + const msvcup_step = b.step("msvcup-setup", "MSVC + WinSDK → zig-libc-*.ini"); + msvcup_step.dependOn(&velopack.addMsvcupSetupStep(b, null).step); + ``` -## Windows and Linux packaging + This is also how a **Windows host without Visual Studio** opts into the managed + toolchain: run it once, and `handle_libc` picks the manifest up from then on. -### Windows +- **`resolveWindowsMsvcLibc(b, target, opts)`** — locates the `zig-libc-*.ini` + for a `*-windows-msvc` target without applying it. -- Build with **`x86_64-windows-msvc`** or **`aarch64-windows-msvc`** and link with `linkVelopack`. -- On a **native Windows** dev machine with Visual Studio installed, Zig often auto-detects MSVC; you may not need a hand-written `--libc` INI. -- **Cross-compiling from macOS or Linux** to `*-windows-msvc`: you need MSVC + Windows SDK headers/libs. Run the **`msvcup-setup`** step once (see below), then point Zig at the generated `zig-libc-x64.ini` / `zig-libc-arm64.ini` (via `--libc`, `-Dwindows-msvc-libc=…`, or `applyWindowsMsvcLibcRecursive`). +- **`applyWindowsMsvcLibcRecursive(b, roots, libc)`** — applies a manifest to + every `*-windows-msvc` compile reachable from `roots`. -### Linux +- **`buildMksquashfs(b)`** / **`attachMksquashfsToVpkRun(b, run, target)`** — + build `mksquashfs` with Zig (upstream squashfs-tools + zlib) and put it on a + `vpk` Run's `PATH`. `addVelopackStep` already does this for Linux targets. + **Linux hosts only**: returns `null` elsewhere, and on the first build before + the lazy `squashfs` dependency has been fetched (Zig prints a `--fetch` hint). + Cross-packaging a Linux AppImage from macOS or Windows needs a `mksquashfs` on + `PATH` yourself — a Linux CI job or container is the usual answer. -- Build your binary for `x86_64-linux-gnu` / `aarch64-linux-gnu` as usual; `linkVelopack` links the gnu `.a` and sets rpath. -- For **`vpk pack`** to produce an AppImage, **`mksquashfs` must be available**. Call `attachMksquashfsToVpkRun(b, vpk_run, target)` once on your `vpk` Run; it handles the linux-target check, lazy `mksquashfs` build, and PATH/dep wiring (example below). +- **`addDotnetToolRestoreStep(b)`** — `dotnet tool restore` in your build root, + for projects that pin `vpk` in their own `.config/dotnet-tools.json` and pass + `.vpk_argv = &.{ "dotnet", "vpk" }`. -## MSVC bootstrap: `msvcup-setup` +## Prerequisites + +| Requirement | When | +|-------------|------| +| **Zig 0.15.2** | Always (see `minimum_zig_version`). | +| **[.NET SDK](https://dotnet.microsoft.com/download)** | Packaging. Velopack's CLI is a .NET tool; `dotnet` is **not** needed for `linkVelopack` alone. | +| **Network access** | First `-Dinstall-vpk=true`, and the first `*-windows-msvc` cross-build (MSVC + Windows SDK). Both cache into your build root. | +| **Xcode / CLT** (codesign, `notarytool`) | macOS **signed** / **notarized** releases only. | +| **Apple Developer Program** | Distribution signing + notarization. | -Use this when cross-compiling **to** `*-windows-msvc` **from** a non-Windows host, **or** on a Windows host where you want a hermetic toolchain tree in the repo (instead of relying on a system-wide Visual Studio install). The setup step works the same on Windows, macOS, and Linux — `setup-msvc.ps1` runs on Windows hosts and `setup-msvc.sh` runs everywhere else. +## Cross-OS packaging -1. Expose a step in your `build.zig`: - ```zig - const msvcup = velopack.addMsvcupSetupStep(b, null); // or b, "my-msvc" - const msvcup_step = b.step("msvcup-setup", "Install MSVC + WinSDK; writes zig-libc-*.ini"); - msvcup_step.dependOn(&msvcup.step); - ``` -2. Run: **`zig build msvcup-setup`** (network required; large download). -3. Outputs land under **`.velopack-msvc/`** (or your custom directory): MSVC tree + **`zig-libc-x64.ini`** / **`zig-libc-arm64.ini`**. -4. Wire **`resolveWindowsMsvcLibc`** + **`applyWindowsMsvcLibcRecursive`** (and optional `exe.step.dependOn(&msvcup.step)` when `.needs_setup`) so the main executable and all C dependencies use that INI. +`vpk` infers the target platform from the host, so `addVelopackStep` passes an +explicit `[win]` / `[linux]` / `[osx]` selector whenever the target OS differs +from the host's. Verified from a Linux host: `x86_64-linux-gnu`, +`aarch64-linux-gnu`, `x86_64-windows-msvc` and `aarch64-windows-msvc` all +package end to end. Two gaps are the host's, not this package's: -If you use a custom directory, pass the **same** basename to both `addMsvcupSetupStep(b, "my-msvc")` and `resolveWindowsMsvcLibc(b, target, .{ .install_dir_name = "my-msvc", ... })`. +- **macOS targets can only be packaged on macOS.** `vpk` itself refuses: + *"Cross-compiling from Linux to MacOS is not supported."* Signing and + notarization need Apple tooling anyway. +- **Linux AppImages** need `mksquashfs`. It is built for you on a Linux host; on + macOS or Windows hosts, put one on `PATH`. + +### Case-sensitive filesystems + +The Windows SDK ships `Windows.h` and `kernel32.Lib`, its own headers include +`windows.h` and `DriverSpecs.h`, and linker directives ask for `LIBCMT.lib`. On +Windows and macOS the filesystem papers over that; on Linux nothing resolves. The +`msvcup-setup` step therefore does an `xwin`-style pass over the installed tree, +reading every header and symlinking each spelling it actually references, plus +all-lowercase and uppercase-stem aliases for the libraries. It runs once per +install (marked by `.zig-case-aliases` in the install root) and takes a few +seconds. ## macOS packaging, code signing, and notarization @@ -117,7 +279,22 @@ Your consumer project can map env vars to `vpk` flags **without** committing val | Installer identity | `MYAPP_MACOS_SIGN_INSTALLER` | `--signInstallIdentity` | | Notarytool profile | `MYAPP_MACOS_NOTARY_PROFILE` | `--notaryProfile` | -Read these with `b.graph.environ_map.get("MYAPP_MACOS_SIGN_APP")` (or `std.process.getEnv` in a small helper) and **only** add the corresponding `Run.addArg` pairs when non-null. For local dev, a **gitignored** `.env.local` plus `set -a && . ./.env.local` in your shell or IDE task is a common pattern. +Read these in your `build.zig` (`std.process.getEnvVarOwned`, or a small helper) +and pass the flags through `extra_args` only when they are set: + +```zig +var signing: std.ArrayList([]const u8) = .empty; +if (std.process.getEnvVarOwned(b.allocator, "MYAPP_MACOS_SIGN_APP")) |id| { + try signing.appendSlice(b.allocator, &.{ "--signAppIdentity", id }); +} else |_| {} + +const packaged = velopack.addVelopackedAppDir(b, .{ + // … + .extra_args = signing.items, +}); +``` + +For local dev, a **gitignored** `.env.local` plus `set -a && . ./.env.local` in your shell or IDE task is a common pattern. ### Order of operations @@ -136,77 +313,34 @@ Unsigned `vpk pack` can work for local testing. For release: build **ReleaseFast }, ``` -`build.zig` (minimal linking + `vpk pack` + Linux `mksquashfs`; adjust paths and install dirs to your app): +See [Usage](#usage) above for the `build.zig` side. A real project usually adds +a couple of things to it: ```zig -const std = @import("std"); -const velopack = @import("velopack_zig"); - -pub fn build(b: *std.Build) !void { - const target = b.standardTargetOptions(.{}); - const optimize = b.standardOptimizeOption(.{}); - - const exe = b.addExecutable(.{ - .name = "myapp", - .root_module = b.createModule(.{ - .root_source_file = b.path("src/main.zig"), - .target = target, - .optimize = optimize, - }), - }); - exe.linkLibC(); - - try velopack.linkVelopack(b, exe, .{ .target = target }); - - const vpk = b.addSystemCommand(&.{ "dotnet", "vpk", "pack", - "--packId", "myapp", - "--packVersion", "0.1.0", - "--mainExe", switch (target.result.os.tag) { - .windows => "myapp.exe", - else => "myapp", - }, - "--delta", "None", "--yes", - }); - vpk.addArg("--outputDir"); - const out = vpk.addOutputDirectoryArg(b.getInstallPath(.bin, "desktop")); - vpk.addArg("--packDir"); - vpk.addDirectoryArg(exe.getEmittedBin().dirname()); - vpk.setEnvironmentVariable("DOTNET_ROLL_FORWARD", "Major"); - - try velopack.attachMksquashfsToVpkRun(b, vpk, target); - - vpk.step.dependOn(&exe.step); - - const install_pkg = b.addInstallDirectory(.{ - .source_dir = out, - .install_dir = .bin, - .install_subdir = "", +// Package for every shipping triple in one command. +const packageall = b.step("packageall", "Package every release triple"); +for ([_][]const u8{ + "x86_64-linux-gnu", "aarch64-linux-gnu", + "x86_64-macos", "aarch64-macos", + "x86_64-windows-msvc", "aarch64-windows-msvc", +}) |triple| { + const run = b.addSystemCommand(&.{ + b.graph.zig_exe, "build", "package", + b.fmt("-Dtarget={s}", .{triple}), + b.fmt("-Doptimize={s}", .{@tagName(optimize)}), }); - install_pkg.step.dependOn(&vpk.step); - - const desktop = b.step("desktop", "Build + pack desktop bundle"); - desktop.dependOn(&install_pkg.step); + if (install_vpk) run.addArg("-Dinstall-vpk=true"); + run.setCwd(b.path(".")); + packageall.dependOn(&run.step); } -``` -**`*-windows-msvc` cross-build** (optional flags + setup dep): declare a `-Dfetch-msvc`-style option and wire libc like this: - -```zig -const fetch_msvc = b.option(bool, "fetch-msvc", "Run msvcup before compile when libc ini missing") orelse false; -const resolved = velopack.resolveWindowsMsvcLibc(b, target, .{ - .fetch_if_missing = fetch_msvc, - .install_dir_name = ".velopack-msvc", // default; omit if unchanged +// Strip the staged binary before it is packed. `strip` can't read foreign +// object files, so skip it when packaging across operating systems. +const strip = b.addSystemCommand(&.{ + if (target.result.os.tag == b.graph.host.result.os.tag) "strip" else "touch", }); -if (resolved.libc_path) |ini| { - const libc_lp: std.Build.LazyPath = .{ .cwd_relative = ini }; - velopack.applyWindowsMsvcLibcRecursive(b, &.{exe}, libc_lp); - if (resolved.needs_setup) { - exe.step.dependOn(&velopack.addMsvcupSetupStep(b, null).step); - } -} - -const msvcup_step = b.step("msvcup-setup", "MSVC + WinSDK → zig-libc-*.ini"); -msvcup_step.dependOn(&velopack.addMsvcupSetupStep(b, null).step); +strip.addFileArg(staged_exe); +// … then pass `.depends_on = &.{&strip.step}` to addVelopackedAppDir. ``` ## License diff --git a/build.zig b/build.zig index 1e31d53..97fe8ab 100644 --- a/build.zig +++ b/build.zig @@ -1,34 +1,39 @@ -//! velopack-zig — shared Velopack packaging glue for Zig projects. +//! velopack-zig — Velopack packaging glue for Zig projects. //! -//! Public API (call from a consumer's build.zig): +//! Two calls cover the whole flow from a consumer's build.zig: //! //! const velopack = @import("velopack_zig"); //! -//! try velopack.linkVelopack(b, exe, .{ .target = target }); +//! // 1. Link the Velopack runtime into your app. On *-windows-msvc this +//! // also installs and wires up MSVC + the Windows SDK when needed. +//! velopack.linkVelopack(b, exe, .{ .target = target, .optimize = optimize }); //! -//! // Linux AppImage build: returns a step + bin dir for vpk's PATH. -//! const sq = try velopack.buildMksquashfs(b); -//! vpk_run.step.dependOn(sq.step); -//! vpk_run.addPathDir(sq.bin_dir); +//! // 2. Run `vpk pack` over a staged directory and install its output. +//! const packaged = velopack.addVelopackedAppDir(b, .{ +//! .name = "myapp", +//! .version = "1.0.0", +//! .target = target, +//! .source_dir = staging.getDirectory(), +//! .install_dir = .prefix, +//! .install_subdir = "desktop", +//! .install_vpk = b.option(bool, "install-vpk", "Download the vpk CLI") orelse false, +//! }); +//! b.step("package", "Build the release bundle").dependOn(&packaged.step); //! -//! // *-windows-msvc cross-compile from non-Windows hosts. -//! const msvcup = velopack.addMsvcupSetupStep(b, null); -//! const msvcup_step = b.step("msvcup-setup", "..."); -//! msvcup_step.dependOn(&msvcup.step); -//! -//! The library bundles the Velopack release zip and a Zig build of -//! mksquashfs (upstream squashfs-tools + zlib) as its own zon dependencies, -//! so consumer projects do not need to declare them themselves. -//! -//! **Linux host only** for `buildMksquashfs`: mksquashfs is built with the Zig -//! toolchain (no `make`). On other hosts this returns `null`; put a host -//! `mksquashfs` on `PATH` yourself if you run `vpk pack` for Linux from there. +//! The package bundles the Velopack release zip, the `vpk` CLI provisioning and +//! a Zig build of mksquashfs (upstream squashfs-tools + zlib), so consumers do +//! not declare any of those themselves. //! //! Targets **Zig 0.15.2** on `main`. const std = @import("std"); const builtin = @import("builtin"); +/// Velopack release whose prebuilt runtime archives this package bundles. +/// Keep in sync with the `velopack` dependency URL in build.zig.zon — the +/// `vpk` CLI and the linked runtime should come from the same release. +pub const velopack_version = "0.0.1589-ga2c5a97"; + /// No-op `pub fn build` — velopack-zig produces no install artifacts of its /// own. The exposed helpers below are invoked from the consumer's build.zig. pub fn build(b: *std.Build) void { @@ -44,30 +49,37 @@ fn ownBuilder(b: *std.Build) *std.Build { return b.dependencyFromBuildZig(@This(), .{}).builder; } -// Per-build cache of the trim-velopack-lib host tool. +// Per-build cache of the host tools we compile out of tools/. var trim_velopack_tool: ?*std.Build.Step.Compile = null; +var vpk_setup_tool: ?*std.Build.Step.Compile = null; -// Per-consumer-builder cache of the `dotnet tool restore` Run, so multiple -// linkVelopack/addVpkPack callsites share a single restore step. -var dotnet_restore_cache: std.AutoHashMapUnmanaged(*std.Build, *std.Build.Step.Run) = .{}; +// Steps that must not be duplicated when several call sites ask for them. +// Keyed by consumer builder plus whatever else makes them distinct. +var step_cache: std.StringHashMapUnmanaged(*std.Build.Step.Run) = .{}; -fn cachedDotnetToolRestore(b: *std.Build) *std.Build.Step.Run { - const gop = dotnet_restore_cache.getOrPut(b.allocator, b) catch @panic("OOM"); - if (!gop.found_existing) gop.value_ptr.* = addDotnetToolRestoreStep(b); +fn cachedRun( + b: *std.Build, + key: []const u8, + context: anytype, + create: fn (*std.Build, @TypeOf(context)) *std.Build.Step.Run, +) *std.Build.Step.Run { + const full_key = b.fmt("{x}:{s}", .{ @intFromPtr(b), key }); + const gop = step_cache.getOrPut(b.allocator, full_key) catch @panic("OOM"); + if (!gop.found_existing) gop.value_ptr.* = create(b, context); return gop.value_ptr.*; } -fn trimVelopackLibTool(own: *std.Build) *std.Build.Step.Compile { - if (trim_velopack_tool) |t| return t; +fn hostTool(own: *std.Build, cache: *?*std.Build.Step.Compile, name: []const u8, src: []const u8) *std.Build.Step.Compile { + if (cache.*) |t| return t; const t = own.addExecutable(.{ - .name = "trim-velopack-lib", + .name = name, .root_module = own.createModule(.{ - .root_source_file = own.path("tools/trim_velopack_lib.zig"), + .root_source_file = own.path(src), .target = own.graph.host, .optimize = .Debug, }), }); - trim_velopack_tool = t; + cache.* = t; return t; } @@ -84,13 +96,29 @@ pub const LinkVelopackOptions = struct { /// Target the consumer is building for. target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, + /// Take care of MSVC + the Windows SDK for `*-windows-msvc` targets: + /// resolve a Zig `--libc` manifest, install the toolchain when there isn't + /// one, and apply the manifest to `compile` and every `*-windows-msvc` + /// compile it links (SDL, FreeType, … need the same UCRT headers). + /// + /// No effect on other targets. Set to false to do all of that yourself + /// with `resolveWindowsMsvcLibc` / `addMsvcupSetupStep` / + /// `applyWindowsMsvcLibcRecursive`. + handle_libc: bool = true, + /// Zig `--libc` manifest to use for `*-windows-msvc`, overriding both the + /// command line `--libc` and the managed toolchain. Only read when + /// `handle_libc` is set. + windows_msvc_libc: ?[]const u8 = null, + /// Directory, relative to the consumer's build root, holding the managed + /// MSVC + Windows SDK tree and its generated `zig-libc-*.ini`. + msvc_dir: []const u8 = ".velopack-msvc", }; pub fn linkVelopack( b: *std.Build, compile: *std.Build.Step.Compile, opts: LinkVelopackOptions, -) !void { +) void { const own = ownBuilder(b); const velopack_dep = own.dependency("velopack", .{}); const target = opts.target; @@ -123,7 +151,7 @@ pub fn linkVelopack( // trim it in place using `zig ar` (Zig's bundled LLVM archiver). const wf = b.addWriteFiles(); const lib_copy = wf.addCopyFile(lib_src, lib_name); - const trim = b.addRunArtifact(trimVelopackLibTool(own)); + const trim = b.addRunArtifact(hostTool(own, &trim_velopack_tool, "trim-velopack-lib", "tools/trim_velopack_lib.zig")); trim.addArg(b.graph.zig_exe); trim.addFileArg(lib_copy); trim.step.dependOn(&wf.step); @@ -134,9 +162,11 @@ pub fn linkVelopack( } if (target.result.os.tag == .windows) { - // Velopack's Rust core (ureq, getrandom, TLS) needs Winsock + BCrypt. + // Velopack's Rust core (ureq, getrandom, TLS) needs Winsock, BCrypt and + // advapi32's SystemFunction036 (RtlGenRandom). compile.root_module.linkSystemLibrary("ws2_32", .{}); compile.root_module.linkSystemLibrary("bcrypt", .{}); + compile.root_module.linkSystemLibrary("advapi32", .{}); } switch (target.result.os.tag) { .linux => { @@ -150,11 +180,267 @@ pub fn linkVelopack( else => {}, } - // Velopack's `vpk` is a .NET tool pinned via .config/dotnet-tools.json; - // packaging fails on a fresh checkout / CI runner without `dotnet tool - // restore`. Attach a cached, idempotent restore Run to the compile so - // consumers don't have to wire it themselves. Cheap when already restored. - compile.step.dependOn(&cachedDotnetToolRestore(b).step); + if (opts.handle_libc) handleWindowsMsvcLibc(b, compile, opts); +} + +/// Resolve a `--libc` manifest for `*-windows-msvc`, installing MSVC + the +/// Windows SDK when the host has nothing usable, and apply it to every +/// `*-windows-msvc` compile reachable from `compile`. +/// +/// Resolution order: +/// 1. `opts.windows_msvc_libc` +/// 2. the build's own `--libc` (which we then scope to the msvc compiles +/// instead of leaving it on every host-side tool compile too) +/// 3. `//zig-libc-{x64,arm64}.ini` if it exists +/// 4. nothing on a Windows host — Zig auto-detects an installed Visual Studio +/// 5. otherwise install the toolchain first (`addMsvcupSetupStep`) +fn handleWindowsMsvcLibc(b: *std.Build, compile: *std.Build.Step.Compile, opts: LinkVelopackOptions) void { + const target = opts.target; + if (target.result.os.tag != .windows or target.result.abi != .msvc) return; + + var needs_setup = false; + const ini: []const u8 = blk: { + if (opts.windows_msvc_libc orelse b.libc_file) |p| { + break :blk if (std.fs.path.isAbsolute(p)) b.dupePath(p) else b.pathFromRoot(p); + } + const rel = msvcLibcIniPath(b, target, opts.msvc_dir) orelse return; + if (fileExists(b, rel)) break :blk b.pathFromRoot(rel); + // A Windows host with Visual Studio installed already has everything + // Zig needs; don't download a second toolchain behind the user's back. + if (b.graph.host.result.os.tag == .windows) return; + needs_setup = true; + break :blk b.pathFromRoot(rel); + }; + + if (needs_setup) compile.step.dependOn(&addMsvcupSetupStep(b, opts.msvc_dir).step); + + // `b.libc_file` is inherited by *every* compile, host-side build tools + // included, which breaks them. Scope the manifest to the msvc compiles. + b.libc_file = null; + applyWindowsMsvcLibcRecursive(b, &.{compile}, .{ .cwd_relative = ini }); +} + +fn msvcLibcIniPath(b: *std.Build, target: std.Build.ResolvedTarget, msvc_dir: []const u8) ?[]const u8 { + const arch_suffix: []const u8 = switch (target.result.cpu.arch) { + .x86_64 => "x64", + .aarch64 => "arm64", + else => return null, + }; + return b.fmt("{s}/zig-libc-{s}.ini", .{ msvc_dir, arch_suffix }); +} + +fn fileExists(b: *std.Build, rel: []const u8) bool { + b.build_root.handle.access(rel, .{}) catch return false; + return true; +} + +// --------------------------------------------------------------------------- +// addVelopackStep / addVelopackedAppDir — run `vpk pack` over a staged app +// directory. velopack-zig owns the whole invocation: provisioning the `vpk` +// CLI, the `[win]`/`[osx]`/`[linux]` selector for cross-OS packaging, and +// mksquashfs for Linux AppImages. +// --------------------------------------------------------------------------- + +/// Which `vpk` release to install. The CLI stamps its own Update binaries into +/// the bundle, so it should match the runtime `linkVelopack` links. +pub const VpkVersion = union(enum) { + /// The release this package bundles (`velopack_version`). Default. + bundled, + /// Newest stable release on NuGet, resolved at install time. + latest, + /// An explicit NuGet version, e.g. `"1.2.0"`. + pinned: []const u8, + + fn string(self: VpkVersion) ?[]const u8 { + return switch (self) { + .bundled => velopack_version, + .latest => null, + .pinned => |v| v, + }; + } +}; + +pub const VelopackOptions = struct { + /// `--packId`: the app's unique Velopack id. + name: []const u8, + /// `--packVersion`: the release version, e.g. `"1.0.0"`. + version: []const u8, + /// `--packDir`: directory whose contents become the app payload. Everything + /// in it is shipped, so stage it (`b.addWriteFiles()`) rather than pointing + /// at a directory of build leftovers. + source_dir: std.Build.LazyPath, + /// Target the payload was built for. Decides the `vpk` platform selector + /// when packaging across operating systems, the default `--mainExe` + /// extension, and whether mksquashfs is needed. + target: std.Build.ResolvedTarget, + /// `--mainExe`: the executable inside `source_dir` that Velopack launches. + /// Defaults to `name`, plus `.exe` on Windows. + main_exe: ?[]const u8 = null, + /// `--packTitle`: human readable app name. + title: ?[]const u8 = null, + /// `--packAuthors`. + authors: ?[]const u8 = null, + /// `--icon`. + icon: ?std.Build.LazyPath = null, + /// `--channel`. + channel: ?[]const u8 = null, + /// `--delta`: `"None"`, `"BestSpeed"` or `"BestSize"`. + delta: []const u8 = "None", + /// Appended verbatim after every generated argument — signing identities, + /// notarization profiles, `--releaseNotes`, and anything else this wrapper + /// doesn't model. + extra_args: []const []const u8 = &.{}, + /// Steps that must finish before `vpk pack` runs. `source_dir` already + /// pulls in whatever produced it; this is for work that touches the staged + /// payload without being its producer, such as stripping the binary. + depends_on: []const *std.Build.Step = &.{}, + + /// Gate on downloading the `vpk` CLI. Wire it to a build option (see the + /// README) so a plain `zig build` never reaches out to NuGet; when it is + /// false and `vpk_dir` has no matching `vpk`, packaging fails with + /// instructions instead. + install_vpk: bool = false, + /// Which `vpk` release the managed install should provide. + vpk_version: VpkVersion = .bundled, + /// Directory, relative to the consumer's build root, holding the managed + /// `vpk` install. + vpk_dir: []const u8 = ".velopack-tools", + /// Run this instead of the managed `vpk`, e.g. `&.{ "dotnet", "vpk" }` for + /// a repo-local `.config/dotnet-tools.json` (pair it with + /// `addDotnetToolRestoreStep`). Disables `install_vpk` entirely. + vpk_argv: ?[]const []const u8 = null, +}; + +pub const VelopackedAppDirOptions = struct { + name: []const u8, + version: []const u8, + source_dir: std.Build.LazyPath, + target: std.Build.ResolvedTarget, + main_exe: ?[]const u8 = null, + title: ?[]const u8 = null, + authors: ?[]const u8 = null, + icon: ?std.Build.LazyPath = null, + channel: ?[]const u8 = null, + delta: []const u8 = "None", + extra_args: []const []const u8 = &.{}, + depends_on: []const *std.Build.Step = &.{}, + install_vpk: bool = false, + vpk_version: VpkVersion = .bundled, + vpk_dir: []const u8 = ".velopack-tools", + vpk_argv: ?[]const []const u8 = null, + + /// Where the release artifacts land, as in `b.addInstallDirectory`. + install_dir: std.Build.InstallDir = .prefix, + install_subdir: []const u8 = "", +}; + +/// Package `source_dir` with `vpk pack` and install the resulting release +/// directory (setup executables, `.nupkg`, `RELEASES` feed, …) under +/// `install_dir` / `install_subdir`, like `b.addInstallDirectory`. +/// +/// This is the call most consumers want; make it a dependency of your own +/// `package` step. +pub fn addVelopackedAppDir( + b: *std.Build, + opts: VelopackedAppDirOptions, +) *std.Build.Step.InstallDir { + var pack_opts: VelopackOptions = undefined; + inline for (@typeInfo(VelopackOptions).@"struct".fields) |field| { + @field(pack_opts, field.name) = @field(opts, field.name); + } + + const run = addVelopackStep(b, pack_opts); + const install = b.addInstallDirectory(.{ + .source_dir = outputDir(run), + .install_dir = opts.install_dir, + .install_subdir = opts.install_subdir, + }); + // Default would be "install generated/", which says nothing in a summary. + install.step.name = b.fmt("install {s} velopack release", .{opts.name}); + return install; +} + +/// Lower-level `vpk pack` Run, for consumers who need to do something with the +/// release directory other than install it. Pair it with `outputDir`. +/// +/// Prefer `addVelopackedAppDir`: it is this plus the install step, and one call +/// less to keep in sync with the options above. +pub fn addVelopackStep(b: *std.Build, opts: VelopackOptions) *std.Build.Step.Run { + const target = opts.target; + const windows = target.result.os.tag == .windows; + + const run = if (opts.vpk_argv) |argv| b.addSystemCommand(argv) else blk: { + const setup = cachedVpkSetup(b, opts); + const r = b.addSystemCommand(&.{vpkExePath(b, opts.vpk_dir)}); + r.step.dependOn(&setup.step); + break :blk r; + }; + + // `vpk` infers the platform from the host, so packaging for another OS + // needs an explicit selector. + if (target.result.os.tag != b.graph.host.result.os.tag) { + run.addArg(switch (target.result.os.tag) { + .windows => "[win]", + .linux => "[linux]", + .macos => "[osx]", + else => @panic("velopack-zig: unsupported package OS"), + }); + } + + run.addArgs(&.{ "pack", "--packId", opts.name, "--packVersion", opts.version }); + run.addArgs(&.{ "--mainExe", opts.main_exe orelse if (windows) b.fmt("{s}.exe", .{opts.name}) else opts.name }); + run.addArgs(&.{ "--delta", opts.delta, "--yes" }); + if (opts.title) |t| run.addArgs(&.{ "--packTitle", t }); + if (opts.authors) |a| run.addArgs(&.{ "--packAuthors", a }); + if (opts.channel) |c| run.addArgs(&.{ "--channel", c }); + if (opts.icon) |icon| { + run.addArg("--icon"); + run.addFileArg(icon); + } + run.addArg("--packDir"); + run.addDirectoryArg(opts.source_dir); + run.addArg("--outputDir"); + _ = run.addOutputDirectoryArg(b.fmt("{s}-velopack", .{opts.name})); + run.addArgs(opts.extra_args); + for (opts.depends_on) |dep| run.step.dependOn(dep); + + // vpk targets an older .NET runtime than what is typically installed. + run.setEnvironmentVariable("DOTNET_ROLL_FORWARD", "Major"); + + attachMksquashfsToVpkRun(b, run, target); + return run; +} + +/// The `--outputDir` a Run from `addVelopackStep` writes its release artifacts +/// to. +pub fn outputDir(run: *std.Build.Step.Run) std.Build.LazyPath { + for (run.argv.items) |arg| switch (arg) { + .output_directory => |out| return .{ .generated = .{ .file = &out.generated_file } }, + else => {}, + }; + @panic("velopack-zig: Run has no output directory; was it made by addVelopackStep?"); +} + +fn vpkExePath(b: *std.Build, vpk_dir: []const u8) []const u8 { + const exe = if (b.graph.host.result.os.tag == .windows) "vpk.exe" else "vpk"; + return b.pathFromRoot(b.pathJoin(&.{ vpk_dir, exe })); +} + +fn cachedVpkSetup(b: *std.Build, opts: VelopackOptions) *std.Build.Step.Run { + const Ctx = struct { dir: []const u8, version: VpkVersion, install: bool }; + const key = b.fmt("vpk:{s}:{s}:{}", .{ opts.vpk_dir, opts.vpk_version.string() orelse "latest", opts.install_vpk }); + return cachedRun(b, key, Ctx{ .dir = opts.vpk_dir, .version = opts.vpk_version, .install = opts.install_vpk }, struct { + fn create(bb: *std.Build, ctx: Ctx) *std.Build.Step.Run { + const own = ownBuilder(bb); + const r = bb.addRunArtifact(hostTool(own, &vpk_setup_tool, "vpk-setup", "tools/vpk_setup.zig")); + r.addArg(bb.pathFromRoot(ctx.dir)); + r.addArg(if (ctx.install) "1" else "0"); + // TODO: when resolving `.latest`, refuse releases younger than five + // days so a broken vpk publish can't take builds down with it. + r.addArg(ctx.version.string() orelse ""); + return r; + } + }.create); } // --------------------------------------------------------------------------- @@ -169,9 +455,6 @@ pub fn linkVelopack( // 2. The bundled squashfs-tools source is a **lazy dependency** that hasn't // been fetched yet. Zig's build runner prints a "run with --fetch" // hint on the first build; subsequent builds resolve normally. -// -// Wire the returned step as a dependency of your `vpk pack` Run, and pass -// `bin_dir` to `Run.addPathDir(...)` so vpk picks up the bundled mksquashfs. // --------------------------------------------------------------------------- pub const MksquashfsBuild = struct { @@ -188,7 +471,9 @@ pub const MksquashfsBuild = struct { /// build runner will then prompt the user to re-run with `--fetch`). Either /// way, callers can simply skip wiring `bin_dir` into the vpk Run and rely on /// a host `mksquashfs` if one is on `PATH`. -pub fn buildMksquashfs(b: *std.Build) !?MksquashfsBuild { +/// +/// `addVelopackStep` already calls this; consumers rarely need it directly. +pub fn buildMksquashfs(b: *std.Build) ?MksquashfsBuild { const own = ownBuilder(b); if (own.graph.host.result.os.tag == .windows) return null; @@ -215,39 +500,43 @@ pub fn buildMksquashfs(b: *std.Build) !?MksquashfsBuild { /// AppImage. On Linux targets this builds mksquashfs (lazily — first build /// prompts the user to `--fetch`) and prepends its bin dir to the Run's PATH. /// -/// Consumers should call this on every vpk Run; velopack-zig handles the -/// target-os check internally so the consumer doesn't have to. +/// `addVelopackStep` already calls this; consumers rarely need it directly. pub fn attachMksquashfsToVpkRun( b: *std.Build, run: *std.Build.Step.Run, target: std.Build.ResolvedTarget, -) !void { +) void { if (target.result.os.tag != .linux) return; - const built = (try buildMksquashfs(b)) orelse return; + const built = buildMksquashfs(b) orelse return; run.addPathDir(built.bin_dir); run.step.dependOn(built.step); } // --------------------------------------------------------------------------- -// addMsvcupSetupStep — install MSVC + Windows SDK into a writable directory -// and emit zig-libc-{x64,arm64}.ini, all from the bundled tools/setup-msvc.sh -// (or .ps1 on Windows hosts). +// Escape hatches. `linkVelopack(.handle_libc = true)` and `addVelopackStep` +// drive all of these; they stay public for consumers doing something the +// options above don't cover. // --------------------------------------------------------------------------- -/// Returns the *Run for the platform-appropriate setup script. The consumer -/// can wire it as a dependency of `b.step("msvcup-setup", ...)` or as a -/// pre-step of compiles for *-windows-msvc cross-builds. +/// Install MSVC + the Windows SDK into `/` (or +/// `.velopack-msvc` when null) via [msvcup](https://github.com/marler8997/msvcup) +/// and emit Zig `--libc` manifests (`zig-libc-x64.ini`, `zig-libc-arm64.ini`). /// -/// `install_dir` is the absolute or build-root-relative directory where the -/// MSVC tree + zig-libc-*.ini will be written. Pass null to use -/// `/.velopack-msvc`. +/// Cached per build root and directory, so exposing it as your own +/// `msvcup-setup` step and letting `linkVelopack` depend on it yields one +/// install, not two. pub fn addMsvcupSetupStep(b: *std.Build, install_dir: ?[]const u8) *std.Build.Step.Run { + const dir = install_dir orelse ".velopack-msvc"; + return cachedRun(b, b.fmt("msvcup:{s}", .{dir}), dir, createMsvcupSetupStep); +} + +fn createMsvcupSetupStep(b: *std.Build, install_dir: []const u8) *std.Build.Step.Run { const own = ownBuilder(b); - const resolved_install_dir: []const u8 = if (install_dir) |p| - if (std.fs.path.isAbsolute(p)) b.dupePath(p) else b.pathFromRoot(p) + const resolved_install_dir: []const u8 = if (std.fs.path.isAbsolute(install_dir)) + b.dupePath(install_dir) else - b.pathFromRoot(".velopack-msvc"); + b.pathFromRoot(install_dir); const env_path = own.path("tools/msvcup.env").getPath3(b, null).toString(b.allocator) catch |e| std.debug.panic("velopack-zig: resolve msvcup.env: {}", .{e}); @@ -258,17 +547,15 @@ pub fn addMsvcupSetupStep(b: *std.Build, install_dir: ?[]const u8) *std.Build.St .windows => blk: { const script_path = own.path("tools/setup-msvc.ps1").getPath3(b, null).toString(b.allocator) catch |e| std.debug.panic("velopack-zig: resolve setup-msvc.ps1: {}", .{e}); - const r = b.addSystemCommand(&.{ + break :blk b.addSystemCommand(&.{ "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script_path, resolved_install_dir, }); - break :blk r; }, else => blk: { const script_path = own.path("tools/setup-msvc.sh").getPath3(b, null).toString(b.allocator) catch |e| std.debug.panic("velopack-zig: resolve setup-msvc.sh: {}", .{e}); - const r = b.addSystemCommand(&.{ "bash", script_path, resolved_install_dir }); - break :blk r; + break :blk b.addSystemCommand(&.{ "bash", script_path, resolved_install_dir }); }, }; run.setEnvironmentVariable("VELOPACK_ZIG_ENV_FILE", env_path); @@ -277,22 +564,6 @@ pub fn addMsvcupSetupStep(b: *std.Build, install_dir: ?[]const u8) *std.Build.St return run; } -// --------------------------------------------------------------------------- -// resolveWindowsMsvcLibc — locate the right zig-libc-*.ini for *-windows-msvc. -// Resolution order (host-agnostic — same behaviour on Windows and on -// non-Windows hosts that cross-compile to *-windows-msvc): -// 1. explicit_path (e.g. -Dwindows-msvc-libc=) -// 2. //zig-libc-{x64,arm64}.ini if it exists -// 3. when fetch_if_missing=true and the file is absent: returns the would-be -// path with .needs_setup = true; caller should attach msvcup-setup as a -// dep of compile. -// Returns null when nothing is configured. On native Windows hosts that lets -// Zig fall back to auto-detecting an installed Visual Studio (preferred when -// the developer already has it). To force the msvcup-installed SDK on a -// Windows host instead, run `msvcup-setup` once or pass -Dfetch-msvc — the -// existence of /zig-libc-*.ini wins over auto-detect. -// --------------------------------------------------------------------------- - pub const ResolveWindowsMsvcLibcOptions = struct { explicit_path: ?[]const u8 = null, install_dir_name: []const u8 = ".velopack-msvc", @@ -304,6 +575,11 @@ pub const ResolvedWindowsMsvcLibc = struct { needs_setup: bool, }; +/// Locate the `zig-libc-*.ini` for a `*-windows-msvc` target without applying +/// it: an explicit path wins, then an installed `` tree, then +/// (when `fetch_if_missing`) the path `addMsvcupSetupStep` would write, flagged +/// `needs_setup`. Null means "nothing configured" — on a Windows host that +/// leaves Zig's Visual Studio auto-detect in play. pub fn resolveWindowsMsvcLibc( b: *std.Build, target: std.Build.ResolvedTarget, @@ -317,55 +593,18 @@ pub fn resolveWindowsMsvcLibc( return .{ .libc_path = abs, .needs_setup = false }; } - const arch_suffix: []const u8 = switch (target.result.cpu.arch) { - .x86_64 => "x64", - .aarch64 => "arm64", - else => return .{ .libc_path = null, .needs_setup = false }, - }; - const rel = b.fmt("{s}/zig-libc-{s}.ini", .{ opts.install_dir_name, arch_suffix }); - - const exists = blk: { - b.build_root.handle.access(rel, .{}) catch break :blk false; - break :blk true; - }; + const rel = msvcLibcIniPath(b, target, opts.install_dir_name) orelse + return .{ .libc_path = null, .needs_setup = false }; - // Same behaviour on Windows and non-Windows hosts: an installed - // / tree (or -Dfetch-msvc) wins; otherwise return null - // and let the caller decide (Zig auto-detect on Windows; explicit failure - // on non-Windows hosts that can't auto-detect). - if (exists) { + if (fileExists(b, rel)) return .{ .libc_path = b.pathFromRoot(rel), .needs_setup = false }; - } - if (opts.fetch_if_missing) { + if (opts.fetch_if_missing) return .{ .libc_path = b.pathFromRoot(rel), .needs_setup = true }; - } return .{ .libc_path = null, .needs_setup = false }; } -// --------------------------------------------------------------------------- -// addDotnetToolRestoreStep — `dotnet tool restore` in the consumer build root. -// Velopack's `vpk` is shipped as a .NET tool; projects that pin it via a -// repo-local `.config/dotnet-tools.json` need to run `dotnet tool restore` -// before `dotnet vpk pack ...` will work. Wire this Run as a dependency of -// your vpk Run (or of the first iteration of a packageall sequence) so a -// fresh checkout / CI runner doesn't fail with "tool not found". -// --------------------------------------------------------------------------- - -/// Returns a Run that executes `dotnet tool restore` in the consumer's build -/// root. Idempotent and cheap when the tools are already restored. -pub fn addDotnetToolRestoreStep(b: *std.Build) *std.Build.Step.Run { - const r = b.addSystemCommand(&.{ "dotnet", "tool", "restore" }); - r.setCwd(b.path(".")); - return r; -} - -// --------------------------------------------------------------------------- -// applyWindowsMsvcLibcRecursive — apply a zig-libc INI to every reachable -// *-windows-msvc compile (exe + every static lib it links). Without this, -// dependencies such as SDL/FreeType/tree-sitter don't see the same UCRT/MSVC -// headers as the exe and fail to compile their C sources. -// --------------------------------------------------------------------------- - +/// Apply a `--libc` manifest to every `*-windows-msvc` compile reachable from +/// `roots`, so C dependencies see the same UCRT/MSVC headers as the executable. pub fn applyWindowsMsvcLibcRecursive( b: *std.Build, roots: []const *std.Build.Step.Compile, @@ -385,3 +624,16 @@ pub fn applyWindowsMsvcLibcRecursive( } } } + +/// `dotnet tool restore` in the consumer's build root, for projects that pin +/// `vpk` in their own `.config/dotnet-tools.json` and pass +/// `.vpk_argv = &.{ "dotnet", "vpk" }` instead of using the managed install. +pub fn addDotnetToolRestoreStep(b: *std.Build) *std.Build.Step.Run { + return cachedRun(b, "dotnet-tool-restore", {}, struct { + fn create(bb: *std.Build, _: void) *std.Build.Step.Run { + const r = bb.addSystemCommand(&.{ "dotnet", "tool", "restore" }); + r.setCwd(bb.path(".")); + return r; + } + }.create); +} diff --git a/tools/gen_zig_libc_msvc.zig b/tools/gen_zig_libc_msvc.zig index febae2e..9c58010 100644 --- a/tools/gen_zig_libc_msvc.zig +++ b/tools/gen_zig_libc_msvc.zig @@ -2,10 +2,24 @@ //! SDK tree. Picks the highest-versioned subdirectory under each well-known //! folder so SDK / MSVC bumps don't require re-pinning. //! +//! On case-sensitive filesystems it also makes the tree usable at all: the +//! Windows SDK ships `Windows.h` and `kernel32.Lib` but its own headers include +//! `windows.h` and `DriverSpecs.h`, and the linker asks for `kernel32.lib`. +//! Every spelling the headers actually use is symlinked to the real file, the +//! same trick `xwin` uses. +//! //! Usage: zig run gen_zig_libc_msvc.zig -- //! //! Pinned to Zig 0.15.2 std APIs. const std = @import("std"); +const builtin = @import("builtin"); + +/// Case-sensitive filesystems need the alias pass; Windows and macOS don't. +const needs_case_aliases = builtin.os.tag != .windows and builtin.os.tag != .macos; + +/// Written into the install root once the header aliases are in place, so the +/// second architecture's run doesn't re-read the whole SDK. +const include_marker = ".zig-case-aliases"; fn die(comptime fmt: []const u8, args: anytype) noreturn { std.debug.print(fmt ++ "\n", args); @@ -31,6 +45,321 @@ fn pickLatestSubdirName(alloc: std.mem.Allocator, parent: std.fs.Dir) ![]const u return best orelse error.Empty; } +/// Maps the lowercased form of every real path under a directory to the real +/// one, so a differently-cased spelling can be resolved back to its file. +const CaseMap = struct { + alloc: std.mem.Allocator, + entries: std.StringHashMapUnmanaged([]const u8) = .{}, + + fn build(alloc: std.mem.Allocator, dir: std.fs.Dir) !CaseMap { + var self: CaseMap = .{ .alloc = alloc }; + var walker = try dir.walk(alloc); + defer walker.deinit(); + while (try walker.next()) |entry| { + // Skip symlinks: aliases from an earlier run, and following them + // would walk the same subtree twice. + if (entry.kind == .sym_link) continue; + const real = try alloc.dupe(u8, entry.path); + const lower = try std.ascii.allocLowerString(alloc, real); + const gop = try self.entries.getOrPut(alloc, lower); + if (!gop.found_existing) gop.value_ptr.* = real; + } + return self; + } + + fn realPath(self: CaseMap, spelling: []const u8) !?[]const u8 { + const lower = try std.ascii.allocLowerString(self.alloc, spelling); + defer self.alloc.free(lower); + return self.entries.get(lower); + } +}; + +/// Make `spelling` resolve to `real_rel` under `dir` by symlinking each path +/// component that is spelled differently. Idempotent. +fn aliasSpelling(alloc: std.mem.Allocator, dir: std.fs.Dir, real_rel: []const u8, spelling: []const u8) !void { + var real_parts = std.mem.tokenizeScalar(u8, real_rel, '/'); + var spell_parts = std.mem.tokenizeScalar(u8, spelling, '/'); + var prefix: []const u8 = ""; + while (real_parts.next()) |real_part| { + const spell_part = spell_parts.next() orelse return; + if (!std.mem.eql(u8, real_part, spell_part)) { + const link = if (prefix.len == 0) + spell_part + else + try std.fs.path.join(alloc, &.{ prefix, spell_part }); + dir.symLink(real_part, link, .{}) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + } + prefix = if (prefix.len == 0) + real_part + else + try std.fs.path.join(alloc, &.{ prefix, real_part }); + } +} + +fn isHeader(name: []const u8) bool { + for ([_][]const u8{ ".h", ".H", ".hpp", ".inl", ".idl" }) |ext| { + if (std.mem.endsWith(u8, name, ext)) return true; + } + return false; +} + +/// Collect every `#include <…>` / `#include "…"` and `#pragma comment(lib, "…")` +/// spelling appearing in a header. +fn scanReferences( + alloc: std.mem.Allocator, + text: []const u8, + includes: *std.StringHashMapUnmanaged(void), + libs: *std.StringHashMapUnmanaged(void), +) !void { + try scanQuoted(alloc, text, "#include", includes); + try scanQuoted(alloc, text, "comment(lib", libs); +} + +fn scanQuoted( + alloc: std.mem.Allocator, + text: []const u8, + marker: []const u8, + out: *std.StringHashMapUnmanaged(void), +) !void { + var i: usize = 0; + while (std.mem.indexOfPos(u8, text, i, marker)) |pos| { + i = pos + marker.len; + var j = i; + while (j < text.len and (text[j] == ' ' or text[j] == '\t' or text[j] == ',')) j += 1; + if (j >= text.len) break; + const closer: u8 = switch (text[j]) { + '<' => '>', + '"' => '"', + else => continue, + }; + const start = j + 1; + const end = std.mem.indexOfScalarPos(u8, text, start, closer) orelse continue; + // A quote that never closes on this line isn't an include directive. + if (std.mem.indexOfScalarPos(u8, text, start, '\n')) |nl| { + if (nl < end) continue; + } + const spelling = text[start..end]; + if (spelling.len == 0 or spelling.len > std.fs.max_path_bytes) continue; + const gop = try out.getOrPut(alloc, spelling); + if (!gop.found_existing) gop.key_ptr.* = try alloc.dupe(u8, spelling); + i = end + 1; + } +} + +/// Read every header under `dir` and gather the spellings it references. +fn scanTree( + alloc: std.mem.Allocator, + dir: std.fs.Dir, + includes: *std.StringHashMapUnmanaged(void), + libs: *std.StringHashMapUnmanaged(void), +) !void { + // Header bodies are large and only needed while scanning; keep them off the + // long-lived arena. + var scratch = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer scratch.deinit(); + + var walker = try dir.walk(alloc); + defer walker.deinit(); + while (try walker.next()) |entry| { + if (entry.kind != .file or !isHeader(entry.basename)) continue; + const text = dir.readFileAlloc(scratch.allocator(), entry.path, 8 << 20) catch continue; + try scanReferences(alloc, text, includes, libs); + _ = scratch.reset(.retain_capacity); + } +} + +/// Alias each referenced spelling that doesn't already resolve, relative to one +/// header/library search root. +fn aliasSpellingsIn( + alloc: std.mem.Allocator, + dir: std.fs.Dir, + spellings: std.StringHashMapUnmanaged(void), +) !void { + const map = try CaseMap.build(alloc, dir); + var it = spellings.keyIterator(); + while (it.next()) |key| { + const spelling = key.*; + if (std.fs.path.isAbsolute(spelling)) continue; + const normalized = try alloc.dupe(u8, spelling); + std.mem.replaceScalar(u8, normalized, '\\', '/'); + if (std.mem.indexOf(u8, normalized, "..") != null) continue; + const real = (try map.realPath(normalized)) orelse continue; + if (std.mem.eql(u8, real, normalized)) continue; + try aliasSpelling(alloc, dir, real, normalized); + } +} + +/// Give every mixed-case entry under `dir` an all-lowercase alias — the +/// spelling clang and lld-link reach for by default. +fn aliasLowercaseTree(alloc: std.mem.Allocator, dir: std.fs.Dir) !void { + const Entry = struct { name: []const u8, is_dir: bool }; + var entries: std.ArrayList(Entry) = .empty; + + var it = dir.iterate(); + while (try it.next()) |e| { + if (e.kind == .sym_link) continue; + try entries.append(alloc, .{ + .name = try alloc.dupe(u8, e.name), + .is_dir = e.kind == .directory, + }); + } + + for (entries.items) |e| { + const lower = try std.ascii.allocLowerString(alloc, e.name); + if (!std.mem.eql(u8, lower, e.name)) { + dir.symLink(e.name, lower, .{}) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + } + if (e.is_dir) { + var sub = try dir.openDir(e.name, .{ .iterate = true }); + defer sub.close(); + try aliasLowercaseTree(alloc, sub); + } + } +} + +/// Object files carry `/DEFAULTLIB:` directives in whatever case the producing +/// compiler used — MSVC emits `LIBCMT`, Rust emits `libcmt` — and those names +/// never appear in a header, so the `#pragma comment(lib)` scan can't see them. +/// Alias the spellings a linker directive can plausibly hold. +fn aliasLibCaseVariants(alloc: std.mem.Allocator, dir: std.fs.Dir) !void { + var names: std.ArrayList([]const u8) = .empty; + var it = dir.iterate(); + while (try it.next()) |e| { + if (e.kind != .file) continue; + try names.append(alloc, try alloc.dupe(u8, e.name)); + } + + for (names.items) |name| { + const dot = std.mem.lastIndexOfScalar(u8, name, '.') orelse continue; + const stem = name[0..dot]; + const ext = name[dot..]; + const upper_stem = try std.ascii.allocUpperString(alloc, stem); + const lower_ext = try std.ascii.allocLowerString(alloc, ext); + const variants = [_][]const u8{ + try std.ascii.allocLowerString(alloc, name), + try std.mem.concat(alloc, u8, &.{ upper_stem, lower_ext }), + try std.ascii.allocUpperString(alloc, name), + }; + for (variants) |variant| { + if (std.mem.eql(u8, variant, name)) continue; + dir.symLink(name, variant, .{}) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + } + } +} + +/// Apply `f` to every immediate subdirectory of `parent`. +fn forEachSubdir( + alloc: std.mem.Allocator, + parent: std.fs.Dir, + f: *const fn (std.mem.Allocator, std.fs.Dir) anyerror!void, +) !void { + var it = parent.iterate(); + while (try it.next()) |entry| { + if (entry.kind != .directory) continue; + var dir = openIter(parent, entry.name) orelse continue; + defer dir.close(); + try f(alloc, dir); + } +} + +fn openIter(root: std.fs.Dir, sub_path: []const u8) ?std.fs.Dir { + return root.openDir(sub_path, .{ .iterate = true }) catch null; +} + +/// Apply `spellings` to every immediate subdirectory of `parent` — the SDK's +/// library roots are one `/` level deep. +fn aliasSpellingsInArchDirs( + alloc: std.mem.Allocator, + parent: std.fs.Dir, + spellings: std.StringHashMapUnmanaged(void), +) !void { + var it = parent.iterate(); + while (try it.next()) |entry| { + if (entry.kind != .directory) continue; + var dir = openIter(parent, entry.name) orelse continue; + defer dir.close(); + try aliasSpellingsIn(alloc, dir, spellings); + } +} + +fn exists(dir: std.fs.Dir, sub_path: []const u8) bool { + dir.access(sub_path, .{}) catch return false; + return true; +} + +/// Make the SDK usable on a case-sensitive filesystem: lowercase aliases +/// everywhere, plus an alias for every spelling the headers themselves use. +/// Include and library spellings resolve against search roots, not against the +/// version directory, so they are applied per root. +fn normalizeCase( + alloc: std.mem.Allocator, + root: std.fs.Dir, + sdk_inc_ver: []const u8, + sdk_lib_ver: []const u8, + msvc_ver: []const u8, +) !void { + const sdk_include = try std.fs.path.join(alloc, &.{ "Windows Kits/10/Include", sdk_inc_ver }); + const sdk_lib = try std.fs.path.join(alloc, &.{ "Windows Kits/10/Lib", sdk_lib_ver }); + const msvc_include = try std.fs.path.join(alloc, &.{ "VC/Tools/MSVC", msvc_ver, "include" }); + const msvc_lib = try std.fs.path.join(alloc, &.{ "VC/Tools/MSVC", msvc_ver, "lib" }); + + for ([_][]const u8{ sdk_include, sdk_lib, msvc_include, msvc_lib }) |sub| { + var dir = openIter(root, sub) orelse continue; + defer dir.close(); + try aliasLowercaseTree(alloc, dir); + } + + var includes: std.StringHashMapUnmanaged(void) = .{}; + var libs: std.StringHashMapUnmanaged(void) = .{}; + for ([_][]const u8{ sdk_include, msvc_include }) |sub| { + var dir = openIter(root, sub) orelse continue; + defer dir.close(); + try scanTree(alloc, dir, &includes, &libs); + } + + // `#include` spellings resolve against each header search root. + for ([_][]const u8{ "ucrt", "um", "shared", "winrt", "cppwinrt" }) |flavour| { + const sub = try std.fs.path.join(alloc, &.{ sdk_include, flavour }); + var dir = openIter(root, sub) orelse continue; + defer dir.close(); + try aliasSpellingsIn(alloc, dir, includes); + } + { + var dir = openIter(root, msvc_include); + if (dir) |*d| { + defer d.close(); + try aliasSpellingsIn(alloc, d.*, includes); + } + } + + // `#pragma comment(lib, "…")` spellings resolve against each library dir, + // which sit one `/` level below the SDK lib root. + for ([_][]const u8{ "ucrt", "um" }) |flavour| { + const sub = try std.fs.path.join(alloc, &.{ sdk_lib, flavour }); + var dir = openIter(root, sub) orelse continue; + defer dir.close(); + try aliasSpellingsInArchDirs(alloc, dir, libs); + try forEachSubdir(alloc, dir, aliasLibCaseVariants); + } + { + var dir = openIter(root, msvc_lib); + if (dir) |*d| { + defer d.close(); + try aliasSpellingsInArchDirs(alloc, d.*, libs); + try forEachSubdir(alloc, d.*, aliasLibCaseVariants); + } + } +} + pub fn main() !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); @@ -58,6 +387,12 @@ pub fn main() !void { defer msvc_root.close(); const msvc_ver = try pickLatestSubdirName(alloc, msvc_root); + if (needs_case_aliases and !exists(root, include_marker)) { + std.debug.print("Normalizing SDK filename case (once per install)...\n", .{}); + try normalizeCase(alloc, root, sdk_inc_ver, sdk_lib_ver, msvc_ver); + (try root.createFile(include_marker, .{})).close(); + } + const include_dir = try std.fs.path.join(alloc, &.{ install_root, "Windows Kits/10/Include", sdk_inc_ver, "ucrt" }); const sys_include_dir = try std.fs.path.join(alloc, &.{ install_root, "VC/Tools/MSVC", msvc_ver, "include" }); const crt_dir = try std.fs.path.join(alloc, &.{ install_root, "Windows Kits/10/Lib", sdk_lib_ver, "ucrt", arch }); diff --git a/tools/trim_velopack_lib.zig b/tools/trim_velopack_lib.zig index fe51c47..18f9f34 100644 --- a/tools/trim_velopack_lib.zig +++ b/tools/trim_velopack_lib.zig @@ -42,30 +42,35 @@ pub fn main() !void { else => return error.ListFailed, } - // Delete the first matching `compiler_builtins-*` member. Velopack's - // archives only contain one such member; we mirror the original tool's - // behaviour of returning after the first delete. + // Delete every `compiler_builtins-*` member. The x64/x86 archives hold a + // single one, arm64 holds a couple hundred codegen units. + var del_argv: std.ArrayList([]const u8) = .empty; + defer del_argv.deinit(gpa); + try del_argv.appendSlice(gpa, &.{ zig_exe, "ar", "--format=coff", "d", lib_path }); + var lines = std.mem.tokenizeAny(u8, listed.stdout, "\r\n"); while (lines.next()) |line| { if (!std.mem.startsWith(u8, line, "compiler_builtins-")) continue; - const del_argv = [_][]const u8{ zig_exe, "ar", "--format=coff", "d", lib_path, line }; - const del = try std.process.Child.run(.{ - .allocator = gpa, - .argv = &del_argv, - .max_output_bytes = 1024 * 1024, - }); - defer { - gpa.free(del.stdout); - gpa.free(del.stderr); - } - switch (del.term) { - .Exited => |c| if (c != 0) { - std.debug.print("trim-velopack-lib: `zig ar d {s}` failed (code {d})\nstderr: {s}\n", .{ line, c, del.stderr }); - return error.DeleteFailed; - }, - else => return error.DeleteFailed, - } - return; + try del_argv.append(gpa, line); + } + // Nothing to trim. + if (del_argv.items.len == 5) return; + + const del = try std.process.Child.run(.{ + .allocator = gpa, + .argv = del_argv.items, + .max_output_bytes = 1024 * 1024, + }); + defer { + gpa.free(del.stdout); + gpa.free(del.stderr); + } + switch (del.term) { + .Exited => |c| if (c != 0) { + std.debug.print("trim-velopack-lib: `zig ar d` failed (code {d})\nstderr: {s}\n", .{ c, del.stderr }); + return error.DeleteFailed; + }, + else => return error.DeleteFailed, } } diff --git a/tools/vpk_setup.zig b/tools/vpk_setup.zig new file mode 100644 index 0000000..4635aac --- /dev/null +++ b/tools/vpk_setup.zig @@ -0,0 +1,105 @@ +//! Ensure the Velopack CLI (`vpk`) is present in a `dotnet tool --tool-path` +//! directory, optionally installing it. +//! +//! vpk_setup [version] +//! +//! With `install` set, a missing or mismatched `vpk` is installed (replacing +//! whatever was there). Without it, a missing or mismatched `vpk` is a hard +//! error — the consumer's build gates network installs behind an explicit +//! option, so an un-gated build never reaches out to NuGet. +//! +//! An empty `version` means "whatever is installed is fine; install the newest +//! stable release if nothing is". + +const std = @import("std"); + +pub fn main() !void { + var arena_state: std.heap.ArenaAllocator = .init(std.heap.page_allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const args = try std.process.argsAlloc(arena); + if (args.len < 3) fatal("usage: vpk_setup [version]", .{}); + + const tool_dir = args[1]; + const may_install = std.mem.eql(u8, args[2], "1"); + const wanted: ?[]const u8 = if (args.len > 3 and args[3].len > 0) args[3] else null; + + const installed = try installedVersion(arena, tool_dir); + + if (installed) |have| { + if (wanted == null or std.mem.eql(u8, have, wanted.?)) return; + if (!may_install) fatal( + \\velopack-zig: {s} has vpk {s}, but vpk {s} was requested. + \\ + \\Enable the vpk install gate to replace it (`.install_vpk = true`; + \\with the recommended build option that is `zig build -Dinstall-vpk=true`), + \\or delete {s} and install vpk there yourself. + , .{ tool_dir, have, wanted.?, tool_dir }); + try run(arena, &.{ "dotnet", "tool", "uninstall", "--tool-path", tool_dir, "vpk" }); + } else if (!may_install) { + fatal( + \\velopack-zig: no vpk found under {s}. + \\ + \\Enable the vpk install gate to download it (`.install_vpk = true`; + \\with the recommended build option that is `zig build -Dinstall-vpk=true`), + \\or install it yourself: + \\ + \\ dotnet tool install --tool-path {s} vpk{s}{s} + , .{ tool_dir, tool_dir, if (wanted == null) "" else " --version ", wanted orelse "" }); + } + + if (wanted) |v| { + try run(arena, &.{ "dotnet", "tool", "install", "--tool-path", tool_dir, "vpk", "--version", v }); + } else { + try run(arena, &.{ "dotnet", "tool", "install", "--tool-path", tool_dir, "vpk" }); + } +} + +/// `dotnet tool list --tool-path ` prints a header, a dashed rule, then +/// one ` ` row per tool. A missing directory +/// makes it exit non-zero, which reads as "nothing installed". +fn installedVersion(arena: std.mem.Allocator, tool_dir: []const u8) !?[]const u8 { + const res = std.process.Child.run(.{ + .allocator = arena, + .argv = &.{ "dotnet", "tool", "list", "--tool-path", tool_dir }, + .max_output_bytes = 1 << 20, + }) catch |err| switch (err) { + error.FileNotFound => fatal( + \\velopack-zig: `dotnet` not found on PATH. + \\ + \\Velopack's CLI is a .NET tool; install the .NET SDK from + \\https://dotnet.microsoft.com/download to package your app. + , .{}), + else => return err, + }; + switch (res.term) { + .Exited => |code| if (code != 0) return null, + else => return null, + } + + var lines = std.mem.splitScalar(u8, res.stdout, '\n'); + while (lines.next()) |line| { + var fields = std.mem.tokenizeAny(u8, line, " \t\r"); + const id = fields.next() orelse continue; + if (!std.ascii.eqlIgnoreCase(id, "vpk")) continue; + return fields.next() orelse continue; + } + return null; +} + +fn run(arena: std.mem.Allocator, argv: []const []const u8) !void { + var child: std.process.Child = .init(argv, arena); + child.stdin_behavior = .Ignore; + const term = try child.spawnAndWait(); + const cmd = try std.mem.join(arena, " ", argv); + switch (term) { + .Exited => |code| if (code != 0) fatal("velopack-zig: `{s}` failed with exit code {d}", .{ cmd, code }), + else => fatal("velopack-zig: `{s}` terminated abnormally", .{cmd}), + } +} + +fn fatal(comptime fmt: []const u8, args: anytype) noreturn { + std.debug.print(fmt ++ "\n", args); + std.process.exit(1); +} From d22015daf633ee5745912ed9d059b9c2afdaf833 Mon Sep 17 00:00:00 2001 From: Michael Belousov Date: Mon, 7 Sep 2026 18:59:35 -0400 Subject: [PATCH 2/8] cut README --- README.md | 284 +----------------------------------------------------- 1 file changed, 2 insertions(+), 282 deletions(-) diff --git a/README.md b/README.md index 0db3130..6bfc143 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,8 @@ Shared build glue for using [Velopack](https://velopack.io) from Zig projects. It wraps the Velopack runtime C API and build toolset, and builds dependencies with zig where possible. -Two calls in your `build.zig` cover the whole flow: `linkVelopack` puts the -Velopack runtime into your executable, `addVelopackedAppDir` turns a staged -directory into an installed release bundle. Everything they need — the `vpk` -CLI, MSVC + the Windows SDK for cross-compiled Windows builds, `mksquashfs` for -Linux AppImages — is provisioned by this package. - -The Velopack release version is `0.0.1589-ga2c5a97`. - -The runtime has a dependency on libgcc_s (See issue #TODO). - Velopack's CLI is a .NET tool, so the [.NET SDK](https://dotnet.microsoft.com/download) -must be installed to package (not to link). +must be installed to package. ## Add this package to your zig project @@ -37,7 +27,7 @@ pub fn build(b: *std.Build) !void { // Gate the one thing that reaches out to the network on demand. Recommended // over hardcoding `true`, so a plain `zig build` is never surprising. - const install_vpk = b.option(bool, "install-vpk", "Download the Velopack CLI into .velopack-tools/ when missing") orelse false; + const install_vpk = b.option(bool, "install-vpk", "Opt-in to installing Velopack dotnet CLI") orelse false; const exe = b.addExecutable(.{ .name = "myapp", @@ -51,8 +41,6 @@ pub fn build(b: *std.Build) !void { velopack.linkVelopack(b, exe, .{ .target = target, .optimize = optimize }); - // Stage exactly what ships. Everything in this directory ends up in the - // bundle, so don't point at a directory of build leftovers. const staging = b.addWriteFiles(); _ = staging.addCopyFile(exe.getEmittedBin(), if (target.result.os.tag == .windows) "myapp.exe" else "myapp"); @@ -74,275 +62,7 @@ pub fn build(b: *std.Build) !void { `zig build package -Dinstall-vpk=true` writes setup executables, the `.nupkg` and the `RELEASES` feed to `zig-out/desktop/`. -## `linkVelopack(b, compile, opts)` - -Adds the `Velopack.h` include path, picks the correct prebuilt static library -for the resolved target, links Winsock + BCrypt on Windows, and sets `$ORIGIN` / -`@loader_path` rpath on Linux / macOS. For `*-windows-msvc` it copies the `.lib` -into a writable dir and trims Velopack's bundled Rust `compiler_builtins-*` -members, which would otherwise collide with Zig's `compiler_rt`. Trimming uses -`zig ar` from the Zig toolchain; no separate LLVM install is required. - -| Option | Default | Meaning | -|--------|---------|---------| -| `target` | — | The resolved target you are building for. | -| `optimize` | — | The optimize mode you are building with. | -| `handle_libc` | `true` | Take care of MSVC + the Windows SDK for `*-windows-msvc` (see below). No effect on other targets. | -| `windows_msvc_libc` | `null` | An explicit Zig `--libc` manifest, overriding both the command-line `--libc` and the managed toolchain. | -| `msvc_dir` | `".velopack-msvc"` | Where the managed MSVC tree and its `zig-libc-*.ini` live, relative to your build root. | - -### `handle_libc` - -Zig can cross-compile to `*-windows-msvc` — the ABI Velopack's Windows build -requires — but only with MSVC and Windows SDK headers and libraries on hand. -With `handle_libc` set (the default), velopack-zig resolves a `--libc` manifest -in this order: - -1. `opts.windows_msvc_libc`. -2. The build's own `--libc`. It is then *scoped* to the `*-windows-msvc` - compiles — left as `b.libc_file` it would also be handed to host-side build - tools, which breaks them. -3. `//zig-libc-{x64,arm64}.ini`, if it exists. -4. On a **Windows host**: nothing, leaving Zig's Visual Studio auto-detect in - play. Run `msvcup-setup` once (below) to force the managed toolchain instead. -5. Otherwise: install MSVC + the Windows SDK with - [msvcup](https://github.com/marler8997/msvcup) before compiling. This is a - large one-time download. - -Whatever it lands on is applied to your executable **and every `*-windows-msvc` -compile it links**, so C dependencies (SDL, FreeType, tree-sitter, …) see the -same UCRT and MSVC headers. - -Call `linkVelopack` after wiring your executable's dependencies — it walks the -compile graph as it exists at that point. - -Set `handle_libc = false` to do all of this yourself; `resolveWindowsMsvcLibc`, -`addMsvcupSetupStep` and `applyWindowsMsvcLibcRecursive` stay available for that. - ### Windows ABI note Velopack's Windows prebuilt is **MSVC**. Use `x86_64-windows-msvc` or `aarch64-windows-msvc` (not `*-windows-gnu`). - -## `addVelopackedAppDir(b, opts)` - -Runs `vpk pack` over a staged directory and installs the release artifacts, -returning the `*std.Build.Step.InstallDir` so you can hang your own `package` -step off it. `install_dir` and `install_subdir` mean what they do in -`b.addInstallDirectory`. - -| Option | Default | `vpk` flag | -|--------|---------|-----------| -| `name` | — | `--packId` | -| `version` | — | `--packVersion` | -| `source_dir` | — | `--packDir` | -| `target` | — | picks the `[win]`/`[osx]`/`[linux]` selector, the `--mainExe` extension and whether mksquashfs is needed | -| `main_exe` | `name` (+ `.exe` on Windows) | `--mainExe` | -| `title` | `null` | `--packTitle` | -| `authors` | `null` | `--packAuthors` | -| `icon` | `null` | `--icon` | -| `channel` | `null` | `--channel` | -| `delta` | `"None"` | `--delta` | -| `extra_args` | `&.{}` | appended verbatim — signing identities, `--releaseNotes`, anything not modelled here | -| `depends_on` | `&.{}` | steps that must finish before `vpk pack` runs | -| `install_dir` / `install_subdir` | `.prefix` / `""` | where the release artifacts are installed | - -`source_dir` already orders the pack run after whatever produced it. `depends_on` -is for work that touches the staged payload without being its producer — running -`strip` over the staged binary, for instance. - -### Getting the `vpk` CLI - -velopack-zig runs `vpk` out of `/.velopack-tools`, a `dotnet tool ---tool-path` install it manages itself. You do not need a -`.config/dotnet-tools.json`. - -| Option | Default | Meaning | -|--------|---------|---------| -| `install_vpk` | `false` | Allow installing `vpk` when it is missing or the wrong version. When false, that situation is a build error explaining what to run. | -| `vpk_version` | `.bundled` | `.bundled` (the release this package's runtime comes from), `.latest` (newest stable on NuGet), or `.{ .pinned = "1.2.0" }`. | -| `vpk_dir` | `".velopack-tools"` | Where the managed install lives, relative to your build root. | -| `vpk_argv` | `null` | Run something else entirely, e.g. `&.{ "dotnet", "vpk" }` for a repo-local tool manifest. Disables the managed install. | - -Wire `install_vpk` to a build option rather than hardcoding `true`, so a plain -`zig build` never downloads anything: - -```zig -.install_vpk = b.option(bool, "install-vpk", "Download the Velopack CLI when missing") orelse false, -``` - -`vpk` stamps its own updater binaries into the bundle, so the CLI and the linked -runtime should come from the same Velopack release — hence `.bundled` as the -default rather than `.latest`. Add `.velopack-tools/` to your `.gitignore`. - -> **TODO:** when resolving `.latest`, skip releases younger than five days, so a -> broken publish upstream can't take builds down with it. - -## Escape hatches - -`linkVelopack` and `addVelopackedAppDir` drive all of these; they stay public -for consumers doing something the options above don't cover. - -- **`addVelopackStep(b, opts)`** — the `vpk pack` `Run` on its own, taking every - option `addVelopackedAppDir` does except `install_dir` / `install_subdir`. - Pair it with **`outputDir(run)`** for the release directory as a `LazyPath`. - Use it when you want to do something with that directory other than install it. - -- **`addMsvcupSetupStep(b, install_dir)`** — installs MSVC + the Windows SDK - into `/` (`null` → `.velopack-msvc`) and emits - `zig-libc-x64.ini` / `zig-libc-arm64.ini`. Cached per build root and - directory, so exposing it as your own step and letting `linkVelopack` depend on - it yields one install, not two: - - ```zig - const msvcup_step = b.step("msvcup-setup", "MSVC + WinSDK → zig-libc-*.ini"); - msvcup_step.dependOn(&velopack.addMsvcupSetupStep(b, null).step); - ``` - - This is also how a **Windows host without Visual Studio** opts into the managed - toolchain: run it once, and `handle_libc` picks the manifest up from then on. - -- **`resolveWindowsMsvcLibc(b, target, opts)`** — locates the `zig-libc-*.ini` - for a `*-windows-msvc` target without applying it. - -- **`applyWindowsMsvcLibcRecursive(b, roots, libc)`** — applies a manifest to - every `*-windows-msvc` compile reachable from `roots`. - -- **`buildMksquashfs(b)`** / **`attachMksquashfsToVpkRun(b, run, target)`** — - build `mksquashfs` with Zig (upstream squashfs-tools + zlib) and put it on a - `vpk` Run's `PATH`. `addVelopackStep` already does this for Linux targets. - **Linux hosts only**: returns `null` elsewhere, and on the first build before - the lazy `squashfs` dependency has been fetched (Zig prints a `--fetch` hint). - Cross-packaging a Linux AppImage from macOS or Windows needs a `mksquashfs` on - `PATH` yourself — a Linux CI job or container is the usual answer. - -- **`addDotnetToolRestoreStep(b)`** — `dotnet tool restore` in your build root, - for projects that pin `vpk` in their own `.config/dotnet-tools.json` and pass - `.vpk_argv = &.{ "dotnet", "vpk" }`. - -## Prerequisites - -| Requirement | When | -|-------------|------| -| **Zig 0.15.2** | Always (see `minimum_zig_version`). | -| **[.NET SDK](https://dotnet.microsoft.com/download)** | Packaging. Velopack's CLI is a .NET tool; `dotnet` is **not** needed for `linkVelopack` alone. | -| **Network access** | First `-Dinstall-vpk=true`, and the first `*-windows-msvc` cross-build (MSVC + Windows SDK). Both cache into your build root. | -| **Xcode / CLT** (codesign, `notarytool`) | macOS **signed** / **notarized** releases only. | -| **Apple Developer Program** | Distribution signing + notarization. | - -## Cross-OS packaging - -`vpk` infers the target platform from the host, so `addVelopackStep` passes an -explicit `[win]` / `[linux]` / `[osx]` selector whenever the target OS differs -from the host's. Verified from a Linux host: `x86_64-linux-gnu`, -`aarch64-linux-gnu`, `x86_64-windows-msvc` and `aarch64-windows-msvc` all -package end to end. Two gaps are the host's, not this package's: - -- **macOS targets can only be packaged on macOS.** `vpk` itself refuses: - *"Cross-compiling from Linux to MacOS is not supported."* Signing and - notarization need Apple tooling anyway. -- **Linux AppImages** need `mksquashfs`. It is built for you on a Linux host; on - macOS or Windows hosts, put one on `PATH`. - -### Case-sensitive filesystems - -The Windows SDK ships `Windows.h` and `kernel32.Lib`, its own headers include -`windows.h` and `DriverSpecs.h`, and linker directives ask for `LIBCMT.lib`. On -Windows and macOS the filesystem papers over that; on Linux nothing resolves. The -`msvcup-setup` step therefore does an `xwin`-style pass over the installed tree, -reading every header and symlinking each spelling it actually references, plus -all-lowercase and uppercase-stem aliases for the libraries. It runs once per -install (marked by `.zig-case-aliases` in the install root) and takes a few -seconds. - -## macOS packaging, code signing, and notarization - -Velopack’s **`vpk pack`** can drive **codesign** and **notarytool** when you pass the right flags. **No passwords or App Store Connect keys belong in git** — use the **macOS Keychain** and environment variables (or local-only files) that **your** `build.zig` reads into `Run` arguments. - -### One-time Apple / machine setup - -1. **Enroll** in the Apple Developer Program (distribution builds). -2. **Create / download** certificates (commonly **Developer ID Application** for the `.app`, **Developer ID Installer** for the installer package) and install them in **Keychain Access**. -3. **Notarytool credentials:** store a profile in the keychain, e.g. - `xcrun notarytool store-credentials --apple-id "…" --team-id … --password …` - (prefer an **app-specific password** or **API key** per Apple’s current guidance — see `notarytool help store-credentials`). -4. **Entitlements:** for **hardened runtime** + **notarization**, use an **`.entitlements`** file (real extension required). Typical needs for mixed native/JIT stacks include keys such as `com.apple.security.cs.allow-jit` / `allow-unsigned-executable-memory` / `disable-library-validation` — exact keys are **app-specific**; validate against Apple’s docs and your threat model. -5. **`Info.plist`:** ensure bundle metadata is correct (`CFBundleExecutable`, `CFBundlePackageType` = `APPL`, etc.) or Gatekeeper / `spctl` may reject the bundle even if binaries are signed. - -### Wiring secrets from the environment (recommended) - -Your consumer project can map env vars to `vpk` flags **without** committing values, for example: - -| Purpose | Typical env var (examples) | `vpk` flag | -|---------|---------------------------|------------| -| Sign the app bundle | `MYAPP_MACOS_SIGN_APP` | `--signAppIdentity` | -| Installer identity | `MYAPP_MACOS_SIGN_INSTALLER` | `--signInstallIdentity` | -| Notarytool profile | `MYAPP_MACOS_NOTARY_PROFILE` | `--notaryProfile` | - -Read these in your `build.zig` (`std.process.getEnvVarOwned`, or a small helper) -and pass the flags through `extra_args` only when they are set: - -```zig -var signing: std.ArrayList([]const u8) = .empty; -if (std.process.getEnvVarOwned(b.allocator, "MYAPP_MACOS_SIGN_APP")) |id| { - try signing.appendSlice(b.allocator, &.{ "--signAppIdentity", id }); -} else |_| {} - -const packaged = velopack.addVelopackedAppDir(b, .{ - // … - .extra_args = signing.items, -}); -``` - -For local dev, a **gitignored** `.env.local` plus `set -a && . ./.env.local` in your shell or IDE task is a common pattern. - -### Order of operations - -Unsigned `vpk pack` can work for local testing. For release: build **ReleaseFast** (or equivalent) → **`vpk pack`** with signing + entitlements → notarization (via `--notaryProfile`) → staple if required. Consult [Velopack’s macOS documentation](https://docs.velopack.io/) for the exact `vpk` flags your version supports. - -## Example: `build.zig` (Zig 0.15.2) - -`build.zig.zon`: - -```zig -.dependencies = .{ - .velopack_zig = .{ - .path = "../velopack-zig", // or .url + .hash - }, - // … -}, -``` - -See [Usage](#usage) above for the `build.zig` side. A real project usually adds -a couple of things to it: - -```zig -// Package for every shipping triple in one command. -const packageall = b.step("packageall", "Package every release triple"); -for ([_][]const u8{ - "x86_64-linux-gnu", "aarch64-linux-gnu", - "x86_64-macos", "aarch64-macos", - "x86_64-windows-msvc", "aarch64-windows-msvc", -}) |triple| { - const run = b.addSystemCommand(&.{ - b.graph.zig_exe, "build", "package", - b.fmt("-Dtarget={s}", .{triple}), - b.fmt("-Doptimize={s}", .{@tagName(optimize)}), - }); - if (install_vpk) run.addArg("-Dinstall-vpk=true"); - run.setCwd(b.path(".")); - packageall.dependOn(&run.step); -} - -// Strip the staged binary before it is packed. `strip` can't read foreign -// object files, so skip it when packaging across operating systems. -const strip = b.addSystemCommand(&.{ - if (target.result.os.tag == b.graph.host.result.os.tag) "strip" else "touch", -}); -strip.addFileArg(staged_exe); -// … then pass `.depends_on = &.{&strip.step}` to addVelopackedAppDir. -``` - -## License - -MIT — see `LICENSE`. From e8ae35c08dc6ae9f699a71bc3eb829719c4e6f39 Mon Sep 17 00:00:00 2001 From: Michael Belousov Date: Mon, 7 Sep 2026 19:07:43 -0400 Subject: [PATCH 3/8] Add vpk arg escape hatches, doc links, and a packaging CI matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extra_args becomes extra_vpk_args, and extra_vpk_path_args joins it for flags whose value is a file this build produces (--releaseNotes and friends); the pack step waits on whatever generates it. Between those two and vpk_argv, anything the modelled options don't cover is reachable. README gains a section on customizing the vpk command and links into Velopack's own docs — the CLI reference per platform, signing, channels, deltas, cross compiling, distributing. test/sample is a minimal consumer (link the runtime, stage a payload, package it, exercising both new arg options). test/package.sh builds and packages it and checks vpk produced a nupkg, a RELEASES feed and an installable artifact. The workflow is just the platform matrix plus dotnet/zig setup calling that script. --- .github/workflows/package.yml | 34 +++++++++++++++++++++++ README.md | 41 ++++++++++++++++++++++++++++ build.zig | 31 ++++++++++++++++----- test/package.sh | 36 +++++++++++++++++++++++++ test/sample/build.zig | 51 +++++++++++++++++++++++++++++++++++ test/sample/build.zig.zon | 10 +++++++ test/sample/src/main.zig | 11 ++++++++ 7 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/package.yml create mode 100755 test/package.sh create mode 100644 test/sample/build.zig create mode 100644 test/sample/build.zig.zon create mode 100644 test/sample/src/main.zig diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 0000000..33df36f --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,34 @@ +name: package + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + package: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@v7 + + # Velopack's CLI is a .NET tool; velopack-zig installs it itself when + # -Dinstall-vpk=true, but it needs an SDK to install it with. + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '9.0.x' + + - uses: mlugg/setup-zig@v2 + with: + version: 0.15.2 + cache-key: ${{ matrix.os }} + + - run: ./test/package.sh + shell: bash diff --git a/README.md b/README.md index 6bfc143..bdf156e 100644 --- a/README.md +++ b/README.md @@ -66,3 +66,44 @@ and the `RELEASES` feed to `zig-out/desktop/`. Velopack's Windows prebuilt is **MSVC**. Use `x86_64-windows-msvc` or `aarch64-windows-msvc` (not `*-windows-gnu`). + +## Customizing the `vpk` command + +`addVelopackedAppDir` models the common `vpk pack` flags directly — `name` +(`--packId`), `version`, `main_exe`, `title`, `authors`, `icon`, `channel`, +`delta`. Everything else goes through: + +- `extra_vpk_args: []const []const u8` — appended verbatim, e.g. + `&.{ "--exclude", ".*\\.pdb", "--noPortable" }`. +- `extra_vpk_path_args: []const VpkPathArg` — same, but the value is a path this + build produces, e.g. `&.{ .{ .prefix = "--releaseNotes", .path = notes } }`. + The pack step waits on whatever generates it. +- `vpk_argv: ?[]const []const u8` — run something other than the managed `vpk`, + e.g. `&.{ "dotnet", "vpk" }` for a repo-local `.config/dotnet-tools.json` + (pair it with `addDotnetToolRestoreStep`). + +`addVelopackStep` returns the `*std.Build.Step.Run` itself if you would rather +add arguments to it directly; `outputDir(run)` gives you the release directory. + +## Testing + +`test/sample/` is a minimal consumer of this package. `./test/package.sh` builds +and packages it, which is exactly what `.github/workflows/package.yml` runs on +Linux, macOS and Windows. + +## Velopack documentation + +- [Docs home](https://docs.velopack.io/) · [C / C++ quick start](https://docs.velopack.io/getting-started/cpp) + — the C ABI these bindings link against · [C API reference](https://docs.velopack.io/reference/cpp) +- [`vpk` CLI reference](https://docs.velopack.io/reference/cli) — every flag + `extra_vpk_args` can carry, per platform: + [Windows](https://docs.velopack.io/reference/cli/content/vpk-windows) · + [Linux](https://docs.velopack.io/reference/cli/content/vpk-linux) · + [macOS](https://docs.velopack.io/reference/cli/content/vpk-osx) +- [Packaging overview](https://docs.velopack.io/packaging/overview) · + [Code signing](https://docs.velopack.io/packaging/signing) · + [Release channels](https://docs.velopack.io/packaging/channels) · + [Delta updates](https://docs.velopack.io/packaging/deltas) · + [Cross compiling](https://docs.velopack.io/packaging/cross-compiling) +- [Distributing releases](https://docs.velopack.io/distributing/overview) · + [Deployment CLI](https://docs.velopack.io/distributing/deploy-cli) diff --git a/build.zig b/build.zig index 97fe8ab..9cbde61 100644 --- a/build.zig +++ b/build.zig @@ -260,6 +260,14 @@ pub const VpkVersion = union(enum) { } }; +/// A `vpk` argument whose value is a path produced by the build graph. +pub const VpkPathArg = struct { + /// Flag emitted before the path, e.g. `"--releaseNotes"`. Null passes the + /// path on its own. + prefix: ?[]const u8 = null, + path: std.Build.LazyPath, +}; + pub const VelopackOptions = struct { /// `--packId`: the app's unique Velopack id. name: []const u8, @@ -286,10 +294,16 @@ pub const VelopackOptions = struct { channel: ?[]const u8 = null, /// `--delta`: `"None"`, `"BestSpeed"` or `"BestSize"`. delta: []const u8 = "None", - /// Appended verbatim after every generated argument — signing identities, - /// notarization profiles, `--releaseNotes`, and anything else this wrapper - /// doesn't model. - extra_args: []const []const u8 = &.{}, + /// Appended verbatim after every generated argument, for the many `vpk pack` + /// flags this wrapper doesn't model — signing identities, notarization + /// profiles, `--exclude`, `--shortcuts`, and so on. See + /// https://docs.velopack.io/reference/cli for the full set. + extra_vpk_args: []const []const u8 = &.{}, + /// Like `extra_vpk_args`, but each value is resolved to a build-graph path + /// when the step runs, and the pack step waits on whatever produces it. + /// Use it for flags that take a file this build generates, e.g. + /// `&.{ .{ .prefix = "--releaseNotes", .path = notes } }`. + extra_vpk_path_args: []const VpkPathArg = &.{}, /// Steps that must finish before `vpk pack` runs. `source_dir` already /// pulls in whatever produced it; this is for work that touches the staged /// payload without being its producer, such as stripping the binary. @@ -322,7 +336,8 @@ pub const VelopackedAppDirOptions = struct { icon: ?std.Build.LazyPath = null, channel: ?[]const u8 = null, delta: []const u8 = "None", - extra_args: []const []const u8 = &.{}, + extra_vpk_args: []const []const u8 = &.{}, + extra_vpk_path_args: []const VpkPathArg = &.{}, depends_on: []const *std.Build.Step = &.{}, install_vpk: bool = false, vpk_version: VpkVersion = .bundled, @@ -401,7 +416,11 @@ pub fn addVelopackStep(b: *std.Build, opts: VelopackOptions) *std.Build.Step.Run run.addDirectoryArg(opts.source_dir); run.addArg("--outputDir"); _ = run.addOutputDirectoryArg(b.fmt("{s}-velopack", .{opts.name})); - run.addArgs(opts.extra_args); + run.addArgs(opts.extra_vpk_args); + for (opts.extra_vpk_path_args) |arg| { + if (arg.prefix) |prefix| run.addArg(prefix); + run.addFileArg(arg.path); + } for (opts.depends_on) |dep| run.step.dependOn(dep); // vpk targets an older .NET runtime than what is typically installed. diff --git a/test/package.sh b/test/package.sh new file mode 100755 index 0000000..63ab374 --- /dev/null +++ b/test/package.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Build the sample app and package it with Velopack, then check that vpk +# actually produced a release. Used by .github/workflows/package.yml on Linux, +# macOS and Windows; run it locally the same way: +# +# ./test/package.sh +# +set -euo pipefail + +cd "$(dirname "$0")/sample" + +# mksquashfs (needed for Linux AppImages) is a lazy dependency, so fetch the +# whole tree up front rather than have the first build quietly skip it. +zig build --fetch=all + +zig build package -Dinstall-vpk=true + +out=zig-out/desktop +echo "--- $out ---" +ls -l "$out" + +have() { compgen -G "$out/$1" >/dev/null; } +require() { + for pattern in "$@"; do + have "$pattern" && return 0 + done + echo "package.sh: expected one of [$*] in $out" >&2 + exit 1 +} + +require '*.nupkg' +require 'RELEASES*' +# Whatever the platform calls its installable artifact. +require '*Setup*' '*Portable*' '*.AppImage' '*.pkg' '*.dmg' + +echo "package.sh: OK" diff --git a/test/sample/build.zig b/test/sample/build.zig new file mode 100644 index 0000000..8d41edb --- /dev/null +++ b/test/sample/build.zig @@ -0,0 +1,51 @@ +//! Smallest app that exercises velopack-zig end to end: link the runtime, stage +//! a payload, package it. `test/package.sh` builds this on every CI platform. +const std = @import("std"); +const velopack = @import("velopack_zig"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const install_vpk = b.option(bool, "install-vpk", "Opt-in to installing Velopack dotnet CLI") orelse false; + + const exe = b.addExecutable(.{ + .name = "velosample", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }), + }); + exe.linkLibC(); + + velopack.linkVelopack(b, exe, .{ .target = target, .optimize = optimize }); + + b.installArtifact(exe); + const run = b.addRunArtifact(exe); + b.step("run", "Run the sample").dependOn(&run.step); + + const staging = b.addWriteFiles(); + _ = staging.addCopyFile( + exe.getEmittedBin(), + if (target.result.os.tag == .windows) "velosample.exe" else "velosample", + ); + + const notes = b.addWriteFiles().add("release-notes.md", "## 1.0.0\n\n- First release.\n"); + + const packaged = velopack.addVelopackedAppDir(b, .{ + .name = "velosample", + .version = "1.0.0", + .title = "Velopack Zig Sample", + .authors = "velopack-zig", + .target = target, + .source_dir = staging.getDirectory(), + .install_dir = .prefix, + .install_subdir = "desktop", + .install_vpk = install_vpk, + // Covers the two escape hatches for flags this wrapper doesn't model. + .extra_vpk_args = &.{ "--exclude", ".*\\.pdb" }, + .extra_vpk_path_args = &.{.{ .prefix = "--releaseNotes", .path = notes }}, + }); + + b.step("package", "Build the release bundle").dependOn(&packaged.step); +} diff --git a/test/sample/build.zig.zon b/test/sample/build.zig.zon new file mode 100644 index 0000000..c12f9a6 --- /dev/null +++ b/test/sample/build.zig.zon @@ -0,0 +1,10 @@ +.{ + .name = .velopack_zig_sample, + .version = "1.0.0", + .fingerprint = 0x259d39aa5313eb7e, + .minimum_zig_version = "0.15.2", + .dependencies = .{ + .velopack_zig = .{ .path = "../.." }, + }, + .paths = .{ "build.zig", "build.zig.zon", "src" }, +} diff --git a/test/sample/src/main.zig b/test/sample/src/main.zig new file mode 100644 index 0000000..8b5d1fa --- /dev/null +++ b/test/sample/src/main.zig @@ -0,0 +1,11 @@ +const std = @import("std"); +const c = @cImport({ + @cInclude("Velopack.h"); +}); + +pub fn main() void { + // Handles Velopack's install/update hooks. A no-op outside an install, so + // this is also safe to run straight out of zig-out. + c.vpkc_app_run(null); + std.debug.print("hello from the velopack-zig sample\n", .{}); +} From ef268a70d3a50b49b31fbd2b76bfa4bc73d0de72 Mon Sep 17 00:00:00 2001 From: Michael Belousov Date: Mon, 7 Sep 2026 19:30:22 -0400 Subject: [PATCH 4/8] README fixes --- README.md | 37 +++++++++++++++---------------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index bdf156e..29fe9d3 100644 --- a/README.md +++ b/README.md @@ -69,41 +69,34 @@ Velopack's Windows prebuilt is **MSVC**. Use `x86_64-windows-msvc` or ## Customizing the `vpk` command -`addVelopackedAppDir` models the common `vpk pack` flags directly — `name` +`addVelopackedAppDir` models common `vpk pack` flags directly: (`--packId`), `version`, `main_exe`, `title`, `authors`, `icon`, `channel`, `delta`. Everything else goes through: -- `extra_vpk_args: []const []const u8` — appended verbatim, e.g. +- `extra_vpk_args: []const []const u8`: appended verbatim, e.g. `&.{ "--exclude", ".*\\.pdb", "--noPortable" }`. -- `extra_vpk_path_args: []const VpkPathArg` — same, but the value is a path this +- `extra_vpk_path_args: []const VpkPathArg`: same, but the value is a path this build produces, e.g. `&.{ .{ .prefix = "--releaseNotes", .path = notes } }`. The pack step waits on whatever generates it. -- `vpk_argv: ?[]const []const u8` — run something other than the managed `vpk`, +- `vpk_argv: ?[]const []const u8`: run something other than the managed `vpk`, e.g. `&.{ "dotnet", "vpk" }` for a repo-local `.config/dotnet-tools.json` (pair it with `addDotnetToolRestoreStep`). `addVelopackStep` returns the `*std.Build.Step.Run` itself if you would rather add arguments to it directly; `outputDir(run)` gives you the release directory. -## Testing - -`test/sample/` is a minimal consumer of this package. `./test/package.sh` builds -and packages it, which is exactly what `.github/workflows/package.yml` runs on -Linux, macOS and Windows. - ## Velopack documentation -- [Docs home](https://docs.velopack.io/) · [C / C++ quick start](https://docs.velopack.io/getting-started/cpp) - — the C ABI these bindings link against · [C API reference](https://docs.velopack.io/reference/cpp) -- [`vpk` CLI reference](https://docs.velopack.io/reference/cli) — every flag - `extra_vpk_args` can carry, per platform: - [Windows](https://docs.velopack.io/reference/cli/content/vpk-windows) · - [Linux](https://docs.velopack.io/reference/cli/content/vpk-linux) · - [macOS](https://docs.velopack.io/reference/cli/content/vpk-osx) -- [Packaging overview](https://docs.velopack.io/packaging/overview) · - [Code signing](https://docs.velopack.io/packaging/signing) · - [Release channels](https://docs.velopack.io/packaging/channels) · - [Delta updates](https://docs.velopack.io/packaging/deltas) · +- [Docs home](https://docs.velopack.io/) [C / C++ quick start](https://docs.velopack.io/getting-started/cpp) + the C ABI these bindings link against [C API reference](https://docs.velopack.io/reference/cpp) +- [`vpk` CLI reference](https://docs.velopack.io/reference/cli): + [Windows](https://docs.velopack.io/reference/cli/content/vpk-windows), + [Linux](https://docs.velopack.io/reference/cli/content/vpk-linux), + [macOS](https://docs.velopack.io/reference/cli/content/vpk-osx), +- [Packaging overview](https://docs.velopack.io/packaging/overview), + [Code signing](https://docs.velopack.io/packaging/signing), + [Release channels](https://docs.velopack.io/packaging/channels), + [Delta updates](https://docs.velopack.io/packaging/deltas), [Cross compiling](https://docs.velopack.io/packaging/cross-compiling) -- [Distributing releases](https://docs.velopack.io/distributing/overview) · +- [Distributing releases](https://docs.velopack.io/distributing/overview), [Deployment CLI](https://docs.velopack.io/distributing/deploy-cli) From c7607a648c3c584efb4f96f01e55ab7924a04145 Mon Sep 17 00:00:00 2001 From: Michael Belousov Date: Mon, 7 Sep 2026 19:33:37 -0400 Subject: [PATCH 5/8] more comment vetting --- README.md | 4 ++++ build.zig | 29 ++--------------------------- 2 files changed, 6 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 29fe9d3..c18dcc0 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@ with zig where possible. Velopack's CLI is a .NET tool, so the [.NET SDK](https://dotnet.microsoft.com/download) must be installed to package. +***WARNING: this project has been generally reviewed but outside of the README, +small pieces of code and many large comments are AI generated and have not been +rewritten yet so may be useless to read.*** + ## Add this package to your zig project ```zig fetch --save "git+https://github.com/graphl-tech/velopack-zig#main"``` diff --git a/build.zig b/build.zig index 9cbde61..bad0363 100644 --- a/build.zig +++ b/build.zig @@ -1,30 +1,5 @@ -//! velopack-zig — Velopack packaging glue for Zig projects. -//! -//! Two calls cover the whole flow from a consumer's build.zig: -//! -//! const velopack = @import("velopack_zig"); -//! -//! // 1. Link the Velopack runtime into your app. On *-windows-msvc this -//! // also installs and wires up MSVC + the Windows SDK when needed. -//! velopack.linkVelopack(b, exe, .{ .target = target, .optimize = optimize }); -//! -//! // 2. Run `vpk pack` over a staged directory and install its output. -//! const packaged = velopack.addVelopackedAppDir(b, .{ -//! .name = "myapp", -//! .version = "1.0.0", -//! .target = target, -//! .source_dir = staging.getDirectory(), -//! .install_dir = .prefix, -//! .install_subdir = "desktop", -//! .install_vpk = b.option(bool, "install-vpk", "Download the vpk CLI") orelse false, -//! }); -//! b.step("package", "Build the release bundle").dependOn(&packaged.step); -//! -//! The package bundles the Velopack release zip, the `vpk` CLI provisioning and -//! a Zig build of mksquashfs (upstream squashfs-tools + zlib), so consumers do -//! not declare any of those themselves. -//! -//! Targets **Zig 0.15.2** on `main`. +//! velopack-zig: Velopack packaging glue for Zig projects. +//! See README.md const std = @import("std"); const builtin = @import("builtin"); From 48d98ff8cc2d2a45d283ef3b6374e5e90472ce79 Mon Sep 17 00:00:00 2001 From: Michael Belousov Date: Mon, 7 Sep 2026 21:37:43 -0400 Subject: [PATCH 6/8] Address review: pack_opts, FIXME, redundant fetch, MSVC ABI - test/package.sh drops `zig build --fetch=all`; verified with a fresh --global-cache-dir that Zig fetches the lazy squashfs dep on its own and still builds the AppImage. - VelopackedAppDirOptions holds a `pack_opts: VelopackOptions` instead of restating every field, which drops the comptime field-copy loop. - FIXME on `depends_on`: needing it at all says the wrapper is too coarse. - Trim the addVelopackStep doc comment. The sample now defaults to the MSVC ABI on Windows hosts. Zig's native Windows default is gnu, so CI was linking Velopack's MSVC-only runtime into a gnu build and lld-link went looking for libLIBCMT.a. linkVelopack now rejects a non-MSVC Windows target outright rather than failing at link. --- README.md | 20 +++++++++++--------- build.zig | 39 ++++++++++++--------------------------- test/package.sh | 4 ---- test/sample/build.zig | 29 ++++++++++++++++++----------- 4 files changed, 41 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index c18dcc0..3403630 100644 --- a/README.md +++ b/README.md @@ -49,14 +49,16 @@ pub fn build(b: *std.Build) !void { _ = staging.addCopyFile(exe.getEmittedBin(), if (target.result.os.tag == .windows) "myapp.exe" else "myapp"); const packaged = velopack.addVelopackedAppDir(b, .{ - .name = "myapp", - .version = "1.0.0", - .target = target, - .source_dir = staging.getDirectory(), - .icon = b.path("art/icon.png"), .install_dir = .prefix, .install_subdir = "desktop", - .install_vpk = install_vpk, + .pack_opts = .{ + .name = "myapp", + .version = "1.0.0", + .target = target, + .source_dir = staging.getDirectory(), + .icon = b.path("art/icon.png"), + .install_vpk = install_vpk, + }, }); b.step("package", "Build the release bundle").dependOn(&packaged.step); @@ -73,9 +75,9 @@ Velopack's Windows prebuilt is **MSVC**. Use `x86_64-windows-msvc` or ## Customizing the `vpk` command -`addVelopackedAppDir` models common `vpk pack` flags directly: -(`--packId`), `version`, `main_exe`, `title`, `authors`, `icon`, `channel`, -`delta`. Everything else goes through: +`addVelopackedAppDir` takes the `vpk pack` options as `pack_opts`, which models +common flags directly: `name` (`--packId`), `version`, `main_exe`, `title`, +`authors`, `icon`, `channel`, `delta`. Everything else goes through: - `extra_vpk_args: []const []const u8`: appended verbatim, e.g. `&.{ "--exclude", ".*\\.pdb", "--noPortable" }`. diff --git a/build.zig b/build.zig index bad0363..d5dd561 100644 --- a/build.zig +++ b/build.zig @@ -100,6 +100,9 @@ pub fn linkVelopack( compile.root_module.addIncludePath(velopack_dep.path("include")); + if (target.result.os.tag == .windows and target.result.abi != .msvc) + @panic("velopack-zig: Velopack's Windows runtime is MSVC-only; build for *-windows-msvc"); + const lib_name = switch (target.result.os.tag) { .linux => switch (target.result.cpu.arch) { .x86_64 => "velopack_libc_linux_x64_gnu.a", @@ -282,6 +285,11 @@ pub const VelopackOptions = struct { /// Steps that must finish before `vpk pack` runs. `source_dir` already /// pulls in whatever produced it; this is for work that touches the staged /// payload without being its producer, such as stripping the binary. + /// + /// FIXME: threading step ordering through an options struct is a sign this + /// wrapper is too coarse. A more fine-grained, idiomatic API would hand the + /// caller the pieces — staging, pack, install — and let them order their + /// own work against the steps directly. depends_on: []const *std.Build.Step = &.{}, /// Gate on downloading the `vpk` CLI. Wire it to a build option (see the @@ -301,24 +309,7 @@ pub const VelopackOptions = struct { }; pub const VelopackedAppDirOptions = struct { - name: []const u8, - version: []const u8, - source_dir: std.Build.LazyPath, - target: std.Build.ResolvedTarget, - main_exe: ?[]const u8 = null, - title: ?[]const u8 = null, - authors: ?[]const u8 = null, - icon: ?std.Build.LazyPath = null, - channel: ?[]const u8 = null, - delta: []const u8 = "None", - extra_vpk_args: []const []const u8 = &.{}, - extra_vpk_path_args: []const VpkPathArg = &.{}, - depends_on: []const *std.Build.Step = &.{}, - install_vpk: bool = false, - vpk_version: VpkVersion = .bundled, - vpk_dir: []const u8 = ".velopack-tools", - vpk_argv: ?[]const []const u8 = null, - + pack_opts: VelopackOptions, /// Where the release artifacts land, as in `b.addInstallDirectory`. install_dir: std.Build.InstallDir = .prefix, install_subdir: []const u8 = "", @@ -334,27 +325,21 @@ pub fn addVelopackedAppDir( b: *std.Build, opts: VelopackedAppDirOptions, ) *std.Build.Step.InstallDir { - var pack_opts: VelopackOptions = undefined; - inline for (@typeInfo(VelopackOptions).@"struct".fields) |field| { - @field(pack_opts, field.name) = @field(opts, field.name); - } - - const run = addVelopackStep(b, pack_opts); + const run = addVelopackStep(b, opts.pack_opts); const install = b.addInstallDirectory(.{ .source_dir = outputDir(run), .install_dir = opts.install_dir, .install_subdir = opts.install_subdir, }); // Default would be "install generated/", which says nothing in a summary. - install.step.name = b.fmt("install {s} velopack release", .{opts.name}); + install.step.name = b.fmt("install {s} velopack release", .{opts.pack_opts.name}); return install; } /// Lower-level `vpk pack` Run, for consumers who need to do something with the /// release directory other than install it. Pair it with `outputDir`. /// -/// Prefer `addVelopackedAppDir`: it is this plus the install step, and one call -/// less to keep in sync with the options above. +/// Prefer `addVelopackedAppDir`. pub fn addVelopackStep(b: *std.Build, opts: VelopackOptions) *std.Build.Step.Run { const target = opts.target; const windows = target.result.os.tag == .windows; diff --git a/test/package.sh b/test/package.sh index 63ab374..ae213ae 100755 --- a/test/package.sh +++ b/test/package.sh @@ -9,10 +9,6 @@ set -euo pipefail cd "$(dirname "$0")/sample" -# mksquashfs (needed for Linux AppImages) is a lazy dependency, so fetch the -# whole tree up front rather than have the first build quietly skip it. -zig build --fetch=all - zig build package -Dinstall-vpk=true out=zig-out/desktop diff --git a/test/sample/build.zig b/test/sample/build.zig index 8d41edb..11d72df 100644 --- a/test/sample/build.zig +++ b/test/sample/build.zig @@ -1,10 +1,15 @@ //! Smallest app that exercises velopack-zig end to end: link the runtime, stage //! a payload, package it. `test/package.sh` builds this on every CI platform. const std = @import("std"); +const builtin = @import("builtin"); const velopack = @import("velopack_zig"); pub fn build(b: *std.Build) void { - const target = b.standardTargetOptions(.{}); + const target = b.standardTargetOptions(.{ + // Velopack's Windows runtime is MSVC-ABI, but Zig's native Windows + // default is gnu, so a plain `zig build` on Windows picks the wrong one. + .default_target = if (builtin.os.tag == .windows) .{ .abi = .msvc } else .{}, + }); const optimize = b.standardOptimizeOption(.{}); const install_vpk = b.option(bool, "install-vpk", "Opt-in to installing Velopack dotnet CLI") orelse false; @@ -33,18 +38,20 @@ pub fn build(b: *std.Build) void { const notes = b.addWriteFiles().add("release-notes.md", "## 1.0.0\n\n- First release.\n"); const packaged = velopack.addVelopackedAppDir(b, .{ - .name = "velosample", - .version = "1.0.0", - .title = "Velopack Zig Sample", - .authors = "velopack-zig", - .target = target, - .source_dir = staging.getDirectory(), .install_dir = .prefix, .install_subdir = "desktop", - .install_vpk = install_vpk, - // Covers the two escape hatches for flags this wrapper doesn't model. - .extra_vpk_args = &.{ "--exclude", ".*\\.pdb" }, - .extra_vpk_path_args = &.{.{ .prefix = "--releaseNotes", .path = notes }}, + .pack_opts = .{ + .name = "velosample", + .version = "1.0.0", + .title = "Velopack Zig Sample", + .authors = "velopack-zig", + .target = target, + .source_dir = staging.getDirectory(), + .install_vpk = install_vpk, + // Covers the two escape hatches for flags this wrapper doesn't model. + .extra_vpk_args = &.{ "--exclude", ".*\\.pdb" }, + .extra_vpk_path_args = &.{.{ .prefix = "--releaseNotes", .path = notes }}, + }, }); b.step("package", "Build the release bundle").dependOn(&packaged.step); From 60379464c827d3a80e9f1a839c5a202563f20375 Mon Sep 17 00:00:00 2001 From: Michael Belousov Date: Mon, 7 Sep 2026 21:37:43 -0400 Subject: [PATCH 7/8] Move the SDK case-alias technique to its own module gen_zig_libc_msvc.zig now just describes the SDK's layout and calls symlink-bad-cased-include-tree.zig, which owns the technique and documents why it works: the inconsistency is in the references, not the files, so the pass enumerates every spelling the headers use (exhaustive) and guesses the few that only exist inside binary /DEFAULTLIB: directives (not). Verified against a synthetic SDK tree: lowercase aliases, enumerated #include and comment(lib) spellings, multi-component paths, and the guessed library variants all resolve. --- tools/gen_zig_libc_msvc.zig | 355 ++--------------------- tools/symlink-bad-cased-include-tree.zig | 304 +++++++++++++++++++ 2 files changed, 334 insertions(+), 325 deletions(-) create mode 100644 tools/symlink-bad-cased-include-tree.zig diff --git a/tools/gen_zig_libc_msvc.zig b/tools/gen_zig_libc_msvc.zig index 9c58010..c4ecbc6 100644 --- a/tools/gen_zig_libc_msvc.zig +++ b/tools/gen_zig_libc_msvc.zig @@ -2,24 +2,19 @@ //! SDK tree. Picks the highest-versioned subdirectory under each well-known //! folder so SDK / MSVC bumps don't require re-pinning. //! -//! On case-sensitive filesystems it also makes the tree usable at all: the -//! Windows SDK ships `Windows.h` and `kernel32.Lib` but its own headers include -//! `windows.h` and `DriverSpecs.h`, and the linker asks for `kernel32.lib`. -//! Every spelling the headers actually use is symlinked to the real file, the -//! same trick `xwin` uses. +//! On a case-sensitive filesystem the installed SDK is also unusable as shipped +//! (`Windows.h` vs `windows.h`, `kernel32.Lib` vs `kernel32.lib`); see +//! `symlink-bad-cased-include-tree.zig`, which this runs once per install. //! //! Usage: zig run gen_zig_libc_msvc.zig -- //! //! Pinned to Zig 0.15.2 std APIs. const std = @import("std"); -const builtin = @import("builtin"); +const case_aliases = @import("symlink-bad-cased-include-tree.zig"); -/// Case-sensitive filesystems need the alias pass; Windows and macOS don't. -const needs_case_aliases = builtin.os.tag != .windows and builtin.os.tag != .macos; - -/// Written into the install root once the header aliases are in place, so the -/// second architecture's run doesn't re-read the whole SDK. -const include_marker = ".zig-case-aliases"; +/// Dropped in the install root once the aliases exist, so the second +/// architecture's run doesn't re-read the whole SDK. +const case_alias_marker = ".zig-case-aliases"; fn die(comptime fmt: []const u8, args: anytype) noreturn { std.debug.print(fmt ++ "\n", args); @@ -45,321 +40,11 @@ fn pickLatestSubdirName(alloc: std.mem.Allocator, parent: std.fs.Dir) ![]const u return best orelse error.Empty; } -/// Maps the lowercased form of every real path under a directory to the real -/// one, so a differently-cased spelling can be resolved back to its file. -const CaseMap = struct { - alloc: std.mem.Allocator, - entries: std.StringHashMapUnmanaged([]const u8) = .{}, - - fn build(alloc: std.mem.Allocator, dir: std.fs.Dir) !CaseMap { - var self: CaseMap = .{ .alloc = alloc }; - var walker = try dir.walk(alloc); - defer walker.deinit(); - while (try walker.next()) |entry| { - // Skip symlinks: aliases from an earlier run, and following them - // would walk the same subtree twice. - if (entry.kind == .sym_link) continue; - const real = try alloc.dupe(u8, entry.path); - const lower = try std.ascii.allocLowerString(alloc, real); - const gop = try self.entries.getOrPut(alloc, lower); - if (!gop.found_existing) gop.value_ptr.* = real; - } - return self; - } - - fn realPath(self: CaseMap, spelling: []const u8) !?[]const u8 { - const lower = try std.ascii.allocLowerString(self.alloc, spelling); - defer self.alloc.free(lower); - return self.entries.get(lower); - } -}; - -/// Make `spelling` resolve to `real_rel` under `dir` by symlinking each path -/// component that is spelled differently. Idempotent. -fn aliasSpelling(alloc: std.mem.Allocator, dir: std.fs.Dir, real_rel: []const u8, spelling: []const u8) !void { - var real_parts = std.mem.tokenizeScalar(u8, real_rel, '/'); - var spell_parts = std.mem.tokenizeScalar(u8, spelling, '/'); - var prefix: []const u8 = ""; - while (real_parts.next()) |real_part| { - const spell_part = spell_parts.next() orelse return; - if (!std.mem.eql(u8, real_part, spell_part)) { - const link = if (prefix.len == 0) - spell_part - else - try std.fs.path.join(alloc, &.{ prefix, spell_part }); - dir.symLink(real_part, link, .{}) catch |err| switch (err) { - error.PathAlreadyExists => {}, - else => return err, - }; - } - prefix = if (prefix.len == 0) - real_part - else - try std.fs.path.join(alloc, &.{ prefix, real_part }); - } -} - -fn isHeader(name: []const u8) bool { - for ([_][]const u8{ ".h", ".H", ".hpp", ".inl", ".idl" }) |ext| { - if (std.mem.endsWith(u8, name, ext)) return true; - } - return false; -} - -/// Collect every `#include <…>` / `#include "…"` and `#pragma comment(lib, "…")` -/// spelling appearing in a header. -fn scanReferences( - alloc: std.mem.Allocator, - text: []const u8, - includes: *std.StringHashMapUnmanaged(void), - libs: *std.StringHashMapUnmanaged(void), -) !void { - try scanQuoted(alloc, text, "#include", includes); - try scanQuoted(alloc, text, "comment(lib", libs); -} - -fn scanQuoted( - alloc: std.mem.Allocator, - text: []const u8, - marker: []const u8, - out: *std.StringHashMapUnmanaged(void), -) !void { - var i: usize = 0; - while (std.mem.indexOfPos(u8, text, i, marker)) |pos| { - i = pos + marker.len; - var j = i; - while (j < text.len and (text[j] == ' ' or text[j] == '\t' or text[j] == ',')) j += 1; - if (j >= text.len) break; - const closer: u8 = switch (text[j]) { - '<' => '>', - '"' => '"', - else => continue, - }; - const start = j + 1; - const end = std.mem.indexOfScalarPos(u8, text, start, closer) orelse continue; - // A quote that never closes on this line isn't an include directive. - if (std.mem.indexOfScalarPos(u8, text, start, '\n')) |nl| { - if (nl < end) continue; - } - const spelling = text[start..end]; - if (spelling.len == 0 or spelling.len > std.fs.max_path_bytes) continue; - const gop = try out.getOrPut(alloc, spelling); - if (!gop.found_existing) gop.key_ptr.* = try alloc.dupe(u8, spelling); - i = end + 1; - } -} - -/// Read every header under `dir` and gather the spellings it references. -fn scanTree( - alloc: std.mem.Allocator, - dir: std.fs.Dir, - includes: *std.StringHashMapUnmanaged(void), - libs: *std.StringHashMapUnmanaged(void), -) !void { - // Header bodies are large and only needed while scanning; keep them off the - // long-lived arena. - var scratch = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer scratch.deinit(); - - var walker = try dir.walk(alloc); - defer walker.deinit(); - while (try walker.next()) |entry| { - if (entry.kind != .file or !isHeader(entry.basename)) continue; - const text = dir.readFileAlloc(scratch.allocator(), entry.path, 8 << 20) catch continue; - try scanReferences(alloc, text, includes, libs); - _ = scratch.reset(.retain_capacity); - } -} - -/// Alias each referenced spelling that doesn't already resolve, relative to one -/// header/library search root. -fn aliasSpellingsIn( - alloc: std.mem.Allocator, - dir: std.fs.Dir, - spellings: std.StringHashMapUnmanaged(void), -) !void { - const map = try CaseMap.build(alloc, dir); - var it = spellings.keyIterator(); - while (it.next()) |key| { - const spelling = key.*; - if (std.fs.path.isAbsolute(spelling)) continue; - const normalized = try alloc.dupe(u8, spelling); - std.mem.replaceScalar(u8, normalized, '\\', '/'); - if (std.mem.indexOf(u8, normalized, "..") != null) continue; - const real = (try map.realPath(normalized)) orelse continue; - if (std.mem.eql(u8, real, normalized)) continue; - try aliasSpelling(alloc, dir, real, normalized); - } -} - -/// Give every mixed-case entry under `dir` an all-lowercase alias — the -/// spelling clang and lld-link reach for by default. -fn aliasLowercaseTree(alloc: std.mem.Allocator, dir: std.fs.Dir) !void { - const Entry = struct { name: []const u8, is_dir: bool }; - var entries: std.ArrayList(Entry) = .empty; - - var it = dir.iterate(); - while (try it.next()) |e| { - if (e.kind == .sym_link) continue; - try entries.append(alloc, .{ - .name = try alloc.dupe(u8, e.name), - .is_dir = e.kind == .directory, - }); - } - - for (entries.items) |e| { - const lower = try std.ascii.allocLowerString(alloc, e.name); - if (!std.mem.eql(u8, lower, e.name)) { - dir.symLink(e.name, lower, .{}) catch |err| switch (err) { - error.PathAlreadyExists => {}, - else => return err, - }; - } - if (e.is_dir) { - var sub = try dir.openDir(e.name, .{ .iterate = true }); - defer sub.close(); - try aliasLowercaseTree(alloc, sub); - } - } -} - -/// Object files carry `/DEFAULTLIB:` directives in whatever case the producing -/// compiler used — MSVC emits `LIBCMT`, Rust emits `libcmt` — and those names -/// never appear in a header, so the `#pragma comment(lib)` scan can't see them. -/// Alias the spellings a linker directive can plausibly hold. -fn aliasLibCaseVariants(alloc: std.mem.Allocator, dir: std.fs.Dir) !void { - var names: std.ArrayList([]const u8) = .empty; - var it = dir.iterate(); - while (try it.next()) |e| { - if (e.kind != .file) continue; - try names.append(alloc, try alloc.dupe(u8, e.name)); - } - - for (names.items) |name| { - const dot = std.mem.lastIndexOfScalar(u8, name, '.') orelse continue; - const stem = name[0..dot]; - const ext = name[dot..]; - const upper_stem = try std.ascii.allocUpperString(alloc, stem); - const lower_ext = try std.ascii.allocLowerString(alloc, ext); - const variants = [_][]const u8{ - try std.ascii.allocLowerString(alloc, name), - try std.mem.concat(alloc, u8, &.{ upper_stem, lower_ext }), - try std.ascii.allocUpperString(alloc, name), - }; - for (variants) |variant| { - if (std.mem.eql(u8, variant, name)) continue; - dir.symLink(name, variant, .{}) catch |err| switch (err) { - error.PathAlreadyExists => {}, - else => return err, - }; - } - } -} - -/// Apply `f` to every immediate subdirectory of `parent`. -fn forEachSubdir( - alloc: std.mem.Allocator, - parent: std.fs.Dir, - f: *const fn (std.mem.Allocator, std.fs.Dir) anyerror!void, -) !void { - var it = parent.iterate(); - while (try it.next()) |entry| { - if (entry.kind != .directory) continue; - var dir = openIter(parent, entry.name) orelse continue; - defer dir.close(); - try f(alloc, dir); - } -} - -fn openIter(root: std.fs.Dir, sub_path: []const u8) ?std.fs.Dir { - return root.openDir(sub_path, .{ .iterate = true }) catch null; -} - -/// Apply `spellings` to every immediate subdirectory of `parent` — the SDK's -/// library roots are one `/` level deep. -fn aliasSpellingsInArchDirs( - alloc: std.mem.Allocator, - parent: std.fs.Dir, - spellings: std.StringHashMapUnmanaged(void), -) !void { - var it = parent.iterate(); - while (try it.next()) |entry| { - if (entry.kind != .directory) continue; - var dir = openIter(parent, entry.name) orelse continue; - defer dir.close(); - try aliasSpellingsIn(alloc, dir, spellings); - } -} - fn exists(dir: std.fs.Dir, sub_path: []const u8) bool { dir.access(sub_path, .{}) catch return false; return true; } -/// Make the SDK usable on a case-sensitive filesystem: lowercase aliases -/// everywhere, plus an alias for every spelling the headers themselves use. -/// Include and library spellings resolve against search roots, not against the -/// version directory, so they are applied per root. -fn normalizeCase( - alloc: std.mem.Allocator, - root: std.fs.Dir, - sdk_inc_ver: []const u8, - sdk_lib_ver: []const u8, - msvc_ver: []const u8, -) !void { - const sdk_include = try std.fs.path.join(alloc, &.{ "Windows Kits/10/Include", sdk_inc_ver }); - const sdk_lib = try std.fs.path.join(alloc, &.{ "Windows Kits/10/Lib", sdk_lib_ver }); - const msvc_include = try std.fs.path.join(alloc, &.{ "VC/Tools/MSVC", msvc_ver, "include" }); - const msvc_lib = try std.fs.path.join(alloc, &.{ "VC/Tools/MSVC", msvc_ver, "lib" }); - - for ([_][]const u8{ sdk_include, sdk_lib, msvc_include, msvc_lib }) |sub| { - var dir = openIter(root, sub) orelse continue; - defer dir.close(); - try aliasLowercaseTree(alloc, dir); - } - - var includes: std.StringHashMapUnmanaged(void) = .{}; - var libs: std.StringHashMapUnmanaged(void) = .{}; - for ([_][]const u8{ sdk_include, msvc_include }) |sub| { - var dir = openIter(root, sub) orelse continue; - defer dir.close(); - try scanTree(alloc, dir, &includes, &libs); - } - - // `#include` spellings resolve against each header search root. - for ([_][]const u8{ "ucrt", "um", "shared", "winrt", "cppwinrt" }) |flavour| { - const sub = try std.fs.path.join(alloc, &.{ sdk_include, flavour }); - var dir = openIter(root, sub) orelse continue; - defer dir.close(); - try aliasSpellingsIn(alloc, dir, includes); - } - { - var dir = openIter(root, msvc_include); - if (dir) |*d| { - defer d.close(); - try aliasSpellingsIn(alloc, d.*, includes); - } - } - - // `#pragma comment(lib, "…")` spellings resolve against each library dir, - // which sit one `/` level below the SDK lib root. - for ([_][]const u8{ "ucrt", "um" }) |flavour| { - const sub = try std.fs.path.join(alloc, &.{ sdk_lib, flavour }); - var dir = openIter(root, sub) orelse continue; - defer dir.close(); - try aliasSpellingsInArchDirs(alloc, dir, libs); - try forEachSubdir(alloc, dir, aliasLibCaseVariants); - } - { - var dir = openIter(root, msvc_lib); - if (dir) |*d| { - defer d.close(); - try aliasSpellingsInArchDirs(alloc, d.*, libs); - try forEachSubdir(alloc, d.*, aliasLibCaseVariants); - } - } -} - pub fn main() !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); @@ -387,10 +72,30 @@ pub fn main() !void { defer msvc_root.close(); const msvc_ver = try pickLatestSubdirName(alloc, msvc_root); - if (needs_case_aliases and !exists(root, include_marker)) { + if (case_aliases.host_needs_aliases and !exists(root, case_alias_marker)) { std.debug.print("Normalizing SDK filename case (once per install)...\n", .{}); - try normalizeCase(alloc, root, sdk_inc_ver, sdk_lib_ver, msvc_ver); - (try root.createFile(include_marker, .{})).close(); + const sdk_include = try std.fs.path.join(alloc, &.{ "Windows Kits/10/Include", sdk_inc_ver }); + const sdk_lib = try std.fs.path.join(alloc, &.{ "Windows Kits/10/Lib", sdk_lib_ver }); + const msvc_include = try std.fs.path.join(alloc, &.{ "VC/Tools/MSVC", msvc_ver, "include" }); + const msvc_lib = try std.fs.path.join(alloc, &.{ "VC/Tools/MSVC", msvc_ver, "lib" }); + try case_aliases.apply(alloc, root, .{ + .lowercase_trees = &.{ sdk_include, sdk_lib, msvc_include, msvc_lib }, + .scan_trees = &.{ sdk_include, msvc_include }, + .include_roots = &.{ + try std.fs.path.join(alloc, &.{ sdk_include, "ucrt" }), + try std.fs.path.join(alloc, &.{ sdk_include, "um" }), + try std.fs.path.join(alloc, &.{ sdk_include, "shared" }), + try std.fs.path.join(alloc, &.{ sdk_include, "winrt" }), + try std.fs.path.join(alloc, &.{ sdk_include, "cppwinrt" }), + msvc_include, + }, + .lib_root_parents = &.{ + try std.fs.path.join(alloc, &.{ sdk_lib, "ucrt" }), + try std.fs.path.join(alloc, &.{ sdk_lib, "um" }), + msvc_lib, + }, + }); + (try root.createFile(case_alias_marker, .{})).close(); } const include_dir = try std.fs.path.join(alloc, &.{ install_root, "Windows Kits/10/Include", sdk_inc_ver, "ucrt" }); diff --git a/tools/symlink-bad-cased-include-tree.zig b/tools/symlink-bad-cased-include-tree.zig new file mode 100644 index 0000000..a12b40f --- /dev/null +++ b/tools/symlink-bad-cased-include-tree.zig @@ -0,0 +1,304 @@ +//! Make a case-unaware header / library tree usable on a case-sensitive +//! filesystem, the way [xwin](https://github.com/Jake-Shadle/xwin) does. +//! +//! The Windows SDK ships `Windows.h` and `kernel32.Lib`, its own headers +//! `#include "DriverSpecs.h"` next to a real `driverspecs.h`, and lld-link asks +//! for `kernel32.lib`. NTFS folds all of that onto one file; ext4 resolves only +//! the exact bytes. +//! +//! There is nothing to normalize *toward*: the inconsistency lives in the +//! references, spread across thousands of read-only headers, so renaming files +//! to one canonical case just breaks whoever spelled it the other way. Instead +//! give every real file a sibling symlink for each spelling anything actually +//! uses. That is purely additive — the real name keeps working — so the pass is +//! idempotent and can be re-run over a tree it has already seen. +//! +//! Spellings come from two places: +//! +//! - **Enumerated.** The tree is a closed set of text files, so every +//! `#include <…>` / `#include "…"` and `#pragma comment(lib, "…")` in it can +//! simply be read out. Exhaustive for references between the headers: a +//! spelling that appears in no header cannot be asked for by one. +//! +//! - **Guessed.** Some spellings appear in no header at all. clang and +//! lld-link reach for all-lowercase by default, and `/DEFAULTLIB:` +//! directives are baked into binary object members in whatever case +//! produced them (MSVC emits `LIBCMT`, Rust emits `libcmt`). Libraries +//! therefore also get lowercase, uppercase-stem and all-caps aliases on +//! spec. +//! +//! That second bucket is convention-matching, not proof — a new vendor's object +//! file can always name a spelling nobody predicted. The complete fix is a +//! case-insensitive *view* of the tree (a FUSE mount, or `mkdir --casefold` on +//! ext4/f2fs), not a symlink farm; this is the portable approximation. +//! +//! Two details make the aliases actually resolve. Links are created in the +//! **real** parent directory one path component at a time, because lookup is +//! per-directory — `GL/GL.h` against a real `gl/gl.h` needs a link at both +//! levels. And `#include` spellings resolve against each **search root**, not +//! against the tree above them, so `DriverSpecs.h` has to be matched inside +//! `shared/` rather than as `shared/driverspecs.h` one level up. +//! +//! Pinned to Zig 0.15.2 std APIs. + +const std = @import("std"); +const builtin = @import("builtin"); + +/// Whether this pass is needed at all. Windows and (by default) macOS fold case +/// in the filesystem, so the aliases would be pure noise there. +pub const host_needs_aliases = builtin.os.tag != .windows and builtin.os.tag != .macos; + +/// Where the interesting directories live, relative to the tree root. Every +/// path is optional: missing ones are skipped, so one layout can describe +/// several SDK versions. +pub const Layout = struct { + /// Trees where every mixed-case entry gets an all-lowercase alias, + /// recursively. + lowercase_trees: []const []const u8 = &.{}, + /// Trees whose headers are read to collect referenced spellings. + scan_trees: []const []const u8 = &.{}, + /// Directories that `#include` spellings resolve against. + include_roots: []const []const u8 = &.{}, + /// Directories whose *immediate subdirectories* are library search roots — + /// SDK libraries sit one `/` level down. + lib_root_parents: []const []const u8 = &.{}, +}; + +/// Symlink every spelling the tree uses onto the file that really exists. +/// Allocations live for the whole pass; hand it an arena. +pub fn apply(alloc: std.mem.Allocator, root: std.fs.Dir, layout: Layout) !void { + for (layout.lowercase_trees) |sub| { + var dir = openIter(root, sub) orelse continue; + defer dir.close(); + try aliasLowercaseTree(alloc, dir); + } + + var includes: std.StringHashMapUnmanaged(void) = .{}; + var libs: std.StringHashMapUnmanaged(void) = .{}; + for (layout.scan_trees) |sub| { + var dir = openIter(root, sub) orelse continue; + defer dir.close(); + try scanTree(alloc, dir, &includes, &libs); + } + + for (layout.include_roots) |sub| { + var dir = openIter(root, sub) orelse continue; + defer dir.close(); + try aliasSpellingsIn(alloc, dir, includes); + } + + for (layout.lib_root_parents) |sub| { + var parent = openIter(root, sub) orelse continue; + defer parent.close(); + var it = parent.iterate(); + while (try it.next()) |entry| { + if (entry.kind != .directory) continue; + var dir = openIter(parent, entry.name) orelse continue; + defer dir.close(); + try aliasSpellingsIn(alloc, dir, libs); + try aliasLibCaseVariants(alloc, dir); + } + } +} + +fn openIter(root: std.fs.Dir, sub_path: []const u8) ?std.fs.Dir { + return root.openDir(sub_path, .{ .iterate = true }) catch null; +} + +/// Maps the lowercased form of every real path under a directory to the real +/// one, so a differently-cased spelling can be resolved back to its file. +const CaseMap = struct { + alloc: std.mem.Allocator, + entries: std.StringHashMapUnmanaged([]const u8) = .{}, + + fn build(alloc: std.mem.Allocator, dir: std.fs.Dir) !CaseMap { + var self: CaseMap = .{ .alloc = alloc }; + var walker = try dir.walk(alloc); + defer walker.deinit(); + while (try walker.next()) |entry| { + // Skip symlinks: aliases from an earlier run, and following them + // would walk the same subtree twice. + if (entry.kind == .sym_link) continue; + const real = try alloc.dupe(u8, entry.path); + const lower = try std.ascii.allocLowerString(alloc, real); + const gop = try self.entries.getOrPut(alloc, lower); + if (!gop.found_existing) gop.value_ptr.* = real; + } + return self; + } + + fn realPath(self: CaseMap, spelling: []const u8) !?[]const u8 { + const lower = try std.ascii.allocLowerString(self.alloc, spelling); + defer self.alloc.free(lower); + return self.entries.get(lower); + } +}; + +/// Make `spelling` resolve to `real_rel` under `dir` by symlinking each path +/// component that is spelled differently. Idempotent. +fn aliasSpelling(alloc: std.mem.Allocator, dir: std.fs.Dir, real_rel: []const u8, spelling: []const u8) !void { + var real_parts = std.mem.tokenizeScalar(u8, real_rel, '/'); + var spell_parts = std.mem.tokenizeScalar(u8, spelling, '/'); + var prefix: []const u8 = ""; + while (real_parts.next()) |real_part| { + const spell_part = spell_parts.next() orelse return; + if (!std.mem.eql(u8, real_part, spell_part)) { + const link = if (prefix.len == 0) + spell_part + else + try std.fs.path.join(alloc, &.{ prefix, spell_part }); + dir.symLink(real_part, link, .{}) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + } + prefix = if (prefix.len == 0) + real_part + else + try std.fs.path.join(alloc, &.{ prefix, real_part }); + } +} + +/// Alias each referenced spelling that doesn't already resolve, relative to one +/// header / library search root. +fn aliasSpellingsIn( + alloc: std.mem.Allocator, + dir: std.fs.Dir, + spellings: std.StringHashMapUnmanaged(void), +) !void { + const map = try CaseMap.build(alloc, dir); + var it = spellings.keyIterator(); + while (it.next()) |key| { + const spelling = key.*; + if (std.fs.path.isAbsolute(spelling)) continue; + const normalized = try alloc.dupe(u8, spelling); + std.mem.replaceScalar(u8, normalized, '\\', '/'); + if (std.mem.indexOf(u8, normalized, "..") != null) continue; + const real = (try map.realPath(normalized)) orelse continue; + if (std.mem.eql(u8, real, normalized)) continue; + try aliasSpelling(alloc, dir, real, normalized); + } +} + +/// Give every mixed-case entry under `dir` an all-lowercase alias — the +/// spelling clang and lld-link reach for by default. +fn aliasLowercaseTree(alloc: std.mem.Allocator, dir: std.fs.Dir) !void { + const Entry = struct { name: []const u8, is_dir: bool }; + var entries: std.ArrayList(Entry) = .empty; + + var it = dir.iterate(); + while (try it.next()) |e| { + if (e.kind == .sym_link) continue; + try entries.append(alloc, .{ + .name = try alloc.dupe(u8, e.name), + .is_dir = e.kind == .directory, + }); + } + + for (entries.items) |e| { + const lower = try std.ascii.allocLowerString(alloc, e.name); + if (!std.mem.eql(u8, lower, e.name)) { + dir.symLink(e.name, lower, .{}) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + } + if (e.is_dir) { + var sub = try dir.openDir(e.name, .{ .iterate = true }); + defer sub.close(); + try aliasLowercaseTree(alloc, sub); + } + } +} + +/// The guessed half: `/DEFAULTLIB:` directives live in binary object members, +/// so no header scan can see them. Alias the spellings such a directive can +/// plausibly hold. +fn aliasLibCaseVariants(alloc: std.mem.Allocator, dir: std.fs.Dir) !void { + var names: std.ArrayList([]const u8) = .empty; + var it = dir.iterate(); + while (try it.next()) |e| { + if (e.kind != .file) continue; + try names.append(alloc, try alloc.dupe(u8, e.name)); + } + + for (names.items) |name| { + const dot = std.mem.lastIndexOfScalar(u8, name, '.') orelse continue; + const upper_stem = try std.ascii.allocUpperString(alloc, name[0..dot]); + const lower_ext = try std.ascii.allocLowerString(alloc, name[dot..]); + const variants = [_][]const u8{ + try std.ascii.allocLowerString(alloc, name), + try std.mem.concat(alloc, u8, &.{ upper_stem, lower_ext }), + try std.ascii.allocUpperString(alloc, name), + }; + for (variants) |variant| { + if (std.mem.eql(u8, variant, name)) continue; + dir.symLink(name, variant, .{}) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + } + } +} + +fn isHeader(name: []const u8) bool { + for ([_][]const u8{ ".h", ".H", ".hpp", ".inl", ".idl" }) |ext| { + if (std.mem.endsWith(u8, name, ext)) return true; + } + return false; +} + +/// Read every header under `dir` and gather the spellings it references. +fn scanTree( + alloc: std.mem.Allocator, + dir: std.fs.Dir, + includes: *std.StringHashMapUnmanaged(void), + libs: *std.StringHashMapUnmanaged(void), +) !void { + // Header bodies are large and only needed while scanning; keep them off the + // long-lived arena. + var scratch = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer scratch.deinit(); + + var walker = try dir.walk(alloc); + defer walker.deinit(); + while (try walker.next()) |entry| { + if (entry.kind != .file or !isHeader(entry.basename)) continue; + const text = dir.readFileAlloc(scratch.allocator(), entry.path, 8 << 20) catch continue; + try scanQuoted(alloc, text, "#include", includes); + try scanQuoted(alloc, text, "comment(lib", libs); + _ = scratch.reset(.retain_capacity); + } +} + +/// Collect what follows `marker` between `<…>` or `"…"`. +fn scanQuoted( + alloc: std.mem.Allocator, + text: []const u8, + marker: []const u8, + out: *std.StringHashMapUnmanaged(void), +) !void { + var i: usize = 0; + while (std.mem.indexOfPos(u8, text, i, marker)) |pos| { + i = pos + marker.len; + var j = i; + while (j < text.len and (text[j] == ' ' or text[j] == '\t' or text[j] == ',')) j += 1; + if (j >= text.len) break; + const closer: u8 = switch (text[j]) { + '<' => '>', + '"' => '"', + else => continue, + }; + const start = j + 1; + const end = std.mem.indexOfScalarPos(u8, text, start, closer) orelse continue; + // A quote that never closes on this line isn't an include directive. + if (std.mem.indexOfScalarPos(u8, text, start, '\n')) |nl| { + if (nl < end) continue; + } + const spelling = text[start..end]; + if (spelling.len == 0 or spelling.len > std.fs.max_path_bytes) continue; + const gop = try out.getOrPut(alloc, spelling); + if (!gop.found_existing) gop.key_ptr.* = try alloc.dupe(u8, spelling); + i = end + 1; + } +} From 9983d2be431e8ae287fffc292344d833dce4c3fd Mon Sep 17 00:00:00 2001 From: Michael Belousov Date: Mon, 7 Sep 2026 21:37:43 -0400 Subject: [PATCH 8/8] Run CI once per change, on a macOS Zig 0.15.2 can build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `push` on every branch duplicated the `pull_request` run; restrict it to main and cancel superseded runs. macos-latest is macOS 26, whose SDK Zig 0.15.2 cannot link against — it failed building its own build runner, before this package was reached. Pinned to macos-15. --- .github/workflows/package.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 33df36f..8de9e31 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -1,13 +1,21 @@ name: package on: + # A PR already runs on `pull_request`; running on every branch push too just + # pays for the same check twice. push: + branches: [main] pull_request: workflow_dispatch: permissions: contents: read +# A new push supersedes the run before it. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: package: name: ${{ matrix.os }} @@ -15,7 +23,10 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + # macos-latest is macOS 26, whose SDK Zig 0.15.2 cannot link against — + # it fails while building its own build runner, before this package is + # reached. Unpin once we are on a Zig that supports it. + os: [ubuntu-latest, windows-latest, macos-15] steps: - uses: actions/checkout@v7