276 lines
12 KiB
Python
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)
|