Files
I38/scripts/shortcuts.py

276 lines
12 KiB
Python

#!/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"</title" in content.lower():
break
encoding = response.headers.get_content_charset()
if not encoding:
match = re.search(br'<meta\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)