Skip to content

Commit f7e18df

Browse files
flatpak: OCR via Screenshot portal + bundled tesseract; run-in-terminal/run-command on host via flatpak-spawn; open-path on host; drop home grant (fixes config writes); v0.9.3
1 parent c88b37c commit f7e18df

8 files changed

Lines changed: 191 additions & 35 deletions

actions.py

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -260,18 +260,46 @@ def search_web(text: str) -> None:
260260
get_backend().open_url(url)
261261

262262

263+
def _in_flatpak() -> bool:
264+
return os.path.exists("/.flatpak-info")
265+
266+
267+
_TERMINALS = [
268+
("gnome-terminal", ["gnome-terminal", "--", "bash", "-c"]),
269+
("konsole", ["konsole", "-e", "bash", "-c"]),
270+
("xfce4-terminal", ["xfce4-terminal", "-e"]),
271+
("alacritty", ["alacritty", "-e", "bash", "-c"]),
272+
("kitty", ["kitty", "bash", "-c"]),
273+
("x-terminal-emulator", ["x-terminal-emulator", "-e", "bash", "-c"]),
274+
("xterm", ["xterm", "-hold", "-e", "bash", "-c"]), # xterm has -hold
275+
]
276+
277+
278+
def _host_has(binary: str) -> bool:
279+
"""Is `binary` on the host's PATH? Checked through flatpak-spawn so it
280+
works from inside the sandbox."""
281+
try:
282+
r = subprocess.run(
283+
["flatpak-spawn", "--host", "sh", "-c",
284+
"command -v %s" % shlex.quote(binary)],
285+
capture_output=True, timeout=5,
286+
)
287+
return r.returncode == 0
288+
except (OSError, subprocess.SubprocessError):
289+
return False
290+
291+
263292
def _find_terminal() -> Optional[tuple[str, list[str]]]:
264-
"""Return (binary, argv-prefix) for the first installed terminal emulator."""
265-
candidates = [
266-
("gnome-terminal", ["gnome-terminal", "--", "bash", "-c"]),
267-
("konsole", ["konsole", "-e", "bash", "-c"]),
268-
("xfce4-terminal", ["xfce4-terminal", "-e"]),
269-
("alacritty", ["alacritty", "-e", "bash", "-c"]),
270-
("kitty", ["kitty", "bash", "-c"]),
271-
("x-terminal-emulator", ["x-terminal-emulator", "-e", "bash", "-c"]),
272-
("xterm", ["xterm", "-hold", "-e", "bash", "-c"]), # xterm has -hold
273-
]
274-
for binary, argv in candidates:
293+
"""Return (binary, argv-prefix) for the first available terminal emulator.
294+
Inside Flatpak the command has to run on the host (running it in the
295+
sandbox would hit the wrong filesystem and tools), so we look for the
296+
terminal on the host's PATH and prefix the argv with flatpak-spawn --host."""
297+
if _in_flatpak():
298+
for binary, argv in _TERMINALS:
299+
if _host_has(binary):
300+
return binary, ["flatpak-spawn", "--host", *argv]
301+
return None
302+
for binary, argv in _TERMINALS:
275303
if shutil.which(binary):
276304
return binary, argv
277305
return None
@@ -512,7 +540,21 @@ def run_in_terminal(text: str) -> None:
512540

513541

