Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 23 additions & 50 deletions packaging/flatpak/io.github.GaimsDevSoftware.LinuxPop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,20 @@
# - gtk-layer-shell: positions the popup as a layer-shell surface
# (a plain GTK window can't be placed on Wayland). Built with
# introspection so the GtkLayerShell-0.1 typelib is loadable by gi.
# - ydotool + ydotoold: key injection on Wayland. ydotoold writes to
# /dev/uinput, which needs the --device=all finish-arg. Bundling it
# means users don't have to install a host tool for Cut/Paste/Backspace.
# Neither ships in GNOME Platform 46, so both are built from source
# below with real upstream URL + sha256.
#
# Key injection (paste chords):
# wdotool (libei/XDG RemoteDesktop portal) is the preferred injector on
# KDE Plasma 6 — it goes through the compositor's input stack, so modifier
# chords (ctrl+v, ctrl+a) arrive correctly in every toolkit. Inside the
# Flatpak sandbox it uses the host's RemoteDesktop portal via D-Bus
# forwarding, so it works without /dev/uinput. Bundle as a Flatpak module
# once wdotool is packaged for Fedora/Debian; until then install it on the
# host via `cargo install wdotool` and it is accessed through flatpak-spawn.
# ydotool (kernel uinput) is the legacy fallback — it drops modifier chords
# in Chrome/Electron on KDE Plasma 6.
#
# RUNTIME VERSION: org.gnome.Platform//49 (GNOME 46 is EOL). 49 was
# verified to ship GTK3, the Handy-1 typelib, and python3-gi on Python
# 3.13 - the bundled Python wheels are cp313 to match. Re-check the
Expand All @@ -51,10 +59,11 @@ finish-args:
- --socket=fallback-x11 # X11 / XWayland backend
- --socket=pulseaudio # text-to-speech (espeak-ng) audio output
- --share=network # AI plugins (ChatGPT URL prefill, Ollama, etc.)
# No filesystem perms: the app's ~/.config/linuxpop + ~/.cache/linuxpop
# resolve to the sandbox's private per-app XDG dirs automatically (the
# linter flags explicit grants for these as unnecessary). xdg-open of
# selected paths/URLs goes through the OpenURI portal.
# No filesystem perms: the app reads $XDG_CONFIG_HOME / $XDG_CACHE_HOME
# (see xdg_paths.py) so its config + cache land in the sandbox's private,
# PERSISTENT per-app dirs. (Hardcoding ~/.config wrote to a throwaway
# tmpfs that the sandbox wiped on exit, losing every setting on restart -
# fixed in 0.9.4.) xdg-open of selected paths/URLs goes through the portal.
- --talk-name=org.freedesktop.Notifications
- --talk-name=org.kde.StatusNotifierWatcher # system tray
- --talk-name=org.kde.kglobalaccel # global hotkey (linter-accepted)
Expand All @@ -81,7 +90,6 @@ finish-args:
# exactly what the feature does, and LinuxPop is self-distributed (not Flathub).
- --talk-name=org.freedesktop.Flatpak
- --device=dri # GTK rendering
- --device=all # ydotoold needs /dev/uinput for key injection

cleanup:
- /include
Expand Down Expand Up @@ -231,23 +239,6 @@ modules:
url: https://github.com/bugaevc/wl-clipboard/archive/refs/tags/v2.3.0.tar.gz
sha256: b4dc560973f0cd74e02f817ffa2fd44ba645a4f1ea94b7b9614dacc9f895f402

# ----- ydotool -----
# Key injection on Wayland. ydotool v1.x is pure C99 with no external
# dependencies and only needs CMake. The companion ydotoold daemon holds
# a persistent uinput device; the wrapper script starts it at launch.
# --device=all grants /dev/uinput access inside the sandbox.
# We patch out the manpage subdir so we don't need scdoc in the SDK.
- name: ydotool
buildsystem: cmake-ninja
config-opts:
- -DCMAKE_POLICY_VERSION_MINIMUM=3.5
sources:
- type: archive
url: https://github.com/ReimuNotMoe/ydotool/archive/refs/tags/v1.0.4.tar.gz
sha256: ba075a43aa6ead51940e892ecffa4d0b8b40c241e4e2bc4bd9bd26b61fde23bd
- type: patch
path: ydotool-no-docs.patch

