diff --git a/.cspell.yaml b/.cspell.yaml index 0bec19c2..b6865b83 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -2,6 +2,7 @@ version: '0.2' language: en words: +- dcompiler - devcontainer - direnv - hadolint diff --git a/.github/workflows/zwift_build_from_scratch.yaml b/.github/workflows/zwift_build_from_scratch.yaml index ec309fe1..75e83341 100644 --- a/.github/workflows/zwift_build_from_scratch.yaml +++ b/.github/workflows/zwift_build_from_scratch.yaml @@ -54,6 +54,7 @@ jobs: docker commit \ --change="LABEL org.opencontainers.image.created=${build_date}" \ --change="LABEL org.opencontainers.image.version=${version}" \ + --change="ENV ZWIFT_IMAGE_VERSION=${version}" \ --change='CMD [""]' \ -m "built from scratch from version ${version}" \ zwift \ diff --git a/docs/advanced/nixos.md b/docs/advanced/nixos.md index cebc00a6..ef4be444 100644 --- a/docs/advanced/nixos.md +++ b/docs/advanced/nixos.md @@ -36,6 +36,8 @@ environment variables in camelCase: programs.zwift = { # Enable the zwift module and install required dependencies enable = true; + # Variant: "container" (default) or "volume" (persistent /home/user volume) + variant = "container"; # The Docker image to use for zwift image = "docker.io/netbrain/zwift"; # The zwift game version to run diff --git a/docs/advanced/volume-variant.md b/docs/advanced/volume-variant.md new file mode 100644 index 00000000..8d86fb21 --- /dev/null +++ b/docs/advanced/volume-variant.md @@ -0,0 +1,85 @@ +--- +title: Volume Variant +parent: Advanced +nav_order: 4 +--- + +# Volume Variant + +The default mode mounts only the Zwift documents directory as a volume. The **volume variant** mounts the entire +`/home/user` directory as a persistent volume instead. On first run, the container runtime automatically populates the +volume from the image contents — no separate installation step needed. + +## Benefits + +- **No per-launch chown**: File ownership persists in the volume, eliminating the slow `chown` that Docker users with + non-1000 uid/gid experience on every launch. +- **Persistent wine prefix**: The entire Wine configuration, registry, and installed prerequisites are preserved between + runs. + +## How it works + +| | Default (`container`) | Volume (`volume`) | +|---------------------|-----------------------------------------------|--------------------------------------------| +| **Image** | `netbrain/zwift:latest` | `netbrain/zwift:latest` (same image) | +| **Volume** | `zwift-$USER` mounted at Zwift docs directory | `zwift-home-$USER` mounted at `/home/user` | +| **First run** | Seconds | Seconds (volume auto-populated from image) | +| **Subsequent runs** | Seconds | Seconds | +| **chown on launch** | Every launch if uid/gid != 1000 | Skipped (ownership persists in volume) | + +When a new named volume is mounted to a container path that already has data, Docker (and Podman) automatically copy +the image contents into the volume. This means the first launch is just as fast as any other — no download or +installation wait. + +## Usage + +### Environment variable + +```bash +ZWIFT_VARIANT="volume" zwift +``` + +Or add to your config file (`~/.config/zwift/config`): + +```bash +ZWIFT_VARIANT="volume" +``` + +### NixOS module + +```nix +{ + programs.zwift = { + enable = true; + variant = "volume"; + }; +} +``` + +### Standalone Nix package + +```bash +nix run github:netbrain/zwift#zwift-volume +``` + +## Updating Zwift + +When a new container image is pulled, the launch script automatically detects the version mismatch and syncs the updated +files from the image into the volume. User data (activities, workouts, logs, preferences, screenshots) is preserved +during the sync. + +You can also force an update manually by passing `--update` as an entrypoint argument: + +```bash +ZWIFT_VARIANT="volume" zwift -- --update +``` + +## Resetting the installation + +To start fresh, remove the persistent volume: + +```bash +docker volume rm "zwift-home-$USER" +``` + +The next launch will re-populate the volume from the current image. diff --git a/docs/configuration/options.md b/docs/configuration/options.md index 96ceaaf4..8d0d9d6d 100644 --- a/docs/configuration/options.md +++ b/docs/configuration/options.md @@ -40,6 +40,7 @@ These environment variables can be used to alter the execution of the zwift bash | `DEBUG` | `0` | If set to `1`, enable debug of zwift script `set -x` | | `VGA_DEVICE_FLAG` | | Override GPU/device flags for container (`--gpus=all`) | | `PRIVILEGED_CONTAINER` | `0` | If set, container will run in privileged mode, SELinux label separation will be disabled (`--privileged --security-opt label=disable`) | +| `ZWIFT_VARIANT` | `container` | Set to `volume` to mount `/home/user` as a persistent volume (faster startup, no per-launch chown) | {: .important } `ZWIFT_UID` and `ZWIFT_GID` can only be used with X11. They do not work in wayland! @@ -59,6 +60,8 @@ These environment variables can be used to alter the execution of the zwift bash the user that started the container. You should not need to change this except in rare cases. - `WINE_EXPERIMENTAL_WAYLAND="1" zwift` This will start zwift using Wayland and not XWayland. It will start full screen windowed. +- `ZWIFT_VARIANT="volume" zwift` will mount `/home/user` as a persistent volume, eliminating per-launch `chown` (see + [Volume Variant](../advanced/volume-variant.md) for details). {: .note } > To pass extra environment variables to the container, they can be added to `CONTAINER_EXTRA_ARGS` with the `-e` flag. diff --git a/docs/troubleshooting/slow-start.md b/docs/troubleshooting/slow-start.md index 2ce862b9..02e97456 100644 --- a/docs/troubleshooting/slow-start.md +++ b/docs/troubleshooting/slow-start.md @@ -11,6 +11,11 @@ file ownership (`chown`) on every launch. Since the container is ephemeral, the runs. Only files with incorrect ownership are updated, but the check itself still needs to scan the file tree which can be slow if you have a large Zwift installation. -If speed is still a concern, consider changing your user to match the container's uid and gid (1000) using `usermod`. +If speed is a concern, you have two options: + +1. **Use the volume variant** (`ZWIFT_VARIANT="volume"`): This mounts the entire `/home/user` as a persistent volume, + so file ownership persists between runs and no `chown` is needed after the first launch. See + [Volume Variant](../advanced/volume-variant.md) for details. +2. **Change your user IDs** to match the container's uid and gid (1000) using `usermod`. **Podman** users are not affected by this since Podman handles uid/gid mapping via user namespaces (`--userns keep-id`). diff --git a/flake.nix b/flake.nix index cb928017..31fd9591 100644 --- a/flake.nix +++ b/flake.nix @@ -11,6 +11,7 @@ { image, tag, + variant, dontCheck, dontPull, dontClean, @@ -45,6 +46,7 @@ nixosRun = pkgs.writeShellScript "zwift-nixos.sh" '' ${pkgs.lib.optionalString (image != "") "export IMAGE=${image}"} ${pkgs.lib.optionalString (tag != "") "export VERSION=${tag}"} + ${pkgs.lib.optionalString (variant != "") "export ZWIFT_VARIANT=${variant}"} ${pkgs.lib.optionalString (dontCheck != "") "export DONT_CHECK=${dontCheck}"} ${pkgs.lib.optionalString (dontPull != "") "export DONT_PULL=${dontPull}"} ${pkgs.lib.optionalString (dontClean != "") "export DONT_CLEAN=${dontClean}"} @@ -110,6 +112,14 @@ options.programs.zwift = { enable = mkEnableOption "zwift on linux"; + variant = mkOption { + type = enum [ + "container" + "volume" + ]; + default = "container"; + description = "Zwift variant: 'container' (default) or 'volume' (persistent /home/user volume, no per-launch chown)"; + }; image = lib.mkOption { type = str; default = ""; @@ -227,6 +237,7 @@ (wrapPackage { inherit image + variant containerTool containerExtraArgs zwiftUsername @@ -316,6 +327,35 @@ desktopItems = [ "bin/Zwift.desktop" ]; }; + zwift-volume = wrapPackage { + image = ""; + tag = ""; + variant = "volume"; + dontCheck = ""; + dontPull = ""; + dontClean = ""; + dryRun = ""; + interactive = ""; + containerTool = ""; + containerExtraArgs = ""; + zwiftUsername = ""; + zwiftPassword = ""; + zwiftWorkoutDir = ""; + zwiftActivityDir = ""; + zwiftLogDir = ""; + zwiftScreenshotsDir = ""; + zwiftOverrideGraphics = ""; + zwiftOverrideResolution = ""; + zwiftFg = ""; + zwiftNoGameMode = ""; + wineExperimentalWayland = ""; + networking = ""; + zwiftUid = ""; + zwiftGid = ""; + vgaDeviceFlag = ""; + debug = ""; + privilegedContainer = ""; + }; default = self.packages.x86_64-linux.zwift; }; }; diff --git a/src/Dockerfile b/src/Dockerfile index c797f079..07a23dcf 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -53,6 +53,7 @@ RUN dpkg --add-architecture i386 \ libgl1 \ libvulkan1 \ procps \ + rsync \ sudo \ wget \ winbind \ diff --git a/src/entrypoint.sh b/src/entrypoint.sh index bbb909c0..88631b2a 100755 --- a/src/entrypoint.sh +++ b/src/entrypoint.sh @@ -25,6 +25,7 @@ readonly ZWIFT_UID="${ZWIFT_UID:-$(id -u user)}" readonly ZWIFT_GID="${ZWIFT_GID:-$(id -g user)}" readonly WINE_EXPERIMENTAL_WAYLAND="${WINE_EXPERIMENTAL_WAYLAND:-0}" readonly CONTAINER_TOOL="${CONTAINER_TOOL:?}" +readonly ZWIFT_VOLUME="${ZWIFT_VOLUME:-0}" readonly WINE_USER_HOME="/home/user/.wine/drive_c/users/user" readonly ZWIFT_HOME="/home/user/.wine/drive_c/Program Files (x86)/Zwift" @@ -160,12 +161,16 @@ if [[ ${CONTAINER_TOOL} == "docker" ]]; then fi fi - msgbox info "Checking file ownership" - if update_ownership; then - msgbox ok "File ownership is correct" + if [[ ${ZWIFT_VOLUME} -eq 1 ]]; then + msgbox ok "Volume variant: skipping ownership update (persisted in volume)" else - msgbox error "Failed to update file ownership" - exit 1 + msgbox info "Checking file ownership" + if update_ownership; then + msgbox ok "File ownership is correct" + else + msgbox error "Failed to update file ownership" + exit 1 + fi fi startup_cmd=(gosu user:user "${startup_cmd[@]}") diff --git a/src/update_zwift.sh b/src/update_zwift.sh index c6b07aaa..c09c3ab1 100755 --- a/src/update_zwift.sh +++ b/src/update_zwift.sh @@ -22,6 +22,7 @@ else fi readonly CONTAINER_TOOL="${CONTAINER_TOOL:?}" +readonly ZWIFT_VOLUME="${ZWIFT_VOLUME:-0}" readonly WINE_USER_HOME="/home/user/.wine/drive_c/users/user" readonly ZWIFT_HOME="/home/user/.wine/drive_c/Program Files (x86)/Zwift" @@ -155,7 +156,10 @@ cleanup() { rm -rf -- "${WINE_USER_HOME}/Downloads/Zwift" || true rm -rf -- "/home/user/.cache/wine*" || true # remove Zwift documents because it causes permission errors with podman - rm -rf -- "${ZWIFT_DOCS}" || true + # skip in minimal variant — ZWIFT_DOCS lives in the persistent volume + if [[ ${ZWIFT_VOLUME} -ne 1 ]]; then + rm -rf -- "${ZWIFT_DOCS}" || true + fi } trap cleanup EXIT diff --git a/src/zwift.sh b/src/zwift.sh index bcdb2448..7dbbaa3f 100755 --- a/src/zwift.sh +++ b/src/zwift.sh @@ -157,6 +157,7 @@ readonly ZWIFT_UID="${ZWIFT_UID:-${UID}}" readonly ZWIFT_GID="${ZWIFT_GID:-$(id -g)}" readonly VGA_DEVICE_FLAG="${VGA_DEVICE_FLAG:-}" readonly PRIVILEGED_CONTAINER="${PRIVILEGED_CONTAINER:-0}" +readonly ZWIFT_VARIANT="${ZWIFT_VARIANT:-container}" # Initialize CONTAINER_TOOL: Use podman if available msgbox info "Looking for container tool" @@ -329,9 +330,15 @@ container_args+=( --name "zwift-${USER}" --hostname "${HOSTNAME}" --env-file "${container_env_file}" - -v "zwift-${USER}:${ZWIFT_DOCS}" ) +if [[ ${ZWIFT_VARIANT} == "volume" ]]; then + container_args+=(-v "zwift-home-${USER}:/home/user") + container_env_vars+=(ZWIFT_VOLUME="1") +else + container_args+=(-v "zwift-${USER}:${ZWIFT_DOCS}") +fi + ################################################### ##### Forward arguments passed to this script ##### @@ -623,16 +630,61 @@ fi # Create a volume if not already exists, this is done now as # if left to the run command the directory can get the wrong permissions -if [[ ${CONTAINER_TOOL} == "podman" ]] && ! ${CONTAINER_TOOL} volume ls | grep -q "zwift-${USER}"; then - mshgbox info "Creating ${CONTAINER_TOOL} volume zwift-${USER}" - if ${CONTAINER_TOOL} volume create "zwift-${USER}"; then - msgbox ok "Created volume zwift-${USER}" +if [[ ${ZWIFT_VARIANT} == "volume" ]]; then + volume_name="zwift-home-${USER}" +else + volume_name="zwift-${USER}" +fi +if [[ ${CONTAINER_TOOL} == "podman" ]] && ! ${CONTAINER_TOOL} volume ls | grep -q "${volume_name}"; then + msgbox info "Creating ${CONTAINER_TOOL} volume ${volume_name}" + if ${CONTAINER_TOOL} volume create "${volume_name}"; then + msgbox ok "Created volume ${volume_name}" else - msgbox error "Failed to create volume zwift-${USER}" + msgbox error "Failed to create volume ${volume_name}" exit 1 fi fi +# Volume variant: sync image contents into volume if image version changed +if [[ ${ZWIFT_VARIANT} == "volume" ]]; then + image_version="" + if image_version="$(${CONTAINER_TOOL} inspect --format '{{index .Config.Labels "org.opencontainers.image.version"}}' "${IMAGE}:${VERSION}" 2> /dev/null)"; then + msgbox info "Image version: ${image_version}" + fi + + if [[ -n ${image_version} ]]; then + volume_version="$(${CONTAINER_TOOL} run --rm --entrypoint cat -v "${volume_name}:/mnt/volume:ro" "${IMAGE}:${VERSION}" /mnt/volume/.zwift-image-version 2> /dev/null || true)" + + if [[ ${image_version} != "${volume_version}" ]]; then + msgbox info "Volume outdated (${volume_version:-empty}), syncing from image (${image_version})..." + if ${CONTAINER_TOOL} run --rm --entrypoint rsync \ + -v "${volume_name}:/mnt/volume" \ + "${IMAGE}:${VERSION}" \ + -a --delete \ + --exclude "AppData/Local/Zwift/Activities/" \ + --exclude "AppData/Local/Zwift/Workouts/" \ + --exclude "AppData/Local/Zwift/Logs/" \ + --exclude "AppData/Local/Zwift/prefs.xml" \ + --exclude "Pictures/Zwift/" \ + /home/user/ /mnt/volume/; then + + ${CONTAINER_TOOL} run --rm --entrypoint sh \ + -v "${volume_name}:/mnt/volume" \ + "${IMAGE}:${VERSION}" \ + -c "echo '${image_version}' > /mnt/volume/.zwift-image-version" + + msgbox ok "Volume synced to image version ${image_version}" + else + msgbox error "Failed to sync volume from image" + fi + else + msgbox ok "Volume is up to date (${image_version})" + fi + else + msgbox warning "Could not determine image version, skipping volume sync" + fi +fi + # Only write environment variables to file when needed msgbox info "Writing environment variables to temporary file" if printf '%s\n' "${container_env_vars[@]}" > "${container_env_file}"; then