diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index d8c96b5..a89d709 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -29,6 +29,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/README.md b/README.md index 9176f73..b6144f7 100644 --- a/README.md +++ b/README.md @@ -152,4 +152,7 @@ repository. See [compliance and terminology guidance](docs/COMPLIANCE.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) 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 ef8a7b5..322e2e9 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "Unofficial 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"; @@ -136,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. @@ -223,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 \ @@ -235,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" @@ -245,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=Unofficial League of Legends macOS client compatibility layer for Linux via Darling -Exec=darling-lol -GenericName=MOBA Game -Icon=darling-lol -Keywords=League;LoL;Riot;Darling; -Name=League of Legends (unofficial) +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 @@ -264,7 +265,7 @@ DESKTOP_EOF ''; meta = with pkgs.lib; { - description = "Unofficial 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" ]; @@ -334,7 +335,16 @@ DESKTOP_EOF # 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 + 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" ''; }; @@ -349,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"; @@ -359,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"; @@ -371,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." ''; }; diff --git a/scripts/create-desktop-entry.sh b/scripts/create-desktop-entry.sh index 81b8c22..dbdb7a2 100755 --- a/scripts/create-desktop-entry.sh +++ b/scripts/create-desktop-entry.sh @@ -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 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 new file mode 100755 index 0000000..e77d1a0 --- /dev/null +++ b/tools/desktop-entry.py @@ -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() diff --git a/tools/riot-desktop-entry.py b/tools/riot-desktop-entry.py deleted file mode 100755 index 049e94d..0000000 --- a/tools/riot-desktop-entry.py +++ /dev/null @@ -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 = """ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -""" - - -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()