From 906e60a042fc632e3d07dadc464b9f1ecbec9aeb Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:33:15 -0500 Subject: [PATCH] feat(desktop): show programs installed by Windows installers (#2146) * Desktop: programs a Windows installer adds get a menu entry and desktop icon Opening a Windows installer on the desktop runs it with Proton in a prefix of its own (droiddeck-proton-run's "own" prefix). Proton's menu builder turns the installer's Start Menu links into .desktop files, but keeps them in the prefix's drive_c/proton_shortcuts with a Windows .lnk path as Exec, and nothing outside the prefix pointed at the installed program: Nexon Launcher installed fine on an AYANEO Pocket FIT Elite and could only be found by browsing the hidden compatdata folder. droiddeck-desktop-programs reads proton_shortcuts in every prefix that is neither a Steam game's nor a current Steam shortcut's, resolves each link to its program inside the prefix, and writes a menu entry that opens it with droiddeck-open, so it runs in that same prefix, with the icon Proton extracted. A program whose installer also put a link on the Windows desktop gets a desktop icon once; one the user deletes is not put back. Entries go away with their program; uninstallers get none. It runs when the desktop starts and after droiddeck-open finishes a Windows program. * Desktop programs: only programs that run in their prefix, Windows argument rules, one refresh at a time A link resolved through Proton's Z: mapping could name a program outside the scanned prefix, and droiddeck-open would then run it in another one. A program is now listed only when droiddeck-proton-run's prefix_of puts it in the prefix it was found in. Shortcut arguments were split with shlex, so a Windows argument with an apostrophe raised and stopped the whole refresh; they are now split by the Windows command-line rules. The desktop start and the end of a Windows program can refresh at the same time and race on the same temporary files; refreshes now take turns on a lock beside the placement record. * Desktop programs: extract the script at session start SessionFiles lists the overlay scripts the app copies into the runtime; droiddeck-desktop-programs was missing, so a real build would never have installed it and check_session_assets failed the build. * Desktop programs: a compatdata folder it cannot read changes nothing Any error listing steamapps/compatdata made the refresh see no prefixes, so it removed every program entry and forgot which desktop icons it had placed; the next good run then put back icons the user had deleted. Only a missing compatdata now means there is nothing installed; any other read error leaves the menu, the desktop and the placement record as they were. * Desktop programs: an unreadable shortcut or desktop folder changes nothing os.path.isdir and glob read an unreadable proton_shortcuts or Windows desktop folder as empty, so the refresh dropped that prefix's entries and placement records and a later run put back icons the user had deleted. Those folders are now listed directly: a missing one is still empty, and any other error leaves the menu, the desktop and the record as they were. * Desktop programs: only a missing file reads as absent read(), link_target() and host_path()'s letter-case lookup turned any error into "absent", so an unreadable placement record read as empty and brought back icons the user had deleted, and an unreadable shortcut or link dropped its program's entry. A missing file, a wrong file type and a malformed link still read as absent; any other error now reaches main(), which leaves the menu, the desktop and the record as they were. * Desktop programs: keep the placement record true when a run fails part way The placement record was read after the menu was already rewritten, so an unreadable record changed the menu although the run reported it had not. It is now read first. When writing desktop icons failed part way, the icons already written were not recorded, and the next run recreated any of them the user had deleted. The record is now written from the icons that exist after the attempt, icons are written in a fixed order, and a failed write leaves no temporary file on the desktop. os.path.exists and isfile also read a program whose path cannot be reached as missing; a stat that fails only because the file is absent now counts as missing, and any other error leaves everything as it was. * Desktop programs: leave the menu alone when Steam's shortcuts cannot be read The list of Steam shortcuts comes from a reader that treats an unreadable or damaged shortcuts.vdf as having no shortcuts, so a Steam shortcut's prefix could get a menu entry and a desktop icon of its own. The file is now checked first: a missing one still means no shortcuts, and any other read or parse failure leaves the menu and the desktop as they were. * Desktop programs: an unreadable Steam account or empty shortcuts file also stops the refresh account_dir takes an unreadable loginusers.vdf or userdata folder as missing, so the shortcuts check could be skipped and a Steam shortcut's prefix still get its own entry. Both are now read first, and an access error leaves the menu as it was. An empty shortcuts.vdf is now parsed like any other, so it stops the refresh too, the same way droiddeck-steam-shortcuts refuses to rewrite it. --------- Co-authored-by: K11MCH1 <81425424+K11MCH1@users.noreply.github.com> --- .../launcher/session/SessionFiles.kt | 1 + tools/linuxfs/desktop/droiddeck-desktop | 1 + .../usr/local/bin/droiddeck-desktop-programs | 317 +++++++++++++++ .../overlay/usr/local/bin/droiddeck-open | 6 + tools/tests/test_desktop_programs.py | 370 ++++++++++++++++++ 5 files changed, 695 insertions(+) create mode 100755 tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-programs create mode 100644 tools/tests/test_desktop_programs.py diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index b65f3ac72..3b0172136 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -72,6 +72,7 @@ object SessionFiles { "usr/local/bin/droiddeck-login1" to "usr/local/bin/droiddeck-login1", "usr/local/bin/droiddeck-steam-launch" to "usr/local/bin/droiddeck-steam-launch", "usr/local/bin/droiddeck-desktop-games" to "usr/local/bin/droiddeck-desktop-games", + "usr/local/bin/droiddeck-desktop-programs" to "usr/local/bin/droiddeck-desktop-programs", "usr/local/bin/droiddeck-desktop-bookmarks" to "usr/local/bin/droiddeck-desktop-bookmarks", "usr/local/bin/droiddeck-downloads" to "usr/local/bin/droiddeck-downloads", "usr/local/bin/droiddeck-root" to "usr/local/bin/droiddeck-root", diff --git a/tools/linuxfs/desktop/droiddeck-desktop b/tools/linuxfs/desktop/droiddeck-desktop index 799a3dee8..c36083d83 100644 --- a/tools/linuxfs/desktop/droiddeck-desktop +++ b/tools/linuxfs/desktop/droiddeck-desktop @@ -31,6 +31,7 @@ export BL_DESKTOP=1 ln -sf /usr/local/bin/droiddeck-steam-launch /usr/local/bin/steamdeck-steam 2>/dev/null ln -sf /usr/local/bin/droiddeck-steam-shim /usr/local/bin/steam 2>/dev/null python3 /usr/local/bin/droiddeck-desktop-games 2>&1 | sed 's/^/[games] /' +python3 /usr/local/bin/droiddeck-desktop-programs 2>&1 | sed 's/^/[programs] /' # startplasma applies this file over the session environment, and keeps what an earlier session wrote. kwriteconfig6 --file plasma-localerc --group Formats --key LANG "${LANG:-C.UTF-8}" if [ -n "${LANGUAGE:-}" ]; then diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-programs b/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-programs new file mode 100755 index 000000000..8c72b0cef --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-programs @@ -0,0 +1,317 @@ +#!/usr/bin/python3 +import fcntl +import glob +import os +import re +import runpy +import stat +import struct +import subprocess +import sys +import zlib + +HOME = os.environ.get("HOME", "/root") +STEAM_ROOT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HOME, ".local/share/Steam") +BIN_DIR = os.path.dirname(os.path.abspath(__file__)) +OPEN = os.path.join(BIN_DIR, "droiddeck-open") +APPS_DIR = os.path.join(HOME, ".local/share/applications") +PLACED = os.path.join(HOME, ".local/share/droiddeck/desktop-programs-placed") +PREFIX = "droiddeck-program-" +SHORTCUT_BIT = 0x80000000 +WINDOWS_DESKTOPS = ("users/Public/Desktop", "users/steamuser/Desktop") +NOT_PROGRAMS = re.compile(r"(?i)^unins") +ABSENT = (FileNotFoundError, NotADirectoryError, IsADirectoryError) + +_run = None + + +def present(path, regular=False): + try: + mode = os.stat(path).st_mode + except ABSENT: + return False + return stat.S_ISREG(mode) if regular else True + + +def read(path): + try: + with open(path, encoding="utf-8", errors="replace") as f: + return f.read() + except ABSENT: + return None + + +def proton_run(): + global _run + if _run is None: + _run = runpy.run_path(os.path.join(BIN_DIR, "droiddeck-proton-run")) + return _run + + +def shortcut_ids(root): + tool = runpy.run_path(os.path.join(BIN_DIR, "droiddeck-steam-shortcuts")) + # account_dir takes either of these as missing when it cannot be read. + read(os.path.join(root, "config", "loginusers.vdf")) + listing(os.path.join(root, "userdata")) + account = tool["account_dir"](root) + if account: + try: + with open(os.path.join(account, "config", "shortcuts.vdf"), "rb") as f: + tool["parse"](f.read()) + except ABSENT: + pass + except (ValueError, IndexError, KeyError, struct.error) as error: + raise OSError("Steam's shortcuts.vdf cannot be read: %s" % error) from error + return {appid for appid, _, _ in proton_run()["shortcut_entries"](root)} + + +def runs_here(pfx, exe): + target = proton_run()["prefix_of"](exe) + return target is not None and os.path.realpath(target.compatdata) == os.path.realpath(os.path.dirname(pfx)) + + +def listing(folder, suffix=""): + try: + names = os.listdir(folder) + except ABSENT: + return None + return sorted(os.path.join(folder, name) for name in names if name.lower().endswith(suffix)) + + +def prefixes(root): + compatdata = os.path.join(root, "steamapps", "compatdata") + names = listing(compatdata) + if names is None: + return [] + owned = shortcut_ids(root) + found = [] + for name in (os.path.basename(path) for path in names): + if not re.fullmatch(r"-?[0-9]+", name): + continue + appid = int(name) % 2 ** 32 + pfx = os.path.join(compatdata, name, "pfx") + if appid & SHORTCUT_BIT and appid not in owned and listing(os.path.join(pfx, "drive_c", "proton_shortcuts")) is not None: + found.append((appid, pfx)) + return found + + +def zero_terminated(data, start, wide): + if wide: + end = start + while data[end:end + 2] not in (b"\0\0", b""): + end += 2 + return data[start:end].decode("utf-16-le", "replace") + end = data.find(b"\0", start) + return data[start:end if end >= 0 else len(data)].decode("cp1252", "replace") + + +def link_target(path): + try: + with open(path, "rb") as f: + data = f.read(1 << 16) + size, = struct.unpack_from("= 0x24: + local_wide, suffix_wide = struct.unpack_from("<2I", data, pos + 0x1C) + target = zero_terminated(data, pos + local_wide, True) + zero_terminated(data, pos + suffix_wide, True) + else: + target = zero_terminated(data, pos + local, False) + zero_terminated(data, pos + suffix, False) + pos += info_size + args = "" + wide = bool(flags & 0x80) + for bit in (0x4, 0x8, 0x10, 0x20, 0x40): + if flags & bit: + count, = struct.unpack_from("= 2 and exec_value[0] == exec_value[-1] == '"': + exec_value = exec_value[1:-1] + # A desktop file escapes a backslash once for the key and again inside a quoted Exec argument. + return exec_value.replace("\\\\", "\\").replace("\\\\", "\\") + + +def icon(shortcuts, name): + best, size = "system-run", 0 + for apps in glob.glob(os.path.join(glob.escape(shortcuts), "icons", "*", "apps")): + found = re.fullmatch(r"(\d+)x\d+", os.path.basename(os.path.dirname(apps))) + path = os.path.join(apps, name + ".png") + if name and found and int(found.group(1)) > size and os.path.isfile(path): + best, size = path, int(found.group(1)) + return best + + +def program(pfx, link): + target, args = link_target(link) + exe = host_path(pfx, target) + if (exe and exe.lower().endswith(".exe") and not NOT_PROGRAMS.match(os.path.basename(exe)) and present(exe, regular=True) + and runs_here(pfx, exe)): + return exe, args + return None, "" + + +def programs(pfx): + shortcuts = os.path.join(pfx, "drive_c", "proton_shortcuts") + found = {} + for entry in listing(shortcuts, ".desktop") or []: + text = read(entry) or "" + link = host_path(pfx, windows_link(value(text, "Exec"))) + if not link or not link.lower().endswith(".lnk"): + continue + exe, args = program(pfx, link) + if exe: + found[exe] = dict(name=value(text, "Name") or os.path.splitext(os.path.basename(entry))[0], args=args, + icon=icon(shortcuts, value(text, "Icon")), wm_class=value(text, "StartupWMClass")) + return found + + +def on_windows_desktop(pfx): + found = set() + for folder in WINDOWS_DESKTOPS: + for link in listing(os.path.join(pfx, "drive_c", folder), ".lnk") or []: + exe, _ = program(pfx, link) + if exe: + found.add(exe) + return found + + +def windows_args(text): + args, current, started, quoted, slashes = [], "", False, False, 0 + for ch in text: + if ch == "\\": + slashes += 1 + started = True + elif ch == '"': + current += "\\" * (slashes // 2) + ('"' if slashes % 2 else "") + if slashes % 2 == 0: + quoted = not quoted + slashes, started = 0, True + else: + current += "\\" * slashes + slashes = 0 + if ch in " \t" and not quoted: + if started: + args.append(current) + current, started = "", False + else: + current += ch + started = True + if started: + args.append(current + "\\" * slashes) + return args + + +def quoted(arg): + escaped = re.sub(r'([\\"`$])', r"\\\1", arg).replace("%", "%%") + return '"%s"' % escaped.replace("\\", "\\\\") + + +def entry(exe, found): + args = windows_args(found["args"]) + lines = ["[Desktop Entry]", "Type=Application", "Name=%s" % found["name"].replace("\n", " "), + "Comment=Windows program installed from the desktop", + "Exec=%s" % " ".join(quoted(arg) for arg in [OPEN, exe] + args), + "Icon=%s" % found["icon"], "Terminal=false", "Categories=Game;"] + if found["wm_class"]: + lines.append("StartupWMClass=%s" % found["wm_class"]) + return "\n".join(lines) + "\n" + + +def desktop_dir(): + try: + path = subprocess.run(["xdg-user-dir", "DESKTOP"], capture_output=True, text=True, timeout=5).stdout.strip() + except (OSError, subprocess.SubprocessError): + path = "" + return path if path and path != HOME else os.path.join(HOME, "Desktop") + + +def sync(directory, wanted, mode=0o644): + os.makedirs(directory, exist_ok=True) + for name in os.listdir(directory): + if name.startswith(PREFIX) and name.endswith(".desktop") and name not in wanted: + os.unlink(os.path.join(directory, name)) + for name, body in wanted.items(): + path = os.path.join(directory, name) + if read(path) != body: + try: + with open(path + ".tmp", "w", encoding="utf-8") as f: + f.write(body) + os.chmod(path + ".tmp", mode) + os.replace(path + ".tmp", path) + except OSError: + if os.path.lexists(path + ".tmp"): + os.unlink(path + ".tmp") + raise + + +def main(): + os.makedirs(os.path.dirname(PLACED), exist_ok=True) + with open(PLACED + ".lock", "w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + try: + refresh() + except OSError as error: + print("droiddeck-desktop-programs: the menu is left as it was (%s)" % error) + + +def refresh(): + menu, desktop_wanted = {}, set() + for appid, pfx in prefixes(STEAM_ROOT): + on_desktop = on_windows_desktop(pfx) + for exe, found in programs(pfx).items(): + name = "%s%d-%08x.desktop" % (PREFIX, appid, zlib.crc32(exe.encode("utf-8"))) + menu[name] = entry(exe, found) + if exe in on_desktop: + desktop_wanted.add(name) + placed = set((read(PLACED) or "").split()) + sync(APPS_DIR, menu) + desktop = desktop_dir() + shown = {name: menu[name] for name in sorted(desktop_wanted) + if name not in placed or present(os.path.join(desktop, name), regular=True)} + try: + sync(desktop, shown, 0o755) + finally: + written = {name for name in shown if present(os.path.join(desktop, name), regular=True)} + with open(PLACED + ".tmp", "w", encoding="utf-8") as f: + f.write("".join(name + "\n" for name in sorted((placed | written) & set(menu)))) + os.replace(PLACED + ".tmp", PLACED) + print("droiddeck-desktop-programs: %d program%s in the menu, %d on the desktop" % ( + len(menu), "" if len(menu) == 1 else "s", len(shown))) + + +if __name__ == "__main__": + main() diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-open b/tools/linuxfs/overlay/usr/local/bin/droiddeck-open index 0edd30212..45f806297 100644 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-open +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-open @@ -17,6 +17,7 @@ PROTON_RUN = os.path.join(BIN, "droiddeck-proton-run") APPIMAGE_RUN = os.path.join(BIN, "droiddeck-appimage-run") SCRIPT_RUN = os.path.join(BIN, "droiddeck-script-run") STEAM_LAUNCH = os.path.join(BIN, "droiddeck-steam-launch") +PROGRAMS = os.path.join(BIN, "droiddeck-desktop-programs") CACHE = os.path.join(HOME, ".cache", "droiddeck") IMPORTS = {"appimage": ("/opt/appimages/user", "app"), "folder": ("/opt/droiddeck-apps", "entry")} SHARED_FOLDERS = ("alarms", "android", "audiobooks", "dcim", "documents", "download", "movies", "music", @@ -395,6 +396,11 @@ def main(argv): if status != 0 and time.monotonic() - started < QUICK_FAILURE: notify("%s did not start" % os.path.basename(path), "It exited with status %d; the session's desktop.log says why." % status) + if what == "windows": + try: + subprocess.call([sys.executable, PROGRAMS]) + except OSError as error: + say("%s: %s" % (PROGRAMS, error)) return status diff --git a/tools/tests/test_desktop_programs.py b/tools/tests/test_desktop_programs.py new file mode 100644 index 000000000..8abd12842 --- /dev/null +++ b/tools/tests/test_desktop_programs.py @@ -0,0 +1,370 @@ +import contextlib +import os +from pathlib import Path +import runpy +import shutil +import stat +import struct +import sys +import tempfile +import unittest +from unittest import mock + +BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin" +PROGRAMS = runpy.run_path(str(BIN / "droiddeck-desktop-programs"))["main"].__globals__ +SHORTCUT_IDS = PROGRAMS["shortcut_ids"] +OPEN = runpy.run_path(str(BIN / "droiddeck-open"))["main"].__globals__ + +NEXON = 3908901178 + + +def lnk(target, args="", wide_info=False): + flags = 0x2 | 0x80 | (0x20 if args else 0) + header = bytearray(76) + struct.pack_into("