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 <[email protected]>
This commit is contained in:
David GhegheaandClaude Opus 5.5 committed 2026-10-18 18:32:14 +02:00
1 parent 78a47d633f
commit 48ae75855f
5 files changed
+220 -2

No files matched your search

+12
View File
@@ -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
+9 -2
View File
@@ -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
+80
View File
@@ -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())
+19
View File
@@ -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)
+100
View File
@@ -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()