fix(launcher): only remove launcher artifacts this project created

Match the legacy entry against this checkout's desktop-run.sh instead of any
desktop-run.sh, and delete the legacy icon files only after that entry or the
old artwork proves ownership.  Quote and escape Exec arguments as the Desktop
Entry specification requires, so a checkout path with reserved characters no
longer produces an invalid entry, and escape the Path value.

Use StartupWMClass=RiotClientServices in both entries: Darling derives WM_CLASS
from -[NSProcessInfo processName], the basename of argv[0], and the launcher
starts .../RiotClientServices.  "Riot Client" is only the bundle display name,
so the checkout entry never matched the window.  Both entries now share
Name/GenericName/Comment/Icon/Keywords/Categories and a test keeps them in
sync; Terminal stays different because the checkout entry logs to
build/desktop-launch.log while the packaged entry runs the session in a
terminal.

Also drop the stray top-level icon copies, handle dangling legacy symlinks,
name the icon for assistive technology, document the alias and menu entry, and
cover the helper with disposable-HOME tests.
This commit is contained in:
lucannez64 committed 2026-10-21 17:38:42 +02:00
1 parent 0947a9f20d
commit e00ee99522
8 files changed
+295 -55

No files matched your search

+1
View File
@@ -25,6 +25,7 @@ jobs:
- run: python -m unittest discover -s tests -p 'test_nix_repairs.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_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_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_urlsession_runner.py'
- run: python -m unittest discover -s tests -p 'test_system_profiler.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_metal_device_info.py'
+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"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Darling Launcher">
<title>darling-lol</title> <title>Darling Launcher</title>
<rect x="8" y="8" width="112" height="112" rx="26" fill="#0f172a"/> <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"/> <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"/> <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

