From 48ae75855f078bb274f426c858be3a92d44aa253 Mon Sep 17 00:00:00 2001 From: David Gheghea <71325082+DavidutzDev@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:32:14 +0200 Subject: [PATCH] Add darling-lol --set-prefix to keep the prefix on another drive The prefix holds the installed clients and game (about 40 GB). The Nix launcher always exported DPREFIX=${DPREFIX:-~/.darling}, so the prefix saved in build/config.json was never used and the only way to move it was an environment variable in every launch context. - The launcher uses DPREFIX if set, then the saved prefix, then ~/.darling. - darling-lol --set-prefix PATH runs scripts/nix-prefix.py in the namespace, whose root owns the prefix files, so cp -a keeps their owners and xattrs. It renames the prefix when possible; across filesystems it copies to PATH.partial and removes the old prefix and its overlay work directory only after the copy completes. A PATH that already holds a Darling prefix is used as it is. It refuses non-empty directories that are not prefixes, nested paths and paths with commas or colons (overlayfs separators), then saves the path with configure.py --prefix. Co-Authored-By: Claude Opus 5.5 --- darling-lol-launcher.sh | 12 +++++ docs/NIX.md | 11 +++- scripts/nix-prefix.py | 80 +++++++++++++++++++++++++++++ tests/test_nix_launcher.py | 19 +++++++ tests/test_nix_prefix.py | 100 +++++++++++++++++++++++++++++++++++++ 5 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 scripts/nix-prefix.py create mode 100644 tests/test_nix_prefix.py diff --git a/darling-lol-launcher.sh b/darling-lol-launcher.sh index ddc8df7..f8a124c 100644 --- a/darling-lol-launcher.sh +++ b/darling-lol-launcher.sh @@ -9,7 +9,17 @@ UNSHARE_BIN="@UNSHARE_BIN@" export DARLING_PACKAGE_ID="$SHARE_DIR" export DISPLAY="${DISPLAY:-:0}" export EGL_PLATFORM="${EGL_PLATFORM:-x11}" +# The guest prefix: DPREFIX if set, else the one saved by --set-prefix, else ~/.darling. +if [ -z "${DPREFIX:-}" ] && [ -f "$WORKSPACE/build/config.json" ]; then + DPREFIX=$(python3 -c 'import json, sys; print(json.load(open(sys.argv[1])).get("prefix", ""))' \ + "$WORKSPACE/build/config.json" 2>/dev/null) || DPREFIX= +fi export DPREFIX="${DPREFIX:-$HOME/.darling}" +if [ "${1:-}" = --set-prefix ]; then + [ $# -eq 2 ] || { echo 'Usage: darling-lol --set-prefix PATH' >&2; exit 2; } + # Resolve here: the namespace starts in the workspace, not in this directory. + set -- --set-prefix "$(realpath -m -- "$2")" +fi export DARLING_HOST_UID="$(id -u)" export DARLING_HOST_GID="$(id -g)" export DARLING_WORKSPACE="$WORKSPACE" @@ -73,6 +83,8 @@ session() { case "${1:-}" in --shell) shift; "${fhs_command[@]}" bash "$@";; --update) shift; "${fhs_command[@]}" ./scripts/update.sh "$@";; + # In the namespace, whose root owns the prefix files, so a copy keeps owners. + --set-prefix) "${fhs_command[@]}" python3 ./scripts/nix-prefix.py "$2";; --installer) shift; session --installer "$@";; *) if [ $# -gt 0 ]; then diff --git a/docs/NIX.md b/docs/NIX.md index a83e126..b0cd1e3 100644 --- a/docs/NIX.md +++ b/docs/NIX.md @@ -210,8 +210,15 @@ Darling namespace; `darling-lol --stop` can still stop it. ## Files and operational limits - Workspace: `${XDG_DATA_HOME:-$HOME/.local/share}/darling-lol`. -- Default guest prefix: `$HOME/.darling`; set `DPREFIX` to use another prefix. - Explicit `DPREFIX` and `RIOT_GUEST_HOME` overrides take precedence over saved +- Default guest prefix: `$HOME/.darling`. The prefix holds the installed + clients and game, about 40 GB. To keep it on another drive, close the clients + and run `darling-lol --set-prefix /mnt/games/league`. This moves an existing + prefix there and saves the path for later launches. Across filesystems it + copies first and removes the old prefix only after the copy completes. If the + path already holds a Darling prefix, it switches to it and moves nothing. The + path must not contain commas or colons (overlayfs uses them as separators). + The next launch sets up the runtime in the new prefix. +- Explicit `DPREFIX` and `RIOT_GUEST_HOME` overrides take precedence over saved configuration, so clear stale overrides before switching installations. - Package upgrades refresh managed tools and remove previously tracked obsolete files. Builds, the installed runtime, guest prefixes, and unmanaged files are diff --git a/scripts/nix-prefix.py b/scripts/nix-prefix.py new file mode 100644 index 0000000..20f2eca --- /dev/null +++ b/scripts/nix-prefix.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""darling-lol --set-prefix PATH: choose where the guest prefix, and with it the +installed games, is stored. + +An existing prefix is moved to PATH. Across filesystems it is copied first and +the old copy is removed only once the new one is complete. If PATH already holds +a Darling prefix, the launcher switches to it and leaves both prefixes alone. +""" +import errno +import os +from pathlib import Path +import shutil +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[1] + + +def has_files(path): + return path.is_dir() and any(path.iterdir()) + + +def is_prefix(path): + # Darling creates this mount point in every prefix it starts. + return (path / 'Volumes/SystemRoot').is_dir() + + +def move(old, new): + new.parent.mkdir(parents=True, exist_ok=True) + if new.is_dir(): + new.rmdir() + try: + os.rename(old, new) + return + except OSError as error: + if error.errno != errno.EXDEV: + raise + partial = new.with_name(new.name + '.partial') + if partial.exists(): + shutil.rmtree(partial) + print(f'Copying {old} to {new}; this can take several minutes.', flush=True) + try: + subprocess.run(['cp', '-a', '--', str(old), str(partial)], check=True) + except BaseException: + shutil.rmtree(partial, ignore_errors=True) + raise + os.rename(partial, new) + print(f'Removing {old}.', flush=True) + shutil.rmtree(old) + + +def main(): + old = Path(os.environ['DPREFIX']).resolve() + new = Path(sys.argv[1]).expanduser() + if not new.is_absolute(): + raise SystemExit('The prefix must be an absolute path.') + new = new.resolve() + # Darling passes the prefix to overlayfs, which splits its options on these. + if any(character in str(new) for character in ',:\n'): + raise SystemExit('The prefix path must not contain commas, colons or newlines.') + if new != old: + if new.is_relative_to(old) or old.is_relative_to(new): + raise SystemExit(f'{new} and {old} must not contain each other.') + if has_files(new): + if not is_prefix(new): + raise SystemExit(f'{new} is not empty and is not a Darling prefix; ' + 'choose a new or empty directory.') + print(f'Using the existing prefix in {new}; {old} is left as it is.') + elif has_files(old): + move(old, new) + # Darling's overlay work directory; it creates a new one next to the prefix. + shutil.rmtree(old.with_name(old.name + '.workdir'), ignore_errors=True) + subprocess.run([sys.executable, str(ROOT / 'scripts/configure.py'), '--prefix', str(new)], + cwd=ROOT, stdout=subprocess.DEVNULL, check=True) + print(f'The prefix is now {new}. The next darling-lol launch sets up the runtime there if needed.') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/test_nix_launcher.py b/tests/test_nix_launcher.py index dc94160..829079e 100644 --- a/tests/test_nix_launcher.py +++ b/tests/test_nix_launcher.py @@ -119,6 +119,25 @@ class LauncherTests(unittest.TestCase): self.assertEqual(session.wait(timeout=5), 0) self.assertFalse(self.log.exists()) + def test_saved_prefix_is_used_unless_dprefix_is_set(self): + (self.bin / 'unshare').write_text('#!/bin/sh\nprintf "%s\\n" "$DPREFIX" "$@" > "$TEST_LOG"\n') + (self.workspace / 'build').mkdir(parents=True) + (self.workspace / 'build/config.json').write_text('{"prefix": "/mnt/games/League Of Legends"}') + self.env.pop('DPREFIX', None) + self.assertEqual(self.launch('true').returncode, 0) + self.assertEqual(self.log.read_text().splitlines()[0], '/mnt/games/League Of Legends') + self.env['DPREFIX'] = '/explicit' + self.assertEqual(self.launch('true').returncode, 0) + self.assertEqual(self.log.read_text().splitlines()[0], '/explicit') + + def test_set_prefix_resolves_the_path_from_the_callers_directory(self): + result = subprocess.run(['bash', str(self.launcher), '--set-prefix', 'Games/League'], + env=self.env, cwd=self.folder, text=True, capture_output=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.log.read_text().splitlines()[-3:], + ['python3', './scripts/nix-prefix.py', str(self.folder.resolve() / 'Games/League')]) + self.assertEqual(self.launch('--set-prefix').returncode, 2) + def test_setup_never_makes_a_user_writable_runtime_setuid(self): result = self.launch('--installer') self.assertEqual(result.returncode, 0, result.stderr) diff --git a/tests/test_nix_prefix.py b/tests/test_nix_prefix.py new file mode 100644 index 0000000..daf2493 --- /dev/null +++ b/tests/test_nix_prefix.py @@ -0,0 +1,100 @@ +"""darling-lol --set-prefix on temporary directories; no Darling or namespace.""" +import errno +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] + + +class PrefixTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.folder = Path(self.tmp.name) + self.workspace = self.folder / 'workspace' + (self.workspace / 'scripts').mkdir(parents=True) + (self.workspace / 'tools').mkdir() + for name in ('nix-prefix.py', 'configure.py', 'common.py'): + shutil.copy(ROOT / 'scripts' / name, self.workspace / 'scripts' / name) + self.old = self.folder / 'home/.darling' + (self.old / 'Volumes/SystemRoot').mkdir(parents=True) + (self.old / 'Applications/League of Legends.app').mkdir(parents=True) + (self.old / 'Applications/League of Legends.app/game').write_text('game data') + (self.folder / 'home/.darling.workdir/work').mkdir(parents=True) + + def set_prefix(self, new): + return subprocess.run([sys.executable, str(self.workspace / 'scripts/nix-prefix.py'), str(new)], + env=dict(os.environ, DPREFIX=str(self.old), HOME=str(self.folder / 'home'), + PYTHONDONTWRITEBYTECODE='1'), + text=True, capture_output=True, timeout=30) + + def saved_prefix(self): + return json.loads((self.workspace / 'build/config.json').read_text())['prefix'] + + def test_moves_the_prefix_and_saves_it(self): + new = self.folder / 'bulk/Games/League Of Legends' + result = self.set_prefix(new) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((new / 'Applications/League of Legends.app/game').read_text(), 'game data') + self.assertFalse(self.old.exists()) + self.assertFalse((self.folder / 'home/.darling.workdir').exists()) + self.assertEqual(self.saved_prefix(), str(new)) + + def test_copies_across_filesystems_and_removes_the_old_copy_last(self): + spec = importlib.util.spec_from_file_location('nix_prefix', self.workspace / 'scripts/nix-prefix.py') + prefix = importlib.util.module_from_spec(spec) + spec.loader.exec_module(prefix) + rename = os.rename + calls = [] + + def cross_device(source, target): + calls.append(Path(target).name) + if len(calls) == 1: + raise OSError(errno.EXDEV, 'cross-device link') + self.assertTrue(self.old.exists(), 'old prefix removed before the copy finished') + rename(source, target) + + new = self.folder / 'bulk/prefix' + with patch.object(prefix.os, 'rename', cross_device): + prefix.move(self.old, new) + # A failed rename of the prefix, then the rename of the finished copy. + self.assertEqual(calls, ['prefix', 'prefix']) + self.assertEqual((new / 'Applications/League of Legends.app/game').read_text(), 'game data') + self.assertFalse(self.old.exists()) + self.assertFalse((self.folder / 'bulk/prefix.partial').exists()) + + def test_switches_to_an_existing_prefix_without_moving(self): + other = self.folder / 'other' + (other / 'Volumes/SystemRoot').mkdir(parents=True) + result = self.set_prefix(other) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((self.old / 'Applications/League of Legends.app/game').exists()) + self.assertEqual(self.saved_prefix(), str(other)) + + def test_refuses_a_directory_with_other_files(self): + games = self.folder / 'Games' + games.mkdir() + (games / 'other-game').mkdir() + result = self.set_prefix(games) + self.assertNotEqual(result.returncode, 0) + self.assertIn('not a Darling prefix', result.stderr) + self.assertTrue(self.old.exists()) + self.assertFalse((self.workspace / 'build/config.json').exists()) + + def test_refuses_overlay_separators_and_nested_paths(self): + for new in (self.folder / 'a,b', self.folder / 'a:b', self.old / 'inner'): + result = self.set_prefix(new) + self.assertNotEqual(result.returncode, 0, new) + self.assertTrue(self.old.exists()) + + +if __name__ == '__main__': + unittest.main()