diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..8e07c4c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,165 @@ +# Build and test lvpm on each platform's own runner, publish the executables, +# and run lvpm against LabVIEW for Linux in NI's container (docs/linux.md). +name: build + +# Every branch, so a feature branch is tested before it is merged. Tags are +# left out. +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +# A newer push to a branch makes its running build moot. On main each commit +# gets a group of its own, so none is cancelled or dropped from the queue. +concurrency: + group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.sha || github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + # Windows 64-bit: the MSVC build the releases ship + - os: windows-latest + target: x86_64-pc-windows-msvc + name: windows-x64 + exe: lvpm.exe + + # Linux 64-bit, static: NI's image is Ubuntu 22.04, older than the + # runner's glibc + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + name: linux-x64 + exe: lvpm + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install musl tools + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y musl-tools + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo index + uses: actions/cache@v4 + with: + path: ~/.cargo/git + key: ${{ runner.os }}-cargo-git-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache target directory + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-${{ matrix.target }}-target-${{ hashFiles('Cargo.lock') }} + + # On the runner's own platform: Windows tests on Windows. + - name: Unit tests + run: cargo test + + - name: Build release + run: cargo build --release --target ${{ matrix.target }} + env: + CC_x86_64_unknown_linux_musl: musl-gcc + + - name: Upload executable + uses: actions/upload-artifact@v4 + with: + name: lvpm-${{ matrix.name }} + path: target/${{ matrix.target }}/release/${{ matrix.exe }} + if-no-files-found: error + + # Both executables in one download, named for the version and commit, to + # hand to testers. The Linux one is a tarball: an artifact zip does not keep + # the executable bit. + collect: + needs: build + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download executables + uses: actions/download-artifact@v4 + with: + path: executables + + - name: Name them + run: | + version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1) + mkdir dist + cp executables/lvpm-windows-x64/lvpm.exe "dist/lvpm-$version-windows-x64.exe" + chmod +x executables/lvpm-linux-x64/lvpm + tar -czf "dist/lvpm-$version-linux-x64.tar.gz" -C executables/lvpm-linux-x64 lvpm + echo "BUNDLE=lvpm-$version-${GITHUB_SHA::7}" >> "$GITHUB_ENV" + ls -l dist + + - name: Upload combined artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.BUNDLE }} + path: dist/ + if-no-files-found: error + + linux-e2e: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download the Linux executable + uses: actions/download-artifact@v4 + with: + name: lvpm-linux-x64 + path: dist + + # Pinned, so a new image is a change someone makes on purpose. + - name: lvpm against LabVIEW 2026 for Linux + run: LVPM=dist/lvpm scripts/linux-e2e.sh nationalinstruments/labview:2026q3-linux + + linux-packages: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download the Linux executable + uses: actions/download-artifact@v4 + with: + name: lvpm-linux-x64 + path: dist + + - name: Public packages on LabVIEW 2026 for Linux + run: LVPM=dist/lvpm OUT=out scripts/linux-packages.sh nationalinstruments/labview:2026q3-linux + + - name: Upload logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: linux-packages-logs + path: out diff --git a/README.md b/README.md index c8551c5..a4a51e3 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,10 @@ Not affiliated with JKI or NI. Working, in active development; see ## Install lvpm -Windows 10/11 with LabVIEW 2020 or later. Linux target detection exists but -is untested. +Windows 10/11 with LabVIEW 2020 or later. On Linux, lvpm is verified against +LabVIEW 2026 Q3 in NI's container image; take `lvpm--linux-x64.tar.gz` +from the artifacts of a CI run, or build it from source, and see +[docs/linux.md](docs/linux.md) for what differs there. ### With the install script @@ -359,6 +361,11 @@ differently in the container than on your machine, `--hooks` is the first thing to try; it starts a headless LabVIEW for them. `--relink` likewise when the job's output *is* relinked packages. +NI's Linux image (`nationalinstruments/labview:2026q3-linux`) works the same +way; [docs/linux.md](docs/linux.md) has the differences, and +[.github/workflows/build.yml](.github/workflows/build.yml) runs lvpm in it on +every push. + Two container facts worth knowing. LabVIEW allows one mode at a time per machine, headless or IDE, so a headless job cannot share a host with an open IDE. And any LabVIEW that is started (by `--hooks`, `--relink`, or the job @@ -398,7 +405,9 @@ fetches them again. but runs to completion reports as ok. - A failure part-way through unpacking leaves files behind with no manifest, and one failed download aborts the rest of the plan. -- Windows is the tested platform. +- Windows is the tested platform. Linux is verified against LabVIEW 2026 Q3 + in NI's container image only, and does not yet honour a package's + `Exclusive_OS` ([docs/linux.md](docs/linux.md)). - In a venv, install hooks are extracted but never run (they act on the LabVIEW installation, not on an overlay), and file groups aimed at machine locations (``, ``) are skipped. Both are recorded in the @@ -426,6 +435,8 @@ fetches them again. | `version.rs` | Package version ordering: OGPT `version-release`, VIPM's four-part form; not semver | | `src/lv-src/relink-package.vi` | The relink VI embedded into the executable at build time (LabVIEW 2020) | | `scripts/install.ps1` | Download, verify and install the latest release | +| `scripts/linux-e2e.sh` | End-to-end check against LabVIEW for Linux in NI's container image | +| `scripts/linux-packages.sh` | Public packages on LabVIEW for Linux: install with hooks, relink, uninstall, venv; the LabVIEW tree must come out unchanged | ## License diff --git a/docs/linux.md b/docs/linux.md new file mode 100644 index 0000000..39e1ead --- /dev/null +++ b/docs/linux.md @@ -0,0 +1,174 @@ +# LabVIEW on Linux + +lvpm builds and runs on Linux, and drives LabVIEW for Linux the way it drives +the Windows IDE: files copied, then relinked and hooks run over VI Server. +Everything below was measured on 2026-09-26 against NI's container image +`nationalinstruments/labview:2026q3-linux` (LabVIEW 2026 Q3, Ubuntu 22.04). +[scripts/linux-e2e.sh](../scripts/linux-e2e.sh) repeats every check in a +throwaway container in about a minute and a half, and [CI](#ci) runs it. + +## Building + +```console +$ cargo build --release # target/release/lvpm, for this machine's glibc +$ cargo test +$ rustup target add x86_64-unknown-linux-musl +$ CC_x86_64_unknown_linux_musl=musl-gcc cargo build --release --target x86_64-unknown-linux-musl +``` + +Nothing in the dependency tree needs OpenSSL: reqwest's `default-tls` is +rustls on aws-lc, which needs only a C compiler (`musl-gcc` for the static +build). A binary built on a current distribution does not start in NI's image +(`GLIBC_2.38 not found`; the image has 2.35), so the static musl build is the +one to ship. + +### Windows stays as it was + +The platform is chosen when lvpm is compiled, not when it runs: each +difference below is a `#[cfg(windows)]` / `#[cfg(target_os = "linux")]` +pair, or `#[cfg(not(windows))]` where the Linux half holds on any POSIX +system (separators, line ends, the per-user cache), and the Windows half is +the code that was there before. Other platforms do not build (see the +roadmap's macOS section). What a Windows user can +see changed is small: the "VI Server is disabled" and "no LabVIEW.exe" +messages name the full path of the file, and a `Target Dir` such as +`/addons/Foo` now expands to `...\vi.lib\addons\Foo` instead of +`...\vi.lib\addons/Foo` (the same path either way). Tests that pin +Windows behaviour are `cfg(windows)`, with Linux counterparts, and run on a +Windows runner in [CI](#ci). + +## What differs + +| | Windows | Linux, measured | lvpm on Linux | +|---|---|---|---| +| Installation | registry; `LabVIEW.exe` | `/usr/local/natinst/LabVIEW--64`, Q1 and Q3 of a year sharing it; `/etc/natinst/labview--64` links to its `etc`, whose `labview.dir` names it. `labview` in it is a symlink to the edition's binary (`labviewprofull`) | reads `/etc/natinst`, scans `/usr/local/natinst`, requires `labview` | +| Internal version | registry key (`26.3`) | not in the directory name; `readme/UNINSTALL` in the install sets `LV_MAJOR_VER=26`, `LV_MINOR_VER=3` | reads it; the year when it is missing | +| Preferences | `LabVIEW.ini` beside the exe | `~/natinst/.config/LabVIEW-/.conf`, per user, named after the name LabVIEW was started as (`labview.conf` for `labview`). Absent until the user's first launch; LabVIEW adds the `[LabVIEW]` header and writes LF | reads `labview.conf`; a venv ini starts from `[LabVIEW]` when there is none | +| VI Server default | on | **off**: without `server.tcp.enabled=True` LabVIEW never listens. NI's image seeds all three `.conf` files with it | absent means off, and the error names the file, instead of a 120 s wait | +| VI Server port | 3363, 3364 on 2026 | 3363 on 2026 Q3 | read from the `.conf` | +| Protocol | | unchanged: handshake, error 63 until initialised, open, run, save, control values | | +| Paths on the wire (`PTH0`) | drive letter first | no drive and no root component: `/usr/local` is `usr`, `local` | decoded to `/usr/local/...`, not `usr\local\...` | +| Relink folder | `C:\...` | `/...`. Handed the backslashed form, the relink VI walks nothing and reports success: 0 items where the same folder gives 2 | sent as it is | +| Palette and menu refresh | two shipping VIs | the same VIs at the same relative paths | joined a component at a time (a `\` inside one `join` separates only on Windows) | +| `` tokens | environment variables | what `Get System Directory.vi` answers (below) | the same table | +| Records of a global install | `%ProgramData%\lvpm` | `/var/lib/lvpm//` | (was a `C:\ProgramData` directory under the working directory) | +| Index cache | `%LOCALAPPDATA%\lvpm\cache` | `$XDG_CACHE_HOME/lvpm`, else `~/.cache/lvpm` | | +| Relink VI, staged PostUninstall | `%TEMP%` | `/tmp` is shared, and a name another user wrote first is one this user cannot write | the per-user cache | +| Target Dir tails | either separator | `\addons\Foo` would be one directory named `addons\Foo` | split on both | +| Headless | `LV_RTE_HEADLESS=1` (2026 and later) | the same switch, also from 2026. LabVIEW starts its own `Xvfb :99` and needs no `DISPLAY`; the log is `/tmp/labview__headless__cur.txt` | the hint names it | +| `-pref ` | yes | yes: a second instance on its own ini and port runs beside the first and shares its Xvfb. The Linux binary has no `AllowMultipleInstances` key | unchanged | +| LVAddons | `C:\Program Files\NI\LVAddons` | `/usr/local/natinst/share/lvaddons` (NI's VI Analyzer support ships there). `LVAddons.AdditionalLocations` is honoured | the venv path goes with forward slashes | +| Restarting on a port | immediate | **a LabVIEW started while its port holds a TIME_WAIT connection (up to 60 s after the last one closed) never serves VI Server**, and never retries | waits for the port, 65 s at most, before starting LabVIEW | +| Ctrl+C | IDE detached | a child in lvpm's process group gets the terminal's SIGINT | LabVIEW gets a process group of its own | + +### `` tokens + +The tokens are the directory types of LabVIEW's `Get System Directory.vi` by +name. lvpm resolves them to what that VI returned over VI Server on Linux, run +as root: + +| Token | Linux | +|---|---| +| `` | `$HOME/Documents` | +| `` | `$HOME/Desktop` | +| `` | `$HOME` | +| ``, ``, `` | `/usr/local` | +| `` | `/usr/lib` | +| `` | `/` | +| `` | `/tmp` | + +The VI's remaining types, for reference: User Preferences `$HOME`, Public +Preferences `/etc`, System Installed Libraries `/usr/local/lib`, Public Cache +`/var/cache`. + +### LabVIEWCLI + +LabVIEWCLI on Linux wants `-LabVIEWPath`, the operation name first and +`-Headless` last; given `-Headless` in between, it prints its usage and exits +0. A clean mass compile says `MassCompile operation succeeded` and exits 0; a +broken VI shows as `### Bad VI:` in the log, with exit code 3. For each +project in the folder, the compile writes `.aliases` and +`..UserState/` beside it. + +## Verified + +With `scripts/linux-e2e.sh`, all in one container: + +- `lvpm targets` reports `LabVIEW 2026 (64-bit) v26.3`. +- A headless `lvpm install --relink --hooks` in a project with no venv starts + LabVIEW, runs PreInstall, copies into `vi.lib` and `$HOME/Documents`, + relinks and runs PostInstall. Records land in `/var/lib/lvpm`. +- `list`, `refresh` (both VIs run), `relink --all`, `run-hooks` and + `uninstall --all` work, and the uninstall prunes what it created. +- `vi-save` rewrites a LabVIEW 2020 VI as 26.3. A caller whose subVI moved is + found, relinked and saved with a `` link. +- In a venv: `venv create`, then `install --relink` in a LabVIEW started with + `-pref` on the venv's port, then `launch` straight after. The overlay works: + a VI linked to `/lvpm_linux_test/...` runs on the venv's LabVIEW and + is broken (error 1003) on a fresh plain one. + +By hand: as a non-root user with no `.conf`, `lvpm start` stops at once with +the "VI Server is disabled" message, and the whole venv flow works without +root. + +## CI + +[.github/workflows/build.yml](../.github/workflows/build.yml), on every push +to any branch, on every pull request (which tests the merge with its base), +and by hand. A newer push to a branch other than `main` cancels its run still +in progress. + +- **build** — one job per platform on its own runner: `windows-latest` + (`x86_64-pc-windows-msvc`) and `ubuntu-latest` (static + `x86_64-unknown-linux-musl`). Each runs `cargo test` and a release build, + and uploads its executable. +- **collect** — puts both executables in one artifact, + `lvpm--`, for handing to testers. +- **linux-e2e** — `scripts/linux-e2e.sh` with the Linux executable, in + `nationalinstruments/labview:2026q3-linux` (pinned). +- **linux-packages** — `scripts/linux-packages.sh` in the same image, with + LUnit and G-Image from the public indexes: a headless `install --hooks` + (G-Image needs its PostInstall) in which every hook must run and every + recorded file must be on disk, `relink --all`, an uninstall after which no + file in the LabVIEW tree is added, gone or changed, and the same set + relinked in a venv, which must leave the LabVIEW tree alone. Its logs are + uploaded. + + There is no mass compile. A bad VI in a third-party package (LUnit ships + one on purpose) or in LabVIEW's own templates says nothing about lvpm, and + the compile writes project files into the tree the uninstall check + compares. + +## Not done + +What Linux still needs, most important first. + +1. **OS gates on packages.** lvpm ignores `Exclusive_OS`, both in the index + (`Platform.Exclusive_OS`) and per file group, so on Linux it would install + a package that declares itself Windows-only. LabVIEW 2026 for Linux ships + NI's Wine layer (`ni-wine`, `ni-wine-dotnet-runtime-80`, + `ni-dotnetcore-interface`); what that means for .NET nodes is not measured. +2. **Root.** The installation is owned by root. A global install needs write + access to it, and the relink and hooks run in a LabVIEW that has to save + into it. Under `sudo`, Ubuntu sets `$HOME` to root's, so lvpm reads root's + `labview.conf` and starts LabVIEW as root. A venv needs neither. lvpm should + check write access up front and say so. +3. **Case.** Linux is case-sensitive; much of what lvpm and packages were + written against is not. `install.rs` looks a manifest up by the name as + typed, so `uninstall OGLib_Error` misses. Archive entries are matched + case-insensitively but written in the spec's case. `venv::port_for` + lowercases the path, so two repos that differ only in case share a port. + And a VI that links a subVI in a different case does not load on Linux. +4. **Releases.** CI builds both executables; a tagged release still carries + only Windows, and there is no `install.sh` to match `install.ps1`. +5. **Not measured.** LabVIEW for Linux before 2026 (the `.conf` directory + name, `readme/UNINSTALL`, `LVAddons.AdditionalLocations` from 2024 Q1 on); + other distributions (RHEL, openSUSE); Community edition; an interactive IDE + on a real display. Headless mode arrived with LabVIEW 2026: the 2025 Q3 + binary has no `LV_RTE_HEADLESS` and no `--headless` (its image sets + `EnableCICDFeaturesForLabVIEW` instead), so lvpm cannot tell that a 2025 + machine is headless. +6. **Containers.** Start the container with `--init`. A LabVIEW that lvpm + started is reparented to PID 1, and `sleep infinity` never reaps it. The + TIME_WAIT rule applies to anything that restarts LabVIEW on a port within a + minute, LabVIEWCLI included. diff --git a/docs/roadmap.md b/docs/roadmap.md index 996ebc0..fd7a09b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -206,9 +206,80 @@ workflow is the work. lvpm's own tooling on packages, which is the reading of "modifying LabVIEW code" to settle before that step runs on shared infrastructure. +## Integration test VIs + +The container jobs test lvpm with packages it does not control: +`linux-e2e` with the VIs in `tools/`, `linux-packages` with LUnit and +G-Image as a reference. Neither was made to test an installer: LUnit ships a +VI broken on purpose, so `linux-packages` cannot ask whether a package's VIs +work, only whether lvpm put them in place and took them out again. A package +of VIs made for the job would test exactly what lvpm answers for, on Windows +and Linux alike: + +- a caller whose subVI sits in another folder of the package, which relink + must fix, and a caller outside the package that links into ``, for + the venv overlay; +- PreInstall and PostInstall hooks that write a marker file with what they + were handed (package name, files installed); today's hook VI only shows + that it ran. One of them does setup the package cannot work without, as + G-Image's does; +- a class, a library, a malleable VI, an LLB and a project, each once; +- a palette `.mnu`, an `` group and a Target Dir written + with backslashes; +- a file group into a folder LabVIEW itself uses, such as + `/Framework/Providers/`, and a file directly in such a + folder, as LUnit's provider library is, where relink must stay inside the + package's own files; +- nothing broken, so the job can run every VI (`vi-run`) and fail on any + error. + +Saved in LabVIEW 2020, like the relink VI, and zipped into a `.vip` by the +test scripts as today. + +## macOS + +Not supported, and a macOS build stops with a `compile_error!` in `main.rs`. +What is a fact about LabVIEW on Linux is `cfg(target_os = "linux")`; what is +true of any POSIX system is `cfg(not(windows))` or `cfg(unix)` and needs no +new branch. + +- **What carries over from Linux:** forward slashes, LF ini files, the + per-user cache, LabVIEW in its own process group, the VI Server client, + venvs. +- **What needs its own branch, each to be measured on a Mac with LabVIEW:** + the install location (likely `/Applications/National Instruments/LabVIEW + 64-bit/`), the executable inside the `.app` bundle, the preferences + file (likely under `~/Library/Preferences/`), the internal version (likely + the bundle's `Info.plist`), the `` tokens (run `Get System + Directory.vi`, as for Linux), `PTH0` paths (the first component may be the + volume name), where install records go, and whether a TIME_WAIT port blocks + VI Server as it does on Linux. +- **The obstacle is testing:** NI publishes no macOS container, so there is + no equivalent of `scripts/linux-e2e.sh` in CI without a macOS runner that + has a licensed LabVIEW. + ## Housekeeping - `Replace Mode = If Newer` compares timestamps instead of "write when absent". - Read hook VIs' `error out` back. - A failure part-way through unpacking leaves files behind with no manifest. -- Linux target detection exists but is untested. +- Relink saves LabVIEW's own files. The relink VI takes folders and walks + their subfolders too, and a package file that lies directly in a folder + LabVIEW fills puts that whole folder in the walk. LUnit's + `LUnit Project Provider.lvlib` lies in `resource/Framework/Providers`; a + single test VI there made relink save 44 of LabVIEW's files (Actor + Framework and AppBuilder providers), still changed after the uninstall. + `linux-packages` fails on this. Same code on Windows. Fix: a relink VI + that takes the package's files, or no walk of a folder that holds files + of others. +- Uninstall leaves the `.aliases` and `.UserState` files that LabVIEW writes + beside a package's project (a mass compile of its folder writes them), and + with them the package's folders. +- `relink --all` never marks a package with nothing to relink, while + `install --relink` does, so `lvpm list` shows such a package + `NOT relinked` for good. +- Linux: install, relink, hooks, refresh and venvs verified against LabVIEW + 2026 Q3 in NI's container ([linux.md](linux.md)), and run in CI + (`.github/workflows/build.yml`). Still open there: `Exclusive_OS` gates, + root ownership of the install tree, case sensitivity, a Linux release + artifact. diff --git a/docs/vi-server-protocol.md b/docs/vi-server-protocol.md index ee71e47..755d19b 100644 --- a/docs/vi-server-protocol.md +++ b/docs/vi-server-protocol.md @@ -146,7 +146,13 @@ encoder serves both the wire and any offline path rewriting. An absolute Windows path contributes its drive letter as the first component: `C:\Git\lvpm\tools\X.vi` → `01 "C" 03 "Git" 04 "lvpm" 05 "tools" 06 "X.vi"`. -The length field covers the count field plus the components. +The length field covers the count field plus the components. The count field +is really a `u16` kind (0 absolute, 1 relative, 2 not a path) and a `u16` +count; an absolute path's kind is 0, so the two read the same. + +On Linux there is no drive and no root component: LabVIEW 2026 Q3 for Linux +sends and takes `/usr/local/x.vi` as `03 "usr" 05 "local" 04 "x.vi"`, kind 0. +Decoded, such a record has to be rooted again, or it reads as a relative path. ## `GetVIRef` (3) diff --git a/scripts/linux-e2e.sh b/scripts/linux-e2e.sh new file mode 100755 index 0000000..9182569 --- /dev/null +++ b/scripts/linux-e2e.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# End-to-end check of lvpm against LabVIEW for Linux, in NI's container image. +# +# Builds a static lvpm, makes two test packages from the repo's own VIs in +# tools/, and drives every LabVIEW-facing path in a throwaway container: +# detection, a headless global install with relink and hooks, palette and menu +# refresh, relink, uninstall, and a venv install whose overlay is proven by a +# VI that links into running on the venv's LabVIEW and not on the +# plain one. No package index is contacted; everything comes from a local +# folder. See docs/linux.md for what each step is evidence of. +# +# scripts/linux-e2e.sh [image] default: nationalinstruments/labview:latest-linux +# +# Needs Docker, rustup's x86_64-unknown-linux-musl target, musl-gcc, python3; +# with LVPM set to a static lvpm, only Docker and python3. +set -euo pipefail + +IMAGE=${1:-nationalinstruments/labview:latest-linux} +ROOT=$(cd "$(dirname "$0")/.." && pwd) +WORK=$(mktemp -d) +NAME=lvpm-e2e-$$ +# The container writes into $WORK as root; it hands it back before it goes, or +# a CI runner's user could not remove it. +cleanup() { + docker exec "$NAME" chown -R "$(id -u):$(id -g)" /work >/dev/null 2>&1 || true + docker rm -f "$NAME" >/dev/null 2>&1 || true + rm -rf "$WORK" || true +} +trap cleanup EXIT + +# NI's image is Ubuntu 22.04 (glibc 2.35); a static binary runs there whatever +# the build host's glibc. +if [ -n "${LVPM:-}" ]; then + cp "$LVPM" "$WORK/lvpm" +else + CC_x86_64_unknown_linux_musl=musl-gcc \ + cargo build --release --target x86_64-unknown-linux-musl --manifest-path "$ROOT/Cargo.toml" + cp "$ROOT/target/x86_64-unknown-linux-musl/release/lvpm" "$WORK/lvpm" +fi +chmod +x "$WORK/lvpm" +# In a folder of its own: LabVIEW searches a missing subVI under the top-level +# VI's folder, and the venv's copy must not be found that way. +mkdir "$WORK/caller" +cp "$ROOT/tools/Test - Relink Packages E2E.vi" "$WORK/caller/caller.vi" + +python3 - "$ROOT/tools" "$WORK" <<'PY' +import sys, zipfile, os +tools, work = sys.argv[1], sys.argv[2] +os.makedirs(f"{work}/pkgs") + +def vip(name, spec, members): + with zipfile.ZipFile(f"{work}/pkgs/{name}-1.0.0.1.vip", "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("spec", f'[Package]\nName="{name}"\nVersion="1.0.0.1"\n\n' + spec) + for arc, src in members: + if src is None: + z.writestr(arc, "lvpm Linux test\n") + else: + z.write(f"{tools}/{src}", arc) + +# Files into and one location, plus both install hooks. +vip("lvpm_linux_test", '''[Script VIs] +PreInstall="PreInstall.vi" +PostInstall="PostInstall.vi" + +[File Group 0] +Target Dir="/lvpm_linux_test" +Replace Mode="Always" +Num Files=3 +File 0="Relink Packages.vi" +File 1="Test - Relink Packages E2E.vi" +File 2="E2E Call VI.vi" + +[File Group 1] +Target Dir="/lvpm_linux_test" +Replace Mode="Always" +Num Files=1 +File 0="readme.txt" +''', [ + ("File Group 0/Relink Packages.vi", "Relink Packages.vi"), + ("File Group 0/Test - Relink Packages E2E.vi", "Test - Relink Packages E2E.vi"), + ("File Group 0/E2E Call VI.vi", "E2E Call VI.vi"), + ("File Group 1/readme.txt", None), + ("PreInstall.vi", "E2E Call VI.vi"), + ("PostInstall.vi", "E2E Call VI.vi"), +]) + +# A caller and its subVI in two folders: what the relink walk must find. The +# second Target Dir is spelled with backslashes, as some specs do. +vip("lvpm_linux_relink_test", '''[File Group 0] +Target Dir="/lvpm_linux_relink_test" +Replace Mode="Always" +Num Files=1 +File 0="Test - Relink Packages E2E.vi" + +[File Group 1] +Target Dir="\\lvpm_linux_relink_test\\sub" +Replace Mode="Always" +Num Files=1 +File 0="Relink Packages.vi" +''', [ + ("File Group 0/Test - Relink Packages E2E.vi", "Test - Relink Packages E2E.vi"), + ("File Group 1/Relink Packages.vi", "Relink Packages.vi"), +]) + +for d, name in (("global", "linux-e2e-global"), ("venv", "linux-e2e-venv")): + os.makedirs(f"{work}/{d}") + open(f"{work}/{d}/lvpm.toml", "w").write(f'''[project] +name = "{name}" +labview = "2026" + +[sources] +local = "../pkgs" +defaults = false + +[dependencies] +lvpm_linux_test = "*" +''') +PY + +docker run -d --init --name "$NAME" -e LV_RTE_HEADLESS=1 -v "$WORK:/work" "$IMAGE" sleep infinity >/dev/null +docker exec -i "$NAME" bash -s <<'SH' +set -u +L=/work/lvpm +LV=$($L targets | awk '{print $NF; exit}') +fails=0 +check() { if eval "$2"; then echo "ok $1"; else echo "FAIL $1"; fails=$((fails + 1)); fi; } +listening() { awk 'NR>1 && $4=="0A" {print $2}' /proc/net/tcp | grep -qi ":$(printf '%04X' "$1")$"; } +run() { echo "\$ lvpm $*" >> /work/log; "$L" "$@" >> /work/log 2>&1; } + +check "targets: detects LabVIEW 2026 with its minor version" \ + '$L targets | grep -Eq "LabVIEW 2026 \(64-bit\) +v26\.[0-9] .*/usr/local/natinst/LabVIEW-2026-64"' + +cd /work/global +check "headless global install with --relink --hooks" 'run install --relink --hooks' +check "files in vi.lib and in (\$HOME/Documents)" \ + '[ -f "$LV/vi.lib/lvpm_linux_test/E2E Call VI.vi" ] && [ -f "$HOME/Documents/lvpm_linux_test/readme.txt" ]' +check "both hooks ran" 'grep -q "pre-install ok" /work/log && grep -Eq "lvpm_linux_test \.\.\. ok" /work/log' +check "install records kept in /var/lib/lvpm" '[ -f /var/lib/lvpm/LabVIEW-2026-64bit/installed/lvpm_linux_test.json ]' +check "a backslashed Target Dir lands in real subfolders" \ + 'run install lvpm_linux_relink_test --relink && [ -f "$LV/vi.lib/lvpm_linux_relink_test/sub/Relink Packages.vi" ]' +check "list shows both relinked" '[ "$($L list 2>/dev/null | grep -v "NOT relinked" | grep -c relinked)" = 2 ]' +check "run-hooks reruns a PostInstall" 'run run-hooks lvpm_linux_test --labview-version 2026' + +VI=$(ls "$HOME"/.cache/lvpm/lvpm-relink-package-*.vi) +check "relink VI walks a Linux folder (finds caller and subVI)" \ + '$L vi-run "$VI" --labview-version 2026 --set "Folder to relink=str:$LV/vi.lib/lvpm_linux_relink_test" --get "all lv items" 2>&1 | grep -q "sub/Relink Packages.vi"' +out=$($L refresh --labview-version 2026 2>&1) +check "refresh: both LabVIEW refresh VIs found and run" \ + 'grep -Eq "palettes +ok" <<<"$out" && grep -Eq "menus +ok" <<<"$out"' +check "relink --all" 'run relink --all' +check "vi-save of a caller relinks it to " 'run vi-save /work/caller/caller.vi --labview-version 2026' +check "uninstall --all leaves vi.lib clean" \ + 'run uninstall --all && ! ls "$LV/vi.lib" | grep -q lvpm_ && [ ! -e "$HOME/Documents/lvpm_linux_test" ]' + +# The plain LabVIEW has had the package's VIs in memory, and a caller links a +# subVI of the same name that is still loaded; only a fresh one is a fair +# control for the overlay check below. +pkill -x labview; for _ in $(seq 30); do pgrep -x labview >/dev/null || break; sleep 1; done + +cd /work/venv +check "venv create" 'run venv create' +check "venv install with relink in a LabVIEW started with -pref" 'run install --relink' +check "venv ini mounts the venv with forward slashes" \ + 'grep -qx "LVAddons.AdditionalLocations=/work/venv/.project" .project/.lvpm/labview.ini' +PORT=$(python3 -c 'import json; print(json.load(open(".project/.lvpm/venv.json"))["port"])') +run launch +for _ in $(seq 60); do listening "$PORT" && break; sleep 2; done +check "launch straight after install still gets VI Server (TIME_WAIT)" 'listening "$PORT"' +for _ in $(seq 30); do $L vi-probe "$LV/vi.lib/Utility/error.llb/General Error Handler.vi" >/dev/null 2>&1 && break; sleep 2; done +check "overlay: caller linked into runs on the venv's LabVIEW" \ + '$L vi-run /work/caller/caller.vi --timeout 60 >/dev/null 2>&1' +check "control: the same caller is broken on a fresh plain LabVIEW" \ + 'run start --global --labview-version 2026 && + $L vi-run /work/caller/caller.vi --global --labview-version 2026 --timeout 60 2>&1 | grep -q "error 1003"' + +echo +[ "$fails" = 0 ] && echo "all checks passed" || { echo "$fails check(s) failed; lvpm's output:"; cat /work/log; exit 1; } +SH diff --git a/scripts/linux-packages.sh b/scripts/linux-packages.sh new file mode 100755 index 0000000..44f81d0 --- /dev/null +++ b/scripts/linux-packages.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# Real packages on LabVIEW for Linux, in NI's container image. +# +# Installs LUnit and G-Image from the public indexes and then, in a throwaway +# container: +# 1. a headless `lvpm install --hooks`: G-Image's PostInstall has to run for +# its VIs to work, and every hook must have run; +# 2. every file in the install records must be on disk; +# 3. `lvpm relink --all` over the packages' folders; +# 4. `lvpm uninstall --all`, after which the LabVIEW tree must be as it was: +# no file added, gone or changed; +# 5. the same set into a venv with `--relink`, relinked in a LabVIEW started +# on the venv (a venv runs no hooks), which must leave the LabVIEW tree +# alone. +# +# Whether the packages' VIs are broken is not asked: a bad VI in a package, or +# in LabVIEW's own templates, says nothing about lvpm, and a mass compile +# writes into the tree the uninstall check compares (docs/linux.md). +# +# scripts/linux-packages.sh [image] default: nationalinstruments/labview:latest-linux +# +# Environment: +# LVPM a static lvpm to use instead of building one (see linux-e2e.sh) +# PACKAGES space-separated package names, instead of the list below +# SOURCE a repository (index URL or folder) to use instead of the public indexes +# OUT where the logs go (default: a temporary directory, removed afterwards) +# DOCKER_ARGS extra `docker run` arguments, e.g. a proxy +# +# Needs Docker and network access to the package sources. +set -euo pipefail + +IMAGE=${1:-nationalinstruments/labview:latest-linux} +ROOT=$(cd "$(dirname "$0")/.." && pwd) +PACKAGES=${PACKAGES:-"astemes_lib_lunit dataflow_g_lib_g_image"} +WORK=$(mktemp -d) +NAME=lvpm-packages-$$ +# The container writes into $WORK as root; it hands it back before it goes, or +# a CI runner's user could not remove it. +cleanup() { + docker exec "$NAME" chown -R "$(id -u):$(id -g)" /work >/dev/null 2>&1 || true + docker rm -f "$NAME" >/dev/null 2>&1 || true + rm -rf "$WORK" || true +} +trap cleanup EXIT + +if [ -n "${LVPM:-}" ]; then + cp "$LVPM" "$WORK/lvpm" +else + CC_x86_64_unknown_linux_musl=musl-gcc \ + cargo build --release --target x86_64-unknown-linux-musl --manifest-path "$ROOT/Cargo.toml" + cp "$ROOT/target/x86_64-unknown-linux-musl/release/lvpm" "$WORK/lvpm" +fi +chmod +x "$WORK/lvpm" + +sources="" +case "${SOURCE:-}" in + "") ;; + http://* | https://*) sources=$'[sources]\nsource = "'"$SOURCE"$'"\ndefaults = false\n' ;; + *) + cp -r "$SOURCE" "$WORK/source" + sources=$'[sources]\nsource = "../source"\ndefaults = false\n' + ;; +esac +for d in global venv; do + mkdir "$WORK/$d" + { + printf '[project]\nname = "linux-packages-%s"\nlabview = "2026"\n\n%s\n[dependencies]\n' "$d" "$sources" + for p in $PACKAGES; do printf '%s = "*"\n' "$p"; done + } > "$WORK/$d/lvpm.toml" +done +mkdir "$WORK/out" + +# shellcheck disable=SC2086 # DOCKER_ARGS is a list of arguments +docker run -d --init --name "$NAME" -e LV_RTE_HEADLESS=1 ${DOCKER_ARGS:-} \ + -v "$WORK:/work" "$IMAGE" sleep infinity >/dev/null +status=0 +docker exec -i "$NAME" bash -s <<'SH' || status=$? +set -u +L=/work/lvpm +O=/work/out +LV=$($L targets | awk '{print $NF; exit}') +# LabVIEWCLI wants the operation first and -Headless last: in between, the flag +# takes the next argument with it, and the CLI prints its usage and exits 0. +cli() { LabVIEWCLI -OperationName "$1" -LabVIEWPath "$LV/labview" "${@:2}" -Headless; } +fails=0 +check() { if eval "$2"; then echo "ok $1"; else echo "FAIL $1"; fails=$((fails + 1)); fi; } +tree() { find "$LV" -path "$LV/VIObjCache" -prune -o -print | sort; } +# The LabVIEW tree against snapshot $1: no name added or gone, and no file +# that was there written since. VIObjCache is LabVIEW's compile cache. +snapshot() { tree > $O/$1-before.txt; touch /tmp/$1.marker; sleep 1; } +untouched() { + tree > $O/$1-after.txt + diff $O/$1-before.txt $O/$1-after.txt > $O/$1-tree.diff + find "$LV" -path "$LV/VIObjCache" -prune -o -type f -newer /tmp/$1.marker -print | sort | + comm -12 - $O/$1-before.txt > $O/$1-changed.txt + [ ! -s $O/$1-tree.diff ] && [ ! -s $O/$1-changed.txt ] +} +show_changes() { + [ -s $O/$1-tree.diff ] && { echo " added or gone:"; head -20 $O/$1-tree.diff; } + [ -s $O/$1-changed.txt ] && { echo " changed: $(wc -l < $O/$1-changed.txt) file(s)"; head -20 $O/$1-changed.txt; } + true +} +close_labview() { + cli CloseLabVIEW >> $O/close.log 2>&1 || true + for _ in $(seq 30); do pgrep -x labview >/dev/null || break; sleep 1; done +} + +snapshot global +cd /work/global +check "headless install --hooks resolves and copies every package" '$L install --hooks > $O/install.log 2>&1' +cat $O/install.log +$L list > $O/list.txt 2>/dev/null; cat $O/list.txt +asked=$(sed -n '/^\[dependencies\]/,$p' lvpm.toml | grep -oE '^[A-Za-z0-9_.-]+' || true) +missing=$(for p in $asked; do grep -qi "^$p " $O/list.txt || echo "$p"; done) +check "lvpm list shows every package asked for" '[ -n "$asked" ] && [ -z "$missing" ]' +[ -n "$missing" ] && echo " missing: $missing" +check "every install hook ran" '! grep -q "hooks skipped" $O/list.txt' + +python3 - > $O/placement.txt <<'PY' +import glob, json, os +records = [json.load(open(r)) for r in sorted(glob.glob("/var/lib/lvpm/*/installed/*.json"))] +files = [(m["name"], f) for m in records for f in m["files"]] +print(f"{len(files)} file(s) in {len(records)} install record(s)") +for name, f in files: + if not os.path.lexists(f): + print(f"not on disk: {name}: {f}") +PY +head -1 $O/placement.txt +check "every file in the install records is on disk" \ + '! grep -q "^0 file" $O/placement.txt && ! grep -q "^not on disk" $O/placement.txt' +grep "^not on disk" $O/placement.txt | head -20 + +check "relink --all over the installed packages" '$L relink --all > $O/relink.log 2>&1' +grep -E "^\s+/|FAILED|saved" $O/relink.log | head -40 +check "lvpm list shows every package relinked" '! $L list 2>/dev/null | grep -q "NOT relinked"' +close_labview + +check "uninstall --all" '$L uninstall --all > $O/uninstall.log 2>&1' +# The uninstall hooks can start LabVIEW; what it writes as it closes counts too. +close_labview +check "the LabVIEW tree is as it was: no file added, gone or changed" 'untouched global' +show_changes global + +cd /work/venv +snapshot venv +check "venv create" '$L venv create > $O/venv.log 2>&1' +check "venv install --relink" '$L install --relink > $O/venv-install.log 2>&1' +grep -E "^\+|FAILED|relinking|skipped|not run" $O/venv-install.log | head -40 +check "every venv package relinked" \ + '$L list 2>/dev/null | grep -q " relinked" && ! $L list 2>/dev/null | grep -q "NOT relinked"' +check "the venv left the LabVIEW tree alone" 'untouched venv' +show_changes venv + +echo +[ "$fails" = 0 ] && echo "all checks passed" || { echo "$fails check(s) failed; logs in the OUT directory"; exit 1; } +SH +# The logs are world-readable: a copy needs no change of owner, and the OUT +# directory the caller named is left as it was. +if [ -n "${OUT:-}" ]; then + mkdir -p "$OUT" + cp -r "$WORK/out/." "$OUT/" + echo "logs: $OUT" +fi +exit "$status" diff --git a/src/launch.rs b/src/launch.rs index 8741a4b..f659d1e 100644 --- a/src/launch.rs +++ b/src/launch.rs @@ -2,7 +2,8 @@ //! //! LabVIEW reads its LVAddons locations once, at launch, from the ini file it //! was started with. So a venv is "activated" by starting a LabVIEW of its -//! own: a copy of the target's `LabVIEW.ini` with the venv added as an +//! own: a copy of the target's `LabVIEW.ini` (`labview.conf` on Linux, where +//! `-pref` works the same way) with the venv added as an //! `LVAddons.AdditionalLocations` entry and VI Server moved to the venv's //! port, handed over with `-pref`. Nothing in the installation changes, and //! the user's primary IDE — if one is open — keeps its own port and its own @@ -23,6 +24,18 @@ use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; +/// Where a headless LabVIEW writes what would have been a dialog. +#[cfg(windows)] +const HEADLESS_LOG: &str = "%TEMP%\\LabVIEW_*_headless_*_cur.txt"; +#[cfg(target_os = "linux")] +const HEADLESS_LOG: &str = "/tmp/labview_*_headless_*_cur.txt"; + +/// Line ending for the ini lvpm writes: LabVIEW's own on each platform. +#[cfg(windows)] +const EOL: &str = "\r\n"; +#[cfg(not(windows))] +const EOL: &str = "\n"; + /// Set `key=value` lines in an ini text: replace in place where the key exists /// (however it was spaced), append where it does not, and never write a key /// twice. Everything else is preserved as it was — the inherited ini is what @@ -49,14 +62,14 @@ pub fn override_keys(text: &str, keys: &[(&str, &str)]) -> String { Some(_) => continue, None => out.push_str(line), } - out.push_str("\r\n"); + out.push_str(EOL); } for (i, (k, v)) in keys.iter().enumerate() { if !seen[i] { out.push_str(k); out.push('='); out.push_str(v); - out.push_str("\r\n"); + out.push_str(EOL); } } out @@ -66,12 +79,22 @@ pub fn override_keys(text: &str, keys: &[(&str, &str)]) -> String { /// the venv mounted and VI Server moved to the venv's port. Regenerated every /// time, so the target's current settings are always what is inherited. pub fn write_ini(v: &Venv) -> Result { - let src = v.target.path.join("LabVIEW.ini"); - let text = std::fs::read_to_string(&src).with_context(|| format!("reading {}", src.display()))?; + let src = v.target.ini(); + let text = match std::fs::read_to_string(&src) { + Ok(text) => text, + // On Linux the file is per user and only exists once this user has + // started LabVIEW; until then the defaults are all there is to inherit. + Err(e) if e.kind() == std::io::ErrorKind::NotFound && cfg!(target_os = "linux") => { + format!("[LabVIEW]{EOL}") + } + Err(e) => return Err(e).with_context(|| format!("reading {}", src.display())), + }; // Verified unquoted; LabVIEW itself quotes path values that carry spaces, // so follow it exactly there and nowhere else. - let raw = v.dir.to_string_lossy().replace('/', "\\"); + let raw = v.dir.to_string_lossy().into_owned(); + #[cfg(windows)] + let raw = raw.replace('/', "\\"); let location = if raw.contains(' ') { format!("\"{raw}\"") } else { raw }; let port = v.port.to_string(); let out = override_keys( @@ -96,8 +119,9 @@ pub fn write_ini(v: &Venv) -> Result { /// Start the venv's LabVIEW with `ini`, optionally opening a project file. /// Detached: LabVIEW outlives lvpm by design. pub fn spawn(v: &Venv, ini: &Path, lvproj: Option<&Path>) -> Result { - let exe = v.target.path.join("LabVIEW.exe"); - ensure!(exe.is_file(), "no LabVIEW.exe in {}", v.target.path.display()); + let exe = v.target.exe(); + ensure!(exe.is_file(), "no {} in {}", exe.display(), v.target.path.display()); + wait_port_released(v.port); let mut cmd = Command::new(&exe); cmd.arg("-pref").arg(ini); if let Some(p) = lvproj { @@ -114,13 +138,56 @@ pub fn spawn(v: &Venv, ini: &Path, lvproj: Option<&Path>) -> Result { /// our stdout. An IDE that inherits that pipe holds it open for as long as it /// runs, and `lvpm launch | tail` never returns. So our std handles are made /// non-inheritable for the duration of the spawn, and restored afterwards. +/// +/// On Linux `Stdio::null()` is enough — the child's 0–2 are replaced, and Rust +/// opens its own descriptors close-on-exec — but the terminal's process group +/// is not: a child left in ours gets the SIGINT of a Ctrl+C meant for lvpm. So +/// there LabVIEW gets a group of its own. pub fn spawn_detached(cmd: &mut Command) -> std::io::Result { cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); #[cfg(windows)] let _keep_our_pipes = win::NoInherit::new(); + #[cfg(unix)] + std::os::unix::process::CommandExt::process_group(cmd, 0); cmd.spawn() } +/// Hold off starting a LabVIEW on `port` while a closed connection still +/// holds that port in TIME_WAIT, up to the kernel's fixed 60 s. +/// +/// LabVIEW on Linux binds its VI Server listener once, at startup, and +/// without `SO_REUSEADDR`: started while the previous instance's connections +/// are in TIME_WAIT, it runs without VI Server for the rest of its life and +/// never says so. Measured on 2026 Q3 — `lvpm launch` straight after an +/// install in a venv had closed its relink instance got a LabVIEW that never +/// listened. Linux-only, which is where it was measured. +#[cfg(target_os = "linux")] +pub fn wait_port_released(port: u16) { + const TIME_WAIT: &str = "06"; + let held = || { + ["/proc/net/tcp", "/proc/net/tcp6"].iter().any(|f| { + std::fs::read_to_string(f).unwrap_or_default().lines().skip(1).any(|l| { + let cols: Vec<&str> = l.split_whitespace().collect(); + cols.len() > 3 + && cols[3] == TIME_WAIT + && cols[1].rsplit(':').next().and_then(|p| u16::from_str_radix(p, 16).ok()) + == Some(port) + }) + }) + }; + if !held() { + return; + } + eprintln!("waiting for port {port} to leave TIME_WAIT, or LabVIEW would start without VI Server..."); + let started = Instant::now(); + while held() && started.elapsed() < Duration::from_secs(65) { + std::thread::sleep(Duration::from_secs(1)); + } +} + +#[cfg(not(target_os = "linux"))] +pub fn wait_port_released(_port: u16) {} + #[cfg(windows)] mod win { use std::os::windows::io::{AsRawHandle, RawHandle}; @@ -195,9 +262,9 @@ pub fn wait_ready(port: u16, budget: Duration) -> Result<()> { // A headless LabVIEW shows no dialogs; what would have been one is in // its log instead. let hint = if std::env::var_os("LV_RTE_HEADLESS").is_some() { - "(headless LabVIEW: see %TEMP%\\LabVIEW_*_headless_*_cur.txt for what went wrong)" + format!("(headless LabVIEW: see {HEADLESS_LOG} for what went wrong)") } else { - "(a dialog may be holding the IDE up — check its window)" + "(a dialog may be holding the IDE up — check its window)".to_string() }; bail!( "LabVIEW never answered VI Server on port {port} within {}s (last: {last})\n{hint}", @@ -265,17 +332,18 @@ mod tests { out, "[LabVIEW]\r\nserver.tcp.port=3400\r\nserver.tcp.acl=\"+*\"\r\n\ LVAddons.AdditionalLocations=C:\\r\\.project\r\n" + .replace("\r\n", EOL) ); } #[test] fn override_does_not_mistake_a_longer_key_for_a_shorter_one() { let out = override_keys("server.tcp.portfoo=1\n", &[("server.tcp.port", "2")]); - assert_eq!(out, "server.tcp.portfoo=1\r\nserver.tcp.port=2\r\n"); + assert_eq!(out, format!("server.tcp.portfoo=1{EOL}server.tcp.port=2{EOL}")); } #[test] fn override_of_an_empty_ini_is_just_the_keys() { - assert_eq!(override_keys("", &[("a", "1")]), "a=1\r\n"); + assert_eq!(override_keys("", &[("a", "1")]), format!("a=1{EOL}")); } } diff --git a/src/main.rs b/src/main.rs index 871c122..dc22f1c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,11 @@ //! lvpm — an open-source package manager for LabVIEW packages. +// lvpm knows where LabVIEW keeps its files on Windows and on Linux. Every +// other platform would build and then look in the wrong places, so it does not +// build: macOS needs a branch of its own (docs/roadmap.md). +#[cfg(not(any(windows, target_os = "linux")))] +compile_error!("lvpm supports Windows and Linux; macOS is on the roadmap (docs/roadmap.md)"); + mod index; mod install; mod launch; @@ -429,11 +435,32 @@ fn headless_roots(cli: &Cli, repo: &Path) -> Result { } /// The index cache is per-user, not per-target — the feeds are the same. +/// `%LOCALAPPDATA%\lvpm\cache` on Windows, `~/.cache/lvpm` (XDG) elsewhere. fn cache_dir() -> PathBuf { - let base = std::env::var("LOCALAPPDATA") + #[cfg(windows)] + let dir = std::env::var_os("LOCALAPPDATA").map(|d| PathBuf::from(d).join("lvpm").join("cache")); + #[cfg(not(windows))] + let dir = std::env::var_os("XDG_CACHE_HOME") + .filter(|d| !d.is_empty()) .map(PathBuf::from) - .unwrap_or_else(|_| std::env::temp_dir()); - base.join("lvpm").join("cache") + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache"))) + .map(|d| d.join("lvpm")); + dir.unwrap_or_else(|| std::env::temp_dir().join("lvpm").join("cache")) +} + +/// Where lvpm puts files a LabVIEW has to open by path: the relink VI, and a +/// PostUninstall hook that must outlive its package. `%TEMP%` is the user's +/// own on Windows; `/tmp` is shared on Linux, where a name another user took +/// first is one this user cannot write — so there it is the per-user cache. +fn work_dir() -> PathBuf { + #[cfg(windows)] + { + std::env::temp_dir() + } + #[cfg(not(windows))] + { + cache_dir() + } } fn cmd_start(cli: &Cli, wait: u64) -> Result<()> { @@ -633,8 +660,10 @@ fn uninstall_one( // hook VI itself, so it runs from a copy that outlives the uninstall. let post = match &before.post_uninstall_vi { Some(hook) if hooks => { - let tmp = std::env::temp_dir().join(format!("lvpm-{}-PostUninstall.vi", before.name)); - std::fs::copy(hook, &tmp) + let dir = work_dir(); + let tmp = dir.join(format!("lvpm-{}-PostUninstall.vi", before.name)); + std::fs::create_dir_all(&dir) + .and_then(|()| std::fs::copy(hook, &tmp)) .map(|_| tmp) .map_err(|e| println!("note: cannot stage PostUninstall.vi ({e}) — not running it")) .ok() @@ -1264,8 +1293,9 @@ fn hook_action_info( files: &[String], ) -> viserver::LvValue { use viserver::LvValue; - let paths: Vec = - files.iter().map(|f| LvValue::Path(f.replace('/', "\\"))).collect(); + // Manifests store forward slashes; a Windows LabVIEW wants its own. + let native = |f: &String| if cfg!(windows) { f.replace('/', "\\") } else { f.clone() }; + let paths: Vec = files.iter().map(|f| LvValue::Path(native(f))).collect(); let files_installed = LvValue::array(paths) .unwrap_or_else(|_| LvValue::empty_array(viserver::TD_PATH)); LvValue::Variant { @@ -1322,7 +1352,7 @@ fn run_relink( args: &RelinkArgs, work: &[(String, Vec)], ) -> Result<()> { - let vi = relink::locate_vi()?; + let vi = relink::locate_vi(&work_dir())?; // One walk covers every folder nested under it, so overlapping packages // share a run instead of relinking the same tree twice — 18 of the first // pass's 109 folders were nested repeats costing 17 of its 61 minutes. diff --git a/src/refresh.rs b/src/refresh.rs index 51ca4a9..8e063bf 100644 --- a/src/refresh.rs +++ b/src/refresh.rs @@ -23,11 +23,12 @@ //! //! | | VI | //! |---|---| -//! | palettes | `vi.lib\Palette API\Refresh Palettes.vi` | -//! | menus | `resource\plugins\PopupMenus\support\Refresh Menus.vi` | +//! | palettes | `vi.lib/Palette API/Refresh Palettes.vi` | +//! | menus | `resource/plugins/PopupMenus/support/Refresh Menus.vi` | //! -//! Present in every install checked (2015, 2025, 2026). So this module is only -//! plumbing: find the two VIs, run them, report what happened. +//! Present in every install checked (2015, 2025, 2026, and 2026 on Linux). So +//! this module is only plumbing: find the two VIs, run them, report what +//! happened. use crate::target::LvTarget; use crate::viserver::{self, Connection}; @@ -43,8 +44,8 @@ use std::time::{Duration, Instant}; /// wrapper around `Menus:Refresh` — a fair reason to expect its path to move /// one day, which is why a missing VI is reported rather than fatal. const REFRESH_VIS: [(&str, &str); 2] = [ - ("palettes", r"vi.lib\Palette API\Refresh Palettes.vi"), - ("menus", r"resource\plugins\PopupMenus\support\Refresh Menus.vi"), + ("palettes", "vi.lib/Palette API/Refresh Palettes.vi"), + ("menus", "resource/plugins/PopupMenus/support/Refresh Menus.vi"), ]; /// One refresh: what it was, and how it went. @@ -65,7 +66,10 @@ pub fn run(target: &LvTarget, timeout: Duration) -> Result> { let mut out = Vec::with_capacity(REFRESH_VIS.len()); for (what, rel) in REFRESH_VIS { - out.push(Outcome { what, result: run_one(&mut conn, target.path.join(rel)) }); + // Joined a component at a time: a separator inside one `join` is only + // a separator on Windows. + let vi = rel.split('/').fold(target.path.clone(), |p, c| p.join(c)); + out.push(Outcome { what, result: run_one(&mut conn, vi) }); } conn.close(); Ok(out) diff --git a/src/relink.rs b/src/relink.rs index a0bfeea..060236d 100644 --- a/src/relink.rs +++ b/src/relink.rs @@ -22,7 +22,7 @@ use crate::spec::Spec; use crate::target::Roots; use crate::viserver::{self, Connection, LvValue, VIRef}; use anyhow::{Context, Result, bail}; -use std::path::{Path, PathBuf}; +use std::path::{MAIN_SEPARATOR, MAIN_SEPARATOR_STR, Path, PathBuf}; use std::time::{Duration, Instant}; /// The control the folder goes into, and the indicator that says it finished. @@ -40,10 +40,13 @@ const LOG_INDICATOR: &str = "report log out"; /// ordinary filename character. So `C:/vi.lib/Foo` arrives as a single /// unparseable component, the relink walks nothing, and it reports success /// over an empty list. Install manifests store forward slashes, which is how -/// a whole relink pass came back reporting nothing at all. +/// a whole relink pass came back reporting nothing at all. On Linux it is the +/// other way round, so there the path goes as it is. fn lv_folder(folder: &Path) -> String { - let s = folder.to_string_lossy().replace('/', "\\"); - s.trim_end_matches('\\').to_string() + let s = folder.to_string_lossy(); + #[cfg(windows)] + let s = s.replace('/', "\\"); + s.trim_end_matches(MAIN_SEPARATOR).to_string() } /// How many saved files to name before summarising the rest — the same @@ -65,10 +68,13 @@ pub fn summarize_log(log: &str, folder: &Path) -> Vec { if saved.is_empty() { return vec!["saved nothing".to_string()]; } - let base = lv_folder(folder) + "\\"; + let base = lv_folder(folder) + MAIN_SEPARATOR_STR; let mut out = vec![format!("saved {} file(s)", saved.len())]; for p in saved.iter().take(SHOWN_SAVES) { + #[cfg(windows)] let rel = p.replace('/', "\\"); + #[cfg(not(windows))] + let rel = p.clone(); let rel = rel.strip_prefix(&base).unwrap_or(&rel); out.push(format!(" {rel}")); } @@ -94,15 +100,13 @@ const RELINK_VI_BYTES: &[u8] = include_bytes!(concat!( "/src/lv-src/relink-package.vi" )); -/// Materialize the bundled relink VI so LabVIEW can open it by path. +/// Materialize the bundled relink VI in `dir` so LabVIEW can open it by path. /// /// The VI is embedded in the executable at compile time, so the installed /// executable does not depend on the source checkout or a companion file. -pub fn locate_vi() -> Result { - let p = std::env::temp_dir().join(format!( - "lvpm-relink-package-{}.vi", - env!("CARGO_PKG_VERSION") - )); +pub fn locate_vi(dir: &Path) -> Result { + std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + let p = dir.join(format!("lvpm-relink-package-{}.vi", env!("CARGO_PKG_VERSION"))); std::fs::write(&p, RELINK_VI_BYTES) .with_context(|| format!("materializing relink VI at {}", p.display()))?; Ok(p) @@ -431,6 +435,7 @@ mod tests { /// The report as LabVIEW writes it: a JSON array of absolute paths, which /// becomes a count and folder-relative names, capped past six. + #[cfg(windows)] #[test] fn summarize_log_counts_and_relativises() { let folder = Path::new(r"C:\Program Files\National Instruments\LabVIEW 2026\examples\DQMH"); @@ -453,6 +458,19 @@ mod tests { assert!(summarize_log("", folder).is_empty()); } + /// The same report from LabVIEW on Linux, whose paths use forward slashes. + #[cfg(not(windows))] + #[test] + fn summarize_log_relativises_linux_paths() { + let folder = Path::new("/usr/local/natinst/LabVIEW-2026-64/examples/DQMH/"); + let log = r#"["/usr/local/natinst/LabVIEW-2026-64/examples/DQMH/Libraries/A/A.lvlib", "/usr/local/natinst/LabVIEW-2026-64/examples/DQMH/B.lvlib"]"#; + assert_eq!( + summarize_log(log, folder), + vec!["saved 2 file(s)", " Libraries/A/A.lvlib", " B.lvlib"] + ); + assert_eq!(summarize_log("[]", folder), vec!["saved nothing"]); + } + /// The cross-package version of `collapse`: a folder covered by another /// package's folder joins that folder's run instead of getting its own, /// and exact duplicates merge. Order and per-folder attribution both @@ -460,22 +478,23 @@ mod tests { #[test] fn collapse_work_merges_overlapping_packages() { let work = vec![ - ("caraya".to_string(), vec![PathBuf::from(r"C:\lv\vi.lib\addons\Caraya")]), - ("h5".to_string(), vec![PathBuf::from(r"C:\lv\vi.lib\addons")]), - ("caraya_cli".to_string(), vec![PathBuf::from(r"C:\lv\vi.lib\addons\Caraya")]), - ("dqmh".to_string(), vec![PathBuf::from(r"C:\lv\project\DQMH")]), + ("caraya".to_string(), vec![PathBuf::from("/lv/vi.lib/addons/Caraya")]), + ("h5".to_string(), vec![PathBuf::from("/lv/vi.lib/addons")]), + ("caraya_cli".to_string(), vec![PathBuf::from("/lv/vi.lib/addons/Caraya")]), + ("dqmh".to_string(), vec![PathBuf::from("/lv/project/DQMH")]), ]; let plan = collapse_work(&work); assert_eq!(plan.len(), 2); - assert_eq!(plan[0].0, PathBuf::from(r"C:\lv\project\DQMH")); + assert_eq!(plan[0].0, PathBuf::from("/lv/project/DQMH")); assert_eq!(plan[0].1, ["dqmh"]); - assert_eq!(plan[1].0, PathBuf::from(r"C:\lv\vi.lib\addons")); + assert_eq!(plan[1].0, PathBuf::from("/lv/vi.lib/addons")); assert_eq!(plan[1].1, ["h5", "caraya", "caraya_cli"]); } /// Install manifests store forward slashes; LabVIEW needs backslashes, or /// it takes the whole path for one filename and relinks nothing while /// reporting success. A whole 101-folder pass came back empty this way. + #[cfg(windows)] #[test] fn folders_reach_labview_with_native_separators() { let m = "C:/Program Files/National Instruments/LabVIEW 2026/vi.lib/Delacor/Libraries"; @@ -489,6 +508,15 @@ mod tests { assert_eq!(lv_folder(Path::new("C:/vi.lib/Foo/")), r"C:\vi.lib\Foo"); } + /// On Linux the manifest's forward slashes are already LabVIEW's own; a + /// backslash there would be part of a file name. + #[cfg(not(windows))] + #[test] + fn folders_reach_linux_labview_as_they_are() { + let m = "/usr/local/natinst/LabVIEW-2026-64/vi.lib/Delacor/Libraries/"; + assert_eq!(lv_folder(Path::new(m)), m.trim_end_matches('/')); + } + fn f(paths: &[&str]) -> Vec { paths.iter().map(|s| s.to_string()).collect() } diff --git a/src/target.rs b/src/target.rs index 5893adb..e60bf25 100644 --- a/src/target.rs +++ b/src/target.rs @@ -3,7 +3,8 @@ //! A scratch prefix and a real LabVIEW installation differ only in where the //! roots point, so both go through `Roots`. That keeps the installer honest: //! the code path exercised against a sandbox is the same one that writes into -//! `C:\Program Files\National Instruments\LabVIEW 2026`. +//! `C:\Program Files\National Instruments\LabVIEW 2026` — or, on Linux, +//! `/usr/local/natinst/LabVIEW-2026-64`. use anyhow::{Result, bail}; use std::path::{Path, PathBuf}; @@ -29,8 +30,41 @@ impl LvTarget { pub fn label(&self) -> String { format!("LabVIEW {} ({}-bit) v{}", self.year(), self.bitness, self.version) } + + /// The executable lvpm starts. On Linux, `labview` is the install dir's + /// symlink to whichever edition is installed (`labviewprofull`, ...). + pub fn exe(&self) -> PathBuf { + self.path.join(EXE) + } + + /// The preferences file a LabVIEW started as [`LvTarget::exe`] reads: + /// VI Server's switch and port, and what a venv's ini is copied from. + /// + /// On Windows that is `LabVIEW.ini` beside the executable. On Linux it is + /// per user, `~/natinst/.config/LabVIEW-/`, and named after the name + /// LabVIEW was started as — `labview.conf` for `labview`, while the + /// `/usr/local/bin/labview64` symlink reads `labview64.conf`. It does not + /// exist before that user's first launch. + pub fn ini(&self) -> PathBuf { + #[cfg(windows)] + { + self.path.join("LabVIEW.ini") + } + #[cfg(target_os = "linux")] + { + env_path("HOME", "/root") + .join("natinst/.config") + .join(format!("LabVIEW-{}", self.year())) + .join(format!("{EXE}.conf")) + } + } } +#[cfg(windows)] +const EXE: &str = "LabVIEW.exe"; +#[cfg(target_os = "linux")] +const EXE: &str = "labview"; + #[cfg(windows)] pub fn detect() -> Result> { use winreg::RegKey; @@ -54,7 +88,7 @@ pub fn detect() -> Result> { continue; } let path = PathBuf::from(path.trim_end_matches(['\\', '/'])); - if !path.join("LabVIEW.exe").exists() { + if !path.join(EXE).exists() { continue; } // Several internal versions share one directory (26.0 and 26.3); @@ -70,26 +104,49 @@ pub fn detect() -> Result> { Ok(out) } -#[cfg(not(windows))] +#[cfg(target_os = "linux")] pub fn detect() -> Result> { - // LabVIEW on Linux lives under /usr/local/natinst/LabVIEW--64 - let mut out = Vec::new(); - if let Ok(rd) = std::fs::read_dir("/usr/local/natinst") { - for e in rd.flatten() { - let p = e.path(); - let n = p.file_name().unwrap_or_default().to_string_lossy().to_string(); - if let Some(rest) = n.strip_prefix("LabVIEW-") - && let Some(year) = rest.split('-').next().and_then(|y| y.parse::().ok()) - { - let bitness = if n.ends_with("-64") { 64 } else { 32 }; - out.push(LvTarget { version: (year - 2000) as f64, bitness, path: p }); - } + // LabVIEW on Linux lives under /usr/local/natinst/LabVIEW--64 unless + // its prefix was moved at install time; either way its package links + // /etc/natinst/labview--64 to the install's `etc`, where + // `labview.dir` names the install. A directory without the executable is + // what an uninstall leaves behind. + let listed = |dir: &str| std::fs::read_dir(dir).into_iter().flatten().flatten(); + let linked = listed("/etc/natinst") + .filter(|e| e.file_name().to_string_lossy().starts_with("labview-")) + .filter_map(|e| std::fs::read_to_string(e.path().join("labview.dir")).ok()) + .map(|d| PathBuf::from(d.trim())); + let mut out: Vec = Vec::new(); + for p in linked.chain(listed("/usr/local/natinst").map(|e| e.path())) { + let n = p.file_name().unwrap_or_default().to_string_lossy().to_string(); + if let Some(rest) = n.strip_prefix("LabVIEW-") + && let Some(year) = rest.split('-').next().and_then(|y| y.parse::().ok()) + && year > 2000 + && p.join(EXE).is_file() + && !out.iter().any(|t| t.path == p) + { + let bitness = if n.ends_with("-64") { 64 } else { 32 }; + let version = linux_version(&p).unwrap_or((year - 2000) as f64); + out.push(LvTarget { version, bitness, path: p }); } } out.sort_by(|a, b| b.version.partial_cmp(&a.version).unwrap_or(std::cmp::Ordering::Equal)); Ok(out) } +/// The internal version of a Linux installation, which its directory name +/// does not carry: a 2026 Q3 is 26.3, and that is what a package's +/// `Exclusive_LabVIEW_Version` gate is compared against. NI's uninstall +/// script in the install dir states it as `LV_MAJOR_VER=26` / `LV_MINOR_VER=3`. +#[cfg(target_os = "linux")] +fn linux_version(install: &Path) -> Option { + let text = std::fs::read_to_string(install.join("readme/UNINSTALL")).ok()?; + let var = |key: &str| { + text.lines().find_map(|l| l.trim().strip_prefix(key)?.strip_prefix('=')?.trim().parse::().ok()) + }; + format!("{}.{}", var("LV_MAJOR_VER")?, var("LV_MINOR_VER")?).parse().ok() +} + /// Pick a target by year ("2026"), internal version ("26.3") or path. pub fn select(targets: &[LvTarget], want: &str) -> Result { let want = want.trim(); @@ -170,6 +227,7 @@ impl Roots { } } + #[cfg(windows)] pub fn labview(t: &LvTarget) -> Roots { let userprofile = env_path("USERPROFILE", "C:\\Users\\Default"); let program_files = if t.bitness == 32 { @@ -196,6 +254,29 @@ impl Roots { } } + /// The machine roots are what LabVIEW's own `Get System Directory.vi` + /// answers on Linux (2026 Q3, read over VI Server): the `` tokens + /// are that VI's directory types by name. + #[cfg(target_os = "linux")] + pub fn labview(t: &LvTarget) -> Roots { + let home = env_path("HOME", "/root"); + Roots { + application: t.path.clone(), + temp: std::env::temp_dir(), + program_data: PathBuf::from("/usr/local"), + program_files: PathBuf::from("/usr/local"), + public_documents: PathBuf::from("/usr/local"), + user_documents: home.join("Documents"), + user_desktop: home.join("Desktop"), + user_appdata: home, + boot_volume: PathBuf::from("/"), + system_core: PathBuf::from("/usr/lib"), + scratch: None, + venv: None, + target: Some(t.clone()), + } + } + /// One package's addon inside a project venv. `//1` mirrors the /// LabVIEW install dir — that is what LVAddons overlays — so every /// LabVIEW-tree token lands inside the addon. The machine roots stay real: @@ -268,16 +349,23 @@ impl Roots { other => bail!("unknown Target Dir token {other:?} (from {target_dir:?})"), }; - Ok(if rest.is_empty() { base } else { base.join(rest) }) + // Specs spell the tail with either separator, and only Windows takes + // both: on Linux `addons\Foo` would be one directory of that name. + Ok(rest.split(['/', '\\']).filter(|c| !c.is_empty()).fold(base, |p, c| p.join(c))) } /// Manifests live beside the thing they describe: inside the venv or the - /// sandbox, in ProgramData keyed by target for a real install. + /// sandbox, and for a real install in machine-wide state keyed by target — + /// `%ProgramData%` on Windows, `/var/lib` on Linux. pub fn store_dir(&self) -> PathBuf { + #[cfg(windows)] + let machine = env_path("ProgramData", "C:\\ProgramData"); + #[cfg(target_os = "linux")] + let machine = PathBuf::from("/var/lib"); match (&self.venv, &self.scratch, &self.target) { (Some(v), _, _) => v.join(".lvpm").join("installed"), (None, Some(p), _) => p.join(".lvpm").join("installed"), - (None, None, Some(t)) => env_path("ProgramData", "C:\\ProgramData") + (None, None, Some(t)) => machine .join("lvpm") .join(t.key()) .join("installed"), @@ -359,52 +447,102 @@ mod tests { } } + /// An install dir and a repo, as each platform spells them. + #[cfg(windows)] + const LV: &str = r"C:\Program Files\National Instruments\LabVIEW 2026"; + #[cfg(not(windows))] + const LV: &str = "/usr/local/natinst/LabVIEW-2026-64"; + #[cfg(windows)] + const REPO: &str = r"C:\repo"; + #[cfg(not(windows))] + const REPO: &str = "/home/dev/repo"; + #[test] fn labview_roots_hang_off_the_install_dir() { - let t = LvTarget { - version: 26.3, - bitness: 64, - path: PathBuf::from(r"C:\Program Files\National Instruments\LabVIEW 2026"), - }; + let t = LvTarget { version: 26.3, bitness: 64, path: PathBuf::from(LV) }; assert_eq!(t.year(), 2026); assert_eq!(t.key(), "LabVIEW-2026-64bit"); let r = Roots::labview(&t); assert_eq!( r.expand("/addons/HSE").unwrap(), - Path::new(r"C:\Program Files\National Instruments\LabVIEW 2026\vi.lib\addons\HSE") + Path::new(LV).join("vi.lib").join("addons").join("HSE") ); assert_eq!( r.expand("/Categories").unwrap(), - Path::new(r"C:\Program Files\National Instruments\LabVIEW 2026\menus\Categories") + Path::new(LV).join("menus").join("Categories") ); } #[test] fn project_roots_put_labview_tokens_in_the_addon_and_leave_machine_roots_alone() { - let t = LvTarget { version: 26.3, bitness: 64, path: PathBuf::from(r"C:\LV2026") }; - let venv = Path::new(r"C:\repo\.project"); + let t = LvTarget { version: 26.3, bitness: 64, path: PathBuf::from(LV) }; + let venv = &Path::new(REPO).join(".project"); let r = Roots::project(venv, &t, "oglib_error"); + let addon = venv.join("oglib_error").join("1"); assert_eq!( r.expand("/_OpenG.lib/error").unwrap(), - Path::new(r"C:\repo\.project\oglib_error\1\vi.lib\_OpenG.lib\error") + addon.join("vi.lib").join("_OpenG.lib").join("error") ); - assert_eq!(r.expand("").unwrap(), Path::new(r"C:\repo\.project\oglib_error\1")); - assert!(!r.expand("").unwrap().starts_with(r"C:\repo")); - assert!(!r.expand("").unwrap().starts_with(r"C:\repo")); + assert_eq!(r.expand("").unwrap(), addon); + assert!(!r.expand("").unwrap().starts_with(REPO)); + assert!(!r.expand("").unwrap().starts_with(REPO)); // Nothing LabVIEW-class may leave the venv; machine roots are not // written to at all in venv mode, and the guard is what says so. r.check_contained(&r.expand("/Categories").unwrap()).unwrap(); assert!(r.check_contained(&r.expand("").unwrap()).is_err()); - assert_eq!(r.store_dir(), Path::new(r"C:\repo\.project\.lvpm\installed")); + assert_eq!(r.store_dir(), venv.join(".lvpm").join("installed")); assert_eq!(Roots::project_store(venv, &t).store_dir(), r.store_dir()); - assert_eq!(Roots::project_store(venv, &t).application, venv); - assert_eq!(r.venv(), Some(venv)); + assert_eq!(&Roots::project_store(venv, &t).application, venv); + assert_eq!(r.venv(), Some(venv.as_path())); assert!(r.target.is_some()); } + /// On Linux the `` tokens land where LabVIEW's Get System + /// Directory.vi says, and a global install's manifests in /var/lib — not + /// in a `C:\ProgramData` relative to the working directory, which is what + /// the Windows fallbacks amount to there. + #[cfg(target_os = "linux")] + #[test] + fn linux_roots_are_the_ones_labview_reports() { + let t = LvTarget { version: 26.3, bitness: 64, path: PathBuf::from(LV) }; + let r = Roots::labview(&t); + let home = env_path("HOME", "/root"); + assert_eq!(r.expand("/x").unwrap(), home.join("Documents/x")); + assert_eq!(r.expand("").unwrap(), home); + assert_eq!(r.expand("").unwrap(), Path::new("/usr/local")); + assert_eq!(r.expand("/ci").unwrap(), Path::new("/ci")); + assert_eq!(r.expand("").unwrap(), Path::new("/usr/lib")); + assert_eq!(r.store_dir(), Path::new("/var/lib/lvpm/LabVIEW-2026-64bit/installed")); + assert_eq!(t.exe(), Path::new(LV).join("labview")); + assert_eq!(t.ini(), home.join("natinst/.config/LabVIEW-2026/labview.conf")); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_version_comes_from_the_uninstall_script() { + let dir = std::env::temp_dir().join(format!("lvpm-target-test-{}", std::process::id())); + std::fs::create_dir_all(dir.join("readme")).unwrap(); + assert_eq!(linux_version(&dir), None); + std::fs::write( + dir.join("readme/UNINSTALL"), + "# LabVIEW 2026 Q3 uninstallation script.\nLV_MAJOR_VER=26\nLV_MINOR_VER=3\nLV_UPDATE_VER=0\n", + ) + .unwrap(); + assert_eq!(linux_version(&dir), Some(26.3)); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn a_target_dir_may_use_either_separator() { + let r = Roots::scratch(Path::new("/tmp/box")); + let want = Path::new("/tmp/box/LabVIEW").join("vi.lib").join("addons").join("Foo"); + assert_eq!(r.expand("/addons/Foo").unwrap(), want); + assert_eq!(r.expand("\\addons\\Foo\\").unwrap(), want); + } + #[test] fn classify_sorts_tokens_by_where_they_point() { assert_eq!(Roots::classify("/addons/Foo").unwrap(), TokenClass::LabView); diff --git a/src/viserver.rs b/src/viserver.rs index 334f14b..99de88a 100644 --- a/src/viserver.rs +++ b/src/viserver.rs @@ -32,9 +32,10 @@ use std::net::{Ipv4Addr, SocketAddr, TcpStream}; use std::path::Path; use std::time::Duration; -/// Read a `key=value` token out of a target's `LabVIEW.ini`. +/// Read a `key=value` token out of a target's `LabVIEW.ini` (`labview.conf` +/// on Linux — see [`LvTarget::ini`]). fn ini_token(target: &LvTarget, key: &str) -> Option { - let text = std::fs::read_to_string(target.path.join("LabVIEW.ini")).ok()?; + let text = std::fs::read_to_string(target.ini()).ok()?; for line in text.lines() { let line = line.trim(); if let Some(rest) = line.strip_prefix(key) @@ -54,17 +55,26 @@ pub fn vi_server_port(target: &LvTarget) -> u16 { ini_token(target, "server.tcp.port").and_then(|v| v.parse().ok()).unwrap_or(3363) } +/// Whether LabVIEW serves VI Server over TCP when its ini says nothing: on for +/// Windows; off on Linux, where a 2026 Q3 started on an ini without the key +/// never listens (NI's Linux container image sets it for that reason). +#[cfg(windows)] +const VI_SERVER_DEFAULT: bool = true; +#[cfg(target_os = "linux")] +const VI_SERVER_DEFAULT: bool = false; + /// Fail early, and legibly, when a target cannot accept a VI Server client. pub fn check_vi_server(target: &LvTarget) -> Result { let enabled = ini_token(target, "server.tcp.enabled") .map(|v| v.eq_ignore_ascii_case("true")) - // Absent means LabVIEW's own default, which is on. - .unwrap_or(true); + // Absent means LabVIEW's own default. + .unwrap_or(VI_SERVER_DEFAULT); if !enabled { bail!( - "VI Server is disabled for {} (server.tcp.enabled=False in LabVIEW.ini).\n\ - Enable it in Tools >> Options >> VI Server.", - target.label() + "VI Server is disabled for {} (server.tcp.enabled is not True in {}).\n\ + Enable it in Tools >> Options >> VI Server, or add server.tcp.enabled=True there.", + target.label(), + target.ini().display() ); } Ok(vi_server_port(target)) @@ -99,10 +109,11 @@ pub fn ensure_vi_server(target: &LvTarget, wait: Duration) -> Result { return Ok(port); } - let exe = target.path.join("LabVIEW.exe"); + let exe = target.exe(); if !exe.is_file() { - bail!("no LabVIEW.exe in {}", target.path.display()); + bail!("no {} in {}", exe.file_name().unwrap_or_default().to_string_lossy(), target.path.display()); } + crate::launch::wait_port_released(port); eprintln!("starting {} and waiting for VI Server on port {port}...", target.label()); // Detached child: LabVIEW outlives lvpm by design — and must not keep // lvpm's own pipes open while it does (see `launch::spawn_detached`). @@ -952,15 +963,27 @@ impl<'a> Reader<'a> { let n = body.take(1)?[0] as usize; parts.push(String::from_utf8_lossy(body.take(n)?).into_owned()); } - // A drive letter comes back as a bare component: "C" is "C:". - if kind == 0 - && let Some(first) = parts.first_mut() - && first.len() == 1 - && first.chars().all(|c| c.is_ascii_alphabetic()) + #[cfg(windows)] { - first.push(':'); + // A drive letter comes back as a bare component: "C" is "C:". + if kind == 0 + && let Some(first) = parts.first_mut() + && first.len() == 1 + && first.chars().all(|c| c.is_ascii_alphabetic()) + { + first.push(':'); + } + Ok(parts.join("\\")) + } + // One root and no drives: "/usr/local" comes back as "usr", "local" + // and must be rooted again, or it reads as a relative path. + #[cfg(target_os = "linux")] + { + Ok(match kind { + 0 => format!("/{}", parts.join("/")), + _ => parts.join("/"), + }) } - Ok(parts.join("\\")) } /// Decode the value at table entry `idx`. `depth` bounds the recursion: a @@ -1060,6 +1083,8 @@ pub struct Connection { /// encoder serves both the wire protocol and any future offline path rewriting: /// `"PTH0" | u32 byte length | u32 component count | pascal components`, with an /// absolute Windows path contributing its drive letter as the first component. +/// A Linux path is the same record without one: `/usr/local` is `usr`, +/// `local` (verified against LabVIEW 2026 Q3 on Linux). pub fn encode_pth0(path: &Path) -> Result> { let s = path.to_string_lossy().replace('/', "\\"); let mut parts: Vec = Vec::new(); @@ -1586,12 +1611,23 @@ mod tests { assert_eq!(got.to_string(), r#"["Hello 1", "Hello 2"]"#); } + /// A Windows path as this platform spells the same place: unchanged on + /// Windows; elsewhere the drive goes and the separators turn, so the round + /// trips below check the decoder the platform actually runs. + fn native(windows_path: &str) -> String { + if cfg!(windows) { + return windows_path.to_string(); + } + let rest = windows_path.split_once(':').map_or(windows_path, |(_, r)| r); + rest.replace('\\', "/") + } + /// `encode_pth0` is already pinned against a capture, so decoding is /// checked by round-tripping the very record LabVIEW's own client sent. #[test] fn decodes_a_pth0_record() { - let path = r"C:\Git\lvpm\tools\Set VI Server Logging.vi"; - let encoded = encode_pth0(Path::new(path)).unwrap(); + let path = native(r"C:\Git\lvpm\tools\Set VI Server Logging.vi"); + let encoded = encode_pth0(Path::new(&path)).unwrap(); assert_eq!(Reader::new(&encoded).pth0().unwrap(), path); // "Not A Path" carries kind 2 and no components. @@ -1602,6 +1638,21 @@ mod tests { assert!(Reader::new(b"XXXX\x00\x00\x00\x04\x00\x00\x00\x00").pth0().is_err()); } + /// What LabVIEW 2026 Q3 on Linux answered for `Application Directory.vi`: + /// the same record as on Windows, minus a drive. Read back as `usr\local\...` + /// it would name a relative path under the working directory. + #[cfg(target_os = "linux")] + #[test] + fn decodes_a_linux_pth0_record_as_a_rooted_path() { + let rec = b"PTH0\x00\x00\x00\x13\x00\x00\x00\x03\x03usr\x05local\x04x.vi"; + assert_eq!(Reader::new(rec).pth0().unwrap(), "/usr/local/x.vi"); + let root = b"PTH0\x00\x00\x00\x04\x00\x00\x00\x00"; + assert_eq!(Reader::new(root).pth0().unwrap(), "/"); + let relative = b"PTH0\x00\x00\x00\x09\x00\x01\x00\x01\x04x.vi"; + assert_eq!(Reader::new(relative).pth0().unwrap(), "x.vi"); + assert_eq!(encode_pth0(Path::new("/usr/local/x.vi")).unwrap(), rec); + } + /// The action-info variant a hook VI's `Variant` control receives: named /// attributes after the data, each an lv-string name and a complete /// flattened variant. Derived from LabVIEW's flattened-data layout; the @@ -1615,7 +1666,7 @@ mod tests { ("Package Name".into(), LvValue::Str("delacor_lib_dqmh_documentation".into())), ( "Files Installed".into(), - LvValue::array(vec![LvValue::Path(r"C:\lv\vi.lib\a.vi".into())]).unwrap(), + LvValue::array(vec![LvValue::Path(native(r"C:\lv\vi.lib\a.vi"))]).unwrap(), ), ], }; @@ -1633,8 +1684,8 @@ mod tests { #[test] fn round_trips_an_array_of_paths() { let v = LvValue::array(vec![ - LvValue::Path(r"C:\Program Files\National Instruments\LabVIEW 2026\vi.lib\a.vi".into()), - LvValue::Path(r"C:\Program Files\National Instruments\LabVIEW 2026\vi.lib\b.vi".into()), + LvValue::Path(native(r"C:\Program Files\National Instruments\LabVIEW 2026\vi.lib\a.vi")), + LvValue::Path(native(r"C:\Program Files\National Instruments\LabVIEW 2026\vi.lib\b.vi")), ]) .unwrap(); let flat = variant(&v).unwrap();