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
@@ -76,6 +76,7 @@ Ratpoison Mode provides quick access to common actions with short key sequences.
|
||||
`e`: Open the text editor, @TEXTEDITOR@.<br>
|
||||
`@MODKEY@` + `e`: Open the email client.<br>
|
||||
`f`: Open the file manager, if one was configured.<br>
|
||||
`Control` + `Alt` + `d`: Create a desktop shortcut from the clipboard, a file, folder, program, web URL, or installed application.<br>
|
||||
`i`: Launch the IRC client, if one was configured.<br>
|
||||
`w`: Launch the web browser, @WEBBROWSER@.<br>
|
||||
`s`: Launch Steam Big Picture, if Steam is installed.<br>
|
||||
@@ -343,6 +344,14 @@ This is only available if you chose multiple keyboard layouts during setup.
|
||||
|
||||
## Desktop and Window Decorations
|
||||
|
||||
### Creating desktop shortcuts
|
||||
|
||||
Enter Ratpoison Mode with `@RATPOISONKEY@`, then press `Control` + `Alt` + `d`. Choose **Add from Clipboard**, **Browse for File**, **Browse for Folder**, or **Search for Application**. Copy one item at a time. You can also enter a path such as `/usr/bin/firefox` or `~/Documents`, a program name, or a complete HTTP/HTTPS URL in the Target field and press Enter.
|
||||
|
||||
The **Shortcut name** edit box contains a suggested name. For a web link, I38 looks up the page title, using the hostname if the title is unavailable. Edit the name if desired, then press **Create Shortcut** to confirm. Nothing is written until you confirm, and a late page title never replaces your edits. Use **Run program in a terminal** for command-line programs. Folders open in your selected file manager; files and web links use their default applications. Application shortcuts preserve the original launcher settings and require that original launcher to remain available. Repeated names receive numbered filenames. Deleting a created shortcut removes only the shortcut.
|
||||
|
||||
### Desktop navigation
|
||||
|
||||
I38 uses minimal window decorations. Windows do not use the usual title bars with minimize and maximize buttons. Instead, windows fill their available space automatically, and most interactions are performed through keyboard shortcuts.
|
||||
|
||||
`@MODKEY@` + `Control` + `d`: Open the I38 Desktop dialog. Use Up and Down to move through items, hold Shift with Up or Down to select multiple items, and press Control+Space to clear the selection. Type to search, press Enter to open the focused item, or press Shift+F10 for a context menu. Escape clears an active search and returns to the list; press it again to close the dialog. Right-click opens the context menu without clearing selected items. Delete permanently removes selected desktop files or folders after confirmation, including the contents of selected folders. Shortcut entries are kept. Use Tab and Shift+Tab to reach the search field and buttons.<br>
|
||||
|
||||
@@ -126,6 +126,18 @@ Press the configured desktop shortcut, Mod+Control+D (Super+Control+D or Alt+Con
|
||||
|
||||
Quick-launch shortcut entries come from `~/.config/i3/desktop_shortcuts`. They are not deleted from disk by the desktop dialog.
|
||||
|
||||
### Creating desktop shortcuts
|
||||
|
||||
Enter Ratpoison Mode, then press Control+Alt+D to open **I38 Create Desktop Shortcut**. Choose **Add from Clipboard**, **Browse for File**, **Browse for Folder**, or **Search for Application**. You can also type an absolute path such as `/usr/bin/firefox`, a home-relative path such as `~/Documents`, a program name on PATH, or a complete HTTP/HTTPS URL in the Target field, then press Enter. Copy one item at a time; copying a file in a file manager and copying its path as text both work. Copied files are left in place, including files marked for cutting.
|
||||
|
||||
Review the suggested shortcut name, edit it if desired, then choose **Create Shortcut**. Web links use the page title when available, with the site hostname as a fallback. The title lookup runs in the background with a size limit and short network timeouts, without browser cookies or JavaScript. It never replaces a name you have edited. Files open in their default application, folders open in I38's selected file manager, and executable programs run directly. For command-line programs, select **Run program in a terminal**. Target accepts a program or path, rather than a shell command with arguments.
|
||||
|
||||
Application search lists registered visible applications, including user and system application launchers, with case-insensitive name-prefix filtering like the desktop dialog. Type to search and press Down to move into the list; press Enter to select an application. The shortcut launches the original `.desktop` entry, preserving its terminal, working-directory, D-Bus, and launch options. Keep that original entry available. You can also browse to or paste a `.desktop` file directly.
|
||||
|
||||
Shortcuts are ordinary `.desktop` files in the XDG desktop directory (normally `~/Desktop`). Existing items are never overwritten; repeated names get numbered filenames. Tab and Shift+Tab move between controls, and Escape closes the dialog. Deleting a created shortcut through I38 Desktop removes only the shortcut file.
|
||||
|
||||
After updating an existing installation, regenerate your I38 configuration to add the new Ratpoison binding; `./i38.sh -u` copies scripts but does not add bindings. Review your configuration preferences before regeneration. Then reload i3 with `i3-msg reload`, or enter Ratpoison Mode and press Control+semicolon, to activate the new binding in the running session.
|
||||
|
||||
## Dependency Helpers
|
||||
|
||||
Distro-specific dependency helpers live in `extra/`. On Arch Linux, run:
|
||||
@@ -150,6 +162,14 @@ git config --local core.hooksPath .githooks
|
||||
The pre-commit hook rejects staged Python cache files. The I38 script installer
|
||||
also excludes and removes Python cache artifacts when copying `scripts/`.
|
||||
|
||||
Shortcut regression tests use Python's unittest, PyGObject, and `desktop-file-validate` (desktop-file-utils). Run `python3 -B -m unittest discover -s tests -v`; its page-title tests start a temporary localhost HTTP server. For native GTK and clipboard checks, also install Xvfb and xclip, then run:
|
||||
|
||||
```bash
|
||||
xvfb-run -a dbus-run-session -- env -u AT_SPI_BUS_ADDRESS I38_PRIVATE_GUI_TEST=1 PYTHONDONTWRITEBYTECODE=1 python3 tests/native_shortcut_gui.py -v
|
||||
```
|
||||
|
||||
The GUI test uses a separate display and session bus. It does not prove live speech or Braille. After reviewing the diff and regenerating your configuration, test the new Ratpoison binding, clipboard and browse sources, application search, edited names, and opening each shortcut type through I38 Desktop. Confirm that normal desktop prefix search and existing launchers still work, that repeated names preserve previous shortcuts, and that deleting a generated shortcut leaves its original target intact.
|
||||
|
||||
### AI-assisted contributions
|
||||
|
||||
AI-assisted contributions are welcome.
|
||||
|
||||
@@ -1481,6 +1481,8 @@ bindsym $escapeKey mode "ratpoison"
|
||||
mode "ratpoison" {
|
||||
# Ratpoison mode keybindings help bound to F1
|
||||
bindsym F1 exec ${i3Path}/scripts/i38-help-rp.sh, mode "default"
|
||||
# Create a desktop shortcut bound to Control+Alt+d
|
||||
bindsym Control+Mod1+d exec --no-startup-id python3 ${i3Path}/scripts/shortcut_creator.py --file-manager '${fileBrowser}', mode "default"
|
||||
# Terminal emulator bound to c
|
||||
bindsym c exec $terminalExec, mode "default"
|
||||
# Text editor bound to e
|
||||
|
||||
+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