Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions .github/workflows/macos-latest.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
name: Publish macos-latest (Wine 9)

# Manually-triggered build of the macos-latest tag, pinned to Wine 9.x.
#
# Why a separate workflow:
# The default `latest` tag tracks Wine stable (currently Wine 10), which
# aborts under QEMU on 16 KB page-size hosts (Apple Silicon Macs via
# Docker Desktop, Asahi Linux). Wine 9 does not exhibit that bug, so the
# `macos-latest` tag stays pinned to Wine 9 until upstream #58084 ships.
#
# Tags published on every run:
# - devel-YYYYMMDD-wine-<major.minor> (immutable, audit trail)
# - macos-latest (rolling pointer, can be overridden)
#
# Run via the Actions tab → "Run workflow", optionally overriding the
# Wine version pin (e.g. to bump to a newer 9.x point release).

on:
workflow_dispatch:
inputs:
wine_version:
description: "Debian package version pin for wine-stable (e.g. 9.0.0.0~bookworm-1)"
required: true
default: "9.0.0.0~bookworm-1"
rolling_tag:
description: "Rolling tag to publish alongside the dated devel tag"
required: true
default: "macos-latest"

jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Compute tags
id: tags
run: |
DATE=$(date -u +%Y%m%d)
# 9.0.0.0~bookworm-1 → 9.0
WINE_SHORT=$(echo "${{ inputs.wine_version }}" | cut -d. -f1-2)
DEVEL_TAG="devel-${DATE}-wine-${WINE_SHORT}"
IMAGE=aanas0sayed/docker-ltspice
echo "devel=${IMAGE}:${DEVEL_TAG}" >> "$GITHUB_OUTPUT"
echo "rolling=${IMAGE}:${{ inputs.rolling_tag }}" >> "$GITHUB_OUTPUT"
echo "Will publish: ${IMAGE}:${DEVEL_TAG} and ${IMAGE}:${{ inputs.rolling_tag }}"

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Build image (load locally for smoke test)
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
load: true
tags: docker-ltspice:macos-ci
build-args: |
WINE_BRANCH=stable
WINE_VERSION=${{ inputs.wine_version }}
cache-to: type=gha,scope=ltspice-wine9,mode=max

- name: Run simulation test
run: |
chmod +x test.sh
./test.sh docker-ltspice:macos-ci