514542
def open_path(text: str) -> None:
515-
path = os.path.expanduser(_strip_invisible(text))
543+
raw = _strip_invisible(text)
544+
if _in_flatpak():
545+
# Open on the host: the sandbox can't open an arbitrary host path
546+
# (the OpenURI portal needs a readable fd we don't have). Expand a
547+
# leading ~ against the HOST's $HOME, then run the host's xdg-open.
548+
argv = ["flatpak-spawn", "--host", "sh", "-c",
549+
'p="$1"; case "$p" in "~"*) p="$HOME${p#\\~}";; esac; '
550+
'exec xdg-open "$p"', "sh", raw]
551+
try:
552+
subprocess.Popen(argv, start_new_session=True)
553+
print(f"[actions] opened path on host: {raw}")
554+
except OSError:
555+
print("[actions] flatpak-spawn not available")
556+
return
557+
path = os.path.expanduser(raw)
516558
try:
517559
subprocess.Popen(
518560
["xdg-open", path],

icon_picker.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,10 +259,11 @@ def _on_link_clicked(self, _label, uri: str) -> bool:
259259
"""Handle the custom linuxpop:// link in the empty-state message."""
260260
if uri == "linuxpop://open-user-icons":
261261
USER_ICONS_DIR.mkdir(parents=True, exist_ok=True)
262+
argv = (["flatpak-spawn", "--host", "xdg-open", str(USER_ICONS_DIR)]
263+
if os.path.exists("/.flatpak-info")
264+
else ["xdg-open", str(USER_ICONS_DIR)])
262265
try:
263-
subprocess.Popen(
264-
["xdg-open", str(USER_ICONS_DIR)], start_new_session=True,
265-
)
266+
subprocess.Popen(argv, start_new_session=True)
266267
except FileNotFoundError:
267268
pass
268269
return True # consumed

main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
from popup import PopupWindow
4141
from settings import get_settings
4242

43-
__version__ = "0.9.2"
43+
__version__ = "0.9.3"
4444

4545
CACHE_DIR = Path(os.path.expanduser("~/.cache/linuxpop"))
4646
LOG_FILE = CACHE_DIR / "linuxpop.log"

mcp_server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444

4545
PROTOCOL_VERSION = "2024-11-05"
4646
SERVER_NAME = "linuxpop"
47-
SERVER_VERSION = "0.9.2"
47+
SERVER_VERSION = "0.9.3"
4848

4949
# Log to a file so the user can debug without stdout-noise corrupting
5050
# the JSON-RPC stream the MCP client is reading.

packaging/flatpak/io.github.GaimsDevSoftware.LinuxPop.yml

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,17 @@ finish-args:
7474
# same host path KWin can open, so no broad /tmp grant is needed and the
7575
# host-tmp linter error is gone.
7676
- --filesystem=xdg-run/linuxpop:create
77-
# Read-only home so the "open file/folder" action can hand the selected
78-
# path to the OpenURI portal (xdg-open needs to open the file to pass its
79-
# fd; with no filesystem access the portal call silently no-ops). Read-only
80-
# is sufficient - the launched handler runs host-side with its own access.
81-
- --filesystem=home:ro
77+
# NOTE: no broad home/host filesystem grant. "Open file/folder" runs the
78+
# host's xdg-open via flatpak-spawn --host (see actions.open_path), so it
79+
# opens any selected path without home access - and the app keeps its own
80+
# writable per-app config dir (a home grant would flip $HOME to the real,
81+
# here read-only, home and break settings writes).
82+
- --env=TESSDATA_PREFIX=/app/share/tessdata # bundled OCR language data (eng+nor)
83+
# "Run in terminal" / run-command actions execute the user's command on the
84+
# HOST through flatpak-spawn --host. Running it inside the sandbox would hit
85+
# the wrong tools and filesystem. This is a broad permission, but it is
86+
# exactly what the feature does, and LinuxPop is self-distributed (not Flathub).
87+
- --talk-name=org.freedesktop.Flatpak
8288
- --device=dri # GTK rendering
8389

8490
cleanup:
@@ -298,6 +304,47 @@ modules:
298304
- /lib/pkgconfig
299305
- '*.la'
300306

307+
# ----- Screen OCR: leptonica + tesseract + language data (eng, nor) -----
308+
# Capture happens through the XDG Screenshot portal (no screenshot binary
309+
# needed in the sandbox); these provide the recognition engine.
310+
- name: leptonica
311+
buildsystem: autotools
312+
sources:
313+
- type: archive
314+
url: https://github.com/DanBloomberg/leptonica/releases/download/1.85.0/leptonica-1.85.0.tar.gz
315+
sha256: 3745ae3bf271a6801a2292eead83ac926e3a9bc1bf622e9cd4dd0f3786e17205
316+
cleanup:
317+
- /include
318+
- /lib/pkgconfig
319+
- '*.la'
320+
321+
- name: tesseract
322+
buildsystem: autotools
323+
config-opts:
324+
- --disable-openmp
325+
- --disable-legacy
326+
sources:
327+
- type: archive
328+
url: https://github.com/tesseract-ocr/tesseract/archive/refs/tags/5.5.0.tar.gz
329+
sha256: f2fb34ca035b6d087a42875a35a7a5c4155fa9979c6132365b1e5a28ebc3fc11
330+
cleanup:
331+
- /include
332+
- /lib/pkgconfig
333+
- '*.la'
334+
335+
- name: tessdata
336+
buildsystem: simple
337+
build-commands:
338+
- install -Dm644 eng.traineddata /app/share/tessdata/eng.traineddata
339+
- install -Dm644 nor.traineddata /app/share/tessdata/nor.traineddata
340+
sources:
341+
- type: file
342+
url: https://github.com/tesseract-ocr/tessdata_fast/raw/main/eng.traineddata
343+
sha256: 7d4322bd2a7749724879683fc3912cb542f19906c83bcc1a52132556427170b2
344+
- type: file
345+
url: https://github.com/tesseract-ocr/tessdata_fast/raw/main/nor.traineddata
346+
sha256: 0451eb4f8049ae78196806bf878a389a2f40f1386fe038568cf4441226ba6ef2
347+
301348
- name: linuxpop
302349
buildsystem: simple
303350
build-commands:

packaging/io.github.GaimsDevSoftware.LinuxPop.metainfo.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@
9696
<content_rating type="oars-1.1" />
9797

9898
<releases>
99+
<release version="0.9.3" date="2026-06-15">
100+
<description>
101+
<p>Flatpak: screen OCR is back (bundled tesseract + Screenshot portal);
102+
run-in-terminal and run-command now run on the host via flatpak-spawn;
103+
open file/folder works for any path; dropped the home filesystem grant
104+
that was blocking the app from saving its own settings.</p>
105+
</description>
106+
</release>
99107
<release version="0.9.2" date="2026-06-15">
100108
<description>
101109
<p>Flatpak fixes: "open file/folder" now works (read-only home access);

recipe_loader.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,13 @@ def disabled_handler(_text: str) -> None:
207207

208208
def handler(text: str) -> None:
209209
cmd = _render(template, text)
210+
# In Flatpak, run on the host (the sandbox has the wrong tools and
211+
# filesystem); flatpak-spawn --host needs --talk-name=org.freedesktop.Flatpak.
212+
argv = (["flatpak-spawn", "--host", "bash", "-c", cmd]
213+
if os.path.exists("/.flatpak-info")
214+
else ["bash", "-c", cmd])
210215
try:
211-
subprocess.Popen(["bash", "-c", cmd], start_new_session=True)
216+
subprocess.Popen(argv, start_new_session=True)
212217
except OSError as exc:
213218
subprocess.run(
214219
["notify-send", "--hint=byte:transient:1", "-t", "3000", "-i", "dialog-error",

screen_ocr.py

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,68 @@ def has(needles: tuple[str, ...]) -> bool:
8989
"tesseract-ocr-nor maim")
9090

9191

92+
def _in_flatpak() -> bool:
93+
return os.path.exists("/.flatpak-info")
94+
95+
96+
def _portal_screenshot(out_path: Path) -> bool:
97+
"""Capture a region via the XDG Screenshot portal (interactive). On KDE
98+
this opens Spectacle's region selector; the portal hands back the saved
99+
image URI, which we copy to out_path. This is how OCR captures inside the
100+
Flatpak sandbox, where no screenshot binary is on PATH."""
101+
try:
102+
import urllib.parse
103+
import dbus
104+
from dbus.mainloop.glib import DBusGMainLoop
105+
from gi.repository import GLib
106+
except Exception as exc: # noqa: BLE001
107+
log.warning("[ocr] screenshot portal unavailable: %s", exc)
108+
return False
109+
got: dict[str, str] = {}
110+
try:
111+
DBusGMainLoop(set_as_default=True)
112+
bus = dbus.SessionBus()
113+
portal = bus.get_object("org.freedesktop.portal.Desktop",
114+
"/org/freedesktop/portal/desktop")
115+
iface = dbus.Interface(portal, "org.freedesktop.portal.Screenshot")
116+
loop = GLib.MainLoop()
117+
118+
def _on_response(response, results):
119+
if int(response) == 0 and "uri" in results:
120+
got["uri"] = str(results["uri"])
121+
loop.quit()
122+
123+
req_path = iface.Screenshot(
124+
"", {"interactive": dbus.Boolean(True),
125+
"handle_token": "linuxpop_ocr_%d" % os.getpid()})
126+
bus.add_signal_receiver(
127+
_on_response, signal_name="Response",
128+
dbus_interface="org.freedesktop.portal.Request", path=str(req_path))
129+
# Generous timeout: the user is drawing a box by hand.
130+
GLib.timeout_add(180000, lambda: (loop.quit(), False)[1])
131+
loop.run()
132+
except Exception as exc: # noqa: BLE001
133+
log.warning("[ocr] screenshot portal call failed: %s", exc)
134+
return False
135+
uri = got.get("uri")
136+
if not uri:
137+
return False # user cancelled, or no result
138+
src = uri[7:] if uri.startswith("file://") else uri
139+
src = urllib.parse.unquote(src)
140+
try:
141+
shutil.copyfile(src, str(out_path))
142+
except OSError as exc:
143+
log.warning("[ocr] could not read portal screenshot %s: %s", src, exc)
144+
return False
145+
return out_path.is_file() and out_path.stat().st_size > 0
146+
147+
92148
def _has_capture_tool() -> bool:
93-
"""True if any supported region-capture tool is on PATH. spectacle and
94-
grim work natively on Wayland; maim/gnome-screenshot are the X11 path."""
149+
"""True if we can capture a region. Inside Flatpak we go through the XDG
150+
Screenshot portal (no binary needed). Otherwise we need spectacle/grim
151+
(Wayland) or maim/gnome-screenshot (X11) on PATH."""
152+
if _in_flatpak():
153+
return True
95154
return bool(shutil.which("spectacle") or shutil.which("grim")
96155
or shutil.which("maim") or shutil.which("gnome-screenshot"))
97156

@@ -114,6 +173,8 @@ def install_argv() -> "list[str] | None":
114173
we don't recognise the package manager. A capture tool is only added
115174
when none is present - KDE already ships spectacle, so on most Wayland
116175
desktops only tesseract is missing."""
176+
if _in_flatpak():
177+
return None # can't install host packages from the sandbox; OCR is bundled
117178
ids = _distro_id()
118179

119180
def has(needles: tuple) -> bool:
@@ -148,6 +209,8 @@ def _capture_region(out_path: Path) -> bool:
148209
"""Use whichever region-capture tool is installed to grab a user-
149210
drawn rectangle and write it as a PNG. Returns False if the user
150211
cancelled or the tool errored out."""
212+
if _in_flatpak():
213+
return _portal_screenshot(out_path)
151214
if shutil.which("spectacle"):
152215
# KDE's capture tool. Its rectangular-region selector works
153216
# natively on Wayland (maim is X11-only and grim needs wlroots),
@@ -290,16 +353,6 @@ def run_ocr_to_clipboard() -> None:
290353
tray menu. Captures a region, OCRs it, puts the result on the
291354
clipboard, and shows the result text in the popup (so it lands as
292355
a selection the rest of LinuxPop's actions can pick up)."""
293-
if os.path.exists("/.flatpak-info"):
294-
# Screen OCR needs a screenshot tool + tesseract; neither is in the
295-
# Flatpak (and Wayland capture would need the Screenshot portal). Don't
296-
# show the distro "install ..." hint to a Flatpak user - just say so.
297-
subprocess.run(
298-
["notify-send", "--hint=byte:transient:1", "-t", "4000",
299-
"-i", "dialog-information", "LinuxPop OCR",
300-
"Screen OCR isn't available in the Flatpak build of LinuxPop."],
301-
check=False)
302-
return
303356
ok_sup, reason = is_supported()
304357
if not ok_sup:
305358
subprocess.run(

0 commit comments

Comments
 (0)