Merge remote-tracking branch 'origin/main' into pr-fix-60

# Conflicts:
#	docs/TROUBLESHOOTING.md
#	tools/riot-launcher-offline-check.py
This commit is contained in:
lucannez64 committed 2026-10-25 19:11:01 +01:00
commit a31753c548
86 files changed
+9623 -643

No files matched your search

+3 -3
View File
@@ -1,3 +1,3 @@
# Unified-diff context uses a single space for an empty source line.
# Do not report that patch syntax as trailing source whitespace.
patches/*.patch whitespace=-blank-at-eol
# Unified-diff context adds a space before source tabs and empty lines.
# Do not report that patch syntax as source whitespace errors.
patches/*.patch whitespace=-blank-at-eol,-space-before-tab
+23
View File
@@ -3,6 +3,12 @@ on: [push, pull_request]
permissions:
contents: read
jobs:
nix-fonts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@3c6342b75005b9cc7c7d4069c3e9179247c89012 # v31
- run: nix build .#checks.x86_64-linux.fonts --no-link -L
validate:
runs-on: ubuntu-latest
steps:
@@ -10,10 +16,27 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Require C++ regression compiler
run: |
command -v c++
c++ --version
- run: python scripts/validate.py
- run: python tools/riot-input-offline-check.py
- run: python tools/riot-launcher-offline-check.py
- run: python tools/riot-runtime-cleanup-offline-check.py
- run: python tools/riot-cdn-dns-offline-check.py
- run: python -m unittest discover -s tests -p 'test_nix_fonts.py'
- run: python -m unittest discover -s tests -p 'test_nix_repairs.py'
- run: python -m unittest discover -s tests -p 'test_hidden_riot_recovery.py'
- run: python -m unittest discover -s tests -p 'test_desktop_run.py'
- run: python -m unittest discover -s tests -p 'test_desktop_entry.py'
- run: python -m unittest discover -s tests -p 'test_urlsession_runner.py'
- run: python -m unittest discover -s tests -p 'test_system_profiler.py'
- run: python -m unittest discover -s tests -p 'test_metal_device_info.py'
- run: python -m unittest discover -s tests -p 'test_indium_address_shadow.py' -v
- run: python -m unittest discover -s tests -p 'test_exec_guest.py'
- run: python -m unittest discover -s tests -p 'test_close_league_client_in_game.py'
- run: python -m unittest discover -s tests -p 'test_recover_stale_client.py'
- run: python -m unittest discover -s tests -p 'test_clean_stale_helpers.py'
- run: python -m unittest discover -s tests -p 'test_hide_league_client_in_game.py'
- run: python -m unittest discover -s tests -p 'test_client_in_game_tricks.py'
+67
View File
@@ -0,0 +1,67 @@
# Contributing
Thank you for improving `darling-lol`. The project is an unofficial
interoperability effort; contributions must keep it in that scope.
## Before you start
- Read [`docs/COMPLIANCE.md`](docs/COMPLIANCE.md) and
[`docs/INTEROPERABILITY.md`](docs/INTEROPERABILITY.md).
- Do not add Riot client files, game binaries, game assets, logos, screenshots,
gameplay audio/video, account data, tokens or credentials.
- Do not add DRM/anti-cheat/authentication circumvention, telemetry spoofing,
credential handling, or gameplay automation. Changes that present the project
as an evasion tool are out of scope.
- Keep existing copyright, license and attribution notices. New third-party code
or assets need a license and provenance note in the pull request.
## License of contributions
- Original integration tooling (scripts, tools, tests, docs and project artwork)
is licensed under **GPL-3.0-only**.
- Patches to upstream components are licensed under the license of the upstream
file(s) they modify. If a patch touches files under different licenses, each
hunk keeps the license of its target file.
- By submitting a contribution, you confirm that you have the right to submit it
and agree to license it under the applicable terms above.
## Developer Certificate of Origin
Sign off every commit:
```sh
git commit -s -m "area: short description"
```
The sign-off certifies the Developer Certificate of Origin 1.1
(<https://developercertificate.org/>): you created the contribution or have the
right to submit it under the project's license, and you understand that the
contribution and its sign-off are public and may be redistributed.
## Checks
Run the source audit and the relevant tests before opening a pull request:
```sh
python3 scripts/validate.py
python3 -m unittest discover -s tests -p 'test_*.py'
```
The audit checks tracked source, rendered Python/shell, pinned revision format,
patch existence and patch parsing. It does not check patch applicability to the
pinned revisions. Do not bypass it; fix the source or document the exception in
the pull request.
## Pull requests
- Keep changes focused and describe the compatibility problem and the fix.
- Mention any change that touches platform identity, sandbox/AMFI probes,
anti-cheat or account risk. Do not describe it as stealth, evasion, spoofing
or an anti-cheat bypass.
- If you change a patch, run `python3 scripts/prepare.py --check-only` against
an existing pinned source checkout to verify that it applies to the pinned
revision.
- If you change a user-facing variable, keep the old name as a deprecated alias
for at least one release.
This is not legal advice. See [`docs/INTEROPERABILITY.md`](docs/INTEROPERABILITY.md).
+13 -1
View File
@@ -4,4 +4,16 @@
The manifest points to public upstream source repositories. This repository includes source differences and authored integration/research tools, not a copy of the upstream runtime, Apple's SDK binaries or Riot's client/game assets. Build artifacts and local installs are excluded.
Some compatibility tools contain short instruction signatures or API names used to recognize the user's installed client. The client itself is not redistributed. Installation downloads from Riot's distribution service directly.
Some compatibility tools refer to public API names and constants of the user's installed client. The client itself is not redistributed. Installation downloads from Riot's distribution service directly.
## Historical material
Earlier revisions of this repository included a third-party game logo and an embedded gameplay clip. Both were removed from the current tree; the current tree uses an original project mark and contains no gameplay media. Git history and third-party forks may still contain those earlier revisions. They are not part of the current project, are not licensed by this project, and should not be reused.
## Trademarks
League of Legends and Riot Games are trademarks or registered trademarks of Riot Games, Inc.
This project is an unofficial compatibility effort and is not affiliated with, endorsed by, or
sponsored by Riot Games.
See [compliance and terminology guidance](docs/COMPLIANCE.md) for scope and review expectations, and [interoperability and compliance scope](docs/INTEROPERABILITY.md) for the project's practices on interoperability, copyright, access controls and trademarks. Neither document is legal advice.
+66 -7
View File
@@ -16,14 +16,14 @@
>
> Even with all of that, you will probably still get permabanned.
>
> **3. Takedown.** Riot may well shut this project down. If that happens, I will publish a fork of Darling containing only the changes made here (none of them breach anything), and release the small script as a gist or similar.
> **3. Continuity.** If this repository is discontinued, the patches remain available under their upstream licenses, and the original integration tooling remains available under GPL-3.0.
>
> **4. To Riot.** If you are reading this, it means a lot of people want to play your game on Linux and are disappointed that they currently can't. As with macOS for your other games (TFT, Valorant), it feels like Riot doesn't care about a part of its players who have been waiting the longest. If you want to fix this, make LoL Next compatible with Linux, either natively or through a layer like Proton. Yes, that includes your kernel anticheat: Epic Games did it with Easy Anti-Cheat. Follow the trend. This is the year of Linux, and people shouldn't be locked out of a platform or forced to dual boot for a single game.
> Since Riot have heard of the repo if you want to contact me you can at [email protected] (especially if you want to do an official or supported version of league of legends on linux)
If someone else wants to contact me know that i'm open to jobs, part-time remote only as a employee and full time remote as a freelancer for a mission
https://github.com/user-attachments/assets/140df3e0-3790-46b6-bab5-578139b84a7c
A short manual ARAM run and the current compatibility status are documented in [docs/STATUS.md](docs/STATUS.md).
Experimental source patches and tools for running the **macOS League of Legends client on x86-64 Linux through Darling**.
@@ -36,11 +36,11 @@ This is a development snapshot, not a finished launcher. Riot and League clients
- Reusable server receive buffers and Darwin allocator hints translated to Linux lazy reclamation.
- Build/install/launch wrappers, atomic incremental installers and standalone regression probes.
Game files, Riot installers, account databases, tokens, local prefixes, captures and compiled binaries are not included. Install the game manually through Riot. The normal launcher does not enable an Electron inspector or start UI automation helpers. The launcher clears `DYLD_INSERT_LIBRARIES` and retains Darling runtime library/framework overrides; this is not a claim of approval by Riot.
Game files, Riot installers, account databases, tokens, local prefixes, captures and compiled binaries are not included. Install the game manually through Riot. The normal launcher does not enable an Electron inspector or start UI automation helpers; the opt-in window-management helpers described below are off by default and do not patch client binaries. The launcher clears `DYLD_INSERT_LIBRARIES` and retains Darling runtime library/framework overrides; this is not a claim of approval by Riot.
## Requirements
- x86-64 Linux **5.14 or newer**, an X11 session or Xwayland, and a working hardware Vulkan/OpenGL driver. AMD RADV has been exercised; other drivers are not validated. Hybrid laptops usually render on the integrated GPU unless started with PRIME offload; NVIDIA is not validated. See [hybrid graphics](docs/TROUBLESHOOTING.md#hybrid-graphics-laptops-amdintel-igpu--nvidia).
- x86-64 Linux **5.14 or newer**, an X11 session or Xwayland, and a working hardware Vulkan/OpenGL driver. AMD RADV and the NVIDIA driver (proprietary and open kernel modules) have been exercised, see [NVIDIA](docs/TROUBLESHOOTING.md#nvidia); other drivers are not validated. Hybrid laptops usually render on the integrated GPU unless started with PRIME offload. See [hybrid graphics](docs/TROUBLESHOOTING.md#hybrid-graphics-laptops-amdintel-igpu--nvidia).
- Git + Git LFS, Bash, curl, unzip, CMake, Make, Clang/LLD, LLVM development files, a C/C++ compiler, Python 3.12+ and the Darling build dependencies. The patch includes LLVM 22/23 branch-opcode compatibility; the new PC used LLVM/Clang 23. Node.js is needed only for the offline JavaScript checks.
- X11/XCB/SHM development files for the current runtime helpers.
- Enough free disk for Darling sources/build, Git LFS assets and the game: budget at least 50 GB. Building is substantially larger than this source-only repository.
@@ -109,9 +109,52 @@ First installation and future Riot updates can still encounter compatibility gap
For another region, set `RCLOL_REGION` and `RCLOL_LOCALE` before launch. For example `RCLOL_REGION=NA RCLOL_LOCALE=en_US ./scripts/run.sh`. Linux display selection follows `$DISPLAY`; the legacy launcher fallback is `:1`.
If the game or Riot client was closed but `darlingserver` is still running, opt in to partial recovery before the next launch:
```sh
RCLOL_RECOVER_STALE_CLIENT=1 ./scripts/run.sh
```
It stops only a windowless Riot/League/CEF tree at least 60 seconds old,
preserves `darlingserver`, launchd/mldr, sockets and `.init.pid`, and then
starts the product normally. It recognizes `LeagueClientUx`, `Riot Client` and
`RiotClientServices` windows, does nothing while a game or managed client window
is present, and fails closed when X11 properties cannot be read. See
[troubleshooting](docs/TROUBLESHOOTING.md#client-remains-running-without-a-window).
If repeated launches leave several `close-league-client-in-game.py` watchers,
opt in to removing the orphaned ones before the new helper starts:
```sh
RCLOL_CLEAN_STALE_HELPERS=1 ./scripts/run.sh
```
It only removes helpers of this prefix whose parent is `init`/`systemd` (the
launcher that started them has exited). A helper of a live launcher, another
prefix, the game, Riot/League/CEF, `darlingserver` and the runtime are never
touched. See [troubleshooting](docs/TROUBLESHOOTING.md#multiple-client-in-game-helpers).
For automatic client handling during a match, opt in with one switch:
```sh
RCLOL_CLIENT_IN_GAME_TRICKS=1 ./scripts/run.sh
```
It reads `lol-user-experience` from the installed League client's
`LCULocalPreferences.yaml`. When `potatoModeEnabled` is true, it closes the
client UI if `closeLeagueClientDuringGame` is true, and hides the
`LeagueClientUx`/`Riot Client` X11 windows when that value is false. Missing or
invalid preference values, disabled client optimizations and unreadable files
select no action. The `RCLOL_CLOSE_CLIENT_IN_GAME` and
`RCLOL_HIDE_CLIENT_IN_GAME` low-level switches remain available when the
automatic switch is not set. The helper exits after ten minutes without a
client or match once it has seen one. See
[troubleshooting](docs/TROUBLESHOOTING.md#automatic-client-handling-during-a-match)
for the measured effect and caveats.
## Testing
Start with standalone regression checks, then play a match manually. See [testing](docs/TESTING.md). Do not attach a debugger, query client APIs, inject diagnostic hooks or automate client/game input. This project ships no client-side automation, inspection or profiling helpers.
Start with standalone regression checks, then play a match manually. See [testing](docs/TESTING.md). Do not attach a debugger, query client APIs, inject diagnostic hooks or automate client/game input. The project includes standalone compatibility probes and the opt-in host-side window-management helpers described above, but no client injection hooks, client API automation or input automation; the normal launcher enables none of them.
FPS counters, game presentation requests and physical display refresh are different measurements. Record average and p99 frame times, the active scene, graphics settings, charger/power state and whether the game stayed focused. See [current status](docs/STATUS.md).
@@ -136,10 +179,26 @@ scripts/ portable entry points
upstream/manifest.json exact base revisions and patch ownership
patches/ current source changes, one patch per repository
tools/ runtime templates, installers and standalone regression probes
docs/ setup, testing, status and licensing notes
docs/ setup, testing, status, compliance and interoperability notes
build/ local sources, outputs and rendered tools (ignored)
```
Darling/upstream components retain their existing licenses; see [third-party notes](NOTICE.md). Original integration tooling is distributed under GPL-3.0; patches retain the licensing of their target files. This project is not affiliated with Riot Games.
Darling/upstream components retain their existing licenses; see [third-party notes](NOTICE.md). Original integration tooling is distributed under GPL-3.0; patches retain the licensing of their target files.
## Interoperability and compliance
This project is an unofficial interoperability effort. It distributes source patches for Darling and original tooling, not the Riot client, game code, assets or accounts. The normal launcher does not patch the client, game, CEF or anti-cheat. Users obtain the client from Riot and are responsible for complying with Riot's terms; account restrictions are possible. See [interoperability and compliance scope](docs/INTEROPERABILITY.md) and [compliance guidance](docs/COMPLIANCE.md). Neither document is legal advice.
## Trademarks
League of Legends and Riot Games are trademarks or registered trademarks of Riot Games, Inc.
This project is an unofficial compatibility effort and is not affiliated with, endorsed by, or
sponsored by Riot Games. No game client, game assets, or account data are distributed in this
repository.
See [compliance and terminology guidance](docs/COMPLIANCE.md) and [interoperability and compliance scope](docs/INTEROPERABILITY.md) for the project's scope and review expectations.
## Donations
If you want to help me live or just pay me a coffee
you can at [Ko-Fi](https://ko-fi.com/lucannez64)
+39 -4
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"
@@ -24,6 +34,9 @@ if (workspace / '.launcher.lock').is_symlink():
raise SystemExit('Refusing symlink workspace lock')
PY
mkdir -p "$WORKSPACE"
if [ "${1:-}" = --stop ]; then
exec python3 "$SHARE_DIR/scripts/nix-stop.py" "$WORKSPACE" "$DPREFIX"
fi
# One mutable base runtime per workspace; do not refresh scripts mid-session.
exec 9<>"$WORKSPACE/.launcher.lock"
flock -n 9 || { echo 'Another darling-lol workspace session is active; use its terminal.' >&2; exit 1; }
@@ -35,7 +48,7 @@ if [ -f "$DPREFIX/.init.pid" ]; then
state=$(awk '/^State:/ {print $2}' "/proc/$server_pid/status")
if [ "$state" != Z ]; then
echo 'Prefix has a live server marker; namespace reconnect is unsupported.' >&2
echo 'Use the original darling-lol-shell terminal to run darling shutdown, then retry. No processes were stopped.' >&2
echo 'Run darling-lol --stop to stop it, then retry. No processes were stopped.' >&2
exit 1
fi
fi
@@ -45,16 +58,38 @@ mkdir -p "$WORKSPACE/usr-local"
cd "$WORKSPACE"
# Map subordinate IDs before bubblewrap sets no_new_privs. UID 0 is not host root.
fhs_command=("$UNSHARE_BIN" --user --map-auto --map-current-user --setuid=0 --setgid=0 "$FHS_BIN")
stop_request="$WORKSPACE/.stop-request"
rm -f "$stop_request"
# Ctrl+C, a closed terminal or SIGTERM must reach nix-lifecycle.py, which shuts
# Darling down in its namespace. They would kill bubblewrap first, and
# bubblewrap's --die-with-parent would kill the lifecycle with it. So bubblewrap
# starts with them ignored and env restores them inside. This supervisor turns
# them into a stop request, the same one darling-lol --stop makes.
session() {
(trap '' INT QUIT HUP TERM
exec "${fhs_command[@]}" env --default-signal=INT,QUIT,HUP,TERM bash ./scripts/nix-session.sh "$@") 0<&0 &
local pid=$! status
trap 'touch "$stop_request"' INT HUP TERM
while :; do
wait "$pid" && status=0 || status=$?
kill -0 "$pid" 2>/dev/null || break
done
trap - INT HUP TERM
rm -f "$stop_request"
return "$status"
}
# Keep this supervisor alive to hold the lock: bubblewrap may close extra FDs.
# It never owns or signals game processes; foreground execution carries SIGINT.
# It never owns or signals game processes.
case "${1:-}" in
--shell) shift; "${fhs_command[@]}" bash "$@";;
--update) shift; "${fhs_command[@]}" ./scripts/update.sh "$@";;
--installer) shift; "${fhs_command[@]}" bash ./scripts/nix-session.sh --installer "$@";;
# 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
"${fhs_command[@]}" "$@"
else
"${fhs_command[@]}" bash ./scripts/nix-session.sh
session
fi;;
esac
+2 -2
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="darling-lol">
<title>darling-lol</title>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Darling Launcher">
<title>Darling Launcher</title>
<rect x="8" y="8" width="112" height="112" rx="26" fill="#0f172a"/>
<path fill-rule="evenodd" fill="#2dd4bf" d="M38 30h20c21 0 34 15 34 34s-13 34-34 34H38V30zm18 18v32h2c9 0 16-7 16-16s-7-16-16-16h-2z"/>
<rect x="30" y="90" width="68" height="8" rx="4" fill="#818cf8"/>

Before

Width:  |  Height:  |  Size: 411 B

After

Width:  |  Height:  |  Size: 421 B

+76
View File
@@ -0,0 +1,76 @@
# Scope and compliance guidance
This project is an unofficial compatibility layer for running the macOS League of Legends
client on Linux through Darling. It is not affiliated with, endorsed by, or sponsored by Riot
Games, Inc. See [interoperability and compliance scope](INTEROPERABILITY.md) for the project's
practices on interoperability, copyright, access controls and trademarks. Neither document is
legal advice.
## Scope
- The project is about compatibility: making the client run, render and behave correctly on
Linux.
- It does not aim to circumvent DRM, anti-cheat, licensing, telemetry, authentication, or other
platform security controls, and it does not distribute a game client, game assets, or account
data.
- It does not forge authentication tokens or server-side integrity attestations. The synthetic
platform identity is local compatibility metadata, not an Apple-issued identifier or a Riot
credential. This repository does not pass it as Riot authentication data; the official
client's later handling of guest-visible values is outside this repository's scope.
- Do not describe or present the project as a tool for evading detection, preventing bans,
bypassing DRM, or defeating anti-cheat. Do not promise that an account will not be banned.
## Terminology
Prefer accurate compatibility language:
| Prefer | Avoid |
|---|---|
| compatibility issue, client check, platform identity | detection vector, stealth, evasion, bypass |
| hardware/platform reporting | spoofing, telemetry leak |
| runtime/launcher fix | anti-cheat bypass |
Technical facts can be described precisely without framing the work as evasion. If a change
touches a client check or a platform identity, say what it does and why it is needed for
compatibility.
## Known local limitations
- Darling has no macOS MAC/Seatbelt/AMFI framework. The compatibility layer reports the local
`___mac_syscall` probe as satisfied so Chromium/CEF can start. This does **not** implement a
sandbox and does not make the guest secure. Treat the guest as unsandboxed and do not run
untrusted content. It does not interact with Riot's servers or bypass a Riot control.
- Platform-identity values generated by `tools/configure-darling-platform.py` are synthetic and
local. They are not Apple-issued identifiers, credentials or authentication tokens; do not use
them with Apple services or to impersonate real hardware.
- The opt-in hide helper manages local client windows while a match runs. The opt-in close helper
terminates eligible League client UI processes. Neither helper patches client binaries, CEF,
anti-cheat or DRM, and both helpers are off by default.
## Assets and third-party material
- Do not add or link third-party game assets: logos, icons, fonts, screenshots, gameplay
audio/video, client files, game data, or account data.
- Project icons and artwork must be original. Forks must use their own name and icon; see the
trademark notice in `README.md` and `NOTICE.md`.
- Do not commit local prefixes, logs, tokens, captures, or compiled client/runtime binaries.
- Keep upstream copyright, license and attribution notices.
- Donations are voluntary and support the free tooling. They do not sell the game, a license,
access or priority.
## Pull requests and issues
- Describe the compatibility problem and the fix. Avoid "stealth", "spoof", "evade",
"undetectable", or "unbannable" framing.
- Maintainers may ask for wording changes before merging. This is about keeping the project's
scope accurate, not about hiding technical details.
- Contributions that add third-party assets or present the project as a DRM/anti-cheat
circumvention tool are out of scope for this repository.
- Sign off commits under the Developer Certificate of Origin; see
[`CONTRIBUTING.md`](../CONTRIBUTING.md). By contributing, you confirm that you have the right
to submit the work and license it under the project's terms.
## Note
This document describes project scope and review expectations. It is not legal advice. See
[interoperability and compliance scope](INTEROPERABILITY.md) and [`NOTICE.md`](../NOTICE.md).
+176
View File
@@ -0,0 +1,176 @@
# Interoperability and compliance scope
> This document explains how the project stays within interoperability and
> responsible-use practices. It is **not legal advice**. Software, contract and
> platform rules are fact-specific and differ by country. Consult qualified
> counsel before distributing, modifying or using this project.
## 1. Purpose
`darling-lol` (<https://github.com/lucannez64/darling-lol/>) is an unofficial
interoperability effort. It applies source patches to
[Darling](https://github.com/darlinghq/darling), a free-software macOS
compatibility layer, so that the official macOS Riot client can run on
x86-64 Linux. The user obtains the client from Riot and signs in through Riot's
own client and services.
The project is not a game, a game server, an account service, a launcher for
pirated content, or a substitute for Riot's platform. It does not provide Riot
accounts, authentication, matchmaking, payments, telemetry or game servers.
## 2. What we distribute and what we do not
We keep the project on the interoperability side by distributing only:
- source patches for Darling, a free-software compatibility layer;
- original integration, build and test tooling;
- project artwork;
- documentation and license notices.
We do **not** distribute, bundle, mirror or sublicense:
- the Riot client or game binaries;
- game assets, art, audio, video, fonts or data;
- account databases, tokens or credentials;
- Riot installers or installers for pirated content.
The user obtains the client from Riot and installs it through Riot's own
installer. The project does not provide accounts, authentication, matchmaking,
payments, telemetry or game servers, and it does not sell the game, a license,
access or priority.
## 3. Platform identity
Darling needs macOS-like platform metadata. `tools/configure-darling-platform.py`
can generate synthetic serial numbers, UUIDs and related fields, or import
values supplied by the user, so that the guest environment is internally
consistent.
These values are **local compatibility metadata**:
- they are not Apple-issued identifiers, credentials or authentication tokens;
- they are not used with Apple services and should not be used to impersonate
real hardware;
- this repository does not pass them to Riot as authentication data. The values
are exposed to guest applications through IOKit, and the official client's
later handling of them is outside this repository's scope. The official client
authenticates through Riot's own flow.
See `docs/PLATFORM_IDENTITY.md` for the schema, validation rules and warnings.
## 4. Local sandbox/AMFI probe
Darling has no macOS MAC, Seatbelt or AMFI framework. Chromium/CEF, which the
Riot client embeds, probes `___mac_syscall`. An `ENOSYS` response is treated as
fatal and prevents CEF helper processes from starting. The compatibility layer
therefore reports the local probe as satisfied. The relevant code and comments
are in `patches/xnu.patch` (`mac_syscall.c`).
This compatibility shim:
- does not implement a sandbox and does not make the guest secure;
- does not interact with Riot's servers or alter Riot's account, payment or
server-side integrity flows;
- does not grant access to Riot's content or services.
Treat the guest as **unsandboxed**. Do not run untrusted content inside it. This
is a local compatibility limitation, not a claim of security. See
`docs/COMPLIANCE.md` for the project's terminology and scope guidance.
## 5. Copyright and licenses
We keep the project's copyright and licensing position clear:
- the game, client, code and assets remain Riot's; we do not reproduce, store,
distribute or commercialise them;
- we distribute patches to free-software Darling and original tooling, not a
copy of the game or the upstream runtime;
- each patch keeps the license and notices of the upstream file it modifies;
original tooling is GPL-3.0-only;
- we keep the upstream license and attribution notices in the patched files and
describe the project's licensing position in `NOTICE.md`;
- we do not remove copyright or license notices from patched files;
- we review good-faith licensing and removal requests.
We do not sell the game, a license, access or priority. Donations are voluntary
and support development of the free tooling; they do not purchase the game or
any Riot service.
## 6. Access, authentication and integrity controls
The project is designed for compatibility, not for defeating access,
authentication, payment or integrity controls:
- we do not provide tools or features whose purpose is to defeat those controls;
- we do not forge authentication tokens, session data, account data or
server-side integrity attestations;
- we do not proxy or emulate Riot accounts, matchmaking, payments, telemetry or
game servers;
- we do not patch Riot's client or game binaries, CEF or anti-cheat;
- we do not automate gameplay input or provide cheats;
- the opt-in hide helper manages local client windows while a match runs; the
opt-in close helper terminates eligible League client UI processes. Both are
off by default and do not change client binaries, CEF or anti-cheat.
The synthetic platform identity in §3 is local metadata; it is not a credential.
This repository does not pass it as Riot authentication data.
## 7. User agreements and account risk
Use of the project is at your own risk. You must:
- own a legitimate Riot account and obtain the client from Riot;
- comply with Riot's terms and user agreement;
- not cheat, exploit, automate gameplay or use the project to gain an unfair
advantage;
- accept that Riot does not support this environment and may suspend or ban
accounts, including accounts used only for testing. Do not use a main or
ranked account.
The project does not authorise any violation of Riot's terms. If you do not
agree, do not use the project. Account restrictions are a real risk and are
documented in the README announcement.
## 8. Trademarks and presentation
League of Legends and Riot Games are trademarks or registered trademarks of Riot
Games, Inc. The names are used only to describe the compatibility target
(nominative use). The project:
- does not use Riot logos, game art or other Riot brand assets;
- uses an original project mark;
- is not affiliated, endorsed or sponsored by Riot Games;
- asks forks to use their own name and icon and not to imply endorsement.
See `NOTICE.md` and `docs/COMPLIANCE.md`.
## 9. Historical material
Earlier revisions of the repository included a third-party game logo and an
embedded gameplay clip. They were removed from the current tree; the current
tree uses an original project mark and contains no gameplay media. Git history
and third-party forks may still contain those earlier revisions. They are not
part of the current project, are not licensed by this project, and should not be
reused. The project will review good-faith removal requests.
## 10. Notices and contact
For a licensing, trademark or interoperability question, contact the maintainer
using the address in `README.md`, or open an issue in the repository. Include:
- the repository URL, file or commit at issue;
- the right or license you assert;
- the action you request;
- a way to contact you.
For a formal copyright notice, use the hosting platform's copyright notice
process. This document is not a designation of an agent for any platform and
does not replace that process.
## 11. No legal advice and no warranty
This document is a technical and scope statement, not legal advice. The project
is provided "as is", without warranty of any kind. Nothing here guarantees that
a particular use is lawful in a particular country, that Riot will not restrict
an account, or that a court or regulator will accept the project's position. If
you need legal certainty, consult a lawyer qualified in your country.
+53 -5
View File
@@ -57,8 +57,15 @@ for prompts and session output.
Keep the terminal open while clients are running and until cleanup completes.
Default and installer sessions wait for clients to exit, then shut Darling down
in the same namespace. A failed shutdown is retried without discarding that
namespace.
in the same namespace. Crash handlers do not count as clients, because they do
not exit on their own under Darling. A failed shutdown is retried without
discarding that namespace.
To end a session while clients are still running, press Ctrl+C in its terminal,
close the terminal, or run `darling-lol --stop` from anywhere. Each of these
makes the session shut Darling down and exit. If the session does not exit
within 30 seconds, `darling-lol --stop` kills the prefix's `darlingserver`
itself. It also stops a server that outlived its session.
## Install permanently
@@ -84,6 +91,9 @@ darling-lol --installer
darling-lol-shell
```
The application menu entry is `Darling Launcher`, and `darling-launcher` is an
alias for `darling-lol` for scripts that prefer the neutral name.
If the desktop entry does not appear, ensure your desktop session includes
`~/.nix-profile/share` in `XDG_DATA_DIRS`, then log out and back in. Non-NixOS
systems may need this desktop-session integration after installing Nix.
@@ -198,13 +208,51 @@ nix run github:lucannez64/darling-lol#shell
Use the launcher-provided shell rather than entering the raw FHS environment.
If you start Darling manually in this shell, run `darling shutdown` there
before exiting. A separately launched shell cannot reconnect to an existing
Darling namespace.
Darling namespace; `darling-lol --stop` can still stop it.
### Text fonts
The FHS environment bundles Liberation and DejaVu text fonts and uses its own
fontconfig configuration and standard aliases. It does not depend on fonts in
the host's `/usr/share/fonts`, which the FHS environment replaces. Fonts in your
user font directories remain available. This also prevents an icon-only user
font inventory from becoming the only fallback for ordinary text.
The FHS environment selects the packaged fontconfig configuration and clears
inherited `FONTCONFIG_PATH` and `FONTCONFIG_SYSROOT` overrides. Clearing the
sysroot prevents it from redirecting the packaged configuration and font paths.
Custom XDG font/configuration directories and legacy
`~/.fonts` remain supported, including paths with spaces. The host configuration
and user font files are not modified.
To check font availability without starting Darling or Riot:
```sh
nix run .#default -- fc-match sans-serif
nix run .#default -- fc-match Arial
```
These should select DejaVu Sans and Liberation Sans respectively. The standalone
`nix build .#checks.x86_64-linux.fonts` check verifies generic and common Windows
font aliases with both an empty home directory and icon-only user fonts. This
checks font availability; CAPTCHA text rendering still needs manual verification
in Riot's UI.
## 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).
Across filesystems the target must support `user.*` extended attributes (ext4,
btrfs, xfs; not NTFS or exFAT), which is checked before copying and again on
the finished copy. The new path is saved before the old prefix is deleted.
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
+37 -7
View File
@@ -1,9 +1,21 @@
# Explicit platform identity overrides
By default, Darling keeps the existing host-derived UUID and `DARLING-` serial.
No override file is generated by installation or launch. Import mode uses values supplied by the user. Explicit `--provision` mode can
generate synthetic identifiers; it does not issue authentic Apple identities
or change CPU, GPU, or security metadata.
First-time setup (`scripts/install-runtime.sh` creating a new prefix) runs
`--provision --write` once, so each new prefix gets its own synthetic identity.
A prefix without saved identity files keeps the host-derived UUID and
`DARLING-` serial; launching never generates an override. Import mode uses
values supplied by the user. `--provision` generates synthetic identifiers; it
does not issue authentic Apple identities or change CPU, GPU, or security
metadata. The overrides are for application compatibility; they are not intended
to bypass anti-cheat, telemetry, licensing, or platform security controls.
> **Synthetic, local metadata.** These values are not Apple-issued identifiers,
> credentials or authentication tokens. Do not use them with Apple services or
> to impersonate real hardware. This repository does not pass them to Riot as
> authentication data; the values are exposed to guest applications through
> IOKit, and the official client's later handling of them is outside this
> repository's scope. See the
> [interoperability and compliance scope](INTEROPERABILITY.md).
Preview an OpenCore `config.plist`, a flat GenSMBIOS JSON file, or an existing
`darling-smbios.conf` without changing the selected prefix:
@@ -89,8 +101,13 @@ python3 tools/configure-darling-platform.py --prefix /absolute/prefix --provisio
```
Launch Darling normally afterwards. This requires the updated iokitd from this
PR; the config tool alone cannot update an older running daemon. Provisioning
is never called automatically by installation or launch.
PR; the config tool alone cannot update an older running daemon. Besides
first-time setup above, provisioning runs automatically in one other case:
after installing, `scripts/update.sh` runs
`configure-darling-platform.py --replace-known-defaults --write`, which
provisions the prefix only if its saved identity still contains the shared
serial or UUID below. Prefixes without saved identity files, or whose saved
values are all custom, are left byte-for-byte unchanged.
Missing serials use 12 characters and missing MLBs use 17 characters, sampled
with Python's `secrets` module from `0123456789ABCDEFGHJKLMNPQRSTUVWXYZ` (Base-34).
@@ -106,7 +123,8 @@ example with your chosen unicast OUI). It only affects a missing ROM. An OUI
does not make a generated identity Apple-issued.
Provisioning replaces the known shared serial `C02ZL0GVMD6N` and UUID
`6F3B2A1E-8C4D-4E5F-9A7B-1D2C3E4F5A6B`, along with the latter's derived fields.
`6F3B2A1E-8C4D-4E5F-9A7B-1D2C3E4F5A6B`, along with the latter's derived fields
(including a standalone `system-id` or `provisioning_UDID` derived from it).
It preserves other existing custom values, including imported serials and UUIDs
that do not follow the generated formats. A standalone custom `system-id` is
preserved by deriving its UUID instead of generating a different one. Model,
@@ -134,3 +152,15 @@ a per-prefix lock. Repeated writes preserve identifiers and unchanged files.
Previewing a fresh identity generates a sample; only `--write` makes it stable.
To restore host-derived defaults after provisioning, remove both identity files
while stopped, so a later provisioning run cannot reimport the legacy copy.
## system_profiler
The prefix's `/usr/sbin/system_profiler` (installed by
`tools/install-darling-system-profiler.py`) answers `SPHardwareDataType` and
`SPSoftwareDataType` from the running guest: model, serial number, platform UUID
and provisioning UDID from IOKit's `IOPlatformExpertDevice` (via `ioreg`), CPU,
cache and memory from `sysctl`, and the OS version from `sw_vers` and `uname`. It
keeps no identity of its own, so it reports the same values as IOKit, including any
override saved by this tool. A field the guest does not provide is left out.
`python3 -m unittest tests.test_system_profiler` checks the output with stub
commands.
+52 -4
View File
@@ -61,10 +61,15 @@ The normal launcher starts neither an Electron inspector nor client API/UI helpe
## Application-menu diagnostics and recovery options
`scripts/create-desktop-entry.sh` creates an application-menu entry that runs
`scripts/desktop-run.sh`, preserving the normal launcher behavior and saving
stdout/stderr in `build/desktop-launch.log` with mode 0600. Logs can contain
private session details; do not attach raw launcher logs to a public issue.
`scripts/create-desktop-entry.sh` creates a `Darling Launcher` application-menu
entry that runs `scripts/desktop-run.sh`, preserving the normal launcher behavior
and saving stdout/stderr in `build/desktop-launch.log` with mode 0600. Logs can
contain private session details; do not attach raw launcher logs to a public
issue. The helper also removes the `league-of-legends.*` entry and icons written
by older versions, but only when it can prove this project created them. The
entry keeps `StartupWMClass=RiotClientServices`: Darling derives `WM_CLASS` from
the basename of `argv[0]`, so that is the window class of the client the entry
starts, while `Riot Client` is only its bundle display name.
Region/locale remain the existing `RCLOL_REGION`/`RCLOL_LOCALE` options.
`RCLOL_CDN_IPV4_ONLY` defaults to `1` in the Riot launcher and can be set to `0`
@@ -73,6 +78,49 @@ opts in to Hyprland-only recovery of a default-prefix Riot session with no
visible window; it is off by default. See [troubleshooting](TROUBLESHOOTING.md#riot-exits-and-league-reports-that-it-lost-its-connection)
for the observed failures, recovery guards and NSURLSession limitations.
`RCLOL_RECOVER_STALE_CLIENT=1` opts in to partial runtime recovery before
launch. If `darlingserver` is alive, no game is running, no managed client
window is visible and stale Riot/League/CEF processes at least 60 seconds old
remain, the launcher stops only that client tree and reuses the runtime. It
recognizes `LeagueClientUx`, `Riot Client` and `RiotClientServices` windows and
preserves `darlingserver`, launchd/mldr, sockets and `.init.pid`; if X11/Xwayland
properties cannot be inspected it does nothing. See
[troubleshooting](TROUBLESHOOTING.md#client-remains-running-without-a-window).
`RCLOL_CLEAN_STALE_HELPERS=1` runs a preflight before the match helper starts.
It removes `close-league-client-in-game.py` and
`hide-league-client-in-game.py` processes of this prefix whose parent is
`init`/`systemd`, which means the launcher that started them has exited. A
helper of a live launcher, another prefix, the game, Riot/League/CEF and
`darlingserver` are preserved. It fails closed when a command line,
environment, stat entry or parent cannot be read. See
[troubleshooting](TROUBLESHOOTING.md#multiple-client-in-game-helpers).
`RCLOL_CLIENT_IN_GAME_TRICKS=1` opts in to automatic client handling during a
match. It reads `lol-user-experience` from the installed client's
`LCULocalPreferences.yaml`: with `potatoModeEnabled: true`, it closes the client
UI when `closeLeagueClientDuringGame: true`, and hides the managed
`LeagueClientUx`/`Riot Client` X11 windows when that value is false. Missing or
invalid preference values, disabled client optimizations and unreadable files
select no action. The low-level `RCLOL_CLOSE_CLIENT_IN_GAME=1` and
`RCLOL_HIDE_CLIENT_IN_GAME=1` switches remain available when the automatic
switch is not set. The feature is off by default, requires X11/Xwayland, and
does not patch client binaries, CEF or the game. The hide helper exits after ten
minutes without a client or match once it has seen one. See
[the troubleshooting measurement](TROUBLESHOOTING.md#automatic-client-handling-during-a-match)
for the measured CPU effect and limitations.
`RCLOL_FILTER_HOST_ENV=1` opts in to removing host desktop-session variables before
the guest starts: `WAYLAND_DISPLAY`, `XDG_SESSION_*`, `XDG_CURRENT_DESKTOP`,
GNOME/KDE/GDM, systemd, SSH agent, terminal, toolkit (`QT_*`, `GTK_*`, `GDK_*`),
Flatpak/Snap/AppImage and exported shell functions, and `SHELL` becomes `/bin/zsh`.
It is off by default. Variables that Darling or the host graphics and audio libraries read
are always kept: `DISPLAY`, `XAUTHORITY`, `XDG_RUNTIME_DIR` and the XDG base directories,
D-Bus, PulseAudio/PipeWire/ALSA, `DRI_PRIME`, `MESA_*`, `__GL_*`, `__NV_*`, `VK_*`,
`LD_PRELOAD` and `LD_LIBRARY_PATH`. List names to keep anyway in `RCLOL_KEEP_HOST_ENV`,
for example `RCLOL_KEEP_HOST_ENV=WAYLAND_DISPLAY`. The launcher always closes inherited
file descriptors above stderr before the guest starts.
## Updating an existing runtime
`./scripts/update.sh` updates everything after `git pull`: `build.sh`, then the base install, then `install-runtime.sh`. When a pull changes a patch, `prepare.py` finds the earlier version applied in that component (its copy in `build/applied-patches`, or a version from this repository's history), reverts it, and applies the new one. It never resets a repository; if neither version matches, it stops and names the repository with other edits. The steps below update single components instead.
+215 -4
View File
@@ -23,11 +23,11 @@ c++ -std=c++17 -I build/darling-src/src/external/metal/private-include \
build/test-metal-device-info
```
Expected: `METAL_DEVICE_INFO failures=0 pass=1`. It covers how `MTLDevice.name` turns Vulkan names into macOS style (`AMD Radeon RX 6800 XT (RADV NAVI21)` becomes `AMD Radeon RX 6800 XT`, `Intel(R) UHD Graphics 630 (CML GT2)` becomes `Intel UHD Graphics 630`, NVIDIA names stay as they are), the `DARLING_METAL_VRAM_MB` parser, and the working-set sum over Vulkan heaps.
Expected: `METAL_DEVICE_INFO failures=0 pass=1`. It covers how `MTLDevice.name` turns Vulkan names into macOS style (`AMD Radeon RX 6800 XT (RADV NAVI21)` becomes `AMD Radeon RX 6800 XT`, `Intel(R) UHD Graphics 630 (CML GT2)` becomes `Intel UHD Graphics 630`, NVIDIA names stay as they are), the `DARLING_METAL_VRAM_MB` parser, the working-set sum over Vulkan heaps, and a non-zero `registryID`. `python3 -m unittest tests.test_metal_device_info` runs the same check against the header in `patches/metal.patch`, so it also works without a prepared source tree; CI runs it.
The LLVM version check compiles Iridium's actual dynamic loader against small stub libraries. It verifies that a matching major initializes, older/newer majors produce a version mismatch, and a missing library fails initialization. It does not start Darling. The native AIR translator (`python3 tools/build-riot-air-check.py --native`) selects both headers and the shared library from the same `llvm-config`; its build prints those paths so shader results can be attributed to the tested LLVM installation.
The patch check uses temporary base indexes and does not reset or modify the source checkout. The launcher check uses stub commands and never starts or contacts Riot or Darling. It checks installed-client and first-time-installer launches, strips inherited `DYLD_INSERT_LIBRARIES` even with the old VPX opt-in set, and preserves Darling runtime library/framework paths. The memory-installer check imports the installer and exercises its policy (default source is the prepared artifact; the artifact is never a destination of a different library) in temporary directories. The Foundation-installer check does the same for the overlay installer: it seeds a temporary framework from a stub SDK, replaces `Versions/C/Foundation`, keeps the standard symlinks, stages a backup, treats a re-run as current and refuses a non-Mach-O source. The installer handoff mock covers 28 scenarios, including HTTP 409, interrupted verification and preserving active downloads.
The patch check uses temporary base indexes and does not reset or modify the source checkout. The launcher check uses stub commands and never starts or contacts Riot or Darling. It checks installed-client and first-time-installer launches, strips inherited `DYLD_INSERT_LIBRARIES` even with the old VPX opt-in set, preserves Darling runtime library/framework paths, and checks that host file descriptors the launcher inherited (including multi-digit ones) are closed before the guest starts. The memory-installer check imports the installer and exercises its policy (default source is the prepared artifact; the artifact is never a destination of a different library) in temporary directories. The Foundation-installer check does the same for the overlay installer: it seeds a temporary framework from a stub SDK, replaces `Versions/C/Foundation`, keeps the standard symlinks, stages a backup, treats a re-run as current and refuses a non-Mach-O source. The installer handoff mock covers 28 scenarios, including HTTP 409, interrupted verification and preserving active downloads.
## NSURLSession lifecycle regression
@@ -78,6 +78,7 @@ Host-only checks, which do not start Darling:
python3 tools/riot-cdn-dns-offline-check.py
python3 -m unittest discover -s tests -p 'test_hidden_riot_recovery.py'
python3 -m unittest discover -s tests -p 'test_desktop_run.py'
python3 -m unittest discover -s tests -p 'test_desktop_entry.py'
python3 -m unittest discover -s tests -p 'test_urlsession_runner.py'
```
@@ -87,9 +88,25 @@ insensitivity, A queries, non-CDN names and misleading domain suffixes without
sending a DNS query. Recovery tests use fake `/proc` entries and mock every
compositor/shutdown command. The desktop test checks exit/argument forwarding,
append-only output and tightening an existing public log to mode 0600. The
runner test uses a fake `darling` executable to verify loopback fixtures and
desktop-entry test renders the helper against a disposable `HOME` and checks
the neutral fields, Exec quoting, icon paths, that only launcher artifacts
this project created are removed, and that the checkout and packaged entries
share their identity fields. The runner test uses a fake `darling` executable to verify loopback fixtures and
private-prefix-only shutdown/cleanup.
With the pinned Darling sources available, run the host resolver regression:
```sh
python3 -m unittest discover -s tests -p 'test_host_dns.py' -v
```
It compiles the actual patched vchroot implementation in its Linux test mode
and reads temporary resolver files through absolute and relative host symlinks.
It checks atomic file replacement, symlink retargeting, guest DNS overrides and
guest absolute-link scope. A CMake fixture verifies that both install modes
replace an older Google resolver file with the host link. No Darling session or
DNS traffic is started.
## CoreText font-list and AppKit resize regressions
After preparing the source and configuring the runtime tools:
@@ -119,6 +136,121 @@ Expected: `CORETEXT_FONT_LIST pass=1` with nonzero family/face counts (306/1124
The font probe reproduces Chromium's installed-family enumeration, mandatory family matching and localized-name lookup with a null language output. It checks ownership of the single-match result and that a nonexistent mandatory family produces no substitute. The resize probe checks backing-layer bounds immediately after frame/bounds changes, including a scaled view and a child layer, without relying on input or coordinate conversion to refresh them. It also verifies that native parent size is current during child layout, and that adding an overlay sibling after the content view does not redirect subsequent content resizing into that overlay. The old sibling selection left content at 1600×900 while stretching the 30×20 overlay to 1280×720; the corrected layout resizes the content and preserves the overlay. Before the fixes, these checks report a stale 1280×720 layer and native parent after a 1600×900 resize. The `FRAME_SIZE_HOOK` check uses a subclass whose `setFrameSize:` publishes its viewport size. It checks growth and shrinkage through parent autoresizing, direct `setFrame:`, and direct `setFrameSize:`. Before component-setter dispatch was corrected only the final direct size setter reached the subclass (one callback); afterward all four updates do, with current geometry visible inside the callback. No probe launches or contacts Riot/League. The user confirmed that the language dropdown and League window resizing work after the final X11 correction (2026-10-15). The practice-tool crash remains unresolved.
### Local event monitors and client dragging
`python3 tools/build-riot-x11-runtime.py` builds
`build/riot-event-monitors-check`. With patched AppKit installed and clients
closed, run:
```sh
darling shell env DYLD_LIBRARY_PATH=/Users/me/RuntimeLibraries \
DYLD_FRAMEWORK_PATH=/Users/me/RuntimeFrameworks \
/Volumes/SystemRoot/path/to/darling-lol/build/riot-event-monitors-check
```
Expected: nine `PASS` lines and exit status 0. This independent probe tests
multiple monitors, mask filtering, removal, consumption, event replacement,
self-removal, and the in-flight snapshot when another monitor is removed.
Monitor order is unspecified and is deliberately not asserted. It dispatches
only synthetic events to its own unmapped window.
The same source runs against native macOS AppKit:
```sh
clang -fblocks -framework AppKit tools/riot-event-monitors-check.m \
-o /tmp/riot-event-monitors-check
/tmp/riot-event-monitors-check
```
Native reference: macOS 26.6.2. LeagueClientUx uses local
monitors for custom window movement; intercepting a fixed titlebar strip instead
would bypass its own drag policy and risk stealing its window-control clicks.
For a live regression check, drag its header, drag ordinary content (which must
not move the window), minimize and restore it, and check Riot Client header
dragging separately. No match is required.
### X11 window decorations
`python3 tools/build-riot-x11-runtime.py` also builds
`build/riot-window-decorations-check`. Install and activate both the patched
AppKit and X11 backend before running it, with clients and the game closed:
```sh
darling shell env DYLD_LIBRARY_PATH=/Users/me/RuntimeLibraries \
DYLD_FRAMEWORK_PATH=/Users/me/RuntimeFrameworks \
/Volumes/SystemRoot/path/to/darling-lol/build/riot-window-decorations-check
```
Expected: `WINDOW_DECORATIONS_CHECK cases=10 pass=1`. The independent Cocoa
probe reads `_MOTIF_WM_HINTS` on its own unmapped native windows. It covers a
normal titled window, hidden title alone, hidden plus transparent titlebar,
both reverse transitions, untitled masks with additional flags, switching to
borderless after creation, native-window recreation, and properties set before
the native window exists. It sends no input, maps no windows and does not
launch or contact Riot/League. Check the success marker, not only the launcher
exit status: some Darling versions do not propagate a guest assertion failure.
Before the fix the hidden/transparent case fails. With the corrected overlays,
the ten cases passed on KDE Wayland/Xwayland. Passive inspection of the reopened
Riot Client also confirmed the no-decoration hint and identical compositor frame
and client dimensions (1536×864), instead of the previous 28-pixel titlebar.
This validates the Riot Client window, not every LeagueClientUx or game mode.
### NSOpenPanel Open/Cancel (issue #85)
`python3 tools/build-riot-openpanel-check.py` builds
`build/riot-openpanel-check`. Install the patched AppKit first, with clients and
the game closed. The probe opens its own directory chooser in a fresh `/tmp`
folder containing one subfolder and needs no Riot/League client:
```sh
P=/Volumes/SystemRoot/path/to/darling-lol/build/riot-openpanel-check
E="env DYLD_LIBRARY_PATH=/Users/me/RuntimeLibraries DYLD_FRAMEWORK_PATH=/Users/me/RuntimeFrameworks"
for m in cancel open-selected open-none; do darling shell $E $P --auto $m; done
darling shell $E $P --interactive
```
Each `--auto` mode waits 1.5 s, then clicks the panel's own Cancel or Open
button (selecting the subfolder first for `open-selected`), and checks that
`runModal` returned, the result code and URL, and that the panel is gone. A
15 s watchdog on a separate thread reports a hang. Expected, one line per mode:
```
OPENPANEL_CHECK mode=cancel result=PASS code=0 ...
OPENPANEL_CHECK mode=open-selected result=PASS code=1 url=/tmp/openpanel-check.XXXXXX/subfolder ...
OPENPANEL_CHECK mode=open-none result=PASS code=1 url=/tmp/openpanel-check.XXXXXX ...
```
A failure prints `result=FAIL ... problems=...` (`HANG` when `runModal` never
returned) and exits non-zero. `--interactive` shows the panel for manual clicks
and prints `OPENPANEL_CHECK mode=interactive result=RETURNED code=... url=...`
once it closes. As with the other probes, check the printed line rather than
only the launcher exit status.
### NSSavePanel Finder tags (`setTagNames:` / `tagNames`)
`python3 tools/build-riot-save-panel-tag-check.py` builds
`build/riot-save-panel-tag-check`. Install the patched AppKit first, with clients
and the game closed. The probe checks selector availability, inheritance, and
safe no-op semantics without launching any client or creating window fixtures:
```sh
P=/Volumes/SystemRoot/path/to/darling-lol/build/riot-save-panel-tag-check
E="env DYLD_LIBRARY_PATH=/Users/me/RuntimeLibraries DYLD_FRAMEWORK_PATH=/Users/me/RuntimeFrameworks"
darling shell $E $P
```
Expected output:
```
SAVE_PANEL_TAG class=NSSavePanel selectors=1 inherited=1 values=1 pass=1
SAVE_PANEL_TAG class=NSOpenPanel selectors=1 inherited=1 values=1 pass=1
SAVE_PANEL_TAG_CHECK pass=1
```
Cocotron does not persist Finder tag metadata or provide a tag UI. `setTagNames:`
safely ignores input and `tagNames` returns an empty `NSArray` to prevent
unrecognized-selector crashes from callers (such as Electron file dialogs).
### Presentation publisher handoff
With clients closed, run `build/riot-client-resize-check --handoff` through Darling with the normal runtime overrides. This creates an owned window and two fixture frame slots. The live software owner first supplies a 1280×720 main frame, then switches to a 262×131 popup; another slot supplies the resized main frame without the software flag. The test samples its own window beyond the original frame extent and checks generation colors across growth and shrinkage. The old selector reports black at the 1600×900 sample; the correction passes all four sizes. Repeat with `RCLOL_XSHM=0` and with `DARLING_XSHM_LIBRARY` pointing to the normal transport to cover both paths. The fixtures remove their own frame slots on exit; they do not read client pixels or send input.
@@ -129,6 +261,12 @@ With clients closed, run `build/riot-client-resize-check --handoff` through Darl
The probe cycles through 1280×720, 1600×900, 1024×576 and 1280×720. Each stage changes the frame color and samples its own X11 window near the bottom-right corner, beyond the original frame's extent when growing. It waits up to 2.5 seconds for presentation and prints `RESIZE_PIXEL ... pass=1` for every stage. This verifies presented pixels rather than only view properties. Success does not reproduce League's own resize callbacks; the reported League UI failure remains open, with the opt-in [geometry trace](TROUBLESHOOTING.md#language-dropdown-closes-riot-or-client-resize-leaves-a-clipped-ui) needed to locate the remaining mismatch.
### X11 drag and drop
`python3 build/runtime/tools/build-riot-x11-drag-check.py` builds an independent AppKit check for the in-process drag loop that the X11 backend uses (`-[NSWindow dragImage:at:offset:event:pasteboard:source:slideBack:]`). It needs the cocotron AppKit built with the drag-and-drop patch. With clients closed, run `build/riot-x11-drag-check` through Darling with the same framework/library overrides as the other probes. It opens its own source and destination windows, posts synthetic events to its own application queue, and does not inject X11 input or touch other clients.
It prints `PASS` or `FAIL` for seven cases: a drop onto a destination without `prepareForDragOperation:` (the default must proceed), a drag ending outside any destination, Escape cancel, two drags back to back with a source lacking `pasteboardChangedOwner:`, main-queue blocks and run loop timers firing during the drag, a mouse-down reaching a view afterwards, and a destination view removed from its window mid-drag. Each drag must call `draggedImage:endedAt:operation:` exactly once. The process exits nonzero on any failure, and a case that makes no progress for 20 seconds is reported as a hang, which is the original failure mode. Success covers the AppKit drag loop only; it does not exercise Chromium's own drag source.
## Legacy operating-system version query
```sh
@@ -181,7 +319,7 @@ build/riot-host-receive-check
python3 tools/riot-launcher-offline-check.py
```
Expected: `warm_empty_allocations=0 single_packet_allocations=2`, correct credentials, descriptor ownership/CLOEXEC, sender addresses, multiple batches, truncation flags, callback reentrancy and exception cleanup. The old receive implementation makes 32 allocations for a warmed empty receive and 64 for a single-packet receive in the same fixture. These are allocation counts, not an FPS benchmark. The launcher check uses stub commands and never starts or contacts Riot or Darling. It checks installed-client and first-time-installer launches, strips inherited `DYLD_INSERT_LIBRARIES` even with the old VPX opt-in set, and preserves Darling runtime library/framework paths.
Expected: `warm_empty_allocations=0 single_packet_allocations=2`, correct credentials, descriptor ownership/CLOEXEC, sender addresses, multiple batches, truncation flags, callback reentrancy and exception cleanup. The old receive implementation makes 32 allocations for a warmed empty receive and 64 for a single-packet receive in the same fixture. These are allocation counts, not an FPS benchmark. The launcher check uses stub commands and never starts or contacts Riot or Darling. It checks installed-client and first-time-installer launches, strips inherited `DYLD_INSERT_LIBRARIES` even with the old VPX opt-in set, preserves Darling runtime library/framework paths, and checks that host file descriptors the launcher inherited (including multi-digit ones) are closed before the guest starts.
The same source passed AddressSanitizer/UndefinedBehaviorSanitizer and leak detection. Allocation counts depend on this fixture/compiler/library; preserve the no-wasted-slot property rather than interpreting them as game performance. See [setup](SETUP.md#updating-an-existing-runtime) for server build/install/activation.
@@ -527,3 +665,76 @@ launch, window placement, click alignment and edge panning before rollout.
After installing the tested X11 and CoreGraphics binaries locally, the user
reported that everything was working (2026-10-16). Fullscreen policy was unchanged.
## VRR presentation regression (#46)
```sh
python3 -m unittest discover -s tests -p 'test_x11_vrr.py'
```
The host fixtures exercise the patched eligibility, X11 property updates and
EGL surface ownership transitions. They cover hidden/titled/partial views,
multiple views, parent recreation, child fallback and current-context handling.
Build `build-riot-coregraphics-runtime.py`, `build-riot-x11-runtime.py` and
`build-riot-metal-presentation.py` together. Stage all three updated frameworks
(CoreGraphics, AppKit's X11 backend, QuartzCore) when testing isolated binaries.
Run `riot-metal-presentation-check` with `RIOT_PRESENTATION_PARENT=1` to create
an owned borderless game-like window. It verifies the parent drawable is
borrowed. Add `RIOT_PRESENTATION_RESIZE=1` to verify fallback to a child at
half width and borrowing the parent again when restored. Add
`RIOT_PRESENTATION_NESTED=1` for nested layers.
The default probe still exercises the independent child-window path.
For physical VRR validation, enable adaptive sync in the monitor and desktop,
then manually test fullscreen and borderless League with a frame cap inside
the monitor's VRR range (for example 157 FPS on a 165 Hz display), with VSync
off. Check the monitor's live refresh display as well as the game FPS counter.
Also test leaving fullscreen, restoring it, resizing, and alt-tab. Report GPU,
driver, compositor and session type. A stable FPS counter or the X11 property
alone does not prove that the monitor is using variable refresh.
Local native validation on 2026-10-19 passed all three modes (child, parent
with resize, nested parent with resize), including Vulkan-to-GL pixel readback
and ten presentations per mode, using staged frameworks in a private prefix.
Physical VRR and in-game performance on the reporter's Mutter setup remain
unverified.
## Metal VSync regression (#46)
```sh
python3 -m unittest discover -s tests -p 'test_metal_vsync.py'
```
These host checks compile the patched layer-tree policy, flush body and CGL
swap methods against inert fixtures. They cover nested layers, VSync toggles,
shared-window requests, independent contexts, late attachment and failed
context/interval changes. They do not measure physical display refresh.
For manual validation after updating with the clients closed, compare the
same scene with in-game VSync on and off. With VSync on, presentation should
follow the display/compositor cadence; with it off, the game's frame cap
should continue to work. Record monitor refresh, compositor, X11/Xwayland,
GPU/driver and window mode. The window presentation probe above checks the
API wiring; an in-game/compositor timing comparison remains necessary.
## Combined VSync and VRR testing
The `test/vsync-vrr` branch combines PRs #81 and #88, resolving their shared
compositor-patch and presentation-probe changes. The probe toggles
`displaySyncEnabled` and checks the CGL interval in child, parent and nested
parent modes; the parent modes also support resize fallback/restoration.
All three native modes passed locally in a private prefix, together with ten
host regression checks and validation of all 31 component patches.
After closing the game and both clients:
```sh
git fetch origin
git switch --track origin/test/vsync-vrr
./scripts/update.sh
```
Test VSync with desktop VRR disabled, then test VRR with VSync off and a cap
inside the monitor's adaptive range. Finally test both enabled. Report game
FPS and the monitor's live refresh reading separately.
+316 -6
View File
@@ -57,6 +57,34 @@ The old kernel library returned success for Darwin allocator free/reuse hints wi
Build/install the kernel library using [the incremental update steps](SETUP.md#updating-an-existing-runtime), then perform the manual restart when finished playing. Linux 5.14+ is required. Do not kill client helpers as an attempted memory optimization during play.
## Shaders are recompiled on every start
Mesa resolves its shader disk cache below `$HOME`. Inside the guest, `$HOME` is
`/Users/<user>`, which does not exist on the host filesystem, so Mesa cannot create the
cache and disables it, once per process:
```
Failed to create /Users/<user> for shader cache (Permission denied)---disabling
```
One launcher log without the fix contained 212 of these lines, and no shader compiled by
one process was reused by another process or by the next start: the client and the game
recompiled every shader, on every launch. Mesa is host code loaded into the guest and
reads `MESA_SHADER_CACHE_DIR` verbatim, so the launcher now passes a host directory:
```sh
MESA_SHADER_CACHE_DIR=${XDG_CACHE_HOME:-${HOME:-$prefix_dir}/.cache}/darling-lol # launcher default
```
Mesa creates `mesa_shader_cache/` (client and game shaders) and `radv_builtin_shaders/`
below it. With the change, one client start logged no shader-cache failure and wrote 434
files / 5.6 MB, still growing while the client ran (Radeon RX 550, radv). Set
`MESA_SHADER_CACHE_DIR` yourself to move the cache, for example to another disk: the
launcher preserves a non-empty value, and a directory that cannot be created leaves the
previous behaviour. Reusing the cache removes the repeated compilation; the startup-time
gain has not been measured separately. Cache entries are keyed by driver and GPU, so a
driver update recompiles once and caches again.
## Installer exits, cannot write temporary files, or Install appears idle
Use `sh scripts/installer.sh euw` (or your supported region). The first-time launcher must omit the installed client's `--app-root` and pass `--skip-to-install`; the normal wrapper now does this. It also selects `league_of_legends` so the product installation UI appears after login.
@@ -121,6 +149,163 @@ ownership or window information cannot be read. A hidden background client or
download is restarted when recovery is enabled. Other desktop environments are
not supported by this helper; recovery is off by default.
For a live Darling runtime with no visible client window but stale Riot/League/
CEF processes at least 60 seconds old, `RCLOL_RECOVER_STALE_CLIENT=1` performs a
partial recovery before the next launch. It keeps `darlingserver`, launchd/mldr,
sockets and `.init.pid`, stops only the stale client process tree, and then lets
the normal launcher start the product again. It recognizes `LeagueClientUx`,
`Riot Client` and `RiotClientServices` windows, does nothing while a game process
is running or when a managed client window is visible, and fails closed when
X11/Xwayland properties are missing or unreadable. This saves the Darling
bootstrap but not the Riot Client/CEF startup itself. If no window appears
afterward, use a full runtime shutdown/restart.
### Multiple client-in-game helpers
The close/hide helper is a long-lived watcher. If an earlier launcher exits
while it remains, the next launch can start another copy; list them with:
```sh
pgrep -af 'close-league-client-in-game.py|hide-league-client-in-game.py'
```
To remove the orphaned copies before the next helper starts:
```sh
RCLOL_CLEAN_STALE_HELPERS=1 ./scripts/run.sh
```
Only helpers of the current prefix whose parent is `init`/`systemd` are
terminated. Helpers whose launcher is still alive, other prefixes, the game,
Riot/League/CEF, `darlingserver` and the runtime are never touched. A dry run
is available from the configured tools directory:
```sh
RCLOL_CLEAN_STALE_HELPERS=1 python3 build/runtime/tools/clean-stale-helpers.py --dry-run
```
### Client window stays black after a match
The League client keeps its Chromium/CEF UI (`LeagueClientUx` and its helpers)
alive during a match. Riot's own "Close client during game" option closes that UI
on Windows, but under Darling the pre-match instance keeps running: the client the
backend starts when the match ends is a second CEF instance on the same
`--user-data-dir`, and its window never paints. Its DOM and audio work and the
window stays black. Closing the client by hand during the match leaves the same
state for the same reason.
An opt-in helper closes that UI itself, from the host, and only while a match
runs, so the client that appears afterwards is the only instance:
```sh
RCLOL_CLOSE_CLIENT_IN_GAME=1 ./scripts/run.sh
```
This is not a memory measure: the UI is closed because it competes with the game
for CPU during the match. Measured on a four-core host with a Radeon RX 550, a
practice match reported roughly 120 FPS with the client UI running and roughly
180 FPS with it closed. On that host a local supervisor using the same detection
closed 13 client processes (2027 MiB resident) when a practice match started, and
the client came back as one instance with a painted window when it ended; the
helper described here has not been through a live match yet. Riot's own option is
the equivalent on Windows, so leave either one disabled if the in-client chat is
wanted during a match.
The helper is off by default and fails closed: it attributes both the game and the
client to the launcher's prefix through the loader's `__mldr_sockpath` (guest
processes do not carry `DPREFIX`), never closes a client younger than 60 seconds
(the one the backend starts after a match), leaves the Riot client, the League
backend and the game untouched, and takes no action when ownership, the
environment or the stat fields cannot be read. The client UI is recognised by the
name of its executable, not by the command line: `LeagueCrashHandler` processes
carry the client UI name in their arguments only, so they are left alone. It
leaves by itself ten minutes after the last client or match is gone (the clock does
not run before the first client appears, so a long patch or login is fine). Diagnose it in one
observation, without waiting for a match:
```sh
python3 tools/close-league-client-in-game.py --once --dry-run
```
### Automatic client handling during a match
A single opt-in switch reads the client's own preferences and chooses the mode:
```sh
RCLOL_CLIENT_IN_GAME_TRICKS=1 ./scripts/run.sh
```
It reads `lol-user-experience` from the installed client's
`LCULocalPreferences.yaml`:
| Client preference | Launcher action |
|---|---|
| `potatoModeEnabled: false` or unreadable | no client-in-game helper |
| `potatoModeEnabled: true`, close preference missing or invalid | no action |
| `potatoModeEnabled: true`, `closeLeagueClientDuringGame: true` | close the client UI |
| `potatoModeEnabled: true`, `closeLeagueClientDuringGame: false` | hide the client windows |
The hide path unmaps the `LeagueClientUx` and `Riot Client` X11 windows and maps
them again when the game process ends. The client is not closed, the League
backend and Riot client keep running, and the game window is never hidden. The
close path uses the UI-close helper described above. The hide helper exits after
ten minutes without a client or match once it has seen one; a missing or invalid
close preference selects no helper.
The low-level `RCLOL_HIDE_CLIENT_IN_GAME=1` and
`RCLOL_CLOSE_CLIENT_IN_GAME=1` switches remain available when the automatic
switch is not set. All modes are off by default. The helpers are
window-management only: they do not patch client binaries, CEF, anti-cheat or
DRM. They attribute the game to this prefix through the loader's
`__mldr_sockpath`, and fail closed when ownership, proc state, environment or
window information cannot be read. The hide helper requires an X11 or Xwayland
desktop; on a native Wayland window manager without Xwayland it cannot find the
windows.
On a CPU-bound host the hide effect appears after CEF has been hidden for a few
seconds. In a local 30-second test on an i5-3470 with a Radeon RX 550, hiding
`LeagueClientUx` reduced the summed CPU of the matched client/CEF processes from
120.7% to 93.4% (about 23%, or 27 percentage points, of one core); showing the
window returned it to 119.9%. This is not a universal FPS or percentage claim:
a game that is GPU-bound, a client with no active UI work, or a different
desktop may show less benefit. The hidden window can also need a moment to
repaint after it is shown.
With client optimizations enabled and close-during-game enabled, the close
helper remains the more aggressive option when in-client chat during the match
is not needed.
Diagnose the match detector without changing any window:
```sh
python3 tools/hide-league-client-in-game.py --once
```
### Riot reports NO INTERNET FOUND while the host is online
Older base runtimes ship Google DNS servers (`8.8.8.8` and `8.8.4.4`), which
some mobile and managed networks block. The base resolver now links to the
host's `/etc/resolv.conf` through `/Volumes/SystemRoot`, including when the host
file is a symlink into `/run` for systemd-resolved or NetworkManager. File and
symlink replacements remain visible without copying the resolver on launch.
Exit Riot and League, then run `./scripts/update.sh` to rebuild and install both
the base link and the host symlink handling correction. Existing prefix DNS
overrides in `$DPREFIX/private/etc/resolv.conf` (normally
`~/.darling/private/etc/resolv.conf`) remain in effect; remove a previous manual
override with Darling stopped if you want to follow the host instead. A custom
prefix `/etc` directory can also shadow the base `/etc -> private/etc` link.
Check resolution inside the guest with:
```sh
darling shell curl --noproxy '*' https://clientconfig.rpg.riotgames.com/
```
An HTTP response establishes that resolution succeeded even if that endpoint
rejects the request. This change follows the host resolver configuration;
already-running clients may cache DNS settings until restarted.
### Downloads repeatedly fail with an IPv6 CONNECT_TO error
The bundled downloader was observed retrying CDN chunks with:
@@ -155,12 +340,39 @@ reference before callback delivery. This establishes the cancellation fix; a lon
Riot session still needs confirmation. With both clients and the game closed,
run `./scripts/update.sh` to install it, then `sh scripts/run.sh`.
## League and Riot UIs crash after clicking Play
The League UX processes and then the Riot Client renderer stop with `SIGTRAP`
a few seconds after the lobby opens. The renderer prints
`Check failed: kr == KERN_SUCCESS. mach_make_memory_entry_64: (os/kern) resource shortage (6)`
from `platform_shared_memory_region_mac.cc`. darlingserver backs every named
memory entry with a memfd, and its open descriptors peak at about 1100 when the
lobby opens. Under the rootless NixOS launcher darlingserver cannot raise its
hard `RLIMIT_NOFILE` to `nr_open`, so it used to keep the soft limit it
inherited. When that limit was 1024 (several Electron apps and desktop launchers
pass it on), `memfd_create` failed with `EMFILE`.
darlingserver now raises its soft limit to the inherited hard limit when it
cannot raise the hard one. A failed named-entry creation also logs
`MEMDIAG … failed errno=… nofile=soft/hard` to `dserver.log`, and every 1024th
memfd logs `MEMDIAG descriptors live=…` so a leak would show as a growing `live`
count. With a 1024 soft limit the old server reproduced the crash and logged
`errno=24 nofile=1024/524288`. With the fix, the server reports a 524288 soft
limit under the same launch. To check a running server, read the `Max open files`
line of `/proc/$(pgrep -x darlingserver)/limits`.
## UI crashes after a button opens an external link
LaunchServices dereferenced a nullable error-output pointer in six paths. The patch checks the pointer itself and populates supplied outputs; the standalone regression covers missing handlers and null outputs. Electron opens LaunchServices through an absolute CoreServices subframework path, so the paired installer also replaces that binary in the stopped guest prefix. Updating only `RuntimeFrameworks/LaunchServices.framework` can leave the crash unfixed.
LaunchServices now opens `http`, `https` and `mailto` links on the Linux desktop itself. It loads the host's `libsystemd.so.0` and asks `xdg-desktop-portal` (`org.freedesktop.portal.OpenURI`) on the user session bus to open the link, so your default browser starts outside Darling and survives `darling shutdown`. Without a portal, it starts `xdg-open` in a transient systemd user unit instead. It also reports a handler for these schemes, since Electron checks for one before opening a link. Other schemes, control characters and URLs over 4096 bytes are rejected. If a link still does nothing, check the client's stderr for a `LaunchServices:` line and that `busctl --user status org.freedesktop.portal.Desktop` works on the host. Both LaunchServices and AppKit must be rebuilt. The hand-off passed the host-side D-Bus regression and opened the host browser from a guest through both `open(1)` and `NSWorkspace`; a link clicked in the Riot client is not yet confirmed. The null-pointer fix does not explain every unrelated button crash.
## Riot client shows "Not Found" instead of its UI
On affected setups, including the reported NixOS installation, the client window opens but only shows `Not Found`, and its log has `ENOENT, dist/renderer/index.html not found in …/app.asar` although the archive contains that file. Electron's asynchronous file opening can copy an archive entry to a temporary file first (`copyFileOut`), and a failed copy can be reported as "not found". Chromium checks `MAC_CHROMIUM_TMPDIR` before falling back to `NSTemporaryDirectory()`; an unusable default temporary directory can therefore produce this symptom even while synchronous archive reads work.
The launcher defaults `MAC_CHROMIUM_TMPDIR` to `/private/tmp`, preserving a non-empty override. `scripts/install-runtime.sh` creates that guest directory with mode `1777`. Verify directory creation and temporary-file writes with a standalone guest probe, then launch the client normally and check whether its UI loads. Do not run diagnostic scripts through Riot's executable. This workaround addresses temporary-file failures; an actually missing or damaged archive needs separate investigation.
## Language dropdown closes Riot, or client resize leaves a clipped UI
The [report in issue #1](https://github.com/lucannez64/darling-lol/issues/1#issuecomment-6194498757) contains an Electron renderer abort with `Symbol not found: _CTFontDescriptorCreateMatchingFontDescriptors`. CoreText now exports font-descriptor matching and localized-attribute lookup, and enumerates installed font families through fontconfig instead of returning a null stub. Localized lookup currently falls back to canonical font names. The standalone font-list check exercises Chromium's sequence, including mandatory family matching and retained results.
@@ -253,7 +465,7 @@ It prints each thread-local access that calls its thunk without the descriptor i
## Base install refuses to continue while Darling is not running
`sudo cmake --install` prints `Darling currently running for <user>, shutting it down...` several times and then `Darling launchd PID … remains alive after shutdown`. A guest launchd outlived its darlingserver, so `darling shutdown` sees no container to stop. `python3 build/runtime/tools/ensure-darling-runtime.py` stops such a launchd (your own, only when no darlingserver runs); `scripts/run.sh` and `scripts/install-runtime.sh` run it too.
`sudo cmake --install` prints `Darling currently running for <user>, shutting it down...` several times and then `Darling launchd PID … remains alive after shutdown`. A guest launchd outlived its darlingserver, so `darling shutdown` sees no container to stop. Automatic cleanup by process name is disabled because guest names and UIDs do not prove prefix ownership (killing by name risks terminating an active launchd from an unrelated prefix or session). If setup is blocked, shut down the original Darling session from its own namespace (`darling shutdown`), or inspect the leftover PID under `/proc/<PID>` before terminating it manually.
## Lower fight FPS than Windows
@@ -272,11 +484,25 @@ On Hyprland, also check that direct scanout is off (`hyprctl getoption render:di
On the NVIDIA proprietary driver, check the submit worker's kernel time: a thread of the game process spending most of its time in the kernel (`/proc/<pid>/task/<tid>/stat` system time, or `perf trace -s -p <pid>`) points at driver calls. Each `vkQueueSubmit2` and each CPU-side `vkSignalSemaphore` is an RM control ioctl (command `0xc020462a`) that queues on a driver-wide lock. Before 2026-10-18 every command buffer signalled the event loop's wake-up semaphore, ~25 times per frame; with lazy wake-ups (default) and a 5 ms batch window (`/tmp/metal-submit-batch-us` = `5000`, `/tmp/metal-submit-batch-max` = `64`) an RTX 4090 ran at 206–552 FPS against 91–147 FPS with `/tmp/metal-no-lazy-wakeup`, in alternating 20 s phases of one match (the later phases with wake-ups on were capped at 240 FPS) (see [setup](SETUP.md#metal-and-indium-overlays)).
The NVIDIA performance figures above were reported on an earlier runtime base;
they have not been remeasured after integration with current `main`.
they have not been remeasured after integration with current `main`. On `bcb3245` an RTX 5060 Laptop reported ~200–215 FPS in Practice Tool ([#53](https://github.com/lucannez64/darling-lol/issues/53)).
## NVIDIA
With the window visual and compositor draw buffer fixes (#22) and the RPC socket fix (#25), the game renders and runs on these NVIDIA GPUs:
| GPU | Driver | Report | Result |
|---|---|---|---|
| GTX 1060 6GB | 580.178.04 | [below](#nvidia-580-pascal-and-older-black-game-window) | client and tutorial |
| RTX 2070 SUPER | 615.78, proprietary | [#55](https://github.com/lucannez64/darling-lol/issues/55) | tutorials, Co-op vs AI, Swiftplay and Draft games; no crashes |
| RTX 3060 | 615.71 | [below](#nvidia-resize-signals-cpu-readback-and-completion-latency) | custom matches through CPU readback |
| RTX 5060 Laptop | 615.71.09, open modules | [#53](https://github.com/lucannez64/darling-lol/issues/53) | Practice Tool at ~200–215 FPS on `bcb3245`; MUX set to discrete, PRIME variables set |
| RTX 5070 | 615.78.08, open modules | [#69](https://github.com/lucannez64/darling-lol/issues/69) | tutorial |
If the game window stays black on NVIDIA, check `metal-device.log` as described in [Check which GPU rendered](#check-which-gpu-rendered).
## Hybrid graphics laptops (AMD/Intel iGPU + NVIDIA)
Metal renders with Vulkan, and each finished frame is handed to an OpenGL (EGL) context that presents it. The hand-off uses opaque file descriptors, which only work when Vulkan and OpenGL run on the same GPU. Indium takes the first Vulkan device the loader lists, and on a hybrid laptop both that device and the EGL context are normally the integrated GPU, so the discrete GPU stays idle. A report in [issue #3](https://github.com/lucannez64/darling-lol/issues/3) (Ryzen 9 5900HS, RTX 3060 Laptop) measured 60-70 FPS in ARAM on the integrated GPU; on the NVIDIA GPU the game showed a black screen. **Running on an NVIDIA GPU is not validated.**
Metal renders with Vulkan, and each finished frame is handed to an OpenGL (EGL) context that presents it. The hand-off uses opaque file descriptors, which only work when Vulkan and OpenGL run on the same GPU. Indium takes the first Vulkan device the loader lists, and on a hybrid laptop both that device and the EGL context are normally the integrated GPU, so the discrete GPU stays idle. A report in [issue #3](https://github.com/lucannez64/darling-lol/issues/3) (Ryzen 9 5900HS, RTX 3060 Laptop) measured 60-70 FPS in ARAM on the integrated GPU; on the NVIDIA GPU the game showed a black screen, since fixed (see [NVIDIA](#nvidia)).
### Run on the discrete GPU
@@ -286,7 +512,12 @@ Start the launcher with PRIME render offload so that both Vulkan and OpenGL move
prime-run sh scripts/run.sh
```
`prime-run` sets `__NV_PRIME_RENDER_OFFLOAD=1`, `__VK_LAYER_NV_optimus=NVIDIA_only` and `__GLX_VENDOR_LIBRARY_NAME=nvidia`. `darling` copies the caller's environment into the guest, and guest processes inherit it; whether the Riot client passes all of it to the game has not been checked; the log below shows the result. Darling's OpenGL uses EGL, so if the log below still shows the integrated GPU as the presenting renderer, also set `__EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/10_nvidia.json`. On two Mesa GPUs, `DRI_PRIME=1` does the same.
`prime-run` sets `__NV_PRIME_RENDER_OFFLOAD=1`, `__VK_LAYER_NV_optimus=NVIDIA_only` and `__GLX_VENDOR_LIBRARY_NAME=nvidia`. `darling` copies the caller's environment into the guest, and guest processes inherit it; whether the Riot client passes all of it to the game has not been checked; the log below shows the result. Darling's OpenGL uses EGL, so if the log below still shows the integrated GPU as the presenting renderer, also set `__EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/10_nvidia.json`. On two Mesa GPUs, `DRI_PRIME=1` does the same. The RTX 5060 Laptop in [#53](https://github.com/lucannez64/darling-lol/issues/53) rendered with all four variables set:
```sh
__NV_PRIME_RENDER_OFFLOAD=1 __VK_LAYER_NV_optimus=NVIDIA_only __GLX_VENDOR_LIBRARY_NAME=nvidia \
__EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/10_nvidia.json sh scripts/run.sh
```
The guest file `/tmp/metal-gpu` (a device index or part of its name, for example `nvidia`) moves only the Vulkan side. Use it for diagnosis: on its own it splits the two sides across GPUs, and frames cannot be presented.
@@ -320,9 +551,9 @@ The first import of a drawable into the presenting OpenGL context, and the first
[MetalDevice] pid=… drawable GL semaphore (first) import_error=0x0 wait_error=0x0 ok (checked for the first 600 imports)
```
A non-zero error or `FAILED` means the frames cannot reach the window. Before 2026-10-16 the import did not mark the memory as dedicated, which the extension requires; Mesa ignores it, and it is the suspected cause of the NVIDIA black screen (not yet confirmed). An NVIDIA log of 2026-10-16 showed `glTiling=optimal` with `setup_error=0x501 storage_error=0x501 FAILED`: NVIDIA's GL rejects `GL_LINEAR_TILING_EXT`, so the linear Vulkan image could not be imported. Since then the internal image and the GL texture use optimal tiling when `GL_VENDOR` contains NVIDIA (`vkTiling=optimal glTiling=optimal ok` is the expected line); everything else keeps linear. If the game shows a black screen on NVIDIA, report it with this file.
A non-zero error or `FAILED` means the frames cannot reach the window. Before 2026-10-16 the import did not mark the memory as dedicated, which the extension requires; Mesa ignores it. It was suspected of causing the NVIDIA black screen; the causes found later were the window visual and the draw buffer (#22). An NVIDIA log of 2026-10-16 showed `glTiling=optimal` with `setup_error=0x501 storage_error=0x501 FAILED`: NVIDIA's GL rejects `GL_LINEAR_TILING_EXT`, so the linear Vulkan image could not be imported. Since then the internal image and the GL texture use optimal tiling when `GL_VENDOR` contains NVIDIA (`vkTiling=optimal glTiling=optimal ok` is the expected line); everything else keeps linear. If the game shows a black screen on NVIDIA, report it with this file.
An additional NVIDIA report on `68f816d` showed successful optimal-tiling import but repeated `Metal Device timed out giving us a Drawable to present` messages. Its [presentation trace](https://github.com/lucannez64/darling-lol/issues/3#issuecomment-6214732591) shows all three drawables queued and all three main-thread presentation callbacks running, followed by timeouts with three frames still queued. The compositor only prepared a Metal layer when it was the root layer, skipping nested Metal layers. It now prepares Metal layers throughout the tree. A standalone nested-layer reproduction exhausts the pool before this correction and passes ten presentations with pixel readback after it; the root-layer probe also passes. Confirmation on the reporter's NVIDIA system is still needed.
An additional NVIDIA report on `68f816d` showed successful optimal-tiling import but repeated `Metal Device timed out giving us a Drawable to present` messages. Its [presentation trace](https://github.com/lucannez64/darling-lol/issues/3#issuecomment-6214732591) shows all three drawables queued and all three main-thread presentation callbacks running, followed by timeouts with three frames still queued. The compositor only prepared a Metal layer when it was the root layer, skipping nested Metal layers. It now prepares Metal layers throughout the tree. A standalone nested-layer reproduction exhausts the pool before this correction and passes ten presentations with pixel readback after it; the root-layer probe also passes. Issue #3 has since been closed, and the game renders on NVIDIA (see [NVIDIA](#nvidia)).
### NVIDIA resize signals, CPU readback and completion latency
@@ -348,6 +579,48 @@ with sway/Xwayland using CPU readback. The initial polling change improved their
reported rate from about 20 to 27 FPS. Those observations predate the narrowed
integration; they do not establish hybrid-GPU or general NVIDIA support.
### NVIDIA 580 (Pascal and older): black game window
Driver 580 is the last branch for Pascal (GTX 10-series) and older GPUs. With
`__GL_THREADED_OPTIMIZATIONS=1`, this driver with egl-x11 1.0.6 rejects the X11
`EGLDisplay` with `EGL_BAD_DISPLAY` on every thread that has a context current.
The display stays valid on other threads, and `eglReleaseThread()` makes it work
again on the failing one. A small native EGL program outside Darling shows the
same: `eglQueryString(display, EGL_VENDOR)` after `eglMakeCurrent` returns
`NVIDIA` without the variable and fails with `EGL_BAD_DISPLAY` with it.
In the Riot client the main thread loses EGL right after its first window frame;
`CGLCreateContext` then fails with CGL error 10004 and, if another display is
opened, glvnd hands it to Mesa's software renderer, which cannot present the
game's NVIDIA Vulkan frames. The launcher therefore leaves threaded optimizations
off (`__GL_THREADED_OPTIMIZATIONS=0`). Setting the variable to `1` before launch
restores the old behaviour.
Tested on a GTX 1060 6GB with driver 580.178.04 on NixOS, Hyprland and Xwayland:
the client and the tutorial match render on the NVIDIA GPU. Newer drivers were not
retested with the option off.
### Metal device name and memory budget
`MTLDevice` reports the host GPU: its Vulkan name in macOS style (for example
`AMD Radeon RX 6800 XT (RADV NAVI21)` becomes `AMD Radeon RX 6800 XT`) and a working set
(`recommendedMaxWorkingSetSize`) from the GPU's Vulkan memory heaps. Apps size their GPU
allocations from the working set, so only override it to match the card's real memory.
Both can be overridden per launch:
```sh
DARLING_METAL_GPU_NAME='AMD Radeon Pro 5500M' DARLING_METAL_VRAM_MB=4096 ./scripts/run.sh
```
The Riot launcher sets `DARLING_METAL_MAC_GPU_NAME=1`: the name becomes the GPU that
the MacBookPro16,1 IOKit reports shipped with, from the host's vendor: `AMD Radeon Pro 5500M`
on AMD and `Intel UHD Graphics 630` on Intel. NVIDIA GPUs keep their own name, so the game
never takes AMD or Intel driver paths on an NVIDIA card. The working set is still the host's.
`DARLING_METAL_MAC_GPU_NAME=0 ./scripts/run.sh` reports the host name instead; other Darling
apps report it unless they set the variable. `DARLING_METAL_GPU_NAME` wins over both.
`registryID` is a fixed non-zero value, as on a real Mac.
### Host-side checks
These are host settings, not game settings. Run on AC power, and use a performance power profile (`powerprofilesctl set performance`, or `asusctl profile -P Performance` on ASUS laptops). On the tested desktop the game is limited mostly by its own main thread on the CPU, not by the GPU (see [status](STATUS.md#game-fps-2026-10-1314)), so laptop CPU power limits matter as much as the GPU choice.
@@ -423,6 +696,43 @@ complete Hyprland window record (`class`, `title`, `mapped`, `hidden`). A second
process scan after window inspection must find the same client identities and
no game. Missing information or shutdown failure leaves normal launch in charge.
## Variable refresh rate (VRR)
Full-window borderless game layers now use the opaque managed X11 parent
surface for presentation. Previously they swapped an independent child while
the managed parent remained static. Using one surface lets the compositor
track the game's updates for VRR and avoids scanning out an empty parent.
Partial views, titled windows and other applications retain child drawables.
The child fallback retains the compositor-bypass prohibition that prevents
black screens. The fullscreen parent path advertises `_VARIABLE_REFRESH=1`;
leaving fullscreen clears that request. Desktop/driver policy still decides
whether to enable adaptive sync.
With the game and both clients closed, rebuild with `./scripts/update.sh`;
CoreGraphics, QuartzCore and the X11 backend must be updated together. Enable
VRR in the desktop and monitor, and compare the monitor's live refresh reading
to a frame cap below its maximum refresh, initially with VSync off. VRR and
VSync are separate: this change does not implement an in-game frame limiter.
See [the VRR probe and manual checks](TESTING.md#vrr-presentation-regression-46).
Standalone child, parent and nested-parent presentation probes passed locally;
monitor-level VRR and the reporter's in-game behavior still need confirmation.
## VSync and frame caps (#46)
The Metal layer's `displaySyncEnabled` property previously had no effect on
the window swap interval. A game that delegates pacing to VSync could run
uncapped when VSync was enabled. The compositor now applies that property to
the window before swapping, including nested Metal layers and setting changes.
If several Metal layers share a window, any layer requesting VSync enables it
for that window. Other windows retain independent swap settings.
Exit the game and both clients, then run `./scripts/update.sh` to rebuild and
install the QuartzCore and OpenGL changes. Until updated, disable in-game
VSync to use the game's frame cap, as the issue reporter confirmed. Physical
scanout timing still depends on the driver and desktop compositor; the fix
has standalone regression coverage but needs an in-game timing retest.
## Installer aborts on `/proc/<pid>/fdinfo`
[Issue #38](https://github.com/lucannez64/darling-lol/issues/38) reports an
+124 -31
View File
@@ -1,5 +1,5 @@
{
description = "Darling League of Legends - experimental macOS LoL runner on Linux via Darling";
description = "Unofficial Darling launcher for the macOS game client on Linux";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
@@ -12,6 +12,23 @@
pkgs = nixpkgs.legacyPackages.${system};
llvmPackages = pkgs.llvmPackages_21;
# Host /etc/fonts survives buildFHSEnv, but host /usr/share/fonts does
# not. Use store paths for both text fonts and the standard alias rules.
darlingFonts = [ pkgs.liberation_ttf pkgs.dejavu_fonts ];
darlingFontsConf = pkgs.makeFontsConf {
fontDirectories = darlingFonts;
includes = [
"${pkgs.fontconfig.out}/etc/fonts/conf.d"
# makeFontsConf adds XDG font directories, but neither it nor
# conf.d/50-user.conf adds the legacy ~/.fonts directory.
(pkgs.writeText "darling-user-fonts.conf" ''
<fontconfig>
<dir>~/.fonts</dir>
</fontconfig>
'')
];
};
# Python environment with dependencies required by tooling
pythonEnv = pkgs.python3.withPackages (
ps: with ps; [
@@ -119,7 +136,7 @@
pythonEnv
];
# FHS environment for building and running Darling and League of Legends.
# FHS environment for building and running Darling and the macOS game client.
# Darling requires root/multitasking mount & namespace semantics, /usr/local layout,
# and dynamically compiled overlays. An FHS environment provides the standard standard
# filesystem hierarchies (/usr/include, /usr/lib, /usr/bin) and native dynamic linker.
@@ -155,11 +172,25 @@
export DPREFIX="''${DPREFIX:-$HOME/.darling}"
export LC_ALL=C.UTF-8
export LANG=C.UTF-8
unset FONTCONFIG_PATH FONTCONFIG_SYSROOT
export FONTCONFIG_FILE=${darlingFontsConf}
export DARLING_RUNNER_DIR=${darlingRunner}/bin
export PATH="$DARLING_RUNNER_DIR:/usr/local/bin:''${PATH:-}"
export PKG_CONFIG_PATH="/usr/local/lib/pkgconfig:/usr/local/share/pkgconfig:/usr/lib/pkgconfig:/usr/lib64/pkgconfig:/usr/share/pkgconfig:''${PKG_CONFIG_PATH:-}"
export CMAKE_PREFIX_PATH="/usr/local:/usr:/usr/lib/cmake/llvm:''${CMAKE_PREFIX_PATH:-}"
unset NIX_LDFLAGS NIX_CFLAGS_COMPILE NIX_HARDENING_ENABLE CPATH LIBRARY_PATH
# NixOS keeps the GPU driver's EGL vendor and external-platform files under
# /run/opengl-driver, which glvnd and NVIDIA's driver in this FHS environment
# do not search. Without them glvnd only finds Mesa, and NVIDIA cannot open
# X11 displays (its X11 support is the egl-x11 external platform): the
# client renders with Mesa's software rasterizer, which garbles text and
# cannot present the game's NVIDIA Vulkan frames. FHS paths stay as fallback.
if [ -d /run/opengl-driver/share/glvnd/egl_vendor.d ]; then
export __EGL_VENDOR_LIBRARY_DIRS="/run/opengl-driver/share/glvnd/egl_vendor.d:/etc/glvnd/egl_vendor.d:/usr/share/glvnd/egl_vendor.d''${__EGL_VENDOR_LIBRARY_DIRS:+:$__EGL_VENDOR_LIBRARY_DIRS}"
fi
if [ -d /run/opengl-driver/share/egl/egl_external_platform.d ]; then
export __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS="/run/opengl-driver/share/egl/egl_external_platform.d:/etc/egl/egl_external_platform.d:/usr/share/egl/egl_external_platform.d''${__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:+:$__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS}"
fi
'';
runScript = pkgs.writeShellScript "darling-lol-run" ''
@@ -192,10 +223,10 @@
mkdir -p $out/bin $out/share/darling-lol $out/share/icons/hicolor/scalable/apps $out/share/icons/hicolor/128x128/apps $out/share/pixmaps $out/share/applications
cp -r * $out/share/darling-lol/
cp darling-lol.svg $out/share/icons/hicolor/scalable/apps/darling-lol.svg
cp darling-lol.png $out/share/icons/hicolor/128x128/apps/darling-lol.png
cp darling-lol.png $out/share/pixmaps/darling-lol.png
cp darling-lol.svg $out/share/pixmaps/darling-lol.svg
cp darling-lol.svg $out/share/icons/hicolor/scalable/apps/darling-launcher.svg
cp darling-lol.png $out/share/icons/hicolor/128x128/apps/darling-launcher.png
cp darling-lol.png $out/share/pixmaps/darling-launcher.png
cp darling-lol.svg $out/share/pixmaps/darling-launcher.svg
# Install configured launcher
substitute ./darling-lol-launcher.sh $out/bin/darling-lol \
@@ -204,6 +235,7 @@
--replace-fail "@UNSHARE_BIN@" "${pkgs.util-linux}/bin/unshare"
chmod +x $out/bin/darling-lol
wrapProgram $out/bin/darling-lol --prefix PATH : ${pkgs.lib.makeBinPath [ pythonEnv pkgs.coreutils pkgs.util-linux pkgs.gawk ]}
ln -s darling-lol $out/bin/darling-launcher
makeWrapper $out/bin/darling-lol $out/bin/darling-lol-installer \
--add-flags "--installer"
@@ -214,15 +246,15 @@
makeWrapper $out/bin/darling-lol $out/bin/darling-lol-build \
--add-flags "./scripts/build.sh"
cat << 'DESKTOP_EOF' > $out/share/applications/darling-lol.desktop
cat << 'DESKTOP_EOF' > $out/share/applications/darling-launcher.desktop
[Desktop Entry]
Categories=Game;
Comment=Play League of Legends macOS client on Linux via Darling
Exec=darling-lol
GenericName=MOBA Game
Icon=darling-lol
Keywords=League;LoL;Riot;Darling;
Name=League of Legends
Comment=Unofficial launcher for the macOS game client via Darling
Exec=darling-launcher
GenericName=Game
Icon=darling-launcher
Keywords=Darling;Launcher;Compatibility;
Name=Darling Launcher
StartupWMClass=RiotClientServices
Terminal=true
Type=Application
@@ -233,7 +265,7 @@ DESKTOP_EOF
'';
meta = with pkgs.lib; {
description = "Tooling and patches to run macOS League of Legends on Linux with Darling";
description = "Unofficial tooling and patches to run the macOS game client on Linux with Darling";
homepage = "https://github.com/lucannez64/darling-lol";
license = licenses.gpl3Only;
platforms = [ "x86_64-linux" ];
@@ -241,20 +273,81 @@ DESKTOP_EOF
};
in
{
checks.${system}.packaging = pkgs.runCommand "darling-lol-packaging-tests" {
nativeBuildInputs = [ pythonEnv pkgs.bash pkgs.git pkgs.coreutils pkgs.util-linux pkgs.gawk ];
} ''
cp -R ${darlingLol}/share/darling-lol source
chmod -R u+w source
cd source
export HOME="$TMPDIR/home" PYTHONDONTWRITEBYTECODE=1
mkdir -p "$HOME"
# Tests use private stub binaries/repositories, never Darling itself.
python3 -m unittest discover -s tests -v
python3 scripts/validate.py
grep -q '^Terminal=true$' ${darlingLol}/share/applications/darling-lol.desktop
touch "$out"
'';
checks.${system} = {
fonts = pkgs.runCommand "darling-lol-font-tests" {
nativeBuildInputs = [ pkgs.fontconfig ];
} ''
export HOME="$TMPDIR/home with spaces" XDG_CONFIG_HOME="$TMPDIR/config with spaces"
export XDG_DATA_HOME="$TMPDIR/data with spaces" XDG_CACHE_HOME="$TMPDIR/cache with spaces"
unset FONTCONFIG_PATH FONTCONFIG_SYSROOT
export FONTCONFIG_FILE=${darlingFontsConf}
mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME"
# Check real font selection with no host or user text fonts available.
check_font() {
actual=$(fc-match --format '%{family}' "$1")
if [ "$actual" != "$2" ]; then
echo "Font match for $1: expected $2, got $actual" >&2
exit 1
fi
test -r "$(fc-match --format '%{file}' "$1")"
}
check_aliases() {
check_font sans-serif 'DejaVu Sans'
check_font serif 'DejaVu Serif'
check_font monospace 'DejaVu Sans Mono'
check_font Arial 'Liberation Sans'
check_font 'Times New Roman' 'Liberation Serif'
check_font 'Courier New' 'Liberation Mono'
}
check_aliases
# Reproduce an icon-only user inventory without host text fonts.
cp -R ${pkgs.font-awesome}/share/fonts "$XDG_DATA_HOME/fonts"
fc-list --format '%{file}\n' | grep -F "$XDG_DATA_HOME/fonts/"
check_aliases
# Retain custom XDG and legacy fonts as well as user alias rules.
chmod u+w "$XDG_DATA_HOME/fonts"
mkdir -p "$HOME/.fonts" "$XDG_CONFIG_HOME/fontconfig"
cp ${pkgs.liberation_ttf}/share/fonts/truetype/LiberationSans-Regular.ttf "$XDG_DATA_HOME/fonts/user-text.ttf"
cp ${pkgs.liberation_ttf}/share/fonts/truetype/LiberationSerif-Regular.ttf "$HOME/.fonts/legacy-text.ttf"
fc-list --format '%{file}\n' | grep -Fx "$XDG_DATA_HOME/fonts/user-text.ttf"
fc-list --format '%{file}\n' | grep -Fx "$HOME/.fonts/legacy-text.ttf"
cat > "$XDG_CONFIG_HOME/fontconfig/fonts.conf" <<'USERCONF_EOF'
<fontconfig>
<alias>
<family>DarlingCompatibilityAlias</family>
<prefer><family>Liberation Serif</family></prefer>
</alias>
</fontconfig>
USERCONF_EOF
check_font DarlingCompatibilityAlias 'Liberation Serif'
touch "$out"
'';
packaging = pkgs.runCommand "darling-lol-packaging-tests" {
nativeBuildInputs = [ pythonEnv pkgs.bash pkgs.git pkgs.coreutils pkgs.util-linux pkgs.gawk ];
} ''
cp -R ${darlingLol}/share/darling-lol source
chmod -R u+w source
cd source
export HOME="$TMPDIR/home" PYTHONDONTWRITEBYTECODE=1
mkdir -p "$HOME"
# Tests use private stub binaries/repositories, never Darling itself.
python3 -m unittest discover -s tests -v
python3 scripts/validate.py
grep -q '^Terminal=true$' ${darlingLol}/share/applications/darling-launcher.desktop
grep -q '^Name=Darling Launcher$' ${darlingLol}/share/applications/darling-launcher.desktop
grep -q '^Icon=darling-launcher$' ${darlingLol}/share/applications/darling-launcher.desktop
grep -q '^Keywords=Darling;Launcher;Compatibility;$' ${darlingLol}/share/applications/darling-launcher.desktop
grep -q '^Categories=Game;$' ${darlingLol}/share/applications/darling-launcher.desktop
# Darling derives WM_CLASS from argv[0], so the window class is the
# client executable name; this is an interoperability identifier.
grep -q '^StartupWMClass=RiotClientServices$' ${darlingLol}/share/applications/darling-launcher.desktop
! grep -qiE '^(Name|Comment|GenericName|Keywords)=.*(League|LoL|Riot|Vanguard|anti-cheat)' ${darlingLol}/share/applications/darling-launcher.desktop
test -x ${darlingLol}/bin/darling-launcher
touch "$out"
'';
};
packages.${system} = {
darling-lol = darlingLol;
@@ -266,7 +359,7 @@ DESKTOP_EOF
default = {
type = "app";
program = "${darlingLol}/bin/darling-lol";
meta.description = "Launch League of Legends via Darling";
meta.description = "Launch the macOS game client via Darling";
};
shell = {
type = "app";
@@ -276,7 +369,7 @@ DESKTOP_EOF
installer = {
type = "app";
program = "${darlingLol}/bin/darling-lol-installer";
meta.description = "Run the Darling and League of Legends rootless installer";
meta.description = "Run the Darling rootless installer";
};
build = {
type = "app";
@@ -288,7 +381,7 @@ DESKTOP_EOF
devShells.${system}.default = pkgs.mkShell {
packages = buildDeps ++ [ darlingLolFhs ];
shellHook = ''
echo "Darling LoL build & development environment loaded."
echo "Darling build & development environment loaded."
echo "Tip: Run 'darling-lol-fhs' to enter the FHS build sandbox if standard paths (/usr/include, /usr/lib) are needed."
'';
};
+1357 -149
View File
File diff suppressed because it is too large. Load diff
+719 -73
View File
File diff suppressed because it is too large. Load diff
+85 -13
View File
@@ -445,7 +445,7 @@ index 458e659..2e33f20 100644
// wait until it's read
if (!dtape_semaphore_down_simple(kqchan->waiter_read_semaphore)) {
diff --git a/duct-tape/src/memory.c b/duct-tape/src/memory.c
index 3114947..e2a8d5e 100644
index 3114947..20cd162 100644
--- a/duct-tape/src/memory.c
+++ b/duct-tape/src/memory.c
@@ -11,6 +11,7 @@
@@ -456,7 +456,55 @@ index 3114947..e2a8d5e 100644
#include <mach_debug/mach_debug.h>
struct zone {
@@ -341,7 +342,14 @@ kern_return_t copyoutmap(vm_map_t map, void* fromdata, vm_map_address_t toaddr,
@@ -52,6 +53,26 @@ int ftruncate(int fd, off_t length);
#define MFD_CLOEXEC 0x1
+/* darling-lol diagnostics: why shared-memory creation fails. */
+int* __errno_location(void);
+struct dtape_diag_rlimit { unsigned long cur, max; };
+int prlimit(int pid, int resource, const struct dtape_diag_rlimit* new_limit, struct dtape_diag_rlimit* old_limit);
+#define DTAPE_DIAG_RLIMIT_NOFILE 7
+
+static long dtape_diag_live_descriptors;
+static unsigned long dtape_diag_created_descriptors;
+
+static void dtape_diag_log_shortage(const char* what, int fd, uint64_t size) {
+ int err = *__errno_location();
+ struct dtape_diag_rlimit limit = {0, 0};
+ prlimit(0, DTAPE_DIAG_RLIMIT_NOFILE, NULL, &limit);
+ dtape_log_error("MEMDIAG %s failed errno=%d size=%#llx fd=%d live=%ld created=%lu nofile=%lu/%lu",
+ what, err, (unsigned long long)size, fd,
+ __atomic_load_n(&dtape_diag_live_descriptors, __ATOMIC_RELAXED),
+ __atomic_load_n(&dtape_diag_created_descriptors, __ATOMIC_RELAXED),
+ limit.cur, limit.max);
+}
+
void dtape_memory_init(void) {
};
@@ -107,6 +128,12 @@ static dtape_map_shared_descriptor_t* dtape_map_shared_descriptor_create(int mem
desc->memfd = memfd;
desc->size = size;
+ long live = __atomic_add_fetch(&dtape_diag_live_descriptors, 1, __ATOMIC_RELAXED);
+ unsigned long created = __atomic_add_fetch(&dtape_diag_created_descriptors, 1, __ATOMIC_RELAXED);
+ if (created % 1024 == 0) {
+ dtape_log_error("MEMDIAG descriptors live=%ld created=%lu last_fd=%d", live, created, memfd);
+ }
+
return desc;
};
@@ -121,6 +148,7 @@ static void dtape_map_shared_descriptor_release(dtape_map_shared_descriptor_t* d
close(desc->memfd);
free(desc);
+ __atomic_sub_fetch(&dtape_diag_live_descriptors, 1, __ATOMIC_RELAXED);
};
static dtape_map_shared_entry_t* dtape_map_shared_entry_create(uint64_t address, uint64_t size, uint64_t page_offset, dtape_map_shared_descriptor_t* descriptor) {
@@ -341,7 +369,14 @@ kern_return_t copyoutmap(vm_map_t map, void* fromdata, vm_map_address_t toaddr,
memmove((void*)toaddr, fromdata, length);
return KERN_SUCCESS;
} else {
@@ -472,7 +520,7 @@ index 3114947..e2a8d5e 100644
}
};
@@ -526,20 +534,212 @@ kern_return_t mach_vm_allocate_kernel(vm_map_t map, mach_vm_offset_t* addr, mach
@@ -526,20 +561,218 @@ kern_return_t mach_vm_allocate_kernel(vm_map_t map, mach_vm_offset_t* addr, mach
}
};
@@ -570,8 +618,13 @@ index 3114947..e2a8d5e 100644
+ dtape_map_shared_descriptor_t* desc = NULL;
+ if (permission & MAP_MEM_NAMED_CREATE) {
+ memfd = memfd_create("darling-mementry", MFD_CLOEXEC);
+ if (memfd < 0 || ftruncate(memfd, map_size) < 0) {
+ if (memfd >= 0) close(memfd);
+ if (memfd < 0) {
+ dtape_diag_log_shortage("named-create memfd_create", memfd, map_size);
+ return KERN_RESOURCE_SHORTAGE;
+ }
+ if (ftruncate(memfd, map_size) < 0) {
+ dtape_diag_log_shortage("named-create ftruncate", memfd, map_size);
+ close(memfd);
+ return KERN_RESOURCE_SHORTAGE;
+ }
+ } else {
@@ -627,6 +680,7 @@ index 3114947..e2a8d5e 100644
+
+ desc = dtape_map_shared_descriptor_create(memfd, map_size);
+ if (desc == NULL) {
+ dtape_diag_log_shortage("named-entry descriptor malloc", memfd, map_size);
+ close(memfd);
+ return KERN_RESOURCE_SHORTAGE;
+ }
@@ -689,7 +743,7 @@ index 3114947..e2a8d5e 100644
};
kern_return_t mach_memory_info(host_priv_t host, mach_zone_name_array_t* namesp, mach_msg_type_number_t* namesCntp, mach_zone_info_array_t* infop, mach_msg_type_number_t* infoCntp, mach_memory_info_array_t* memoryInfop, mach_msg_type_number_t* memoryInfoCntp) {
@@ -591,8 +791,115 @@ kern_return_t mach_vm_machine_attribute(vm_map_t map, mach_vm_address_t addr, ma
@@ -591,8 +824,115 @@ kern_return_t mach_vm_machine_attribute(vm_map_t map, mach_vm_address_t addr, ma
};
kern_return_t mach_vm_map_external(vm_map_t target_map, mach_vm_offset_t* address, mach_vm_size_t initial_size, mach_vm_offset_t mask, int flags, ipc_port_t port, vm_object_offset_t offset, boolean_t copy, vm_prot_t cur_protection, vm_prot_t max_protection, vm_inherit_t inheritance) {
@@ -807,7 +861,7 @@ index 3114947..e2a8d5e 100644
};
kern_return_t mach_vm_msync(vm_map_t map, mach_vm_offset_t address, mach_vm_size_t size, vm_sync_t sync_flags) {
@@ -620,15 +927,22 @@ kern_return_t mach_vm_msync(vm_map_t map, mach_vm_offset_t address, mach_vm_size
@@ -620,15 +960,22 @@ kern_return_t mach_vm_msync(vm_map_t map, mach_vm_offset_t address, mach_vm_size
};
kern_return_t mach_vm_page_info(vm_map_t map, mach_vm_address_t address, vm_page_info_flavor_t flavor, vm_page_info_t info, mach_msg_type_number_t* count) {
@@ -833,7 +887,7 @@ index 3114947..e2a8d5e 100644
};
kern_return_t mach_vm_protect(vm_map_t map, mach_vm_offset_t start, mach_vm_size_t size, boolean_t set_maximum, vm_prot_t new_protection) {
@@ -756,7 +1070,8 @@ region_info_out:
@@ -756,7 +1103,8 @@ region_info_out:
return kr;
};
default:
@@ -843,7 +897,7 @@ index 3114947..e2a8d5e 100644
}
};
@@ -856,7 +1171,7 @@ static kern_return_t mach_vm_remap_external_shared(vm_map_t target_map, mach_vm_
@@ -856,7 +1204,7 @@ static kern_return_t mach_vm_remap_external_shared(vm_map_t target_map, mach_vm_
src_existing_entry_count = dtape_map_find_shared_entries_locked(src_map, start_memaddr, map_size, src_existing_entries, sizeof(src_existing_entries) / sizeof(*src_existing_entries));
@@ -852,7 +906,7 @@ index 3114947..e2a8d5e 100644
// special case for what LLDB does with dyld info
dtape_map_shared_descriptor_retain(src_existing_entries[0]->descriptor);
descriptor = src_existing_entries[0]->descriptor;
@@ -1441,6 +1756,10 @@ mach_destroy_memory_entry(
@@ -1441,6 +1789,10 @@ mach_destroy_memory_entry(
named_entry->ref_count -= 1;
if (named_entry->ref_count == 0) {
@@ -863,7 +917,7 @@ index 3114947..e2a8d5e 100644
if (named_entry->is_sub_map) {
vm_map_deallocate(named_entry->backing.map);
} else if (named_entry->is_copy) {
@@ -1456,6 +1775,9 @@ mach_destroy_memory_entry(
@@ -1456,6 +1808,9 @@ mach_destroy_memory_entry(
named_entry_unlock(named_entry);
named_entry_lock_destroy(named_entry);
@@ -1802,7 +1856,7 @@ index cc10f1d..7df0056 100644
try {
thread->processSignal(_body.bsd_signal_number, _body.linux_signal_number, _body.code, _body.signal_address, _body.thread_state, _body.float_state);
diff --git a/src/darlingserver.cpp b/src/darlingserver.cpp
index f79f057..2bf9cfb 100644
index f79f057..54c42aa 100644
--- a/src/darlingserver.cpp
+++ b/src/darlingserver.cpp
@@ -497,6 +497,45 @@ static void handle_sigusr1(int signum) {
@@ -1882,7 +1936,25 @@ index f79f057..2bf9cfb 100644
if (argc < 6) {
fprintf(stderr, "darlingserver is not meant to be started manually\n");
exit(1);
@@ -745,6 +808,13 @@ int main(int argc, char** argv) {
@@ -577,8 +640,15 @@ int main(int argc, char** argv) {
// now set our increased rlimit
if (setrlimit(RLIMIT_NOFILE, &increased_limit) != 0) {
- fprintf(stderr, "Warning: failed to increase FD rlimit: %s\n", strerror(errno));
- //exit(1);
+ // darling-lol: a rootless user namespace cannot raise the hard limit.
+ // Still raise the soft limit to it: every named memory entry holds a
+ // memfd here, and Chromium's UX restart pushes past a 1024 soft limit,
+ // failing memfd_create with EMFILE.
+ increased_limit.rlim_cur = increased_limit.rlim_max = default_limit.rlim_max;
+ if (setrlimit(RLIMIT_NOFILE, &increased_limit) != 0) {
+ fprintf(stderr, "Warning: failed to increase FD rlimit: %s\n", strerror(errno));
+ //exit(1);
+ }
}
#endif
@@ -745,6 +815,13 @@ int main(int argc, char** argv) {
#endif
// create the server
+42 -7
View File
@@ -289,7 +289,7 @@ index b7ffcce..5b1e84c 100644
};
};
diff --git a/private-include/indium/command-encoder.private.hpp b/private-include/indium/command-encoder.private.hpp
index c3b1b93..3dab2ce 100644
index c3b1b93..6b8783d 100644
--- a/private-include/indium/command-encoder.private.hpp
+++ b/private-include/indium/command-encoder.private.hpp
@@ -1,6 +1,7 @@
@@ -300,7 +300,7 @@ index c3b1b93..3dab2ce 100644
#include <memory>
#include <optional>
#include <array>
@@ -17,117 +18,295 @@
@@ -17,117 +18,330 @@
#include <iridium/iridium.hpp>
@@ -529,6 +529,40 @@ index c3b1b93..3dab2ce 100644
+ struct AddressPool {
+ std::shared_ptr<Buffer> chunk;
+ size_t used = 0;
+
+ // Two shader stages are live in the render encoder. Cache their small
+ // immutable tables in CPU memory, not in the GPU-mapped scratch chunk.
+ // This is only a read cache: misses retain the original byte comparison.
+ struct Shadow {
+ bool valid = false;
+ uint32_t offset;
+ VkDeviceSize range;
+ size_t count;
+ std::array<uint64_t, 64> addresses;
+ };
+ std::array<Shadow, 2> shadows;
+ size_t nextShadow = 0;
+
+ bool sameAddresses(uint32_t offset, VkDeviceSize range, const uint64_t* addresses, size_t count) const {
+ for (const auto& shadow : shadows) {
+ // A table's last eight bytes can be padding. If a new layout
+ // reads that padding, only the mapped bytes define the old key.
+ if (shadow.valid && shadow.offset == offset && shadow.range == range && count <= shadow.count)
+ return !memcmp(shadow.addresses.data(), addresses, count * sizeof(uint64_t));
+ }
+ return !memcmp(static_cast<const char*>(chunk->contents()) + offset, addresses, count * sizeof(uint64_t));
+ }
+
+ void rememberAddresses(uint32_t offset, VkDeviceSize range, const uint64_t* addresses, size_t count) {
+ if (count > 64) return;
+ auto& shadow = shadows[nextShadow];
+ nextShadow = (nextShadow + 1) % shadows.size();
+ shadow.offset = offset;
+ shadow.range = range;
+ shadow.count = count;
+ memcpy(shadow.addresses.data(), addresses, count * sizeof(uint64_t));
+ shadow.valid = true;
+ }
+ };
- if (functionResources.buffers.size() > 0) {
@@ -583,17 +617,18 @@ index c3b1b93..3dab2ce 100644
+ if (bindingReuseEnabled() && previous && addressPool.chunk && previous->range == tableSize &&
+ previous->offset <= chunkSize && tableSize <= chunkSize - previous->offset &&
+ previous->buffer == static_cast<PrivateBuffer*>(addressPool.chunk.get())->buffer() &&
+ !memcmp(static_cast<const char*>(addressPool.chunk->contents()) + previous->offset,
+ addresses.data(), addresses.size() * sizeof(uint64_t)))
+ addressPool.sameAddresses(previous->offset, previous->range, addresses.data(), addresses.size()))
+ return *previous;
+ size_t tableOffset = (addressPool.used + alignment - 1) & ~(alignment - 1);
+ if (!addressPool.chunk || tableOffset + tableSize > chunkSize) {
+ addressPool.chunk = privateDevice->acquireScratchChunk();
+ for (auto& shadow : addressPool.shadows) shadow.valid = false;
+ // we need to keep the chunk alive until the operation is completed
+ keepAliveBuffers.push_back(addressPool.chunk);
+ tableOffset = 0;
+ }
+ memcpy(static_cast<char*>(addressPool.chunk->contents()) + tableOffset, addresses.data(), addresses.size() * sizeof(uint64_t));
+ if (previous) addressPool.rememberAddresses(static_cast<uint32_t>(tableOffset), tableSize, addresses.data(), addresses.size());
+ addressPool.used = tableOffset + tableSize;
+ // plain pointer: a shared_ptr cast here was an atomic reference count round trip per stage per draw
+ auto* privateAddressBuffer = static_cast<PrivateBuffer*>(addressPool.chunk.get());
@@ -654,7 +689,7 @@ index c3b1b93..3dab2ce 100644
descSet.descriptorCount = 1;
descSet.pBufferInfo = &info;
}
@@ -136,17 +315,27 @@ namespace Indium {
@@ -136,17 +350,27 @@ namespace Indium {
auto& bindingInfo = funcInfo.bindings[j];
if (bindingInfo.type == Iridium::BindingType::Texture) {
@@ -691,7 +726,7 @@ index c3b1b93..3dab2ce 100644
auto& descSet = writeDescSet.emplace_back();
descSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
descSet.dstSet = descriptorSets[i];
@@ -156,19 +345,21 @@ namespace Indium {
@@ -156,19 +380,21 @@ namespace Indium {
descSet.descriptorCount = 1;
descSet.pImageInfo = &info;
} else if (bindingInfo.type == Iridium::BindingType::Sampler) {
@@ -724,7 +759,7 @@ index c3b1b93..3dab2ce 100644
info.sampler = privateSampler->sampler();
auto& descSet = writeDescSet.emplace_back();
@@ -188,10 +379,4 @@ namespace Indium {
@@ -188,10 +414,4 @@ namespace Indium {
return descriptorSets;
};
+63 -27
View File
@@ -1134,7 +1134,7 @@ index 2a57796..0cbedf2 100644
-
@end
diff --git a/src/Metal/MTLDevice.mm b/src/Metal/MTLDevice.mm
index f58184a..a80a625 100644
index f58184a..2c45608 100644
--- a/src/Metal/MTLDevice.mm
+++ b/src/Metal/MTLDevice.mm
@@ -2,14 +2,32 @@
@@ -1218,7 +1218,7 @@ index f58184a..a80a625 100644
}
@synthesize device = _device;
@@ -116,6 +168,39 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
@@ -116,6 +168,50 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
[_threadExitCondition unlock];
}
@@ -1228,16 +1228,27 @@ index f58184a..a80a625 100644
+ // Overrides are read once per process.
+ static const char* const nameOverride = getenv("DARLING_METAL_GPU_NAME");
+ static const uint64_t vramOverride = MTLDeviceInfo::parseMegabytes(getenv("DARLING_METAL_VRAM_MB"));
+ static const bool macName = [] {
+ const char* value = getenv("DARLING_METAL_MAC_GPU_NAME");
+ return value && value[0] == '1' && value[1] == '\0';
+ }();
+
+ std::string vulkanName = _device->name();
+ _name = [[NSString alloc] initWithUTF8String: MTLDeviceInfo::resolveName(nameOverride, vulkanName.c_str()).c_str()];
+ auto priv = dynamic_cast<Indium::PrivateDevice*>(_device.get());
+ std::string vulkanName;
+ try {
+ vulkanName = _device ? _device->name() : "";
+ } catch (...) {
+ vulkanName = "";
+ }
+ const char* mac = macName && priv ? MTLDeviceInfo::macGpuName(priv->properties().vendorID) : nullptr;
+ _name = [[NSString alloc] initWithUTF8String: MTLDeviceInfo::resolveName(nameOverride, vulkanName.c_str(), mac).c_str()];
+ if (_name == nil) {
+ _name = [[NSString alloc] initWithUTF8String: MTLDeviceInfo::kFallbackName];
+ }
+
+ uint64_t workingSet = 0;
+ _sharesSystemMemory = NO;
+ if (auto priv = dynamic_cast<Indium::PrivateDevice*>(_device.get())) {
+ if (priv) {
+ const VkPhysicalDeviceType type = priv->properties().deviceType;
+ _sharesSystemMemory = type == VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU || type == VK_PHYSICAL_DEVICE_TYPE_CPU;
+ const VkPhysicalDeviceMemoryProperties memory = priv->memoryProperties();
@@ -1258,7 +1269,7 @@ index f58184a..a80a625 100644
- (instancetype)initWithDevice: (std::shared_ptr<Indium::Device>)device
{
self = [super init];
@@ -123,6 +208,7 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
@@ -123,6 +219,7 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
_device = device;
_threadExitCondition = [NSCondition new];
_threadIsRunning = NO;
@@ -1266,7 +1277,7 @@ index f58184a..a80a625 100644
_pollingThread = [[NSThread alloc] initWithTarget: self selector: @selector(pollingLoop) object: nil];
[_pollingThread start];
}
@@ -133,6 +219,7 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
@@ -133,6 +230,7 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
{
[_pollingThread release];
[_threadExitCondition release];
@@ -1274,7 +1285,7 @@ index f58184a..a80a625 100644
[super dealloc];
}
@@ -153,12 +240,60 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
@@ -153,12 +251,60 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
[_threadExitCondition unlock];
}
@@ -1311,7 +1322,7 @@ index f58184a..a80a625 100644
+- (BOOL)isRemovable { return NO; }
+- (BOOL)isHeadless { return NO; }
+- (NSString*)name { return _name; }
+- (uint64_t)registryID { return 0; }
+- (uint64_t)registryID { return MTLDeviceInfo::kDefaultRegistryID; }
+- (BOOL)supportsFamily: (NSUInteger)family { return YES; }
+- (BOOL)supportsFeatureSet: (NSUInteger)featureSet { return YES; }
+- (BOOL)supportsTextureSampleCount: (NSUInteger)sampleCount { return sampleCount <= 4; }
@@ -1336,7 +1347,7 @@ index f58184a..a80a625 100644
if (!pso) {
if (error) {
// TODO: better error and/or match what the official Metal method does
@@ -166,6 +301,7 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
@@ -166,6 +312,7 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
}
return nil;
}
@@ -1344,7 +1355,7 @@ index f58184a..a80a625 100644
return [[MTLComputePipelineStateInternal alloc] initWithState: pso device: self label: descriptor.label];
}
@@ -183,7 +319,13 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
@@ -183,7 +330,13 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
reflection: (MTLAutoreleasedComputePipelineReflection*)reflection
error: (NSError**)error
{
@@ -1359,7 +1370,7 @@ index f58184a..a80a625 100644
if (!pso) {
if (error) {
// TODO: better error and/or match what the official Metal method does
@@ -191,13 +333,44 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
@@ -191,13 +344,44 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
}
return nil;
}
@@ -1405,7 +1416,7 @@ index f58184a..a80a625 100644
if (!pso) {
if (error) {
// TODO: better error and/or match what the official Metal method does
@@ -205,7 +378,114 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
@@ -205,7 +389,118 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
}
return nil;
}
@@ -1453,7 +1464,9 @@ index f58184a..a80a625 100644
+- (id<MTLDepthStencilState>)newDepthStencilStateWithDescriptor:(MTLDepthStencilDescriptor*)descriptor
+{
+ if (!descriptor) return nil;
+ auto state = _device->newDepthStencilState([descriptor asIndiumDescriptor]);
+ std::shared_ptr<Indium::DepthStencilState> state;
+ try { state = _device->newDepthStencilState([descriptor asIndiumDescriptor]); }
+ catch (...) { return nil; }
+ if (!state) return nil;
+ return [[MTLDepthStencilStateInternal alloc] initWithState:state device:self label:descriptor.label];
+}
@@ -1475,7 +1488,9 @@ index f58184a..a80a625 100644
+ desc.lodMaxClamp = descriptor.lodMaxClamp;
+ desc.supportArgumentBuffers = descriptor.supportArgumentBuffers;
+ desc.compareFunction = static_cast<Indium::CompareFunction>(descriptor.compareFunction);
+ auto sampler = _device->newSamplerState(desc);
+ std::shared_ptr<Indium::SamplerState> sampler;
+ try { sampler = _device->newSamplerState(desc); }
+ catch (...) { return nil; }
+ if (!sampler) return nil;
+ return [[MTLSamplerStateInternal alloc] initWithSampler:sampler device:self label:descriptor.label];
+}
@@ -1521,7 +1536,7 @@ index f58184a..a80a625 100644
}
- (id<MTLCommandQueue>)newCommandQueue
@@ -275,8 +555,21 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
@@ -275,8 +570,21 @@ void MTLRemoveDeviceObserver(id<NSObject> observer) {
- (id<MTLLibrary>)newLibraryWithData: (dispatch_data_t)data
error: (NSError**)error
{
@@ -2581,7 +2596,7 @@ index 66a9056..f28235c 100644
-
@end
diff --git a/src/Metal/MTLTexture.mm b/src/Metal/MTLTexture.mm
index 6c71fb9..822c112 100644
index 6c71fb9..186b8c0 100644
--- a/src/Metal/MTLTexture.mm
+++ b/src/Metal/MTLTexture.mm
@@ -1,3 +1,5 @@
@@ -2590,7 +2605,7 @@ index 6c71fb9..822c112 100644
// SPDX-FileCopyrightText: 2026 Darling Developers
// SPDX-License-Identifier: MPL-2.0
@@ -7,11 +9,37 @@
@@ -7,11 +9,38 @@
#import <Metal/MTLPixelFormatInternal.h>
#import <Metal/MTLTypesInternal.h>
@@ -2606,7 +2621,8 @@ index 6c71fb9..822c112 100644
+ if (FILE* log = fopen("/tmp/metal-trace.log", "a")) {
+ const unsigned char* b = (const unsigned char*) bytes;
+ unsigned long nonZero = 0;
+ size_t probe = std::min<size_t>(4096, (size_t) region.size.width * 4);
+ // Every pixel format takes at least one byte per pixel, so the first `width` bytes lie inside the first row.
+ size_t probe = std::min<size_t>(4096, (size_t) region.size.width);
+ for (size_t i = 0; i < probe; ++i) if (b[i]) ++nonZero;
+ fprintf(log, "replaceRegion tex=%p %lux%lu fmt=%lu level=%lu region=(%lu,%lu %lux%lu) firstBytesNonZero=%lu/%zu took=%.1fms\n", texture,
+ (unsigned long) texture.width, (unsigned long) texture.height, (unsigned long) texture.pixelFormat, (unsigned long) level,
@@ -2628,7 +2644,7 @@ index 6c71fb9..822c112 100644
@synthesize device = _device;
@synthesize resourceOptions = _resourceOptions;
@synthesize label = _label;
@@ -164,7 +192,9 @@
@@ -164,7 +193,9 @@
withBytes: (const void*)pixelBytes
bytesPerRow: (NSUInteger)bytesPerRow
{
@@ -2638,7 +2654,7 @@ index 6c71fb9..822c112 100644
}
- (void)replaceRegion: (MTLRegion)region
@@ -174,7 +204,9 @@
@@ -174,7 +205,9 @@
bytesPerRow: (NSUInteger)bytesPerRow
bytesPerImage: (NSUInteger)bytesPerImage
{
@@ -2736,7 +2752,7 @@ index 37cccb3..79b2350 100644
-
@end
diff --git a/src/Metal/MTLVertexDescriptor.mm b/src/Metal/MTLVertexDescriptor.mm
index cde968d..aa7fe05 100644
index cde968d..0a016c6 100644
--- a/src/Metal/MTLVertexDescriptor.mm
+++ b/src/Metal/MTLVertexDescriptor.mm
@@ -1,24 +1,435 @@
@@ -3015,8 +3031,8 @@ index cde968d..aa7fe05 100644
+ if (index >= 31) [NSException raise:NSRangeException format:@"Vertex descriptor index %lu exceeds 30", (unsigned long)index];
+ MTLVertexBufferLayoutDescriptor* old = _items[index];
+ _items[index] = [descriptor retain];
+ [old release];
+ MTLVertexBufferLayoutArrayRetireItem(self, old);
+ [old release];
+ const uint32_t bit = 1u << index;
+ if (descriptor) _present |= bit; else _present &= ~bit;
+ if (descriptor && object_getClass(descriptor) == [MTLVertexBufferLayoutDescriptor class]) _concrete |= bit; else _concrete &= ~bit;
@@ -3077,8 +3093,8 @@ index cde968d..aa7fe05 100644
+ if (index >= 31) [NSException raise:NSRangeException format:@"Vertex descriptor index %lu exceeds 30", (unsigned long)index];
+ MTLVertexAttributeDescriptor* old = _items[index];
+ _items[index] = [descriptor retain];
+ [old release];
+ MTLVertexAttributeArrayRetireItem(self, old);
+ [old release];
+ const uint32_t bit = 1u << index;
+ if (descriptor) _present |= bit; else _present &= ~bit;
+ if (descriptor && object_getClass(descriptor) == [MTLVertexAttributeDescriptor class]) _concrete |= bit; else _concrete &= ~bit;
@@ -3440,7 +3456,7 @@ diff --git a/private-include/Metal/MTLDepthStencilInternal.h b/private-include/M
new file mode 100644
--- /dev/null
+++ b/private-include/Metal/MTLDepthStencilInternal.h
@@ -0,0 +1,25 @@
@@ -0,0 +1,27 @@
+// SPDX-FileCopyrightText: 2026 Darling Developers
+// SPDX-License-Identifier: MPL-2.0
+
@@ -3462,15 +3478,17 @@ new file mode 100644
+- (instancetype)initWithState:(std::shared_ptr<Indium::DepthStencilState>)state device:(id<MTLDevice>)device label:(NSString*)label;
+#endif
+@end
+#if DARLING_METAL_ENABLED
+// Direct access for the encoders: the property getter is a message send that returns the shared_ptr by value.
+const std::shared_ptr<Indium::DepthStencilState>& MTLDepthStencilGetIndium(id object);
+#endif
+
+#endif
diff --git a/private-include/Metal/MTLDeviceInfo.h b/private-include/Metal/MTLDeviceInfo.h
new file mode 100644
--- /dev/null
+++ b/private-include/Metal/MTLDeviceInfo.h
@@ -0,0 +1,148 @@
@@ -0,0 +1,166 @@
+// SPDX-FileCopyrightText: 2026 Darling Developers
+// SPDX-License-Identifier: MPL-2.0
+
@@ -3489,6 +3507,8 @@ new file mode 100644
+
+// Used when neither the cleaned nor the raw Vulkan name has any characters.
+static constexpr const char* kFallbackName = "Darling Indium Device";
+// A real Mac never reports registry ID 0, and apps use it as a key for the device.
+static constexpr uint64_t kDefaultRegistryID = 0x100000439ULL;
+
+static inline bool isSpace(char c) {
+ return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v';
@@ -3568,10 +3588,26 @@ new file mode 100644
+}
+
+// DARLING_METAL_GPU_NAME (used verbatim when non-empty) wins over the Vulkan name.
+static inline std::string resolveName(const char* overrideName, const char* vulkanName) {
+// With DARLING_METAL_MAC_GPU_NAME=1: the GPU from the host's vendor that the MacBookPro16,1
+// iokitd reports shipped with, by Vulkan vendor ID. nullptr for other vendors (NVIDIA), which
+// keep their own name so apps never take another vendor's driver paths.
+static inline const char* macGpuName(uint32_t vendorID) {
+ switch (vendorID) {
+ case 0x1002: return "AMD Radeon Pro 5500M";
+ case 0x8086: return "Intel UHD Graphics 630";
+ default: return nullptr;
+ }
+}
+
+// DARLING_METAL_GPU_NAME wins, then the Mac GPU name (when enabled and known), then the
+// cleaned Vulkan name.
+static inline std::string resolveName(const char* overrideName, const char* vulkanName, const char* macName = nullptr) {
+ if (overrideName && overrideName[0] != '\0') {
+ return overrideName;
+ }
+ if (macName && macName[0] != '\0') {
+ return macName;
+ }
+ return metalNameFromVulkanName(vulkanName);
+}
+
+15
View File
@@ -205,6 +205,21 @@ index 06c5d4aa..9f24ea91 100644
if (context.numTasks >= MAX_TASKS) {
secnotice("http", "Too many fetch %@ requests for this cert", [self class]);
return true;
diff --git a/trust/trustd/macOS/com.apple.trustd.plist b/trust/trustd/macOS/com.apple.trustd.plist
index b3780e3c..71e089a4 100644
--- a/trust/trustd/macOS/com.apple.trustd.plist
+++ b/trust/trustd/macOS/com.apple.trustd.plist
@@ -2,7 +2,9 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
- <key>EnablePressuredExit</key>
+ <key>KeepAlive</key>
+ <true/>
+ <key>RunAtLoad</key>
<true/>
<key>EnableTransactions</key>
<true/>
diff --git a/trust/trustd/trustdFileHelper/main.m b/trust/trustd/trustdFileHelper/main.m
index f6d166e2..fb7ae819 100644
--- a/trust/trustd/trustdFileHelper/main.m
+21 -1
View File
@@ -5051,7 +5051,7 @@ index b998456..c6281e6 100644
+ * (/tmp/rclol-local-semaphores, the former opt-in switch, is now a no-op). While it was on by default in every
+ * process (2026-10-10 22:22-23:50) LeagueClient died ~85 s after start in 3 of 4 checked launches
+ * ("mach_msg_overwrite failed -9", then dserver_rpc_interrupt_enter -9: its RPC descriptor had become invalid) and
+ * the user saw Vanguard errors; that mechanism is not understood, so the clients keep the server path. With the
+ * the client logged integrity-check errors; that mechanism is not understood, so the clients keep the server path. With the
+ * lifetime fixes above the game ran two full matches with it (2026-10-14: the main thread's server waits fell from
+ * 1.36 to 0.10 ms per frame, 39 to 15 blocks per frame). /tmp/rclol-local-semaphores-all turns it on for every
+ * process (probe tests). Decided once per process and logged
@@ -6180,3 +6180,23 @@ index 0000000..eb90f43
+ return true;
+}
+#endif
diff --git a/darling/src/libsystem_kernel/emulation/src/linux_premigration/vchroot_userspace.c b/darling/src/libsystem_kernel/emulation/src/linux_premigration/vchroot_userspace.c
--- a/darling/src/libsystem_kernel/emulation/src/linux_premigration/vchroot_userspace.c
+++ b/darling/src/libsystem_kernel/emulation/src/linux_premigration/vchroot_userspace.c
@@ -421,12 +421,14 @@
ctxt->follow = true;
ctxt->symlink_depth++;
- // resolve absolute symlinks within the prefix
+ // Keep absolute host symlinks outside the prefix after crossing
+ // /Volumes/SystemRoot (e.g. /etc/resolv.conf -> /run/...).
+ // Resolve guest symlinks within the prefix
//
// however, DON'T resolve `/proc` symlinks within the prefix.
// anything under `/proc` is from Linux's `/proc` since we bind-mount it, so we can special-case them because we want those to be Linux-root relative.
// see https://github.com/darlinghq/darling/issues/1052 for the issue this works around.
- if (link[0] == '/' && !(strncmp(link, "/proc/", 6) == 0 || (rv == 5 && strncmp(link, "/proc", 5) == 0))) {
+ if (link[0] == '/' && ctxt->current_root_len > 0 && !(strncmp(link, "/proc/", 6) == 0 || (rv == 5 && strncmp(link, "/proc", 5) == 0))) {
ctxt->current_root = prefix_path;
ctxt->current_root_len = prefix_path_len;
}
+1 -1
View File
@@ -4,4 +4,4 @@ set -eu
repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$repo"
python3 scripts/configure.py >/dev/null
python3 build/runtime/tools/riot-desktop-entry.py
python3 build/runtime/tools/desktop-entry.py
+9
View File
@@ -16,6 +16,9 @@ if [ -d build/venv/bin ]; then PATH="$repo/build/venv/bin:$PATH"; export PATH; f
# A server killed by the base install leaves a stale .init.pid that makes darling shell fail.
python3 build/runtime/tools/ensure-darling-runtime.py
# Initialize the normal user-owned prefix before installing overrides.
# Darling treats a missing prefix directory as new; only then is an identity generated below.
new_prefix=
[ -e "$prefix_dir" ] || new_prefix=1
darling shell /usr/bin/true
# The prefix has no /private/tmp of its own, so the guest falls through to the root-owned
# installed layer and every write there (the Riot installer's temp files) fails with EACCES.
@@ -26,6 +29,12 @@ chmod 1777 "$prefix/private/tmp"
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
# Give a new prefix its own synthetic serial, MLB, UUID and ROM instead of the host-derived defaults.
# Existing prefixes keep their identity; update.sh only replaces the old shared serial and UUID.
if [ -n "$new_prefix" ]; then
python3 tools/configure-darling-platform.py --prefix "$prefix_dir" --provision --write || \
echo 'Platform identity not provisioned; run tools/configure-darling-platform.py --provision --write' >&2
fi
sh build/runtime/tools/install-riot-presentation-runtime.sh
sh build/runtime/tools/install-riot-gpu-runtime.sh
# darling-lol perf: build and install optimized libc/libc++/libplatform.
+31 -14
View File
@@ -3,6 +3,30 @@
import os
from pathlib import Path
# Crash handlers do not exit after their clients under Darling, so a mapping by
# one of them alone does not mean that a client still uses the prefix.
CRASH_HANDLERS = {'RiotClientCrashHandler', 'LeagueCrashHandler', 'chrome_crashpad_handler'}
def crash_handler(process):
executable = (process / 'cmdline').read_bytes().split(b'\0', 1)[0]
return os.path.basename(executable.decode(errors='replace')) in CRASH_HANDLERS
def server_running(prefix):
"""Whether the prefix marker names a live (not zombie) process."""
try:
pid = int((prefix / '.init.pid').read_text().strip())
except FileNotFoundError:
return False
if pid <= 0:
return False
try:
status = (Path('/proc') / str(pid) / 'status').read_text()
except (FileNotFoundError, ProcessLookupError):
return False
return not any(line.startswith('State:') and line.split()[1] == 'Z' for line in status.splitlines())
def client_active(prefix, *, processes=None):
"""Check exact-prefix mappings; only vanished processes may be skipped."""
@@ -22,7 +46,7 @@ def client_active(prefix, *, processes=None):
if processes is None and (process / 'uid_map').read_text() != namespace_map:
continue
mappings = (process / 'maps').read_text()
if any(path in mappings for path in paths):
if any(path in mappings for path in paths) and not crash_handler(process):
return True
except (FileNotFoundError, ProcessLookupError):
# Process exited between enumeration and inspection.
@@ -35,20 +59,13 @@ def client_active(prefix, *, processes=None):
def main():
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):
running = server_running(prefix)
except 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, ProcessLookupError):
pass
except OSError as error:
raise SystemExit(f'Cannot validate prefix server marker: {error}') from error
except OSError as error:
raise SystemExit(f'Cannot validate prefix server marker: {error}') from error
if running:
raise SystemExit('Prefix has a live server marker; exit Riot/League and run darling-lol --stop before updating. No process was stopped.')
try:
active = client_active(prefix)
except (OSError, RuntimeError) as error:
+29 -5
View File
@@ -28,9 +28,14 @@ def main():
idle = importlib.util.module_from_spec(spec)
spec.loader.exec_module(idle)
prefix = Path(os.environ['DPREFIX']).resolve()
# darling-lol --stop creates this file; the launcher removes it.
stop_request = Path(os.environ['DARLING_WORKSPACE']) / '.stop-request'
child = None
interrupted = 0
def stopping():
return interrupted or stop_request.exists()
def interrupted_by(signum, _frame):
nonlocal interrupted
interrupted = 128 + signum
@@ -46,16 +51,18 @@ def main():
result = 1
try:
child = subprocess.Popen(sys.argv[1:])
result = child.wait()
if result < 0:
result = 128 - result
# Poll rather than wait: guest processes can outlive the command, and a
# signal or stop request must still reach the shutdown below.
while child.poll() is None and not stopping():
time.sleep(0.5)
except OSError as error:
warn(f'Cannot start client: {error}')
finally:
# A launcher can return while guest clients remain. Do not tear down
# their server, or discard the only namespace capable of shutting it down.
# their server, or discard the only namespace capable of shutting it down,
# unless the user asked to stop.
last_error = None
while True:
while not stopping():
try:
if not idle.client_active(prefix):
break
@@ -66,6 +73,8 @@ def main():
'Exit clients to allow safe shutdown.')
last_error = str(error)
time.sleep(1)
if stopping():
warn('Stopping Darling.')
while True:
try:
shutdown = subprocess.run(['bash', str(scripts / 'darling-rootless.sh'), 'shutdown']).returncode
@@ -74,10 +83,25 @@ def main():
shutdown = 1
if not shutdown:
break
try:
# Nothing is left to shut down, for example after the server crashed.
if not idle.server_running(prefix):
break
except (OSError, ValueError):
pass
warn(f'Darling namespace-local shutdown failed (status {shutdown}); '
'retaining the original namespace and retrying in 5 seconds. '
'Keep this terminal open.')
time.sleep(5)
if child is not None:
# Without its server the command exits; it is our own child to kill.
try:
result = child.wait(timeout=10)
except subprocess.TimeoutExpired:
child.kill()
result = child.wait()
if result < 0:
result = 128 - result
result = interrupted or result
return result
+139
View File
@@ -0,0 +1,139 @@
#!/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
import tempfile
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()
XATTR = 'user.darling-lol-prefix-check'
def user_xattrs(path):
try:
return {name: os.getxattr(path, name, follow_symlinks=False)
for name in os.listxattr(path, follow_symlinks=False) if name.startswith('user.')}
except OSError as error:
if error.errno in (errno.ENOTSUP, errno.ENOENT):
return {}
raise
def check_xattrs(folder):
"""Darling keeps state in user.* xattrs; cp -a ignores a target that drops them."""
probe = folder / '.xattr-check'
probe.touch()
try:
os.setxattr(probe, XATTR, b'1')
if os.getxattr(probe, XATTR) != b'1':
raise OSError(errno.ENOTSUP, 'xattr changed')
except OSError as error:
raise SystemExit(f'{folder.parent} does not support user extended attributes ({error.strerror}), '
'which the prefix needs. Choose a drive with a Linux filesystem '
'(ext4, btrfs, xfs); nothing was moved.')
finally:
probe.unlink()
def verify_copy(old, new):
"""The copy must match the original: names, types and user.* xattrs."""
for folder, folders, files in os.walk(old):
target = new / Path(folder).relative_to(old)
for name in [*folders, *files]:
source = Path(folder) / name
copy = target / name
if not copy.is_symlink() and not copy.exists() or user_xattrs(source) != user_xattrs(copy):
raise SystemExit(f'The copy of {source} differs from the original; the old prefix was kept.')
if user_xattrs(folder) != user_xattrs(target):
raise SystemExit(f'The copy of {folder} differs from the original; the old prefix was kept.')
def move(old, new):
"""Put the prefix at new. Returns old if it still has to be removed, else None."""
new.parent.mkdir(parents=True, exist_ok=True)
if new.is_dir():
new.rmdir()
try:
os.rename(old, new)
return None
except OSError as error:
if error.errno != errno.EXDEV:
raise
# A directory made for this move: never touch anything that was already there.
staging = Path(tempfile.mkdtemp(prefix=new.name + '.partial-', dir=new.parent))
try:
check_xattrs(staging)
print(f'Copying {old} to {new}; this can take several minutes.', flush=True)
subprocess.run(['cp', '-a', '--', str(old), str(staging / 'prefix')], check=True)
verify_copy(old, staging / 'prefix')
os.rename(staging / 'prefix', new)
finally:
shutil.rmtree(staging, ignore_errors=True)
return old
def remove(path):
try:
shutil.rmtree(path)
except OSError as error:
print(f'Could not remove {path} ({error}); delete it by hand to free the space.', file=sys.stderr)
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.')
# Darling's overlay work directory; it creates a new one next to the prefix.
workdir = old.with_name(old.name + '.workdir')
leftovers = []
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 new.is_relative_to(workdir) or workdir.is_relative_to(new):
raise SystemExit(f'{new} must not be inside {workdir}, which is removed after the move.')
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):
print(f'Moving the prefix {old} to {new}.', flush=True)
if move(old, new):
leftovers.append(old)
leftovers.append(workdir)
# Save the new path before deleting anything, so a failed delete leaves a working install.
subprocess.run([sys.executable, str(ROOT / 'scripts/configure.py'), '--prefix', str(new)],
cwd=ROOT, stdout=subprocess.DEVNULL, check=True)
for path in leftovers:
remove(path)
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())
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""darling-lol --stop: end the workspace's session and its Darling server.
The session shuts Darling down in its own namespace when it finds the stop
request. Only if it does not finish in time, or if no session holds the lock
but the prefix server is still alive, is that server killed from here. The
marker PID alone is not trusted: the process must be a darlingserver of this
user for this prefix.
"""
import fcntl
import os
from pathlib import Path
import signal
import sys
import time
WAIT_SECONDS = 30
def session_running(lock):
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return True
fcntl.flock(lock, fcntl.LOCK_UN)
return False
def wait_for_session(lock):
deadline = time.monotonic() + WAIT_SECONDS
while session_running(lock):
if time.monotonic() > deadline:
return False
time.sleep(0.5)
return True
def prefix_server(prefix):
"""PID of this prefix's darlingserver, or 0 if none is running."""
try:
pid = int((prefix / '.init.pid').read_text().strip())
except (FileNotFoundError, ValueError):
return 0
process = Path('/proc') / str(pid)
try:
argv = (process / 'cmdline').read_bytes().split(b'\0')
owner = process.stat().st_uid
except (FileNotFoundError, ProcessLookupError):
return 0
if (pid <= 0 or owner != os.getuid() or len(argv) < 2
or os.path.basename(argv[0]) != b'darlingserver'
or Path(os.fsdecode(argv[1])).resolve() != prefix):
return 0
return pid
def kill_server(pid):
"""Same as darling shutdown: kill launchd, which ends its PID namespace, then the server."""
children = []
for task in (Path('/proc') / str(pid) / 'task').glob('*/children'):
try:
children += [int(child) for child in task.read_text().split()]
except (FileNotFoundError, ProcessLookupError):
pass
for target in [*children, pid]:
try:
os.kill(target, signal.SIGKILL)
except ProcessLookupError:
pass
def main():
workspace, prefix = Path(sys.argv[1]), Path(sys.argv[2]).resolve()
request = workspace / '.stop-request'
workspace.mkdir(parents=True, exist_ok=True)
with open(workspace / '.launcher.lock', 'a+') as lock:
if session_running(lock):
print('Asking the darling-lol session to shut Darling down...', flush=True)
request.touch()
if wait_for_session(lock):
request.unlink(missing_ok=True)
print('Stopped.')
return 0
print(f'The session did not stop within {WAIT_SECONDS} seconds.', flush=True)
server = prefix_server(prefix)
if server:
print(f'Killing the Darling server of {prefix} (PID {server}).', flush=True)
kill_server(server)
elif not session_running(lock):
print('No darling-lol session is running.')
return 0
if wait_for_session(lock):
request.unlink(missing_ok=True)
print('Stopped.')
return 0
# Do not leave a request that would stop the next session at once.
request.unlink(missing_ok=True)
print('Darling is stopped, but the session that started it is still running. '
'Close its terminal.', file=sys.stderr)
return 1
if __name__ == '__main__':
raise SystemExit(main())
+9
View File
@@ -7,6 +7,13 @@
set -eu
repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$repo"
# Older setups shipped one serial and UUID shared by every install. Give a prefix that still
# has them its own identity; any other saved values, and prefixes without them, are left alone.
replace_shared_identity() {
python3 build/runtime/tools/stop-darling-runtime.py
python3 tools/configure-darling-platform.py --replace-known-defaults --write || \
echo 'Could not replace the shared platform identity; run tools/configure-darling-platform.py --provision --write' >&2
}
if [ -n "${DARLING_WORKSPACE:-}" ]; then
python3 scripts/nix-idle.py
# Initial setup and upgrades use the same supported user-owned install path.
@@ -21,6 +28,7 @@ if [ -n "${DARLING_WORKSPACE:-}" ]; then
cmake --install build/darling-src/build
python3 scripts/nix-state.py stamp-base
./scripts/install-runtime.sh
replace_shared_identity
echo 'Rootless update installed. In this shell use scripts/run.sh; otherwise exit this shell and start darling-lol.'
exit 0
fi
@@ -45,4 +53,5 @@ PY
python3 build/runtime/tools/stop-darling-runtime.py
sudo cmake --install build/darling-src/build
./scripts/install-runtime.sh
replace_shared_identity
echo 'Update installed. Start Darling with: sh scripts/run.sh'
+175
View File
@@ -0,0 +1,175 @@
// CPU-only buffer/Vulkan shims; the scratch, pool and table helper are extracted
// from the authoritative component patch, not copied into this fixture.
#include <algorithm>
#include <array>
#include <cassert>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <new>
#include <vector>
static size_t allocationCalls = 0;
void* operator new(size_t size) {
++allocationCalls;
if (auto* p = std::malloc(size ? size : 1)) return p;
throw std::bad_alloc();
}
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, size_t) noexcept { std::free(p); }
using VkBuffer = uint64_t;
using VkDeviceSize = uint64_t;
constexpr VkBuffer VK_NULL_HANDLE = 0;
namespace Iridium { enum class BindingType { Buffer, Texture, Sampler }; }
static bool enabled = true;
bool bindingReuseEnabled() { return enabled; }
void logUnboundResource(const char*, size_t, size_t, size_t) {}
struct Buffer { virtual ~Buffer() = default; virtual void* contents() = 0; };
struct PrivateBuffer : Buffer {
uint64_t id, address;
size_t reads = 0;
std::vector<unsigned char> bytes;
PrivateBuffer(uint64_t id, uint64_t address) : id(id), address(address), bytes(256*1024, 0xa5) {}
void* contents() override { ++reads; return bytes.data(); }
VkBuffer buffer() const { return id; }
uint64_t gpuAddress() const { return address; }
};
struct PrivateDevice {
size_t chunks = 0;
uint64_t fallback = 0xf00d;
uint64_t fallbackBufferAddress() { return fallback; }
std::shared_ptr<Buffer> acquireScratchChunk() { ++chunks; return std::make_shared<PrivateBuffer>(chunks, chunks << 20); }
};
struct FunctionResources { std::vector<std::pair<std::shared_ptr<Buffer>, size_t>> buffers; };
struct Resolved { PrivateBuffer* value = nullptr; };
struct ResolvedFunctionResources { std::vector<Resolved> buffers; };
struct Binding { Iridium::BindingType type; size_t index; };
struct FunctionInfo { std::vector<Binding> bindings; };
/* ACTUAL_PATCHED_HELPER */
struct Check {
AddressPool pool;
std::shared_ptr<PrivateDevice> device = std::make_shared<PrivateDevice>();
std::vector<std::shared_ptr<Buffer>> keep;
std::array<BufferAddressTable, 4> tables{};
Check() { keep.reserve(8); }
PrivateBuffer* mapped() { return static_cast<PrivateBuffer*>(pool.chunk.get()); }
void run(size_t stage, const FunctionResources& resources, const FunctionInfo& info,
bool reuse, const ResolvedFunctionResources* resolved = nullptr, bool previous = true) {
auto old = tables[stage];
auto used = pool.used, chunks = device->chunks, calls = allocationCalls;
auto table = makeBufferAddressTable(resources, info, resolved, device, keep, pool,
previous ? &tables[stage] : nullptr);
// Only the existing DescriptorScratch overflow and new scratch chunk
// may allocate (make_shared control/object plus the chunk byte vector).
assert(allocationCalls - calls == (info.bindings.size() > 64 ? 1u : 0u) +
(device->chunks - chunks) * 2);
assert((table.buffer == old.buffer && table.offset == old.offset && pool.used == used) == reuse);
assert(table.range == ((std::count_if(info.bindings.begin(), info.bindings.end(),
[](const Binding& b) { return b.type == Iridium::BindingType::Buffer; }) * 8 + 15) & ~size_t(15)));
assert(keep.size() == device->chunks);
size_t index = 0;
for (const auto& binding : info.bindings) {
if (binding.type != Iridium::BindingType::Buffer) continue;
PrivateBuffer* buffer = nullptr;
if (resolved && binding.index < resolved->buffers.size()) buffer = resolved->buffers[binding.index].value;
else if (binding.index < resources.buffers.size()) buffer = dynamic_cast<PrivateBuffer*>(resources.buffers[binding.index].first.get());
uint64_t expected = buffer ? buffer->gpuAddress() + resources.buffers[binding.index].second : device->fallback;
uint64_t actual;
memcpy(&actual, mapped()->bytes.data() + table.offset + index++ * 8, 8);
assert(actual == expected);
}
tables[stage] = table;
}
};
FunctionInfo info(size_t count, size_t start = 0) {
FunctionInfo result;
for (size_t i = 0; i < count; ++i) result.bindings.push_back({Iridium::BindingType::Buffer, start+i});
return result;
}
int main() {
// Two 64-key entries: +1080 bytes per pool on the tested 64-bit ABI.
static_assert(sizeof(AddressPool) == sizeof(std::shared_ptr<Buffer>) + sizeof(size_t) + 1080);
FunctionResources r;
auto resource = std::make_shared<PrivateBuffer>(100, 0x1000);
for (size_t i = 0; i < 150; ++i) r.buffers.push_back({resource, i*16});
Check p;
auto f = info(3);
p.run(0, r, f, false);
auto accesses = p.mapped()->reads;
p.run(0, r, f, true); assert(p.mapped()->reads == accesses);
r.buffers[100].second++; p.run(0, r, f, true);
auto equivalent = std::make_shared<PrivateBuffer>(101, 0x0ff0);
r.buffers[0] = {equivalent, 16}; p.run(0, r, f, true);
r.buffers[1].second++; p.run(0, r, f, false);
auto mixed = f;
mixed.bindings.push_back({Iridium::BindingType::Texture, 999});
mixed.bindings.push_back({Iridium::BindingType::Sampler, 999});
p.run(0, r, mixed, true);
p.run(1, r, info(4, 20), false);
accesses = p.mapped()->reads;
p.run(0, r, f, true); p.run(1, r, info(4, 20), true);
assert(p.mapped()->reads == accesses); // both interleaved stages hit CPU keys
for (size_t i = 0; i < 5; ++i) {
r.buffers[20].second++; p.run(1, r, info(4, 20), false);
}
accesses = p.mapped()->reads;
p.run(0, r, f, true);
assert(p.mapped()->reads == accesses + 1); // eviction retains mapped fallback
Check padding;
padding.run(0, r, info(1), false);
r.buffers[1] = {resource, uint64_t(0xa5a5a5a5a5a5a5a5ULL - resource->gpuAddress())};
accesses = padding.mapped()->reads;
padding.run(0, r, info(2), true); // odd -> even reads real padding
assert(padding.mapped()->reads == accesses + 1);
r.buffers[1].second++; padding.run(0, r, info(2), false);
accesses = padding.mapped()->reads;
padding.run(0, r, info(1), true); // even -> odd needs only cached prefix
assert(padding.mapped()->reads == accesses);
padding.run(0, r, info(3), false);
Check large;
for (size_t count : {size_t(64), size_t(65), size_t(150), size_t(32767), size_t(32768)}) {
large.run(0, r, info(count), false);
large.run(0, r, info(count), true);
}
r.buffers[64].second++; large.run(0, r, info(65), false);
large.run(1, r, info(8), false, nullptr, false);
large.run(1, r, info(8), true);
Check missing;
FunctionResources empty;
missing.run(0, empty, info(2, 999), false);
missing.run(0, empty, info(2, 999), true);
missing.device->fallback++; missing.run(0, empty, info(2, 999), false);
ResolvedFunctionResources resolved;
resolved.buffers.resize(r.buffers.size()); resolved.buffers[0].value = resource.get();
missing.run(1, r, info(1), false, &resolved);
missing.run(1, r, info(1), true, &resolved);
resolved.buffers[0].value = nullptr;
missing.run(1, r, info(1), false, &resolved);
Check roll;
roll.run(0, r, info(2), false); roll.run(1, r, info(2, 3), false);
auto oldChunk = roll.pool.chunk;
roll.pool.used = 256*1024;
r.buffers[0].second++; roll.run(0, r, info(2), false);
assert(roll.pool.chunk != oldChunk && roll.keep.size() == 2);
// Old offset 256 must not supply a stale key for untouched new-chunk bytes.
roll.tables[1] = {roll.tables[0].buffer, 256, 16};
roll.run(1, r, info(2, 3), false); roll.run(1, r, info(2, 3), true);
enabled = false; roll.run(1, r, info(2, 3), false); enabled = true;
Check guard;
guard.run(0, r, info(0), false); guard.run(0, r, info(0), true);
auto id = guard.tables[0].buffer;
guard.tables[0] = {id, 256*1024, 0}; guard.run(0, r, info(0), true);
guard.tables[0] = {id, 256*1024+1, 0}; guard.run(0, r, info(0), false);
accesses = guard.mapped()->reads;
guard.tables[0] = {id, 256*1024-8, 16}; guard.run(0, r, info(1), false);
assert(guard.mapped()->reads == accesses + 1); // write only; no invalid read
guard.tables[0].buffer += 100; guard.run(0, r, info(1), false);
guard.tables[0].range = 32; guard.run(0, r, info(1), false);
}
+110
View File
@@ -0,0 +1,110 @@
"""Exercise orphan-helper cleanup with fake /proc entries and mocked signals."""
import importlib.util
import io
import os
from pathlib import Path
import signal
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location(
'clean_stale_helpers', ROOT / 'tools/clean-stale-helpers.py')
clean_stale_helpers = importlib.util.module_from_spec(spec)
spec.loader.exec_module(clean_stale_helpers)
HELPER = '/repo/tools/close-league-client-in-game.py'
HIDE_HELPER = '/repo/tools/hide-league-client-in-game.py'
class CleanStaleHelpersTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.proc = self.root / 'proc'
self.proc.mkdir()
self.prefix = self.root / '.darling'
self.other = self.root / 'other-prefix'
def parent(self, pid, comm):
folder = self.proc / str(pid)
folder.mkdir(exist_ok=True)
(folder / 'comm').write_text(comm + '\n')
def process(self, pid, script=HELPER, parent_pid=500, parent_comm='systemd',
prefix='own', argv0='/usr/bin/python3', state='S',
environment=True, arguments=('--detach',)):
folder = self.proc / str(pid)
folder.mkdir()
argv = [argv0, script, *arguments]
(folder / 'cmdline').write_bytes(
b'\0'.join(arg.encode() for arg in argv) + b'\0')
if parent_comm is not None:
self.parent(parent_pid, parent_comm)
fields = [state, str(parent_pid)] + ['0'] * 17 + ['100']
(folder / 'stat').write_text(f'{pid} (python3) ' + ' '.join(fields) + '\n')
if environment:
prefix = self.prefix if prefix == 'own' else prefix
(folder / 'environ').write_bytes(
b'DPREFIX=' + os.fsencode(prefix) + b'\0')
else:
(folder / 'environ').write_bytes(b'UNRELATED=1\0')
return folder
def run_clean(self, dry_run=False):
killed = []
def kill(pid, signum):
killed.append((pid, signum))
out = io.StringIO()
result = clean_stale_helpers.clean(
self.prefix, proc_root=self.proc, kill=kill,
sleep=lambda _seconds: None, out=out, dry_run=dry_run)
return result, killed, out.getvalue()
def test_removes_only_orphaned_helpers_of_this_prefix(self):
self.process(100, HELPER)
self.process(101, HIDE_HELPER)
self.process(200, HELPER, parent_pid=600, parent_comm='darling')
self.process(201, HELPER, prefix=self.other)
self.process(202, HELPER, argv0='/usr/bin/vim')
self.process(203, HELPER, state='Z')
self.process(204, HELPER, environment=False)
self.process(205, HELPER, parent_pid=700, parent_comm=None)
result, killed, output = self.run_clean()
self.assertEqual(result, 0)
self.assertEqual(sorted(killed), sorted([
(100, signal.SIGTERM), (100, signal.SIGKILL),
(101, signal.SIGTERM), (101, signal.SIGKILL),
]))
self.assertIn('removing 2 orphaned helper(s): 100,101', output)
def test_dry_run_reports_without_signalling(self):
self.process(100)
result, killed, output = self.run_clean(dry_run=True)
self.assertEqual((result, killed), (0, []))
self.assertIn('would remove 1 orphaned helper(s): 100', output)
def test_no_orphans_is_a_noop(self):
self.process(200, parent_pid=600, parent_comm='darling')
result, killed, output = self.run_clean()
self.assertEqual((result, killed), (0, []))
self.assertIn('no orphaned helpers', output)
def test_relative_helper_prefix_is_preserved(self):
self.process(100, prefix='relative-prefix')
result, killed, output = self.run_clean()
self.assertEqual((result, killed), (0, []))
self.assertIn('no orphaned helpers', output)
def test_launcher_runs_cleanup_before_starting_a_helper(self):
source = (ROOT / 'tools/run-riot-client.sh').read_text()
self.assertIn('RCLOL_CLEAN_STALE_HELPERS', source)
self.assertLess(source.index('clean-stale-helpers.py'),
source.index('close-league-client-in-game.py'))
if __name__ == '__main__':
unittest.main()
+99
View File
@@ -0,0 +1,99 @@
"""Select the automatic client-in-game mode from LCULocalPreferences.yaml."""
import importlib.util
import io
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location(
'client_in_game_tricks', ROOT / 'tools/client-in-game-tricks.py')
tricks = importlib.util.module_from_spec(spec)
spec.loader.exec_module(tricks)
class ClientInGameTricksTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.path = Path(self.temp.name) / 'LCULocalPreferences.yaml'
def write(self, optimization=None, close=None):
lines = ['lol-user-experience:', ' data:']
if optimization is not None:
lines.append(f' potatoModeEnabled: {optimization}')
if close is not None:
lines.append(f' closeLeagueClientDuringGame: {close}')
self.path.write_text('\n'.join(lines) + '\n')
def test_close_when_both_preferences_are_true(self):
self.write(True, True)
self.assertEqual(tricks.mode(self.path), 'close')
def test_hide_when_optimizations_are_on_and_close_is_false(self):
self.write(True, False)
self.assertEqual(tricks.mode(self.path), 'hide')
def test_none_when_close_preference_is_missing_or_invalid(self):
self.write(True)
self.assertEqual(tricks.mode(self.path), 'none')
for value in ('maybe', '"true', 'true#note', '"true"junk'):
self.write(True, value)
self.assertEqual(tricks.mode(self.path), 'none', value)
def test_none_when_optimizations_are_off(self):
self.write(False, True)
self.assertEqual(tricks.mode(self.path), 'none')
def test_none_when_optimizations_are_missing_or_file_is_unreadable(self):
self.write(None, True)
self.assertEqual(tricks.mode(self.path), 'none')
self.assertEqual(tricks.mode(self.path.with_name('missing.yaml')), 'none')
def test_last_duplicate_value_wins_and_quotes_are_accepted(self):
self.path.write_text(
'lol-user-experience:\n'
' data:\n'
' potatoModeEnabled: false\n'
' closeLeagueClientDuringGame: false\n'
' potatoModeEnabled: "true"\n'
' closeLeagueClientDuringGame: "true"\n')
self.assertEqual(tricks.mode(self.path), 'close')
def test_keys_outside_the_named_section_are_ignored(self):
self.path.write_text(
'lol-user-experience:\n'
' data:\n'
' potatoModeEnabled: true\n'
' closeLeagueClientDuringGame: false\n'
'other-section:\n'
' potatoModeEnabled: true\n'
' closeLeagueClientDuringGame: true\n')
self.assertEqual(tricks.mode(self.path), 'hide')
def test_inline_yaml_comments_are_ignored(self):
self.path.write_text(
'lol-user-experience:\n'
' data:\n'
' potatoModeEnabled: true # client optimizations\n'
' closeLeagueClientDuringGame: "true" # close during matches\n')
self.assertEqual(tricks.mode(self.path), 'close')
def test_second_read_failure_fails_closed(self):
self.write(True, False)
text = self.path.read_text()
with patch.object(Path, 'read_text', side_effect=[text, OSError()]):
self.assertEqual(tricks.mode(self.path), 'none')
def test_main_prints_mode(self):
self.write(True, False)
output = io.StringIO()
with patch('sys.stdout', output):
self.assertEqual(tricks.main([str(self.path)]), 0)
self.assertEqual(output.getvalue().strip(), 'hide')
if __name__ == '__main__':
unittest.main()
+232
View File
@@ -0,0 +1,232 @@
"""Exercise the in-match client close with fake /proc entries and mocked signals only."""
import importlib.util
import io
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('in_game', ROOT / 'tools/close-league-client-in-game.py')
in_game = importlib.util.module_from_spec(spec)
spec.loader.exec_module(in_game)
MAIN = '/Applications/League of Legends.app/Contents/LoL/LeagueClientUx.app/Contents/MacOS/LeagueClientUx'
HELPER = ('/Applications/League of Legends.app/Contents/LoL/LeagueClientUx Helper.app'
'/Contents/MacOS/LeagueClientUx Helper')
GAME = '/Applications/LeagueofLegends.app/Contents/MacOS/LeagueofLegends'
BACKEND = '/Applications/League of Legends.app/Contents/LoL/LeagueClient.app/Contents/MacOS/LeagueClient'
RIOT = '/Applications/Riot Client.app/Contents/MacOS/RiotClientServices'
CRASH_HANDLER = ('/Applications/League of Legends.app/Contents/LoL/LeagueClient.app'
'/Contents/MacOS/LeagueCrashHandler')
# A live crash handler carries the client UI name in its arguments only.
CRASH_ARGUMENTS = ('--attachment=/Applications/League of Legends.app/Contents/LoL/Logs/'
'LeagueClientUx Logs/2026-10-18T09-49-13_659_LeagueClientUx.log',
'--handshake-fd=52')
class InGameCloseTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.proc = self.root / 'proc'
self.proc.mkdir()
(self.proc / 'uptime').write_text('1000 0\n')
self.home = self.root / 'home'
self.home_patch = patch.object(Path, 'home', return_value=self.home)
self.home_patch.start()
self.addCleanup(self.home_patch.stop)
self.prefix = self.home / '.darling'
self.env_patch = patch.dict(os.environ, {'DPREFIX': str(self.prefix)})
self.env_patch.start()
self.addCleanup(self.env_patch.stop)
def environment(self, prefix):
if prefix is None:
return b''
return b'%s=%s\0' % (in_game.SOCK_VARIABLE, os.fsencode(prefix / in_game.SOCK_NAME))
def process(self, pid, executable, prefix='own', age=120, environ=None, args=('--flag',)):
folder = self.proc / str(pid)
folder.mkdir()
(folder / 'cmdline').write_bytes(executable.encode() + b'\0'
+ b''.join(arg.encode() + b'\0' for arg in args))
if environ is None:
environ = self.environment(self.prefix if prefix == 'own' else prefix)
(folder / 'environ').write_bytes(environ)
fields = ['S'] + ['0'] * 18 + [str(int((1000 - age) * os.sysconf('SC_CLK_TCK')))]
(folder / 'stat').write_text(str(pid) + ' (LeagueClientUx (helper)) ' + ' '.join(fields))
return folder
def client_ui(self, pid, prefix='own', age=120, environ=None):
return self.process(pid, MAIN if pid % 2 else HELPER, prefix=prefix, age=age, environ=environ)
def run_once(self, dry_run=False):
self.output = io.StringIO()
with patch.object(in_game.time, 'sleep'), patch.object(in_game.os, 'kill') as kill:
result = in_game.supervise(self.proc, once=True, dry_run=dry_run, out=self.output)
return result, [call.args for call in kill.call_args_list]
def test_client_ui_without_a_match_is_preserved(self):
self.client_ui(123)
result, calls = self.run_once()
self.assertEqual((result, calls), (0, []))
self.assertIn('match=absent client_ui=1', self.output.getvalue())
def test_running_match_closes_only_the_client_ui(self):
self.client_ui(123)
self.process(124, HELPER)
self.process(125, GAME)
self.process(126, BACKEND)
self.process(127, RIOT)
self.process(128, MAIN, prefix=self.root / 'other-prefix')
result, calls = self.run_once()
self.assertEqual(result, 0)
self.assertEqual(sorted(calls), sorted([(123, in_game.signal.SIGTERM),
(124, in_game.signal.SIGTERM),
(123, in_game.signal.SIGKILL),
(124, in_game.signal.SIGKILL)]))
self.assertIn('closed the client UI for the running match (2 processes: 123,124)',
self.output.getvalue())
def test_match_soon_after_boot_still_closes_the_client_ui(self):
# time.monotonic() counts from boot; a fresh machine (or CI runner) is
# still inside the first cooldown window.
self.client_ui(123)
self.process(125, GAME)
with patch.object(in_game.time, 'monotonic', return_value=5.0):
result, calls = self.run_once()
self.assertEqual(result, 0)
self.assertEqual(sorted(calls), sorted([(123, in_game.signal.SIGTERM),
(123, in_game.signal.SIGKILL)]))
def test_marker_in_arguments_does_not_make_a_process_client_ui(self):
self.client_ui(123)
self.process(124, CRASH_HANDLER, args=CRASH_ARGUMENTS)
self.process(125, GAME)
result, calls = self.run_once()
self.assertEqual(result, 0)
self.assertEqual(sorted(calls), sorted([(123, in_game.signal.SIGTERM),
(123, in_game.signal.SIGKILL)]))
self.assertIn('closed the client UI for the running match (1 processes: 123)',
self.output.getvalue())
def test_game_argument_naming_the_client_ui_is_still_the_game(self):
self.process(125, GAME, args=('--parent=LeagueClientUx',))
self.process(126, CRASH_HANDLER, args=CRASH_ARGUMENTS)
result, calls = self.run_once()
self.assertEqual((result, calls), (0, []))
self.assertIn('match=running client_ui=0', self.output.getvalue())
def test_ui_identity_rejects_a_related_process_with_the_marker(self):
folder = self.client_ui(123)
self.assertIsNotNone(in_game.ui_identity(self.proc, '123'))
(folder / 'cmdline').write_bytes(CRASH_HANDLER.encode() + b'\0'
+ b'\0'.join(a.encode() for a in CRASH_ARGUMENTS) + b'\0')
self.assertIsNone(in_game.ui_identity(self.proc, '123'))
with patch.object(in_game.time, 'sleep'), patch.object(in_game.os, 'kill') as kill:
in_game.close_ui(self.proc, {'123': 1})
self.assertEqual(kill.call_args_list, [])
def test_match_in_another_install_is_preserved(self):
self.client_ui(123)
self.process(125, GAME, prefix=self.root / 'other-prefix')
self.assertEqual(self.run_once()[1], [])
def test_game_without_an_install_socket_is_preserved(self):
self.client_ui(123)
self.process(125, GAME, environ=b'')
self.assertEqual(self.run_once()[1], [])
def test_young_client_ui_is_preserved(self):
self.client_ui(123, age=in_game.MIN_AGE_SECONDS - 1)
self.process(125, GAME)
self.assertEqual(self.run_once()[1], [])
def test_client_ui_of_another_install_is_preserved(self):
self.client_ui(123, prefix=self.root / 'other-prefix')
self.process(125, GAME)
self.assertEqual(self.run_once()[1], [])
def test_client_ui_without_an_install_socket_is_preserved(self):
self.client_ui(123, environ=b'')
self.process(125, GAME)
self.assertEqual(self.run_once()[1], [])
def test_missing_prefix_prevents_any_action(self):
self.client_ui(123)
self.process(125, GAME)
with patch.dict(os.environ, {'DPREFIX': 'relative/path'}):
self.assertIsNone(in_game.target_prefix())
self.assertEqual(self.run_once()[1], [])
def test_foreign_owned_process_is_ignored(self):
self.client_ui(123)
self.process(125, GAME)
with patch.object(in_game.os, 'getuid', return_value=os.getuid() + 1):
result, calls = self.run_once()
self.assertEqual((result, calls), (0, []))
def test_unreadable_environment_prevents_action(self):
self.client_ui(123)
self.process(125, GAME)
original = Path.read_bytes
def read(path):
if path.name == 'environ':
raise PermissionError()
return original(path)
with patch.object(Path, 'read_bytes', read):
self.assertEqual(self.run_once()[1], [])
def test_malformed_or_missing_age_prevents_action(self):
proc = self.client_ui(123)
self.process(125, GAME)
for value in ('garbage', '123 (worker) S'):
(proc / 'stat').write_text(value)
self.assertEqual(self.run_once()[1], [])
(proc / 'stat').unlink()
self.assertEqual(self.run_once()[1], [])
def test_dry_run_reports_without_signalling(self):
self.client_ui(123)
self.process(125, GAME)
self.assertEqual(self.run_once(dry_run=True)[1], [])
self.assertIn('would close the client UI (1 processes: 123)', self.output.getvalue())
def test_exited_or_reused_processes_are_preserved(self):
folder = self.client_ui(123)
identity = in_game.ui_identity(self.proc, '123')
self.assertIsNotNone(identity)
(folder / 'cmdline').write_bytes(b'/usr/bin/editor\0')
self.assertIsNone(in_game.ui_identity(self.proc, '123'))
with patch.object(in_game.time, 'sleep'), patch.object(in_game.os, 'kill') as kill:
in_game.close_ui(self.proc, {'123': identity, '124': 1})
self.assertEqual(kill.call_args_list, [])
def test_idle_clock_starts_only_after_a_client_or_match(self):
empty = {'game': False, 'ui': {}}
client = {'game': False, 'ui': {'10': 5}}
match = {'game': True, 'ui': {}}
# Before any client (long patch or login): never idle, however long it takes.
self.assertEqual(in_game.update_idle(empty, False, None, 5000.0), (False, None))
# A client or a match marks the session as seen and keeps the clock stopped.
self.assertEqual(in_game.update_idle(client, False, None, 10.0), (True, None))
self.assertEqual(in_game.update_idle(match, True, 50.0, 60.0), (True, None))
# Afterwards the clock starts once and keeps its start time.
self.assertEqual(in_game.update_idle(empty, True, None, 70.0), (True, 70.0))
self.assertEqual(in_game.update_idle(empty, True, 70.0, 900.0), (True, 70.0))
def test_close_decision_covers_limits_and_cooldown(self):
state = {'game': True, 'ui': {'123': 1}}
self.assertEqual(in_game.close_decision(state, 0, None, 100.0), 'close')
self.assertEqual(in_game.close_decision(state, 0, None, 5.0), 'close')
self.assertIsNone(in_game.close_decision(state, 0, 90.0, 100.0))
self.assertEqual(in_game.close_decision(state, in_game.MAX_CLOSES, 0.0, 100.0), 'limit')
self.assertIsNone(in_game.close_decision({'game': False, 'ui': {'123': 1}}, 0, 0.0, 100.0))
self.assertIsNone(in_game.close_decision({'game': True, 'ui': {}}, 0, 0.0, 100.0))
if __name__ == '__main__':
unittest.main()
+157
View File
@@ -0,0 +1,157 @@
"""Verify the desktop-entry helper against a disposable HOME."""
import os
import re
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
HELPER = ROOT / 'tools/desktop-entry.py'
FLAKE = ROOT / 'flake.nix'
OLD_SVG = '<svg><linearGradient id="goldGrad"/><path d="M172 108 L242 108"/></svg>'
IDENTITY_KEYS = ('Name', 'GenericName', 'Comment', 'Icon', 'Keywords', 'Categories', 'StartupWMClass')
def parse_entry(text):
entry = {}
for line in text.splitlines():
if '=' in line and not line.startswith(('#', '[')):
key, value = line.split('=', 1)
entry[key] = value
return entry
class DesktopEntryTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix='desktop entry ', dir=os.environ.get('TMPDIR'))
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.home = self.root / 'home'
self.home.mkdir()
def project(self, name='project'):
project = self.root / name
project.mkdir(parents=True)
shutil.copy2(ROOT / 'darling-lol.svg', project / 'darling-lol.svg')
shutil.copy2(ROOT / 'darling-lol.png', project / 'darling-lol.png')
return project
def install(self, project):
script = project / 'desktop-entry.py'
script.write_text(HELPER.read_text()
.replace('@PROJECT_ROOT@', str(project))
.replace('@HOST_HOME@', str(self.home)))
return subprocess.run([sys.executable, str(script)], capture_output=True, text=True,
env=dict(os.environ, HOME=str(self.home)), check=False)
def entry(self):
return self.home / '.local/share/applications/darling-launcher.desktop'
def icons(self):
base = self.home / '.local/share/icons'
return base / 'hicolor/scalable/apps/darling-launcher.svg', base / 'hicolor/128x128/apps/darling-launcher.png'
def test_installs_neutral_entry_and_icons(self):
project = self.project()
result = self.install(project)
self.assertEqual(result.returncode, 0, result.stderr)
text = self.entry().read_text()
self.assertIn('Name=Darling Launcher\n', text)
self.assertIn('Icon=darling-launcher\n', text)
self.assertIn(f'Path={project}\n', text)
for field in ('Name', 'Comment', 'GenericName', 'Keywords'):
line = next(line for line in text.splitlines() if line.startswith(field + '='))
for brand in ('League', 'LoL', 'Riot', 'Vanguard', 'anti-cheat'):
self.assertNotIn(brand.lower(), line.lower())
svg, png = self.icons()
self.assertTrue(svg.is_file())
self.assertTrue(png.is_file())
self.assertEqual(png.read_bytes(), (project / 'darling-lol.png').read_bytes())
def test_preserves_entries_and_icons_it_did_not_create(self):
project = self.project()
other = self.root / 'elsewhere'
(self.home / '.local/share/applications').mkdir(parents=True)
legacy_entry = self.home / '.local/share/applications/league-of-legends.desktop'
legacy_entry.write_text(f'[Desktop Entry]\nExec={other}/scripts/desktop-run.sh\n')
(self.home / 'Desktop').mkdir()
desktop_entry = self.home / 'Desktop/league-of-legends.desktop'
desktop_entry.write_text(f'[Desktop Entry]\nExec={other}/scripts/desktop-run.sh\n')
icons = self.home / '.local/share/icons'
icons.mkdir(parents=True)
(icons / 'league-of-legends.png').write_text('user icon')
result = self.install(project)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(legacy_entry.is_file())
self.assertTrue(desktop_entry.is_file())
self.assertEqual((icons / 'league-of-legends.png').read_text(), 'user icon')
self.assertTrue(self.entry().is_file())
def test_removes_legacy_artifacts_it_created(self):
project = self.project()
applications = self.home / '.local/share/applications'
applications.mkdir(parents=True)
owned = f'[Desktop Entry]\nExec="{project}/scripts/desktop-run.sh"\n'
legacy_entry = applications / 'league-of-legends.desktop'
legacy_entry.write_text(owned)
(self.home / 'Desktop').mkdir()
desktop_link = self.home / 'Desktop/league-of-legends.desktop'
desktop_link.symlink_to(project / 'scripts/desktop-run.sh')
icons = self.home / '.local/share/icons'
scalable = icons / 'hicolor/scalable/apps'
legacy_png_dir = icons / 'hicolor/512x512/apps'
scalable.mkdir(parents=True)
legacy_png_dir.mkdir(parents=True)
legacy_svg = scalable / 'league-of-legends.svg'
legacy_svg.write_text(OLD_SVG)
legacy_png = legacy_png_dir / 'league-of-legends.png'
legacy_png.write_bytes(b'old client icon')
result = self.install(project)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(legacy_entry.exists())
self.assertFalse(desktop_link.is_symlink())
self.assertFalse(legacy_svg.exists())
self.assertFalse(legacy_png.exists())
self.assertTrue(self.entry().is_file())
def test_quotes_exec_arguments_with_reserved_characters(self):
project = self.project('proj $dollar#hash\'quote')
result = self.install(project)
self.assertEqual(result.returncode, 0, result.stderr)
text = self.entry().read_text()
exec_line = next(line for line in text.splitlines() if line.startswith('Exec='))
self.assertTrue(exec_line.startswith('Exec="'), exec_line)
self.assertTrue(exec_line.endswith('"'), exec_line)
self.assertIn('\\$dollar', exec_line)
if shutil.which('desktop-file-validate'):
check = subprocess.run(['desktop-file-validate', str(self.entry())],
capture_output=True, text=True, check=False)
self.assertEqual(check.returncode, 0, check.stdout + check.stderr)
def test_identity_fields_match_the_packaged_entry(self):
project = self.project()
result = self.install(project)
self.assertEqual(result.returncode, 0, result.stderr)
rendered = parse_entry(self.entry().read_text())
packaged_here = re.search(
r"cat << 'DESKTOP_EOF' > \$out/share/applications/darling-launcher\.desktop\n(.*?)\n\s*DESKTOP_EOF",
FLAKE.read_text(), re.S)
self.assertIsNotNone(packaged_here, 'packaged desktop entry not found in flake.nix')
packaged = parse_entry(packaged_here.group(1))
for key in IDENTITY_KEYS:
self.assertIn(key, packaged)
self.assertEqual(rendered.get(key), packaged[key], key)
def test_missing_icon_fails(self):
project = self.project()
(project / 'darling-lol.png').unlink()
result = self.install(project)
self.assertNotEqual(result.returncode, 0)
self.assertIn('launcher icon is missing', result.stderr)
if __name__ == '__main__':
unittest.main()
+90
View File
@@ -0,0 +1,90 @@
"""exec-guest.py: inherited descriptors and the opt-in host environment filter."""
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import sys
import unittest
ROOT = Path(__file__).resolve().parents[1]
HELPER = ROOT / 'tools/exec-guest.py'
spec = importlib.util.spec_from_file_location('exec_guest', HELPER)
exec_guest = importlib.util.module_from_spec(spec)
spec.loader.exec_module(exec_guest)
# What the guest and the host libraries Darling loads still need.
REQUIRED = {
'DISPLAY': ':0', 'XAUTHORITY': '/run/user/1000/.mutter-Xwaylandauth.A', 'XCURSOR_SIZE': '24',
'XDG_RUNTIME_DIR': '/run/user/1000', 'XDG_DATA_DIRS': '/usr/share', 'XDG_CONFIG_HOME': '/home/u/.config',
'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/run/user/1000/bus', 'PULSE_SERVER': 'unix:/run/pulse',
'PIPEWIRE_LATENCY': '128/48000', 'ALSA_CONFIG_PATH': '/usr/share/alsa/alsa.conf',
'DRI_PRIME': '1', 'MESA_NO_ERROR': '1', 'mesa_glthread': 'false', '__GL_THREADED_OPTIMIZATIONS': '0',
'__NV_PRIME_RENDER_OFFLOAD': '1', '__EGL_VENDOR_LIBRARY_FILENAMES': '/usr/share/glvnd/egl_vendor.d/10_nvidia.json',
'VK_ICD_FILENAMES': '/usr/share/vulkan/icd.d/nvidia_icd.json', 'LIBGL_ALWAYS_SOFTWARE': '0',
'LD_PRELOAD': 'libgamemodeauto.so.0', 'LD_LIBRARY_PATH': '/run/opengl-driver/lib',
'HOME': '/home/u', 'USER': 'u', 'PATH': '/usr/bin:/bin', 'LANG': 'en_US.UTF-8', 'TERM': 'xterm',
'DPREFIX': '/home/u/.darling', 'RCLOL_REFRESH_HZ': '144', 'DYLD_LIBRARY_PATH': '/Users/u/RuntimeLibraries',
}
SESSION = {
'WAYLAND_DISPLAY': 'wayland-0', 'XDG_CURRENT_DESKTOP': 'GNOME', 'XDG_SESSION_TYPE': 'wayland',
'XDG_SESSION_ID': '2', 'XDG_SEAT': 'seat0', 'DESKTOP_SESSION': 'gnome', 'GDMSESSION': 'gnome',
'GDM_LANG': 'en_US.UTF-8', 'SESSION_MANAGER': 'local/host:@/tmp/.ICE-unix/1', 'GNOME_SETUP_DISPLAY': ':1',
'KDE_FULL_SESSION': 'true', 'SSH_AUTH_SOCK': '/run/user/1000/ssh', 'SYSTEMD_EXEC_PID': '1',
'INVOCATION_ID': 'abc', 'JOURNAL_STREAM': '8:1', 'MANAGERPID': '1', 'NOTIFY_SOCKET': '/run/notify',
'LISTEN_FDS': '1', 'MEMORY_PRESSURE_WATCH': '/sys/fs/cgroup', 'QT_QPA_PLATFORM': 'wayland',
'GTK_MODULES': 'canberra', 'GDK_BACKEND': 'wayland', 'GIO_LAUNCHED_DESKTOP_FILE': '/x.desktop',
'FLATPAK_ID': 'x', 'SNAP': '/snap/x', 'APPIMAGE': '/x.AppImage', 'VIRTUAL_ENV': '/home/u/.venv',
'COLORTERM': 'truecolor', 'VTE_VERSION': '7600', 'WINDOWID': '1', 'TERM_PROGRAM': 'vscode',
'LS_COLORS': 'di=01;34', 'SHLVL': '2', 'BASH_FUNC_f%%': '() { :; }',
}
class FilterTests(unittest.TestCase):
def test_strips_session_metadata_and_keeps_runtime_variables(self):
environ = {**REQUIRED, **SESSION, 'SHELL': '/bin/fish'}
removed = exec_guest.filter_host_environment(environ)
self.assertEqual(set(removed), set(SESSION))
self.assertEqual(environ, {**REQUIRED, 'SHELL': '/bin/zsh'})
def test_keep_list(self):
environ = {'WAYLAND_DISPLAY': 'wayland-0', 'SSH_AUTH_SOCK': '/s', 'SHELL': '/bin/fish',
'RCLOL_KEEP_HOST_ENV': 'WAYLAND_DISPLAY, SHELL'}
self.assertEqual(exec_guest.filter_host_environment(environ), ['SSH_AUTH_SOCK'])
self.assertEqual(environ['WAYLAND_DISPLAY'], 'wayland-0')
self.assertEqual(environ['SHELL'], '/bin/fish')
class ExecTests(unittest.TestCase):
DUMP = ('import json, os; print(json.dumps({"env": dict(os.environ), "fds": sorted('
'fd for fd in map(int, os.listdir("/proc/self/fd")) if fd < 3 or os.path.exists("/proc/self/fd/%d" % fd))}))')
def run_helper(self, environ):
leaked = list(os.pipe())
leaked.append(os.dup2(leaked[0], 41, inheritable=False))
try:
result = subprocess.run([sys.executable, str(HELPER), sys.executable, '-c', self.DUMP],
env=environ, pass_fds=leaked, capture_output=True, text=True, check=True)
finally:
for fd in leaked:
os.close(fd)
return json.loads(result.stdout)
def test_closes_descriptors_and_keeps_environment_by_default(self):
environ = {**REQUIRED, **SESSION, 'SHELL': '/bin/fish'}
child = self.run_helper(environ)
self.assertEqual(child['fds'], [0, 1, 2])
for name, value in environ.items():
self.assertEqual(child['env'].get(name), value, name)
def test_opt_in_filter(self):
child = self.run_helper({**REQUIRED, **SESSION, 'SHELL': '/bin/fish', 'RCLOL_FILTER_HOST_ENV': '1'})
self.assertEqual(child['fds'], [0, 1, 2])
self.assertFalse(set(SESSION) & set(child['env']))
for name, value in REQUIRED.items():
self.assertEqual(child['env'].get(name), value, name)
self.assertEqual(child['env']['SHELL'], '/bin/zsh')
if __name__ == '__main__':
unittest.main()
+346
View File
@@ -0,0 +1,346 @@
"""Exercise the in-match client window hide with fake /proc entries and X11."""
import importlib.util
import io
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location(
'hide_in_game', ROOT / 'tools/hide-league-client-in-game.py')
hide_in_game = importlib.util.module_from_spec(spec)
spec.loader.exec_module(hide_in_game)
GAME = '/Applications/League of Legends.app/Contents/LoL/LeagueofLegends'
MAIN = '/Applications/League of Legends.app/Contents/LoL/LeagueClientUx.app/Contents/MacOS/LeagueClientUx'
RIOT = '/Applications/Riot Client.app/Contents/MacOS/RiotClientServices'
class FakeX11:
def __init__(self):
self.unmapped = []
self.shown = []
def unmap(self, window):
self.unmapped.append(window)
def show(self, window):
self.shown.append(window)
class HideClientInGameTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.proc = self.root / 'proc'
self.proc.mkdir()
self.prefix = self.root / '.darling'
self.env_patch = patch.dict(os.environ, {'DPREFIX': str(self.prefix)})
self.env_patch.start()
self.addCleanup(self.env_patch.stop)
def environment(self, prefix):
return b'%s=%s\0' % (
hide_in_game.SOCK_VARIABLE,
os.fsencode(prefix / hide_in_game.SOCK_NAME),
)
def process(self, pid, executable=GAME, prefix='own', environ=None, args=('--flag',)):
folder = self.proc / str(pid)
folder.mkdir()
(folder / 'cmdline').write_bytes(
executable.encode() + b'\0' +
b''.join(arg.encode() + b'\0' for arg in args))
if environ is None:
environ = self.environment(self.prefix if prefix == 'own' else prefix)
(folder / 'environ').write_bytes(environ)
(folder / 'stat').write_text(f'{pid} (game) S')
return folder
def test_game_running_for_own_prefix(self):
self.process(123)
self.assertTrue(hide_in_game.game_running(self.prefix, self.proc))
def test_no_game_is_false(self):
self.process(123, executable=MAIN)
self.assertFalse(hide_in_game.game_running(self.prefix, self.proc))
def test_game_of_another_prefix_is_false(self):
self.process(123, prefix=self.root / 'other')
self.assertFalse(hide_in_game.game_running(self.prefix, self.proc))
def test_game_without_socket_is_false(self):
self.process(123, environ=b'')
self.assertFalse(hide_in_game.game_running(self.prefix, self.proc))
def test_unreadable_environment_is_unknown(self):
folder = self.process(123)
original = Path.read_bytes
def read(path):
if path.name == 'environ':
raise PermissionError()
return original(path)
with patch.object(Path, 'read_bytes', read):
self.assertIsNone(hide_in_game.game_running(self.prefix, self.proc))
def test_unreadable_process_directory_is_unknown(self):
class UnreadableProc:
def iterdir(self):
raise OSError()
self.assertIsNone(hide_in_game.game_running(self.prefix, UnreadableProc()))
def test_host_socket_for_guest_uses_nspid(self):
host = self.proc / '999'
host.mkdir()
(host / 'status').write_text('Name:\tCrBrowserMain\nNSpid:\t999\t5877\n')
(host / 'environ').write_bytes(self.environment(self.prefix))
self.assertEqual(
hide_in_game.host_socket_for_guest(5877, self.proc),
os.fsencode(self.prefix / hide_in_game.SOCK_NAME),
)
def test_window_belongs_to_prefix(self):
socket = os.fsencode(self.prefix / hide_in_game.SOCK_NAME)
with patch.object(hide_in_game, 'window_pid', return_value=5877), \
patch.object(hide_in_game, 'host_socket_for_guest', return_value=socket):
self.assertTrue(hide_in_game.window_belongs_to_prefix('0x10', self.prefix))
self.assertFalse(hide_in_game.window_belongs_to_prefix(
'0x10', self.root / 'other'))
def test_once_runs_without_opt_in(self):
output = io.StringIO()
with patch.dict(os.environ, {
'RCLOL_HIDE_CLIENT_IN_GAME': '',
'RCLOL_CLOSE_CLIENT_IN_GAME': '',
}), patch.object(hide_in_game, 'target_prefix',
return_value=self.prefix), \
patch.object(hide_in_game, 'game_running', return_value=True), \
patch.object(hide_in_game.sys, 'stdout', output):
self.assertEqual(hide_in_game.main(['--once']), 0)
self.assertIn('match=running', output.getvalue())
def test_target_windows_ignores_game_class(self):
with patch.object(hide_in_game, 'xprop_root',
return_value=['0x10', '0x20', '0x30']), \
patch.object(hide_in_game, 'window_class',
side_effect=[
'wm_class(string) = "leagueclientux"',
'wm_class(string) = "leagueoflegends"',
'wm_class(string) = "riot client"',
]), \
patch.object(hide_in_game, 'window_belongs_to_prefix', return_value=True):
self.assertEqual(hide_in_game.target_windows(self.prefix), [0x10, 0x30])
def test_target_windows_requires_this_prefix(self):
with patch.object(hide_in_game, 'xprop_root',
return_value=['0x10', '0x20']), \
patch.object(hide_in_game, 'window_class',
return_value='wm_class(string) = "leagueclientux"'), \
patch.object(hide_in_game, 'window_belongs_to_prefix',
side_effect=[True, False]):
self.assertEqual(hide_in_game.target_windows(self.prefix), [0x10])
def test_target_windows_caches_non_empty_classes(self):
cache = {}
with patch.object(hide_in_game, 'xprop_root', return_value=['0x10']), \
patch.object(hide_in_game, 'window_class',
return_value='wm_class(string) = "leagueclientux"') as classes, \
patch.object(hide_in_game, 'window_belongs_to_prefix', return_value=True):
self.assertEqual(hide_in_game.target_windows(self.prefix, cache), [0x10])
self.assertEqual(hide_in_game.target_windows(self.prefix, cache), [0x10])
self.assertEqual(classes.call_count, 1)
def test_missing_wm_class_is_not_cached_as_a_class(self):
with patch.object(hide_in_game, 'xprop_value',
return_value='WM_CLASS: not found.'):
self.assertEqual(hide_in_game.window_class('0x10'), '')
def test_unmap_failure_removes_the_window_from_hidden(self):
class FailingX11(FakeX11):
def unmap(self, window):
raise RuntimeError('unmap failed')
watcher = hide_in_game.Watcher(
self.prefix, FailingX11(),
window_source=lambda _prefix, _cache: [0xabc],
state_source=lambda _prefix: True,
sleep=lambda _seconds: None,
out=io.StringIO(),
)
watcher._hide_new()
self.assertEqual(watcher.hidden, set())
def test_interrupted_unmap_keeps_the_window_tracked(self):
shown = []
class InterruptingX11:
def unmap(self, window):
raise KeyboardInterrupt
def show(self, window):
shown.append(window)
watcher = hide_in_game.Watcher(
self.prefix, InterruptingX11(),
window_source=lambda _prefix, _cache: [0xabc],
state_source=lambda _prefix: True,
sleep=lambda _seconds: None,
out=io.StringIO(),
)
with self.assertRaises(KeyboardInterrupt):
watcher._hide_new()
self.assertEqual(watcher.hidden, {0xabc})
watcher.restore()
self.assertEqual(shown, [0xabc])
def test_log_failure_does_not_discard_an_unmapped_window(self):
class BrokenOutput:
def write(self, _text):
raise BrokenPipeError
def flush(self):
pass
watcher = hide_in_game.Watcher(
self.prefix, FakeX11(),
window_source=lambda _prefix, _cache: [0xabc],
state_source=lambda _prefix: True,
sleep=lambda _seconds: None,
out=BrokenOutput(),
)
watcher._hide_new()
self.assertEqual(watcher.hidden, {0xabc})
def test_restore_log_failure_still_shows_the_window(self):
class BrokenOutput:
def write(self, _text):
raise BrokenPipeError
def flush(self):
pass
x11 = FakeX11()
watcher = hide_in_game.Watcher(
self.prefix, x11,
window_source=lambda _prefix, _cache: [0xabc],
state_source=lambda _prefix: True,
sleep=lambda _seconds: None,
out=BrokenOutput(),
)
watcher.hidden = {0xabc}
watcher._show_all('test')
self.assertEqual(x11.shown, [0xabc])
self.assertEqual(watcher.hidden, set())
def test_idle_timeout_leaves_after_client_disappears(self):
states = iter([True, False, False])
windows = iter([[0xabc], [], []])
times = iter([0.0, 10.0, 700.0])
x11 = FakeX11()
output = io.StringIO()
watcher = hide_in_game.Watcher(
self.prefix, x11,
window_source=lambda _prefix, _cache: next(windows),
state_source=lambda _prefix: next(states),
sleep=lambda _seconds: None,
out=output,
clock=lambda: next(times),
)
self.assertEqual(watcher.run(max_steps=3), 0)
self.assertEqual(x11.unmapped, [0xabc])
self.assertEqual(x11.shown, [0xabc])
self.assertIn('no client or match', output.getvalue())
def test_hides_then_shows_on_match_end(self):
states = iter([True, True, False])
x11 = FakeX11()
output = io.StringIO()
watcher = hide_in_game.Watcher(
self.prefix, x11,
window_source=lambda _prefix, _cache: [0xabc],
state_source=lambda _prefix: next(states),
sleep=lambda _seconds: None,
out=output,
)
watcher.run(max_steps=3)
self.assertEqual(x11.unmapped, [0xabc])
self.assertEqual(x11.shown, [0xabc])
self.assertIn('hidden 0xabc', output.getvalue())
self.assertIn('the match ended', output.getvalue())
self.assertIn('shown 0xabc', output.getvalue())
def test_unreadable_state_restores_the_window(self):
states = iter([True, None])
x11 = FakeX11()
output = io.StringIO()
watcher = hide_in_game.Watcher(
self.prefix, x11,
window_source=lambda _prefix, _cache: [0xdef],
state_source=lambda _prefix: next(states),
sleep=lambda _seconds: None,
out=output,
)
watcher.run(max_steps=2)
self.assertEqual(x11.unmapped, [0xdef])
self.assertEqual(x11.shown, [0xdef])
self.assertIn('unreadable /proc state', output.getvalue())
def test_no_game_does_not_touch_windows(self):
x11 = FakeX11()
watcher = hide_in_game.Watcher(
self.prefix, x11,
window_source=lambda _prefix, _cache: [0xabc],
state_source=lambda _prefix: False,
sleep=lambda _seconds: None,
out=io.StringIO(),
)
watcher.run(max_steps=1)
self.assertEqual(x11.unmapped, [])
self.assertEqual(x11.shown, [])
def test_main_is_disabled_by_default(self):
with patch.dict(os.environ, {
'RCLOL_HIDE_CLIENT_IN_GAME': '',
'RCLOL_CLOSE_CLIENT_IN_GAME': '',
}):
self.assertEqual(hide_in_game.main([]), 0)
def test_close_mode_takes_precedence(self):
with patch.dict(os.environ, {
'RCLOL_HIDE_CLIENT_IN_GAME': '1',
'RCLOL_CLOSE_CLIENT_IN_GAME': '1',
}):
self.assertEqual(hide_in_game.main([]), 0)
def test_helper_starts_after_the_installed_client_check(self):
source = (ROOT / 'tools/run-riot-client.sh').read_text()
self.assertLess(source.index('Riot Client is not installed in'),
source.index('RCLOL_CLIENT_IN_GAME_TRICKS'))
def test_master_tricks_switch_enables_hide(self):
class FakeX11:
def close(self):
pass
watcher = Mock()
with patch.dict(os.environ, {
'RCLOL_CLIENT_IN_GAME_TRICKS': '1',
'RCLOL_HIDE_CLIENT_IN_GAME': '',
'RCLOL_CLOSE_CLIENT_IN_GAME': '',
}), patch.object(hide_in_game, 'target_prefix',
return_value=self.prefix), \
patch.object(hide_in_game, 'X11', return_value=FakeX11()), \
patch.object(hide_in_game, 'Watcher', return_value=watcher):
self.assertEqual(hide_in_game.main([]), 0)
watcher.run.assert_called_once_with()
watcher.restore.assert_called_once_with()
if __name__ == '__main__':
unittest.main()
+133
View File
@@ -0,0 +1,133 @@
"""Exercise the base resolver install and actual vchroot code without DNS traffic."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
import test_runtime_compatibility as sources
@unittest.skipUnless(shutil.which('cc') and shutil.which('patch'), 'C compiler and patch required')
class HostDNSChecks(unittest.TestCase):
patched = sources.CompatibilityChecks.patched
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix='host-dns-')
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.prefix = self.root / 'guest'
(self.prefix / 'private/etc').mkdir(parents=True)
(self.prefix / 'Volumes/SystemRoot').mkdir(parents=True)
(self.prefix / 'etc').symlink_to('private/etc')
self.resolver = self.prefix / 'private/etc/resolv.conf'
def build_vchroot(self):
path = 'darling/src/libsystem_kernel/emulation/'
source = self.patched('xnu', path + 'src/linux_premigration/vchroot_userspace.c')
header = self.root / 'darling/emulation/linux_premigration/vchroot_expand.h'
header.parent.mkdir(parents=True)
header.write_text(self.patched('xnu', path + 'include/linux_premigration/vchroot_expand.h'))
# Use upstream's Linux TEST mode, replacing only its CLI and RPC setup.
fixture = r'''
#define _GNU_SOURCE
#define TEST
#include <stdint.h>
#include <string.h>
#include <strings.h>
#include <stdlib.h>
#define LC_C_LOCALE 0
#define strncasecmp_l(a,b,n,l) strncasecmp(a,b,n)
#define __simple_printf printf
#define __simple_abort abort
static int dserver_rpc_vchroot_path(char *p, unsigned long n, uint64_t *len) { abort(); }
#define main upstream_main
'''
fixture += source + r'''
#undef main
int main(int argc, const char **argv) {
if (argc != 3) return 2;
int fd = open(argv[1], O_RDONLY | O_DIRECTORY);
if (fd < 0 || __darling_vchroot(fd)) return 3;
close(fd);
struct vchroot_expand_args args = {.dfd = -100, .flags = VCHROOT_FOLLOW};
strcpy(args.path, argv[2]);
int result = vchroot_expand(&args);
if (result) return 4;
FILE *file = fopen(args.path, "r");
if (!file) return 5;
char buf[4096];
while (fgets(buf, sizeof(buf), file)) fputs(buf, stdout);
return fclose(file) != 0;
}
'''
file = self.root / 'check.c'
file.write_text(fixture)
self.binary = self.root / 'check'
result = subprocess.run(['cc', '-std=gnu11', '-I', str(self.root), str(file),
'-o', str(self.binary)], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
def read_guest(self, path='/etc/resolv.conf'):
result = subprocess.run([str(self.binary), str(self.prefix), path],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
return result.stdout
def test_host_symlink_chains_updates_and_guest_override(self):
self.build_vchroot()
host = self.root / 'host'
host.mkdir()
data = host / 'stub-resolv.conf'
data.write_text('nameserver 127.0.0.53\nsearch tether.example\n')
self.resolver.symlink_to('/Volumes/SystemRoot' + str(data))
self.assertEqual(self.read_guest(), data.read_text())
self.resolver.unlink()
# An absolute host link followed by a relative one models resolved and
# NetworkManager layouts. Never write to the real host resolver.
(host / 'relative').symlink_to(data.name)
(host / 'resolv.conf').symlink_to(host / 'relative')
self.resolver.symlink_to('/Volumes/SystemRoot' + str(host / 'resolv.conf'))
self.assertEqual(self.read_guest(), data.read_text())
# Network managers replace files atomically; a bind mount or snapshot
# would keep reading the old resolver inode.
replacement = host / 'next'
replacement.write_text('nameserver 192.0.2.53\n')
os.replace(replacement, data)
self.assertEqual(self.read_guest(), data.read_text())
other = host / 'other'
other.write_text('nameserver 192.0.2.54\n')
(host / 'resolv.conf').unlink()
(host / 'resolv.conf').symlink_to(other)
self.assertEqual(self.read_guest(), other.read_text())
self.resolver.unlink()
self.resolver.write_text('nameserver 127.0.0.1\n')
self.assertEqual(self.read_guest(), self.resolver.read_text())
# Absolute links originating inside the guest must retain guest scope.
(self.prefix / 'guest-link').symlink_to('/private/etc/resolv.conf')
self.assertEqual(self.read_guest('/guest-link'), self.resolver.read_text())
@unittest.skipUnless(shutil.which('cmake'), 'CMake required')
def test_base_install_replaces_google_file_with_host_link(self):
cmake = self.patched('darling', 'CMakeLists.txt')
start = cmake.index('# Follow the host resolver')
end = cmake.index('\n', cmake.index('InstallSymlink(', start))
macro = self.patched('darling', 'cmake/InstallSymlink.cmake')
project = self.root / 'project'
project.mkdir()
(project / 'CMakeLists.txt').write_text(
'cmake_minimum_required(VERSION 3.16)\nproject(dns NONE)\n' + macro + '\n' + cmake[start:end] + '\n')
base = self.root / 'base'
target = base / 'libexec/darling/private/etc/resolv.conf'
target.parent.mkdir(parents=True)
for packaging in ('OFF', 'ON'):
target.unlink(missing_ok=True)
target.write_text('nameserver 8.8.8.8\nnameserver 8.8.4.4\n')
build = self.root / ('build-' + packaging)
subprocess.run(['cmake', '-S', str(project), '-B', str(build),
'-DCMAKE_INSTALL_PREFIX=' + str(base),
'-DBINARY_PACKAGING_MODE=' + packaging], check=True, capture_output=True)
subprocess.run(['cmake', '--install', str(build)], check=True, capture_output=True)
self.assertTrue(target.is_symlink())
self.assertEqual(os.readlink(target), '/Volumes/SystemRoot/etc/resolv.conf')
+50
View File
@@ -0,0 +1,50 @@
"""Compile the shipped address-table helper against an inert CPU-only fixture."""
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
HEADER = 'private-include/indium/command-encoder.private.hpp'
def address_helper():
patch = (ROOT / 'patches/metal--deps--indium.patch').read_text()
marker = f'diff --git a/{HEADER} b/{HEADER}\n'
section = patch.split(marker, 1)[1].split('\ndiff --git ', 1)[0]
# Extract each postimage hunk separately: never silently join a source gap.
hunks = re.split(r'(?m)^@@ [^\n]*\n', section)[1:]
postimages = [''.join(line[1:] for line in hunk.splitlines(keepends=True)
if line.startswith((' ', '+'))) for hunk in hunks]
def region(start, end):
matches = [text[text.index(start):text.index(end, text.index(start))]
for text in postimages if start in text and end in text]
if len(matches) != 1:
raise AssertionError('expected one complete patched region: ' + start)
return matches[0]
return (region(' template<class T, size_t capacity = 64> class DescriptorScratch',
' // Per-encoder binding tables') +
region('\tstruct AddressPool {', '\n\ttemplate<size_t setCount>'))
@unittest.skipUnless(shutil.which('c++'), 'C++ compiler required')
class AddressShadowChecks(unittest.TestCase):
def test_interleaving_fallback_padding_rollover_large_keys_and_allocations(self):
fixture = (ROOT / 'tests/fixtures/indium-address-shadow.cpp').read_text()
with tempfile.TemporaryDirectory() as folder:
path = Path(folder)
source = path / 'check.cpp'
source.write_text(fixture.replace('/* ACTUAL_PATCHED_HELPER */', address_helper()))
build = subprocess.run(['c++', '-std=c++17', '-O1', str(source), '-o', str(path/'check')],
capture_output=True, text=True)
self.assertEqual(build.returncode, 0, build.stderr)
result = subprocess.run([str(path/'check')], capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == '__main__':
unittest.main()
+47
View File
@@ -0,0 +1,47 @@
"""Compile tools/test-metal-device-info.cpp against MTLDeviceInfo.h as shipped in patches/metal.patch."""
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
HEADER = 'private-include/Metal/MTLDeviceInfo.h'
def header_from_patch():
"""MTLDeviceInfo.h is a new file in the patch, so its text is the section's added lines."""
patch = (ROOT / 'patches/metal.patch').read_text()
section = re.search(r'(?ms)^diff --git a/%s b/%s\n.*?^@@ [^\n]*\n(.*?)(?=^diff --git |\Z)' % (HEADER, HEADER), patch)
if not section:
raise AssertionError(HEADER + ' is not a new file in patches/metal.patch')
lines = section.group(1).splitlines(keepends=True)
if not all(line.startswith('+') for line in lines):
raise AssertionError(HEADER + ' section has context or removed lines')
return ''.join(line[1:] for line in lines)
class MetalDeviceInfoTests(unittest.TestCase):
def test_host_check_passes(self):
compiler = shutil.which('c++')
if not compiler:
self.skipTest('no C++ compiler')
with tempfile.TemporaryDirectory() as folder:
include = Path(folder) / 'include/Metal'
include.mkdir(parents=True)
(include / 'MTLDeviceInfo.h').write_text(header_from_patch())
binary = Path(folder) / 'test-metal-device-info'
build = subprocess.run([compiler, '-std=c++17', '-Wall', '-Werror', '-I', str(include.parent),
str(ROOT / 'tools/test-metal-device-info.cpp'), '-o', str(binary)],
capture_output=True, text=True)
self.assertEqual(build.returncode, 0, build.stderr)
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stdout)
self.assertIn('METAL_DEVICE_INFO failures=0 pass=1', result.stdout)
self.assertIn('PASS registry ID is non-zero', result.stdout)
self.assertIn('PASS Mac GPU name', result.stdout)
if __name__ == '__main__':
unittest.main()
+139
View File
@@ -0,0 +1,139 @@
"""Exercise the patched presentation policy and CGL call order without a GPU."""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
import test_runtime_compatibility as sources
@unittest.skipUnless(shutil.which('c++'), 'C++ compiler required')
class MetalVSyncChecks(unittest.TestCase):
patched = sources.CompatibilityChecks.patched
def test_explicit_vsync_is_scoped_to_each_cgl_context(self):
source = self.patched('darling', 'src/frameworks/OpenGL/OpenGL.c')
methods = '\n'.join(sources.function(source, name) for name in ('CGLFlushDrawable', 'CGLSetParameter'))
fixture = r'''
#include <cassert>
#include <cstdio>
using GLint=int;
using CGLError=int;
using CGLContextParameter=int;
using EGLBoolean=int;
constexpr int kCGLNoError=0, kCGLBadContext=1, kCGLBadState=2, kCGLBadAddress=3, kCGLBadValue=4;
constexpr int kCGLCPSwapInterval=1;
struct Context { int egl_display=1, egl_surface=1, swap_interval=1, swap_interval_requested=0; };
using CGLContextObj=Context*;
static Context* current;
static int display=1, applied=-1;
static bool fail=false;
static int swapInterval(int, int value) { if(fail) return 0; applied=value; return 1; }
static auto p_eglSwapInterval=swapInterval;
static int p_eglSwapBuffers(int,int) { return 1; }
static Context* CGLGetCurrentContext() { return current; }
''' + methods + r'''
int main() {
Context a, b;
current=&a;
int interval=1;
assert(CGLSetParameter(&a,kCGLCPSwapInterval,&interval)==0 && applied==1);
CGLFlushDrawable(&a); assert(applied==1);
current=&b;
CGLFlushDrawable(&b); assert(applied==0); // Another window retains its default.
current=&a;
interval=0;
assert(CGLSetParameter(&a,kCGLCPSwapInterval,&interval)==0 && applied==0);
interval=1;
assert(CGLSetParameter(&a,kCGLCPSwapInterval,&interval)==0 && applied==1);
current=&b; fail=true;
assert(CGLSetParameter(&b,kCGLCPSwapInterval,&interval)==kCGLBadValue);
assert(!b.swap_interval_requested); // Failed requests must not change policy.
fail=false; CGLFlushDrawable(&b); assert(applied==0);
assert(CGLFlushDrawable(nullptr)==kCGLBadContext);
}
'''
self.run_cpp(fixture)
def test_nested_layers_toggles_windows_and_context_failures(self):
source = self.patched('cocotron', 'QuartzCore/CALayerContext.m')
policy = sources.function(source, 'metalSwapInterval')
# Translate only Objective-C message/iteration syntax; compile the actual
# policy and flush body against inert layer and CGL fixtures.
policy = policy.replace('[[layer class] isSubclassOfClass: [CAMetalLayerInternal class]]', 'layer->metal')
policy = policy.replace('[(CAMetalLayer *)layer displaySyncEnabled]', 'layer->sync')
policy = policy.replace('for (CALayer *child in layer.sublayers)', 'for (CALayer *child : layer->children)')
flush = source.split('- (void) flush {', 1)[1].split('\n}\n', 1)[0]
fixture = r'''
#include <cassert>
#include <vector>
using GLint = int;
using CGLContextObj = int;
constexpr int kCGLNoError = 0, kCGLCPSwapInterval = 1;
constexpr int NULL_CONTEXT = 0;
struct CALayer { bool metal=false, sync=true; std::vector<CALayer*> children; };
struct Context { int interval=-2, swaps=0; } contexts[3];
static int current=2, attached=0;
static bool fail=false, skip=false;
static int CGLGetCurrentContext() { return current; }
static int CGLSetCurrentContext(int context) { current=context; return 0; }
static int CGLContextMakeCurrentAndAttachToWindow(int context, int window) {
if(fail) return 1;
current=context; attached=window; return 0;
}
static int CGLSetParameter(int context, int parameter, const int* interval) {
assert(current==context && attached==context && parameter==kCGLCPSwapInterval);
contexts[context].interval=*interval; return 0;
}
static int CGLFlushDrawable(int context) {
assert(current==context && attached==context);
++contexts[context].swaps; return 0;
}
static bool skipLocalRender(CALayer*, int) { return skip; }
'''
fixture += policy
fixture += '\nstruct Window { int _glContext=1, _cglWindow=1, _frame=0; CALayer* _layer; void flush() {'
fixture += flush.replace('NULL', 'NULL_CONTEXT').replace('_frame.size', '_frame') + '\n} };\n'
fixture += r'''
int main() {
CALayer root, container, metal, other;
metal.metal=other.metal=true;
root.children={&container}; container.children={&metal};
assert(metalSwapInterval(&root)==1);
Window a{1,1,0,&root};
a.flush(); assert(contexts[1].interval==1 && current==2);
metal.sync=false;
a.flush(); assert(contexts[1].interval==0 && current==2);
container.children.push_back(&other);
a.flush(); assert(contexts[1].interval==1);
other.sync=false;
a.flush(); assert(contexts[1].interval==0);
CALayer plain;
assert(metalSwapInterval(&plain)==-1);
Window b{2,2,0,&plain};
b.flush(); assert(contexts[2].interval==-2);
other.sync=true;
a.flush(); assert(contexts[1].interval==1 && contexts[2].interval==-2);
a._cglWindow=0;
int swaps=contexts[1].swaps;
a.flush(); assert(contexts[1].swaps==swaps);
a._cglWindow=1; fail=true;
a.flush(); assert(contexts[1].swaps==swaps && current==2);
fail=false; skip=true;
a.flush(); assert(contexts[1].swaps==swaps);
skip=false; current=0;
a.flush(); assert(current==0);
current=1; a.flush(); assert(current==1);
root.children.clear();
assert(metalSwapInterval(&root)==-1);
}
'''
self.run_cpp(fixture)
def run_cpp(self, fixture):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory)
(path/'check.cpp').write_text(fixture)
result = subprocess.run(['c++', '-std=c++17', str(path/'check.cpp'), '-o', str(path/'check')], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
subprocess.run([str(path/'check')], check=True, timeout=10)
+100
View File
@@ -0,0 +1,100 @@
import json
import os
import re
import subprocess
import sys
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
def extract_fhs_profile(flake_text: str) -> str:
"""Extract the FHS shell profile, substituting inert paths for Nix values."""
match = re.search(r"profile\s*=\s*''(.*?)'';", flake_text, re.DOTALL)
if not match:
raise ValueError("Could not find profile in flake.nix")
raw_profile = match.group(1)
# Nix multi-line strings: (?<!'')\$\{...\} is interpolation, ''$ is escaped $
profile_script = re.sub(r"(?<!'')\$\{([^}]+)\}", r"/nix/store/mock-\1", raw_profile)
profile_script = profile_script.replace("''$", "$")
return profile_script
class NixFontsConfigTest(unittest.TestCase):
"""Check packaged font configuration and isolation from host overrides."""
def test_flake_font_configuration(self):
"""Require bundled text fonts, user font support, and ordered sanitization."""
flake_text = (REPO_ROOT / "flake.nix").read_text(encoding="utf-8")
# Flake must bundle both font families in darlingFontsConf
self.assertIn("pkgs.liberation_ttf", flake_text)
self.assertIn("pkgs.dejavu_fonts", flake_text)
# makeFontsConf includes store fontconfig config directory
self.assertIn('"${pkgs.fontconfig.out}/etc/fonts/conf.d"', flake_text)
# Legacy ~/.fonts XML fragment included in darlingFontsConf
self.assertIn("<dir>~/.fonts</dir>", flake_text)
# FHS profile unsets foreign/sysroot fontconfig vars before exporting package FONTCONFIG_FILE
self.assertIn("unset FONTCONFIG_PATH FONTCONFIG_SYSROOT", flake_text)
self.assertIn("export FONTCONFIG_FILE=${darlingFontsConf}", flake_text)
unset_pos = flake_text.index("unset FONTCONFIG_PATH FONTCONFIG_SYSROOT")
export_pos = flake_text.index("export FONTCONFIG_FILE=${darlingFontsConf}")
self.assertLess(unset_pos, export_pos, "unset must happen before export FONTCONFIG_FILE")
def test_fhs_profile_hostile_environment_sanitization(self):
"""Clear hostile fontconfig overrides while preserving user XDG settings."""
flake_text = (REPO_ROOT / "flake.nix").read_text(encoding="utf-8")
profile_script = extract_fhs_profile(flake_text)
hostile_envs = [
{
"FONTCONFIG_SYSROOT": "/not/a/real/host/sysroot",
"FONTCONFIG_PATH": "/foreign/host/etc/fonts",
},
{
"FONTCONFIG_SYSROOT": "",
"FONTCONFIG_PATH": "/foreign/host/etc/fonts",
},
]
for hostile_font_env in hostile_envs:
with self.subTest(hostile_font_env=hostile_font_env):
test_env = dict(hostile_font_env)
test_env.update({
"HOME": "/tmp/mock-home",
"XDG_DATA_HOME": "/tmp/mock-xdg data with spaces",
"XDG_CONFIG_HOME": "/tmp/mock-xdg config with spaces",
"UNRELATED_USER_VAR": "preserve_this_value",
"PATH": os.environ.get("PATH", "/bin:/usr/bin"),
})
dump_cmd = (
f"{profile_script}\n"
f'"{sys.executable}" -c \'import os, json; print(json.dumps(dict(os.environ)))\''
)
proc = subprocess.run(
["bash", "-c", dump_cmd],
env=test_env,
capture_output=True,
text=True,
check=True,
)
resulting_env = json.loads(proc.stdout)
self.assertNotIn("FONTCONFIG_SYSROOT", resulting_env)
self.assertNotIn("FONTCONFIG_PATH", resulting_env)
self.assertEqual(
resulting_env.get("FONTCONFIG_FILE"),
"/nix/store/mock-darlingFontsConf",
)
self.assertEqual(resulting_env.get("XDG_DATA_HOME"), "/tmp/mock-xdg data with spaces")
self.assertEqual(resulting_env.get("XDG_CONFIG_HOME"), "/tmp/mock-xdg config with spaces")
self.assertEqual(resulting_env.get("UNRELATED_USER_VAR"), "preserve_this_value")
if __name__ == "__main__":
unittest.main()
+76
View File
@@ -1,8 +1,11 @@
"""Regression tests for the Nix launcher; no root or game account required."""
import fcntl
import os
from pathlib import Path
import signal
import subprocess
import tempfile
import time
import unittest
ROOT = Path(__file__).resolve().parents[1]
@@ -20,6 +23,7 @@ class LauncherTests(unittest.TestCase):
(self.share / 'scripts/run.sh').write_text('new packaged script\n')
import shutil
shutil.copy(ROOT / 'scripts/nix-copy.py', self.share / 'scripts/nix-copy.py')
shutil.copy(ROOT / 'scripts/nix-stop.py', self.share / 'scripts/nix-stop.py')
self.workspace = self.home / '.local/share/darling-lol'
self.log = self.folder / 'calls'
self.bin = self.folder / 'bin'
@@ -62,6 +66,78 @@ class LauncherTests(unittest.TestCase):
self.assertIn('--setgid=0', calls)
self.assertEqual(calls[-3:], [str(self.bin / 'fhs'), 'echo', 'hello world'])
def test_session_restores_signals_inside_the_namespace(self):
result = self.launch()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.log.read_text().splitlines()[-5:],
[str(self.bin / 'fhs'), 'env', '--default-signal=INT,QUIT,HUP,TERM',
'bash', './scripts/nix-session.sh'])
def test_interrupt_becomes_a_stop_request_and_namespace_survives_it(self):
# Stands in for bubblewrap: it must outlive Ctrl+C and see the request.
(self.bin / 'unshare').write_text(
'#!/bin/sh\necho started > "$TEST_LOG"\n'
'while [ ! -e .stop-request ]; do sleep 0.1; done\n'
'echo stopped >> "$TEST_LOG"\n')
launcher = subprocess.Popen(['bash', str(self.launcher)], env=self.env, text=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
start_new_session=True)
for _ in range(100):
if self.log.exists():
break
time.sleep(0.1)
os.killpg(launcher.pid, signal.SIGINT)
_, stderr = launcher.communicate(timeout=10)
self.assertEqual(launcher.returncode, 0, stderr)
self.assertEqual(self.log.read_text().splitlines(), ['started', 'stopped'])
self.assertFalse((self.workspace / '.stop-request').exists())
def test_stop_without_session_does_not_enter_the_namespace(self):
result = self.launch('--stop')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn('No darling-lol session is running', result.stdout)
self.assertFalse(self.log.exists())
def test_stop_asks_the_session_holding_the_lock_to_exit(self):
self.workspace.mkdir(parents=True)
lock = self.workspace / '.launcher.lock'
session = subprocess.Popen(['flock', str(lock), 'sh', '-c',
'while [ ! -e "$0/.stop-request" ]; do sleep 0.1; done',
str(self.workspace)])
self.addCleanup(session.kill)
with open(lock, 'a+') as handle:
for _ in range(100):
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
fcntl.flock(handle, fcntl.LOCK_UN)
time.sleep(0.1)
except BlockingIOError:
break
result = self.launch('--stop')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn('Stopped.', result.stdout)
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)
+60
View File
@@ -3,8 +3,10 @@ import importlib.util
import os
from pathlib import Path
import shutil
import signal
import subprocess
import tempfile
import time
import unittest
from unittest.mock import MagicMock, patch
@@ -30,6 +32,26 @@ class IdleTests(unittest.TestCase):
(process / 'maps').read_text.side_effect = FileNotFoundError()
self.assertFalse(idle.client_active(Path('/fixture/prefix'), processes=[process]))
def test_crash_handlers_alone_do_not_keep_the_prefix_active(self):
spec = importlib.util.spec_from_file_location('nix_idle_handlers', ROOT / 'scripts/nix-idle.py')
assert spec is not None and spec.loader is not None
idle = importlib.util.module_from_spec(spec)
spec.loader.exec_module(idle)
with tempfile.TemporaryDirectory() as folder:
prefix = Path(folder) / 'prefix'
app = prefix / 'Applications/League of Legends.app'
def process(pid, executable):
entry = Path(folder) / 'proc' / str(pid)
entry.mkdir(parents=True)
(entry / 'maps').write_text(f'7f00-7f01 r-xp 0 0:0 1 {app}/Contents/MacOS/{executable}\n')
(entry / 'cmdline').write_bytes(f'/Applications/League of Legends.app/Contents/MacOS/{executable}\0--flag\0'.encode())
return entry
handlers = [process(1, 'LeagueCrashHandler'), process(2, 'chrome_crashpad_handler')]
self.assertFalse(idle.client_active(prefix, processes=handlers))
self.assertTrue(idle.client_active(prefix, processes=[*handlers, process(3, 'LeagueClientUx')]))
class LifecycleTests(unittest.TestCase):
def setUp(self):
@@ -54,9 +76,16 @@ def client_active(prefix, **kwargs):
with open(os.environ['TEST_LOG'], 'a') as f:
f.write('scan\\n')
return calls < 2
def server_running(prefix):
return os.environ.get('SERVER_RUNNING', '1') == '1'
''')
(scripts / 'darling-rootless.sh').write_text('''#!/bin/bash
echo "shutdown:$*" >> "$TEST_LOG"
# Like darling shutdown, end the client command that the server ran.
if [ -n "${CLIENT_SLEEP:-}" ]; then
for _ in 1 2 3 4 5 6 7 8 9 10; do [ -e "$TEST_LOG.pid" ] && break; sleep 0.2; done
fi
[ -e "$TEST_LOG.pid" ] && kill "$(cat "$TEST_LOG.pid")"
if [ "${SHUTDOWN_RC:-0}" != 0 ] && [ ! -e "$TEST_LOG.retried" ]; then
touch "$TEST_LOG.retried"
exit "$SHUTDOWN_RC"
@@ -65,6 +94,10 @@ exit 0
''')
client = scripts / 'run.sh'
client.write_text('''#!/usr/bin/env bash
if [ -n "${CLIENT_SLEEP:-}" ]; then
echo "$$" > "$TEST_LOG.pid"
exec sleep "$CLIENT_SLEEP"
fi
echo client-finished >> "$TEST_LOG"
exit "${CLIENT_RC:-0}"
'''.replace('#!/usr/bin/env bash', '#!' + str(shutil.which('bash'))))
@@ -89,11 +122,38 @@ exit "${CLIENT_RC:-0}"
self.assertEqual(self.log.read_text().splitlines().count('shutdown:shutdown'), 2)
self.assertIn('shutdown:shutdown', self.log.read_text())
def test_stop_request_shuts_down_without_waiting_for_command_or_clients(self):
(self.root / '.stop-request').touch()
result = self.run_session(CLIENT_SLEEP='60')
self.assertEqual(result.returncode, 128 + 15, result.stderr)
self.assertIn('Stopping Darling', result.stderr)
self.assertEqual(self.log.read_text().splitlines(), ['shutdown:shutdown'])
def test_interrupt_shuts_down_without_waiting_for_command_or_clients(self):
session = subprocess.Popen(['bash', str(self.root / 'scripts/nix-session.sh')],
env=dict(self.env, CLIENT_SLEEP='60'), text=True,
stderr=subprocess.PIPE)
pid_file = Path(str(self.log) + '.pid')
for _ in range(50):
if pid_file.exists():
break
time.sleep(0.1)
session.send_signal(signal.SIGINT)
_, stderr = session.communicate(timeout=10)
self.assertEqual(session.returncode, 128 + signal.SIGINT, stderr)
self.assertEqual(self.log.read_text().splitlines(), ['shutdown:shutdown'])
def test_failed_shutdown_is_not_retried_once_the_server_is_gone(self):
result = self.run_session(SHUTDOWN_RC='23', SERVER_RUNNING='0')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(self.log.read_text().splitlines().count('shutdown:shutdown'), 1)
def test_shutdown_retries_when_terminal_diagnostics_fail(self):
spec = importlib.util.spec_from_file_location('lifecycle_fixture', self.root / 'scripts/nix-lifecycle.py')
lifecycle = importlib.util.module_from_spec(spec)
spec.loader.exec_module(lifecycle)
child = MagicMock()
child.poll.return_value = 17
child.wait.return_value = 17
stderr = MagicMock()
stderr.write.side_effect = OSError('terminal disconnected')
+191
View File
@@ -0,0 +1,191 @@
"""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):
leftover = 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')
# The caller removes the old prefix once the new path is saved.
self.assertEqual(leftover, self.old)
self.assertTrue(self.old.exists())
self.assertEqual(list((self.folder / 'bulk').iterdir()), [new])
def load(self):
spec = importlib.util.spec_from_file_location('nix_prefix_unsafe', self.workspace / 'scripts/nix-prefix.py')
prefix = importlib.util.module_from_spec(spec)
spec.loader.exec_module(prefix)
return prefix
def cross_device(self, prefix):
rename = os.rename
first = []
def rename_across(source, target):
if not first:
first.append(1)
raise OSError(errno.EXDEV, 'cross-device link')
rename(source, target)
return patch.object(prefix.os, 'rename', rename_across)
def test_copy_leaves_an_existing_partial_directory_alone(self):
prefix = self.load()
new = self.folder / 'bulk/prefix'
unrelated = self.folder / 'bulk/prefix.partial'
unrelated.mkdir(parents=True)
(unrelated / 'keep').write_text('mine')
with self.cross_device(prefix):
prefix.move(self.old, new)
self.assertEqual((unrelated / 'keep').read_text(), 'mine')
self.assertEqual(sorted(path.name for path in (self.folder / 'bulk').iterdir()),
['prefix', 'prefix.partial'])
def test_failed_copy_keeps_the_old_prefix_and_removes_only_its_staging(self):
prefix = self.load()
new = self.folder / 'bulk/prefix'
new.parent.mkdir()
error = subprocess.CalledProcessError(1, 'cp')
with self.cross_device(prefix), patch.object(prefix.subprocess, 'run', side_effect=error):
with self.assertRaises(subprocess.CalledProcessError):
prefix.move(self.old, new)
self.assertEqual((self.old / 'Applications/League of Legends.app/game').read_text(), 'game data')
self.assertEqual(list(new.parent.iterdir()), [])
def test_copy_refused_when_the_target_drops_user_xattrs(self):
prefix = self.load()
new = self.folder / 'bulk/prefix'
with self.cross_device(prefix), patch.object(prefix.os, 'setxattr',
side_effect=OSError(errno.ENOTSUP, 'not supported')):
with self.assertRaises(SystemExit) as raised:
prefix.move(self.old, new)
self.assertIn('extended attributes', str(raised.exception))
self.assertTrue(self.old.exists())
self.assertEqual(list(new.parent.iterdir()), [])
def test_copy_that_lost_xattrs_is_refused(self):
prefix = self.load()
game = self.old / 'Applications/League of Legends.app/game'
try:
os.setxattr(game, 'user.test', b'1')
except OSError:
self.skipTest('the temporary directory has no user xattrs')
new = self.folder / 'bulk/prefix'
real = prefix.user_xattrs
stripped = lambda path: {} if Path(path).name == 'game' and Path(path).is_relative_to(new.parent) else real(path)
with self.cross_device(prefix), patch.object(prefix, 'user_xattrs', stripped):
with self.assertRaises(SystemExit) as raised:
prefix.move(self.old, new)
self.assertIn('old prefix was kept', str(raised.exception))
self.assertTrue(game.exists())
self.assertEqual(list(new.parent.iterdir()), [])
def test_failed_delete_of_the_old_prefix_keeps_the_new_one_saved(self):
prefix = self.load()
new = self.folder / 'bulk/prefix'
with patch.dict(os.environ, {'DPREFIX': str(self.old)}), \
patch.object(sys, 'argv', ['nix-prefix.py', str(new)]), \
patch.object(prefix, 'ROOT', self.workspace), \
patch.object(prefix, 'move', lambda old, target: (shutil.copytree(old, target), old)[1]), \
patch.object(prefix.shutil, 'rmtree', side_effect=OSError('busy')):
self.assertEqual(prefix.main(), 0)
self.assertEqual(self.saved_prefix(), str(new))
self.assertTrue((new / 'Applications/League of Legends.app/game').exists())
self.assertTrue(self.old.exists())
def test_refuses_a_destination_inside_the_overlay_work_directory(self):
for new in (self.folder / 'home/.darling.workdir/games', self.folder / 'home/.darling.workdir'):
result = self.set_prefix(new)
self.assertNotEqual(result.returncode, 0, new)
self.assertIn('work', result.stderr)
self.assertTrue((self.old / 'Applications/League of Legends.app/game').exists())
self.assertFalse((self.workspace / 'build/config.json').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()
+10 -2
View File
@@ -440,6 +440,7 @@ class SessionTests(unittest.TestCase):
self.assertFalse((root / 'other-prefix').exists())
def test_first_run_eof_is_actionable_and_desktop_requests_terminal(self):
"""Report missing terminal input without setup and package a terminal launcher."""
with tempfile.TemporaryDirectory(prefix='nix-session-test-') as directory:
root = Path(directory)
(root / 'scripts').mkdir()
@@ -453,7 +454,8 @@ class SessionTests(unittest.TestCase):
self.assertIn('terminal', result.stderr.lower())
self.assertFalse((root / 'build').exists())
self.assertIn('Terminal=true', (ROOT / 'flake.nix').read_text())
self.assertIn('checks.${system}.packaging', (ROOT / 'flake.nix').read_text())
self.assertIn('packaging = pkgs.runCommand "darling-lol-packaging-tests"',
(ROOT / 'flake.nix').read_text())
def test_rootless_update_refuses_live_marker_without_build_or_stop(self):
with tempfile.TemporaryDirectory(prefix='nix-update-idle-') as directory:
@@ -491,6 +493,9 @@ class SessionTests(unittest.TestCase):
path.chmod(0o755)
(scripts / 'nix-state.py').write_text('import os, sys\nwith open(os.environ["TEST_LOG"], "a") as f: f.write(sys.argv[1]+"\\n")\n')
(tools / 'stop-darling-runtime.py').write_text('import os\nwith open(os.environ["TEST_LOG"], "a") as f: f.write("stop\\n")\n')
(root / 'tools').mkdir()
(root / 'tools/configure-darling-platform.py').write_text(
'import os, sys\nwith open(os.environ["TEST_LOG"], "a") as f: f.write("identity " + " ".join(sys.argv[1:]) + "\\n")\n')
for name in ['cmake', 'sudo', 'nix-store']:
path = bin_dir / name
path.write_text('#!/bin/sh\necho ' + name + ' >> "$TEST_LOG"\n' +
@@ -502,12 +507,15 @@ class SessionTests(unittest.TestCase):
result = run('sh', str(scripts / 'update.sh'), env=env)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(log.read_text().splitlines(),
['nix-store', 'invalidate-base', 'build', 'stop', 'cmake', 'stamp-base', 'overlays'])
['nix-store', 'invalidate-base', 'build', 'stop', 'cmake', 'stamp-base', 'overlays',
# The shared serial/UUID replacement writes the prefix, so Darling is stopped first.
'stop', 'identity --replace-known-defaults --write'])
log.write_text('')
env['INSTALL_EXIT'] = '23'
self.assertEqual(run('sh', str(scripts / 'update.sh'), env=env).returncode, 23)
self.assertNotIn('stamp-base', log.read_text())
self.assertNotIn('overlays', log.read_text())
self.assertNotIn('identity', log.read_text())
self.assertNotIn('sudo', log.read_text())
def test_rootless_update_never_uses_sudo_and_stamps_only_after_success(self):
+46
View File
@@ -0,0 +1,46 @@
"""Check that the patched Cocotron file panels always end their modal session and never throw on Open."""
import unittest
import test_runtime_compatibility as sources
def body(source, signature):
return source.split(signature + ' {', 1)[1].split('\n}', 1)[0]
class FilePanelCloseChecks(unittest.TestCase):
patched = sources.CompatibilityChecks.patched
def save_panel(self):
return self.patched('cocotron', 'AppKit/NSSavePanel.subproj/NSSavePanel.m')
def open_panel(self):
return self.patched('cocotron', 'AppKit/NSSavePanel.subproj/NSOpenPanel.m')
def test_both_buttons_dismiss_through_one_helper(self):
source = self.save_panel()
dismiss = body(source, '- (void) _dismissWithCode: (NSModalResponse) code')
self.assertIn('[NSApp modalWindow] == self', dismiss)
self.assertIn('stopModalWithCode: code', dismiss)
self.assertIn('orderOut', dismiss)
self.assertIn('_dismissWithCode: NSCancelButton', body(source, '- (IBAction) _cancel: (id) sender'))
self.assertIn('_dismissWithCode: NSOKButton', body(source, '- (IBAction) _selectFile: (id) sender'))
def test_open_without_a_selection_cannot_throw(self):
# Nothing selected used to hand nil to -_setFilename:, and NSOpenPanel boxed it in @[ ... ].
self.assertNotIn('@[ filename ]];', self.open_panel().replace('[filename length] > 0 ? @[ filename ] : @[]', ''))
select = body(self.save_panel(), '- (IBAction) _selectFile: (id) sender')
self.assertNotIn('itemAtRow: [_outlineView selectedRow]', select)
self.assertIn('@catch', select)
self.assertIn('NSCancelButton', select.split('@catch', 1)[1])
def test_open_panel_honours_what_it_may_choose(self):
accept = body(self.open_panel(), '- (NSArray *) _acceptablePaths')
self.assertIn('_canChooseDirectories', accept)
self.assertIn('_canChooseFiles', accept)
def test_escape_cancels(self):
self.assertIn('[self _cancel: sender]', body(self.save_panel(), '- (void) cancelOperation: (id) sender'))
if __name__ == '__main__':
unittest.main()
+40
View File
@@ -157,3 +157,43 @@ class ProvisioningChecks(unittest.TestCase):
self.assertEqual(platform.provision(supplied)['IOPlatformUUID'], value['IOPlatformUUID'])
with self.assertRaises(ValueError):
platform.validate({'IOPlatformUUID': value['IOPlatformUUID'], 'provisioning_UDID': str(uuid.uuid4())})
def test_replace_known_defaults_only_touches_shared_identities(self):
replace = [sys.executable, str(SCRIPT), '--prefix', str(self.prefix), '--runtime-root',
str(self.runtime), '--replace-known-defaults', '--write']
# A prefix without identity files is not created or provisioned.
result = subprocess.run(replace, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(self.prefix.exists())
# A custom identity stays byte-for-byte unchanged.
custom = {'IOPlatformSerialNumber': 'MY-SERIAL', 'model': 'CustomModel'}
platform.save(self.prefix, custom)
canonical = platform.destination(self.prefix)
before = canonical.read_bytes()
result = subprocess.run(replace, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(canonical.read_bytes(), before)
self.assertFalse((self.prefix / 'private/etc/darling-smbios.conf').exists())
# The shared values are replaced; custom fields survive and a rerun is stable.
platform.save(self.prefix, {**custom, 'IOPlatformSerialNumber': platform.KNOWN_SERIAL,
'IOPlatformUUID': platform.KNOWN_UUID})
result = subprocess.run(replace, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
value = platform.load(canonical)
self.assertNotEqual(value['IOPlatformSerialNumber'], platform.KNOWN_SERIAL)
self.assertNotEqual(value['IOPlatformUUID'], platform.KNOWN_UUID)
self.assertFalse(platform.has_known_defaults(value))
self.assertEqual(value['model'], 'CustomModel')
self.assertNotIn('board-id', value)
self.assertNotIn('manufacturer', value)
self.assertEqual(platform.load(self.prefix / 'private/etc/darling-smbios.conf'), value)
after = canonical.read_bytes()
subprocess.run(replace, capture_output=True, text=True, check=True)
self.assertEqual(canonical.read_bytes(), after)
def test_standalone_fields_derived_from_known_uuid_are_replaced(self):
known_id = uuid.UUID(platform.KNOWN_UUID).bytes_le
for supplied in [{'system-id': known_id}, {'provisioning_UDID': platform.KNOWN_UUID}]:
value = platform.provision(supplied)
self.assertNotEqual(value['IOPlatformUUID'], platform.KNOWN_UUID)
self.assertNotEqual(value['system-id'], known_id)
+161
View File
@@ -0,0 +1,161 @@
"""Test the opt-in partial client-tree recovery with fake /proc entries."""
import importlib.util
import io
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location(
'recover_stale_client', ROOT / 'tools/recover-stale-client.py')
recover_stale_client = importlib.util.module_from_spec(spec)
spec.loader.exec_module(recover_stale_client)
GAME = '/Applications/League of Legends.app/Contents/LoL/LeagueofLegends'
CLIENT = '/Users/Shared/Riot Games/Riot Client.app/Contents/MacOS/RiotClientServices'
class RecoverStaleClientTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.proc = self.root / 'proc'
self.proc.mkdir()
(self.proc / 'uptime').write_text('1000 0\n')
self.prefix = self.root / '.darling'
def process(self, pid, comm, argv, env=None, executable=None, age=120):
folder = self.proc / str(pid)
folder.mkdir()
(folder / 'comm').write_text(comm + '\n')
(folder / 'cmdline').write_bytes(
(argv[0] if executable is None else executable).encode() + b'\0' +
b''.join(arg.encode() + b'\0' for arg in argv[1:]))
ticks = os.sysconf('SC_CLK_TCK')
fields = ['S'] + ['0'] * 18 + [str(int((1000 - age) * ticks))]
(folder / 'stat').write_text(f'{pid} ({comm}) ' + ' '.join(fields) + '\n')
if env is not None:
(folder / 'environ').write_bytes(env)
return folder
def client_env(self, prefix=None):
prefix = self.prefix if prefix is None else prefix
return b'%s=%s\0' % (
recover_stale_client.SOCK_VARIABLE,
os.fsencode(prefix / recover_stale_client.SOCK_NAME),
)
def run_recover(self, window=False, on_kill=None):
killed = []
def kill(pid, signum):
killed.append((pid, signum))
if on_kill is not None:
on_kill(pid, signum)
out = io.StringIO()
with patch.object(recover_stale_client, 'visible_client_window',
return_value=window):
result = recover_stale_client.recover(
self.prefix, proc_root=self.proc, window_check=lambda: window,
kill=kill, sleep=lambda _seconds: None, out=out)
return result, killed, out.getvalue()
def test_no_darlingserver_does_nothing(self):
self.process(10, 'mldr', ['/sbin/launchd'])
result, killed, output = self.run_recover()
self.assertEqual((result, killed), (0, []))
self.assertIn('no darlingserver', output)
def test_game_is_never_touched(self):
self.process(10, 'darlingserver', ['darlingserver'])
self.process(11, 'mldr', [GAME])
result, killed, output = self.run_recover()
self.assertEqual((result, killed), (0, []))
self.assertIn('game running', output)
def test_visible_window_does_nothing(self):
self.process(10, 'darlingserver', ['darlingserver'])
self.process(11, 'mldr', [CLIENT], self.client_env())
result, killed, output = self.run_recover(window=True)
self.assertEqual((result, killed), (0, []))
self.assertIn('client window present', output)
def test_stale_client_is_killed_but_runtime_is_preserved(self):
self.process(10, 'darlingserver', ['darlingserver'])
self.process(11, 'mldr', ['/sbin/launchd'])
self.process(12, 'mldr', [CLIENT], self.client_env())
result, killed, output = self.run_recover()
self.assertEqual(result, 0)
self.assertEqual([pid for pid, _sig in killed], [12, 12])
self.assertIn('stale client tree', output)
def test_other_prefix_and_game_are_preserved(self):
self.process(10, 'darlingserver', ['darlingserver'])
self.process(11, 'mldr', [CLIENT], self.client_env(self.root / 'other'))
self.process(12, 'mldr', [GAME], self.client_env())
result, killed, output = self.run_recover()
self.assertEqual((result, killed), (0, []))
self.assertIn('game running', output)
def test_runtime_process_names_are_not_killed(self):
self.process(10, 'darlingserver', ['darlingserver'])
self.process(11, 'mldr', ['/sbin/launchd'])
self.process(12, 'mldr', [CLIENT], self.client_env())
result, killed, _output = self.run_recover()
self.assertEqual(result, 0)
self.assertNotIn(10, [pid for pid, _sig in killed])
self.assertNotIn(11, [pid for pid, _sig in killed])
def test_pid_identity_is_rechecked_before_sigkill(self):
self.process(10, 'darlingserver', ['darlingserver'])
self.process(12, 'mldr', [CLIENT], self.client_env())
def reuse_pid(pid, signum):
if signum == recover_stale_client.signal.SIGTERM:
(self.proc / str(pid) / 'cmdline').write_bytes(b'/usr/bin/editor\0')
result, killed, _output = self.run_recover(on_kill=reuse_pid)
self.assertEqual(result, 0)
self.assertEqual(killed, [(12, recover_stale_client.signal.SIGTERM)])
def test_young_client_is_preserved(self):
self.process(10, 'darlingserver', ['darlingserver'])
self.process(12, 'mldr', [CLIENT], self.client_env(),
age=recover_stale_client.MIN_AGE_SECONDS - 1)
result, killed, output = self.run_recover()
self.assertEqual((result, killed), (0, []))
self.assertIn('no stale client processes', output)
def test_missing_net_client_list_is_inconclusive(self):
with patch.object(recover_stale_client.subprocess, 'check_output',
return_value='_NET_CLIENT_LIST: not found.\n'):
self.assertTrue(recover_stale_client.visible_client_window())
def test_empty_client_list_is_no_window(self):
with patch.object(recover_stale_client.subprocess, 'check_output',
return_value='_NET_CLIENT_LIST(WINDOW): window id #\n'):
self.assertFalse(recover_stale_client.visible_client_window())
def test_missing_wm_class_is_inconclusive(self):
with patch.object(recover_stale_client.subprocess, 'check_output',
side_effect=[
'_NET_CLIENT_LIST(WINDOW): window id # 0x10\n',
'WM_CLASS: not found.\n',
]):
self.assertTrue(recover_stale_client.visible_client_window())
def test_riot_client_shell_is_a_visible_window(self):
with patch.object(recover_stale_client.subprocess, 'check_output',
side_effect=[
'_NET_CLIENT_LIST(WINDOW): window id # 0x10\n',
'WM_CLASS(STRING) = "RiotClientServices", "RiotClientServices"\n',
]):
self.assertTrue(recover_stale_client.visible_client_window())
if __name__ == '__main__':
unittest.main()
+151
View File
@@ -0,0 +1,151 @@
"""Cleanup scope regressions: fake proc/prefix trees and mocked signals only."""
import contextlib
import importlib.util
import io
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import MagicMock, Mock, patch
SCRIPT = Path(__file__).resolve().parents[1] / 'tools/ensure-darling-runtime.py'
class RuntimeCleanupScopeTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory(prefix='cleanup-scope-')
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.proc = self.root / 'proc'
self.proc.mkdir()
self.prefix = self.root / 'selected prefix'
self.other = self.root / 'unrelated prefix'
self.shm = self.root / 'shm'
for folder in (self.prefix / 'private/tmp', self.other / 'private/tmp', self.shm):
folder.mkdir(parents=True)
# Guard signals before import; no real process may ever be signalled.
self.kill = self.enterContext(patch('os.kill'))
spec = importlib.util.spec_from_file_location('cleanup_scope', SCRIPT)
assert spec is not None and spec.loader is not None
self.cleanup = importlib.util.module_from_spec(spec)
spec.loader.exec_module(self.cleanup)
self.stderr = self.enterContext(contextlib.redirect_stderr(io.StringIO()))
self.enterContext(contextlib.redirect_stdout(io.StringIO()))
self.enterContext(patch.dict(os.environ, {'DPREFIX': str(self.prefix)}))
self.enterContext(patch.object(self.cleanup, 'Path', side_effect=self.fake_path))
# Its default Path('/proc') is bound at definition time, before patching Path.
running = self.cleanup.any_server_running
self.enterContext(patch.object(self.cleanup, 'any_server_running',
side_effect=lambda proc_root=None: running(
self.proc if proc_root is None else proc_root)))
def fake_path(self, value):
# Never inspect host /proc or remove host shared-memory files.
return {'/proc': self.proc, '/dev/shm': self.shm}.get(str(value), Path(value))
def process(self, pid, *, comm='launchd', argv=('/sbin/launchd',), state='S'):
folder = self.proc / str(pid)
folder.mkdir()
(folder / 'comm').write_text(comm + '\n')
(folder / 'cmdline').write_bytes(b'\0'.join(arg.encode() for arg in argv) + b'\0')
(folder / 'status').write_text(f'State:\t{state} (fixture)\n')
return folder
def test_direct_cleanup_entry_points_preserve_ambiguous_processes(self):
self.process(101)
self.process(102, comm='chrome_crashpad', argv=(
'/Applications/Riot Client.app/Contents/Frameworks/chrome_crashpad_handler',))
self.cleanup.stop_orphaned_launchd(self.proc)
self.cleanup.stop_riot_crashpad(self.proc)
self.kill.assert_not_called()
self.assertIn('prefix ownership', self.stderr.getvalue())
self.assertIn('launchd candidate PIDs 101', self.stderr.getvalue())
self.assertIn('Riot crashpad candidate PIDs 102', self.stderr.getvalue())
def test_clean_start_is_silent(self):
self.cleanup.main()
self.assertEqual(self.stderr.getvalue(), '')
self.kill.assert_not_called()
def test_unrelated_processes_are_silent(self):
self.process(110, comm='chrome_crashpad', argv=(
'/opt/google/chrome/chrome_crashpad_handler', '--annotation=riot'))
self.process(111, comm='unrelated', argv=('/usr/bin/tool', '/sbin/launchd'))
self.process(112, comm='unrelated', argv=('/usr/bin/tool',
'/Applications/Riot Client.app/chrome_crashpad_handler'))
self.cleanup.main()
self.assertEqual(self.stderr.getvalue(), '')
self.kill.assert_not_called()
def test_foreign_owned_candidates_are_silent(self):
self.process(113)
self.process(114, comm='chrome_crashpad', argv=(
'/Applications/Riot Client.app/chrome_crashpad_handler',))
with patch.object(Path, 'stat', return_value=Mock(st_uid=os.getuid() + 1)):
self.cleanup.stop_orphaned_launchd(self.proc)
self.cleanup.stop_riot_crashpad(self.proc)
self.assertEqual(self.stderr.getvalue(), '')
self.kill.assert_not_called()
def test_main_preserves_processes_from_another_prefix_without_a_server(self):
self.process(101)
self.process(102, comm='chrome_crashpad', argv=(
str(self.other / 'Applications/Riot Client.app/crashpad_handler'),))
selected = self.prefix / 'private/tmp/.com.riotgames.selected'
selected.write_text('stale')
unrelated = self.other / 'private/tmp/.com.riotgames.other'
unrelated.write_text('preserve')
shared = self.shm / 'darling-buffer-fixture'
shared.write_text('stale')
self.cleanup.main()
self.kill.assert_not_called()
self.assertFalse(selected.exists())
self.assertFalse(shared.exists())
self.assertEqual(unrelated.read_text(), 'preserve')
def test_any_live_server_preserves_locks_and_shared_buffers(self):
self.process(103, comm='darlingserver', argv=('darlingserver',))
lock = self.prefix / 'private/tmp/.com.riotgames.lock'
lock.write_text('active')
shared = self.shm / 'darling-buffer-fixture'
shared.write_text('active')
self.cleanup.main()
self.kill.assert_not_called()
self.assertTrue(lock.exists())
self.assertTrue(shared.exists())
def test_unreadable_process_list_fails_closed(self):
hidden = MagicMock()
hidden.name = '104'
(hidden / 'comm').read_text.side_effect = PermissionError('hidden')
proc = Mock()
proc.iterdir.return_value = [hidden]
self.assertTrue(self.cleanup.any_server_running(proc))
self.kill.assert_not_called()
def test_dead_marker_is_recovered_only_in_the_selected_prefix(self):
self.process(105, state='Z')
marker = self.prefix / '.init.pid'
marker.write_text('105')
other = self.other / '.init.pid'
other.write_text('105')
self.cleanup.main()
self.assertFalse(marker.exists())
recovered = list(self.prefix.glob('.init.pid.stale-105-*'))
self.assertEqual(len(recovered), 1)
self.assertEqual(recovered[0].read_text(), '105')
self.assertEqual(other.read_text(), '105')
self.kill.assert_not_called()
def test_live_marker_is_not_replaced(self):
self.process(106)
marker = self.prefix / '.init.pid'
marker.write_text('106')
self.cleanup.main()
self.assertEqual(marker.read_text(), '106')
self.kill.assert_not_called()
if __name__ == '__main__':
unittest.main()
+1 -1
View File
@@ -38,7 +38,7 @@ class CompatibilityChecks(unittest.TestCase):
patch = patch_text.split(marker, 1)[1].split('\ndiff --git ', 1)[0]
with tempfile.TemporaryDirectory() as folder:
target = Path(folder) / path
target.parent.mkdir(parents=True)
target.parent.mkdir(parents=True, exist_ok=True)
if original is not None:
target.write_bytes(original)
subprocess.run(['patch', '--silent', '-p1', '-d', folder], input=marker + patch + ("" if patch.endswith("\n") else "\n"),
+169
View File
@@ -0,0 +1,169 @@
"""Offline contracts for NSSavePanel's unsupported Finder-tag compatibility."""
import json
from pathlib import Path
import re
import runpy
import shutil
import subprocess
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
IMPLEMENTATION = 'AppKit/NSSavePanel.subproj/NSSavePanel.m'
HEADER = 'AppKit/include/AppKit/NSSavePanel.h'
PROBE_SOURCE = 'tools/riot-save-panel-tag-check.m'
BUILD_HELPER = 'tools/build-riot-save-panel-tag-check.py'
def added_source(path):
marker = f'diff --git a/{path} b/{path}\n'
section = (ROOT / 'patches/cocotron.patch').read_text().split(marker, 1)[1]
section = section.split('\ndiff --git ', 1)[0]
return '\n'.join(line[1:] for line in section.splitlines()
if line.startswith('+') and not line.startswith('+++'))
def extract_patch(path):
marker = f'diff --git a/{path} b/{path}\n'
patch_text = (ROOT / 'patches/cocotron.patch').read_text()
if marker not in patch_text:
raise AssertionError(f'Missing patch section for {path}')
section = patch_text.split(marker, 1)[1].split('\ndiff --git ', 1)[0]
return marker + section + '\n'
def method(source, signature):
match = re.search(re.escape(signature) + r'\s*\{([^}]+)\}', source)
if match is None:
raise AssertionError(f'Missing implementation: {signature}')
return match.group(1)
class SavePanelTagTests(unittest.TestCase):
def test_tag_names_setter_accepts_unsupported_metadata_without_storing_it(self):
body = method(added_source(IMPLEMENTATION), '- (void) setTagNames: (NSArray *) names')
self.assertIn('(void)names;', body)
self.assertNotIn('retain', body)
self.assertNotIn('copy', body)
def test_tag_names_getter_returns_an_empty_array_not_nil(self):
body = method(added_source(IMPLEMENTATION), '- (NSArray *) tagNames')
self.assertIn('return [NSArray array];', body)
def test_public_header_declares_both_tag_name_methods(self):
header = added_source(HEADER)
self.assertIn('- (void) setTagNames: (NSArray *) names;', header)
self.assertIn('- (NSArray *) tagNames;', header)
def test_existing_tag_field_and_modal_fixes_are_retained(self):
source = added_source(IMPLEMENTATION)
self.assertIn('- (void) setShowsTagField: (BOOL) flag', source)
self.assertIn('- (BOOL) showsTagField', source)
self.assertIn('- (void) _dismissWithCode: (NSModalResponse) code', source)
self.assertNotIn('setShowsTags:', source)
def test_patch_applies_cleanly_to_pinned_upstream_cocotron(self):
if not shutil.which('patch'):
self.skipTest('patch executable required')
manifest = json.loads((ROOT / 'upstream/manifest.json').read_text())
component = next(c for c in manifest['components'] if c['patch'] == 'patches/cocotron.patch')
repo = ROOT / 'build/darling-src' / component['path']
if not (repo / '.git').exists():
self.skipTest('prepared Darling sources required')
revision = component['revision']
for relpath in [IMPLEMENTATION, HEADER]:
filename = Path(relpath).name
original = subprocess.check_output(['git', '-C', str(repo), 'show', f'{revision}:{relpath}'])
with tempfile.TemporaryDirectory() as td:
work_file = Path(td) / filename
work_file.write_bytes(original)
patch_file = Path(td) / f'{filename}.patch'
patch_file.write_text(extract_patch(relpath))
res = subprocess.run(
['patch', '--dry-run', '--fuzz=0', str(work_file), str(patch_file)],
capture_output=True,
text=True,
)
self.assertEqual(res.returncode, 0, f'Patch failed to apply to {relpath}: {res.stderr}\n{res.stdout}')
def test_extracted_method_bodies_compile_with_clang(self):
clang = shutil.which('clang')
if not clang:
self.skipTest('clang not available')
impl = added_source(IMPLEMENTATION)
setter_body = method(impl, '- (void) setTagNames: (NSArray *) names')
getter_body = method(impl, '- (NSArray *) tagNames')
snippet = f'''
@interface NSObject
+ (instancetype)alloc;
- (instancetype)init;
@end
@interface NSArray : NSObject
+ (instancetype)array;
@end
@interface NSSavePanel : NSObject
- (void) setTagNames: (NSArray *) names;
- (NSArray *) tagNames;
@end
@implementation NSSavePanel
- (void) setTagNames: (NSArray *) names {{{setter_body}}}
- (NSArray *) tagNames {{{getter_body}}}
@end
'''
with tempfile.NamedTemporaryFile(suffix='.m', mode='w') as f:
f.write(snippet)
f.flush()
res = subprocess.run(
[clang, '-Qunused-arguments', '-x', 'objective-c', '-fsyntax-only', '-Werror', f.name],
capture_output=True,
text=True,
)
self.assertEqual(res.returncode, 0, f'Clang syntax check failed: {res.stderr}')
def test_builder_compiles_and_links_probe_without_embedding_shim(self):
source = ROOT / BUILD_HELPER
self.assertTrue(source.exists(), f'Missing build helper: {BUILD_HELPER}')
with tempfile.TemporaryDirectory() as temporary:
project = Path(temporary)
driver = project / 'tools' / source.name
driver.parent.mkdir()
driver.write_text(source.read_text())
framework = project / 'build/darling-src/build/src/external/cocotron/AppKit'
cmake = framework / 'CMakeFiles/X11_backend.dir'
cmake.mkdir(parents=True)
(cmake / 'flags.make').write_text('C_DEFINES = -DTEST\nC_INCLUDES = -Itest\nC_FLAGS = -O0\n')
calls = []
with patch('subprocess.run', side_effect=lambda argv, **kw: calls.append((argv, kw))):
runpy.run_path(str(driver), run_name='__main__')
probe_name = 'riot-save-panel-tag-check'
compiles = [argv for argv, _ in calls if str(project / f'tools/{probe_name}.m') in argv]
self.assertEqual(len(compiles), 1, 'probe must be compiled')
self.assertEqual(compiles[0][compiles[0].index('-o') + 1], str(project / f'build/{probe_name}.o'))
links = [argv for argv, _ in calls if str(project / f'build/{probe_name}') in argv]
self.assertEqual(len(links), 1, 'probe must be linked')
self.assertIn(str(project / f'build/{probe_name}.o'), links[0])
self.assertIn('AppKit', links[0])
for argv, _ in calls:
for arg in argv:
self.assertNotIn('compat', arg)
def test_probe_declares_only_and_implements_no_shim(self):
source = (ROOT / PROBE_SOURCE).read_text()
self.assertIn('@interface NSSavePanel (RiotTagProbeDeclarations)', source)
self.assertIn('- (void)setTagNames:(NSArray *)names;', source)
self.assertIn('- (NSArray *)tagNames;', source)
self.assertIn('- (void)setShowsTagField:(BOOL)value;', source)
self.assertIn('- (BOOL)showsTagField;', source)
self.assertNotIn('@implementation NSSavePanel', source, 'probe must not contain compatibility shim')
self.assertIn('checkPanel([NSSavePanel class])', source)
self.assertIn('checkPanel([NSOpenPanel class])', source)
if __name__ == '__main__':
unittest.main()
+97
View File
@@ -0,0 +1,97 @@
"""system_profiler hardware/software overviews, with stub guest commands; never starts Darling."""
import os
from pathlib import Path
import plistlib
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / 'tools/darling-system_profiler.sh'
IOREG = '''+-o MacBookPro16,1 <class IOPlatformExpertDevice, id 0x100000110, registered, matched, active, busy 0 (0 ms), retain 30>
{
"IOPlatformSerialNumber" = "USER-SUPPLIED"
"model" = <"MacBookPro16,1">
"manufacturer" = <"Apple Inc.">
"IOPlatformUUID" = "8E1F2A3B-4C5D-4E6F-8A7B-9C0D1E2F3A4B"
"provisioning_UDID" = "8E1F2A3B-4C5D-4E6F-8A7B-9C0D1E2F3A4B"
"ROM" = <0a1b2c3d4e5f>
}
'''
SYSCTL = {
'hw.physicalcpu': '8', 'hw.packages': '1', 'hw.memsize': str(32 << 30),
'machdep.cpu.brand_string': 'AMD Ryzen 7 5800X 8-Core Processor & more',
'hw.l2cachesize': str(512 << 10), 'hw.l3cachesize': str(32 << 20), 'hw.cpufrequency': '3800000000',
}
class SystemProfilerTests(unittest.TestCase):
def setUp(self):
self.folder = tempfile.TemporaryDirectory()
self.bin = Path(self.folder.name)
self.stub('sysctl', 'import sys\nvalues = %r\nname = sys.argv[-1]\n'
'if name not in values: sys.exit(1)\nprint(values[name])' % SYSCTL)
self.stub('sw_vers', 'import sys\nprint({"-productName": "macOS", "-productVersion": "11.7.4", '
'"-buildVersion": "20G1120"}[sys.argv[1]])')
self.stub('uname', 'print("Darwin 20.6.0")')
self.stub('hostname', 'print("darling")')
self.stub('ioreg', 'import sys\nsys.stdout.write(%r)' % IOREG)
def tearDown(self):
self.folder.cleanup()
def stub(self, name, body):
path = self.bin / name
path.write_text('#!%s\n%s\n' % (sys.executable, body))
path.chmod(0o755)
def run_profiler(self, *args):
env = {'PATH': '%s:%s' % (self.bin, os.environ['PATH']), 'USER': 'player'}
return subprocess.run(['sh', str(SCRIPT), *args], env=env, check=True, capture_output=True, text=True).stdout
def xml_items(self, data_type):
report = plistlib.loads(self.run_profiler('-xml', data_type).encode())
self.assertEqual(report[0]['_dataType'], data_type)
return report[0]['_items'][0]
def test_hardware_reports_iokit_identity_and_sysctl_values(self):
item = self.xml_items('SPHardwareDataType')
self.assertEqual(item, {
'_name': 'hardware_overview', 'machine_name': 'MacBook Pro', 'machine_model': 'MacBookPro16,1',
'cpu_type': 'AMD Ryzen 7 5800X 8-Core Processor & more', 'current_processor_speed': '3.8 GHz',
'packages': 1, 'number_processors': 8, 'l2_cache_core': '512 KB', 'l3_cache': '32 MB',
'physical_memory': '32 GB', 'serial_number': 'USER-SUPPLIED',
'platform_UUID': '8E1F2A3B-4C5D-4E6F-8A7B-9C0D1E2F3A4B',
'provisioning_UDID': '8E1F2A3B-4C5D-4E6F-8A7B-9C0D1E2F3A4B',
})
text = self.run_profiler('SPHardwareDataType')
self.assertIn(' Serial Number (system): USER-SUPPLIED\n', text)
self.assertIn(' Model Name: MacBook Pro\n', text)
def test_missing_iokit_and_sysctl_values_are_omitted(self):
self.stub('ioreg', 'import sys\nsys.exit(1)')
self.stub('sysctl', 'import sys\nsys.exit(1)')
self.assertEqual(self.xml_items('SPHardwareDataType'), {'_name': 'hardware_overview'})
self.assertNotIn('Serial Number', self.run_profiler('SPHardwareDataType'))
def test_software_overview(self):
item = self.xml_items('SPSoftwareDataType')
self.assertEqual(item, {
'_name': 'os_overview', 'os_version': 'macOS 11.7.4 (20G1120)', 'kernel_version': 'Darwin 20.6.0',
'boot_volume': 'Macintosh HD', 'boot_mode': 'normal_boot', 'local_host_name': 'darling',
'user_name': 'player',
})
self.assertIn(' Boot Mode: Normal\n', self.run_profiler('SPSoftwareDataType'))
def test_listed_and_default_types(self):
listed = self.run_profiler('-listDataTypes')
self.assertIn('SPHardwareDataType', listed)
self.assertIn('SPSoftwareDataType', listed)
types = [entry['_dataType'] for entry in plistlib.loads(self.run_profiler('-xml').encode())]
self.assertEqual(types[:2], ['SPHardwareDataType', 'SPSoftwareDataType'])
if __name__ == '__main__':
unittest.main()
+36
View File
@@ -0,0 +1,36 @@
"""Check that the patched X11 backend cannot re-map a window AppKit has hidden."""
import unittest
import test_runtime_compatibility as sources
def body(source, signature):
return source.split(signature + ' {', 1)[1].split('\n}', 1)[0]
class HiddenWindowChecks(unittest.TestCase):
patched = sources.CompatibilityChecks.patched
def test_restack_and_make_key_map_only_visible_windows(self):
source = self.patched('cocotron', 'AppKit/X11.backend/X11Window.m')
self.assertIn('[_delegate isVisible]', body(source, '- (void) ensureMappedIfVisible'))
for signature in ('- (void) placeAboveWindow: (NSInteger) otherNumber',
'- (void) placeBelowWindow: (NSInteger) otherNumber', '- (void) makeKey'):
method = body(source, signature)
self.assertIn('[self ensureMappedIfVisible]', method, signature)
self.assertNotIn('[self ensureMapped]', method, signature)
def test_ordering_marks_window_visible_before_placing_it(self):
source = self.patched('cocotron', 'AppKit/NSWindow.m')
ordering = source.split('relativeTo: (NSInteger) relativeTo\n{', 1)[1].split('\n}', 1)[0]
for call in ('placeAboveWindow', 'placeBelowWindow'):
self.assertLess(ordering.index('_isVisible = YES'), ordering.index(call))
def test_hiding_reaches_the_platform_window_when_already_not_visible(self):
source = self.patched('cocotron', 'AppKit/NSWindow.m')
method = body(source, '- (void) setIsVisible: (BOOL) visible')
self.assertIn('_isVisible == visible && visible', method)
self.assertIn('hideWindow', method)
if __name__ == '__main__':
unittest.main()
+164
View File
@@ -0,0 +1,164 @@
"""Compile the patched VRR eligibility and drawable ownership methods with inert fixtures."""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
import test_runtime_compatibility as sources
def body(source, signature):
return source.split(signature + ' {', 1)[1].split('\n}', 1)[0]
@unittest.skipUnless(shutil.which('c++'), 'C++ compiler required')
class VrrChecks(unittest.TestCase):
patched = sources.CompatibilityChecks.patched
def run_cpp(self, source):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory)
(path/'check.cpp').write_text('#include <cstddef>\n' + source)
result = subprocess.run(['c++', '-std=c++17', str(path/'check.cpp'), '-o', str(path/'check')], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
subprocess.run([str(path/'check')], check=True, timeout=10)
def test_hints_only_allow_vrr_for_fullscreen_parent_presentation(self):
source = self.patched('cocotron', 'AppKit/X11.backend/X11Window.m')
method = body(source, '- (void) updateLayerPresentationHints: (BOOL) fullscreen')
getter = body(source, '- (void *) layerDrawableFor: (id) owner')
acquire = body(source, '- (void) acquireLayerDrawable: (id) owner').replace('[self updateLayerPresentationHints: _fullscreenRequested]', 'hints(_fullscreenRequested)')
release = body(source, '- (void) releaseLayerDrawable: (id) owner').replace('[self updateLayerPresentationHints: _fullscreenRequested]', 'hints(_fullscreenRequested)')
self.run_cpp(r'''
#include <cassert>
#include <cstring>
using BOOL=bool;
constexpr auto nil=nullptr;
constexpr int False=0, XA_CARDINAL=6, PropModeReplace=0;
static bool game=true;
static int adaptive=-1,bypass=-1,calls=0;
static bool X11IsLeagueGameWindow(int,int,int) { return game; }
static const char* XInternAtom(int,const char* name,int) { return name; }
static int XChangeProperty(int,int,const char* name,int type,int bits,int mode,unsigned char* data,int count) {
assert(type==XA_CARDINAL && bits==32 && mode==PropModeReplace && count==1);
int value=*(unsigned long*)data; ++calls;
if(!strcmp(name,"_VARIABLE_REFRESH")) adaptive=value;
else { assert(!strcmp(name,"_NET_WM_BYPASS_COMPOSITOR")); bypass=value; }
return 0;
}
struct Window {
int _display=1,_window=1,_delegate=1;
void* _layerDrawableOwner=nullptr;
void* _cglWindow=(void*)3; bool _hasAlpha=false,_fullscreenRequested=true;
void hints(BOOL fullscreen) {
''' + method + '\n }\n void* drawable(void* owner) {' + getter + '\n}\n void acquire(void* owner) {' + acquire + '\n}\n void release(void* owner) {' + release + r'''
}
};
int main() {
Window w;
w.hints(true); assert(adaptive==0 && bypass==2);
w._layerDrawableOwner=&w;
w.hints(true); assert(adaptive==1 && bypass==0);
w.hints(false); assert(adaptive==0 && bypass==0);
w._layerDrawableOwner=nullptr;
w.hints(true); assert(adaptive==0 && bypass==2);
int before=calls; game=false; w.hints(true); assert(calls==before);
assert(!w.drawable(&w)); game=true;
int a,b;
assert(w.drawable(&a)==(void*)3);
w.acquire(&a); assert(w.drawable(&a)==(void*)3 && !w.drawable(&b));
w.acquire(&b); assert(w._layerDrawableOwner==&a);
w.release(&b); assert(w._layerDrawableOwner==&a);
w.release(&a); assert(!w._layerDrawableOwner && w.drawable(&b)==(void*)3);
w._hasAlpha=true; assert(!w.drawable(&a));
}
''')
def test_subwindows_borrow_only_when_visible_borderless_and_covering_parent(self):
source = self.patched('cocotron', 'AppKit/X11.backend/X11SubWindow.m')
method = body(source, '- (void *) sharedCGLWindow')
replacements = {'[_parent frame]':'_parent->frame', '[_parent styleMask]':'_parent->style',
'[_parent layerDrawableFor: self]':'_parent->drawable(this)',
'[_parent acquireLayerDrawable: self]':'_parent->acquire(this)',
'[_parent releaseLayerDrawable: self]':'_parent->release(this)'}
for old,new in replacements.items():
method=method.replace(old,new)
self.run_cpp(r'''
#include <cassert>
using BOOL=bool;
struct CGRect { struct {double x,y;} origin; struct {double width,height;} size; };
static CGRect CGRectMake(double x,double y,double w,double h) {return {{x,y},{w,h}};}
static bool CGRectEqualToRect(CGRect a,CGRect b) {return a.origin.x==b.origin.x && a.origin.y==b.origin.y && a.size.width==b.size.width && a.size.height==b.size.height;}
constexpr int NSTitledWindowMask=1;
static bool mapped;
static void XMapWindow(int,int) {mapped=true;}
static void XUnmapWindow(int,int) {mapped=false;}
struct Parent {
CGRect frame=CGRectMake(0,0,800,600); int style=0; bool eligible=true;
void* owner=nullptr; void* surface=this;
void* drawable(void* requester) {return eligible && (!owner || owner==requester) ? surface : nullptr;}
void acquire(void* requester) {assert(!owner); owner=requester;}
void release(void* requester) {assert(owner==requester); owner=nullptr;}
};
struct Child {
Parent* _parent; CGRect _drawableFrame=CGRectMake(0,0,800,600);
BOOL _visible=true,_usingParentDrawable=false,_allowsParentDrawable=true; int _display=1,_window=2;
void* shared() {
''' + method + r'''
}
};
int main() {
Parent p; Child a{&p}; Child b{&p};
assert(a.shared()==p.surface && !mapped && p.owner==&a);
assert(a.shared()==p.surface); // Repeated lookup does not reacquire.
assert(b.shared()==nullptr && mapped); // A second view cannot share ownership.
a._visible=false; assert(!a.shared() && !mapped && !p.owner);
a._visible=true; assert(a.shared()==p.surface);
a._drawableFrame.size.width=400;
assert(!a.shared() && mapped && !p.owner);
a._drawableFrame=CGRectMake(0,0,800,600); p.style=NSTitledWindowMask;
assert(!a.shared() && mapped);
p.style=0; p.eligible=false; assert(!a.shared() && mapped);
p.eligible=true; a._allowsParentDrawable=false; assert(!a.shared() && mapped);
a._allowsParentDrawable=true; a._drawableFrame.origin.x=1; assert(!a.shared() && mapped);
a._drawableFrame.origin.x=0; assert(a.shared()==p.surface && !mapped);
}
''')
def test_switching_surfaces_destroys_only_owned_child_drawables(self):
source = self.patched('cocotron', 'QuartzCore/CALayerContext.m')
method = body(source, '- (void) updateDrawable')
method=method.replace('[_subwindow setAllowsParentDrawable: hasMetalLayer(_layer)]', '_subwindow->allow(hasMetalLayer(_layer))')
method=method.replace('[_subwindow sharedCGLWindow]', '_subwindow->shared').replace('[_subwindow nativeWindow]', '_subwindow->native')
self.run_cpp(r'''
#include <cassert>
using BOOL=bool;
using CGLContextObj=int;
static int current=0,destroyed=0,created=0;
static void* destroyedSurface;
static int CGLGetCurrentContext() {return current;}
static void CGLSetCurrentContext(int c) {current=c;}
static void CGLDestroyWindow(void* surface) {assert(surface==(void*)10); destroyedSurface=surface; ++destroyed;}
static void* CGLGetWindow(void*) {++created; return (void*)10;}
static void CGLContextMakeCurrentAndAttachToWindow(int c,void*) {current=c;}
static bool hasMetalLayer(int layer) {return layer!=0;}
struct Subwindow {void* shared=nullptr; void* native=(void*)2; void allow(bool enabled) {assert(enabled);} };
struct Context {
Subwindow* _subwindow; int _layer=1; int _glContext=1; void* _cglWindow=nullptr; bool _ownsCglWindow=false;
void update() {
''' + method.replace('CGLSetCurrentContext(NULL)', 'CGLSetCurrentContext(0)') + r'''
}
};
int main() {
Subwindow sub; Context c{&sub};
c.update(); assert(created==1 && c._ownsCglWindow); // Ordinary clients still get a child.
c.update(); assert(created==1 && destroyed==0);
current=1; sub.shared=(void*)20;
c.update(); assert(destroyed==1 && destroyedSurface==(void*)10 && !c._ownsCglWindow && current==1);
c.update(); assert(destroyed==1);
sub.shared=(void*)30; c.update(); assert(destroyed==1 && c._cglWindow==(void*)30);
sub.shared=nullptr; c.update(); assert(created==2 && destroyed==1 && c._ownsCglWindow);
current=2; sub.shared=(void*)20; c.update(); assert(current==2 && destroyed==2);
c._subwindow=nullptr; c.update(); assert(destroyed==2);
}
''')
+1 -1
View File
@@ -10,5 +10,5 @@ OBJDUMP="$(command -v llvm-objdump || true)"
file "$BIN" | tee "$OUT/file.txt"
[ -n "$OTOOL" ] && { "$OTOOL" -hv "$BIN" > "$OUT/headers.txt" 2>&1; "$OTOOL" -L "$BIN" > "$OUT/linked-libs.txt" 2>&1; echo "wrote headers + linked-libs"; } || echo "no otool/llvm-otool found"
[ -n "$OBJDUMP" ] && "$OBJDUMP" --macho --dylibs-used "$BIN" > "$OUT/dylibs.txt" 2>&1 || true
strings -a "$BIN" | grep -iE "Metal|MTL|Vanguard|vgc|CEF|Chromium|RiotClient|NSApplication|LSOpen|CFBundle" | sort -u | head -100 | tee "$OUT/strings-of-interest.txt"
strings -a "$BIN" | { grep -iE "Metal|MTL|CEF|Chromium|RiotClient|NSApplication|LSOpen|CFBundle" || test "$?" -eq 1; } | sort -u | head -100 | tee "$OUT/strings-of-interest.txt"
echo "done -> $OUT/"
+1 -1
View File
@@ -12,7 +12,7 @@ for line in (framework / 'CMakeFiles/AppKit.dir/flags.make').read_text().splitli
name, value = line.split(' = ', 1)
flags[name] = shlex.split(value)
link = shlex.split((framework / 'CMakeFiles/AppKit.dir/link.txt').read_text())
for name in ['NSResponder', 'NSApplication', 'NSWindow', 'NSDisplay', 'NSView', 'NSThemeFrame', 'NSTextInput', 'NSBitmapImageRep', 'NSEvent.subproj/NSEvent_mouse']:
for name in ['NSResponder', 'NSApplication', 'NSWindow', 'NSDisplay', 'NSView', 'NSThemeFrame', 'NSTextInput', 'NSBitmapImageRep', 'NSEvent.subproj/NSEvent', 'NSEvent.subproj/NSEvent_mouse']:
obj = project / f'build/riot-{Path(name).name}.o'
subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'],
'-O3', '-march=native', '-mtune=generic', '-Wno-macro-redefined', '-c',
+17 -8
View File
@@ -43,14 +43,16 @@ if matrix_obj in link:
link[link.index(matrix_obj)]=str(obj)
else:
link.append(str(obj))
# The default-output unit must be recompiled too: its SetProperty refuses kAudioOutputUnitProperty_CurrentDevice unless patched.
obj=project/'build/riot-DefaultOutputAU.o'
subprocess.run(['clang++',*component_flags['CXX_DEFINES'],*component_flags['CXX_INCLUDES'],
*component_flags['CXX_FLAGS'],'-O2','-c',str(project/'build/darling-src/src/CoreAudio/CoreAudioComponent/DefaultOutputAU.cpp'),
'-o',str(obj)],check=True)
default_obj='CMakeFiles/CoreAudioComponent.dir/DefaultOutputAU.cpp.o'
assert default_obj in link, 'DefaultOutputAU object missing from the link line'
link[link.index(default_obj)]=str(obj)
# The AUHAL subclasses must be recompiled too: they share AUHAL.h's layout and vtable, and the default-output
# unit's SetProperty refuses kAudioOutputUnitProperty_CurrentDevice unless patched.
for name in ['DefaultOutputAU','SystemOutputAU']:
obj=project/f'build/riot-{name}.o'
subprocess.run(['clang++',*component_flags['CXX_DEFINES'],*component_flags['CXX_INCLUDES'],
*component_flags['CXX_FLAGS'],'-O2','-c',str(project/f'build/darling-src/src/CoreAudio/CoreAudioComponent/{name}.cpp'),
'-o',str(obj)],check=True)
default_obj=f'CMakeFiles/CoreAudioComponent.dir/{name}.cpp.o'
assert default_obj in link, f'{name} object missing from the link line'
link[link.index(default_obj)]=str(obj)
link[link.index('-o')+1]=str(project/'build/riot-CoreAudioComponent')
subprocess.run(link,cwd=component,check=True)
toolbox=build/'src/CoreAudio/AudioToolbox'
@@ -90,3 +92,10 @@ subprocess.run(['clang++',*cpp_flags['CXX_DEFINES'],*cpp_flags['CXX_INCLUDES'],
subprocess.run(['clang++','-target','x86_64-apple-macosx11.0','-fuse-ld=lld','-nostdlib',
'-isysroot','@DARLING_SDK@',str(obj),'-lSystem','-lc++',
str(project/'build/riot-AudioToolbox'),'-o',str(project/'build/riot-audio-graph-check')],check=True)
obj=project/'build/riot-audio-input-check.o'
subprocess.run(['clang++',*cpp_flags['CXX_DEFINES'],*cpp_flags['CXX_INCLUDES'],
*cpp_flags['CXX_FLAGS'],'-O2','-c',str(project/'tools/riot-audio-input-check.cpp'),'-o',str(obj)],check=True)
subprocess.run(['clang++','-target','x86_64-apple-macosx11.0','-fuse-ld=lld','-nostdlib',
'-isysroot','@DARLING_SDK@',str(obj),'-lSystem','-lc++','-lobjc','-framework','CoreAudio',
'-framework','AudioToolbox','-framework','AVFoundation','-o',str(project/'build/riot-audio-input-check')],check=True)
+7 -6
View File
@@ -10,12 +10,13 @@ for line in (folder / 'CMakeFiles/CoreGraphics.dir/flags.make').read_text().spli
if line.startswith('C_') and ' = ' in line:
key, value = line.split(' = ', 1)
flags[key] = shlex.split(value)
obj = root / 'build/riot-CGDirectDisplay.o'
subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'],
'-O2', '-Wno-macro-redefined', '-c',
str(root / 'build/darling-src/src/external/cocotron/CoreGraphics/CGDirectDisplay.m'),
'-o', str(obj)], check=True)
link = shlex.split((folder / 'CMakeFiles/CoreGraphics.dir/link.txt').read_text())
link[link.index('CMakeFiles/CoreGraphics.dir/CGDirectDisplay.m.o')] = str(obj)
for name in ('CGDirectDisplay', 'CGSubWindow'):
obj = root / f'build/riot-{name}.o'
subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'],
'-O2', '-Wno-macro-redefined', '-c',
str(root / f'build/darling-src/src/external/cocotron/CoreGraphics/{name}.m'),
'-o', str(obj)], check=True)
link[link.index(f'CMakeFiles/CoreGraphics.dir/{name}.m.o')] = str(obj)
link[link.index('-o') + 1] = str(root / 'build/riot-CoreGraphics')
subprocess.run(link, cwd=folder, check=True)
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""Build the owned NSOpenPanel modal-session check (issue #85)."""
from pathlib import Path
import json
import shlex
import subprocess
root = Path(__file__).resolve().parent.parent
folder = root / 'build/darling-src/build/src/external/cocotron/AppKit'
config = root / 'build/config.json'
sdk = json.loads(config.read_text())['sdk'] if config.exists() else '/usr/local/libexec/darling'
flags = {}
for line in (folder / 'CMakeFiles/X11_backend.dir/flags.make').read_text().splitlines():
if line.startswith('C_') and ' = ' in line:
key, value = line.split(' = ', 1)
flags[key] = shlex.split(value)
obj = root / 'build/riot-openpanel-check.o'
subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'],
'-O2', '-fblocks', '-UNDEBUG', '-Wno-macro-redefined', '-c', str(root / 'tools/riot-openpanel-check.m'),
'-o', str(obj)], check=True)
subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib',
'-isysroot', sdk, str(obj), '-lSystem', '-lobjc',
'-framework', 'AppKit', '-framework', 'CoreFoundation', '-framework', 'Foundation',
'-o', str(root / 'build/riot-openpanel-check')], check=True)
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""Build the standalone NSSavePanel Finder-tag compatibility check."""
from pathlib import Path
import json
import shlex
import subprocess
root = Path(__file__).resolve().parent.parent
folder = root / 'build/darling-src/build/src/external/cocotron/AppKit'
config = root / 'build/config.json'
sdk = json.loads(config.read_text())['sdk'] if config.exists() else '/usr/local/libexec/darling'
flags = {}
for line in (folder / 'CMakeFiles/X11_backend.dir/flags.make').read_text().splitlines():
if line.startswith('C_') and ' = ' in line:
key, value = line.split(' = ', 1)
flags[key] = shlex.split(value)
obj = root / 'build/riot-save-panel-tag-check.o'
subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'],
'-O2', '-fblocks', '-UNDEBUG', '-Wno-macro-redefined', '-c', str(root / 'tools/riot-save-panel-tag-check.m'),
'-o', str(obj)], check=True)
subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib',
'-isysroot', sdk, str(obj), '-lSystem', '-lobjc',
'-framework', 'AppKit', '-framework', 'CoreFoundation', '-framework', 'Foundation',
'-o', str(root / 'build/riot-save-panel-tag-check')], check=True)
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""Build an owned AppKit check for the in-process X11 drag-and-drop loop."""
from pathlib import Path
import shlex
import subprocess
root = Path(__file__).resolve().parent.parent
folder = root / 'build/darling-src/build/src/external/cocotron/AppKit'
flags = {}
for line in (folder / 'CMakeFiles/X11_backend.dir/flags.make').read_text().splitlines():
if line.startswith('C_') and ' = ' in line:
key, value = line.split(' = ', 1)
flags[key] = shlex.split(value)
obj = root / 'build/riot-x11-drag-check.o'
subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'],
'-O2', '-fblocks', '-Wno-macro-redefined', '-c', str(root / 'tools/riot-x11-drag-check.m'),
'-o', str(obj)], check=True)
subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib',
'-isysroot', '@DARLING_SDK@', str(obj), '-lSystem', '-lobjc',
'-framework', 'AppKit', '-framework', 'CoreFoundation', '-framework', 'Foundation',
'-o', str(root / 'build/riot-x11-drag-check')], check=True)
+21 -2
View File
@@ -14,7 +14,7 @@ for line in (framework / 'CMakeFiles/X11_backend.dir/flags.make').read_text().sp
key, value = line.split(' = ', 1)
flags[key] = shlex.split(value)
link = shlex.split((framework / 'CMakeFiles/X11_backend.dir/link.txt').read_text())
for name in ('X11Display', 'X11Window'):
for name in ('X11Display', 'X11Window', 'X11SubWindow'):
obj = project / f'build/riot-{name}.o'
subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], '-O3', '-march=native', '-mtune=generic', '-Wno-macro-redefined', '-c', str(project / f'build/darling-src/src/external/cocotron/AppKit/X11.backend/{name}.m'), '-o', str(obj)], check=True)
link[link.index(f'CMakeFiles/X11_backend.dir/X11.backend/{name}.m.o')] = str(obj)
@@ -48,4 +48,23 @@ subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FL
str(project / 'tools/riot-fullscreen-geometry-check.m'), '-o', str(probe)], check=True)
subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld',
'-nostdlib', '-isysroot', sdk, str(probe), '-lSystem', '-lobjc', '-framework', 'Foundation',
'-o', str(project / 'build/riot-fullscreen-geometry-check')], check=True)
'-o', str(project / 'build/riot-fullscreen-geometry-check')], check=True)
# Query the actual native hints after Cocoa title/style changes, without mapping windows.
probe = project / 'build/riot-window-decorations-check.o'
subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'],
'-O3', '-march=native', '-mtune=generic', '-UNDEBUG', '-Wno-macro-redefined', '-c',
str(project / 'tools/riot-window-decorations-check.m'), '-o', str(probe)], check=True)
subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld',
'-nostdlib', '-isysroot', sdk, str(probe), '-lSystem', '-lobjc', '-framework', 'AppKit',
'-framework', 'Foundation', '-framework', 'CoreGraphics',
'-o', str(project / 'build/riot-window-decorations-check')], check=True)
# Match native AppKit's local event monitor dispatch semantics.
probe = project / 'build/riot-event-monitors-check.o'
subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'],
'-O3', '-march=native', '-mtune=generic', '-Wno-macro-redefined', '-c',
str(project / 'tools/riot-event-monitors-check.m'), '-o', str(probe)], check=True)
subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld',
'-nostdlib', '-isysroot', sdk, str(probe), '-lSystem', '-lobjc', '-framework', 'AppKit',
'-framework', 'Foundation', '-o', str(project / 'build/riot-event-monitors-check')], check=True)
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""Remove orphaned client-in-game helpers from an earlier launcher run.
The close/hide helpers are long-lived watch processes. A launcher can end
while they remain, and a later run starts another helper, so several copies
watch the same prefix. This opt-in preflight removes only helpers that belong
to this prefix and whose parent is init/systemd: the launcher that started
them has already exited and reparented them. It never signals a helper whose
parent is still alive, the current launcher, darlingserver, the game,
Riot/League/CEF, or a process of another prefix.
It fails closed: an unreadable command line, environment, stat entry or
parent leaves that process alone. Enable it with
RCLOL_CLEAN_STALE_HELPERS=1; the launcher runs it before starting its own
helper.
"""
import argparse
import os
import signal
import sys
import time
from pathlib import Path
HELPER_NAMES = ('close-league-client-in-game.py',
'hide-league-client-in-game.py')
REAPER_NAMES = ('init', 'systemd')
TERM_GRACE = 3.0
def target_prefix():
"""The prefix this launcher owns, or None when it cannot be determined."""
value = os.environ.get('DPREFIX', str(Path.home() / '.darling'))
prefix = Path(value).expanduser()
return prefix if prefix.is_absolute() else None
def process_info(proc):
"""Return (state, ppid, start_ticks), or None when stat is unreadable."""
try:
fields = (proc / 'stat').read_text().rsplit(')', 1)[1].split()
if len(fields) < 20:
return None
return fields[0], int(fields[1]), int(fields[19])
except (OSError, ValueError, IndexError):
return None
def parent_is_reaper(ppid, proc_root):
"""True when the parent is PID 1 or a systemd/init user manager."""
if ppid == 1:
return True
try:
comm = (proc_root / str(ppid) / 'comm').read_text().strip()
except OSError:
return False
return comm in REAPER_NAMES
def helper_identity(proc, prefix, proc_root):
"""Return the start ticks of an orphaned helper of prefix, or None.
The script name must be an exact argument, and the executable must be a
Python interpreter. This avoids matching an editor or a log viewer that
happens to have the helper name in its arguments. DPREFIX is inherited by
the helper from run-riot-client.sh; a missing or different value belongs
to another install and is preserved.
"""
try:
if proc.stat().st_uid != os.getuid():
return None
raw = (proc / 'cmdline').read_bytes()
argv = [part.decode(errors='replace') for part in raw.split(b'\0') if part]
if len(argv) < 2 or not os.path.basename(argv[0]).startswith(('python', 'pypy')):
return None
if not any(os.path.basename(arg) in HELPER_NAMES for arg in argv[1:]):
return None
environment = dict(
item.split(b'=', 1)
for item in (proc / 'environ').read_bytes().split(b'\0')
if b'=' in item
)
raw_prefix = environment.get(b'DPREFIX')
if raw_prefix is None:
return None
helper_prefix = Path(os.fsdecode(raw_prefix)).expanduser()
if not helper_prefix.is_absolute():
return None
helper_prefix = helper_prefix.resolve()
if helper_prefix != prefix:
return None
info = process_info(proc)
if info is None or info[0] == 'Z':
return None
if not parent_is_reaper(info[1], proc_root):
return None
return info[2]
except FileNotFoundError:
return None
except (OSError, RuntimeError, ValueError):
return None
def clean(prefix, proc_root=Path('/proc'), kill=os.kill, sleep=time.sleep,
out=sys.stdout, dry_run=False):
"""Terminate orphaned helpers for prefix; return 0 on every normal path."""
if prefix is None or not prefix.is_absolute():
print('clean-stale-helpers: unusable DPREFIX: no action', file=out, flush=True)
return 1
try:
prefix = prefix.expanduser().resolve()
except (OSError, RuntimeError):
print('clean-stale-helpers: unusable DPREFIX: no action', file=out, flush=True)
return 1
try:
entries = list(proc_root.iterdir())
except OSError:
print('clean-stale-helpers: unreadable /proc: no action', file=out, flush=True)
return 0
helpers = {}
for proc in entries:
if not proc.name.isdigit() or int(proc.name) == os.getpid():
continue
start_ticks = helper_identity(proc, prefix, proc_root)
if start_ticks is not None:
helpers[int(proc.name)] = start_ticks
if not helpers:
print('clean-stale-helpers: no orphaned helpers; nothing to do',
file=out, flush=True)
return 0
pids = sorted(helpers)
action = 'would remove' if dry_run else 'removing'
print('clean-stale-helpers: %s %d orphaned helper(s): %s'
% (action, len(pids), ','.join(str(pid) for pid in pids)),
file=out, flush=True)
if dry_run:
return 0
for pid, start_ticks in helpers.items():
proc = proc_root / str(pid)
if helper_identity(proc, prefix, proc_root) != start_ticks:
continue
try:
kill(pid, signal.SIGTERM)
except OSError:
continue
sleep(TERM_GRACE)
for pid, start_ticks in helpers.items():
proc = proc_root / str(pid)
if helper_identity(proc, prefix, proc_root) != start_ticks:
continue
try:
kill(pid, signal.SIGKILL)
except OSError:
pass
return 0
def main():
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--prefix', type=Path,
help='prefix whose helpers should be checked')
parser.add_argument('--dry-run', action='store_true',
help='report what would be removed without signalling')
args = parser.parse_args()
if os.environ.get('RCLOL_CLEAN_STALE_HELPERS') != '1':
print('clean-stale-helpers: disabled; set RCLOL_CLEAN_STALE_HELPERS=1',
flush=True)
return 0
prefix = args.prefix or target_prefix()
if prefix is None or not prefix.is_absolute():
print('clean-stale-helpers: unusable DPREFIX: no action',
file=sys.stderr, flush=True)
return 1
return clean(prefix, dry_run=args.dry_run)
if __name__ == '__main__':
sys.exit(main())
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Resolve the client-in-game mode from League's local preferences.
The public launcher switch is RCLOL_CLIENT_IN_GAME_TRICKS=1. It reads
`lol-user-experience` from LCULocalPreferences.yaml:
- potatoModeEnabled must be true (client optimizations enabled);
- closeLeagueClientDuringGame true -> close the client UI while a match runs;
- closeLeagueClientDuringGame false -> hide the client windows instead;
- missing or invalid closeLeagueClientDuringGame or potatoModeEnabled -> no action
(none).
Only the named `lol-user-experience` section is considered, so identical keys
in another section cannot select a helper. This helper only selects a mode; it
does not change preferences or files.
"""
import sys
from pathlib import Path
SECTION = 'lol-user-experience'
CLOSE_KEY = 'closeLeagueClientDuringGame'
OPTIMIZATION_KEY = 'potatoModeEnabled'
TRUE_VALUES = {'true', 'yes', '1'}
FALSE_VALUES = {'false', 'no', '0'}
def parse_bool(value):
"""Return True/False/None for one YAML scalar, ignoring a trailing comment."""
value = value.strip()
if not value:
return None
if value[0] in ('"', "'"):
quote = value[0]
end = value.find(quote, 1)
if end == -1:
return None
scalar = value[1:end]
remainder = value[end + 1:]
if remainder and not remainder[0].isspace():
return None
remainder = remainder.strip()
if remainder and not remainder.startswith('#'):
return None
else:
scalar = value
for index, char in enumerate(scalar):
if char == '#' and (index == 0 or scalar[index - 1].isspace()):
scalar = scalar[:index].rstrip()
break
scalar = scalar.lower()
if scalar in TRUE_VALUES:
return True
if scalar in FALSE_VALUES:
return False
return None
def read_bool(path, key, section=SECTION):
"""Return True/False for the last key in section, or None when invalid.
OSError/UnicodeError is raised for an unreadable file so callers can fail
closed instead of treating a read race as a missing preference.
"""
lines = Path(path).read_text().splitlines()
in_section = False
value = None
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith('#'):
continue
if not line.startswith((' ', '\t')):
if stripped == section + ':':
in_section = True
elif in_section:
break
continue
if not in_section or not stripped.startswith(key + ':'):
continue
value = parse_bool(stripped.split(':', 1)[1])
return value
def mode(path):
try:
optimization = read_bool(path, OPTIMIZATION_KEY)
except (OSError, UnicodeError):
return 'none'
if optimization is not True:
return 'none'
try:
close = read_bool(path, CLOSE_KEY)
except (OSError, UnicodeError):
return 'none'
if close is True:
return 'close'
if close is False:
return 'hide'
return 'none'
def main(argv=None):
argv = sys.argv[1:] if argv is None else argv
path = argv[0] if argv else ''
print(mode(path))
return 0
if __name__ == '__main__':
sys.exit(main())
+269
View File
@@ -0,0 +1,269 @@
#!/usr/bin/env python3
"""Close the League client UI while a match runs; fail closed.
Opt-in helper for `run-riot-client.sh` (`RCLOL_CLOSE_CLIENT_IN_GAME=1`). The
League client keeps its Chromium/CEF UI (executable `LeagueClientUx` and its
helpers) alive during a match. Those processes compete with the game for CPU,
and the client the backend starts when the match ends is a second CEF instance
on the same `--user-data-dir` as the pre-match one, so its window never paints:
the DOM and audio work, the window stays black. Riot's own "Close client during
game" option has both outcomes on a Windows client, but under Darling it leaves
the pre-match instance running, so the option reproduces the black window.
This helper performs the close itself, from the host, while a match is running.
Only then is the client UI expendable, and the client that appears afterwards is
the only instance. It never touches the Riot client, the League backend
(`LeagueClient`) or the game, and it exits by itself when no client is left.
It is fail closed: it acts only after positively identifying a running game and
the League client UI of its own prefix, and never closes a client younger than
MIN_AGE_SECONDS (the one the backend starts after a match). An unreadable
`/proc` entry, an unreadable environment, an unreadable or malformed stat field,
a process of another install and a missing game all lead to no action.
The client UI is recognised by the name of its executable, not by the command
line: `LeagueCrashHandler` processes carry the `LeagueClientUx` string in their
arguments (attachment paths under `LeagueClientUx Logs`) and live under the same
`/League` paths, so a wider match would signal a related process.
Diagnose with `--once` (one observation) or `--once --dry-run` (report only).
"""
import argparse
import math
import os
from pathlib import Path
import signal
import sys
import time
UX_NAME = 'LeagueClientUx'
GAME_SUFFIX = '/LeagueofLegends'
# DPREFIX is not in the environment of guest processes (checked on a live session), but
# every process the loader starts carries the socket of the install it belongs to.
SOCK_VARIABLE = b'__mldr_sockpath'
SOCK_NAME = '.darlingserver.sock'
MIN_AGE_SECONDS = 60 # a younger client UI is the one starting after a match
TERM_GRACE = 4.0 # seconds between SIGTERM and SIGKILL
KILL_GRACE = 2.0
POLL_SECONDS = 5.0
COOLDOWN_SECONDS = 30.0 # minimum between two closes in the same match
MAX_CLOSES = 3 # give up on a client that keeps reopening during a match
IDLE_SECONDS = 600.0 # leave after this long without a client or a game, once one was seen
def target_prefix():
"""The prefix this launcher owns, or None when it cannot be determined."""
value = os.environ.get('DPREFIX', str(Path.home() / '.darling'))
prefix = Path(value).expanduser()
return prefix if prefix.is_absolute() else None
def is_client_ui(executable):
"""True for the League client UI and its helpers, by executable name.
The whole command line is not usable: `LeagueCrashHandler` carries the
`LeagueClientUx` string in its arguments (attachment paths under
`LeagueClientUx Logs`) and lives under the same `/League` paths, so only the
file name tells the UI apart from a related process.
"""
name = os.path.basename(executable)
return name == UX_NAME or name.startswith(UX_NAME + ' Helper')
def match_state(proc_root, prefix):
"""Return {'game': bool, 'ui': {pid: start_ticks}}, or None if not reliable.
None means the caller must not act. The game and the client UI are both
attributed to `prefix` through the loader's socket path: a process of
another install is not a match and its client is not touched.
"""
state = {'game': False, 'ui': {}}
owner = (prefix / SOCK_NAME).parent.resolve()
try:
uptime = float((proc_root / 'uptime').read_text().split()[0])
ticks = os.sysconf('SC_CLK_TCK')
if not math.isfinite(uptime) or uptime < 0 or ticks <= 0:
return None
entries = list(proc_root.iterdir())
except (OSError, ValueError, IndexError):
return None
for proc in entries:
if not proc.name.isdigit():
continue
try:
if proc.stat().st_uid != os.getuid():
continue
raw = (proc / 'cmdline').read_bytes()
executable = raw.split(b'\0')[0].decode(errors='replace')
client_ui = is_client_ui(executable)
if not client_ui and not executable.endswith(GAME_SUFFIX):
continue
environment = dict(item.split(b'=', 1) for item in
(proc / 'environ').read_bytes().split(b'\0') if b'=' in item)
socket = environment.get(SOCK_VARIABLE)
if socket is None or Path(os.fsdecode(socket)).parent.resolve() != owner:
continue
if not client_ui:
state['game'] = True
continue
# Field 22 follows a comm field that may contain spaces/parentheses.
start_ticks = int((proc / 'stat').read_text().rsplit(')', 1)[1].split()[19])
if start_ticks < 0 or uptime - start_ticks / ticks < MIN_AGE_SECONDS:
continue
state['ui'][proc.name] = start_ticks
except FileNotFoundError:
if proc.exists():
return None
continue
except (OSError, ValueError, IndexError):
return None
return state
def ui_identity(proc_root, pid):
"""Start ticks of a live client UI process, or None when it is not one."""
try:
raw = (proc_root / pid / 'cmdline').read_bytes()
executable = raw.split(b'\0')[0].decode(errors='replace')
if not is_client_ui(executable):
return None
return int((proc_root / pid / 'stat').read_text().rsplit(')', 1)[1].split()[19])
except (OSError, ValueError, IndexError):
return None
def close_ui(proc_root, processes):
"""Terminate the given client UI processes; return the pids signalled.
Each pid is rechecked, including its start time, before every signal: a
process that exited, a reused pid and a process that is no longer a client
UI are all left alone.
"""
closed = set()
for sig, grace in ((signal.SIGTERM, TERM_GRACE), (signal.SIGKILL, KILL_GRACE)):
for pid, start_ticks in processes.items():
if ui_identity(proc_root, pid) != start_ticks:
continue
try:
os.kill(int(pid), sig)
closed.add(pid)
except OSError:
continue
time.sleep(grace)
return closed
def close_decision(state, closes, last_close, now):
"""Return 'close', 'limit' or None for one observation."""
if not state['game'] or not state['ui']:
return None
if closes >= MAX_CLOSES:
return 'limit'
# last_close is None until the first close: monotonic time counts from boot,
# so a 0.0 start would block closes for the first COOLDOWN_SECONDS of uptime.
if last_close is not None and now - last_close < COOLDOWN_SECONDS:
return None
return 'close'
def update_idle(state, seen, idle_since, now):
"""Return (seen, idle_since) after one observation.
The idle clock only starts once a client UI or a match has been seen: a long
patch or login can keep the League client closed for more than IDLE_SECONDS
after launch, and leaving then would skip the match that follows.
"""
active = state['game'] or bool(state['ui'])
seen = seen or active
if active or not seen:
return seen, None
return seen, now if idle_since is None else idle_since
def supervise(proc_root=Path('/proc'), prefix=None, once=False, dry_run=False, out=sys.stdout):
"""Watch for a match and close the client UI while it runs."""
prefix = prefix or target_prefix()
if prefix is None:
print('close-league-client-in-game: unusable DPREFIX: no action', file=out, flush=True)
return 0
print('close-league-client-in-game: watching %s for a running match; '
'opt out with RCLOL_CLOSE_CLIENT_IN_GAME=0' % prefix, file=out, flush=True)
in_game = False
closes = 0
last_close = None
limit_reported = False
seen = False
idle_since = None
while True:
state = match_state(proc_root, prefix)
now = time.monotonic()
if state is None:
print('close-league-client-in-game: unreadable /proc state: no action',
file=out, flush=True)
else:
if state['game'] and not in_game:
in_game = True
closes = 0
last_close = None
limit_reported = False
print('close-league-client-in-game: a match started', file=out, flush=True)
elif not state['game'] and in_game:
in_game = False
print('close-league-client-in-game: the match ended; the client can reopen',
file=out, flush=True)
decision = close_decision(state, closes, last_close, now)
if decision == 'limit':
if not limit_reported:
print('close-league-client-in-game: the client reopened %d times in one '
'match: no further action' % closes, file=out, flush=True)
limit_reported = True
elif decision == 'close':
pids = sorted(state['ui'], key=int)
if dry_run:
print('close-league-client-in-game: would close the client UI (%d '
'processes: %s)' % (len(pids), ','.join(pids)), file=out, flush=True)
else:
closed = close_ui(proc_root, state['ui'])
if closed:
closes += 1
last_close = time.monotonic()
print('close-league-client-in-game: closed the client UI for the '
'running match (%d processes: %s)'
% (len(closed), ','.join(sorted(closed, key=int))),
file=out, flush=True)
seen, idle_since = update_idle(state, seen, idle_since, now)
if once:
if state is not None:
print('close-league-client-in-game: one observation: match=%s client_ui=%d'
% ('running' if state['game'] else 'absent', len(state['ui'])),
file=out, flush=True)
return 0
if idle_since is not None and time.monotonic() - idle_since > IDLE_SECONDS:
print('close-league-client-in-game: no client or match for %ds: leaving'
% IDLE_SECONDS, file=out, flush=True)
return 0
time.sleep(POLL_SECONDS)
return 0
def main():
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--once', action='store_true',
help='one observation and exit (diagnostics)')
parser.add_argument('--dry-run', action='store_true',
help='report what it would close, without signalling anything')
parser.add_argument('--detach', action='store_true',
help='leave the launcher session so a terminal hang-up cannot end it')
args = parser.parse_args()
if args.detach:
try:
os.setsid()
except OSError:
pass
return supervise(once=args.once, dry_run=args.dry_run)
if __name__ == '__main__':
sys.exit(main())
+27 -3
View File
@@ -182,13 +182,23 @@ def save(prefix, properties):
return atomic_write(destination(prefix), encoded)
def has_known_defaults(properties):
"""True when an identity still carries the shared serial, UUID, or a field derived from that UUID."""
return (properties.get('IOPlatformSerialNumber') == KNOWN_SERIAL
or str(properties.get('IOPlatformUUID', '')).upper() == KNOWN_UUID
or str(properties.get('provisioning_UDID', '')).upper() == KNOWN_UUID
or properties.get('system-id') == uuid.UUID(KNOWN_UUID).bytes_le)
def provision(properties, rom_oui=None):
"""Fill missing identifiers, replacing only the two known shared defaults."""
result = validate(properties)
if result.get('IOPlatformSerialNumber') == KNOWN_SERIAL:
result.pop('IOPlatformSerialNumber')
if result.get('IOPlatformUUID', '').upper() == KNOWN_UUID:
result.pop('IOPlatformUUID')
if (result.get('IOPlatformUUID', '').upper() == KNOWN_UUID
or result.get('provisioning_UDID', '').upper() == KNOWN_UUID
or result.get('system-id') == uuid.UUID(KNOWN_UUID).bytes_le):
result.pop('IOPlatformUUID', None)
result.pop('system-id', None)
result.pop('provisioning_UDID', None)
for key, length in [('IOPlatformSerialNumber', 12), ('mlb-serial-number', 17)]:
@@ -274,8 +284,14 @@ def main(argv=None):
parser.add_argument('--provision', action='store_true', help='Generate missing synthetic identifiers and provision prefix metadata')
parser.add_argument('--runtime-root', type=Path, help='Runtime supplying SystemVersion.plist (default: saved SDK or /usr/local/libexec/darling)')
parser.add_argument('--rom-oui', help='Explicit three-byte unicast OUI for a missing ROM; default is locally administered')
parser.add_argument('--replace-known-defaults', action='store_true',
help='Provision only if the prefix identity still has the shared serial or UUID; otherwise change nothing')
parser.add_argument('--write', action='store_true', help='Write validated overrides to a stopped prefix (otherwise preview only)')
args = parser.parse_args(argv)
if args.replace_known_defaults:
if args.source or args.set:
parser.error('--replace-known-defaults cannot be combined with --import or --set')
args.provision = True
config = Path(__file__).resolve().parents[1] / 'build/config.json'
saved = json.loads(config.read_text()) if config.exists() else {}
prefix = args.prefix or Path(os.environ.get('DPREFIX') or saved.get('prefix', str(Path.home() / '.darling')))
@@ -288,11 +304,19 @@ def main(argv=None):
rom_oui = bytes.fromhex(args.rom_oui.replace(':', '').replace('-', ''))
if len(rom_oui) != 3 or rom_oui[0] & 1:
raise ValueError('--rom-oui requires exactly three unicast bytes')
identity_files = [checked_path(prefix, 'private/etc/darling-smbios.conf'), destination(prefix)]
if args.replace_known_defaults and not any(path.exists() for path in identity_files):
print('No saved platform identity; nothing to replace.')
return 0
with prefix_lock(prefix) if args.write else nullcontext():
path = destination(prefix)
properties = {}
if args.replace_known_defaults and not any(
path.exists() and has_known_defaults(load(path)) for path in identity_files):
print('Platform identity has no shared defaults; nothing changed.')
return 0
if args.provision:
legacy = checked_path(prefix, 'private/etc/darling-smbios.conf')
legacy = identity_files[0]
if legacy.exists():
properties.update(load(legacy))
if path.exists():
+83 -3
View File
@@ -1,6 +1,8 @@
#!/bin/sh
# system_profiler for the Darling prefix: reports the host's real display and
# network hardware for the data types applications query at startup.
# network hardware for the data types applications query at startup. The hardware
# and software overviews come from the guest's own IOKit, sysctl and sw_vers, so they
# match what other APIs report (including a configure-darling-platform.py override).
# Installed as /usr/sbin/system_profiler by tools/install-darling-system-profiler.py.
host=/Volumes/SystemRoot
xml=0
@@ -9,12 +11,12 @@ while [ $# -gt 0 ]; do
case "$1" in
-xml) xml=1;;
-detailLevel|-timeout) shift;;
-listDataTypes) printf 'Available Datatypes:\nSPAirPortDataType\nSPDisplaysDataType\nSPEthernetDataType\n'; exit 0;;
-listDataTypes) printf 'Available Datatypes:\nSPAirPortDataType\nSPDisplaysDataType\nSPEthernetDataType\nSPHardwareDataType\nSPSoftwareDataType\n'; exit 0;;
SP*DataType) types="$types $1";;
esac
shift
done
[ -n "$types" ] || types=" SPDisplaysDataType SPEthernetDataType SPAirPortDataType"
[ -n "$types" ] || types=" SPHardwareDataType SPSoftwareDataType SPDisplaysDataType SPEthernetDataType SPAirPortDataType"
escape() { sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g'; }
@@ -46,6 +48,80 @@ displays() { # one line per GPU: vendor|device|vram MB or empty|name
done
}
load_platform() { # IOPlatformExpertDevice string properties, "key|value" per line
# String values print as "value", NUL-terminated data as <"value">; other data is skipped.
platform_properties=$(ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null |
sed -n 's/^[ |]*"\([^"]*\)" = <\{0,1\}"\(.*\)">\{0,1\}$/\1|\2/p')
}
platform() {
printf '%s\n' "$platform_properties" | awk -v key="$1" 'index($0, key "|") == 1 { print substr($0, length(key) + 2); exit }'
}
machine_name() { # model identifier -> marketing family, as in About This Mac
case "$1" in
MacBookPro*) echo 'MacBook Pro';; MacBookAir*) echo 'MacBook Air';; MacBook*) echo 'MacBook';;
iMacPro*) echo 'iMac Pro';; iMac*) echo 'iMac';; Macmini*) echo 'Mac mini';; MacPro*) echo 'Mac Pro';;
*) echo 'Mac';;
esac
}
hardware() { # label|xml key|xml type|value for each known field
load_platform
model=$(platform model)
cores=$(sysctl -n hw.physicalcpu 2>/dev/null)
memory=$(sysctl -n hw.memsize 2>/dev/null | awk '$1 > 0 { printf "%d GB", $1 / 1073741824 + 0.5 }')
speed=$(sysctl -n hw.cpufrequency 2>/dev/null | awk '$1 > 0 { printf "%.1f GHz", $1 / 1e9 }')
l2=$(sysctl -n hw.l2cachesize 2>/dev/null | awk '$1 > 0 { printf "%d KB", $1 / 1024 }')
l3=$(sysctl -n hw.l3cachesize 2>/dev/null | awk '$1 > 0 { printf "%d MB", $1 / 1048576 }')
echo "Model Name|machine_name|string|${model:+$(machine_name "$model")}"
echo "Model Identifier|machine_model|string|$model"
echo "Processor Name|cpu_type|string|$(sysctl -n machdep.cpu.brand_string 2>/dev/null)"
echo "Processor Speed|current_processor_speed|string|$speed"
echo "Number of Processors|packages|integer|$(sysctl -n hw.packages 2>/dev/null)"
echo "Total Number of Cores|number_processors|integer|$cores"
echo "L2 Cache (per Core)|l2_cache_core|string|$l2"
echo "L3 Cache|l3_cache|string|$l3"
echo "Memory|physical_memory|string|$memory"
echo "Serial Number (system)|serial_number|string|$(platform IOPlatformSerialNumber)"
echo "Hardware UUID|platform_UUID|string|$(platform IOPlatformUUID)"
echo "Provisioning UDID|provisioning_UDID|string|$(platform provisioning_UDID)"
}
software() {
name=$(sw_vers -productName 2>/dev/null); version=$(sw_vers -productVersion 2>/dev/null)
build=$(sw_vers -buildVersion 2>/dev/null)
echo "System Version|os_version|string|${version:+${name:-macOS} $version${build:+ ($build)}}"
echo "Kernel Version|kernel_version|string|$(uname -sr 2>/dev/null)"
echo "Boot Volume|boot_volume|string|Macintosh HD"
echo "Boot Mode|boot_mode|string|normal_boot"
echo "Computer Name|local_host_name|string|$(hostname -s 2>/dev/null)"
echo "User Name|user_name|string|${USER:-$(id -un 2>/dev/null)}"
}
overview_xml() { # $1 = _name; fields on stdin; empty values are omitted
echo ' <dict>'
echo " <key>_name</key><string>$1</string>"
while IFS='|' read -r label key kind value; do
[ -n "$value" ] || continue
if [ "$kind" = integer ]; then
case "$value" in *[!0-9]*) continue;; esac
echo " <key>$key</key><integer>$value</integer>"
else
echo " <key>$key</key><string>$(printf '%s' "$value" | escape)</string>"
fi
done
echo ' </dict>'
}
overview_text() { # $1 = section title, $2 = overview title; fields on stdin
printf '%s:\n\n %s:\n\n' "$1" "$2"
while IFS='|' read -r label key kind value; do
[ "$key" != boot_mode ] || value=Normal
[ -z "$value" ] || printf ' %s: %s\n' "$label" "$value"
done
echo
}
interfaces() { # $1 = wired|wireless; physical host interfaces only
for path in "$host"/sys/class/net/*; do
[ -e "$path/device" ] || continue
@@ -65,6 +141,8 @@ if [ "$xml" = 1 ]; then
echo ' <key>_items</key>'
echo ' <array>'
case "$type" in
SPHardwareDataType) hardware | overview_xml hardware_overview;;
SPSoftwareDataType) software | overview_xml os_overview;;
SPDisplaysDataType)
displays | while IFS='|' read -r vendor device vram name; do
name=$(printf '%s' "$name" | escape)
@@ -108,6 +186,8 @@ fi
for type in $types; do
case "$type" in
SPHardwareDataType) hardware | overview_text Hardware 'Hardware Overview';;
SPSoftwareDataType) software | overview_text Software 'System Software Overview';;
SPDisplaysDataType)
printf 'Graphics/Displays:\n\n'
displays | while IFS='|' read -r vendor device vram name; do
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Create and install the GNOME / FreeDesktop desktop entry and launcher icon."""
import os
import shutil
import subprocess
from pathlib import Path
PROJECT_ROOT = Path("@PROJECT_ROOT@")
HOST_HOME = Path("@HOST_HOME@")
# Marks a launcher entry created by this project. Previous versions pointed
# Exec at this checkout's desktop-run.sh, so an entry without that marker
# belongs to someone else and is left alone.
LEGACY_RUN_SCRIPT = "scripts/desktop-run.sh"
# Distinctive fragments of the artwork the previous helper wrote.
LEGACY_SVG_MARKERS = ('id="goldGrad"', 'M172 108 L242 108')
# Characters that force Exec quoting, per the Desktop Entry specification.
EXEC_RESERVED = set(' \t\n"\'\\><~|&;$*?#()`')
def escape_string(value: str) -> str:
"""Escape a value of Desktop Entry type ``string``."""
return (value.replace("\\", "\\\\").replace("\t", "\\t")
.replace("\n", "\\n").replace("\r", "\\r"))
def quote_exec_argument(value: str) -> str:
"""Escape and quote one Exec argument as the specification requires."""
escaped = value.replace("%", "%%")
if not any(char in EXEC_RESERVED for char in escaped):
return escaped
escaped = "".join("\\" + char if char in '"`$\\' else char for char in escaped)
return f'"{escaped}"'
def legacy_entry_owned(path: Path) -> bool:
"""Return True when a launcher entry was created by this project."""
marker = str(PROJECT_ROOT / LEGACY_RUN_SCRIPT)
try:
text = path.read_text(errors="ignore")
except OSError:
# A dangling symlink still counts when it points at this checkout.
try:
text = os.readlink(path)
except OSError:
return False
return marker in text or marker.replace("%", "%%") in text
def remove_legacy_entry(path: Path) -> bool:
"""Remove a previous launcher entry only when this project created it."""
if not (path.is_symlink() or path.exists()):
return False
if not legacy_entry_owned(path):
return False
try:
path.unlink()
except OSError:
return False
return True
def legacy_svg_owned(path: Path) -> bool:
"""Return True when an icon is the artwork the previous helper wrote."""
try:
text = path.read_text(errors="ignore")
except OSError:
return False
return all(marker in text for marker in LEGACY_SVG_MARKERS)
def shortcut_directories() -> list[Path]:
"""Return the desktop directories that hold launcher shortcuts."""
directories: list[Path] = []
try:
result = subprocess.run(["xdg-user-dir", "DESKTOP"], capture_output=True, text=True, check=False)
if result.returncode == 0 and result.stdout.strip():
path = Path(result.stdout.strip())
if path.is_dir():
directories.append(path)
except Exception:
pass
for candidate in [HOST_HOME / "Desktop"]:
if candidate.is_dir() and candidate not in directories:
directories.append(candidate)
return directories
def main():
icons_base = HOST_HOME / ".local/share/icons"
apps_base = HOST_HOME / ".local/share/applications"
scalable_dir = icons_base / "hicolor/scalable/apps"
png_dir = icons_base / "hicolor/128x128/apps"
scalable_dir.mkdir(parents=True, exist_ok=True)
png_dir.mkdir(parents=True, exist_ok=True)
apps_base.mkdir(parents=True, exist_ok=True)
source_svg = PROJECT_ROOT / "darling-lol.svg"
source_png = PROJECT_ROOT / "darling-lol.png"
if not source_svg.is_file() or not source_png.is_file():
raise SystemExit("launcher icon is missing: expected darling-lol.svg and darling-lol.png")
# Install the project's original icon under a neutral name. Do not extract
# or install an icon from the game/client installation.
shutil.copyfile(source_svg, scalable_dir / "darling-launcher.svg")
shutil.copyfile(source_png, png_dir / "darling-launcher.png")
# Remove artifacts created by older versions of this helper. Only files
# proven to be ours are deleted: an entry that pointed at this checkout's
# desktop-run.sh, and the SVG with the old artwork. The old PNG was
# extracted from the client and cannot be verified, so it is removed only
# when one of those proofs succeeded.
owned = remove_legacy_entry(apps_base / "league-of-legends.desktop")
desktop_dirs = shortcut_directories()
for directory in desktop_dirs:
owned = remove_legacy_entry(directory / "league-of-legends.desktop") or owned
legacy_svgs = (scalable_dir / "league-of-legends.svg", icons_base / "league-of-legends.svg")
if any(legacy_svg_owned(path) for path in legacy_svgs):
owned = True
if owned:
for legacy_icon in (
*legacy_svgs,
icons_base / "league-of-legends.png",
icons_base / "hicolor/512x512/apps/league-of-legends.png",
):
try:
legacy_icon.unlink(missing_ok=True)
except OSError:
pass
# Create the desktop entry. Keep StartupWMClass: Darling's AppKit sets
# WM_CLASS from -[NSProcessInfo processName], which is the basename of
# argv[0], and the launcher starts .../RiotClientServices. It is an
# interoperability identifier, not a user-visible name.
# Terminal stays false here because this entry runs desktop-run.sh, which
# saves session output in build/desktop-launch.log; the packaged entry runs
# the session directly and therefore needs a terminal.
exec_cmd = quote_exec_argument(str(PROJECT_ROOT / LEGACY_RUN_SCRIPT))
project_path = escape_string(str(PROJECT_ROOT))
desktop_content = f"""[Desktop Entry]
Version=1.0
Type=Application
Name=Darling Launcher
GenericName=Game
Comment=Unofficial launcher for the macOS game client via Darling
Exec={exec_cmd}
Path={project_path}
Icon=darling-launcher
Terminal=false
Categories=Game;
Keywords=Darling;Launcher;Compatibility;
StartupWMClass=RiotClientServices
StartupNotify=false
"""
desktop_file = apps_base / "darling-launcher.desktop"
desktop_file.write_text(desktop_content)
os.chmod(desktop_file, 0o755)
print(f"Created application launcher: {desktop_file}")
for directory in desktop_dirs:
desktop_shortcut = directory / "darling-launcher.desktop"
desktop_shortcut.write_text(desktop_content)
os.chmod(desktop_shortcut, 0o755)
gio = shutil.which("gio")
if gio:
subprocess.run([gio, "set", str(desktop_shortcut), "metadata::trusted", "true"], check=False)
print(f"Created desktop shortcut: {desktop_shortcut}")
# Refresh icon and desktop databases
update_desktop = shutil.which("update-desktop-database")
if update_desktop:
subprocess.run([update_desktop, str(apps_base)], check=False)
update_icons = shutil.which("gtk-update-icon-cache")
if update_icons:
subprocess.run([update_icons, "-f", "-t", str(icons_base / "hicolor")], check=False)
print("GNOME application launcher and icon installed successfully.")
if __name__ == "__main__":
main()
+32 -25
View File
@@ -2,7 +2,7 @@
"""Clear a dead Darling server's stale PID marker, and stale shared buffers once no server runs."""
import os
from pathlib import Path
import signal
import sys
import time
def recover_marker(prefix):
marker = prefix / '.init.pid'
@@ -38,39 +38,46 @@ def any_server_running(proc_root=Path('/proc')):
pass
return False
def stop_orphaned_launchd(proc_root=Path('/proc')):
"""A guest launchd that outlived its darlingserver cannot be shut down with `darling shutdown`
and makes the base install (tools/shutdown-user.sh) refuse to continue."""
def _matching_pids(kind, proc_root):
"""Find same-user candidates by guest executable; this does not establish ownership."""
uid = os.getuid()
for proc in proc_root.iterdir():
if not proc.name.isdigit():
continue
try:
if proc.stat().st_uid != uid or (proc / 'cmdline').read_bytes().rstrip(b'\0') != b'/sbin/launchd':
if proc.stat().st_uid != uid:
continue
os.kill(int(proc.name), signal.SIGKILL)
print(f'Stopped orphaned Darling launchd PID {proc.name}', flush=True)
argv = (proc / 'cmdline').read_bytes()
if kind == 'launchd':
matches = argv.rstrip(b'\0') == b'/sbin/launchd'
else:
executable = argv.split(b'\0', 1)[0].decode(errors='replace')
matches = (proc / 'comm').read_text().startswith('chrome_crashpad') and any(
bundle in executable for bundle in (
'/Riot Client.app/', '/League of Legends.app/',
'/League of Legends (TM) Client.app/'))
if matches:
yield proc.name
except (OSError, ValueError):
pass
continue
def stop_orphaned_launchd(proc_root=Path('/proc')):
"""Warn about launchd candidates without assuming prefix ownership or signalling them."""
pids = list(_matching_pids('launchd', proc_root))
if pids:
print(f'Preserving launchd candidate PIDs {", ".join(pids)}: prefix ownership '
'cannot be proved. If setup is blocked, shut down the original Darling '
'session or inspect the leftover processes manually.', file=sys.stderr, flush=True)
def stop_riot_crashpad(proc_root=Path('/proc')):
"""A Riot crash handler outliving its server holds the old session's ports; spare every other app's."""
uid = os.getuid()
for proc in proc_root.iterdir():
if not proc.name.isdigit():
continue
try:
if not (proc / 'comm').read_text().startswith('chrome_crashpad') or proc.stat().st_uid != uid:
continue
argv = (proc / 'cmdline').read_bytes().split(b'\0')
executable = argv[0].decode(errors='replace')
# Match the executable's bundle, not arbitrary arguments (a browser may mention Riot).
if any(bundle in executable for bundle in (
'/Riot Client.app/', '/League of Legends.app/', '/League of Legends (TM) Client.app/'
)):
os.kill(int(proc.name), signal.SIGTERM)
except (OSError, ValueError):
pass
"""Warn about Riot crashpad candidates without assuming prefix ownership or signalling them."""
pids = list(_matching_pids('crashpad', proc_root))
if pids:
print(f'Preserving Riot crashpad candidate PIDs {", ".join(pids)}: prefix ownership '
'cannot be proved. Use the original session to shut down leftover helpers.',
file=sys.stderr, flush=True)
def remove_owned(paths):
uid = os.getuid()
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Run a command without the host file descriptors this launcher inherited (anything above stderr).
With RCLOL_FILTER_HOST_ENV=1 it also drops host desktop-session variables first (see
host_session_variable). RCLOL_KEEP_HOST_ENV lists names to keep anyway, separated by spaces or commas.
"""
import os
import sys
# Desktop-session, service-manager, terminal and packaging metadata that nothing in the guest reads.
# Everything Darling and the host libraries it loads need is kept: DISPLAY, XAUTHORITY, XCURSOR_*,
# XDG_RUNTIME_DIR, XDG_DATA_DIRS and the other XDG base directories (the Vulkan loader searches
# them), DBUS_SESSION_BUS_ADDRESS, PULSE_*, PIPEWIRE_*, ALSA_*, DRI_PRIME, MESA_*, mesa_glthread,
# __GL_*, __NV_*, __EGL_*, VK_*, LIBGL_*, LD_PRELOAD (gamemoderun) and LD_LIBRARY_PATH.
SESSION_PREFIXES = (
'XDG_SESSION_', 'XDG_SEAT', 'XDG_VTNR', 'XDG_CURRENT_DESKTOP', 'XDG_MENU_PREFIX',
'GNOME_', 'KDE_', 'GDM', 'SSH_', 'SYSTEMD_', 'MEMORY_PRESSURE_', 'QT_', 'GTK_', 'GDK_',
'GIO_', 'GSETTINGS_', 'GJS_', 'FLATPAK_', 'SNAP_', 'KONSOLE_', 'TERM_PROGRAM', 'BASH_FUNC_',
)
SESSION_NAMES = {
'WAYLAND_DISPLAY', 'DESKTOP_SESSION', 'DESKTOP_STARTUP_ID', 'SESSION_MANAGER',
'INVOCATION_ID', 'JOURNAL_STREAM', 'MANAGERPID', 'MANAGERPIDFDID', 'NOTIFY_SOCKET',
'LISTEN_FDS', 'LISTEN_PID', 'LISTEN_FDNAMES', 'COLORTERM', 'VTE_VERSION', 'WINDOWID',
'WINDOWPATH', 'CHROME_DESKTOP', 'NO_AT_BRIDGE', 'MOTD_SHOWN', 'SNAP', 'APPIMAGE', 'APPDIR',
'OWD', 'VIRTUAL_ENV', 'DEBUGINFOD_URLS', 'LS_COLORS', 'COLORFGBG', 'OLDPWD', 'SHLVL',
}
GUEST_SHELL = '/bin/zsh'
def host_session_variable(name):
return name in SESSION_NAMES or name.startswith(SESSION_PREFIXES)
def filter_host_environment(environ):
"""Return the names removed from environ (a mutable mapping), honoring RCLOL_KEEP_HOST_ENV."""
keep = set(environ.get('RCLOL_KEEP_HOST_ENV', '').replace(',', ' ').split())
removed = sorted(name for name in environ if host_session_variable(name) and name not in keep)
for name in removed:
del environ[name]
# The host login shell (for example /bin/fish) does not exist in the guest.
if 'SHELL' not in keep:
environ['SHELL'] = GUEST_SHELL
return removed
def close_inherited_fds():
"""A terminal, IDE or service manager can leak descriptors; the guest must not inherit them."""
try:
names = os.listdir('/proc/self/fd')
except OSError:
os.closerange(3, 65536)
return
for name in names:
if name.isdigit() and int(name) > 2:
try:
os.close(int(name))
except OSError:
pass # the descriptor listdir used is already closed
def main(argv):
if len(argv) < 2:
sys.exit('usage: exec-guest.py COMMAND [ARGUMENT]...')
if os.environ.get('RCLOL_FILTER_HOST_ENV') == '1':
filter_host_environment(os.environ)
close_inherited_fds()
os.execvp(argv[1], argv[1:])
if __name__ == '__main__':
main(sys.argv)
+425
View File
@@ -0,0 +1,425 @@
#!/usr/bin/env python3
"""Opt-in: hide the League client UI while a match is running.
The launcher's `RCLOL_CLIENT_IN_GAME_TRICKS=1` mode selects this helper when
the client's own close-during-game preference is false. The low-level
`RCLOL_HIDE_CLIENT_IN_GAME=1` switch remains available for direct launches. It
hides the managed X11 windows for `LeagueClientUx` and `Riot Client`, and shows
them again when the game process exits. The Riot client, League backend, CEF
helpers and game keep running.
This is launcher/window management only. It does not patch client binaries,
CEF, anti-cheat or DRM, and it never hides the game window. It is off by
default, fails closed, and is intended for X11/Xwayland desktops.
The game is attributed to this prefix through the loader's
`__mldr_sockpath`, because guest processes do not carry `DPREFIX`. If proc,
ownership or window information cannot be read, no window is touched.
Diagnose one observation with:
python3 tools/hide-league-client-in-game.py --once
Enable it for a launcher run with:
RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh
"""
import argparse
import ctypes
import os
import re
import signal
import subprocess
import sys
import time
from pathlib import Path
GAME_NAME = 'LeagueofLegends'
SOCK_VARIABLE = b'__mldr_sockpath'
SOCK_NAME = '.darlingserver.sock'
POLL_SECONDS = 2.0
IDLE_SECONDS = 600
TARGET_CLASSES = ('leagueclientux', 'riot client')
def target_prefix():
"""The prefix this launcher owns, or None when it cannot be determined."""
value = os.environ.get('DPREFIX', str(Path.home() / '.darling'))
prefix = Path(value).expanduser()
return prefix if prefix.is_absolute() else None
def process_state(proc):
try:
return (proc / 'stat').read_text().rsplit(')', 1)[1].split()[0]
except (OSError, IndexError):
return None
def command_names(proc):
try:
raw = (proc / 'cmdline').read_bytes()
except OSError:
return []
return [
os.path.basename(part.decode(errors='replace'))
for part in raw.split(b'\0')
if part
]
def game_running(prefix, proc_root=Path('/proc')):
"""True/False for a game of this prefix; None when /proc is unreliable.
The game executable is matched by its exact file name. The process must
carry the loader socket of this prefix, so a game from another install is
never considered. Any unreadable candidate makes the result None (fail
closed).
"""
owner = (prefix / SOCK_NAME).parent.resolve()
try:
entries = list(proc_root.iterdir())
except OSError:
return None
for proc in entries:
if not proc.name.isdigit() or process_state(proc) in ('Z', None):
continue
try:
if proc.stat().st_uid != os.getuid():
continue
if GAME_NAME not in command_names(proc):
continue
environment = dict(
item.split(b'=', 1)
for item in (proc / 'environ').read_bytes().split(b'\0')
if b'=' in item
)
socket = environment.get(SOCK_VARIABLE)
if socket is None:
continue
if Path(os.fsdecode(socket)).parent.resolve() != owner:
continue
return True
except FileNotFoundError:
if proc.exists():
return None
continue
except (OSError, ValueError):
return None
return False
def xprop_root():
try:
listing = subprocess.check_output(
['xprop', '-root', '_NET_CLIENT_LIST'], text=True, timeout=5
)
except (OSError, subprocess.SubprocessError):
return []
return re.findall(r'0x[0-9a-fA-F]+', listing)
def xprop_value(window, prop):
try:
return subprocess.check_output(
['xprop', '-id', window, prop], text=True, timeout=5
)
except (OSError, subprocess.SubprocessError):
return ''
def window_class(window):
value = xprop_value(window, 'WM_CLASS').lower()
return value if '=' in value else ''
def window_pid(window):
match = re.search(r'=\s*(\d+)', xprop_value(window, '_NET_WM_PID'))
return int(match.group(1)) if match else None
def _socket_from_process(proc):
try:
environment = dict(
item.split(b'=', 1)
for item in (proc / 'environ').read_bytes().split(b'\0')
if b'=' in item
)
except (OSError, ValueError):
return None
return environment.get(SOCK_VARIABLE)
def host_socket_for_guest(guest_pid, proc_root=Path('/proc')):
"""Find the host process for a guest X11 PID and return its loader socket.
Darling's X11 window stores the guest PID in _NET_WM_PID, while the
process visible in /proc is the host loader. NSpid carries both IDs. The
direct /proc/<pid> case is checked too for a native process.
"""
direct = proc_root / str(guest_pid)
if direct.exists():
socket = _socket_from_process(direct)
if socket is not None:
return socket
sockets = set()
try:
entries = list(proc_root.iterdir())
except OSError:
return None
for proc in entries:
if not proc.name.isdigit():
continue
try:
status = (proc / 'status').read_text()
except OSError:
continue
for line in status.splitlines():
if not line.startswith('NSpid:'):
continue
if str(guest_pid) not in line.split()[1:]:
continue
socket = _socket_from_process(proc)
if socket is not None:
sockets.add(socket)
return sockets.pop() if len(sockets) == 1 else None
def window_belongs_to_prefix(window, prefix):
pid = window_pid(window)
if pid is None:
return False
socket = host_socket_for_guest(pid)
if socket is None:
return False
try:
return Path(os.fsdecode(socket)).parent.resolve() == (prefix / SOCK_NAME).parent.resolve()
except OSError:
return False
def target_windows(prefix, class_cache=None):
"""Managed client windows for this prefix; the game window is never selected.
WM_CLASS is cached only when non-empty. The cache is pruned when a window
leaves _NET_CLIENT_LIST; empty results are retried because a class can be
set after the window appears.
"""
current = []
for raw in xprop_root():
try:
current.append((int(raw, 16), raw))
except ValueError:
continue
current_ids = {window for window, _raw in current}
if class_cache is not None:
for stale in set(class_cache) - current_ids:
del class_cache[stale]
found = []
for window, raw in current:
classes = class_cache.get(window) if class_cache is not None else None
if not classes:
classes = window_class(raw)
if classes and class_cache is not None:
class_cache[window] = classes
if not classes or not any(name in classes for name in TARGET_CLASSES):
continue
if window_belongs_to_prefix(raw, prefix):
found.append(window)
return sorted(set(found))
class X11:
def __init__(self):
self.lib = ctypes.CDLL('libX11.so.6')
self.lib.XOpenDisplay.restype = ctypes.c_void_p
self.lib.XOpenDisplay.argtypes = [ctypes.c_char_p]
self.lib.XUnmapWindow.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
self.lib.XMapRaised.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
self.lib.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int]
self.lib.XCloseDisplay.argtypes = [ctypes.c_void_p]
error_type = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p)
self._error_handler = error_type(lambda _display, _event: 0)
self.lib.XSetErrorHandler.argtypes = [error_type]
self.lib.XSetErrorHandler.restype = ctypes.c_void_p
self.lib.XSetErrorHandler(self._error_handler)
self.display = self.lib.XOpenDisplay(None)
if not self.display:
raise RuntimeError('cannot open DISPLAY')
def unmap(self, window):
self.lib.XUnmapWindow(self.display, window)
self.lib.XSync(self.display, 0)
def show(self, window):
self.lib.XMapRaised(self.display, window)
self.lib.XSync(self.display, 0)
def close(self):
self.lib.XCloseDisplay(self.display)
class Watcher:
"""Hide while a match is running, restore on end, uncertainty or exit."""
def __init__(self, prefix, x11, window_source=target_windows,
state_source=game_running, sleep=time.sleep, out=sys.stdout,
clock=time.monotonic):
self.prefix = prefix
self.x11 = x11
self.window_source = window_source
self.state_source = state_source
self.sleep = sleep
self.out = out
self.clock = clock
self.hidden = set()
self.class_cache = {}
self.reported_no_window = False
self.seen = False
self.idle_since = None
def _log(self, message, stream=None):
try:
print(message, file=self.out if stream is None else stream, flush=True)
except Exception:
# A closed launcher pipe must not prevent window restoration.
pass
def _show_all(self, reason):
if not self.hidden:
return
self._log(f'hide-league-client-in-game: {reason}; showing the client')
for window in sorted(self.hidden):
try:
self.x11.show(window)
except Exception as error:
self._log(f'hide-league-client-in-game: cannot show 0x{window:x}: {error}',
sys.stderr)
continue
self._log(f'hide-league-client-in-game: shown 0x{window:x}')
self.hidden.clear()
def _hide_new(self):
windows = self.window_source(self.prefix, self.class_cache)
if not windows and not self.hidden and not self.reported_no_window:
self._log('hide-league-client-in-game: no managed client window; not hiding')
self.reported_no_window = True
for window in windows:
if window in self.hidden:
continue
self.hidden.add(window)
try:
self.x11.unmap(window)
except Exception as error:
self.hidden.discard(window)
self._log(f'hide-league-client-in-game: cannot hide 0x{window:x}: {error}',
sys.stderr)
continue
self._log(f'hide-league-client-in-game: hidden 0x{window:x}')
def _windows_present(self):
try:
return bool(self.window_source(self.prefix, self.class_cache))
except Exception:
# Uncertainty must not turn into an automatic exit.
return True
def run(self, max_steps=None):
steps = 0
while True:
now = self.clock()
state = self.state_source(self.prefix)
if state is None:
self._show_all('unreadable /proc state')
elif state:
self.seen = True
self.idle_since = None
if not self.hidden and self.reported_no_window:
self.reported_no_window = False
self._hide_new()
else:
self._show_all('the match ended')
self.reported_no_window = False
if self._windows_present():
self.seen = True
self.idle_since = None
elif self.seen:
if self.idle_since is None:
self.idle_since = now
if now - self.idle_since >= IDLE_SECONDS:
self._log('hide-league-client-in-game: no client or match for '
f'{IDLE_SECONDS}s: leaving')
return 0
steps += 1
if max_steps is not None and steps >= max_steps:
return 0
self.sleep(POLL_SECONDS)
def restore(self):
self._show_all('exiting')
def main(argv=None):
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--detach', action='store_true',
help='leave the launcher session so a terminal hang-up cannot end it')
parser.add_argument('--once', action='store_true',
help='one game observation and exit (no window changes)')
args = parser.parse_args(argv)
tricks = os.environ.get('RCLOL_CLIENT_IN_GAME_TRICKS') == '1'
if not args.once and not tricks and os.environ.get('RCLOL_CLOSE_CLIENT_IN_GAME') == '1':
print('hide-league-client-in-game: ignored; RCLOL_CLOSE_CLIENT_IN_GAME=1 takes precedence',
file=sys.stderr, flush=True)
return 0
if not args.once and not tricks and os.environ.get('RCLOL_HIDE_CLIENT_IN_GAME') != '1':
print('hide-league-client-in-game: disabled; set RCLOL_HIDE_CLIENT_IN_GAME=1 or '
'RCLOL_CLIENT_IN_GAME_TRICKS=1', flush=True)
return 0
prefix = target_prefix()
if prefix is None:
print('hide-league-client-in-game: unusable DPREFIX: no action',
file=sys.stderr, flush=True)
return 1
if args.once:
state = game_running(prefix)
print(f'hide-league-client-in-game: match={"unreadable" if state is None else "running" if state else "absent"}',
flush=True)
return 0
if args.detach:
try:
os.setsid()
except OSError:
pass
try:
x11 = X11()
except Exception as error:
print(f'hide-league-client-in-game: cannot open X11: {error}',
file=sys.stderr, flush=True)
return 1
watcher = Watcher(prefix, x11)
def stop(_signum, _frame):
raise KeyboardInterrupt
for signum in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
signal.signal(signum, stop)
optout = ('RCLOL_CLIENT_IN_GAME_TRICKS=0' if tricks
else 'RCLOL_HIDE_CLIENT_IN_GAME=0')
print('hide-league-client-in-game: watching %s for a running match; '
'opt out with %s' % (prefix, optout), flush=True)
try:
watcher.run()
except KeyboardInterrupt:
pass
finally:
watcher.restore()
x11.close()
return 0
if __name__ == '__main__':
sys.exit(main())
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""Partially recover a live Darling runtime with a stale client tree.
This is opt-in through RCLOL_RECOVER_STALE_CLIENT=1. It only acts when all
of these are true:
- a darlingserver is running;
- no League game process is running;
- no managed Riot/League client window is visible;
- one or more Riot/League client, CEF or crash-handler processes at least 60
seconds old are still present for this prefix.
It terminates only those client processes with SIGTERM/SIGKILL. The
darlingserver, launchd/mldr services, sockets and .init.pid stay intact so the
next launcher run can reuse the runtime. It never patches files or the game.
"""
import math
import os
import re
import signal
import subprocess
import sys
import time
from pathlib import Path
GAME_NAME = 'LeagueofLegends'
WINDOW_CLASSES = ('leagueclientux', 'riot client', 'riotclientservices')
MIN_AGE_SECONDS = 60
CLIENT_MARKERS = (
'/Applications/League of Legends.app',
'/Applications/Riot Client.app',
'/Users/Shared/Riot Games/Riot Client.app',
'RiotClientServices',
'Riot Client',
'LeagueClient',
'LeagueClientUx',
'LeagueCrashHandler',
)
RUNTIME_NAMES = ('darlingserver', 'darling')
SOCK_VARIABLE = b'__mldr_sockpath'
SOCK_NAME = '.darlingserver.sock'
def target_prefix():
value = os.environ.get('DPREFIX', str(Path.home() / '.darling'))
prefix = Path(value).expanduser()
return prefix if prefix.is_absolute() else None
def command_names(proc):
try:
raw = (proc / 'cmdline').read_bytes()
except OSError:
return []
return [
os.path.basename(part.decode(errors='replace'))
for part in raw.split(b'\0')
if part
]
def command_text(proc):
try:
raw = (proc / 'cmdline').read_bytes()
except OSError:
return ''
return b' '.join(part for part in raw.split(b'\0') if part).decode(
errors='replace')
def process_state(proc):
try:
return (proc / 'stat').read_text().rsplit(')', 1)[1].split()[0]
except (OSError, IndexError):
return None
def server_running(proc_root=Path('/proc')):
try:
entries = list(proc_root.iterdir())
except OSError:
return False
for proc in entries:
if not proc.name.isdigit():
continue
try:
if (proc / 'comm').read_text().strip() == 'darlingserver':
return True
except OSError:
pass
return False
def game_running(proc_root=Path('/proc')):
for proc in proc_root.iterdir() if proc_root.exists() else ():
if not proc.name.isdigit():
continue
names = command_names(proc)
if GAME_NAME in names:
return True
return False
def visible_client_window():
"""Return True when a managed client window is present.
A missing xprop/property or display is treated as 'cannot prove there is no
window' so we do not kill a possibly working client. xprop exits zero and
prints "not found" for a property the window manager did not set.
"""
try:
listing = subprocess.check_output(
['xprop', '-root', '_NET_CLIENT_LIST'], text=True, timeout=5
)
except (OSError, subprocess.SubprocessError):
return True
lowered = listing.lower()
if '_net_client_list' not in lowered or 'not found' in lowered:
return True
for raw in re.findall(r'0x[0-9a-fA-F]+', listing):
try:
classes = subprocess.check_output(
['xprop', '-id', raw, 'WM_CLASS'], text=True, timeout=5
).lower()
except (OSError, subprocess.SubprocessError):
return True
if 'not found' in classes or '=' not in classes:
return True
if any(name in classes for name in WINDOW_CLASSES):
return True
return False
def client_identity(prefix, pid, proc_root=Path('/proc')):
"""Start ticks of a live stale client for prefix, or None when not eligible."""
proc = proc_root / str(pid)
if process_state(proc) in ('Z', None):
return None
owner = (prefix / SOCK_NAME).parent.resolve()
try:
if proc.stat().st_uid != os.getuid():
return None
names = command_names(proc)
if GAME_NAME in names or any(name in RUNTIME_NAMES for name in names):
return None
text = command_text(proc)
if not any(marker in text for marker in CLIENT_MARKERS):
return None
environment = dict(
item.split(b'=', 1)
for item in (proc / 'environ').read_bytes().split(b'\0')
if b'=' in item
)
socket = environment.get(SOCK_VARIABLE)
if socket is None:
return None
if Path(os.fsdecode(socket)).parent.resolve() != owner:
return None
uptime = float((proc_root / 'uptime').read_text().split()[0])
ticks = os.sysconf('SC_CLK_TCK')
if not math.isfinite(uptime) or uptime < 0 or ticks <= 0:
return None
# Field 22 follows a comm field that may contain spaces/parentheses.
start_ticks = int((proc / 'stat').read_text().rsplit(')', 1)[1].split()[19])
if start_ticks < 0 or uptime - start_ticks / ticks < MIN_AGE_SECONDS:
return None
return start_ticks
except (OSError, ValueError, IndexError):
return None
def client_pids(prefix, proc_root=Path('/proc')):
"""Client/CEF PIDs belonging to prefix, excluding Darling and the game."""
found = []
try:
entries = list(proc_root.iterdir())
except OSError:
return []
for proc in entries:
if proc.name.isdigit() and client_identity(prefix, proc.name, proc_root) is not None:
found.append(int(proc.name))
return sorted(found)
def recover(prefix, proc_root=Path('/proc'), window_check=visible_client_window,
kill=os.kill, sleep=time.sleep, out=sys.stdout):
if not server_running(proc_root):
print('recover-stale-client: no darlingserver; normal startup', file=out, flush=True)
return 0
if game_running(proc_root):
print('recover-stale-client: game running; no action', file=out, flush=True)
return 0
if window_check():
print('recover-stale-client: client window present; no action', file=out, flush=True)
return 0
identities = {}
for pid in client_pids(prefix, proc_root):
start_ticks = client_identity(prefix, pid, proc_root)
if start_ticks is not None:
identities[pid] = start_ticks
if not identities:
print('recover-stale-client: no stale client processes; no action', file=out, flush=True)
return 0
if window_check():
print('recover-stale-client: client window appeared; no action', file=out, flush=True)
return 0
print('recover-stale-client: stale client tree without a window; '
'terminating %d process(es)' % len(identities), file=out, flush=True)
for pid, start_ticks in identities.items():
if client_identity(prefix, pid, proc_root) != start_ticks:
continue
try:
kill(pid, signal.SIGTERM)
except OSError:
pass
sleep(4)
for pid, start_ticks in identities.items():
if client_identity(prefix, pid, proc_root) != start_ticks:
continue
try:
kill(pid, signal.SIGKILL)
except OSError:
pass
return 0
def main():
if os.environ.get('RCLOL_RECOVER_STALE_CLIENT') != '1':
print('recover-stale-client: disabled; set RCLOL_RECOVER_STALE_CLIENT=1',
flush=True)
return 0
prefix = target_prefix()
if prefix is None:
print('recover-stale-client: unusable DPREFIX; no action',
file=sys.stderr, flush=True)
return 1
return recover(prefix)
if __name__ == '__main__':
sys.exit(main())
+179
View File
@@ -0,0 +1,179 @@
// Microphone path used by voice chat (Vivox/Chromium): AVFoundation audio authorization, the
// input device's channels, and an AUHAL input unit delivering recorded frames through
// AudioUnitRender(bus 1). Samples are only counted, never stored or printed.
#include <AudioToolbox/AudioUnit.h>
#include <CoreAudio/AudioHardware.h>
#include <objc/message.h>
#include <objc/runtime.h>
#include <dispatch/dispatch.h>
#include <atomic>
#include <cstdio>
#include <cstring>
#include <pthread.h>
#include <unistd.h>
extern "C" void* const AVMediaTypeAudio;
extern "C" void* const AVMediaTypeVideo;
static AudioUnit unit;
static std::atomic<unsigned> callbacks{0}, renderErrors{0}, nonIncreasing{0}, zeroFrames{0}, badBuffers{0};
static std::atomic<unsigned long long> totalFrames{0};
static double lastSample=-1;
static bool planar;
static unsigned interleavedChannels=1;
static float samples[2][2*4096];
static OSStatus input(void*,AudioUnitRenderActionFlags* flags,const AudioTimeStamp* time,UInt32 bus,UInt32 frames,AudioBufferList*) {
++callbacks;
if(!frames || frames>4096) {++zeroFrames;return noErr;}
if(!(time->mFlags&kAudioTimeStampSampleTimeValid) || time->mSampleTime<=lastSample) ++nonIncreasing;
lastSample=time->mSampleTime;
unsigned char storage[sizeof(AudioBufferList)+sizeof(AudioBuffer)];
auto* list=(AudioBufferList*)storage;
list->mNumberBuffers=planar?2:1;
for(unsigned b=0;b<list->mNumberBuffers;++b) {
list->mBuffers[b].mNumberChannels=planar?1:interleavedChannels;
list->mBuffers[b].mDataByteSize=frames*sizeof(float)*list->mBuffers[b].mNumberChannels;
list->mBuffers[b].mData=samples[b];
}
AudioUnitRenderActionFlags renderFlags=0;
OSStatus status=AudioUnitRender(unit,&renderFlags,time,bus,frames,list);
if(status) ++renderErrors;
else for(unsigned b=0;b<list->mNumberBuffers;++b)
if(list->mBuffers[b].mDataByteSize!=frames*sizeof(float)*list->mBuffers[b].mNumberChannels) ++badBuffers;
totalFrames+=frames;
return noErr;
}
static int authorization() {
Class device=objc_getClass("AVCaptureDevice");
if(!device) {printf("AUDIO_INPUT_AUTH class=0\n");return 1;}
SEL status=sel_registerName("authorizationStatusForMediaType:");
auto query=(long(*)(id,SEL,void*))objc_msgSend;
long audio=query((id)device,status,AVMediaTypeAudio),video=query((id)device,status,AVMediaTypeVideo);
// macOS calls the handler on another queue, never on the requesting thread inside the call.
// Shared with the handler's thread, so atomic; read only after the handler signals.
static std::atomic<int> granted{-1};
static std::atomic<bool> synchronous{false};
const pthread_t caller=pthread_self();
dispatch_semaphore_t done=dispatch_semaphore_create(0);
auto request=(void(*)(id,SEL,void*,void(^)(BOOL)))objc_msgSend;
request((id)device,sel_registerName("requestAccessForMediaType:completionHandler:"),AVMediaTypeAudio,^(BOOL value) {
synchronous=pthread_equal(pthread_self(),caller)!=0;granted=value;dispatch_semaphore_signal(done);
});
long waited=dispatch_semaphore_wait(done,dispatch_time(DISPATCH_TIME_NOW,2*NSEC_PER_SEC));
printf("AUDIO_INPUT_AUTH audio=%ld video=%ld request_granted=%d async=%d\n",audio,video,(int)granted,!waited && !synchronous);
fflush(stdout);
return audio!=3 || video!=2 || waited || granted!=1 || synchronous;
}
static int device(AudioDeviceID* out) {
AudioObjectPropertyAddress p={kAudioHardwarePropertyDefaultInputDevice,kAudioObjectPropertyScopeGlobal,kAudioObjectPropertyElementMaster};
AudioDeviceID id=kAudioObjectUnknown;UInt32 size=sizeof(id);
OSStatus status=AudioObjectGetPropertyData(kAudioObjectSystemObject,&p,0,nullptr,&size,&id);
if(status || id==kAudioObjectUnknown) {printf("AUDIO_INPUT_DEVICE status=%d\n",status);return 1;}
unsigned channels[2]={0,0};
for(unsigned i=0;i<2;++i) {
AudioObjectPropertyAddress c={kAudioDevicePropertyStreamConfiguration,
i?kAudioObjectPropertyScopeOutput:kAudioObjectPropertyScopeInput,kAudioObjectPropertyElementMaster};
unsigned char storage[256]={};size=sizeof(storage);
if(AudioObjectGetPropertyData(id,&c,0,nullptr,&size,storage)) return 2;
auto* list=(AudioBufferList*)storage;
for(unsigned b=0;b<list->mNumberBuffers;++b) channels[i]+=list->mBuffers[b].mNumberChannels;
}
AudioObjectPropertyAddress s={kAudioDevicePropertyStreams,kAudioObjectPropertyScopeInput,kAudioObjectPropertyElementMaster};
UInt32 streams=0;
status=AudioObjectGetPropertyDataSize(id,&s,0,nullptr,&streams);
AudioObjectPropertyAddress d={kAudioHardwarePropertyDevices,kAudioObjectPropertyScopeGlobal,kAudioObjectPropertyElementMaster};
AudioDeviceID devices[16];size=sizeof(devices);
bool listed=false;
if(!AudioObjectGetPropertyData(kAudioObjectSystemObject,&d,0,nullptr,&size,devices))
for(unsigned i=0;i<size/sizeof(AudioDeviceID);++i) listed|=devices[i]==id;
printf("AUDIO_INPUT_DEVICE input_channels=%u output_channels=%u input_streams=%u listed=%d\n",
channels[0],channels[1],(unsigned)(streams/sizeof(AudioStreamID)),listed);
fflush(stdout);
*out=id;
return channels[0]!=2 || channels[1]!=0 || status || !streams || !listed;
}
static int record(AudioDeviceID id,bool nonInterleaved,unsigned channels,unsigned milliseconds) {
AudioComponentDescription description={kAudioUnitType_Output,kAudioUnitSubType_HALOutput,kAudioUnitManufacturer_Apple,0,0};
AudioComponent component=AudioComponentFindNext(nullptr,&description);
if(!component || AudioComponentInstanceNew(component,&unit) || !unit) return 1;
planar=nonInterleaved;interleavedChannels=channels;callbacks=0;renderErrors=0;nonIncreasing=0;zeroFrames=0;badBuffers=0;totalFrames=0;lastSample=-1;
UInt32 on=1,off=0;
if(AudioUnitSetProperty(unit,kAudioOutputUnitProperty_EnableIO,kAudioUnitScope_Input,1,&on,sizeof(on))) return 2;
if(AudioUnitSetProperty(unit,kAudioOutputUnitProperty_EnableIO,kAudioUnitScope_Output,0,&off,sizeof(off))) return 3;
// Vivox (the interleaved stereo run) sets the capture device on element 1.
const AudioUnitElement deviceElement=channels==2?1:0;
if(AudioUnitSetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,deviceElement,&id,sizeof(id))) return 4;
AudioDeviceID current=0;UInt32 size=sizeof(current);
if(AudioUnitGetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,deviceElement,&current,&size) || current!=id) return 5;
AudioStreamBasicDescription format=nonInterleaved
? AudioStreamBasicDescription{44100,kAudioFormatLinearPCM,kAudioFormatFlagIsFloat|kAudioFormatFlagIsPacked|kAudioFormatFlagIsNonInterleaved,4,1,4,2,32,0}
: channels==2
// Vivox's recording format: interleaved Float32 stereo at the device rate.
? AudioStreamBasicDescription{44100,kAudioFormatLinearPCM,kAudioFormatFlagIsFloat|kAudioFormatFlagIsPacked,8,1,8,2,32,0}
: AudioStreamBasicDescription{48000,kAudioFormatLinearPCM,kAudioFormatFlagIsFloat|kAudioFormatFlagIsPacked,4,1,4,1,32,0};
OSStatus status=AudioUnitSetProperty(unit,kAudioUnitProperty_StreamFormat,kAudioUnitScope_Output,1,&format,sizeof(format));
if(status) {printf("AUDIO_INPUT_FORMAT status=%d\n",status);return 6;}
UInt32 frames=480;
if(AudioUnitSetProperty(unit,kAudioDevicePropertyBufferFrameSize,kAudioUnitScope_Global,0,&frames,sizeof(frames))) return 7;
AURenderCallbackStruct callback={input,nullptr};
if(AudioUnitSetProperty(unit,kAudioOutputUnitProperty_SetInputCallback,kAudioUnitScope_Global,0,&callback,sizeof(callback))) return 8;
if(AudioUnitInitialize(unit)) return 9;
status=AudioOutputUnitStart(unit);
if(status) {printf("AUDIO_INPUT_START status=%d\n",status);return 10;}
usleep(milliseconds*1000);
OSStatus stopped=AudioOutputUnitStop(unit);
unsigned count=callbacks;
usleep(100000);
bool quiet=callbacks==count;
AudioUnitUninitialize(unit);
AudioComponentInstanceDispose(unit);
double seconds=double(totalFrames)/format.mSampleRate;
printf("AUDIO_INPUT_RECORD planar=%d channels=%u rate=%.0f callbacks=%u frames=%llu seconds=%.3f render_errors=%u bad_buffers=%u "
"nonincreasing=%u zero_frames=%u stop_status=%d quiet_after_stop=%d\n",nonInterleaved,(unsigned)format.mChannelsPerFrame,format.mSampleRate,count,
(unsigned long long)totalFrames,seconds,(unsigned)renderErrors,(unsigned)badBuffers,(unsigned)nonIncreasing,
(unsigned)zeroFrames,stopped,quiet);
fflush(stdout);
// Startup latency aside, most of the recording time must have been delivered.
return !count || renderErrors || badBuffers || nonIncreasing || zeroFrames || stopped || !quiet
|| seconds<milliseconds/2000.0;
}
// Vivox's playback unit sets interleaved Float32 stereo at 32 kHz on input scope element 0;
// only the format is checked, nothing is played.
static int playbackFormat() {
AudioComponentDescription description={kAudioUnitType_Output,kAudioUnitSubType_HALOutput,kAudioUnitManufacturer_Apple,0,0};
AudioComponent component=AudioComponentFindNext(nullptr,&description);
AudioUnit playback=nullptr;
if(!component || AudioComponentInstanceNew(component,&playback) || !playback) return 1;
AudioStreamBasicDescription format={32000,kAudioFormatLinearPCM,kAudioFormatFlagIsFloat|kAudioFormatFlagIsPacked,8,1,8,2,32,0};
OSStatus status=AudioUnitSetProperty(playback,kAudioUnitProperty_StreamFormat,kAudioUnitScope_Input,0,&format,sizeof(format));
AudioStreamBasicDescription unsignedFormat={44100,kAudioFormatLinearPCM,kAudioFormatFlagIsPacked,4,1,4,2,16,0};
OSStatus rejected=AudioUnitSetProperty(playback,kAudioUnitProperty_StreamFormat,kAudioUnitScope_Input,0,&unsignedFormat,sizeof(unsignedFormat));
AudioComponentInstanceDispose(playback);
printf("AUDIO_PLAYBACK_FORMAT interleaved_status=%d unsigned_status=%d\n",(int)status,(int)rejected);
fflush(stdout);
return status!=noErr || rejected==noErr;
}
int main() {
int failures=0;
failures+=authorization()!=0;
AudioDeviceID id=kAudioObjectUnknown;
if(device(&id)) return 100+failures;
int result=record(id,false,1,1000);
if(result) printf("AUDIO_INPUT_RECORD failed step=%d\n",result);
failures+=result!=0;
result=record(id,true,2,400);
if(result) printf("AUDIO_INPUT_RECORD failed step=%d planar=1\n",result);
failures+=result!=0;
result=record(id,false,2,400);
if(result) printf("AUDIO_INPUT_RECORD failed step=%d interleaved_stereo=1\n",result);
failures+=result!=0;
failures+=playbackFormat()!=0;
printf("AUDIO_INPUT_CHECK failures=%d\n",failures);
return failures;
}
-202
View File
@@ -1,202 +0,0 @@
#!/usr/bin/env python3
"""Create and install the GNOME / FreeDesktop desktop entry and launcher icon."""
import os
import shutil
import subprocess
import sys
from pathlib import Path
PROJECT_ROOT = Path("@PROJECT_ROOT@")
HOST_HOME = Path("@HOST_HOME@")
DPREFIX = Path("@DPREFIX@")
SVG_ICON_CONTENT = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#091428"/>
<stop offset="50%" stop-color="#0a1c36"/>
<stop offset="100%" stop-color="#030811"/>
</linearGradient>
<linearGradient id="borderGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#f0e6d2"/>
<stop offset="40%" stop-color="#c8aa6e"/>
<stop offset="80%" stop-color="#785a28"/>
<stop offset="100%" stop-color="#c89b3c"/>
</linearGradient>
<linearGradient id="goldGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#fff2c6"/>
<stop offset="25%" stop-color="#f0e6d2"/>
<stop offset="50%" stop-color="#c8aa6e"/>
<stop offset="85%" stop-color="#9a7a35"/>
<stop offset="100%" stop-color="#785a28"/>
</linearGradient>
<linearGradient id="goldHighlight" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.6"/>
<stop offset="100%" stop-color="#c8aa6e" stop-opacity="0"/>
</linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="6" result="blur"/>
<feComposite in="SourceGraphic" in2="blur" operator="over"/>
</filter>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="8" stdDeviation="14" flood-color="#000000" flood-opacity="0.85"/>
</filter>
</defs>
<!-- Background Shield / Squircle -->
<rect x="24" y="24" width="464" height="464" rx="104" fill="url(#bgGrad)" stroke="url(#borderGrad)" stroke-width="12" filter="url(#shadow)"/>
<rect x="40" y="40" width="432" height="432" rx="90" fill="none" stroke="#c8aa6e" stroke-width="2" stroke-opacity="0.4"/>
<!-- Crest Emblem -->
<g filter="url(#glow)">
<path d="M172 108 L242 108 L242 316 L364 316 L352 388 L156 388 L156 124 Z"
fill="url(#goldGrad)" stroke="#463714" stroke-width="3"/>
<path d="M 180 120 L 230 120 L 230 250 L 180 200 Z"
fill="url(#goldHighlight)"/>
<path d="M 242 328 L 348 328 L 340 376 L 172 376 L 172 356 L 242 356 Z"
fill="#c8aa6e" opacity="0.45"/>
<polygon points="256,76 264,96 284,96 268,108 274,128 256,116 238,128 244,108 228,96 248,96" fill="#f0e6d2"/>
</g>
</svg>
"""
def extract_best_png_from_icns(icns_path: Path) -> bytes | None:
"""Extract the highest-resolution embedded PNG from an Apple ICNS file."""
try:
data = icns_path.read_bytes()
if not data.startswith(b"icns"):
return None
offset = 8
best_png = None
best_len = 0
while offset + 8 <= len(data):
size = int.from_bytes(data[offset + 4 : offset + 8], "big")
if size < 8 or offset + size > len(data):
break
chunk = data[offset + 8 : offset + size]
if chunk.startswith(b"\x89PNG\r\n\x1a\n") and len(chunk) > best_len:
best_png = chunk
best_len = len(chunk)
offset += size
return best_png
except Exception:
return None
def find_candidate_icns() -> Path | None:
"""Find any available Riot or League .icns icon files."""
candidates = [
*list((PROJECT_ROOT / "build/installer").glob("*League of Legends*.app/Contents/Resources/*.icns")),
*list(DPREFIX.glob("Users/Shared/Riot Games/Riot Client.app/Contents/Resources/*.icns")),
*list(DPREFIX.glob("Applications/Riot Client.app/Contents/Resources/*.icns")),
*list(DPREFIX.glob("Applications/League of Legends.app/Contents/Resources/*.icns")),
]
for c in candidates:
if c.is_file() and c.stat().st_size > 1024:
return c
return None
def main():
icons_base = HOST_HOME / ".local/share/icons"
apps_base = HOST_HOME / ".local/share/applications"
desktop_dir = HOST_HOME / "Desktop"
scalable_dir = icons_base / "hicolor/scalable/apps"
png_dir = icons_base / "hicolor/512x512/apps"
scalable_dir.mkdir(parents=True, exist_ok=True)
png_dir.mkdir(parents=True, exist_ok=True)
apps_base.mkdir(parents=True, exist_ok=True)
# 1. Install scalable SVG icon
svg_target = scalable_dir / "league-of-legends.svg"
svg_target.write_text(SVG_ICON_CONTENT)
shutil.copyfile(svg_target, icons_base / "league-of-legends.svg")
# 2. Extract or generate PNG icon
png_target = png_dir / "league-of-legends.png"
icns_file = find_candidate_icns()
extracted = False
if icns_file:
png_data = extract_best_png_from_icns(icns_file)
if png_data:
png_target.write_bytes(png_data)
shutil.copyfile(png_target, icons_base / "league-of-legends.png")
extracted = True
print(f"Extracted official icon from {icns_file.name} to {png_target}")
if not extracted and not png_target.exists():
# Try to rasterize SVG via rsvg-convert or magick
rsvg = shutil.which("rsvg-convert")
magick = shutil.which("magick") or shutil.which("convert")
if rsvg:
subprocess.run([rsvg, "-w", "512", "-h", "512", str(svg_target), "-o", str(png_target)], check=False)
if png_target.exists():
shutil.copyfile(png_target, icons_base / "league-of-legends.png")
elif magick:
subprocess.run([magick, "-background", "none", "-resize", "512x512", str(svg_target), str(png_target)], check=False)
if png_target.exists():
shutil.copyfile(png_target, icons_base / "league-of-legends.png")
# 3. Create .desktop file
run_script = PROJECT_ROOT / "scripts/desktop-run.sh"
exec_path = str(run_script).replace("%", "%%")
exec_cmd = f'"{exec_path}"' if " " in exec_path else exec_path
desktop_content = f"""[Desktop Entry]
Version=1.0
Type=Application
Name=League of Legends
GenericName=MOBA Game
Comment=Play League of Legends on Linux with darling-lol
Exec={exec_cmd}
Path={PROJECT_ROOT}
Icon=league-of-legends
Terminal=false
Categories=Game;ActionGame;StrategyGame;
Keywords=riot;league;lol;darling;games;
StartupWMClass=Riot Client
StartupNotify=false
"""
desktop_file = apps_base / "league-of-legends.desktop"
desktop_file.write_text(desktop_content)
os.chmod(desktop_file, 0o755)
print(f"Created application launcher: {desktop_file}")
target_desktop_dirs = []
try:
res = subprocess.run(["xdg-user-dir", "DESKTOP"], capture_output=True, text=True, check=False)
if res.returncode == 0 and res.stdout.strip():
d = Path(res.stdout.strip())
if d.is_dir() and d not in target_desktop_dirs:
target_desktop_dirs.append(d)
except Exception:
pass
for cand in [HOST_HOME / "Desktop"]:
if cand.is_dir() and cand not in target_desktop_dirs:
target_desktop_dirs.append(cand)
for ddir in target_desktop_dirs:
desktop_shortcut = ddir / "league-of-legends.desktop"
desktop_shortcut.write_text(desktop_content)
os.chmod(desktop_shortcut, 0o755)
gio = shutil.which("gio")
if gio:
subprocess.run([gio, "set", str(desktop_shortcut), "metadata::trusted", "true"], check=False)
print(f"Created desktop shortcut: {desktop_shortcut}")
# 4. Refresh icon and desktop databases
update_desktop = shutil.which("update-desktop-database")
if update_desktop:
subprocess.run([update_desktop, str(apps_base)], check=False)
update_icons = shutil.which("gtk-update-icon-cache")
if update_icons:
subprocess.run([update_icons, "-f", "-t", str(icons_base / "hicolor")], check=False)
print("GNOME application launcher and icon installed successfully.")
if __name__ == "__main__":
main()
+98
View File
@@ -0,0 +1,98 @@
/* Local event dispatch regression; also runs unchanged against native AppKit.
* No global input, client hooks, account access, or mapped windows. */
#import <AppKit/AppKit.h>
static NSMutableString *calls;
static int failures;
@interface MonitorTestWindow : NSWindow
@end
@implementation MonitorTestWindow
- (void) sendEvent: (NSEvent *) event {
[calls appendFormat: @"W%ld", (long)[event type]];
}
@end
static void expectCalls(NSString *name, NSString *expected) {
BOOL ok = [calls isEqual: expected];
printf("%s %s got=%s expected=%s\n", ok ? "PASS" : "FAIL",
[name UTF8String], [calls UTF8String], [expected UTF8String]);
failures += !ok;
[calls setString: @""];
}
/* AppKit does not promise an order between independent registrations. Native
* ordering can change with block addresses, even on the same OS build. */
static void expectBoth(NSString *name) {
NSString *expected = [calls isEqual: @"ABW1"] ? @"ABW1" : @"BAW1";
expectCalls(name, expected);
}
int main(void) {
@autoreleasepool {
[NSApplication sharedApplication];
calls = [NSMutableString new];
NSWindow *window = [[MonitorTestWindow alloc]
initWithContentRect: NSMakeRect(100, 100, 300, 200)
styleMask: 0 backing: NSBackingStoreBuffered defer: NO];
NSEvent *down = [NSEvent mouseEventWithType: NSLeftMouseDown
location: NSMakePoint(10, 10) modifierFlags: 0 timestamp: 0
windowNumber: [window windowNumber] context: nil
eventNumber: 1 clickCount: 1 pressure: 1];
NSEvent *up = [NSEvent mouseEventWithType: NSLeftMouseUp
location: NSMakePoint(10, 10) modifierFlags: 0 timestamp: 0
windowNumber: [window windowNumber] context: nil
eventNumber: 2 clickCount: 1 pressure: 0];
id a = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask
handler: ^NSEvent *(NSEvent *event) {
[calls appendString: @"A"]; return event;
}];
id b = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask
handler: ^NSEvent *(NSEvent *event) {
[calls appendString: @"B"]; return event;
}];
[NSApp sendEvent: down]; expectBoth(@"multiple-monitors");
[NSApp sendEvent: up]; expectCalls(@"mask-filter", @"W2");
[NSEvent removeMonitor: b];
[NSApp sendEvent: down]; expectCalls(@"remove", @"AW1");
[NSEvent removeMonitor: a];
b = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask
handler: ^NSEvent *(NSEvent *event) {
[calls appendString: @"B"]; return nil;
}];
[NSApp sendEvent: down]; expectCalls(@"consume", @"B");
[NSEvent removeMonitor: b];
b = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask
handler: ^NSEvent *(NSEvent *event) {
[calls appendString: @"B"]; return up;
}];
[NSApp sendEvent: down]; expectCalls(@"replace-event", @"BW2");
[NSEvent removeMonitor: b];
__block id selfRemoving;
selfRemoving = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask
handler: ^NSEvent *(NSEvent *event) {
[calls appendString: @"S"];
[NSEvent removeMonitor: selfRemoving]; return event;
}];
[NSApp sendEvent: down]; expectCalls(@"self-remove", @"SW1");
[NSApp sendEvent: down]; expectCalls(@"self-remove-next", @"W1");
__block id pendingA, pendingB;
pendingA = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask
handler: ^NSEvent *(NSEvent *event) {
[calls appendString: @"A"];
[NSEvent removeMonitor: pendingB]; return event;
}];
pendingB = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask
handler: ^NSEvent *(NSEvent *event) {
[calls appendString: @"B"];
[NSEvent removeMonitor: pendingA]; return event;
}];
[NSApp sendEvent: down]; expectBoth(@"remove-pending-snapshot");
[NSApp sendEvent: down]; expectCalls(@"remove-pending-next", @"W1");
[window release];
[calls release];
}
return failures ? 1 : 0;
}
+41 -11
View File
@@ -16,15 +16,29 @@ def check():
folder = Path(directory)
launcher = folder / "launch.sh"
launcher.write_text((ROOT / "tools/run-riot-client.sh").read_text().replace("@GUEST_HOME@", "/Users/offline-check"))
(folder / "exec-guest.py").write_text((ROOT / "tools/exec-guest.py").read_text())
binary_folder = folder / "bin"
binary_folder.mkdir()
log = folder / "calls.jsonl"
stub = f'''#!{sys.executable}
import json, os, sys
record = {{"command": os.path.basename(sys.argv[0]), "args": sys.argv[1:]}}
if record["command"] in ("darling", "python3"):
# The launcher starts the guest through exec-guest.py; run it for real.
if record["command"] == "python3" and sys.argv[1:] and os.path.basename(sys.argv[1]) == "exec-guest.py":
os.execv(sys.executable, [sys.executable] + sys.argv[1:])
if record["command"] == "darling":
# Descriptors open now were inherited; the one listdir used is closed again before this check.
def is_open(fd):
try:
os.fstat(fd)
return True
except OSError:
return False
record["fds"] = sorted(fd for fd in map(int, os.listdir("/proc/self/fd")) if is_open(fd))
record["flags"] = {{key: os.environ.get(key) for key in
("RCLOL_APP_ARG3", "RCLOL_APP_ARG3_SLOT", "RCLOL_UX_ARG2", "RCLOL_UX_ARG2_SLOT", "RCLOL_UX_ARG1", "RCLOL_APP_ARGS", "RCLOL_APP_ARG2", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", "DARLING_XSHM_LIBRARY", "MANGOHUD", "DISABLE_MANGOHUD")}}
("RCLOL_APP_ARG3", "RCLOL_APP_ARG3_SLOT", "RCLOL_UX_ARG2", "RCLOL_UX_ARG2_SLOT", "RCLOL_UX_ARG1", "RCLOL_APP_ARGS", "RCLOL_APP_ARG2", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", "DARLING_XSHM_LIBRARY",
"WAYLAND_DISPLAY", "XAUTHORITY", "DRI_PRIME", "MESA_NO_ERROR", "__GL_THREADED_OPTIMIZATIONS", "SHELL")}}
record["hud"] = {{key: os.environ.get(key) for key in ("MANGOHUD", "DISABLE_MANGOHUD")}}
with open({str(log)!r}, "a") as output:
output.write(json.dumps(record) + "\\n")
'''
@@ -62,7 +76,15 @@ with open({str(log)!r}, "a") as output:
"RCLOL_OPEN_URL_BRIDGE": "1",
"RCLOL_DISABLE_GPU_VSYNC": "1",
})
subprocess.run(["sh", str(launcher)], env=env, check=True, capture_output=True, text=True)
# Leaked host descriptors, including multi-digit ones, must not reach the guest.
leaked = list(os.pipe())
for target in (12, 200):
leaked.append(os.dup2(leaked[0], target, inheritable=False))
try:
subprocess.run(["sh", str(launcher)], env=env, check=True, capture_output=True, text=True, pass_fds=leaked)
finally:
for fd in leaked:
os.close(fd)
records = [json.loads(line) for line in log.read_text().splitlines()]
maintenance = [r for r in records if r["command"] == "python3"]
assert [Path(r["args"][0]).name for r in maintenance] == [
@@ -72,9 +94,9 @@ with open({str(log)!r}, "a") as output:
launches = [r for r in records if r["command"] == "darling"]
assert len(launches) == 1, "unexpected launch count"
for record in records:
assert record["flags"]["MANGOHUD"] is None, "inherited MangoHud activation survived"
assert record["flags"]["DISABLE_MANGOHUD"] == "1", "MangoHud not disabled before a runtime helper"
assert record["hud"] == {"MANGOHUD": None, "DISABLE_MANGOHUD": "1"}, "MangoHud survived before a runtime helper"
launch = launches[0]
assert launch["fds"] == [0, 1, 2], f"inherited descriptors reached the guest: {launch['fds']}"
assert all(launch["flags"][key] is None for key in (
"RCLOL_APP_ARG3", "RCLOL_APP_ARG3_SLOT", "RCLOL_UX_ARG2", "RCLOL_UX_ARG2_SLOT"
)), "inherited inspector/unlimited-frame argument survived"
@@ -89,23 +111,31 @@ with open({str(log)!r}, "a") as output:
assert "--log-dir=/Users/offline-check/Library/Logs/Riot Games/Riot Client" in launch["args"], "absolute log directory lost"
# An inherited injection must also be absent for first-time installation.
env["RIOT_LAUNCHER_HOST"] = str(folder / "Install Riot.app/Contents/MacOS/RiotClientServices")
env["DISABLE_MANGOHUD"] = "0" # An inherited value cannot override launcher policy.
env["DISABLE_MANGOHUD"] = "0" # Inherited settings cannot override launcher policy.
subprocess.run(["sh", str(launcher)], env=env, check=True, capture_output=True, text=True)
install = json.loads(log.read_text().splitlines()[-1])
assert install["args"][0] == env["RIOT_LAUNCHER_HOST"]
assert "--skip-to-install" in install["args"]
assert not any(arg.startswith("--app-root=") for arg in install["args"])
assert install["flags"]["DYLD_INSERT_LIBRARIES"] is None
assert install["flags"]["MANGOHUD"] is None
assert install["flags"]["DISABLE_MANGOHUD"] == "1"
assert install["hud"] == {"MANGOHUD": None, "DISABLE_MANGOHUD": "1"}
env.pop("RIOT_LAUNCHER_HOST")
env.pop("DYLD_INSERT_LIBRARIES")
subprocess.run(["sh", str(launcher)], env=env, check=True, capture_output=True, text=True)
clean = json.loads(log.read_text().splitlines()[-1])
assert clean["flags"]["DYLD_INSERT_LIBRARIES"] is None
assert clean["flags"]["MANGOHUD"] is None
assert clean["flags"]["DISABLE_MANGOHUD"] == "1"
print("LAUNCHER_OFFLINE pass=1 installed=PASS installer=PASS inherited_injection=absent mangohud=disabled runtime=preserved")
assert clean["hud"] == {"MANGOHUD": None, "DISABLE_MANGOHUD": "1"}
# The opt-in host environment filter drops session metadata but never the launcher's GPU tuning.
env.update({"RCLOL_FILTER_HOST_ENV": "1", "WAYLAND_DISPLAY": "wayland-0", "XAUTHORITY": "/tmp/xauth",
"DRI_PRIME": "1", "SHELL": "/bin/fish"})
env.pop("MESA_NO_ERROR", None)
env.pop("__GL_THREADED_OPTIMIZATIONS", None)
subprocess.run(["sh", str(launcher)], env=env, check=True, capture_output=True, text=True)
filtered = json.loads(log.read_text().splitlines()[-1])["flags"]
assert filtered["WAYLAND_DISPLAY"] is None and filtered["SHELL"] == "/bin/zsh", "host session metadata survived"
assert (filtered["XAUTHORITY"], filtered["DRI_PRIME"]) == ("/tmp/xauth", "1"), "X11/GPU selection lost"
assert (filtered["MESA_NO_ERROR"], filtered["__GL_THREADED_OPTIMIZATIONS"]) == ("1", "0"), "launcher GPU tuning lost"
print("LAUNCHER_OFFLINE pass=1 installed=PASS installer=PASS inherited_injection=absent runtime=preserved inherited_fds=closed host_env_filter=PASS mangohud=disabled")
if __name__ == "__main__":
+41 -4
View File
@@ -8,6 +8,14 @@
#import <OpenGL/gl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
extern "C" void _dispatch_main_queue_callback_4CF(void *);
@interface PresentationCheckWindow : NSWindow
@end
@implementation PresentationCheckWindow
- (BOOL)canBecomeKeyWindow { return YES; }
@end
static void verifyPresentation(bool value, const char* message) {
if (!value) { fprintf(stderr, "FAIL: %s\n", message); exit(1); }
@@ -18,7 +26,12 @@ static void pump(NSApplication* app) {
NSEvent* event = [app nextEventMatchingMask:NSAnyEventMask untilDate:[NSDate distantPast]
inMode:NSDefaultRunLoopMode dequeue:YES];
if (event) [app sendEvent:event];
[[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.005]];
// A timed NSRunLoop wait can miss its deadline on Darling when the
// X connection is quiet. Parent presentation no longer supplies child
// Expose events to wake it; service sources without a blocking wait.
CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.0, true);
_dispatch_main_queue_callback_4CF(nullptr);
usleep(1000);
}
}
int main() {
@@ -35,6 +48,7 @@ int main() {
id<MTLDevice> device = MTLCreateSystemDefaultDevice();
verifyPresentation(device != nil, "device");
CAMetalLayer* layer = [CAMetalLayer layer];
verifyPresentation(layer.displaySyncEnabled == YES, "VSync enabled by default");
layer.device = device;
layer.drawableSize = CGSizeMake(160,96);
layer.frame = CGRectMake(0,0,160,96);
@@ -52,12 +66,17 @@ int main() {
} else {
view.layer = layer;
}
NSWindow* window = [[NSWindow alloc] initWithContentRect:NSMakeRect(50,50,160,96)
styleMask:NSTitledWindowMask backing:NSBackingStoreBuffered defer:NO];
window.title = @"Metal presentation check";
const bool parentDrawable = getenv("RIOT_PRESENTATION_PARENT") != nullptr;
NSWindow* window = [[PresentationCheckWindow alloc] initWithContentRect:NSMakeRect(50,50,160,96)
styleMask:(parentDrawable ? NSBorderlessWindowMask : NSTitledWindowMask) backing:NSBackingStoreBuffered defer:parentDrawable];
window.title = parentDrawable ? @"Presentation (TM) Client" : @"Metal presentation check";
window.contentView = view;
[window makeKeyAndOrderFront:nil];
pump(app);
if (parentDrawable) {
void *borrowed = [[(CALayerContext*)[layer _context] subwindow] sharedCGLWindow];
verifyPresentation(borrowed != nullptr, "full-window game layer borrows managed parent drawable");
}
id<MTLCommandQueue> queue = [device newCommandQueue];
const unsigned frameCount = captureTest ? 130u : getenv("RIOT_PRESENTATION_STRESS") ? 120u : 10u;
for (unsigned frame = 0; frame < frameCount; ++frame) {
@@ -66,6 +85,7 @@ int main() {
verifyPresentation(flag != nullptr, "request second bounded capture");
fclose(flag);
}
layer.displaySyncEnabled = (frame % 3) != 1;
@autoreleasepool {
id<CAMetalDrawable> drawable = [layer nextDrawable];
verifyPresentation(drawable != nil, "drawable after late window/context attachment");
@@ -82,6 +102,23 @@ int main() {
[commands commit];
[commands waitUntilCompleted];
pump(app);
GLint interval = -1;
CGLGetParameter([(CALayerContext*)[layer _context] glContext], kCGLCPSwapInterval, &interval);
verifyPresentation(interval == (layer.displaySyncEnabled ? 1 : 0), "Metal VSync reaches window swap interval");
if (parentDrawable) {
void *borrowed = [[(CALayerContext*)[layer _context] subwindow] sharedCGLWindow];
verifyPresentation(borrowed != nullptr, "parent drawable remains borrowed");
if (frame == 3 && getenv("RIOT_PRESENTATION_RESIZE")) {
CALayerContext* context = (CALayerContext*)[layer _context];
CGRect original = [view frame];
[context setFrame:CGRectMake(0,0,original.size.width / 2,original.size.height)];
[context render]; [context flush];
verifyPresentation([[context subwindow] sharedCGLWindow] == nullptr, "partial view falls back to child drawable");
[context setFrame:original];
[context render]; [context flush];
verifyPresentation([[context subwindow] sharedCGLWindow] != nullptr, "restored view borrows parent drawable again");
}
}
}
}
CGLContextObj previous = CGLGetCurrentContext();
+27 -1
View File
@@ -48,8 +48,34 @@ int main(void) {
XEvent map={0};map.type=KeymapNotify;[display postXEvent:&map];expectModifier(0x37,0);
key(display,KeyPress,66,0);expectModifier(0x39,NSAlphaShiftKeyMask);
key(display,KeyRelease,66,LockMask);expectModifier(0x39,NSAlphaShiftKeyMask);
// GNOME overview: Super press delivered, release swallowed, no focus change.
// The next event's server state lacks Mod4, so Command must be released first.
key(display,KeyPress,133,LockMask);expectModifier(0x37,NSAlphaShiftKeyMask|NSCommandKeyMask);
NSUInteger before=[events count];
key(display,KeyPress,50,LockMask);
assert([events count]==before+2);
NSEvent *released=[events objectAtIndex:before];
assert([released keyCode]==0x37 && [released modifierFlags]==NSAlphaShiftKeyMask);
expectModifier(0x38,NSAlphaShiftKeyMask|NSShiftKeyMask);
// A modifier that is still held stays held.
key(display,KeyPress,133,LockMask|ShiftMask);expectModifier(0x37,NSAlphaShiftKeyMask|NSShiftKeyMask|NSCommandKeyMask);
before=[events count];
key(display,KeyRelease,50,LockMask|ShiftMask|Mod4Mask);
assert([events count]==before+1);
expectModifier(0x38,NSAlphaShiftKeyMask|NSCommandKeyMask);
// Right Alt is Mod1 (Option) on US layouts and Mod5 (AltGr) elsewhere; the
// backend reads the server map, so take whichever it reported and check it
// is still held, and reported once, when Command goes up.
key(display,KeyPress,108,LockMask|Mod4Mask);
NSUInteger rightAlt=[[events lastObject] modifierFlags]&~(NSUInteger)(NSAlphaShiftKeyMask|NSCommandKeyMask);
assert(rightAlt==NSAlternateKeyMask || rightAlt==NSFunctionKeyMask);
unsigned int rightAltMask=rightAlt==NSAlternateKeyMask ? Mod1Mask : Mod5Mask;
before=[events count];
key(display,KeyRelease,133,LockMask|Mod4Mask|rightAltMask);
assert([events count]==before+1);
expectModifier(0x37,NSAlphaShiftKeyMask|rightAlt);
method_setImplementation(method,original);
puts("PASS Super=Command, Alt=Option, both Command keys, post-transition flags, focus-loss release, missed-release reconciliation, Caps Lock");
puts("PASS Super=Command, Alt=Option, both Command keys, post-transition flags, focus-loss release, missed-release reconciliation, Caps Lock, swallowed Super release, layout-mapped Right Alt");
[events release];
}
return 0;
+201
View File
@@ -0,0 +1,201 @@
/* Standalone AppKit regression for issue #85: the panel's own Open/Cancel
* buttons must end runModal. Drives only its own NSOpenPanel; no Riot/League
* client is launched or contacted and no external input is generated.
*
* riot-openpanel-check --auto cancel|open-selected|open-none
* riot-openpanel-check --interactive
*/
#import <AppKit/AppKit.h>
#include <dispatch/dispatch.h>
#include <objc/runtime.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static NSOpenPanel *panel;
static NSString *tempDir, *subDir;
static const char *mode;
static BOOL interactive;
static BOOL panelVisibleAtTrigger;
/* Plain C storage: an autoreleased NSString kept here would be freed by the
* modal loop's pools before the result is reported (and the watchdog thread
* reads it too). */
static char triggerNote[160] = "";
static const double kTriggerDelay = 1.5, kWatchdog = 15.0;
static NSButton *findButton(NSView *view, SEL action) {
if ([view isKindOfClass:[NSButton class]] && [(NSButton *)view action] == action)
return (NSButton *)view;
for (NSView *sub in [view subviews]) {
NSButton *found = findButton(sub, action);
if (found) return found;
}
return nil;
}
static NSOutlineView *findOutline(NSView *view) {
if ([view isKindOfClass:[NSOutlineView class]]) return (NSOutlineView *)view;
for (NSView *sub in [view subviews]) {
NSOutlineView *found = findOutline(sub);
if (found) return found;
}
return nil;
}
static void press(SEL action, const char *name) {
NSButton *button = findButton([panel contentView], action);
if (button) {
snprintf(triggerNote, sizeof triggerNote, "%s via button", name);
[button performClick:nil];
} else {
snprintf(triggerNote, sizeof triggerNote, "%s via direct action (button not found)", name);
[panel performSelector:action withObject:nil];
}
}
static char selectNote[320] = "";
static BOOL selectionOK = YES;
static NSString *urlPath(id item) {
NSString *path = [item isKindOfClass:[NSURL class]] ? [item path] : nil;
while ([path length] > 1 && [path hasSuffix:@"/"])
path = [path substringToIndex:[path length] - 1];
return path;
}
/* The panel's tree is rooted at "/" and is not navigated to the directory
* being shown, so expand each component of subDir from the root, then select
* its row. Records diagnostics in selectNote. */
static BOOL selectSubfolder(void) {
NSOutlineView *outline = findOutline([panel contentView]);
if (!outline) { snprintf(selectNote, sizeof selectNote, "no_outline_view"); return NO; }
[outline reloadData];
NSString *prefix = @"";
NSInteger found = -1;
for (NSString *part in [subDir pathComponents]) {
if ([part isEqualToString:@"/"]) continue;
prefix = [prefix stringByAppendingFormat:@"/%@", part];
found = -1;
for (NSInteger row = 0; row < [outline numberOfRows]; row++) {
if ([urlPath([outline itemAtRow:row]) isEqualToString:prefix]) { found = row; break; }
}
if (found < 0) {
snprintf(selectNote, sizeof selectNote, "row_not_found at=%s rows=%ld",
[prefix UTF8String], (long)[outline numberOfRows]);
return NO;
}
if (![prefix isEqualToString:subDir]) [outline expandItem:[outline itemAtRow:found]];
}
[outline selectRowIndexes:[NSIndexSet indexSetWithIndex:found] byExtendingSelection:NO];
NSIndexSet *sel = [outline selectedRowIndexes];
NSInteger selRow = [sel firstIndex] == NSNotFound ? -1 : (NSInteger)[sel firstIndex];
id item = selRow >= 0 ? [outline itemAtRow:selRow] : nil;
NSString *itemPath = urlPath(item);
snprintf(selectNote, sizeof selectNote,
"target_row=%ld selected_count=%lu selected_row=%ld item_class=%s item_path=%s",
(long)found, (unsigned long)[sel count], (long)selRow,
item ? class_getName([item class]) : "nil", itemPath ? [itemPath UTF8String] : "(nil)");
return selRow == found;
}
@interface Trigger : NSObject
- (void)fire:(NSTimer *)timer;
@end
@implementation Trigger
- (void)fire:(NSTimer *)timer {
static BOOL done;
if (done) return;
done = YES;
panelVisibleAtTrigger = [panel isVisible];
if (!strcmp(mode, "cancel")) {
press(@selector(_cancel:), "cancel");
} else {
if (!strcmp(mode, "open-selected")) selectionOK = selectSubfolder();
press(@selector(_selectFile:), "open");
}
}
@end
int main(int argc, char **argv) {
setbuf(stdout, NULL);
if (argc == 2 && !strcmp(argv[1], "--interactive")) {
interactive = YES;
mode = "interactive";
} else if (argc == 3 && !strcmp(argv[1], "--auto") &&
(!strcmp(argv[2], "cancel") || !strcmp(argv[2], "open-selected") ||
!strcmp(argv[2], "open-none"))) {
mode = argv[2];
} else {
fprintf(stderr, "usage: %s --auto cancel|open-selected|open-none | --interactive\n", argv[0]);
return 2;
}
@autoreleasepool {
[NSApplication sharedApplication];
char templ[] = "/tmp/openpanel-check.XXXXXX";
tempDir = [[NSString stringWithUTF8String:mkdtemp(templ)] retain];
subDir = [[tempDir stringByAppendingPathComponent:@"subfolder"] retain];
[[NSFileManager defaultManager] createDirectoryAtPath:subDir
withIntermediateDirectories:YES attributes:nil error:NULL];
panel = [[NSOpenPanel openPanel] retain];
[panel setCanChooseDirectories:YES];
[panel setCanChooseFiles:NO];
[panel setAllowsMultipleSelection:NO];
[panel setDirectory:tempDir];
if (!interactive) {
/* Independent of the run loop: a hung runModal still reports. */
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kWatchdog * NSEC_PER_SEC)),
dispatch_get_global_queue(0, 0), ^{
printf("OPENPANEL_CHECK mode=%s result=FAIL problems=HANG "
"trigger=\"%s\" visible_at_trigger=%d\n",
mode, triggerNote, panelVisibleAtTrigger);
fflush(stdout);
_exit(3);
});
Trigger *trigger = [[Trigger alloc] init];
NSTimer *timer = [NSTimer timerWithTimeInterval:kTriggerDelay target:trigger
selector:@selector(fire:) userInfo:nil repeats:NO];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSModalPanelRunLoopMode];
} else {
printf("Interactive: pick the folder, or press Open/Cancel on the panel.\n");
}
NSInteger code = [panel runModal];
NSString *path = [[panel URL] path];
NSArray *urls = [panel URLs];
BOOL visible = [panel isVisible];
NSString *shown = path ? path : @"(none)";
if (interactive) {
printf("OPENPANEL_CHECK mode=interactive result=RETURNED code=%ld url=%s urls=%lu visible=%d\n",
(long)code, [shown UTF8String], (unsigned long)[urls count], visible);
return 0;
}
NSMutableArray *problems = [NSMutableArray array];
BOOL wantOpen = strcmp(mode, "cancel") != 0;
NSString *wantPath = !strcmp(mode, "open-selected") ? subDir : tempDir;
if (!panelVisibleAtTrigger) [problems addObject:@"panel_not_visible_at_trigger"];
if (wantOpen) {
if (code != NSOKButton) [problems addObject:@"code_not_ok"];
if (![path isEqualToString:wantPath]) [problems addObject:@"wrong_url"];
if ([urls count] != 1) [problems addObject:@"wrong_url_count"];
} else if (code != NSCancelButton) {
[problems addObject:@"code_not_cancel"];
}
if (visible) [problems addObject:@"still_visible"];
if (!selectionOK) [problems addObject:@"selection_failed"];
BOOL pass = [problems count] == 0;
printf("OPENPANEL_CHECK mode=%s result=%s code=%ld url=%s expected_url=%s visible=%d trigger=\"%s\"%s%s%s%s%s\n",
mode, pass ? "PASS" : "FAIL", (long)code, [shown UTF8String],
wantOpen ? [wantPath UTF8String] : "-", visible, triggerNote,
selectNote[0] ? " selection=\"" : "", selectNote, selectNote[0] ? "\"" : "",
pass ? "" : " problems=",
pass ? "" : [[problems componentsJoinedByString:@","] UTF8String]);
fflush(stdout);
return pass ? 0 : 1;
}
}
+3 -4
View File
@@ -25,10 +25,9 @@ with tempfile.TemporaryDirectory(prefix='darling-cleanup-check-') as folder:
process(104, 'unrelated', ['/usr/bin/unrelated', '/sbin/launchd'])
with patch.object(cleanup.os, 'kill') as kill:
cleanup.stop_riot_crashpad(root)
assert kill.call_args_list == [((102, cleanup.signal.SIGTERM),)]
kill.reset_mock()
kill.assert_not_called()
cleanup.stop_orphaned_launchd(root)
assert kill.call_args_list == [((103, cleanup.signal.SIGKILL),)]
kill.assert_not_called()
assert not cleanup.any_server_running(root)
process(105, 'darlingserver', ['darlingserver', '--prefix=another-prefix'])
assert cleanup.any_server_running(root)
@@ -58,4 +57,4 @@ with tempfile.TemporaryDirectory(prefix='darling-cleanup-check-') as folder:
crashpad.assert_not_called()
orphan.assert_not_called()
remove.assert_not_called()
print('RUNTIME_CLEANUP_OFFLINE pass=1 browser_preserved=1 other_prefix_preserved=1 foreign_owner_preserved=1 hidden_process_preserved=1 orphan_recovered=1')
print('RUNTIME_CLEANUP_OFFLINE pass=1 browser_preserved=1 other_prefix_preserved=1 foreign_owner_preserved=1 hidden_process_preserved=1 orphan_preserved=1')
+74
View File
@@ -0,0 +1,74 @@
#import <AppKit/NSApplication.h>
#import <AppKit/NSSavePanel.h>
#import <AppKit/NSOpenPanel.h>
#import <Foundation/Foundation.h>
#import <objc/runtime.h>
#include <stdio.h>
/* Declarations only: implementations must come from the loaded AppKit, not
* from linking a compatibility category directly into this probe. */
@interface NSSavePanel (RiotTagProbeDeclarations)
- (void)setShowsTagField:(BOOL)value;
- (BOOL)showsTagField;
- (void)setTagNames:(NSArray *)names;
- (NSArray *)tagNames;
@end
static BOOL emptyTags(NSSavePanel *panel) {
NSArray *tags = [panel tagNames];
return tags != nil && [tags isKindOfClass:[NSArray class]] && [tags count] == 0;
}
static BOOL checkPanel(Class panelClass) {
SEL selectors[] = {@selector(setShowsTagField:), @selector(showsTagField),
@selector(setTagNames:), @selector(tagNames)};
BOOL available = YES, inherited = YES, values = NO;
/* This stateless probe must work without panel initialization. Bare runtime
* allocation avoids NSWindow/nib/display setup; it does NOT test the chooser.
* Dispose without running NSWindow's deallocator on an uninitialized panel. */
NSSavePanel *panel = (NSSavePanel *)class_createInstance(panelClass, 0);
for (unsigned i = 0; i < sizeof(selectors) / sizeof(selectors[0]); ++i) {
Method base = class_getInstanceMethod([NSSavePanel class], selectors[i]);
Method method = class_getInstanceMethod(panelClass, selectors[i]);
available &= panel != nil && method != NULL && [panel respondsToSelector:selectors[i]];
inherited &= base != NULL && method != NULL &&
method_getImplementation(base) == method_getImplementation(method);
}
if (panelClass == [NSOpenPanel class])
inherited &= class_getSuperclass(panelClass) == [NSSavePanel class];
if (available) {
@try {
values = ![panel showsTagField] && emptyTags(panel);
[panel setShowsTagField:YES];
values &= ![panel showsTagField];
[panel setShowsTagField:NO];
values &= ![panel showsTagField];
NSMutableArray *names = [NSMutableArray arrayWithObject:@"probe-tag"];
[panel setTagNames:names];
[names addObject:@"second-tag"];
values &= emptyTags(panel);
[panel setTagNames:[NSArray array]];
values &= emptyTags(panel);
[panel setTagNames:nil];
values &= emptyTags(panel);
} @catch (NSException *exception) {
fprintf(stderr, "SAVE_PANEL_TAG exception=%s\n", [[exception name] UTF8String]);
values = NO;
}
}
if (panel != nil)
object_dispose(panel);
BOOL pass = available && inherited && values;
printf("SAVE_PANEL_TAG class=%s selectors=%d inherited=%d values=%d pass=%d\n",
class_getName(panelClass), available, inherited, values, pass);
return pass;
}
int main(void) {
@autoreleasepool {
BOOL save = checkPanel([NSSavePanel class]);
BOOL open = checkPanel([NSOpenPanel class]);
printf("SAVE_PANEL_TAG_CHECK pass=%d\n", save && open);
return save && open ? 0 : 1;
}
}
+86
View File
@@ -0,0 +1,86 @@
/* Standalone Cocoa/X11 regression: no client windows, hooks or input. */
#import <AppKit/AppKit.h>
#import <AppKit/NSDisplay.h>
#include <X11/Xlib.h>
#include <assert.h>
#include <dlfcn.h>
#include <stdio.h>
@protocol DecorationProbeDisplay
- (void *)display;
@end
@interface NSObject (DecorationProbe)
- (unsigned long)windowHandle;
- (void)recreateXWindowWithAlpha:(BOOL)alpha;
@end
static BOOL undecorated(NSWindow *window) {
void *xlib = dlopen("/usr/lib/native/libX11.dylib", RTLD_LAZY);
assert(xlib);
Atom (*intern)(Display *, const char *, Bool) = dlsym(xlib, "XInternAtom");
int (*sync)(Display *, Bool) = dlsym(xlib, "XSync");
int (*get)(Display *, Window, Atom, long, long, Bool, Atom,
Atom *, int *, unsigned long *, unsigned long *, unsigned char **) =
dlsym(xlib, "XGetWindowProperty");
int (*freeData)(void *) = dlsym(xlib, "XFree");
assert(intern && sync && get && freeData);
id native = [window platformWindow];
Display *display = (Display *)[(id<DecorationProbeDisplay>)[NSDisplay currentDisplay] display];
sync(display, False);
Atom type = None;
int format = 0;
unsigned long count = 0, remaining = 0;
unsigned char *data = NULL;
int status = get(display, [native windowHandle], intern(display, "_MOTIF_WM_HINTS", False),
0, 5, False, AnyPropertyType, &type, &format, &count, &remaining, &data);
assert(status == Success);
BOOL result = format == 32 && count >= 3 && data &&
(((unsigned long *)data)[0] & 2) && ((unsigned long *)data)[2] == 0;
if (data) freeData(data);
dlclose(xlib);
return result;
}
static void assertDecoration(NSWindow *window, BOOL expected, const char *name) {
BOOL actual = undecorated(window);
printf("WINDOW_DECORATION case=%s undecorated=%d expected=%d\n", name, actual, expected);
assert(actual == expected);
}
int main(void) {
setbuf(stdout, NULL);
@autoreleasepool {
[NSApplication sharedApplication];
NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(20, 20, 320, 180)
styleMask:NSTitledWindowMask backing:NSBackingStoreBuffered defer:NO];
assertDecoration(window, NO, "ordinary titled window");
[window setTitleVisibility:1];
assertDecoration(window, NO, "hidden title alone preserves frame");
[window setTitlebarAppearsTransparent:YES];
assertDecoration(window, YES, "hidden and transparent title removes frame");
[window setTitleVisibility:0];
assertDecoration(window, NO, "visible title restores frame");
[window setTitleVisibility:1];
[window setTitlebarAppearsTransparent:NO];
assertDecoration(window, NO, "opaque title restores frame");
[window setStyleMask:NSTexturedBackgroundWindowMask];
assertDecoration(window, YES, "untitled with extra flags");
[window setStyleMask:NSTitledWindowMask];
assertDecoration(window, NO, "titled style restored");
[window setStyleMask:NSBorderlessWindowMask];
assertDecoration(window, YES, "borderless after creation");
[window setStyleMask:NSTitledWindowMask];
[window setTitlebarAppearsTransparent:YES];
[(id)[window platformWindow] recreateXWindowWithAlpha:NO];
assertDecoration(window, YES, "recreated native window retains client chrome");
NSWindow *deferred = [[NSWindow alloc] initWithContentRect:NSMakeRect(20, 20, 320, 180)
styleMask:NSTitledWindowMask backing:NSBackingStoreBuffered defer:YES];
[deferred setTitleVisibility:1];
[deferred setTitlebarAppearsTransparent:YES];
assertDecoration(deferred, YES, "properties set before native creation");
puts("WINDOW_DECORATIONS_CHECK cases=10 pass=1");
// Windows stay unmapped: the probe never changes compositor focus.
}
return 0;
}
+276
View File
@@ -0,0 +1,276 @@
/* Standalone check for the in-process X11 drag loop (-[NSWindow _localDragImage:...]).
* Opens its own windows, drives -dragImage:at:offset:event:pasteboard:source:slideBack:
* with synthetic NSEvents posted to this app's own queue, and asserts the outcome.
* It injects no X11 input and touches no other client. A hang is reported as FAIL. */
#import <AppKit/AppKit.h>
#include <dispatch/dispatch.h>
#include <pthread.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <time.h>
#define CASE_TIMEOUT_SECONDS 20.0
@interface DragLog : NSObject {
@public
int entered, updated, exited, prepared, performed, concluded, ended, mouseDowns;
NSDragOperation endOperation;
BOOL removeWhenUpdated;
}
@end
@implementation DragLog @end
@interface NSView (DragCheckDefaults)
- (BOOL)prepareForDragOperation:(id<NSDraggingInfo>)sender;
@end
/* Destination without -prepareForDragOperation:, so the NSView default applies. */
@interface DropView : NSView { @public DragLog *log; }
@end
@implementation DropView
- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)s { log->entered++; return NSDragOperationCopy; }
- (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)s { log->updated++; return NSDragOperationCopy; }
- (void)draggingExited:(id<NSDraggingInfo>)s { log->exited++; }
- (BOOL)performDragOperation:(id<NSDraggingInfo>)s { log->performed++; return YES; }
- (void)concludeDragOperation:(id<NSDraggingInfo>)s { log->concluded++; }
- (void)mouseDown:(NSEvent *)e { log->mouseDowns++; }
@end
/* Source that, like Chromium's, does not implement -pasteboardChangedOwner:. */
@interface DragSource : NSObject { @public DragLog *log; }
@end
@implementation DragSource
- (NSDragOperation)draggingSourceOperationMask { return NSDragOperationEvery; }
- (NSDragOperation)draggingSourceOperationMaskForLocal:(BOOL)local { return NSDragOperationEvery; }
- (void)draggedImage:(NSImage *)image endedAt:(NSPoint)p operation:(NSDragOperation)op {
log->ended++; log->endOperation = op;
}
@end
static const char *currentCase = "startup";
static volatile time_t caseStart;
static int failures = 0;
static void *watchdog(void *unused) {
for (;;) {
usleep(100000);
if (time(NULL) - caseStart > CASE_TIMEOUT_SECONDS) {
printf("FAIL %s: no progress for %.0fs (drag hang)\n", currentCase, CASE_TIMEOUT_SECONDS);
fflush(stdout);
_exit(1);
}
}
return NULL;
}
static void beginCase(const char *name) { currentCase = name; caseStart = time(NULL); }
static void expect(BOOL ok, const char *what) {
if (!ok) { failures++; printf(" FAIL %s: %s\n", currentCase, what); }
}
static void endCase(int failuresBefore) {
printf("%s %s\n", failures == failuresBefore ? "PASS" : "FAIL", currentCase);
fflush(stdout);
}
static void pump(double seconds) {
NSDate *end = [NSDate dateWithTimeIntervalSinceNow:seconds];
while ([end timeIntervalSinceNow] > 0) {
NSEvent *e = [NSApp nextEventMatchingMask:NSAnyEventMask untilDate:[NSDate distantPast]
inMode:NSDefaultRunLoopMode dequeue:YES];
if (e) [NSApp sendEvent:e];
[[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.005]];
}
}
static NSWindow *makeWindow(NSString *title, NSRect frame) {
NSWindow *w = [[NSWindow alloc] initWithContentRect:frame styleMask:NSBorderlessWindowMask
backing:NSBackingStoreBuffered defer:NO];
[w setTitle:title];
[w setReleasedWhenClosed:NO];
[w makeKeyAndOrderFront:nil];
return w;
}
/* Destination window holding a registered DropView that only its superview owns. */
static NSWindow *makeDestination(DragLog *log, DropView **viewOut) {
NSWindow *w = makeWindow(@"Darling drag check destination", NSMakeRect(500, 200, 240, 240));
DropView *v = [[DropView alloc] initWithFrame:NSMakeRect(20, 20, 200, 200)];
v->log = log;
[v registerForDraggedTypes:[NSArray arrayWithObject:NSStringPboardType]];
[[w contentView] addSubview:v];
if (viewOut) *viewOut = v;
[v release];
return w;
}
static NSEvent *mouse(NSEventType type, NSWindow *w, NSPoint p) {
return [NSEvent mouseEventWithType:type location:p modifierFlags:0
timestamp:[[NSProcessInfo processInfo] systemUptime]
windowNumber:[w windowNumber] context:nil eventNumber:0
clickCount:1 pressure:1.0];
}
static NSEvent *escapeKey(NSWindow *w) {
return [NSEvent keyEventWithType:NSKeyDown location:NSMakePoint(0, 0) modifierFlags:0
timestamp:[[NSProcessInfo processInfo] systemUptime]
windowNumber:[w windowNumber] context:nil characters:@"\033"
charactersIgnoringModifiers:@"\033" isARepeat:NO keyCode:53];
}
static void post(NSEvent *e) { [NSApp postEvent:e atStart:NO]; }
static NSPasteboard *declarePasteboard(id owner) {
NSPasteboard *pb = [NSPasteboard pasteboardWithName:NSDragPboard];
[pb declareTypes:[NSArray arrayWithObject:NSStringPboardType] owner:owner];
[pb setString:@"darling drag check" forType:NSStringPboardType];
return pb;
}
static void runDrag(NSWindow *sourceWindow, DragSource *source) {
NSPasteboard *pb = declarePasteboard(source);
NSImage *image = [[[NSImage alloc] initWithSize:NSMakeSize(32, 32)] autorelease];
NSEvent *down = mouse(NSLeftMouseDown, sourceWindow, NSMakePoint(50, 50));
[sourceWindow dragImage:image at:NSMakePoint(50, 50) offset:NSZeroSize event:down
pasteboard:pb source:source slideBack:YES];
}
static NSPoint inside = {120, 120}; /* inside DropView, in destination window base coordinates */
static NSPoint outside = {-4000, -4000};
int main(int argc, const char **argv) {
@autoreleasepool {
[NSApplication sharedApplication];
pthread_t thread;
beginCase("startup");
pthread_create(&thread, NULL, watchdog, NULL);
NSWindow *sourceWindow = makeWindow(@"Darling drag check source", NSMakeRect(100, 200, 240, 240));
pump(0.3);
int before;
/* 1. Drop onto a destination that lacks prepareForDragOperation:. */
{
beginCase("1 drop onto destination"); before = failures;
DragLog *log = [[DragLog alloc] init]; DragSource *src = [[DragSource alloc] init]; src->log = log;
DropView *view; NSWindow *dw = makeDestination(log, &view); pump(0.2);
expect([view prepareForDragOperation:nil] == YES, "NSView default prepareForDragOperation: is not YES");
NSWindow *dest = dw;
post(mouse(NSLeftMouseDragged, dest, inside));
post(mouse(NSLeftMouseDragged, dest, NSMakePoint(inside.x + 2, inside.y)));
post(mouse(NSLeftMouseUp, dest, inside));
runDrag(sourceWindow, src);
expect(log->entered == 1, "draggingEntered: not called exactly once");
expect(log->performed == 1, "performDragOperation: not called exactly once");
expect(log->concluded == 1, "concludeDragOperation: not called exactly once");
expect(log->ended == 1, "draggedImage:endedAt:operation: not called exactly once");
expect(log->endOperation != NSDragOperationNone, "end operation is None after a successful drop");
[dw close]; [dw release]; [src release]; [log release];
endCase(before);
}
/* 2. Drag leaves the destination and ends outside any window. */
{
beginCase("2 end outside destination"); before = failures;
DragLog *log = [[DragLog alloc] init]; DragSource *src = [[DragSource alloc] init]; src->log = log;
NSWindow *dw = makeDestination(log, NULL); pump(0.2);
post(mouse(NSLeftMouseDragged, dw, inside));
post(mouse(NSLeftMouseDragged, sourceWindow, outside));
post(mouse(NSLeftMouseUp, sourceWindow, outside));
runDrag(sourceWindow, src);
expect(log->entered == 1, "draggingEntered: not called exactly once");
expect(log->exited >= 1, "draggingExited: not called");
expect(log->performed == 0, "performDragOperation: called for a drop outside");
expect(log->ended == 1, "draggedImage:endedAt:operation: not called exactly once");
expect(log->endOperation == NSDragOperationNone, "end operation is not None");
[dw close]; [dw release]; [src release]; [log release];
endCase(before);
}
/* 3. Escape cancels while over the destination. */
{
beginCase("3 escape cancel"); before = failures;
DragLog *log = [[DragLog alloc] init]; DragSource *src = [[DragSource alloc] init]; src->log = log;
NSWindow *dw = makeDestination(log, NULL); pump(0.2);
post(mouse(NSLeftMouseDragged, dw, inside));
post(escapeKey(sourceWindow));
runDrag(sourceWindow, src);
expect(log->performed == 0, "performDragOperation: called after Escape");
expect(log->ended == 1, "draggedImage:endedAt:operation: not called exactly once");
expect(log->endOperation == NSDragOperationNone, "end operation is not None");
[dw close]; [dw release]; [src release]; [log release];
endCase(before);
}
/* 4. Two drags back to back with sources lacking pasteboardChangedOwner:. */
{
beginCase("4 back-to-back drags"); before = failures;
DragLog *log1 = [[DragLog alloc] init], *log2 = [[DragLog alloc] init];
DragSource *s1 = [[DragSource alloc] init], *s2 = [[DragSource alloc] init];
s1->log = log1; s2->log = log2;
expect(![s1 respondsToSelector:@selector(pasteboardChangedOwner:)], "fixture source implements pasteboardChangedOwner:");
NSWindow *dw = makeDestination(log1, NULL); pump(0.2);
post(mouse(NSLeftMouseDragged, dw, inside)); post(mouse(NSLeftMouseUp, dw, inside));
runDrag(sourceWindow, s1);
post(mouse(NSLeftMouseDragged, dw, inside)); post(mouse(NSLeftMouseUp, dw, inside));
runDrag(sourceWindow, s2); /* declaring new owner notifies s1, which must be tolerated */
expect(log1->ended == 1, "first drag did not end exactly once");
expect(log2->ended == 1, "second drag did not end exactly once");
expect(log1->performed == 2, "destination did not receive both drops");
[dw close]; [dw release]; [s1 release]; [s2 release]; [log1 release]; [log2 release];
endCase(before);
}
/* 5. Main queue and run loop timers are serviced during the drag. */
{
beginCase("5 main queue and timer serviced"); before = failures;
DragLog *log = [[DragLog alloc] init]; DragSource *src = [[DragSource alloc] init]; src->log = log;
NSWindow *dw = makeDestination(log, NULL); pump(0.2);
__block BOOL queueFired = NO, timerFired = NO, inDrag = YES;
__block BOOL queueDuring = NO, timerDuring = NO;
CFRunLoopTimerRef timer = CFRunLoopTimerCreateWithHandler(NULL, CFAbsoluteTimeGetCurrent() + 0.03, 0, 0, 0,
^(CFRunLoopTimerRef t) { timerFired = YES; timerDuring = inDrag; });
CFRunLoopAddTimer(CFRunLoopGetMain(), timer, kCFRunLoopDefaultMode);
dispatch_async(dispatch_get_main_queue(), ^{ queueFired = YES; queueDuring = inDrag; });
post(mouse(NSLeftMouseDragged, dw, inside)); post(mouse(NSLeftMouseUp, dw, inside));
runDrag(sourceWindow, src);
inDrag = NO;
expect(queueDuring, queueFired ? "dispatch_async block ran only after the drag returned" : "dispatch_async block never ran");
expect(timerDuring, timerFired ? "run loop timer fired only after the drag returned" : "run loop timer never fired");
CFRunLoopTimerInvalidate(timer); CFRelease(timer);
expect(log->ended == 1, "drag did not end exactly once");
[dw close]; [dw release]; [src release]; [log release];
endCase(before);
}
/* 6. Mouse input reaches views normally after the drags. */
{
beginCase("6 input after drags"); before = failures;
DragLog *log = [[DragLog alloc] init];
DropView *view; NSWindow *dw = makeDestination(log, &view); pump(0.2);
post(mouse(NSLeftMouseDown, dw, inside)); post(mouse(NSLeftMouseUp, dw, inside));
pump(0.3);
expect(log->mouseDowns == 1, "mouse-down was not delivered to the view after drags");
[dw close]; [dw release]; [log release];
endCase(before);
}
/* 7. Destination view removed (and window closed) from a main-queue block mid-drag. */
{
beginCase("7 destination removed mid-drag"); before = failures;
DragLog *log = [[DragLog alloc] init]; DragSource *src = [[DragSource alloc] init]; src->log = log;
DropView *view; NSWindow *dw = makeDestination(log, &view); pump(0.2);
post(mouse(NSLeftMouseDragged, dw, inside)); post(mouse(NSLeftMouseUp, dw, inside));
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 20 * NSEC_PER_MSEC), dispatch_get_main_queue(), ^{
[view removeFromSuperview]; /* the drag loop must have retained it */
[dw orderOut:nil];
[dw close];
});
runDrag(sourceWindow, src);
expect(log->ended == 1, "drag did not end exactly once after the destination went away");
pump(0.2);
[dw release]; [src release]; [log release];
endCase(before);
}
[sourceWindow close]; [sourceWindow release];
printf("%s x11 drag check (%d failure%s)\n", failures ? "FAIL" : "PASS", failures, failures == 1 ? "" : "s");
return failures ? 1 : 0;
}
}
+64 -3
View File
@@ -14,9 +14,22 @@ if [ "${RCLOL_CORE_DUMPS:-0}" != 1 ]; then
fi
runner_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
. "$runner_dir/runtime-paths.sh"
# Opt-in partial recovery: if Darling is alive but a previous client tree is
# windowless, stop only that client/CEF tree and reuse the runtime. Never
# touches the game, darlingserver or the runtime sockets.
if [ "${RCLOL_RECOVER_STALE_CLIENT:-0}" = 1 ]; then
python3 "$runner_dir/recover-stale-client.py"
fi
if [ "${RCLOL_RECOVER_HIDDEN_CLIENT:-0}" = 1 ]; then
python3 "$runner_dir/recover-hidden-riot-client.py"
fi
# Opt-in cleanup of helpers left by an earlier launcher. This runs before the
# current helper is started. It removes only helpers of this prefix whose
# parent is init/systemd, so a helper of a live launcher is not touched.
if [ "${RCLOL_CLEAN_STALE_HELPERS:-0}" = 1 ]; then
python3 "$runner_dir/clean-stale-helpers.py" --prefix "$prefix_dir" ||
echo 'clean-stale-helpers: cleanup failed; continuing' >&2
fi
# The bundled downloader rejects IPv6 CONNECT_TO addresses. Only Riot CDN
# AAAA lookups are suppressed; set this to 0 to retain the upstream resolver.
export RCLOL_CDN_IPV4_ONLY="${RCLOL_CDN_IPV4_ONLY:-1}"
@@ -34,7 +47,9 @@ unset RCLOL_APP_ARG3 RCLOL_APP_ARG3_SLOT RCLOL_UX_ARG1 RCLOL_UX_ARG1_SLOT
unset RCLOL_UX_ARG2 RCLOL_UX_ARG2_SLOT
# The paired runtime presents completed GPU textures without CPU readback.
# Explicit 0 or an empty value selects the CPU fallback for diagnostics.
for option in RCLOL_GPU_DIRECT_PRESENT RCLOL_GPU_DIRECT_ASYNC RCLOL_GPU_DIRECT_VSYNC DARLING_CA_PRESENT_AT_COMMIT DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER RCLOL_FORWARD_MOUSE_MOVED RCLOL_MOUSE_CONFINEMENT; do
# DARLING_METAL_MAC_GPU_NAME reports the MacBookPro16,1 GPU of the host vendor (AMD or Intel) as
# MTLDevice.name, matching the model IOKit reports; memory figures stay the host's.
for option in RCLOL_GPU_DIRECT_PRESENT RCLOL_GPU_DIRECT_ASYNC RCLOL_GPU_DIRECT_VSYNC DARLING_CA_PRESENT_AT_COMMIT DARLING_CA_SKIP_LOCAL_IOSURFACE_RENDER RCLOL_FORWARD_MOUSE_MOVED RCLOL_MOUSE_CONFINEMENT DARLING_METAL_MAC_GPU_NAME; do
eval 'value=${'"$option"'-1}'
case "$value" in 0|'') unset "$option";; *) export "$option=$value";; esac
done
@@ -62,6 +77,33 @@ if [ -z "${RIOT_LAUNCHER_HOST:-}" ] && [ ! -e "$client" ]; then
ln -s "../Users/Shared/Riot Games/Riot Client.app" "$client"
fi
fi
# Opt-in automatic client-in-game handling. Start the watcher only after the
# installed-client check above: a failed installer launch must not leave a
# helper behind. The preference selector returns no action for missing or
# invalid close-during-game values.
if [ -z "${RIOT_LAUNCHER_HOST:-}" ]; then
if [ "${RCLOL_CLIENT_IN_GAME_TRICKS:-0}" = 1 ]; then
pref="$prefix_dir/Applications/League of Legends.app/Contents/LoL/Config/LCULocalPreferences.yaml"
mode=$(python3 "$runner_dir/client-in-game-tricks.py" "$pref" 2>/dev/null || echo none)
case "$mode" in
close)
echo 'client-in-game-tricks: closeLeagueClientDuringGame=true -> close helper' >&2
python3 "$runner_dir/close-league-client-in-game.py" --detach &
;;
hide)
echo 'client-in-game-tricks: closeLeagueClientDuringGame=false -> hide helper' >&2
python3 "$runner_dir/hide-league-client-in-game.py" --detach &
;;
*)
echo 'client-in-game-tricks: no action (client optimizations disabled or preferences missing/invalid)' >&2
;;
esac
elif [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then
python3 "$runner_dir/close-league-client-in-game.py" --detach &
elif [ "${RCLOL_HIDE_CLIENT_IN_GAME:-0}" = 1 ]; then
python3 "$runner_dir/hide-league-client-in-game.py" --detach &
fi
fi
A=${RIOT_LAUNCHER_HOST:-"$client/Contents/MacOS/RiotClientServices"}
# External XIM (for example Fcitx) consumes keys before Darling forwards them.
# Use Xlib's local input method; allow explicit testing of another modifier.
@@ -95,15 +137,34 @@ fi
if [ -n "${RCLOL_REFRESH_HZ-}" ]; then export RCLOL_REFRESH_HZ; fi
# Vulkan WSI & Mesa optimizations
export MESA_NO_ERROR="${MESA_NO_ERROR:-1}"
# Mesa resolves its shader disk cache below $HOME, which inside the guest is
# /Users/<user>: that path does not exist on the host filesystem, so every process logs
# "Failed to create /Users for shader cache (Permission denied)---disabling" and
# recompiles every shader on start (212 such lines in one launcher log). The host Mesa
# loaded into the guest reads MESA_SHADER_CACHE_DIR verbatim, so point it at a host
# directory; Mesa creates mesa_shader_cache/ and radv_builtin_shaders/ below it.
cache_root=${XDG_CACHE_HOME:-${HOME:-$prefix_dir}/.cache}/darling-lol
MESA_SHADER_CACHE_DIR="${MESA_SHADER_CACHE_DIR:=$cache_root}"
export MESA_SHADER_CACHE_DIR
mkdir -p "$MESA_SHADER_CACHE_DIR" 2>/dev/null || true
# NVIDIA proprietary driver: limit pre-rendered frames.
export __GL_MaxFramesAllowed="${__GL_MaxFramesAllowed:-1}"
export __GL_THREADED_OPTIMIZATIONS="${__GL_THREADED_OPTIMIZATIONS:-1}"
# Threaded optimizations stay off: with them, NVIDIA 580 (the last branch for
# Pascal GPUs) with egl-x11 1.0.6 rejects the X11 EGLDisplay with EGL_BAD_DISPLAY
# on every thread that has a context current, and the game window stays black.
export __GL_THREADED_OPTIMIZATIONS="${__GL_THREADED_OPTIMIZATIONS:-0}"
export __GL_YIELD="${__GL_YIELD:-NOTHING}"
# Reduce timer slack for tighter scheduling in the game process tree.
if [ -w /proc/self/timerslack_ns ]; then
echo 1 > /proc/self/timerslack_ns 2>/dev/null || true
fi
exec env -u ELECTRON_RUN_AS_NODE -u DYLD_INSERT_LIBRARIES EGL_PLATFORM=x11 \
# On affected setups Chromium's default temp directory is unusable. Electron
# can copy app.asar entries to temporary files for async reads and report a
# failed copy as "not found". Use a writable guest directory by default.
# exec-guest.py closes host file descriptors above stderr before starting the guest. A shell
# loop cannot: dash only accepts single-digit descriptors in redirections.
exec python3 "$runner_dir/exec-guest.py" env -u ELECTRON_RUN_AS_NODE -u DYLD_INSERT_LIBRARIES EGL_PLATFORM=x11 \
MAC_CHROMIUM_TMPDIR="${MAC_CHROMIUM_TMPDIR:-/private/tmp}" \
LANG="${RCLOL_GUEST_LOCALE:-en_US.UTF-8}" LC_ALL="${RCLOL_GUEST_LOCALE:-en_US.UTF-8}" \
mesa_glthread="${RCLOL_MESA_GLTHREAD-${mesa_glthread-false}}" \
XMODIFIERS="${RCLOL_XMODIFIERS:-@im=none}" DISPLAY="${DISPLAY:-:1}" \
+20
View File
@@ -49,6 +49,13 @@ int main() {
expectName("", MTLDeviceInfo::kFallbackName);
expectName(nullptr, MTLDeviceInfo::kFallbackName);
// -[MTLDevice registryID]: never 0, like a real Mac.
{
bool ok = MTLDeviceInfo::kDefaultRegistryID != 0;
failures += !ok;
std::printf("%s registry ID is non-zero\n", ok ? "PASS" : "FAIL");
}
// DARLING_METAL_GPU_NAME override.
{
bool ok = MTLDeviceInfo::resolveName("My GPU", "AMD Radeon RX 6800 XT (RADV NAVI21)") == "My GPU"
@@ -58,6 +65,19 @@ int main() {
std::printf("%s name override\n", ok ? "PASS" : "FAIL");
}
// DARLING_METAL_MAC_GPU_NAME: MacBookPro16,1 GPUs for AMD and Intel hosts; NVIDIA keeps its name.
{
using MTLDeviceInfo::macGpuName;
bool ok = std::string(macGpuName(0x1002)) == "AMD Radeon Pro 5500M"
&& std::string(macGpuName(0x8086)) == "Intel UHD Graphics 630"
&& macGpuName(0x10de) == nullptr
&& MTLDeviceInfo::resolveName(nullptr, "AMD Radeon RX 6600 (RADV NAVI23)", macGpuName(0x1002)) == "AMD Radeon Pro 5500M"
&& MTLDeviceInfo::resolveName(nullptr, "NVIDIA GeForce GTX 1060 6GB", macGpuName(0x10de)) == "NVIDIA GeForce GTX 1060 6GB"
&& MTLDeviceInfo::resolveName("My GPU", "AMD Radeon RX 6600 (RADV NAVI23)", macGpuName(0x1002)) == "My GPU";
failures += !ok;
std::printf("%s Mac GPU name\n", ok ? "PASS" : "FAIL");
}
// DARLING_METAL_VRAM_MB parsing: positive decimal integer only.
const unsigned long long MiB = 1024ull * 1024ull;
expectBytes("vram \"8192\"", MTLDeviceInfo::parseMegabytes("8192"), 8192 * MiB);
+2 -2
View File
@@ -57,7 +57,7 @@
"revision": "c8d38d16a9f613d300157bebbab2b9501bc0c274",
"repository": "https://github.com/darlinghq/darling-cocotron.git",
"patch": "patches/cocotron.patch",
"modified_files": 104,
"modified_files": 115,
"new_files": [
"QuartzCore/include/QuartzCore/CATransformLayer.h",
"AppKit/NSGeometryTrace.h"
@@ -274,7 +274,7 @@
"revision": "fa29287aa2f0115271e091f1031f53c9e024005d",
"repository": "https://github.com/darlinghq/darling-xnu.git",
"patch": "patches/xnu.patch",
"modified_files": 64,
"modified_files": 65,
"new_files": [
"darling/src/libsystem_kernel/emulation/include/common/cpu_topology.h",
"darling/src/libsystem_kernel/emulation/include/common/private_mutex.h",