- name: Push image with both tags
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: true
tags: |
${{ steps.tags.outputs.devel }}
${{ steps.tags.outputs.rolling }}
build-args: |
WINE_BRANCH=stable
WINE_VERSION=${{ inputs.wine_version }}
cache-from: type=gha,scope=ltspice-wine9
67 changes: 50 additions & 17 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -55,37 +55,70 @@ RUN apt-get update \
fi \
&& rm -rf /var/lib/apt/lists/*

# ── 5. Wine env ────────────────────────────────────────────────────────────
ENV WINEPREFIX=/root/.wine \
# ── 5. Unprivileged user ───────────────────────────────────────────────────
# Image runs as wineuser (uid 1000) by default. To also support
# --user=$(id -u):$(id -g) for arbitrary uid/gid, the prefix is shipped as
# a *template* (uid-agnostic) and the entrypoint copies it into a per-run
# location owned by the current uid. Wine refuses to use a prefix not
# owned by the running uid, so a simple chmod-only approach does not work.
RUN groupadd -g 1000 wineuser \
&& useradd -m -u 1000 -g 1000 -s /bin/bash wineuser

# ── 6. Wine env ────────────────────────────────────────────────────────────
# WINEPREFIX deliberately points into /tmp (tmpfs in callers) — the
# entrypoint materialises it from the on-image template on every fresh
# container start. The build-time prefix lives at /opt/wineprefix-template.
ENV HOME=/home/wineuser \
WINEPREFIX=/tmp/wine-prefix \
WINEDEBUG=-all \
DISPLAY=:99

# ── 6. Install LTspice ─────────────────────────────────────────────────────
# Xvfb must be running before wineboot/msiexec – both need a display.
# We start it in the same RUN layer, do everything, then kill it.
# wget and other build-only tools are removed at the end of this layer.
# ── 7. Install LTspice into a build-time prefix (as wineuser) ─────────────
USER wineuser
WORKDIR /home/wineuser
RUN Xvfb :99 -screen 0 1024x768x24 & \
sleep 2 \
&& WINEDLLOVERRIDES="mscoree,mshtml=" wineboot --init \
&& wineserver --wait \
&& WINEPREFIX=/home/wineuser/.wine WINEDLLOVERRIDES="mscoree,mshtml=" \
wineboot --init \
&& WINEPREFIX=/home/wineuser/.wine wineserver --wait \
&& wget -q -O /tmp/LTspice64.msi https://ltspice.analog.com/software/LTspice64.msi \
&& wine msiexec /i /tmp/LTspice64.msi /quiet /norestart \
&& wineserver --wait \
&& WINEPREFIX=/home/wineuser/.wine wine msiexec /i /tmp/LTspice64.msi /quiet /norestart \
&& WINEPREFIX=/home/wineuser/.wine wineserver --wait \
&& rm /tmp/LTspice64.msi \
&& rm -rf /tmp/.wine-* /tmp/wine-* \
&& kill %1 2>/dev/null || true

# ── 7. Remove build-only tools ────────────────────────────────────────────
USER root

# ── 8. Split LTspice install out of the prefix; turn the prefix into a
# uid-agnostic template ─────────────────────────────────────────────
# The 1.7 GB LTspice tree moves to /opt/ltspice (read-only at runtime),
# replaced by a relative symlink from inside the prefix so the registry
# entries written by msiexec still resolve. The slimmed-down prefix
# (~150 MB) becomes /opt/wineprefix-template — the entrypoint copies it
# per run into $WINEPREFIX, which makes the copy owned by the current
# uid and satisfies Wine's "prefix is not owned by you" check.
RUN mv "/home/wineuser/.wine/drive_c/Program Files/ADI/LTspice" /opt/ltspice \
&& ln -s /opt/ltspice "/home/wineuser/.wine/drive_c/Program Files/ADI/LTspice" \
&& mv /home/wineuser/.wine /opt/wineprefix-template \
&& find /opt/ltspice -type d -exec chmod a+rwx {} + \
&& find /opt/ltspice -type f -exec chmod a+rw {} + \
&& find /opt/wineprefix-template -type d -exec chmod a+rwx {} + \
&& find /opt/wineprefix-template -type f -exec chmod a+rw {} + \
&& chmod a+rwx /home/wineuser \
&& rm -rf /tmp/.X* /tmp/.wine-* /tmp/wine-*

# ── 9. Remove build-only tools ────────────────────────────────────────────
RUN apt-get purge -y --auto-remove wget p7zip-full unzip \
&& rm -rf /var/lib/apt/lists/*

# ── 8. Entrypoint ──────────────────────────────────────────────────────────
# ── 10. Entrypoint + wrapper ──────────────────────────────────────────────
COPY entrypoint.sh /usr/local/bin/entrypoint
RUN chmod +x /usr/local/bin/entrypoint

# ── 9. LTspice wrapper ─────────────────────────────────────────────────────
COPY wrappers/ltspice /usr/local/bin/ltspice
RUN chmod +x /usr/local/bin/ltspice
RUN chmod +x /usr/local/bin/entrypoint /usr/local/bin/ltspice

USER wineuser
WORKDIR /home/wineuser

ENTRYPOINT ["/usr/local/bin/entrypoint"]
CMD ["/bin/bash"]
CMD ["/bin/bash"]
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Run [LTspice](https://www.analog.com/en/resources/design-tools-and-calculators/l

The image is based on `debian:bookworm-slim` with Wine (stable) and LTspice pre-installed. The entrypoint automatically handles Wine initialisation and starts Xvfb on `:99`, so simulations work out of the box.

The container runs as a non-root `wineuser` (uid 1000) by default and works under `--user=$(id -u):$(id -g)` for any host uid, so output files (`.log`, `.raw`) end up owned by you and the container can be run with `--cap-drop=ALL` (no `CAP_DAC_OVERRIDE`).

Pre-built images are available on [DockerHub](https://hub.docker.com/r/aanas0sayed/docker-ltspice).

> [!IMPORTANT]
Expand Down Expand Up @@ -39,9 +41,26 @@ docker run --rm \

The `ltspice` command is a thin wrapper around `wine LTspice.exe`.

### Sandboxed invocation (recommended on Linux)

To run with the host user's uid (so output files belong to you) and with all Linux capabilities dropped:

```bash
docker run --rm \
--user "$(id -u):$(id -g)" \
--cap-drop=ALL \
--security-opt=no-new-privileges \
--network=none \
-v "$(pwd):/sim" \
aanas0sayed/docker-ltspice \
ltspice -b -run "Z:\\sim\\your_circuit.net"
```

The image creates a fresh Wine prefix at `/tmp/wine-prefix` on each container start, owned by whatever uid you pass to `--user`, so Wine's prefix-ownership check is satisfied without `CAP_CHOWN` or `CAP_DAC_OVERRIDE`.

### CI example

See [test.sh](test.sh) and [.github/workflows/test.yml](.github/workflows/test.yml) for a working example that runs a simulation and validates `.meas` results from the output log.
See [test.sh](test.sh) and [.github/workflows/ci.yml](.github/workflows/ci.yml) for a working example that runs a simulation under the hardened invocation above and validates `.meas` results from the output log.

---

Expand Down Expand Up @@ -99,6 +118,8 @@ docker run --rm -it \

- **Xvfb fails on first run (macOS):** The entrypoint prints `ERROR: Xvfb exited unexpectedly` on the first container start on macOS. This is a known issue — the container is still usable. Run `ltspice` from the shell and it will work normally.
- **File not found / path errors:** LTspice runs inside Wine, so paths must use the Wine `Z:` drive (which maps to `/` on the container). For example, a netlist mounted at `/sim/circuit.net` should be passed as `Z:\\sim\\circuit.net`.
- **Permission denied writing the log/raw file:** the container is non-root (uid 1000 by default). If your bind-mount directory isn't writable by that uid, either pass `--user=$(id -u):$(id -g)` so the in-container uid matches the directory owner, or `chmod` the directory so uid 1000 can write to it.
- **Slower first call inside a container:** on every fresh `docker run`, the entrypoint copies the Wine prefix template into `/tmp/wine-prefix` (~150 MB). This is sub-second on tmpfs but adds a small fixed cost per container start.
- **Interactive shell:** The entrypoint still runs (priming Wine and starting Xvfb) before handing off to your command:

```bash
Expand Down
47 changes: 47 additions & 0 deletions docs/wineprefix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Wine prefix

A Wine prefix is the directory Wine uses to emulate a Windows install for one user. It's a self-contained fake `C:\` plus a fake Windows registry, stored as regular Linux files in a single folder.

## Layout

```
$WINEPREFIX/
├── drive_c/ ← fake C: drive
│ ├── windows/ ← fake C:\Windows (system DLLs, fonts)
│ ├── Program Files/ ← where Windows apps install themselves
│ ├── users/<name>/ ← fake C:\Users\<name> (Desktop, AppData, Temp)
│ └── ProgramData/
├── dosdevices/
│ ├── c: -> ../drive_c
│ └── z: -> / ← Z: maps to the Linux root, the trick that
│ lets `Z:\sim\foo.net` mean `/sim/foo.net`
├── system.reg ← fake HKEY_LOCAL_MACHINE
├── user.reg ← fake HKEY_CURRENT_USER
└── userdef.reg ← fake HKEY_USERS\.Default
```

## What it's for

When Wine runs a Windows `.exe`, the `.exe` asks Windows things like "where is `C:\Program Files`?", "what's in the registry under `HKLM\Software\X`?", "give me `%TEMP%`." Wine answers by reading files inside the prefix. The prefix is the per-user state of "Windows."

A prefix is created the first time you run `wineboot` (or any `wine` command) — Wine generates a default skeleton with system DLLs. When you install a Windows app via `wine msiexec /i installer.msi`, the installer writes files into `drive_c/Program Files/...` and registry entries into `system.reg` / `user.reg` **inside that prefix.** That on-disk state is what makes the app "installed."

You can have many prefixes — `WINEPREFIX=/some/other/dir` gives you a separate fake Windows. Used to isolate finicky apps from each other.

## Why this image cares

Wine has a check at every invocation: `stat($WINEPREFIX).st_uid != getuid()` → hard refusal with `wine: '<prefix>' is not owned by you`. The prefix must be owned by the running uid.

That single check is the reason for the prefix-template design:

- The image runs as `wineuser` (uid 1000) by default, but supports `docker run --user=$(id -u):$(id -g)` for arbitrary host uids.
- If we shipped a prefix at `/home/wineuser/.wine` owned by uid 1000, any `--user` value other than 1000 would fail Wine's owner check, and we can't `chown` at runtime under `--cap-drop=ALL`.
- So the build splits the prefix into a **uid-agnostic template** at `/opt/wineprefix-template`, and the entrypoint `cp -a --no-preserve=ownership` it into `/tmp/wine-prefix` on every container start. The copy inherits the running uid → owner check passes for any uid.

## What's in this image's prefix

- A fully installed LTspice — except its 1.7 GB install was moved out to `/opt/ltspice` and replaced with a relative symlink at `drive_c/Program Files/ADI/LTspice`. Wine still sees it at `C:\Program Files\ADI\LTspice`; the registry entries written by `msiexec` still resolve.
- The `Z: -> /` symlink under `dosdevices/`, which is what lets callers pass `Z:\sim\foo.net` to LTspice.
- The registry entries from the LTspice install (file associations, COM registration).

The prefix at `/tmp/wine-prefix` is per-container — anything written into it during a run (Wine timestamp updates, LTspice config) is discarded when the container exits. For batch simulation that's the right default; for "develop interactively in the container," preferences reset on every start.
55 changes: 43 additions & 12 deletions entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,32 +1,63 @@
#!/bin/bash
# entrypoint.sh – runs before every command inside the container
# entrypoint.sh – runs before every command inside the container.
#
# Responsibilities (mirrors what scottyhardy's entrypoint.sh does):
# 1. Start Xvfb on DISPLAY :99 so Wine has a virtual screen to render into
# 2. Wait until the X server is actually accepting connections
# 3. First-run: initialise WINEPREFIX (creates the fake C: drive)
# 4. exec the user's command (bash by default, or anything passed to docker run)
# Responsibilities:
# 1. Materialise the WINEPREFIX from the on-image template so it is
# owned by the current uid (required: Wine refuses to use a prefix
# not owned by the running uid).
# 2. Prime Wine so first-run service init completes without an X server.
# 3. Start Xvfb on DISPLAY :99 so subsequent Wine calls have a display.
# 4. exec the user's command (bash by default).
#
# Runs as the unprivileged wineuser (uid 1000) by default. Also tolerates
# `docker run --user=<uid>:<gid>` for arbitrary uid/gid — the prefix copy
# inherits the current uid and /tmp is world-writable on caller systems.

set -e

XVFB_DISPLAY="${DISPLAY:-:99}"
DISPLAY_NUM="${XVFB_DISPLAY#:}"
XVFB_RESOLUTION="${XVFB_RESOLUTION:-1024x768x16}"

# ── 1. Prime Wine (no X server yet) ───────────────────────────────────────
# Defensive: an arbitrary --user uid often has no /etc/passwd entry,
# leaving HOME unset. Wine and other tools then fall back to "/" which
# is read-only. Force a sensible HOME.
export HOME="${HOME:-/home/wineuser}"
export WINEPREFIX="${WINEPREFIX:-/tmp/wine-prefix}"
# Several Wine code paths look up the username via getpwuid(); when the
# uid is not in /etc/passwd this returns NULL and Wine creates an
# unnamed users/ subdir. Pinning $LOGNAME/$USER avoids that and matches
# the username baked into the prefix template at build time.
export LOGNAME="${LOGNAME:-wineuser}"
export USER="${USER:-wineuser}"

TEMPLATE=/opt/wineprefix-template

# ── 1. Materialise the prefix ─────────────────────────────────────────────
if [ ! -d "$WINEPREFIX" ]; then
echo "[entrypoint] Materialising WINEPREFIX at ${WINEPREFIX} (uid $(id -u))"
# cp -a would try to preserve owner and fail when run as non-root.
# --no-preserve=ownership keeps mode/timestamps/symlinks but lets the
# copy inherit the running uid — which is exactly what Wine needs.
cp -a --no-preserve=ownership "$TEMPLATE" "$WINEPREFIX"
fi

LTSPICE_EXE="${WINEPREFIX}/drive_c/Program Files/ADI/LTspice/LTspice.exe"

# ── 2. Prime Wine (no X server yet) ───────────────────────────────────────
# The first wine invocation after a fresh container start triggers internal
# service/init work (winebth, shell32, etc.). If an X server IS available,
# those services try to create windows and block forever. Running LTspice
# once with DISPLAY pointing to a non-existent server makes them fail fast
# and complete their init. The second run then works cleanly.
echo "[entrypoint] Priming Wine (display ${XVFB_DISPLAY}, no X yet)..."
export DISPLAY="${XVFB_DISPLAY}"
wine "/root/.wine/drive_c/Program Files/ADI/LTspice/LTspice.exe" -b 2>/dev/null || true
wine "$LTSPICE_EXE" -b 2>/dev/null || true
wineserver --wait 2>/dev/null || true
echo "[entrypoint] Wine primed."

# ── 2. Start Xvfb ─────────────────────────────────────────────────────────
rm -f "/tmp/.X${DISPLAY_NUM}-lock" "/tmp/.X11-unix/X${DISPLAY_NUM}"
# ── 3. Start Xvfb ─────────────────────────────────────────────────────────
rm -f "/tmp/.X${DISPLAY_NUM}-lock" "/tmp/.X11-unix/X${DISPLAY_NUM}" 2>/dev/null || true
echo "[entrypoint] Starting Xvfb on ${XVFB_DISPLAY} (${XVFB_RESOLUTION})"
Xvfb "${XVFB_DISPLAY}" -screen 0 "${XVFB_RESOLUTION}" -nolisten tcp 2>/dev/null &
XVFB_PID=$!
Expand All @@ -46,5 +77,5 @@ done

export DISPLAY="${XVFB_DISPLAY}"

# ── 3. Hand off to the user's command ──────────────────────────────────────
exec "$@"
# ── 4. Hand off to the user's command ──────────────────────────────────────
exec "$@"
Loading
Loading