Easy way to make desktop shortcuts to almost anything added.
This commit is contained in:
1 parent
6c793f530a
commit
d2c5695221
6 files changed
+608
-1
No files matched your search
+6
-1
@@ -26,6 +26,8 @@ gi.require_version("Gtk", "3.0")
|
||||
|
||||
from gi.repository import Gdk, Gio, GLib, Gtk
|
||||
|
||||
from shortcuts import desktop_directory
|
||||
|
||||
try:
|
||||
gi.require_version("GioUnix", "2.0")
|
||||
from gi.repository import GioUnix
|
||||
@@ -100,7 +102,10 @@ def desktop_entry_name(path):
|
||||
|
||||
|
||||
def read_desktop_items():
|
||||
desktop_path = Path.home() / "Desktop"
|
||||
try:
|
||||
desktop_path = desktop_directory()
|
||||
except ValueError:
|
||||
return []
|
||||
if not desktop_path.is_dir():
|
||||
return []
|
||||
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# This file is part of I38, licensed under the GNU GPL, version 3 or later.
|
||||
|
||||
import argparse
|
||||
import threading
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Atk", "1.0")
|
||||
gi.require_version("Gdk", "3.0")
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gdk, GLib, Gtk
|
||||
|
||||
from shortcuts import (clipboard_target, create_shortcut, fetch_page_title,
|
||||
installed_applications, parse_target)
|
||||
|
||||
|
||||
class ApplicationDialog(Gtk.Dialog):
|
||||
def __init__(self, parent):
|
||||
super().__init__(title="Search for application", transient_for=parent, modal=True)
|
||||
self.set_default_size(620, 420)
|
||||
self.add_buttons("_Cancel", Gtk.ResponseType.CANCEL, "S_elect", Gtk.ResponseType.OK)
|
||||
self.set_default_response(Gtk.ResponseType.OK)
|
||||
box = self.get_content_area()
|
||||
box.set_spacing(8)
|
||||
box.set_border_width(12)
|
||||
label = Gtk.Label.new_with_mnemonic("_Search applications:")
|
||||
label.set_xalign(0)
|
||||
self.searchEntry = Gtk.SearchEntry()
|
||||
label.set_mnemonic_widget(self.searchEntry)
|
||||
self.searchEntry.get_accessible().set_name("Search applications")
|
||||
box.pack_start(label, False, False, 0)
|
||||
box.pack_start(self.searchEntry, False, False, 0)
|
||||
self.store = Gtk.ListStore(str, str, str)
|
||||
for app in installed_applications():
|
||||
self.store.append((app.get_display_name(), app.get_id(), app.get_filename()))
|
||||
self.query = ""
|
||||
self.filtered = self.store.filter_new()
|
||||
self.filtered.set_visible_func(lambda model, row, _data:
|
||||
model[row][0].casefold().startswith(self.query))
|
||||
self.view = Gtk.TreeView(model=self.filtered)
|
||||
self.view.set_enable_search(False)
|
||||
self.view.get_accessible().set_name("Applications")
|
||||
for index, title in enumerate(("Application", "Launcher")):
|
||||
self.view.append_column(Gtk.TreeViewColumn(title, Gtk.CellRendererText(), text=index))
|
||||
self.view.connect("row-activated", self.confirm_selection)
|
||||
self.view.connect("key-press-event", self.on_list_key)
|
||||
scroll = Gtk.ScrolledWindow()
|
||||
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||||
scroll.add(self.view)
|
||||
box.pack_start(scroll, True, True, 0)
|
||||
self.countLabel = Gtk.Label(xalign=0)
|
||||
box.pack_start(self.countLabel, False, False, 0)
|
||||
self.searchEntry.connect("changed", self.on_search)
|
||||
self.searchEntry.connect("activate", self.confirm_selection)
|
||||
self.searchEntry.connect("key-press-event", self.on_search_key)
|
||||
self.view.get_selection().connect("changed", self.on_selection)
|
||||
self.on_search(self.searchEntry)
|
||||
self.show_all()
|
||||
self.searchEntry.grab_focus()
|
||||
|
||||
def on_search(self, entry):
|
||||
self.query = entry.get_text().casefold()
|
||||
self.filtered.refilter()
|
||||
if len(self.filtered):
|
||||
self.view.set_cursor(Gtk.TreePath.new_first())
|
||||
self.countLabel.set_text("{} applications found.".format(len(self.filtered)))
|
||||
self.on_selection(self.view.get_selection())
|
||||
|
||||
def on_selection(self, selection):
|
||||
_model, row = selection.get_selected()
|
||||
self.set_response_sensitive(Gtk.ResponseType.OK, row is not None)
|
||||
|
||||
def on_search_key(self, _entry, event):
|
||||
if event.keyval == Gdk.KEY_Down:
|
||||
self.view.grab_focus()
|
||||
return True
|
||||
return False
|
||||
|
||||
def on_list_key(self, _view, event):
|
||||
character = Gdk.keyval_to_unicode(event.keyval)
|
||||
if character >= 32 and not event.state & (Gdk.ModifierType.CONTROL_MASK |
|
||||
Gdk.ModifierType.MOD1_MASK |
|
||||
Gdk.ModifierType.SUPER_MASK):
|
||||
self.searchEntry.grab_focus()
|
||||
self.searchEntry.insert_text(chr(character), -1)
|
||||
self.searchEntry.set_position(-1)
|
||||
return True
|
||||
if event.keyval == Gdk.KEY_BackSpace:
|
||||
self.searchEntry.set_text(self.searchEntry.get_text()[:-1])
|
||||
self.searchEntry.grab_focus()
|
||||
self.searchEntry.set_position(-1)
|
||||
return True
|
||||
return False
|
||||
|
||||
def selected_path(self):
|
||||
model, row = self.view.get_selection().get_selected()
|
||||
return model[row][2] if row is not None else None
|
||||
|
||||
def confirm_selection(self, *_args):
|
||||
if self.selected_path() is not None:
|
||||
self.response(Gtk.ResponseType.OK)
|
||||
|
||||
|
||||
class ShortcutWindow(Gtk.Window):
|
||||
def __init__(self, fileManager=""):
|
||||
super().__init__(title="I38 Create Desktop Shortcut")
|
||||
self.fileManager = fileManager
|
||||
self.target = None
|
||||
self.requestNumber = 0
|
||||
self.nameVersion = 0
|
||||
self.closed = False
|
||||
self.set_default_size(650, 300)
|
||||
self.set_border_width(12)
|
||||
self.connect("destroy", Gtk.main_quit)
|
||||
self.connect("destroy", self.on_destroy)
|
||||
self.connect("key-press-event", self.on_key)
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||
self.add(box)
|
||||
instructions = Gtk.Label(label="Choose a source, then review the target and shortcut name.", xalign=0)
|
||||
box.pack_start(instructions, False, False, 0)
|
||||
for label, callback in (("Add from _Clipboard", self.on_clipboard),
|
||||
("Browse for _File", self.on_file),
|
||||
("Browse for F_older", self.on_folder),
|
||||
("Search for _Application", self.on_application)):
|
||||
button = Gtk.Button.new_with_mnemonic(label)
|
||||
button.connect("clicked", callback)
|
||||
box.pack_start(button, False, False, 0)
|
||||
self.targetEntry = Gtk.Entry()
|
||||
self.targetEntry.set_placeholder_text("/usr/bin/firefox, ~/Documents, or https://example.org/")
|
||||
self.add_field(box, "_Target (path, program, or web URL):", self.targetEntry)
|
||||
self.nameEntry = Gtk.Entry()
|
||||
self.add_field(box, "Shortcut _name:", self.nameEntry)
|
||||
self.targetEntry.connect("changed", self.on_target_changed)
|
||||
self.targetEntry.connect("activate", self.on_target_activate)
|
||||
self.nameEntry.connect("changed", self.on_name_changed)
|
||||
self.nameEntry.set_activates_default(True)
|
||||
self.terminalCheck = Gtk.CheckButton.new_with_mnemonic("Run program in a ter_minal")
|
||||
self.terminalCheck.set_sensitive(False)
|
||||
box.pack_start(self.terminalCheck, False, False, 0)
|
||||
self.statusLabel = Gtk.Label(label="No source selected.", xalign=0)
|
||||
self.statusLabel.set_line_wrap(True)
|
||||
self.statusLabel.set_selectable(True)
|
||||
self.statusLabel.get_accessible().set_name("Shortcut status")
|
||||
box.pack_start(self.statusLabel, False, False, 0)
|
||||
buttons = Gtk.ButtonBox(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
buttons.set_layout(Gtk.ButtonBoxStyle.END)
|
||||
closeButton = Gtk.Button.new_with_mnemonic("C_lose")
|
||||
closeButton.connect("clicked", lambda *_args: self.close())
|
||||
self.createButton = Gtk.Button.new_with_mnemonic("C_reate Shortcut")
|
||||
self.createButton.set_can_default(True)
|
||||
self.createButton.connect("clicked", self.on_create)
|
||||
self.createButton.set_sensitive(False)
|
||||
buttons.add(closeButton)
|
||||
buttons.add(self.createButton)
|
||||
box.pack_start(buttons, False, False, 0)
|
||||
self.set_default(self.createButton)
|
||||
self.show_all()
|
||||
box.get_children()[1].grab_focus()
|
||||
|
||||
def add_field(self, box, text, entry):
|
||||
label = Gtk.Label.new_with_mnemonic(text)
|
||||
label.set_xalign(0)
|
||||
label.set_mnemonic_widget(entry)
|
||||
box.pack_start(label, False, False, 0)
|
||||
box.pack_start(entry, False, False, 0)
|
||||
|
||||
def show_message(self, message, error=False):
|
||||
dialog = Gtk.MessageDialog(transient_for=self, modal=True,
|
||||
message_type=Gtk.MessageType.ERROR if error else Gtk.MessageType.INFO,
|
||||
buttons=Gtk.ButtonsType.CLOSE,
|
||||
text="Could not create shortcut" if error else "Desktop shortcut created")
|
||||
dialog.format_secondary_text(message)
|
||||
dialog.run()
|
||||
dialog.destroy()
|
||||
|
||||
def on_target_changed(self, _entry):
|
||||
self.requestNumber += 1
|
||||
self.target = None
|
||||
self.createButton.set_sensitive(False)
|
||||
self.terminalCheck.set_active(False)
|
||||
self.terminalCheck.set_sensitive(False)
|
||||
self.statusLabel.set_text("Press Enter to check the target, or choose a source above.")
|
||||
|
||||
def on_name_changed(self, _entry):
|
||||
self.nameVersion += 1
|
||||
self.createButton.set_sensitive(self.target is not None and bool(self.nameEntry.get_text().strip()))
|
||||
|
||||
def set_target(self, target):
|
||||
self.targetEntry.set_text(target.value)
|
||||
self.target = target
|
||||
self.nameEntry.set_text(target.name)
|
||||
self.terminalCheck.set_sensitive(target.kind == "Program")
|
||||
self.statusLabel.set_text("{}: {}".format(target.kind, target.value))
|
||||
self.nameEntry.grab_focus()
|
||||
self.nameEntry.select_region(0, -1)
|
||||
if target.kind == "Web link":
|
||||
requestNumber = self.requestNumber
|
||||
nameVersion = self.nameVersion
|
||||
self.statusLabel.set_text("Looking up page title. You can edit the name or create the shortcut now.")
|
||||
|
||||
def lookup():
|
||||
try:
|
||||
title = fetch_page_title(target.value)
|
||||
except Exception:
|
||||
# Network, TLS, decoding, and malformed HTML failures all
|
||||
# leave the usable hostname suggestion in place.
|
||||
title = None
|
||||
GLib.idle_add(self.apply_title, requestNumber, nameVersion, target, title)
|
||||
|
||||
threading.Thread(target=lookup, daemon=True).start()
|
||||
|
||||
def apply_title(self, requestNumber, nameVersion, target, title):
|
||||
if self.closed or requestNumber != self.requestNumber:
|
||||
return False
|
||||
if nameVersion == self.nameVersion and title:
|
||||
self.nameEntry.set_text(title)
|
||||
self.nameEntry.select_region(0, -1)
|
||||
self.statusLabel.set_text("Page title found." if title else
|
||||
"Page title unavailable. Using the site hostname unless you edit the name.")
|
||||
return False
|
||||
|
||||
def on_destroy(self, _widget):
|
||||
self.closed = True
|
||||
self.requestNumber += 1
|
||||
|
||||
def on_target_activate(self, _entry):
|
||||
try:
|
||||
self.set_target(parse_target(self.targetEntry.get_text()))
|
||||
except (ValueError, OSError, GLib.Error) as error:
|
||||
self.show_message(str(error), error=True)
|
||||
|
||||
def on_clipboard(self, _button):
|
||||
self.requestNumber += 1
|
||||
requestNumber = self.requestNumber
|
||||
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
|
||||
self.statusLabel.set_text("Reading clipboard…")
|
||||
|
||||
def received_text(_clipboard, text, _data):
|
||||
if requestNumber != self.requestNumber:
|
||||
return
|
||||
try:
|
||||
self.set_target(clipboard_target(text or "", "text"))
|
||||
except (ValueError, UnicodeError, OSError, GLib.Error) as error:
|
||||
self.show_message(str(error), error=True)
|
||||
|
||||
def received_contents(_clipboard, selection, targetType):
|
||||
if requestNumber != self.requestNumber:
|
||||
return
|
||||
try:
|
||||
self.set_target(clipboard_target(selection.get_data() or b"", targetType))
|
||||
except (ValueError, UnicodeError, OSError, GLib.Error) as error:
|
||||
self.show_message(str(error), error=True)
|
||||
|
||||
def received_targets(_clipboard, targets, _targetCount, _data):
|
||||
if requestNumber != self.requestNumber:
|
||||
return
|
||||
names = {atom.name(): atom for atom in targets or []}
|
||||
for targetType in ("x-special/gnome-copied-files", "text/uri-list", "application/x-kde4-urilist"):
|
||||
if targetType in names:
|
||||
clipboard.request_contents(names[targetType], received_contents, targetType)
|
||||
return
|
||||
clipboard.request_text(received_text, None)
|
||||
|
||||
clipboard.request_targets(received_targets, None)
|
||||
|
||||
def choose_path(self, folder):
|
||||
self.requestNumber += 1
|
||||
dialog = Gtk.FileChooserDialog(title="Choose folder" if folder else "Choose file or program",
|
||||
transient_for=self,
|
||||
action=Gtk.FileChooserAction.SELECT_FOLDER if folder else Gtk.FileChooserAction.OPEN)
|
||||
dialog.add_buttons("_Cancel", Gtk.ResponseType.CANCEL, "_Select", Gtk.ResponseType.OK)
|
||||
dialog.set_local_only(True)
|
||||
if dialog.run() == Gtk.ResponseType.OK:
|
||||
value = dialog.get_filename()
|
||||
else:
|
||||
value = None
|
||||
dialog.destroy()
|
||||
if value:
|
||||
try:
|
||||
self.set_target(parse_target(value))
|
||||
except (ValueError, OSError, GLib.Error) as error:
|
||||
self.show_message(str(error), error=True)
|
||||
|
||||
def on_file(self, _button):
|
||||
self.choose_path(False)
|
||||
|
||||
def on_folder(self, _button):
|
||||
self.choose_path(True)
|
||||
|
||||
def on_application(self, _button):
|
||||
self.requestNumber += 1
|
||||
try:
|
||||
dialog = ApplicationDialog(self)
|
||||
value = dialog.selected_path() if dialog.run() == Gtk.ResponseType.OK else None
|
||||
dialog.destroy()
|
||||
if value:
|
||||
self.set_target(parse_target(value))
|
||||
except (ValueError, OSError, GLib.Error) as error:
|
||||
self.show_message(str(error), error=True)
|
||||
|
||||
def on_create(self, _button):
|
||||
if self.target is None:
|
||||
return
|
||||
try:
|
||||
destination = create_shortcut(self.target, self.nameEntry.get_text(), self.fileManager,
|
||||
self.terminalCheck.get_active())
|
||||
except (ValueError, OSError, GLib.Error) as error:
|
||||
self.show_message(str(error), error=True)
|
||||
return
|
||||
self.show_message("Created {}.\n\nOpen I38 Desktop to use it.".format(destination))
|
||||
self.close()
|
||||
|
||||
def on_key(self, _widget, event):
|
||||
if event.keyval == Gdk.KEY_Escape:
|
||||
self.close()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Create an I38 desktop shortcut")
|
||||
parser.add_argument("--file-manager", default="", help="I38's selected file manager executable")
|
||||
args = parser.parse_args()
|
||||
GLib.set_application_name("I38 Create Desktop Shortcut")
|
||||
ShortcutWindow(args.file_manager)
|
||||
Gtk.main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/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 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 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):
|
||||
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 == "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 == "Program")
|
||||
return keyFile.to_data()[0]
|
||||
|
||||
|
||||
def create_shortcut(target, name, fileManager="", terminal=False, desktopPath=None):
|
||||
data = shortcut_data(target, name, fileManager, terminal)
|
||||
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)
|
||||
Reference in new issue
Block a user