-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhost.py
More file actions
366 lines (307 loc) · 14.9 KB
/
host.py
File metadata and controls
366 lines (307 loc) · 14.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
"""pywebview host — the single warm process for the React UI.
Embeds the data-driven core (core.api.CoreApi) and exposes it to the React app as
``window.pywebview.api.*``. HostApi adds the methods that need the native window
(open/save file dialogs); everything else delegates to CoreApi. One Python
process; the React build is just static assets.
Run:
- dev: cd webui && npm run dev ; then set BELEG_DEV=1 ; python host.py
- prod: cd webui && npm run build ; then python host.py
"""
import io
import os
import sys
import webview
from core.api import CoreApi
from infra.log_config import logger
from version_info import APP_NAME
def _prewarm():
"""Warm the load/render/compress path in the background so the first real
open/render doesn't pay the cold-import cost. Runs on a daemon thread at
startup, concurrent with the user looking at the window / picking a file.
Deliberately does NOT touch universal_importer (win32com/COM, extract-msg,
pillow-heif): that ~2.6 s cost is only needed to *import* Office/email/archive/
HEIC files, so it stays lazy and loads on first such import, not at startup.
Best-effort; never blocks startup.
"""
try:
import os
import tempfile
from pypdf import PdfWriter
from formats import pdf_storage # noqa: F401 (the big one: universal_importer/COM/pikepdf)
from core.bridge import save_belegtool, load_belegtool
from core.model import Document, Node
from services.render import render_pdf_to_pngs # PyMuPDF/fitz
from formats.compress_pdf_bytes import compress_all_methods # PIL/pikepdf
writer = PdfWriter()
writer.add_blank_page(width=72, height=72)
buf = io.BytesIO()
writer.write(buf)
tiny = buf.getvalue()
doc = Document(Node(name="root", is_folder=True, children=(
Node(name="w", pdf_length=1, original_data=tiny),)))
path = os.path.join(tempfile.gettempdir(), "_belegtool_warm.belegtool")
save_belegtool(doc, path) # warm the save path
load_belegtool(path) # warm the parse/slice load path
render_pdf_to_pngs(tiny) # warm the fitz render path
compress_all_methods(tiny, dpi=72) # warm the PIL/pikepdf compress path
try:
os.remove(path)
except Exception:
pass
except Exception:
# best-effort; never block startup — but log so a broken import path
# (missing DLL, library drift) is diagnosable instead of silently slow.
logger.exception("prewarm failed")
# Frozen (PyInstaller onedir): data files live under sys._MEIPASS, not next to
# this module (which is packed into the archive). Dev: resolve from the source.
HERE = getattr(sys, "_MEIPASS", None) or os.path.dirname(os.path.abspath(__file__))
DEV_URL = "http://localhost:5173"
PROD_INDEX = os.path.join(HERE, "webui", "dist", "index.html")
FILE_TYPES = ("BelegTool (*.belegtool)", "PDF (*.pdf)", "Alle Dateien (*.*)")
IMPORT_FILE_TYPES = (
"Unterstützte Dateien (*.pdf;*.belegtool;*.jpg;*.jpeg;*.png;*.webp;*.heic;"
"*.docx;*.xlsx;*.pptx;*.zip;*.tar;*.eml;*.msg)",
"Alle Dateien (*.*)",
)
def _entry():
return DEV_URL if os.environ.get("BELEG_DEV") else PROD_INDEX
def _bind_close(win, api):
"""Per-window close guard: confirm before discarding unsaved changes. Uses the
Python-side dirty flag the React app pushes via set_dirty — NOT evaluate_js,
which hangs during window teardown (windows then wouldn't close). Return False
cancels the close."""
def _on_closing():
try:
if api._dirty and not win.create_confirmation_dialog(
"Ungespeicherte Änderungen",
"Das Fenster schließen und die ungespeicherten Änderungen verwerfen?"):
return False # user cancelled → keep the window open AND keep the lock
except Exception:
pass
try:
if api._session:
api._core.release(api._session) # free the file lock for this window
except Exception:
pass
return True
win.events.closing += _on_closing
def _open_window(core, startup_path=None):
"""Open a document window with its own HostApi (sharing one CoreApi — sessions
are independent per window). Used for the first window and every 'new window'.
``startup_path`` (only the first window) is a .belegtool the React app opens on
load — used when the legacy GUI hands a document over via 'open in new GUI'."""
api = HostApi(core)
api._startup_path = startup_path
win = webview.create_window(
APP_NAME, _entry(), js_api=api,
width=1280, height=820, min_size=(900, 600))
api._uid = win.uid # bind after creation (storing the window object recurses)
_bind_close(win, api)
return {"ok": True}
class HostApi:
"""JS-facing API for one window: shared CoreApi ops + native dialogs on *this*
window. One HostApi per window; it stores only the window's uid (storing the
window object makes pywebview recurse when it serialises js_api)."""
def __init__(self, core, uid=None):
self._core = core
self._uid = uid
self._dirty = False # pushed from the React app for the close guard
self._startup_path = None # .belegtool to open on load (first window only)
self._session = None # this window's session id (for the close-time lock release)
def set_dirty(self, value):
self._dirty = bool(value)
return {"ok": True}
def _win(self):
# this window only — never fall back to windows[0], or a dialog could open
# against the wrong document if this window was closed mid-call.
for w in webview.windows:
if w.uid == self._uid:
return w
return None
# window management
def new_window(self):
try:
return _open_window(self._core)
except Exception as e: # never crash the caller
return {"ok": False, "error": str(e)}
def open_view_in_new_window(self, session, node_ids, name=None):
"""Materialise the currently displayed tag view (``node_ids``) as a temp
.belegtool and open it in a fresh window — a real, editable copy of just the
shown nodes, in normal tree order (grouping not applied). ``name`` becomes the
new document's title (the used tag prefixed onto the old name)."""
try:
res = self._core.materialize_subset(session, node_ids, name)
if not res.get("ok"):
return res
return _open_window(self._core, startup_path=res["path"])
except Exception as e:
return {"ok": False, "error": str(e)}
# core ops (delegate)
def config(self):
cfg = dict(self._core.config())
if self._startup_path:
cfg["startup_path"] = self._startup_path # React opens this on load
cfg["dev"] = bool(os.environ.get("BELEG_DEV")) # gate dev-only UI (Testmodus)
return cfg
def open(self, session=None, path=None):
resp = self._core.open(session, path)
if resp.get("ok"):
self._session = resp.get("session") # remember for the close-time lock release
return resp
def dispatch(self, session, command):
return self._core.dispatch(session, command)
def undo(self, session):
return self._core.undo(session)
def redo(self, session):
return self._core.redo(session)
def render(self, session, node_id, dpi=100):
return self._core.render(session, node_id, dpi)
def render_compressed(self, session, node_id, dpi=150, method=None):
return self._core.render_compressed(session, node_id, dpi, method)
def compress_options(self, session, node_id, dpi=150):
return self._core.compress_options(session, node_id, dpi)
# windowed render cache
def render_stats(self):
return self._core.render_stats()
def set_render_budget(self, mb):
return self._core.set_render_budget(mb)
def page_count(self, session, node_id):
return self._core.page_count(session, node_id)
def page_dims(self, session, node_id):
return self._core.page_dims(session, node_id)
def render_window(self, session, node_id, first=0, count=10, dpi=100):
return self._core.render_window(session, node_id, first, count, dpi)
def render_compressed_window(self, session, node_id, dpi=150, method=None, first=0, count=10):
return self._core.render_compressed_window(session, node_id, dpi, method, first, count)
# import (drop path uses bytes; the button uses the native dialog → real paths)
def import_bytes(self, session, name, data, parent_id=None, index=None):
return self._core.import_bytes(session, name, data, parent_id, index)
def export_dialog(self, session, node_ids=None, options=None):
win = self._win()
if win is None:
return {"ok": False, "error": "Fenster nicht gefunden"}
name = (self._core.document_name(session) or "Export").strip() or "Export"
path = win.create_file_dialog(
webview.FileDialog.SAVE, save_filename=f"{name}.pdf", file_types=("PDF (*.pdf)",))
if not path:
return {"ok": False, "error": "cancelled"}
if isinstance(path, (tuple, list)):
path = path[0]
return self._core.export(session, path, node_ids, options)
def import_dialog(self, session, parent_id=None):
win = self._win()
if win is None:
return {"ok": False, "error": "Fenster nicht gefunden"}
result = win.create_file_dialog(
webview.FileDialog.OPEN, allow_multiple=True, file_types=IMPORT_FILE_TYPES)
if not result:
return {"ok": False, "error": "cancelled"}
return self._core.import_paths(session, list(result), parent_id)
# host-only ops (native dialogs)
def open_file(self, session=None):
win = self._win()
if win is None:
return {"ok": False, "error": "Fenster nicht gefunden"}
result = win.create_file_dialog(webview.FileDialog.OPEN, file_types=FILE_TYPES)
if not result:
return {"ok": False, "error": "cancelled"}
return self._core.open(session, result[0])
def save_info(self, session):
return self._core.save_info(session)
def save_file(self, session, store_alternatives=True):
"""Save in place if this document already has a path; otherwise prompt once.
``store_alternatives`` False → 'Original speichern' (don't embed variants)."""
path = self._core.document_path(session)
if path:
return self._core.save(session, path, store_alternatives)
return self.save_file_as(session, store_alternatives)
def save_file_as(self, session, store_alternatives=True):
win = self._win()
if win is None:
return {"ok": False, "error": "Fenster nicht gefunden"}
name = self._core.document_name(session) or "unbenannt"
path = win.create_file_dialog(
webview.FileDialog.SAVE, save_filename=f"{name}.belegtool",
file_types=("BelegTool (*.belegtool)",))
if not path:
return {"ok": False, "error": "cancelled"}
if isinstance(path, (tuple, list)):
path = path[0]
return self._core.save(session, path, store_alternatives)
def _safe_http_port():
"""An OS-assigned free ephemeral port (>=49152) for pywebview's internal file
server. Chromium/WebView2 hard-blocks a set of "unsafe" ports (1719, 1720, …);
if pywebview's random pick lands on one the page fails with ERR_UNSAFE_PORT.
Ephemeral ports are never on that block list, so we hand one in explicitly."""
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
finally:
s.close()
_WEBVIEW2_URL = "https://developer.microsoft.com/microsoft-edge/webview2/"
# Evergreen WebView2 Runtime registration GUID (also where the Win11 in-box runtime registers).
_WEBVIEW2_GUID = "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"
def _webview2_installed() -> bool:
"""True if the Edge WebView2 Runtime is present. Without it the React UI renders
blank (pywebview can't use the Chromium backend). Non-Windows: assume present."""
if sys.platform != "win32":
return True
import winreg
keys = [
(winreg.HKEY_LOCAL_MACHINE, rf"SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{_WEBVIEW2_GUID}"),
(winreg.HKEY_LOCAL_MACHINE, rf"SOFTWARE\Microsoft\EdgeUpdate\Clients\{_WEBVIEW2_GUID}"),
(winreg.HKEY_CURRENT_USER, rf"Software\Microsoft\EdgeUpdate\Clients\{_WEBVIEW2_GUID}"),
]
for root, path in keys:
try:
with winreg.OpenKey(root, path) as k:
pv = winreg.QueryValueEx(k, "pv")[0]
if pv and pv != "0.0.0.0":
return True
except OSError:
continue
return False
def _warn_missing_webview2():
"""Tell the user plainly (native dialog + open the download page) instead of showing
a blank window when the WebView2 Runtime is missing."""
msg = ("Microsoft Edge WebView2 Runtime fehlt.\n\n"
"BelegTool benötigt die WebView2-Runtime — sonst bleibt das Fenster leer.\n"
"Bitte installieren und BelegTool neu starten:\n" + _WEBVIEW2_URL)
try:
import ctypes
ctypes.windll.user32.MessageBoxW(0, msg, "BelegTool – WebView2 erforderlich", 0x10)
except Exception:
print(msg)
try:
import webbrowser
webbrowser.open(_WEBVIEW2_URL)
except Exception:
pass
def main(startup_path=None):
# Fail loudly, not blank: without the WebView2 Runtime the window renders empty.
# (BELEG_SKIP_WEBVIEW2_CHECK=1 bypasses, in case detection ever false-negatives.)
skip = os.environ.get("BELEG_SKIP_WEBVIEW2_CHECK", "").lower() not in ("", "0", "false", "no")
if not skip and not _webview2_installed():
_warn_missing_webview2()
return
core = CoreApi() # shared across all windows; sessions are per window
_open_window(core, startup_path)
# Warm up only AFTER the window is up (start's func runs on its own thread once
# the GUI loop is live), so warming doesn't compete with window creation.
# Pin a safe http port so the file server never lands on a Chromium-blocked one.
webview.start(_prewarm, http_port=_safe_http_port())
def _startup_path_from_argv(argv):
"""A .belegtool handed on the command line (file association / 'open with' /
BelegTool.exe <file>). Honor only an existing .belegtool — open() loads via
load_belegtool; other types belong on the import path, not startup. Returns
None when there's nothing valid to open."""
if len(argv) < 2:
return None
path = argv[1]
if path.lower().endswith(".belegtool") and os.path.isfile(path):
return path
return None
if __name__ == "__main__":
main(_startup_path_from_argv(sys.argv))