#!/usr/bin/env python3 # This file is part of I38, licensed under the GNU GPL, version 3 or later. """Desktop shortcut creation. Targets are data, never shell commands.""" import os import re import shutil import tempfile import time from collections import namedtuple from html.parser import HTMLParser from pathlib import Path from urllib.parse import unquote, urlsplit from urllib.request import HTTPRedirectHandler, Request, build_opener import gi gi.require_version("Gio", "2.0") from gi.repository import Gio, GLib try: gi.require_version("GioUnix", "2.0") from gi.repository import GioUnix DesktopAppInfo = GioUnix.DesktopAppInfo except (ImportError, ValueError): DesktopAppInfo = Gio.DesktopAppInfo Target = namedtuple("Target", "value name kind icon") class PageTitleParser(HTMLParser): def __init__(self): super().__init__(convert_charrefs=True) self.inTitle = False self.complete = False self.parts = [] def handle_starttag(self, tag, attrs): if tag == "title" and not self.complete: self.inTitle = True def handle_endtag(self, tag): if tag == "title" and self.inTitle: self.inTitle = False self.complete = True def handle_data(self, data): if self.inTitle: self.parts.append(data) class WebRedirectHandler(HTTPRedirectHandler): def redirect_request(self, request, response, code, message, headers, newUrl): if urlsplit(newUrl).scheme.lower() not in ("http", "https"): raise ValueError("Unsupported web redirect") return super().redirect_request(request, response, code, message, headers, newUrl) def fetch_page_title(url): """Fetch bounded public HTML without browser cookies or script execution. The caller runs tihs off the GTK thread and keeps the hostname on failure. """ request = Request(url, headers={"User-Agent": "I38-Shortcut-Creator/1.0", "Accept": "text/html, application/xhtml+xml"}) deadline = time.monotonic() + 8 with build_opener(WebRedirectHandler()).open(request, timeout=3) as response: if response.headers.get_content_type() not in ("text/html", "application/xhtml+xml"): return None content = bytearray() while len(content) < 262144 and time.monotonic() < deadline: chunk = response.read1(min(16384, 262144 - len(content))) if not chunk: break content.extend(chunk) if b"]*charset\s*=\s*["\x27]?\s*([a-zA-Z0-9_-]+)', content[:8192], re.I) encoding = match.group(1).decode("ascii") if match else "utf-8" try: html = content.decode(encoding, errors="replace") except LookupError: html = content.decode("utf-8", errors="replace") parser = PageTitleParser() parser.feed(html) title = " ".join("".join(parser.parts).split()) title = "".join(character for character in title if ord(character) >= 32 and ord(character) != 127) return title[:200] if parser.complete and title else None def desktop_directory(): location = GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_DESKTOP) desktopPath = Path(location) if location else Path.home() / "Desktop" if desktopPath == Path.home(): raise ValueError("The desktop directory is disabled. Configure XDG_DESKTOP_DIR first.") return desktopPath def parse_target(value): value = value.strip() if not value or any(ord(character) < 32 or ord(character) == 127 for character in value): raise ValueError("Enter one file, folder, program, or http/https URL.") parts = urlsplit(value) if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", value) else None if parts and parts.scheme.lower() in ("http", "https"): if not parts.hostname or any(character.isspace() for character in value): raise ValueError("Enter a complete web address, such as https://example.org/.") # Accessing port also validates malformed ports and IPv6 addresses. parts.port return Target(value, parts.hostname, "Web link", "web-browser") if parts and parts.scheme.lower() == "file": if parts.netloc not in ("", "localhost") or parts.query or parts.fragment: raise ValueError("Only local file URLs are supported.") value = unquote(parts.path) if any(ord(character) < 32 or ord(character) == 127 for character in value): raise ValueError("File names containing control characters are not supported.") elif parts: raise ValueError("Use a local path or an http/https web address.") targetPath = Path(value).expanduser() if not targetPath.is_absolute(): command = shutil.which(value) if "/" not in value else None if command is None: raise ValueError("Use an absolute file path, ~/path, or a program name on PATH.") targetPath = Path(command) if not targetPath.exists(): raise ValueError("That file or folder does not exist.") if targetPath.is_dir(): return Target(str(targetPath), targetPath.name or "/", "Folder", "folder") if not targetPath.is_file(): raise ValueError("Choose a regular file, folder, or program.") if targetPath.suffix.lower() == ".desktop": try: app = DesktopAppInfo.new_from_filename(str(targetPath)) except (TypeError, GLib.Error): app = None if app is None: raise ValueError("That desktop launcher is invalid or its program is unavailable.") icon = app.get_icon() return Target(str(targetPath), app.get_display_name(), "Application", icon.to_string() if icon else "application-x-executable") if targetPath.suffix.lower() == ".exe": return Target(str(targetPath), targetPath.stem, "Windows program", "application-x-executable") if os.access(str(targetPath), os.X_OK): return Target(str(targetPath), targetPath.name, "Program", "application-x-executable") return Target(str(targetPath), targetPath.name, "File", "text-x-generic") def is_wine_prefix(path): return ((path / "drive_c").is_dir() and (path / "dosdevices").is_dir() and (path / "system.reg").is_file()) def detect_wine_prefix(value): targetPath = Path(value).expanduser() # Check the supplied path first: drive_c itself can be a symlink. Then # resolve links so an executable linked onto the desktop also works. for programPath in (targetPath, targetPath.resolve()): for parent in programPath.parents: if is_wine_prefix(parent): return str(parent) defaultPrefix = Path.home() / ".wine" return str(defaultPrefix) if is_wine_prefix(defaultPrefix) else None def validate_wine_prefix(value): if not value or any(ord(character) < 32 or ord(character) == 127 for character in value): raise ValueError("Choose the Wine prefix (bottle) folder for this Windows program.") prefixPath = Path(value).expanduser() if not prefixPath.is_absolute() or not is_wine_prefix(prefixPath): raise ValueError("Choose an existing Wine prefix containing drive_c, dosdevices, and system.reg.") return str(prefixPath) def clipboard_target(data, targetType): if isinstance(data, bytes): data = data.decode("utf-8") lines = data.replace("\r\n", "\n").strip().splitlines() if targetType in ("x-special/gnome-copied-files", "application/x-kde4-urilist"): if lines and lines[0] in ("copy", "cut"): lines = lines[1:] if targetType != "text": lines = [line for line in lines if line and not line.startswith("#")] if len(lines) != 1: raise ValueError("Copy one file, folder, or URL at a time.") return parse_target(lines[0]) def installed_applications(): apps = [] for app in Gio.AppInfo.get_all(): if isinstance(app, DesktopAppInfo) and app.should_show() and app.get_filename(): apps.append(app) return sorted(apps, key=lambda app: (app.get_display_name().casefold(), app.get_id())) def exec_argument(value): # Exec has its own quoting rules; GLib.KeyFile handles the outer string escaping. escaped = value.replace("%", "%%") for character in ("\\", '"', "`", "$"): escaped = escaped.replace(character, "\\" + character) return '"' + escaped + '"' def shortcut_data(target, name, fileManager="", terminal=False, winePrefix=None): name = name.strip() if not name or any(ord(character) < 32 or ord(character) == 127 for character in name): raise ValueError("Enter a shortcut name without line breaks or control characters.") # Validate again in case the target disappeared while the dialog was open. target = parse_target(target.value) if target.kind == "Folder": if not fileManager or not shutil.which(fileManager): raise ValueError("I38 has no available file manager configured. Select one in I38 setup.") arguments = [fileManager, target.value] elif target.kind == "Program": if "=" in target.value: raise ValueError("Desktop launchers cannot run a program with '=' in its path.") arguments = [target.value] elif target.kind == "Windows program": winePrefix = validate_wine_prefix(winePrefix if winePrefix is not None else detect_wine_prefix(target.value)) if shutil.which("wine") is None: raise ValueError("Required program is unavailable: wine") arguments = ["env", "WINEPREFIX=" + winePrefix, "wine", target.value] elif target.kind == "Application": # Launch the original entry so D-Bus identity, field codes, terminal and # working-directory settings survive a renamed desktop shortcut. arguments = ["gio", "launch", target.value] else: arguments = ["xdg-open", target.value] if shutil.which(arguments[0]) is None: raise ValueError("Required program is unavailable: " + arguments[0]) keyFile = GLib.KeyFile() for key, value in (("Type", "Application"), ("Name", name), ("Icon", target.icon), ("Exec", " ".join(exec_argument(argument) for argument in arguments))): keyFile.set_string("Desktop Entry", key, value) keyFile.set_boolean("Desktop Entry", "Terminal", terminal and target.kind in ("Program", "Windows program")) if target.kind == "Windows program": keyFile.set_string("Desktop Entry", "Path", str(Path(target.value).resolve().parent)) return keyFile.to_data()[0] def create_shortcut(target, name, fileManager="", terminal=False, desktopPath=None, winePrefix=None): data = shortcut_data(target, name, fileManager, terminal, winePrefix) desktopPath = Path(desktopPath) if desktopPath is not None else desktop_directory() desktopPath.mkdir(parents=True, exist_ok=True) # Keep long names below common NAME_MAX limits, including numbered suffixes. stem = re.sub(r"[/\\\x00-\x1f\x7f]", "_", name.strip()).lstrip(".") stem = stem.encode("utf-8")[:180].decode("utf-8", errors="ignore") or "Shortcut" tempName = None try: with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=str(desktopPath), prefix=".i38-shortcut-", delete=False) as stream: tempName = stream.name stream.write(data) stream.flush() os.fchmod(stream.fileno(), 0o755) # Linking a complete temporary file publishes atomically without # overwriting existing files, directories, or even dangling symlinks. number = 1 while True: suffix = "" if number == 1 else " ({})".format(number) destination = desktopPath / (stem + suffix + ".desktop") try: os.link(tempName, str(destination)) return destination except FileExistsError: number += 1 finally: if tempName is not None: os.unlink(tempName)