+3
View File
@@ -91,6 +91,9 @@ darling-lol --installer
darling-lol-shell 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 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 `~/.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. systems may need this desktop-session integration after installing Nix.
+9 -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 ## Application-menu diagnostics and recovery options
`scripts/create-desktop-entry.sh` creates an application-menu entry that runs `scripts/create-desktop-entry.sh` creates a `Darling Launcher` application-menu
`scripts/desktop-run.sh`, preserving the normal launcher behavior and saving entry that runs `scripts/desktop-run.sh`, preserving the normal launcher behavior
stdout/stderr in `build/desktop-launch.log` with mode 0600. Logs can contain and saving stdout/stderr in `build/desktop-launch.log` with mode 0600. Logs can
private session details; do not attach raw launcher logs to a public issue. 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. 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` `RCLOL_CDN_IPV4_ONLY` defaults to `1` in the Riot launcher and can be set to `0`
+5 -1
View File
@@ -78,6 +78,7 @@ Host-only checks, which do not start Darling:
python3 tools/riot-cdn-dns-offline-check.py 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_hidden_riot_recovery.py'
python3 -m unittest discover -s tests -p 'test_desktop_run.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' python3 -m unittest discover -s tests -p 'test_urlsession_runner.py'
``` ```
@@ -87,7 +88,10 @@ insensitivity, A queries, non-CDN names and misleading domain suffixes without
sending a DNS query. Recovery tests use fake `/proc` entries and mock every sending a DNS query. Recovery tests use fake `/proc` entries and mock every
compositor/shutdown command. The desktop test checks exit/argument forwarding, compositor/shutdown command. The desktop test checks exit/argument forwarding,
append-only output and tightening an existing public log to mode 0600. The 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. private-prefix-only shutdown/cleanup.
With the pinned Darling sources available, run the host resolver regression: With the pinned Darling sources available, run the host resolver regression:
+4
View File
@@ -339,6 +339,10 @@ DESKTOP_EOF
grep -q '^Name=Darling Launcher$' ${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 '^Icon=darling-launcher$' ${darlingLol}/share/applications/darling-launcher.desktop
grep -q '^Keywords=Darling;Launcher;Compatibility;$' ${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 ! grep -qiE '^(Name|Comment|GenericName|Keywords)=.*(League|LoL|Riot|Vanguard|anti-cheat)' ${darlingLol}/share/applications/darling-launcher.desktop
test -x ${darlingLol}/bin/darling-launcher test -x ${darlingLol}/bin/darling-launcher
touch "$out" touch "$out"
+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()
+114 -48
View File
@@ -8,14 +8,84 @@ from pathlib import Path
PROJECT_ROOT = Path("@PROJECT_ROOT@") PROJECT_ROOT = Path("@PROJECT_ROOT@")
HOST_HOME = Path("@HOST_HOME@") 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"
def remove_legacy_entry(path: Path) -> None: # Distinctive fragments of the artwork the previous helper wrote.
"""Remove a previous launcher entry only when this project created it.""" 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: try:
if path.exists() and "desktop-run.sh" in path.read_text(errors="ignore"): text = path.read_text(errors="ignore")
path.unlink()
except OSError: 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 pass
for candidate in [HOST_HOME / "Desktop"]:
if candidate.is_dir() and candidate not in directories:
directories.append(candidate)
return directories
def main(): def main():
@@ -35,32 +105,42 @@ def main():
# Install the project's original icon under a neutral name. Do not extract # Install the project's original icon under a neutral name. Do not extract
# or install an icon from the game/client installation. # or install an icon from the game/client installation.
svg_target = scalable_dir / "darling-launcher.svg" shutil.copyfile(source_svg, scalable_dir / "darling-launcher.svg")
png_target = png_dir / "darling-launcher.png" shutil.copyfile(source_png, png_dir / "darling-launcher.png")
shutil.copyfile(source_svg, svg_target)
shutil.copyfile(source_png, png_target)
shutil.copyfile(source_svg, icons_base / "darling-launcher.svg")
shutil.copyfile(source_png, icons_base / "darling-launcher.png")
# Remove artifacts created by older versions of this helper. # Remove artifacts created by older versions of this helper. Only files
remove_legacy_entry(apps_base / "league-of-legends.desktop") # proven to be ours are deleted: an entry that pointed at this checkout's
for legacy_icon in ( # desktop-run.sh, and the SVG with the old artwork. The old PNG was
icons_base / "league-of-legends.svg", # extracted from the client and cannot be verified, so it is removed only
icons_base / "league-of-legends.png", # when one of those proofs succeeded.
scalable_dir / "league-of-legends.svg", owned = remove_legacy_entry(apps_base / "league-of-legends.desktop")
icons_base / "hicolor/512x512/apps/league-of-legends.png", desktop_dirs = shortcut_directories()
): for directory in desktop_dirs:
try: owned = remove_legacy_entry(directory / "league-of-legends.desktop") or owned
if legacy_icon.exists(): legacy_svgs = (scalable_dir / "league-of-legends.svg", icons_base / "league-of-legends.svg")
legacy_icon.unlink() if any(legacy_svg_owned(path) for path in legacy_svgs):
except OSError: owned = True
pass
# Create the desktop entry. Keep StartupWMClass: it is the client's real if owned:
# window class and is required for the compositor to match the window. for legacy_icon in (
run_script = PROJECT_ROOT / "scripts/desktop-run.sh" *legacy_svgs,
exec_path = str(run_script).replace("%", "%%") icons_base / "league-of-legends.png",
exec_cmd = f'"{exec_path}"' if " " in exec_path else exec_path 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] desktop_content = f"""[Desktop Entry]
Version=1.0 Version=1.0
Type=Application Type=Application
@@ -68,12 +148,12 @@ Name=Darling Launcher
GenericName=Game GenericName=Game
Comment=Unofficial launcher for the macOS game client via Darling Comment=Unofficial launcher for the macOS game client via Darling
Exec={exec_cmd} Exec={exec_cmd}
Path={PROJECT_ROOT} Path={project_path}
Icon=darling-launcher Icon=darling-launcher
Terminal=false Terminal=false
Categories=Game;ActionGame;StrategyGame; Categories=Game;
Keywords=darling;launcher;compatibility; Keywords=Darling;Launcher;Compatibility;
StartupWMClass=Riot Client StartupWMClass=RiotClientServices
StartupNotify=false StartupNotify=false
""" """
@@ -82,22 +162,8 @@ StartupNotify=false
os.chmod(desktop_file, 0o755) os.chmod(desktop_file, 0o755)
print(f"Created application launcher: {desktop_file}") print(f"Created application launcher: {desktop_file}")
target_desktop_dirs = [] for directory in desktop_dirs:
try: desktop_shortcut = directory / "darling-launcher.desktop"
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:
remove_legacy_entry(ddir / "league-of-legends.desktop")
desktop_shortcut = ddir / "darling-launcher.desktop"
desktop_shortcut.write_text(desktop_content) desktop_shortcut.write_text(desktop_content)
os.chmod(desktop_shortcut, 0o755) os.chmod(desktop_shortcut, 0o755)
gio = shutil.which("gio") gio = shutil.which("gio")