diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 0000000..8de9e31 --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,45 @@ +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 }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # 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 + + # 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/.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..3403630 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,12 @@ 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. -The Velopack release version is `0.0.1589-ga2c5a97`. +Velopack's CLI is a .NET tool, so the [.NET SDK](https://dotnet.microsoft.com/download) +must be installed to package. -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. +***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 @@ -19,124 +19,7 @@ system to use this package. **`main` targets Zig 0.15.2** (`build.zig.zon` `minimum_zig_version`). -## What this package provides - -- **`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. - -- **`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. - -- **`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. - -- **`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`). - -- **`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. - -- **`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. - -- **`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). - -### 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. - -## Prerequisites - -| 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. | - -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. - -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`). - -## Packaging overview (all platforms) - -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. - -`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. - -## Windows and Linux packaging - -### Windows - -- 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`). - -### Linux - -- 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). - -## MSVC bootstrap: `msvcup-setup` - -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. - -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. - -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 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 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. - -### 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 - }, - // … -}, -``` - -`build.zig` (minimal linking + `vpk pack` + Linux `mksquashfs`; adjust paths and install dirs to your app): +## Usage ```zig const std = @import("std"); @@ -146,6 +29,10 @@ pub fn build(b: *std.Build) !void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + // 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", "Opt-in to installing Velopack dotnet CLI") orelse false; + const exe = b.addExecutable(.{ .name = "myapp", .root_module = b.createModule(.{ @@ -156,59 +43,66 @@ pub fn build(b: *std.Build) !void { }); exe.linkLibC(); - try velopack.linkVelopack(b, exe, .{ .target = target }); + velopack.linkVelopack(b, exe, .{ .target = target, .optimize = optimize }); - 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"); + const staging = b.addWriteFiles(); + _ = staging.addCopyFile(exe.getEmittedBin(), if (target.result.os.tag == .windows) "myapp.exe" else "myapp"); - try velopack.attachMksquashfsToVpkRun(b, vpk, target); - - vpk.step.dependOn(&exe.step); - - const install_pkg = b.addInstallDirectory(.{ - .source_dir = out, - .install_dir = .bin, - .install_subdir = "", + const packaged = velopack.addVelopackedAppDir(b, .{ + .install_dir = .prefix, + .install_subdir = "desktop", + .pack_opts = .{ + .name = "myapp", + .version = "1.0.0", + .target = target, + .source_dir = staging.getDirectory(), + .icon = b.path("art/icon.png"), + .install_vpk = install_vpk, + }, }); - install_pkg.step.dependOn(&vpk.step); - const desktop = b.step("desktop", "Build + pack desktop bundle"); - desktop.dependOn(&install_pkg.step); + b.step("package", "Build the release bundle").dependOn(&packaged.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 -}); -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); - } -} +`zig build package -Dinstall-vpk=true` writes setup executables, the `.nupkg` +and the `RELEASES` feed to `zig-out/desktop/`. -const msvcup_step = b.step("msvcup-setup", "MSVC + WinSDK → zig-libc-*.ini"); -msvcup_step.dependOn(&velopack.addMsvcupSetupStep(b, null).step); -``` - -## License +### Windows ABI note -MIT — see `LICENSE`. +Velopack's Windows prebuilt is **MSVC**. Use `x86_64-windows-msvc` or +`aarch64-windows-msvc` (not `*-windows-gnu`). + +## Customizing the `vpk` command + +`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" }`. +- `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. + +## 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): + [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 1e31d53..d5dd561 100644 --- a/build.zig +++ b/build.zig @@ -1,34 +1,14 @@ -//! velopack-zig — shared Velopack packaging glue for Zig projects. -//! -//! Public API (call from a consumer's build.zig): -//! -//! const velopack = @import("velopack_zig"); -//! -//! try velopack.linkVelopack(b, exe, .{ .target = target }); -//! -//! // 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); -//! -//! // *-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. -//! -//! 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"); +/// 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 +24,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,19 +71,38 @@ 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; 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", @@ -123,7 +129,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 +140,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 +158,268 @@ 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, + }; + } +}; + +/// 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, + /// `--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, 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. + /// + /// 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 + /// 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 { + pack_opts: VelopackOptions, + /// 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 { + 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.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`. +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_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. + 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 +434,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 +450,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 +479,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 +526,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 +543,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 +554,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 +572,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 +603,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/test/package.sh b/test/package.sh new file mode 100755 index 0000000..ae213ae --- /dev/null +++ b/test/package.sh @@ -0,0 +1,32 @@ +#!/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" + +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..11d72df --- /dev/null +++ b/test/sample/build.zig @@ -0,0 +1,58 @@ +//! 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(.{ + // 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; + + 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, .{ + .install_dir = .prefix, + .install_subdir = "desktop", + .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); +} 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", .{}); +} diff --git a/tools/gen_zig_libc_msvc.zig b/tools/gen_zig_libc_msvc.zig index febae2e..c4ecbc6 100644 --- a/tools/gen_zig_libc_msvc.zig +++ b/tools/gen_zig_libc_msvc.zig @@ -2,10 +2,19 @@ //! SDK tree. Picks the highest-versioned subdirectory under each well-known //! folder so SDK / MSVC bumps don't require re-pinning. //! +//! 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 case_aliases = @import("symlink-bad-cased-include-tree.zig"); + +/// 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); @@ -31,6 +40,11 @@ fn pickLatestSubdirName(alloc: std.mem.Allocator, parent: std.fs.Dir) ![]const u return best orelse error.Empty; } +fn exists(dir: std.fs.Dir, sub_path: []const u8) bool { + dir.access(sub_path, .{}) catch return false; + return true; +} + pub fn main() !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); @@ -58,6 +72,32 @@ pub fn main() !void { defer msvc_root.close(); const msvc_ver = try pickLatestSubdirName(alloc, msvc_root); + if (case_aliases.host_needs_aliases and !exists(root, case_alias_marker)) { + std.debug.print("Normalizing SDK filename case (once per install)...\n", .{}); + 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" }); 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/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; + } +} 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); +}