Skip to content

Commit 460ee79

Browse files
Restore native OCR capture in Flatpak (drop portal)
The sandbox build captured through the XDG Screenshot portal, which on KDE opens Spectacle's full window plus an Accept step and a one-time permission dialog. Route capture through the host's own tools via flatpak-spawn instead, writing to the shared $XDG_RUNTIME_DIR/linuxpop dir: the frictionless freeze-and-drag overlay (host spectacle -f for the full grab) and the spectacle -r region fallback both work again, exactly like a native install. Portal stays as a last resort. Bump to 0.9.5.
1 parent a7c05c8 commit 460ee79

5 files changed

Lines changed: 149 additions & 15 deletions

File tree

main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
from settings import get_settings
4242
from xdg_paths import CACHE_DIR, CONFIG_DIR
4343

44-
__version__ = "0.9.4"
44+
__version__ = "0.9.5"
4545

4646
LOG_FILE = CACHE_DIR / "linuxpop.log"
4747
LOCK_FILE = CACHE_DIR / "linuxpop.lock"

mcp_server.py

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

4848
PROTOCOL_VERSION = "2024-11-05"
4949
SERVER_NAME = "linuxpop"
50-
SERVER_VERSION = "0.9.4"
50+
SERVER_VERSION = "0.9.5"
5151

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

ocr_selector.py

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,63 @@
2727
from gi.repository import Gtk, Gdk, GdkPixbuf, GLib # noqa: E402
2828

2929