# ----- python3-dbus (dbus-python) -----
# The Wayland/KDE backend uses dbus-python for KGlobalAccel (global
# hotkeys) and KWin scripting over D-Bus; it is NOT in the GNOME runtime,
Expand All @@ -261,25 +252,13 @@ modules:
url: https://dbus.freedesktop.org/releases/dbus-python/dbus-python-1.3.2.tar.gz
sha256: ad67819308618b5069537be237f8e68ca1c7fcc95ee4a121fe6845b1418248f8

# ----- PySide6 (Qt6) for the system-tray subprocess -----
# tray_qt.py uses QSystemTrayIcon (StatusNotifierItem + DBusMenu) and
# QtSvg to recolour the tray glyph. The GNOME runtime has no Qt, so bundle
# PySide6-Essentials (Core/Gui/Widgets/DBus/Svg + platform plugins) and
# its shiboken6 runtime. abi3 wheels -> run on the runtime's Python 3.13.
# Without this the tray subprocess dies ("could not connect") in-sandbox.
- name: python3-pyside6
buildsystem: simple
build-commands:
- pip3 install --prefix=/app --no-build-isolation --no-deps
shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl
pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl
sources:
- type: file
url: https://files.pythonhosted.org/packages/c7/9b/e0355d8897b5c150770f1d95718aad17d432fcc9c035c04f3f58427d4693/shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl
sha256: 9a8bccfafc8805254cabcfa1edfaf55cd52889f4998c91ad0d9a4433fb1bcdbe
- type: file
url: https://files.pythonhosted.org/packages/5c/49/0e1237c4400bec7e335d2c4eeb49bc40d9fd88a9ac44ca9083ce1abdc308/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl
sha256: e3ef7027b41e4e55fadb56e3b3257dc8ee92154b639fe67fc4c8e05e9d976c60
# The system tray (tray_dbus.py) is a hand-rolled StatusNotifierItem built
# on dbus-python + GdkPixbuf - both already in the runtime / bundled above -
# so no Qt is needed. This previously bundled PySide6-Essentials + shiboken6
# (~well over 100 MB) purely for the old QSystemTrayIcon tray; dropping it
# shrinks the Flatpak substantially. The dbus tray also advertises
# ItemIsMenu=true, which Qt's QSystemTrayIcon could not, so the menu now
# opens on left-click too.

# ----- LinuxPop itself -----
# ----- QR code generator (qr_code plugin) -----
Expand Down Expand Up @@ -368,12 +347,6 @@ modules:
# No `|| true` here: a missing source should fail the build loudly.
- cp -r *.py platform_backend icons plugins_repo /app/share/linuxpop/

# 1b. Bake the version. The sandbox has no .git at runtime, so resolve it
# now (git tag, with a metainfo fallback) into a _version.py the app
# reads first - keeps the About dialog in sync with the release.
- bash -c 'printf "VERSION = \"%s\"\n" "$(bash packaging/gen-version.sh)"
> /app/share/linuxpop/_version.py'

# 2. Wrapper script on PATH
- install -Dm755 packaging/flatpak/linuxpop.wrapper /app/bin/linuxpop

Expand Down
96 changes: 91 additions & 5 deletions plugin_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@

_PLUGINS: List[Plugin] = []

# Module names of the user plugins loaded by the previous _load_user_plugins()
# run. Before each reload we call every one's optional unregister() hook so a
# plugin that starts a background thread (e.g. clipboard_history's watchers)
# can stop it - otherwise each reload, and one fires on every settings save,
# leaked another live thread.
_USER_MODULE_NAMES: set[str] = set()

USER_PLUGIN_DIR = CONFIG_DIR / "plugins"
LINUXPOP_DIR = str(Path(__file__).resolve().parent)
REPO_PLUGIN_DIR = Path(LINUXPOP_DIR) / "plugins_repo"
Expand Down Expand Up @@ -226,21 +233,82 @@ def for_content_type(content_type: ContentType, text: str | None = None) -> List
# Plugins not in the order list fall back to their built-in priority.
try:
from settings import get_settings
order = list(get_settings().get("plugin_order") or [])
s = get_settings()
order = list(s.get("plugin_order") or [])
pinned = list(s.get("pinned_plugins") or [])
except Exception:
order = []
order, pinned = [], []
order_index = {name: i for i, name in enumerate(order)}
pinned_index = {name: i for i, name in enumerate(pinned)}
big = len(order) + 1_000_000

