feat(nix): add experimental rootless FHS package and desktop launcher

This commit is contained in:
Nicolai Bruns committed 2026-10-18 00:30:14 +02:00
1 parent e287abb5d6
commit f5e65e3cf1
28 files changed
+1684 -63

No files matched your search

+5 -1
View File
@@ -20,4 +20,8 @@ cmake -S build/darling-src -B build/darling-src/build -G 'Unix Makefiles' \
-DCMAKE_C_FLAGS="-O3 -march=native -mtune=generic -pipe" \
-DCMAKE_CXX_FLAGS="-O3 -march=native -mtune=generic -pipe"
cmake --build build/darling-src/build --parallel "${JOBS:-$(nproc)}"
echo 'Build complete. Install the base runtime with: sudo cmake --install build/darling-src/build'
if [ -n "${DARLING_WORKSPACE:-}" ]; then
echo 'Build complete. Install the base runtime with: cmake --install build/darling-src/build'
else
echo 'Build complete. Install the base runtime with: sudo cmake --install build/darling-src/build'
fi
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
: "${DARLING_HOST_UID:?missing Darling user namespace}"
: "${DARLING_WORKSPACE:?missing Darling workspace}"
if [ "${DARLING_SETUP:-0}" != 1 ] && [ "${1:-}" != shutdown ]; then
python3 "$DARLING_WORKSPACE/scripts/nix-state.py" check || {
echo 'Rootless runtime readiness missing; run darling-lol --installer in a terminal.' >&2
exit 1
}
fi
# This is namespace root (a subordinate host UID), not host root. Keep real
# UID/GID intact so Darling initializes the correct guest account and ownership.
exec setpriv --euid 0 --egid 0 --keep-groups \
"$DARLING_WORKSPACE/usr-local/bin/darling" "$@"
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
: "${DARLING_HOST_UID:?start this environment with darling-lol}"
: "${DARLING_HOST_GID:?start this environment with darling-lol}"
# Carry only namespace-scoped capabilities into the ordinary-user process.
# Darling's shim temporarily switches effective IDs; real IDs stay the user's.
exec setpriv --ruid "$DARLING_HOST_UID" --euid "$DARLING_HOST_UID" \
--rgid "$DARLING_HOST_GID" --egid "$DARLING_HOST_GID" --keep-groups \
--inh-caps +all --ambient-caps +all "$@"
+14 -1
View File
@@ -3,6 +3,13 @@
set -eu
repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$repo"
if [ -n "${DARLING_WORKSPACE:-}" ]; then
python3 scripts/nix-idle.py
python3 scripts/nix-state.py check-base || { echo 'Base runtime readiness missing; run darling-lol --update.' >&2; exit 1; }
python3 scripts/nix-state.py invalidate
# Prefix initialization is a setup-only exception to the runner launch gate.
export DARLING_SETUP=1
fi
python3 scripts/configure.py
. build/runtime/tools/runtime-paths.sh
if [ -d build/venv/bin ]; then PATH="$repo/build/venv/bin:$PATH"; export PATH; fi
@@ -16,7 +23,7 @@ prefix=$prefix_dir
mkdir -p "$prefix/private/tmp"
chmod 1777 "$prefix/private/tmp"
# Build the system root store now that the prefix exists; Darling is stopped next anyway.
python3 build/runtime/tools/ensure-darling-root-certificates.py ||
python3 build/runtime/tools/ensure-darling-root-certificates.py || \
echo 'Darling system root store not installed; the launcher retries it' >&2
python3 build/runtime/tools/stop-darling-runtime.py
sh build/runtime/tools/install-riot-presentation-runtime.sh
@@ -29,3 +36,9 @@ fi
python3 build/runtime/tools/install-darling-system-profiler.py
# Every guest process loads these overlays; refuse a set the compiler broke (check-darwin-tlv-calls.py).
python3 build/runtime/tools/validate-riot-runtime.py
# Mark this canonical prefix and configured guest ready only after validation.
if [ -n "${DARLING_WORKSPACE:-}" ]; then
# Overlay installation can also refresh darlingserver; record the final base.
python3 scripts/nix-state.py stamp-base
python3 scripts/nix-state.py stamp
fi
+6 -3
View File
@@ -2,10 +2,13 @@
set -eu
repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$repo"
if [ -n "${DARLING_WORKSPACE:-}" ] && ! python3 scripts/nix-state.py check; then
echo 'Rootless runtime readiness is missing or incompatible; run darling-lol --installer in a terminal.' >&2
exit 1
fi
export PATH="${DARLING_RUNNER_DIR:+$DARLING_RUNNER_DIR:}$repo/usr-local/bin:/usr/local/bin:$PATH"
region=${1:-euw}
# Riot publishes LAN, LAS and OCE installers under their platform codes.
case "$region" in lan) region=la1;; las) region=la2;; oce) region=oc1;; esac
case "$region" in euw|eune|na|br|kr|jp|la1|la2|oc1|ru|tr) ;; *) echo 'Use a Riot installer region code' >&2; exit 1;; esac
case "$region" in euw|eune|na|br|kr|jp|lan|las|oce|ru|tr) ;; *) echo 'Use a Riot installer region code' >&2; exit 1;; esac
python3 scripts/configure.py >/dev/null
bash build/runtime/tools/fetch-installer.sh "$region" build/installer
unzip -q -o "build/installer/live.$region.zip" -d build/installer
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Refresh managed package files without following mutable destination symlinks."""
import os
from pathlib import Path
import shutil
import stat
import sys
import tempfile
source, destination = map(Path, sys.argv[1:])
items = 'docs patches research scripts tests tools upstream LICENSE NOTICE.md README.md requirements.txt'.split()
# Preflight the entire managed tree before writing anything. Never chmod or copy
# through a link planted by a previous installation (including directory links).
for root in [destination, *destination.parents]:
if root.is_symlink():
raise SystemExit(f'Refusing symlink workspace ancestor: {root}')
for item in items:
for base in [source / item, destination / item]:
for path in [base, *base.rglob('*')] if base.is_dir() else [base]:
if path.is_symlink():
raise SystemExit(f'Refusing symlink in managed package tree: {path}')
if path.exists() and not (path.is_file() or path.is_dir()):
raise SystemExit(f'Refusing special file in managed package tree: {path}')
for item in items:
src = source / item
if not src.exists():
continue
for path in [src, *src.rglob('*')] if src.is_dir() else [src]:
target = destination / path.relative_to(source)
if path.is_dir():
target.mkdir(parents=True, exist_ok=True)
target.chmod(0o755)
else:
target.parent.mkdir(parents=True, exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix='.package-', dir=target.parent)
try:
with os.fdopen(fd, 'wb') as output, path.open('rb') as input_file:
shutil.copyfileobj(input_file, output)
# Keep executable bits, remove special bits, and keep owner writable.
os.chmod(temporary, stat.S_IMODE(path.stat().st_mode) & 0o777 | 0o600)
os.replace(temporary, target)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Read-only, fail-closed rootless maintenance preflight. Never signal a process."""
import os
from pathlib import Path
prefix = Path(os.environ.get('DPREFIX', str(Path.home() / '.darling'))).resolve()
try:
pid = int((prefix / '.init.pid').read_text().strip())
except FileNotFoundError:
pid = 0
except (OSError, ValueError):
raise SystemExit('Cannot validate prefix server marker; inspect it before maintenance.')
if pid > 0:
try:
status = (Path('/proc') / str(pid) / 'status').read_text()
if not any(line.startswith('State:') and line.split()[1] == 'Z' for line in status.splitlines()):
raise SystemExit('Prefix has a live server marker; exit Riot/League and run darling shutdown in the ORIGINAL namespace before updating. No process was stopped.')
except FileNotFoundError:
pass
# Only inspect same-user mappings under this exact prefix. An unrelated game's
# command name is not grounds to stop or modify it. Unreadable same-user maps
# fail closed because we cannot rule out a client still using these files.
paths = [str(prefix / name) + '/' for name in (
'Applications/Riot Client.app', 'Applications/League of Legends.app',
'Users/Shared/Riot Games')]
for process in Path('/proc').iterdir():
if not process.name.isdigit():
continue
try:
if process.stat().st_uid != os.getuid():
continue
mappings = (process / 'maps').read_text()
if any(path in mappings for path in paths):
raise SystemExit('A client maps this prefix; exit it before rootless maintenance. No process was stopped.')
except (FileNotFoundError, ProcessLookupError, PermissionError):
continue
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Run setup and the client in the SAME namespace. Never reconnect by killing it.
set -euo pipefail
cd "${DARLING_WORKSPACE:?start with darling-lol}"
ready() { python3 scripts/nix-state.py check; }
interactive() {
if [ ! -t 0 ]; then
echo 'Setup needs an interactive terminal. Run darling-lol --installer in a terminal (or use the desktop entry with Terminal=true).' >&2
exit 1
fi
}
if ! ready; then
interactive
echo "Prefix: $DPREFIX"
if ! python3 scripts/nix-state.py check-base; then
echo 'Base runtime missing or incompatible; building requires about 40–50 GB.'
read -r -p 'Build/update the rootless runtime? [y/N] ' reply || exit 1
case "$reply" in y|Y) ;; *) exit 1;; esac
./scripts/update.sh
else
./scripts/install-runtime.sh
fi
fi
# Ready existing installations never rebuild merely because the launcher opens.
if [ "${1:-}" = --installer ] ||
{ [ ! -e "$DPREFIX/Applications/Riot Client.app" ] &&
[ ! -d "$DPREFIX/Users/Shared/Riot Games/Riot Client.app" ]; }; then
interactive
read -r -p 'Region [euw]: ' region || exit 1
exec ./scripts/installer.sh "${region:-euw}"
fi
exec ./scripts/run.sh
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""Per-prefix rootless readiness; no Darling execution or prefix writes."""
import hashlib
import json
import os
from pathlib import Path
import pwd
import sys
sys.dont_write_bytecode = True
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
from runtime_paths import runtime_paths
def selected_paths():
config_path = ROOT / 'build/config.json'
config = json.loads(config_path.read_text()) if config_path.exists() else {}
guest = config.get('guest_user') or pwd.getpwuid(os.getuid()).pw_name
config.setdefault('prefix', str(Path.home() / '.darling'))
config.setdefault('guest_home', '/Users/' + guest)
configured_prefix = Path(os.environ.get('DPREFIX') or config['prefix']).resolve()
env_prefix = os.environ.get('DPREFIX')
try:
os.environ['DPREFIX'] = str(configured_prefix)
prefix, home = runtime_paths(config)
finally:
if env_prefix is None:
os.environ.pop('DPREFIX', None)
else:
os.environ['DPREFIX'] = env_prefix
return prefix.resolve(), home.resolve(), guest
def guest_user():
return selected_paths()[2]
def digest(paths):
result = hashlib.sha256()
for path in sorted(paths):
if path.is_file():
result.update(str(path.relative_to(ROOT)).encode() + b'\0' + path.read_bytes())
return result.hexdigest()
def base_identity():
runtime_tree = ROOT / 'usr-local/libexec/darling'
runtime_modes = {name: (ROOT / 'usr-local/bin' / name).stat().st_mode & 0o777
if (ROOT / 'usr-local/bin' / name).exists() else None
for name in ('darling', 'darlingserver')}
return {'schema': 2, 'package': os.environ.get('DARLING_PACKAGE_ID', ''),
'runtime_modes': runtime_modes,
'runtime_tree': inventory(runtime_tree),
'sources': digest([* (ROOT / 'patches').glob('*.patch'),
ROOT / 'upstream/manifest.json',
* (ROOT / 'scripts').glob('*.py'),
* (ROOT / 'scripts').glob('*.sh'),
* (ROOT / 'tools').glob('*')]),
'runtime': digest([ROOT / 'usr-local/bin/darling',
ROOT / 'usr-local/bin/darlingserver'])}
def inventory(root):
"""Include all required trees and modes without hashing large installed binaries."""
if not root.is_dir():
return None
result = hashlib.sha256()
def enumeration_error(error):
raise error
for directory, dirs, files in os.walk(root, followlinks=False,
onerror=enumeration_error):
for name in sorted(dirs + files):
path = Path(directory) / name
if any(token in path.name for token in ('.before-', '.rt-', '.llvm')):
continue
info = path.lstat()
target_str = ''
if path.is_symlink():
try:
target_str = f':{os.readlink(path)}'
except OSError:
pass
result.update(str(path.relative_to(root)).encode() + b'\0')
result.update(f'{info.st_mode}:{info.st_size}:{info.st_mtime_ns}{target_str}'.encode())
return result.hexdigest()
def overlay_identity(home):
result: dict[str, object] = {name: inventory(home / name) for name in
('RuntimeLibraries', 'RuntimeFrameworks',
'Library/Audio/Plug-Ins/Components/DarlingRiotAudio.component')}
helper = home / 'libDarlingXShm.dylib'
result['xshm'] = ([helper.stat().st_mode, helper.stat().st_size,
helper.stat().st_mtime_ns] if helper.is_file() else None)
return result
def identity():
prefix, home, guest = selected_paths()
return dict(base_identity(), prefix=str(prefix), guest=guest, guest_home=str(home),
overlays=overlay_identity(home), uid=os.getuid(), gid=os.getgid())
def read(path):
try:
return json.loads(path.read_text())
except (OSError, ValueError):
return None
def atomic(path, value):
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix('.tmp')
temporary.write_text(json.dumps(value, sort_keys=True) + '\n')
temporary.replace(path)
def main():
action = sys.argv[1]
state = identity()
key = hashlib.sha256(state['prefix'].encode()).hexdigest()
marker = ROOT / 'build/nix-ready' / (key + '.json')
base_marker = ROOT / 'build/nix-base.json'
if action == 'guest':
print(state['guest'])
elif action == 'check-base':
return 0 if read(base_marker) == base_identity() else 1
elif action == 'check':
return 0 if read(marker) == state and read(base_marker) == base_identity() else 1
elif action == 'stamp-base':
if (not all((ROOT / 'usr-local/bin' / name).is_file() and
os.access(ROOT / 'usr-local/bin' / name, os.X_OK)
for name in ('darling', 'darlingserver')) or
not (ROOT / 'usr-local/libexec/darling').is_dir() or not state['package']):
raise SystemExit('Cannot stamp readiness without executable base runtime, SDK and package identity')
atomic(base_marker, base_identity())
elif action == 'stamp':
if read(base_marker) != base_identity():
raise SystemExit('Base runtime readiness is missing; run darling-lol --update in a terminal.')
home = Path(str(state['guest_home']))
if not home.is_dir() or not any((home / 'RuntimeLibraries').glob('*')) or not any((home / 'RuntimeFrameworks').glob('*')):
raise SystemExit('Cannot stamp an empty or missing prefix runtime')
# Reuse the complete installation gate; readiness must never mint an
# apparently valid manifest for partial overlays, even when called directly.
import importlib.util
spec = importlib.util.spec_from_file_location('runtime_validation', ROOT / 'tools/validate-riot-runtime.py')
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
module.inputs(Path(str(state['prefix'])), home)
atomic(marker, state)
elif action in ('invalidate', 'invalidate-base'):
marker.unlink(missing_ok=True)
if action == 'invalidate-base':
base_marker.unlink(missing_ok=True)
else:
raise SystemExit(f'Unknown state action: {action}')
return 0
if __name__ == '__main__':
raise SystemExit(main())
+23 -13
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env python3
"""Fetch pinned Darling sources and apply each patch to its owning repository."""
import argparse,json,os,subprocess,tempfile
import argparse,json,os,subprocess,tempfile,sys
from pathlib import Path
# --check-only must not create import caches in the source package either.
sys.dont_write_bytecode=True
from common import ROOT,render
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--source',type=Path,default=ROOT/'build/darling-src')
@@ -25,19 +27,27 @@ def previous_patches(component):
if shown.returncode==0:yield render(shown.stdout)
if not (source/'.git').exists():
if a.check_only:p.error('check-only requires an existing pinned source checkout')
if source.exists():p.error('refusing non-checkout destination; choose an empty source path')
source.parent.mkdir(parents=True,exist_ok=True)
run(['git','clone',manifest['upstream'],str(source)])
run(['git','-C',str(source),'checkout','--detach',manifest['revision']])
# Recursive initialization can spend a long time fetching nested repos (and
# Git LFS assets). Explicit progress keeps the last completed submodule
# from looking like the operation that is currently stuck. The pinned
# darling-swift .lfsconfig points at the retired git-lfs.darlinghq.org, so
# fetch that submodule's LFS objects from GitHub, which still serves them;
# otherwise the checkout blocks on the dead host right after sqlite.
print('Initializing Darling submodules (large repositories/LFS assets may take a while)...',flush=True)
run(['git','-C',str(source),'-c','lfs.url=https://github.com/darlinghq/darling-swift.git/info/lfs',
'submodule','update','--init','--recursive','--progress'])
print('Darling submodules initialized.',flush=True)
# Fetch the exact pin, not today's master. Publish only a complete checkout;
# failed downloads are removed without touching an existing user checkout.
with tempfile.TemporaryDirectory(prefix='darling-clone-',dir=source.parent) as temporary:
checkout=Path(temporary)/'source'
run(['git','init',str(checkout)])
run(['git','-C',str(checkout),'remote','add','origin',manifest['upstream']])
run(['git','-C',str(checkout),'fetch','--depth','1','origin',manifest['revision']])
run(['git','-C',str(checkout),'checkout','--detach','FETCH_HEAD'])
print('Initializing Darling submodules (large repositories/LFS assets may take a while)...',flush=True)
for attempt in range(1,4):
try:
run(['git','-C',str(checkout),'-c','lfs.url=https://github.com/darlinghq/darling-swift.git/info/lfs',
'submodule','update','--init','--recursive','--depth','1','--progress'])
break
except subprocess.CalledProcessError:
if attempt==3:raise
print(f'Submodule fetch interrupted (attempt {attempt}/3). Retrying...',flush=True)
checkout.rename(source)
# Never reset someone else's checkout to make a patch apply.
for component in manifest['components']:
repo=source/component['path']
+5
View File
@@ -2,6 +2,11 @@
set -eu
repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$repo"
if [ -n "${DARLING_WORKSPACE:-}" ] && ! python3 scripts/nix-state.py check; then
echo 'Rootless runtime readiness is missing or incompatible; run darling-lol --installer in a terminal.' >&2
exit 1
fi
export PATH="${DARLING_RUNNER_DIR:+$DARLING_RUNNER_DIR:}$repo/usr-local/bin:/usr/local/bin:$PATH"
python3 scripts/configure.py >/dev/null
if [ -d build/venv/bin ]; then PATH="$repo/build/venv/bin:$PATH"; export PATH; fi
# darling-lol perf: optionally wrap with gamemoderun for automatic
+20 -1
View File
@@ -1,10 +1,29 @@
#!/bin/sh
# Bring an existing build and installation up to date after `git pull`: re-apply the patches that changed
# (prepare.py reverts the earlier version it finds applied), rebuild incrementally, and reinstall the base
# runtime and the per-user overrides. Run it with no Riot/League session open; it never stops one.
# runtime, and the per-user overrides. Run it after manually closing Riot/League.
# Rootless callers must remain in their original namespace; the outer launcher
# refuses a live prefix marker instead of trying to reconnect or stop a session.
set -eu
repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$repo"
if [ -n "${DARLING_WORKSPACE:-}" ]; then
python3 scripts/nix-idle.py
# Initial setup and upgrades use the same supported user-owned install path.
# Keep each package closure rooted: copied ELF files can retain its loader.
package=${DARLING_PACKAGE_ID%/share/darling-lol}
case "$package" in /nix/store/*) ;; *) echo 'Missing Nix package identity' >&2; exit 1;; esac
mkdir -p build/nix-gcroots
nix-store --realise "$package" --add-root "$repo/build/nix-gcroots/$(basename "$package")" --indirect >/dev/null
python3 scripts/nix-state.py invalidate-base
./scripts/build.sh
python3 build/runtime/tools/stop-darling-runtime.py
cmake --install build/darling-src/build
python3 scripts/nix-state.py stamp-base
./scripts/install-runtime.sh
echo 'Rootless update installed. In this shell use scripts/run.sh; otherwise exit this shell and start darling-lol.'
exit 0
fi
if [ ! -d build/darling-src/build ]; then
echo 'No existing build; follow the first-time setup in README.md instead.' >&2
exit 1
+3
View File
@@ -12,6 +12,9 @@ errors=[]
secret_patterns=[r'gh[pousr]_[A-Za-z0-9]{30,}',r'AKIA[0-9A-Z]{16}',r'eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}',r'-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----']
for name in filter(None,files):
p=ROOT/name;data=p.read_bytes()
# The Nix desktop package intentionally ships this icon, not a runtime binary.
if name == 'darling-lol.png' and data.startswith(b'\x89PNG\r\n\x1a\n'):
continue
if data.startswith((b'\x7fELF',b'MZ',b'\xcf\xfa\xed\xfe',b'\xca\xfe\xba\xbe')) or b'\0' in data:
errors.append(name+': binary content is not part of this source package');continue
try:text=data.decode()