mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
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>
This commit is contained in:
1 parent
9d2580d502
commit
906e60a042
5 files changed
+695
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("<I", data, 0)
|
||||
flags, = struct.unpack_from("<I", data, 0x14)
|
||||
pos, target = size, None
|
||||
if flags & 0x1:
|
||||
pos += 2 + struct.unpack_from("<H", data, pos)[0]
|
||||
if flags & 0x2:
|
||||
info_size, header, info_flags, _, local, _, suffix = struct.unpack_from("<7I", data, pos)
|
||||
if info_flags & 0x1:
|
||||
if header >= 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("<H", data, pos)
|
||||
end = pos + 2 + count * (2 if wide else 1)
|
||||
if bit == 0x20:
|
||||
args = data[pos + 2:end].decode("utf-16-le" if wide else "cp1252", "replace").rstrip("\0")
|
||||
pos = end
|
||||
return target, args
|
||||
except ABSENT + (struct.error, ValueError):
|
||||
return None, ""
|
||||
|
||||
|
||||
def host_path(pfx, windows):
|
||||
found = re.fullmatch(r"([A-Za-z]):\\?(.*)", windows or "")
|
||||
if not found:
|
||||
return None
|
||||
path = os.path.realpath(os.path.join(pfx, "dosdevices", found.group(1).lower() + ":"))
|
||||
for part in (p for p in found.group(2).split("\\") if p):
|
||||
exact = os.path.join(path, part)
|
||||
if not os.path.lexists(exact):
|
||||
try:
|
||||
exact = next((os.path.join(path, name) for name in os.listdir(path) if name.lower() == part.lower()), exact)
|
||||
except ABSENT:
|
||||
return None
|
||||
path = exact
|
||||
return path if present(path) else None
|
||||
|
||||
|
||||
def value(text, key):
|
||||
found = re.search(r"^%s=(.*)$" % re.escape(key), text, re.M)
|
||||
return found.group(1).strip() if found else ""
|
||||
|
||||
|
||||
def windows_link(exec_value):
|
||||
if len(exec_value) >= 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()
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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("<I", header, 0, 76)
|
||||
struct.pack_into("<I", header, 0x14, flags)
|
||||
local = target.encode("cp1252") + b"\0"
|
||||
if wide_info:
|
||||
wide = target.encode("utf-16-le") + b"\0\0"
|
||||
size = 36 + len(local) + len(wide) + 2
|
||||
info = struct.pack("<9I", size, 36, 1, 0, 36, 0, 36 + len(local) - 1, 36 + len(local), size - 2) + local + wide + b"\0\0"
|
||||
else:
|
||||
info = struct.pack("<7I", 28 + len(local), 28, 1, 0, 28, 0, 28 + len(local) - 1) + local
|
||||
strings = b""
|
||||
if args:
|
||||
strings = struct.pack("<H", len(args) + 1) + (args + "\0").encode("utf-16-le")
|
||||
return bytes(header) + info + strings
|
||||
|
||||
|
||||
class ProgramsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.tmp, True)
|
||||
self.home = self.tmp / "home"
|
||||
self.steam = self.home / ".local/share/Steam"
|
||||
self.apps = self.home / ".local/share/applications"
|
||||
self.desktop = self.home / "Desktop"
|
||||
self.shortcuts = set()
|
||||
patch = mock.patch.dict(PROGRAMS, {
|
||||
"STEAM_ROOT": str(self.steam), "APPS_DIR": str(self.apps),
|
||||
"PLACED": str(self.home / ".local/share/droiddeck/desktop-programs-placed"),
|
||||
"desktop_dir": lambda: str(self.desktop), "shortcut_ids": lambda root: self.shortcuts,
|
||||
"print": lambda *a: None,
|
||||
})
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
|
||||
def prefix(self, appid):
|
||||
pfx = self.steam / "steamapps/compatdata" / str(appid) / "pfx"
|
||||
(pfx / "drive_c").mkdir(parents=True)
|
||||
(pfx / "dosdevices").mkdir()
|
||||
os.symlink("../drive_c", pfx / "dosdevices/c:")
|
||||
os.symlink("/", pfx / "dosdevices/z:")
|
||||
return pfx
|
||||
|
||||
def install(self, pfx, name="Nexon Launcher", folder="Program Files (x86)/Nexon/Nexon Launcher",
|
||||
exe="nexon_launcher.exe", on_desktop=True, target=None, args=""):
|
||||
program = pfx / "drive_c" / folder / exe
|
||||
program.parent.mkdir(parents=True, exist_ok=True)
|
||||
program.write_bytes(b"MZ")
|
||||
windows = target or "C:\\" + folder.replace("/", "\\") + "\\" + exe
|
||||
menu = "ProgramData\\Microsoft\\Windows\\Start Menu\\Programs\\" + name + ".lnk"
|
||||
link = pfx / "drive_c" / menu.replace("\\", "/")
|
||||
link.parent.mkdir(parents=True, exist_ok=True)
|
||||
link.write_bytes(lnk(windows, args))
|
||||
shortcuts = pfx / "drive_c/proton_shortcuts"
|
||||
for size in ("16x16", "256x256"):
|
||||
icon = shortcuts / "icons" / size / "apps/6D6F_logo.0.png"
|
||||
icon.parent.mkdir(parents=True, exist_ok=True)
|
||||
icon.write_bytes(b"png")
|
||||
(shortcuts / (name + ".desktop")).write_text(
|
||||
"[Desktop Entry]\nName=%s\nExec=\"%s\"\nType=Application\nStartupNotify=true\nIcon=6D6F_logo.0\n"
|
||||
"StartupWMClass=%s\n" % (name, ("C:\\" + menu).replace("\\", "\\\\\\\\"), exe))
|
||||
if on_desktop:
|
||||
desktop = pfx / "drive_c/users/Public/Desktop" / (name + ".lnk")
|
||||
desktop.parent.mkdir(parents=True, exist_ok=True)
|
||||
desktop.write_bytes(lnk(windows, args))
|
||||
return program
|
||||
|
||||
def entries(self, folder):
|
||||
return sorted(p.name for p in folder.glob(PROGRAMS["PREFIX"] + "*.desktop")) if folder.is_dir() else []
|
||||
|
||||
def test_an_installed_program_gets_a_menu_entry_that_opens_it_in_its_prefix(self):
|
||||
pfx = self.prefix(NEXON)
|
||||
exe = self.install(pfx)
|
||||
PROGRAMS["main"]()
|
||||
[name] = self.entries(self.apps)
|
||||
self.assertTrue(name.startswith("droiddeck-program-%d-" % NEXON))
|
||||
text = (self.apps / name).read_text()
|
||||
self.assertIn("Name=Nexon Launcher\n", text)
|
||||
self.assertIn('Exec="%s" "%s"\n' % (PROGRAMS["OPEN"], exe), text)
|
||||
self.assertIn("Icon=%s\n" % (pfx / "drive_c/proton_shortcuts/icons/256x256/apps/6D6F_logo.0.png"), text)
|
||||
self.assertIn("StartupWMClass=nexon_launcher.exe\n", text)
|
||||
|
||||
def test_it_lands_on_the_desktop_once_when_its_installer_put_it_there(self):
|
||||
pfx = self.prefix(NEXON)
|
||||
self.install(pfx)
|
||||
self.install(pfx, name="Tool", folder="Tool", exe="tool.exe", on_desktop=False)
|
||||
PROGRAMS["main"]()
|
||||
self.assertEqual(2, len(self.entries(self.apps)))
|
||||
[name] = self.entries(self.desktop)
|
||||
self.assertEqual((self.apps / name).read_text(), (self.desktop / name).read_text())
|
||||
self.assertTrue((self.desktop / name).stat().st_mode & stat.S_IXUSR)
|
||||
(self.desktop / name).unlink()
|
||||
PROGRAMS["main"]()
|
||||
self.assertEqual([], self.entries(self.desktop))
|
||||
self.assertEqual(2, len(self.entries(self.apps)))
|
||||
|
||||
def test_an_uninstalled_program_leaves_the_menu_and_the_desktop(self):
|
||||
exe = self.install(self.prefix(NEXON))
|
||||
PROGRAMS["main"]()
|
||||
exe.unlink()
|
||||
PROGRAMS["main"]()
|
||||
self.assertEqual([], self.entries(self.apps))
|
||||
self.assertEqual([], self.entries(self.desktop))
|
||||
self.assertEqual("", Path(PROGRAMS["PLACED"]).read_text())
|
||||
|
||||
def test_steam_games_and_shortcuts_keep_their_own_entries(self):
|
||||
self.install(self.prefix(1238080))
|
||||
self.install(self.prefix(2243774769))
|
||||
self.shortcuts = {2243774769}
|
||||
PROGRAMS["main"]()
|
||||
self.assertEqual([], self.entries(self.apps))
|
||||
|
||||
def steam_shortcut(self, account, appid):
|
||||
vdf = self.steam / "userdata" / str(account) / "config/shortcuts.vdf"
|
||||
vdf.parent.mkdir(parents=True)
|
||||
vdf.write_bytes(b"\0shortcuts\0\0" + b"0\0\x02appid\0" + struct.pack("<i", appid - 2 ** 32)
|
||||
+ b"\x01Exe\0\"C:\\\\launcher.exe\"\0\x08\x08\x08")
|
||||
return vdf
|
||||
|
||||
def test_steam_shortcuts_it_cannot_read_leave_the_menu_as_it_was(self):
|
||||
if os.geteuid() == 0:
|
||||
self.skipTest("file permissions do not apply to root")
|
||||
self.install(self.prefix(NEXON))
|
||||
vdf = self.steam_shortcut(123, NEXON)
|
||||
with mock.patch.dict(PROGRAMS, {"shortcut_ids": SHORTCUT_IDS}):
|
||||
PROGRAMS["main"]()
|
||||
self.assertEqual([], self.entries(self.apps))
|
||||
vdf.chmod(0)
|
||||
try:
|
||||
PROGRAMS["main"]()
|
||||
finally:
|
||||
vdf.chmod(0o644)
|
||||
self.assertEqual([], self.entries(self.apps))
|
||||
for damaged in (vdf.read_bytes()[:20], b""):
|
||||
vdf.write_bytes(damaged)
|
||||
PROGRAMS["main"]()
|
||||
self.assertEqual([], self.entries(self.apps))
|
||||
vdf.unlink()
|
||||
PROGRAMS["main"]()
|
||||
self.assertEqual(1, len(self.entries(self.apps)))
|
||||
|
||||
def test_a_steam_account_it_cannot_look_up_leaves_the_menu_as_it_was(self):
|
||||
if os.geteuid() == 0:
|
||||
self.skipTest("file permissions do not apply to root")
|
||||
self.install(self.prefix(NEXON))
|
||||
self.steam_shortcut(123, NEXON)
|
||||
(self.steam / "userdata/456").mkdir()
|
||||
users = self.steam / "config/loginusers.vdf"
|
||||
users.parent.mkdir(parents=True)
|
||||
users.write_text('"users"\n{\n\t"%d"\n\t{\n\t\t"MostRecent"\t\t"1"\n\t}\n}\n' % (76561197960265728 + 123))
|
||||
with mock.patch.dict(PROGRAMS, {"shortcut_ids": SHORTCUT_IDS}):
|
||||
PROGRAMS["main"]()
|
||||
self.assertEqual([], self.entries(self.apps))
|
||||
for unreadable in (users, self.steam / "userdata"):
|
||||
unreadable.chmod(0)
|
||||
try:
|
||||
PROGRAMS["main"]()
|
||||
finally:
|
||||
unreadable.chmod(0o755)
|
||||
self.assertEqual([], self.entries(self.apps))
|
||||
|
||||
def test_uninstallers_and_links_to_other_files_get_no_entry(self):
|
||||
pfx = self.prefix(NEXON)
|
||||
self.install(pfx, name="Uninstall", exe="unins000.exe")
|
||||
self.install(pfx, name="Read me", exe="readme.txt")
|
||||
PROGRAMS["main"]()
|
||||
self.assertEqual([], self.entries(self.apps))
|
||||
|
||||
def test_a_link_in_other_letter_case_with_arguments_still_starts_the_program(self):
|
||||
pfx = self.prefix(NEXON)
|
||||
exe = self.install(pfx, target="C:\\PROGRAM FILES (X86)\\NEXON\\nexon launcher\\NEXON_LAUNCHER.EXE",
|
||||
args='-mode "full screen" C:\\save')
|
||||
PROGRAMS["main"]()
|
||||
[name] = self.entries(self.apps)
|
||||
self.assertIn('Exec="%s" "%s" "-mode" "full screen" "C:\\\\\\\\save"\n' % (PROGRAMS["OPEN"], exe),
|
||||
(self.apps / name).read_text())
|
||||
|
||||
def test_a_link_to_a_program_outside_the_prefix_gets_no_entry(self):
|
||||
outside = self.tmp / "Downloads/tool.exe"
|
||||
outside.parent.mkdir(parents=True)
|
||||
outside.write_bytes(b"MZ")
|
||||
self.install(self.prefix(NEXON), name="Tool", target="Z:" + str(outside).replace("/", "\\"))
|
||||
PROGRAMS["main"]()
|
||||
self.assertEqual([], self.entries(self.apps))
|
||||
|
||||
def test_windows_arguments_follow_windows_quoting(self):
|
||||
cases = {
|
||||
"--profile=O'Brien": ["--profile=O'Brien"],
|
||||
'-mode "full screen" C:\\save': ["-mode", "full screen", "C:\\save"],
|
||||
'"a\\"b" \\\\server\\share': ['a"b', "\\\\server\\share"],
|
||||
'"x\\\\" y': ["x\\", "y"],
|
||||
" ": [],
|
||||
}
|
||||
for text, expected in cases.items():
|
||||
with self.subTest(text):
|
||||
self.assertEqual(expected, PROGRAMS["windows_args"](text))
|
||||
|
||||
def test_an_apostrophe_in_a_links_arguments_does_not_stop_the_refresh(self):
|
||||
exe = self.install(self.prefix(NEXON), args="--profile=O'Brien")
|
||||
PROGRAMS["main"]()
|
||||
[name] = self.entries(self.apps)
|
||||
self.assertIn('Exec="%s" "%s" "--profile=O\'Brien"\n' % (PROGRAMS["OPEN"], exe), (self.apps / name).read_text())
|
||||
|
||||
def test_a_file_or_folder_it_cannot_read_leaves_the_menu_and_desktop_alone(self):
|
||||
if os.geteuid() == 0:
|
||||
self.skipTest("file permissions do not apply to root")
|
||||
pfx = self.prefix(NEXON)
|
||||
self.install(pfx, target="C:\\PROGRAM FILES (X86)\\Nexon\\Nexon Launcher\\nexon_launcher.exe")
|
||||
PROGRAMS["main"]()
|
||||
menu, desktop = self.entries(self.apps), self.entries(self.desktop)
|
||||
placed = Path(PROGRAMS["PLACED"]).read_text()
|
||||
drive = pfx / "drive_c"
|
||||
unreadable = {
|
||||
"compatdata": (self.steam / "steamapps/compatdata", 0),
|
||||
"proton_shortcuts": (drive / "proton_shortcuts", 0),
|
||||
"windows desktop": (drive / "users/Public/Desktop", 0),
|
||||
"shortcut file": (drive / "proton_shortcuts/Nexon Launcher.desktop", 0),
|
||||
"link file": (drive / "ProgramData/Microsoft/Windows/Start Menu/Programs/Nexon Launcher.lnk", 0),
|
||||
"folder above a differently cased part": (drive, 0o311),
|
||||
}
|
||||
for name, (path, mode) in unreadable.items():
|
||||
with self.subTest(name):
|
||||
before = path.stat().st_mode
|
||||
path.chmod(mode)
|
||||
try:
|
||||
PROGRAMS["main"]()
|
||||
finally:
|
||||
path.chmod(before)
|
||||
self.assertEqual(menu, self.entries(self.apps))
|
||||
self.assertEqual(desktop, self.entries(self.desktop))
|
||||
self.assertEqual(placed, Path(PROGRAMS["PLACED"]).read_text())
|
||||
|
||||
def test_an_unreadable_placement_record_does_not_bring_back_a_deleted_icon(self):
|
||||
if os.geteuid() == 0:
|
||||
self.skipTest("file permissions do not apply to root")
|
||||
self.install(self.prefix(NEXON))
|
||||
PROGRAMS["main"]()
|
||||
[icon] = self.entries(self.desktop)
|
||||
(self.desktop / icon).unlink()
|
||||
record = Path(PROGRAMS["PLACED"])
|
||||
placed = record.read_text()
|
||||
record.chmod(0)
|
||||
try:
|
||||
PROGRAMS["main"]()
|
||||
finally:
|
||||
record.chmod(0o644)
|
||||
self.assertEqual([], self.entries(self.desktop))
|
||||
self.assertEqual(placed, record.read_text())
|
||||
|
||||
def test_icons_written_before_a_failed_write_are_recorded(self):
|
||||
pfx = self.prefix(NEXON)
|
||||
self.install(pfx)
|
||||
self.install(pfx, name="Tool", folder="Tool", exe="tool.exe")
|
||||
PROGRAMS["main"]()
|
||||
first, second = self.entries(self.apps)
|
||||
for name in (first, second):
|
||||
(self.desktop / name).unlink()
|
||||
Path(PROGRAMS["PLACED"]).write_text("")
|
||||
(self.desktop / second / "blocked").mkdir(parents=True)
|
||||
PROGRAMS["main"]()
|
||||
self.assertTrue((self.desktop / first).is_file())
|
||||
self.assertEqual(first + "\n", Path(PROGRAMS["PLACED"]).read_text())
|
||||
self.assertEqual([], list(self.desktop.glob("*.tmp")))
|
||||
(self.desktop / first).unlink()
|
||||
shutil.rmtree(self.desktop / second)
|
||||
PROGRAMS["main"]()
|
||||
self.assertEqual([second], self.entries(self.desktop))
|
||||
|
||||
def test_an_unreadable_placement_record_leaves_the_menu_as_it_was(self):
|
||||
if os.geteuid() == 0:
|
||||
self.skipTest("file permissions do not apply to root")
|
||||
pfx = self.prefix(NEXON)
|
||||
self.install(pfx)
|
||||
PROGRAMS["main"]()
|
||||
menu = self.entries(self.apps)
|
||||
self.install(pfx, name="Tool", folder="Tool", exe="tool.exe")
|
||||
record = Path(PROGRAMS["PLACED"])
|
||||
record.chmod(0)
|
||||
try:
|
||||
PROGRAMS["main"]()
|
||||
finally:
|
||||
record.chmod(0o644)
|
||||
self.assertEqual(menu, self.entries(self.apps))
|
||||
|
||||
def test_a_program_behind_a_folder_it_cannot_reach_keeps_its_entry(self):
|
||||
if os.geteuid() == 0:
|
||||
self.skipTest("file permissions do not apply to root")
|
||||
exe = self.install(self.prefix(NEXON))
|
||||
PROGRAMS["main"]()
|
||||
menu, desktop = self.entries(self.apps), self.entries(self.desktop)
|
||||
hidden = self.tmp / "locked/nexon_launcher.exe"
|
||||
hidden.parent.mkdir()
|
||||
hidden.write_bytes(b"MZ")
|
||||
exe.unlink()
|
||||
exe.symlink_to(hidden)
|
||||
hidden.parent.chmod(0)
|
||||
try:
|
||||
PROGRAMS["main"]()
|
||||
finally:
|
||||
hidden.parent.chmod(0o755)
|
||||
self.assertEqual(menu, self.entries(self.apps))
|
||||
self.assertEqual(desktop, self.entries(self.desktop))
|
||||
|
||||
def test_without_any_prefixes_the_entries_go(self):
|
||||
self.install(self.prefix(NEXON))
|
||||
PROGRAMS["main"]()
|
||||
shutil.rmtree(self.steam / "steamapps/compatdata")
|
||||
PROGRAMS["main"]()
|
||||
self.assertEqual([], self.entries(self.apps))
|
||||
|
||||
def test_refreshes_take_turns(self):
|
||||
calls = []
|
||||
with mock.patch.object(PROGRAMS["fcntl"], "flock", lambda lock, op: calls.append(op)), \
|
||||
mock.patch.dict(PROGRAMS, {"refresh": lambda: calls.append("refresh")}):
|
||||
PROGRAMS["main"]()
|
||||
self.assertEqual([PROGRAMS["fcntl"].LOCK_EX, "refresh"], calls)
|
||||
|
||||
def test_a_link_with_a_unicode_path_block_is_read_too(self):
|
||||
path = self.tmp / "game.lnk"
|
||||
path.write_bytes(lnk("C:\\Games\\Ünïcode\\game.exe", wide_info=True))
|
||||
self.assertEqual(("C:\\Games\\Ünïcode\\game.exe", ""), PROGRAMS["link_target"](str(path)))
|
||||
|
||||
|
||||
class OpenTest(unittest.TestCase):
|
||||
def run_program(self, name, data):
|
||||
folder = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, folder, True)
|
||||
program = folder / name
|
||||
program.write_bytes(data)
|
||||
ran = []
|
||||
with mock.patch.dict(OPEN, {"steam_title": lambda path: None, "placed": lambda cmd, *a: cmd,
|
||||
"say": lambda *a: None, "notify": lambda *a: None, "game_folder": lambda path: None,
|
||||
"for_x86": lambda path: False}), \
|
||||
mock.patch.object(OPEN["subprocess"], "call", lambda cmd: ran.append(cmd) or 0), contextlib.chdir(folder):
|
||||
self.assertEqual(0, OPEN["main"]([str(program)]))
|
||||
return ran
|
||||
|
||||
def test_a_windows_run_brings_the_menu_of_installed_programs_up_to_date(self):
|
||||
ran = self.run_program("NexonLauncherSetup.exe", b"MZ" + bytes(62))
|
||||
self.assertEqual([sys.executable, OPEN["PROGRAMS"]], ran[-1])
|
||||
self.assertEqual(2, len(ran))
|
||||
|
||||
def test_other_programs_leave_the_menu_alone(self):
|
||||
ran = self.run_program("start.sh", b"echo hi\n")
|
||||
self.assertEqual(1, len(ran))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user