30+
def _in_flatpak() -> bool:
31+
return os.path.exists("/.flatpak-info")
32+
33+
34+
def _host_has(binary: str) -> bool:
35+
"""Is `binary` on the host's PATH? (Used in Flatpak, where the screen
36+
grabbers live on the host, not in the sandbox.)"""
37+
try:
38+
r = subprocess.run(
39+
["flatpak-spawn", "--host", "sh", "-c", f"command -v {binary}"],
40+
capture_output=True, text=True, timeout=5)
41+
return r.returncode == 0 and bool(r.stdout.strip())
42+
except (OSError, subprocess.SubprocessError):
43+
return False
44+
45+
46+
def _grabber() -> "str | None":
47+
"""Name of the first available full-screen grabber, or None. Checks the
48+
host's PATH inside Flatpak, the sandbox's PATH otherwise."""
49+
probe = _host_has if _in_flatpak() else shutil.which
50+
for tool in ("spectacle", "grim", "maim"):
51+
if probe(tool):
52+
return tool
53+
return None
54+
55+
3056
def _capture_fullscreen() -> "str | None":
3157
"""Grab the whole screen to a temp PNG with no UI. spectacle -f -b is the
32-
KWin-native path; grim covers wlroots; maim covers X11."""
33-
fd, path = tempfile.mkstemp(suffix=".png", prefix="lp-ocr-full-")
34-
os.close(fd)
58+
KWin-native path; grim covers wlroots; maim covers X11.
59+
60+
Inside Flatpak the grabbers run on the HOST (flatpak-spawn), and the PNG
61+
must land in a dir the host can write and the sandbox can read: the app's
62+
$XDG_RUNTIME_DIR/linuxpop is bind-mounted to the identical host path."""
63+
tool = _grabber()
64+
if not tool:
65+
return None
66+
in_fp = _in_flatpak()
67+
if in_fp:
68+
runtime = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
69+
shared = os.path.join(runtime, "linuxpop")
70+
try:
71+
os.makedirs(shared, exist_ok=True)
72+
except OSError:
73+
return None
74+
path = os.path.join(shared, f"lp-ocr-full-{os.getpid()}.png")
75+
prefix = ["flatpak-spawn", "--host"]
76+
else:
77+
fd, path = tempfile.mkstemp(suffix=".png", prefix="lp-ocr-full-")
78+
os.close(fd)
79+
prefix = []
80+
argv = {
81+
"spectacle": [*prefix, "spectacle", "-f", "-b", "-n", "-o", path],
82+
"grim": [*prefix, "grim", path],
83+
"maim": [*prefix, "maim", path],
84+
}[tool]
3585
try:
36-
if shutil.which("spectacle"):
37-
subprocess.run(["spectacle", "-f", "-b", "-n", "-o", path],
38-
capture_output=True, timeout=15)
39-
elif shutil.which("grim"):
40-
subprocess.run(["grim", path], capture_output=True, timeout=15)
41-
elif shutil.which("maim"):
42-
subprocess.run(["maim", path], capture_output=True, timeout=15)
86+
subprocess.run(argv, capture_output=True, timeout=15)
4387
if os.path.exists(path) and os.path.getsize(path) > 0:
4488
return path
4589
except (OSError, subprocess.SubprocessError):
@@ -212,9 +256,9 @@ def select_and_capture(callback) -> bool:
212256

213257

214258
def available() -> bool:
215-
"""True if we have a full-screen grabber and layer-shell/X11 to overlay."""
216-
if not (shutil.which("spectacle") or shutil.which("grim")
217-
or shutil.which("maim")):
259+
"""True if we have a full-screen grabber and layer-shell/X11 to overlay.
260+
In Flatpak the grabber is checked on the host (flatpak-spawn)."""
261+
if not _grabber():
218262
return False
219263
try:
220264
gi.require_version("GtkLayerShell", "0.1")

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

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

9898
<releases>
99+
<release version="0.9.5" date="2026-06-15">
100+
<description>
101+
<p>Screen OCR feels like it used to again. The Flatpak build had been
102+
routing capture through the desktop's screenshot portal, which on KDE
103+
popped Spectacle's full window and an extra confirm step. It now freezes
104+
the screen and lets you drag one rectangle, the same quick flow as a
105+
normal install, by driving the host's own capture tool. Recognition
106+
still runs on the bundled engine, no setup needed.</p>
107+
</description>
108+
</release>
99109
<release version="0.9.4" date="2026-06-15">
100110
<description>
101111
<p>Your settings finally stick. In the Flatpak build the app was saving

screen_ocr.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,81 @@ def _on_response(response, results):
145145
return out_path.is_file() and out_path.stat().st_size > 0
146146

147147

148+
def _host_has(binary: str) -> bool:
149+
"""Is `binary` on the host's PATH? (In Flatpak the capture tools live on
150+
the host, not in the sandbox.)"""
151+
try:
152+
r = subprocess.run(
153+
["flatpak-spawn", "--host", "sh", "-c", f"command -v {binary}"],
154+
capture_output=True, text=True, timeout=5)
155+
return r.returncode == 0 and bool(r.stdout.strip())
156+
except (OSError, subprocess.SubprocessError):
157+
return False
158+
159+
160+
def _host_capture_region(out_path: Path) -> bool:
161+
"""Flatpak: drive the HOST's region-capture tool (the same spectacle / grim
162+
/ maim flow a native install uses) through flatpak-spawn, instead of the
163+
clunkier Screenshot portal. The capture lands in $XDG_RUNTIME_DIR/linuxpop,
164+
which is bind-mounted to the identical host path, then we move it to
165+
out_path. Returns False (so the caller can fall back to the portal) when no
166+
host tool is present or the user cancelled."""
167+
runtime = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
168+
shared_dir = Path(runtime) / "linuxpop"
169+
try:
170+
shared_dir.mkdir(parents=True, exist_ok=True)
171+
except OSError:
172+
return False
173+
shared = shared_dir / f"ocr-region-{os.getpid()}.png"
174+
try:
175+
shared.unlink()
176+
except OSError:
177+
pass
178+
sp = str(shared)
179+
180+
def host(*argv, timeout):
181+
try:
182+
return subprocess.run(["flatpak-spawn", "--host", *argv],
183+
capture_output=True, text=True, timeout=timeout)
184+
except (OSError, subprocess.SubprocessError):
185+
return None
186+
187+
ok = False
188+
if _host_has("spectacle"):
189+
# -r region, -b background (no GUI window), -n no notification: a
190+
# direct rectangular drag, exactly like a native KDE install.
191+
host("spectacle", "-r", "-b", "-n", "-o", sp, timeout=120)
192+
ok = shared.is_file() and shared.stat().st_size > 0
193+
elif _host_has("grim") and _host_has("slurp"):
194+
geom = host("slurp", timeout=60)
195+
if geom and geom.returncode == 0 and geom.stdout.strip():
196+
host("grim", "-g", geom.stdout.strip(), sp, timeout=30)
197+
ok = shared.is_file() and shared.stat().st_size > 0
198+
elif _host_has("maim"):
199+
host("maim", "-s", sp, timeout=60)
200+
ok = shared.is_file() and shared.stat().st_size > 0
201+
elif _host_has("gnome-screenshot"):
202+
host("gnome-screenshot", "--area", "--file", sp, timeout=60)
203+
ok = shared.is_file() and shared.stat().st_size > 0
204+
else:
205+
return False # no host capture tool; caller tries the portal
206+
if not ok:
207+
try:
208+
shared.unlink()
209+
except OSError:
210+
pass
211+
return False
212+
try:
213+
shutil.move(sp, str(out_path))
214+
except OSError:
215+
try:
216+
shutil.copyfile(sp, str(out_path))
217+
shared.unlink()
218+
except OSError:
219+
return False
220+
return out_path.is_file() and out_path.stat().st_size > 0
221+
222+
148223
def _has_capture_tool() -> bool:
149224
"""True if we can capture a region. Inside Flatpak we go through the XDG
150225
Screenshot portal (no binary needed). Otherwise we need spectacle/grim
@@ -210,6 +285,11 @@ def _capture_region(out_path: Path) -> bool:
210285
drawn rectangle and write it as a PNG. Returns False if the user
211286
cancelled or the tool errored out."""
212287
if _in_flatpak():
288+
# Prefer the native host tools (spectacle region drag, etc.) via
289+
# flatpak-spawn; only fall back to the Screenshot portal if the host
290+
# has no capture tool at all.
291+
if _host_capture_region(out_path):
292+
return True
213293
return _portal_screenshot(out_path)
214294
if shutil.which("spectacle"):
215295
# KDE's capture tool. Its rectangular-region selector works

0 commit comments

Comments
 (0)