diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index 3e91493..a866b9d 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -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_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' diff --git a/darling-lol.svg b/darling-lol.svg index 7ec69b9..1916ca4 100644 --- a/darling-lol.svg +++ b/darling-lol.svg @@ -1,5 +1,5 @@ - - darling-lol + + Darling Launcher diff --git a/docs/NIX.md b/docs/NIX.md index 2cde500..0323f61 100644 --- a/docs/NIX.md +++ b/docs/NIX.md @@ -91,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. diff --git a/docs/SETUP.md b/docs/SETUP.md index 4d85c6b..0c0d39b 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -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` diff --git a/docs/TESTING.md b/docs/TESTING.md index 63cdd2a..1e80d10 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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,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 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: diff --git a/flake.nix b/flake.nix index cc37e30..322e2e9 100644 --- a/flake.nix +++ b/flake.nix @@ -339,6 +339,10 @@ DESKTOP_EOF 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" diff --git a/tests/test_desktop_entry.py b/tests/test_desktop_entry.py new file mode 100644 index 0000000..4806f3e --- /dev/null +++ b/tests/test_desktop_entry.py @@ -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 = '' +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() diff --git a/tools/desktop-entry.py b/tools/desktop-entry.py index 0aa6c76..e77d1a0 100755 --- a/tools/desktop-entry.py +++ b/tools/desktop-entry.py @@ -8,14 +8,84 @@ 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" -def remove_legacy_entry(path: Path) -> None: - """Remove a previous launcher entry only when this project created it.""" +# 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: - if path.exists() and "desktop-run.sh" in path.read_text(errors="ignore"): - path.unlink() + 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(): @@ -35,32 +105,42 @@ def main(): # Install the project's original icon under a neutral name. Do not extract # or install an icon from the game/client installation. - svg_target = scalable_dir / "darling-launcher.svg" - png_target = 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") + 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. - remove_legacy_entry(apps_base / "league-of-legends.desktop") - for legacy_icon in ( - icons_base / "league-of-legends.svg", - icons_base / "league-of-legends.png", - scalable_dir / "league-of-legends.svg", - icons_base / "hicolor/512x512/apps/league-of-legends.png", - ): - try: - if legacy_icon.exists(): - legacy_icon.unlink() - except OSError: - pass + # 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 - # Create the desktop entry. Keep StartupWMClass: it is the client's real - # window class and is required for the compositor to match the window. - 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 + 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 @@ -68,12 +148,12 @@ Name=Darling Launcher GenericName=Game Comment=Unofficial launcher for the macOS game client via Darling Exec={exec_cmd} -Path={PROJECT_ROOT} +Path={project_path} Icon=darling-launcher Terminal=false -Categories=Game;ActionGame;StrategyGame; -Keywords=darling;launcher;compatibility; -StartupWMClass=Riot Client +Categories=Game; +Keywords=Darling;Launcher;Compatibility; +StartupWMClass=RiotClientServices StartupNotify=false """ @@ -82,22 +162,8 @@ StartupNotify=false 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: - remove_legacy_entry(ddir / "league-of-legends.desktop") - desktop_shortcut = ddir / "darling-launcher.desktop" + 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")