def sort_key(p: Plugin):
# Tier 0: pinned (locked to the top, in pin order). Tier 1: user
# plugin_order. Tier 2: everything else by built-in priority.
if p.name in pinned_index:
return (0, pinned_index[p.name], p.priority)
if p.name in order_index:
return (0, order_index[p.name], p.priority)
return (1, big, p.priority)
return (1, order_index[p.name], p.priority)
return (2, big, p.priority)

matched.sort(key=sort_key)
return matched


# Known plugin categories. A plugin sets Plugin.category to one of these keys;
# the popup collapses each group behind a chip (icon + label) that expands to
# its members. Order here is irrelevant - chips appear at the position of the
# group's first member, so plugin_order still controls placement.
CATEGORIES: dict[str, dict[str, str]] = {
"format": {"label": "Formatting", "icon": "linuxpop-format-symbolic"},
"markdown": {"label": "Markdown", "icon": "linuxpop-md-symbolic"},
}


def plan_grouped(plugins, *, group: bool, min_size: int,
categories: dict | None = None) -> list[tuple]:
"""Turn an ordered plugin list into a popup display plan.

Returns a list of entries, preserving the incoming order:
("action", plugin)
("category", key, label, icon, [member plugins])

A category collapses into one chip only when it has at least `min_size`
members present; smaller groups stay inline as plain actions (no point
hiding one button behind a chip). With group=False every plugin is an
inline action, i.e. today's behaviour. Pure/GTK-free so it can be tested.
"""
cats = CATEGORIES if categories is None else categories
if not group:
return [("action", p) for p in plugins]

members: dict[str, list] = {}
skeleton: list[tuple] = []
for p in plugins:
key = getattr(p, "category", None)
if key and key in cats:
if key not in members:
members[key] = []
skeleton.append(("catref", key))
members[key].append(p)
else:
skeleton.append(("action", p))

out: list[tuple] = []
for entry in skeleton:
if entry[0] != "catref":
out.append(entry)
continue
key = entry[1]
group_members = members[key]
if len(group_members) >= max(2, min_size):
meta = cats[key]
out.append(("category", key, meta["label"], meta["icon"], group_members))
else:
out.extend(("action", m) for m in group_members)
return out


def _register_builtins() -> None:
# Universal: copy works on anything
register(Plugin(
Expand Down Expand Up @@ -313,7 +381,7 @@ def _can_run(text: str) -> bool:
# EMAIL
register(Plugin(
name="compose-email",
icon="mail-send-symbolic",
icon="mail-message-new-symbolic",
tooltip="Compose email",
handler=actions.compose_email,
content_types=(ContentType.EMAIL,),
Expand Down Expand Up @@ -372,9 +440,25 @@ def _seed_default_plugins() -> None:
_PLUGIN_SEED_MARKER.touch()


def _teardown_user_plugins() -> None:
"""Call the optional unregister() hook on every user plugin loaded last
time, so its background threads stop before we re-import. Also catches
plugins whose file was removed since the previous load."""
for mod_name in _USER_MODULE_NAMES:
hook = getattr(sys.modules.get(mod_name), "unregister", None)
if callable(hook):
try:
hook()
except Exception:
print(f"[plugin_loader] {mod_name}.unregister() failed:")
traceback.print_exc()
_USER_MODULE_NAMES.clear()


def _load_user_plugins() -> None:
_ensure_on_path()
_seed_default_plugins()
_teardown_user_plugins()
if not USER_PLUGIN_DIR.is_dir():
return
for path in sorted(USER_PLUGIN_DIR.glob("*.py")):
Expand All @@ -390,6 +474,8 @@ def _load_user_plugins() -> None:
# introspection needs cls.__module__ to resolve.
sys.modules[mod_name] = module
spec.loader.exec_module(module)
# Track it so the next reload can call its unregister() hook.
_USER_MODULE_NAMES.add(mod_name)
if hasattr(module, "register"):
# Wrap register so we can track which source file each
# plugin came from - the Plugin Manager uses this to
Expand Down
Loading