From 5a8b26298471e451ab543e7e26d7e1096f65165b Mon Sep 17 00:00:00 2001
From: Aron Bijl <33731256+AronAxe@users.noreply.github.com>
Date: Tue, 6 Oct 2026 16:23:39 +0200
Subject: [PATCH] Fix native Windows bridge recursion and close/save deadlock;
verify compiled UI
---
.github/workflows/windows.yml | 4 +-
CHANGELOG.md | 6 +
README.md | 3 +
docs/QUALITY.md | 21 +++
docs/RELEASE-0.2.1.md | 42 ++++++
docs/validation/v0.2.1/browser.json | 25 ++++
docs/validation/v0.2.1/native-package.json | 33 +++++
docs/validation/v0.2.1/native-source.json | 33 +++++
docs/validation/v0.2.1/package.json | 21 +++
docs/validation/v0.2.1/studio.json | 17 +++
docs/validation/v0.2.1/summary.json | 16 +++
docs/validation/v0.2.1/windows.xml | 1 +
graphpaper/__init__.py | 2 +-
graphpaper/desktop.py | 81 +++++++----
publish-manifest.json | 32 +++--
pyproject.toml | 2 +-
scripts/build_release.ps1 | 2 +-
scripts/native_smoke.py | 154 +++++++++++++++++++++
scripts/ui_studio_smoke.py | 2 +-
tests/test_desktop_and_publish.py | 14 +-
tests/test_native_bridge.py | 66 +++++++++
ui/app.js | 2 +-
22 files changed, 527 insertions(+), 52 deletions(-)
create mode 100644 docs/RELEASE-0.2.1.md
create mode 100644 docs/validation/v0.2.1/browser.json
create mode 100644 docs/validation/v0.2.1/native-package.json
create mode 100644 docs/validation/v0.2.1/native-source.json
create mode 100644 docs/validation/v0.2.1/package.json
create mode 100644 docs/validation/v0.2.1/studio.json
create mode 100644 docs/validation/v0.2.1/summary.json
create mode 100644 docs/validation/v0.2.1/windows.xml
create mode 100644 scripts/native_smoke.py
create mode 100644 tests/test_native_bridge.py
diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml
index 3dd0f02..6cde3c0 100644
--- a/.github/workflows/windows.yml
+++ b/.github/workflows/windows.yml
@@ -34,6 +34,8 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
run: ./scripts/build_release.ps1
+ - name: Exercise the actual Windows window, native dialogs and save-on-close
+ run: python scripts/native_smoke.py --executable dist/GraphPaper/GraphPaper.exe --out test-results/native-package
- uses: actions/upload-artifact@v4
with:
name: GraphPaper-Windows-x64
@@ -49,7 +51,7 @@ jobs:
$version = python -c "from graphpaper import __version__; print(__version__)"
if ($env:GITHUB_REF_NAME -ne "v$version") { throw 'Tag does not match application version.' }
gh release view $env:GITHUB_REF_NAME *> $null
- if ($LASTEXITCODE -ne 0) { gh release create $env:GITHUB_REF_NAME --verify-tag --title "GraphPaper $env:GITHUB_REF_NAME" --notes-file docs/RELEASE-0.2.md }
+ if ($LASTEXITCODE -ne 0) { gh release create $env:GITHUB_REF_NAME --verify-tag --title "GraphPaper $env:GITHUB_REF_NAME" --notes-file docs/RELEASE-0.2.1.md }
gh release upload $env:GITHUB_REF_NAME "dist/GraphPaper-v$version-Windows-x64.zip" dist/SHA256SUMS.txt --clobber
- uses: actions/upload-artifact@v4
if: always()
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1089cca..228607f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## 0.2.1 ? 2026-10-06
+
+- Fix recursive native-window exposure in the JavaScript bridge.
+- Fix native close/save deadlock without discarding pending edits.
+- Add real Windows mouse, bridge, dialog and save-on-close regression tests for the compiled release.
+
## 0.2.0 — 2026-10-05
- Learn and edit an author voice from uploaded writing or selected article URLs.
diff --git a/README.md b/README.md
index 29caf4c..9816875 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,9 @@

### A studio for connected thought.
+**Windows fix: [v0.2.1](https://github.com/AronAxe/GraphPaper/releases/tag/v0.2.1)** fixes the native interface freeze and the save-on-close deadlock. Existing projects remain intact. See the [patch notes](docs/RELEASE-0.2.1.md).
+
+
**Source material → knowledge graph → a distinctive angle → an editable outline → writing worth reading.**
GraphPaper is a local-first, Windows-oriented writing studio for **nonfiction and fiction**. It is a graphical application, not a command-line writing tool. Bring your sources, see how their ideas connect, guide the interpretation, and keep authorship of the result.
diff --git a/docs/QUALITY.md b/docs/QUALITY.md
index 4c890c4..88f357f 100644
--- a/docs/QUALITY.md
+++ b/docs/QUALITY.md
@@ -1,5 +1,26 @@
# Quality, tests and known limits
+## 0.2.1 native Windows correction ? 6 October 2026
+
+The reported interface freeze exposed a gap in the earlier validation: the
+Windows startup probe and Chromium tests did not initialize or interact with
+the native JavaScript/Python bridge. That bridge exposed a public native Window,
+so pywebview recursively inspected Windows/COM objects. A separate close callback
+also waited for JavaScript while holding the WinForms UI thread. Both are fixed.
+
+The corrected **compiled executable**, not just a browser substitute, passed all
+9 native interaction checks: Windows mouse input, restricted API initialization,
+promise round-trip, project creation/typing, Connections, native Save As open/cancel,
+and saving pending text before a normal Windows close. The same native test also
+passed from source. 126 automated Windows tests passed, with one symlink-permission
+skip. All 26 browser workflows passed; no JavaScript page errors occurred.
+
+Reports: [release summary](validation/v0.2.1/summary.json) and
+[native executable test](validation/v0.2.1/native-package.json). All tests used
+isolated temporary projects. No user manuscript or credentials were used, and no
+live model or OAuth request was made. The strict CSP is unchanged.
+
+
## Version 0.2 validation update
The 0.2 update was tested on a native Windows 11 machine with an isolated Python 3.13 environment: **120 automated tests passed**, with one symlink-permission test skipped; **all 26 real-HTTP browser workflow checks passed** with zero JavaScript page errors. The official bundled **Codex CLI 0.160.1** passed its signed-out app-server handshake and required-command checks. These are local Windows results, not GitHub-hosted CI results; the hosted runner remained queued and that run was cancelled. Reports are in [validation/v0.2](validation/v0.2/) (or the corresponding directory from this documentation page).
diff --git a/docs/RELEASE-0.2.1.md b/docs/RELEASE-0.2.1.md
new file mode 100644
index 0000000..6730307
--- /dev/null
+++ b/docs/RELEASE-0.2.1.md
@@ -0,0 +1,42 @@
+# GraphPaper 0.2.1 ? Windows interface freeze fix
+
+This is a corrective release for the Windows interface in 0.2.0. It retains
+all author-voice, project-folder, prose-editing and Codex sign-in features.
+
+## Fixed
+
+- Restricted the Python/JavaScript bridge to four explicit methods. Its store,
+ job runner and native window are now private and cannot be recursively
+ reflected into the browser API. Native testing of the old bridge produced
+ recursion-limit and wrong-thread COM/WebView2 errors while API initialization
+ failed to finish.
+- Fixed a second native deadlock on closing: the WinForms closing handler now
+ returns immediately and performs the save handshake on a worker, rather than
+ waiting for JavaScript while holding the UI event loop.
+- Failed saves and declined close prompts reset the handshake so closing can be
+ retried. The original manuscript is not discarded to make the window close.
+
+## Windows download
+
+Extract **GraphPaper-v0.2.1-Windows-x64.zip** into a new folder and open
+**GraphPaper.exe**. Keep its `_internal` companion folder. No Python installation
+is needed. Close the old executable first. Existing projects and credentials
+remain in the same local data directory; this patch does not reset them.
+
+## Native regression coverage
+
+The new `scripts/native_smoke.py` launches the actual Windows/WebView2 shell in
+an isolated temporary project directory. It sends a genuine Windows mouse click,
+checks the restricted bridge and its returned promise, creates a project, types
+in the editor, opens Connections, opens and cancels a native Save As dialog, and
+sends the Windows close message before autosave's delay expires. It then verifies
+that the pending manuscript was written and the process exited normally.
+
+It runs against source and the compiled executable. This is not the browser-only
+or HTTP-startup check used in previous releases. The release workflow now requires
+the compiled native regression before uploading its Windows artifact.
+
+126 automated Windows tests passed; one symlink-permission test was skipped.
+Native test reports are recorded with this release after the packaged run.
+No live model request or OAuth login is part of these tests. The binary remains
+unsigned. SHA256SUMS.txt records the exact downloadable ZIP checksum.
diff --git a/docs/validation/v0.2.1/browser.json b/docs/validation/v0.2.1/browser.json
new file mode 100644
index 0000000..6d7a759
--- /dev/null
+++ b/docs/validation/v0.2.1/browser.json
@@ -0,0 +1,25 @@
+{
+ "mode": "HTTP browser",
+ "live_models": false,
+ "checks": [
+ "Welcome and empty state",
+ "Illustrative graph renders with 18 nodes",
+ "Inspect graph node and persist pin",
+ "Path query and highlight",
+ "Edit and select an angle",
+ "Edit and reorder outline",
+ "Manuscript autosave and safe live preview",
+ "Clickable source references",
+ "Read and restore earlier version",
+ "Export options and valid Word output",
+ "Configure provider through UI (mock transport only)",
+ "Browser file-input upload and source ingestion",
+ "Complete graph \u2192 JEV \u2192 angle \u2192 outline \u2192 draft \u2192 review path with deterministic AI",
+ "Explicit revision job and versioning",
+ "Fiction from premise, scene generation and continuity ledger",
+ "Light theme",
+ "Responsive 390px layout without horizontal overflow"
+ ],
+ "page_errors": [],
+ "ok": true
+}
\ No newline at end of file
diff --git a/docs/validation/v0.2.1/native-package.json b/docs/validation/v0.2.1/native-package.json
new file mode 100644
index 0000000..3f268ba
--- /dev/null
+++ b/docs/validation/v0.2.1/native-package.json
@@ -0,0 +1,33 @@
+{
+ "native_windows": true,
+ "packaged_executable": true,
+ "live_models": false,
+ "checks": [
+ "Actual Windows/WebView2 window starts and responds to Windows messages",
+ "Native bridge initializes with only four explicit methods and resolves its promise",
+ "Strict application CSP remains enabled",
+ "A Windows mouse click opens the project dialog without freezing the host",
+ "Native window supports typing and project creation",
+ "Connections dialog opens and closes in the native window",
+ "Native Save As dialog opens, cancels and returns without blocking the interface",
+ "Windows close completes the save handshake and exits normally",
+ "Pending manuscript text is persisted before the native process exits"
+ ],
+ "page_errors": [],
+ "ok": true,
+ "bridge_methods": [
+ "cancel_close",
+ "notify_ready",
+ "request_close",
+ "save_export"
+ ],
+ "native_child_classes": [
+ "Chrome_RenderWidgetHostHWND",
+ "Chrome_WidgetWin_0",
+ "Chrome_WidgetWin_1",
+ "Intermediate D3D Window",
+ "WindowsForms10.Window.8.app.0.aec740_r21_ad1"
+ ],
+ "exit_code": 0,
+ "recursion_errors": false
+}
diff --git a/docs/validation/v0.2.1/native-source.json b/docs/validation/v0.2.1/native-source.json
new file mode 100644
index 0000000..ced18cd
--- /dev/null
+++ b/docs/validation/v0.2.1/native-source.json
@@ -0,0 +1,33 @@
+{
+ "native_windows": true,
+ "packaged_executable": false,
+ "live_models": false,
+ "checks": [
+ "Actual Windows/WebView2 window starts and responds to Windows messages",
+ "Native bridge initializes with only four explicit methods and resolves its promise",
+ "Strict application CSP remains enabled",
+ "A Windows mouse click opens the project dialog without freezing the host",
+ "Native window supports typing and project creation",
+ "Connections dialog opens and closes in the native window",
+ "Native Save As dialog opens, cancels and returns without blocking the interface",
+ "Windows close completes the save handshake and exits normally",
+ "Pending manuscript text is persisted before the native process exits"
+ ],
+ "page_errors": [],
+ "ok": true,
+ "bridge_methods": [
+ "cancel_close",
+ "notify_ready",
+ "request_close",
+ "save_export"
+ ],
+ "native_child_classes": [
+ "Chrome_RenderWidgetHostHWND",
+ "Chrome_WidgetWin_0",
+ "Chrome_WidgetWin_1",
+ "Intermediate D3D Window",
+ "WindowsForms10.Window.8.app.0.aec740_r21_ad1"
+ ],
+ "exit_code": 0,
+ "recursion_errors": false
+}
diff --git a/docs/validation/v0.2.1/package.json b/docs/validation/v0.2.1/package.json
new file mode 100644
index 0000000..2b575a7
--- /dev/null
+++ b/docs/validation/v0.2.1/package.json
@@ -0,0 +1,21 @@
+{
+ "ok": true,
+ "checks": {
+ "/health": {
+ "status": 200,
+ "bytes": 29
+ },
+ "/": {
+ "status": 200,
+ "bytes": 705
+ },
+ "/static/app.js": {
+ "status": 200,
+ "bytes": 71060
+ },
+ "/static/styles.css": {
+ "status": 200,
+ "bytes": 25023
+ }
+ }
+}
\ No newline at end of file
diff --git a/docs/validation/v0.2.1/studio.json b/docs/validation/v0.2.1/studio.json
new file mode 100644
index 0000000..792ea05
--- /dev/null
+++ b/docs/validation/v0.2.1/studio.json
@@ -0,0 +1,17 @@
+{
+ "mode": "real HTTP",
+ "live_models": false,
+ "checks": [
+ "Voice sample upload through the actual file input",
+ "Learn, inspect and edit a persistent author voice profile",
+ "Project folder import and automatic source ownership",
+ "Idle folder watcher detects and imports new files without an AI call",
+ "Local explainable prose inspection",
+ "Humanizer proposal, side-by-side comparison and explicit acceptance",
+ "Separate deslopping pass and non-destructive rejection",
+ "Codex subscription provider selectable without an API key",
+ "New controls fit a smaller desktop window"
+ ],
+ "page_errors": [],
+ "ok": true
+}
\ No newline at end of file
diff --git a/docs/validation/v0.2.1/summary.json b/docs/validation/v0.2.1/summary.json
new file mode 100644
index 0000000..ebcdf81
--- /dev/null
+++ b/docs/validation/v0.2.1/summary.json
@@ -0,0 +1,16 @@
+{
+ "version": "0.2.1",
+ "windows_unit_tests": {
+ "passed": 126,
+ "skipped": 1,
+ "failed": 0
+ },
+ "native_source_checks": 9,
+ "native_packaged_executable_checks": 9,
+ "browser_workflow_checks": 26,
+ "native_page_errors": [],
+ "live_model_calls": false,
+ "archive": "GraphPaper-v0.2.1-Windows-x64.zip",
+ "size_bytes": 157603152,
+ "sha256": "988cd93b4a1a1f7a4f849de800ea385a0dcc528c69a667f81a0c728f46e357b4"
+}
diff --git a/docs/validation/v0.2.1/windows.xml b/docs/validation/v0.2.1/windows.xml
new file mode 100644
index 0000000..663e2aa
--- /dev/null
+++ b/docs/validation/v0.2.1/windows.xml
@@ -0,0 +1 @@
+C:\Users\aron\AppData\Local\Temp\GraphPaper-release-97a16cdb5c6f4a558141f884c6d33f38\tests\test_studio_update.py:172: Symlinks unavailable for this Windows account
\ No newline at end of file
diff --git a/graphpaper/__init__.py b/graphpaper/__init__.py
index d9ef2ba..0b61a58 100644
--- a/graphpaper/__init__.py
+++ b/graphpaper/__init__.py
@@ -1,2 +1,2 @@
"""GraphPaper: an evidence-aware, graph-native writing studio."""
-__version__ = "0.2.0"
+__version__ = "0.2.1"
diff --git a/graphpaper/desktop.py b/graphpaper/desktop.py
index 35788ff..ae65d69 100644
--- a/graphpaper/desktop.py
+++ b/graphpaper/desktop.py
@@ -22,38 +22,76 @@ def server_config(app, **options):
class DesktopBridge:
- """Small explicit bridge: export only through an OS save dialog."""
+ """Only explicit methods cross the JavaScript boundary.
+
+ pywebview recursively inspects public attributes. Never attach application
+ state or a native Window there: WinForms/COM properties are UI-thread-only
+ and their object graphs can recurse indefinitely during API injection.
+ """
+ __slots__ = ("_store", "_runner", "_window", "_ui_ready", "_close_authorized", "_close_pending")
def __init__(self, store, runner=None):
- self.store = store
- self.runner = runner
- self.window = None
- self.ui_ready = False
- self.close_authorized = False
+ self._store = store
+ self._runner = runner
+ self._window = None
+ self._ui_ready = False
+ self._close_authorized = False
+ self._close_pending = threading.Event()
def notify_ready(self):
- self.ui_ready = True
+ self._ui_ready = True
return True
def request_close(self):
"""Called by our UI only after its pending saves have completed."""
- active = self.runner and any(j.state in {"queued", "running"} for j in self.runner.jobs.values())
- if active and not self.window.create_confirmation_dialog(
+ active = self._runner and any(j.state in {"queued", "running"} for j in self._runner.jobs.values())
+ if active and not self._window.create_confirmation_dialog(
"AI job running", "Close and cancel this job? An in-flight provider request may still finish and be billed. Saved checkpoints remain available."
):
+ self._close_pending.clear()
return {"closed": False}
- self.close_authorized = True
- self.window.destroy()
+ self._close_authorized = True
+ self._window.destroy()
return {"closed": True}
+ def cancel_close(self):
+ """Let the author retry closing after a failed save or declined prompt."""
+ self._close_pending.clear()
+ return True
+
+ def _on_closing(self):
+ """Cancel the initial close immediately; never wait for JS on the UI thread."""
+ if self._close_authorized or not self._ui_ready:
+ return True
+ if not self._close_pending.is_set():
+ self._close_pending.set()
+ threading.Thread(target=self._ask_ui_to_close, name="graphpaper-close", daemon=True).start()
+ return False
+
+ def _ask_ui_to_close(self):
+ try:
+ # WinForms must first return from FormClosing before a JS dispatch
+ # can complete. The worker lets the UI event loop keep pumping.
+ self._window.run_js("window.graphpaperRequestClose()")
+ except Exception:
+ logging.exception("The editor could not confirm its save state")
+ try:
+ if self._window.create_confirmation_dialog(
+ "Close GraphPaper?", "The editor could not confirm its save state. Close anyway? Unsaved text may be lost."
+ ):
+ self._close_authorized = True
+ self._window.destroy()
+ finally:
+ self._close_pending.clear()
+
def save_export(self, project_id: str, kind: str):
try:
import webview
if kind not in {"md", "docx", "html", "json", "graph"}:
return {"error": "Unknown export type"}
- p = self.store.get(project_id)
+ p = self._store.get(project_id)
data, _, ext = export(p, kind)
stem = re.sub(r'[^\w\s.-]', '', p.title).strip()[:80] or "GraphPaper"
- result = self.window.create_file_dialog(
+ result = self._window.create_file_dialog(
webview.FileDialog.SAVE, save_filename=stem + ext,
file_types=(f"{kind.upper()} files (*{ext})", "All files (*.*)"),
)
@@ -123,22 +161,9 @@ def cleanup():
js_api=bridge, width=1440, height=940, min_size=(940, 650),
background_color="#101714", text_select=True,
)
- bridge.window = window
-
- def closing():
- if bridge.close_authorized or not bridge.ui_ready:
- return True
- try:
- # The JS -> Python callback completes an async save handshake.
- # Do not use evaluate_js: its eval wrapper conflicts with CSP.
- window.run_js("window.graphpaperRequestClose()")
- return False
- except Exception:
- return window.create_confirmation_dialog(
- "Close GraphPaper?", "The editor could not confirm its save state. Close anyway? Unsaved text may be lost."
- )
+ bridge._window = window
- window.events.closing += closing
+ window.events.closing += bridge._on_closing
webview.start(gui="edgechromium" if sys.platform == "win32" else None, debug=False, private_mode=True)
finally:
cleanup()
diff --git a/publish-manifest.json b/publish-manifest.json
index d2692fe..d0d5902 100644
--- a/publish-manifest.json
+++ b/publish-manifest.json
@@ -5,19 +5,20 @@
".github/topics.json": "341aa983b1829624dec7ab7cd2e83eed08d45bc78305033c1e860631c1227e41",
".github/workflows/import-build.yml": "c56a990b97242f154907f76059f5b859448ca8de7b582928d63bb3717ac0d60a",
".github/workflows/quality.yml": "8cb33528c74e888ea54ef1715d83a093402bd2d1d2491d6be6f69fef6b6fb30b",
- ".github/workflows/windows.yml": "71c6a0dcb1cf9d85f4321822fca51ba6c4d6832e23156f4758bfcc394ce0d9bc",
+ ".github/workflows/windows.yml": "883c74f13cb201729dbcc9d66acc3a8059a4190474f4027e034071b43ae1c574",
".gitignore": "229a84e2b07c91b31c30f06df236ea17fe211eb75b217adc2eecb3f9d377f9bf",
- "CHANGELOG.md": "602dc1d73f768c6d35eb0ce9ddad0a28a55e2c41de4b41aaf8b999cc6d54b85c",
+ "CHANGELOG.md": "2b45af560d30fa44095fc8e47a37a0665c964b21b0738c420ee9bea7786998bd",
"GraphPaper.pyw": "e862a9309815483e4c166396da8eccd304c118461f6b220246dd18c23bcc9bb4",
"LICENSE": "5849844cbc9e9e199cf576224279d63d5fa56ca913f7a13285e4aa4fcb99bf80",
"Open GraphPaper.vbs": "6ddf70eba29c281f9373c11b6aa7f69862c8ea7b03050b974c98d817ca11974e",
"Publish GraphPaper.pyw": "a55984668dd0040d9bb5fc8c4c18ea5a201eede7e8a51ab79f8f0f095162ec8b",
"Publish GraphPaper.vbs": "f9c595bcd13da5df60dcc2cfd906665243628f6e6c10a8b6a03ad1392a9698ad",
- "README.md": "8143bd573f2895b73af114b25ed90c2d58b4f44247d3fa01446da6db412537be",
+ "README.md": "0b3ecc6ecaeb2e2bcefac25823a92adb7cad8c66df8015f4f5087a99dedd7f53",
"docs/ARCHITECTURE.md": "46ffc5baa76654ed590515422c399be347ac0b7f74536216936c4626e485f5cd",
"docs/INTEGRATIONS.md": "09b1c5bf44f17e79446c99ea9b41cec76350e726469c1dca997405654672db62",
"docs/PUBLISHING.md": "70991445e9ad24ad65ed7153615ca9020aef41eb8d27aea4f3ba43ff8e116d08",
- "docs/QUALITY.md": "910fc903a109a5df1c72294dcc5f14ae5dcbd18a68dea8c7f198cabe0fba30e9",
+ "docs/QUALITY.md": "38fcde5a214285a461eaa131b5fa440c6fcd85719c8ef321ff3842a6b09b1ff8",
+ "docs/RELEASE-0.2.1.md": "42642432ff5c6017630d33a64726c5dca37abbbb36a476a901a59ec8887aa870",
"docs/RELEASE-0.2.md": "c03a41903088590845d0da0707a562b9e6cbf4c5e1ff57d8794763f349093e44",
"docs/SECURITY.md": "c896569c368a261b1ba32e7930f5bc8c887b91ccc8c5562c63e7c47867e3ad08",
"docs/UPDATE-0.2.md": "cd71d4bb37bfdbb859fa4ea558a91e0cb61f900f0a4390d87f513abb8b8d815d",
@@ -35,6 +36,13 @@
"docs/validation/browser.json": "5692769d1e74ce27b9ddd97c858573178b7a15bcc47fc6358cf2bac39774668d",
"docs/validation/loopback.json": "6edcf86c685dcf15c926002d6128fd40e4ad5b37f6449973b8f8465d30fb68f9",
"docs/validation/pytest.xml": "58085cc6c3e680e826e0a5c0bc782a53cdbd1a7a5e30ed9997531ba9e038fc0d",
+ "docs/validation/v0.2.1/browser.json": "5692769d1e74ce27b9ddd97c858573178b7a15bcc47fc6358cf2bac39774668d",
+ "docs/validation/v0.2.1/native-package.json": "f8178efacadcaa1056177ca4edd79a7b9fa4ab9f6e00b2fb807177d921eb4acc",
+ "docs/validation/v0.2.1/native-source.json": "5c0b00bce3110365ed28646aaceb3e050c1a2fa811be6f59cf92d65e01eaff03",
+ "docs/validation/v0.2.1/package.json": "c078afed0ae73667b4558863d54101a47a02185584090ef55ba5bef8a2d149b1",
+ "docs/validation/v0.2.1/studio.json": "b26277877389bde6c83956862415f54fb6aa5015d2b266257274bc36d892a5f4",
+ "docs/validation/v0.2.1/summary.json": "6fde8bd521135ecc24c297ba9e01c57ba098f8e657837476cfd0dbc6715aae6c",
+ "docs/validation/v0.2.1/windows.xml": "cf999492d7824f8eb970ce0366dee5fdc91915d75ef7920c43383ff50fd6f8d9",
"docs/validation/v0.2/browser.json": "5692769d1e74ce27b9ddd97c858573178b7a15bcc47fc6358cf2bac39774668d",
"docs/validation/v0.2/codex-runtime.json": "d103f97769745d4d7a552887cea47380706fa9e4d75dcab1d0424064475d72da",
"docs/validation/v0.2/loopback.json": "caab542951b4eb83f86f959f36fbbdad604f507d07ba9754cd06cd9626059f14",
@@ -44,11 +52,11 @@
"examples/README.md": "0b7b83630202501a3ba6ad070708419aa23e32c8060ef9447491c6434043bbf6",
"examples/fiction.json": "e3f90c0eed71102640c8a65ae86510e0258e9c90c3cc18730ed05329f632e88d",
"examples/nonfiction.json": "af9196cf15dbe1d431f02c7d8ec11a720f25e1c3e30a756c1f9711b4c65a08cc",
- "graphpaper/__init__.py": "66eb5ca3f645281ad322a9c3d1253269332c0caf26174e7ba59fc27d016695ae",
+ "graphpaper/__init__.py": "611cdd59ab96c59d6477eb32954d1e9729b5c545d54254df6754bc8e00d9bd8d",
"graphpaper/author_web.py": "16ad1218ce0bf76bdd067e731176357586a28dfb55d297a393a3e6a596e126ff",
"graphpaper/codex.py": "87a1f409dd06e755e0f4aa5cab66f3cd45f26f58658df96adf961d86d10ab98c",
"graphpaper/demo.py": "5fcee68b786da702cd4365dca17e3f5df415fa26c7c6928568d64d8b7d693006",
- "graphpaper/desktop.py": "d176c01f271430e0c4215384ca886f6053823a835414abb9a34fbda83a77198f",
+ "graphpaper/desktop.py": "40090785ec6eb8765dcf0c7d4a3975350d81ea91dbeceb5d87f62ee2f3061a62",
"graphpaper/export.py": "f964f4e64364a9392dcb8a3e64b91d1e2e071cd9d593ca052c066681c0c37801",
"graphpaper/folders.py": "37f9b255406e3ff93b6f6886294722c14561a4c1195d5c2107fe67c8de6fbca8",
"graphpaper/graph.py": "36310ecd1e89af94f799c4fbc4611d90fea640edcbb07f06f92b832f90300770",
@@ -64,32 +72,34 @@
"graphpaper/studio_routes.py": "bc77494829dfff3961a1076f7a152a5f9119c34d2038abde25105a78b67c8ff9",
"graphpaper/support.py": "2f4a3f5f7f6a725b15719a775155bdc73c32ce0df022d43b4fb53dbf7e862d8e",
"graphpaper/voice.py": "2527ef556be695942e481bf3df099beb05937c7a059d0e1ece94d7ca32d0e51e",
- "pyproject.toml": "4e370871614f8696cd51bea95efcd01ccdd92e601ed344afcd86f07c4782da6e",
+ "pyproject.toml": "91f16a09e509db3ff6f0df5935225d018138cadbd2de6485a13a67e211c9b896",
"requirements-desktop.txt": "182bf080861b4cfda8adcb7ce9debff7181b7619ab8452445e143ec8ebf7044e",
"requirements-dev.txt": "180b600f70aadb56ce7665a5697a5b788cbc10351189b3442927096a6632b78e",
"requirements.txt": "9bdbe67c6139451e1bf2a81169beb52484343d785fdda8196c91ecd23884828b",
- "scripts/build_release.ps1": "00d358ddbafbd02f4e4ede9cbfcee0ffdc250fac09789f5f168220ee28d61f6f",
+ "scripts/build_release.ps1": "f385debb188203f61470c2d44bc8b19c2182acaa5ca029842debaa628c3b606b",
"scripts/build_windows.ps1": "44e0008ff8221097ead7355aa184a60e824d2fcbf0c77e54c74ddfc8f3a54858",
"scripts/bundle_codex.py": "651d08e26949dbda47bca14dbc368d97b82e11a28759792cb6096adcea174efa",
"scripts/check_codex.py": "2fff4a6cc80188d54914086ab3f3d7d3cf1ec02001541bad6bc8a426fc1bbc81",
"scripts/check_package.py": "ef863315ea3f21a8f94b412276849df73d1b4fb0aaa30d7890a62a426a6cc4b7",
"scripts/desktop_entry.py": "97db3eedabd7fa1397386419d627479c31c889d7f593f81f971b443ee27ea514",
+ "scripts/native_smoke.py": "d1c8da4713da6ca1680f18662e41e7221c775d62779ec20ac8ed4ba0c0712054",
"scripts/prepare_assets.py": "a77238973118112411cd0ac4e2e4b6b52c6193c7e52de9680362501cc77d4136",
"scripts/publish_gui.py": "46bf7dff734e16b31c322e45bbf80abcfa29d7fbbc93f4c8162a119ff9c79ef4",
"scripts/ui_smoke.py": "367cd36f8d03a180cc377678cf58db9d986c884928b4dc62c2e37d96898ccfcd",
- "scripts/ui_studio_smoke.py": "c1cd0e8b5c884a63004311724c5a41617d204a2bdbeb24f672c5f481d86ba85a",
+ "scripts/ui_studio_smoke.py": "ccfa788ca2163403f71edd04a1e479057135d86ac6b98cc7858d074dff575c3f",
"tests/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"tests/conftest.py": "ec52ebc98e568bae0f9ae6223d5f48640f0698d7605987bdafded5c814bf5381",
"tests/test_api.py": "05e5e721805fdd9511fd7d7296984cb13ccb5a5d919f512d84822dbb99591908",
"tests/test_codex_runtime.py": "35688888c892c30bf1e745ab94f09dcf7b8a5cf99c29019b5e15bb172cf3fb6a",
"tests/test_core.py": "167baaef162cfb6212e70fd6a1354479ab31352b4ed1000281a8773260d5a6d5",
- "tests/test_desktop_and_publish.py": "1230c3de1a3beecfae95a70dc44961ceabcfa99910fe204cdea1cce3dfe6128c",
+ "tests/test_desktop_and_publish.py": "71fae513bac9965c146b20035b84d006cfb80045fb521523cbff38a11ef37edc",
+ "tests/test_native_bridge.py": "5b611fb9f94733d97d87ac6a45cecd8575e708c0ab687acf59468a76c213251a",
"tests/test_pipeline.py": "28373af3ea4739c252de747710923f6945f06a3397b9e59344263c117f48487a",
"tests/test_providers.py": "83737b8773b8a11936675fba26388848cf6d95f40ef4b6db25baf40baa9d79cb",
"tests/test_safety.py": "41a739403206f67fa9b286f56eae6e07c668ec06f2f70f8a45a184bf8f74da6a",
"tests/test_studio_update.py": "d01b6c74cd0afc300f4468755213d9d21a1f58986efe61eca9d58c3f677c9653",
"tests/test_windowless.py": "421620004c87cf24dec771966602b0666e75ecca67fec05071cf40e4b6887f44",
- "ui/app.js": "c55c58e134eedf7f0ae5f7c0ec93e6e5829237e5ea80aa2d41d41fd5f435604e",
+ "ui/app.js": "c7b29f7870a7a3b3881950b31cc61503389864e057aa110ae89c0bdeee0c25d2",
"ui/graphpaper.ico": "f94c4061c53e8e1a1f2be96ef1a8c18b20c19a984bc35f6337f1037c1025a493",
"ui/icon.svg": "9f97b80a90020b682f96756d1245dfa3cc9f19ed989b0751f802220a3934be0f",
"ui/index.html": "b2a07fc114c06163a2453d2ae1a38254bb44c13fc65128b12becb2b794c5a92d",
diff --git a/pyproject.toml b/pyproject.toml
index 6f1f205..fd411de 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "graphpaper-studio"
-version = "0.2.0"
+version = "0.2.1"
description = "A local-first graph-guided writing studio for nonfiction and fiction"
readme = "README.md"
requires-python = ">=3.11"
diff --git a/scripts/build_release.ps1 b/scripts/build_release.ps1
index 81a4c68..a0e21e8 100644
--- a/scripts/build_release.ps1
+++ b/scripts/build_release.ps1
@@ -9,7 +9,7 @@ if ($LASTEXITCODE -ne 0) { throw 'Codex runtime protocol check failed.' }
python -m PyInstaller --noconfirm --clean --windowed --onedir --name GraphPaper --paths . --icon ui/graphpaper.ico --add-data 'ui;ui' --add-data 'vendor;vendor' --collect-all webview scripts/desktop_entry.py
if ($LASTEXITCODE -ne 0) { throw 'Windows desktop build failed.' }
Copy-Item README.md, LICENSE -Destination dist/GraphPaper/
-Copy-Item docs/UPDATE-0.2.md -Destination dist/GraphPaper/WHATS-NEW.md
+Copy-Item docs/RELEASE-0.2.1.md -Destination dist/GraphPaper/WHATS-NEW.md
python scripts/check_package.py dist/GraphPaper
if ($LASTEXITCODE -ne 0) { throw 'Compiled application self-test failed.' }
$version = python -c "from graphpaper import __version__; print(__version__)"
diff --git a/scripts/native_smoke.py b/scripts/native_smoke.py
new file mode 100644
index 0000000..edd0f74
--- /dev/null
+++ b/scripts/native_smoke.py
@@ -0,0 +1,154 @@
+"""Interactive Windows/WebView2 regression; never uses the user's project directory.
+
+Uses the actual native window, Windows messages and its WebView2 via CDP. No
+browser substitute, model calls, login, app-policy changes or user-data access.
+The debug port exists only in the child test process. Requires an interactive
+Windows desktop. Pass --executable for the actual packaged release binary.
+"""
+from __future__ import annotations
+import argparse,ctypes,json,os,socket,sqlite3,subprocess,sys,tempfile,time,traceback
+from ctypes import wintypes
+from pathlib import Path
+from urllib.request import urlopen
+from playwright.sync_api import sync_playwright,expect
+
+ROOT=Path(__file__).resolve().parents[1]
+
+
+def main():
+ parser=argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--executable',type=Path)
+ parser.add_argument('--out',type=Path,default=ROOT/'test-results/native')
+ args=parser.parse_args()
+ if sys.platform!='win32':raise SystemExit('This regression requires a native Windows desktop.')
+ out=args.out.resolve();out.mkdir(parents=True,exist_ok=True)
+ u=ctypes.WinDLL('user32',use_last_error=True)
+ cbtype=ctypes.WINFUNCTYPE(wintypes.BOOL,wintypes.HWND,wintypes.LPARAM)
+ u.EnumWindows.argtypes=[cbtype,wintypes.LPARAM]
+ u.EnumChildWindows.argtypes=[wintypes.HWND,cbtype,wintypes.LPARAM]
+ u.GetWindowThreadProcessId.argtypes=[wintypes.HWND,ctypes.POINTER(wintypes.DWORD)]
+ u.GetWindowTextW.argtypes=[wintypes.HWND,wintypes.LPWSTR,ctypes.c_int]
+ u.GetClassNameW.argtypes=[wintypes.HWND,wintypes.LPWSTR,ctypes.c_int]
+ u.IsWindowVisible.argtypes=[wintypes.HWND];u.IsHungAppWindow.argtypes=[wintypes.HWND]
+ u.PostMessageW.argtypes=[wintypes.HWND,wintypes.UINT,wintypes.WPARAM,wintypes.LPARAM]
+ u.SendMessageTimeoutW.argtypes=[wintypes.HWND,wintypes.UINT,wintypes.WPARAM,wintypes.LPARAM,wintypes.UINT,wintypes.UINT,ctypes.POINTER(ctypes.c_size_t)]
+ u.SendMessageTimeoutW.restype=ctypes.c_size_t
+ report={'native_windows':True,'packaged_executable':bool(args.executable),'live_models':False,'checks':[],'page_errors':[],'ok':False}
+ def check(name):report['checks'].append(name);print('PASS',name,flush=True)
+ with socket.socket() as s:s.bind(('127.0.0.1',0));port=s.getsockname()[1]
+ data=Path(tempfile.mkdtemp(prefix='GraphPaper-native-test-'))
+ env=dict(os.environ,GRAPHPAPER_DATA_DIR=str(data),WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=f'--remote-debugging-port={port}')
+ if args.executable:command=[str(args.executable.resolve())]
+ else:
+ # Avoid Windows venv's redirector process so proc.pid owns the window.
+ command=[str(Path(sys.base_prefix)/'python.exe'),'-m','graphpaper.desktop']
+ env['PYTHONPATH']=os.pathsep.join([str(ROOT),*sys.path])
+ log=(out/'native.log').open('w',encoding='utf-8')
+ proc=subprocess.Popen(command,cwd=ROOT,env=env,stdout=log,stderr=subprocess.STDOUT)
+ def windows(parent=None):
+ rows=[]
+ def cb(hwnd,_):
+ pid=wintypes.DWORD();u.GetWindowThreadProcessId(hwnd,ctypes.byref(pid))
+ if parent is not None or pid.value==proc.pid:
+ title=ctypes.create_unicode_buffer(512);cls=ctypes.create_unicode_buffer(256)
+ u.GetWindowTextW(hwnd,title,512);u.GetClassNameW(hwnd,cls,256)
+ rows.append({'hwnd':int(hwnd),'title':title.value,'class':cls.value,'visible':bool(u.IsWindowVisible(hwnd)),'hung':bool(u.IsHungAppWindow(hwnd))})
+ return True
+ if parent is None:u.EnumWindows(cbtype(cb),0)
+ else:u.EnumChildWindows(parent,cbtype(cb),0)
+ return rows
+ def message(hwnd,msg,wparam=0,lparam=0):
+ result=ctypes.c_size_t()
+ ok=u.SendMessageTimeoutW(hwnd,msg,wparam,lparam,2,2000,ctypes.byref(result))
+ if not ok:raise AssertionError(f'Native window stopped processing Windows message {msg:#x}')
+ def responsive(hwnd):
+ message(hwnd,0)
+ assert not u.IsHungAppWindow(hwnd),'Windows marks the native window as hung'
+ def wait_test(predicate,seconds=10):
+ end=time.monotonic()+seconds
+ while time.monotonic() Object.keys(window.pywebview?.api||{})'))==expected)
+ report['bridge_methods']=sorted(expected)
+ result=page.evaluate("() => Promise.race([window.pywebview.api.notify_ready(),new Promise(r=>setTimeout(()=>r('timeout'),3000))])")
+ assert result is True,result;check('Native bridge initializes with only four explicit methods and resolves its promise')
+ response=page.request.get(page.url)
+ assert "'unsafe-eval'" not in response.headers['content-security-policy'];check('Strict application CSP remains enabled')
+ # Dispatch genuine Win32 mouse messages to the WebView input surface.
+ children=windows(hwnd);report['native_child_classes']=sorted(set(w['class'] for w in children))
+ target=next((w for w in children if w['class']=='Chrome_RenderWidgetHostHWND'),None)
+ assert target,'WebView2 native input surface was not found'
+ box=page.get_by_role('button',name='New project',exact=True).bounding_box()
+ ratio=page.evaluate('() => window.devicePixelRatio')
+ x=int((box['x']+box['width']/2)*ratio);y=int((box['y']+box['height']/2)*ratio)
+ pos=(y<<16)|(x&0xffff)
+ message(target['hwnd'],0x200,0,pos);message(target['hwnd'],0x201,1,pos);message(target['hwnd'],0x202,0,pos)
+ page.wait_for_selector('#project-title');responsive(hwnd)
+ check('A Windows mouse click opens the project dialog without freezing the host')
+ page.locator('#project-title').fill('Native regression project')
+ page.get_by_role('button',name='Create project',exact=True).click();page.wait_for_selector('#dropzone')
+ pid=page.evaluate('() => state.p.id');check('Native window supports typing and project creation')
+ page.locator('[data-action="settings"]').click();page.wait_for_selector('#s-provider')
+ page.get_by_role('button',name='Close dialog',exact=True).click();responsive(hwnd)
+ check('Connections dialog opens and closes in the native window')
+ page.locator('.nav-link[data-tab="write"]').click();page.wait_for_selector('#manuscript')
+ page.locator('#manuscript').fill('# Native test\n\nA saved line.')
+ page.evaluate('() => flush()')
+ # Invoke a real native Save As dialog through the actual JS bridge;
+ # cancel it using the OS close button message, not a mocked return.
+ page.evaluate("id => {window.__saveTest=null;window.pywebview.api.save_export(id,'md').then(r=>window.__saveTest=r).catch(e=>window.__saveTest={error:String(e)});}",pid)
+ dlg=wait_test(lambda:next((w for w in windows() if w['visible'] and w['class']=='#32770'),None))
+ u.PostMessageW(dlg['hwnd'],0x10,0,0)
+ result=wait_test(lambda:page.evaluate('() => window.__saveTest'))
+ assert result=={'saved':False},result;responsive(hwnd)
+ check('Native Save As dialog opens, cancels and returns without blocking the interface')
+ page.screenshot(path=str(out/'native-writing.png'))
+ # Trigger close before the 650 ms autosave debounce can fire.
+ draft='# Native test\n\nThis pending edit must survive the native close button.'
+ page.locator('#manuscript').fill(draft)
+ assert page.evaluate('() => Object.keys(pending).length')>0
+ u.PostMessageW(hwnd,0x10,0,0)
+ proc.wait(timeout=15)
+ assert proc.returncode==0,proc.returncode
+ check('Windows close completes the save handshake and exits normally')
+ with sqlite3.connect(data/'studio.sqlite3') as c:stored=json.loads(c.execute('SELECT data FROM projects WHERE id=?',(pid,)).fetchone()[0])
+ assert stored['draft']==draft,stored['draft'];check('Pending manuscript text is persisted before the native process exits')
+ assert not report['page_errors'],report['page_errors']
+ report['ok']=True
+ except Exception:
+ report['error']=traceback.format_exc();raise
+ finally:
+ if proc.poll() is None:
+ subprocess.run(['taskkill','/PID',str(proc.pid),'/T','/F'],capture_output=True)
+ proc.wait(timeout=10)
+ log.close()
+ report['exit_code']=proc.returncode
+ report['recursion_errors']='maximum recursion depth' in (out/'native.log').read_text(encoding='utf-8',errors='replace')
+ if report['recursion_errors']:report['ok']=False
+ (out/'report.json').write_text(json.dumps(report,indent=2)+'\n',encoding='utf-8')
+ # Test data alone; never clear the user's GraphPaper directory.
+ import shutil
+ shutil.rmtree(data,ignore_errors=True)
+ print(json.dumps(report,indent=2))
+ if not report["ok"]:raise SystemExit(1)
+
+if __name__=='__main__':main()
diff --git a/scripts/ui_studio_smoke.py b/scripts/ui_studio_smoke.py
index 8878833..b5f91ad 100644
--- a/scripts/ui_studio_smoke.py
+++ b/scripts/ui_studio_smoke.py
@@ -131,7 +131,7 @@ def bridge(req):
report['checks'].append('Separate deslopping pass and non-destructive rejection')
page.locator('[data-action="settings"]').click();page.locator('#s-provider').select_option('codex')
assert page.locator('#s-model').input_value()==''
- page.screenshot(path=str(out/'codex.png'),full_page=True);page.get_by_role('button',name='Save connections',exact=True).click()
+ page.screenshot(path=str(out/'codex.png'),full_page=True);page.get_by_role('button',name='Save connections',exact=True).click();expect(page.locator('.modal')).to_have_count(0)
assert client.get('/api/settings').json()['provider']=='codex'
report['checks'].append('Codex subscription provider selectable without an API key')
page.set_viewport_size({'width':1000,'height':800});page.locator('.nav-link[data-tab="sources"]').click()
diff --git a/tests/test_desktop_and_publish.py b/tests/test_desktop_and_publish.py
index 3e82942..53f8916 100644
--- a/tests/test_desktop_and_publish.py
+++ b/tests/test_desktop_and_publish.py
@@ -13,20 +13,20 @@
def test_native_close_does_not_need_eval():
bridge=DesktopBridge(None)
- bridge.window=Mock()
+ bridge._window=Mock()
assert bridge.notify_ready()
assert bridge.request_close()=={"closed":True}
- assert bridge.close_authorized
- bridge.window.destroy.assert_called_once()
+ assert bridge._close_authorized
+ bridge._window.destroy.assert_called_once()
def test_native_close_preserves_running_job_when_declined():
bridge=DesktopBridge(None,SimpleNamespace(jobs={"j":SimpleNamespace(state="running")}))
- bridge.window=Mock()
- bridge.window.create_confirmation_dialog.return_value=False
+ bridge._window=Mock()
+ bridge._window.create_confirmation_dialog.return_value=False
assert bridge.request_close()=={"closed":False}
- bridge.window.destroy.assert_not_called()
- assert not bridge.close_authorized
+ bridge._window.destroy.assert_not_called()
+ assert not bridge._close_authorized
def test_graph_import_bad_edge_is_validation_error():
diff --git a/tests/test_native_bridge.py b/tests/test_native_bridge.py
new file mode 100644
index 0000000..221d641
--- /dev/null
+++ b/tests/test_native_bridge.py
@@ -0,0 +1,66 @@
+"""Regression coverage for native API reflection and UI-thread close dispatch."""
+import threading
+from types import SimpleNamespace
+from unittest.mock import Mock
+import pytest
+from graphpaper.desktop import DesktopBridge
+
+
+def test_public_bridge_contains_only_explicit_rpc_methods():
+ bridge=DesktopBridge(Mock(), Mock())
+ bridge._window=Mock()
+ public={name:getattr(bridge,name) for name in dir(bridge) if not name.startswith('_')}
+ assert set(public)=={'cancel_close','notify_ready','request_close','save_export'}
+ assert all(callable(v) for v in public.values())
+ for name in ['window','store','runner']:
+ with pytest.raises(AttributeError):setattr(bridge,name,Mock())
+
+
+def test_os_close_returns_before_slow_js_dispatch_finishes():
+ entered=threading.Event();release=threading.Event()
+ def run_js(script):
+ assert script=='window.graphpaperRequestClose()'
+ entered.set();release.wait(3)
+ bridge=DesktopBridge(None);bridge._window=Mock();bridge._window.run_js.side_effect=run_js
+ bridge.notify_ready()
+ try:
+ assert bridge._on_closing() is False
+ assert entered.wait(1)
+ assert not bridge._close_authorized
+ assert bridge._on_closing() is False
+ assert bridge._window.run_js.call_count==1
+ finally:release.set()
+
+
+def test_failed_save_can_reset_close_request():
+ bridge=DesktopBridge(None)
+ bridge._close_pending.set()
+ assert bridge.cancel_close() is True
+ assert not bridge._close_pending.is_set()
+
+
+def test_declining_busy_close_allows_another_attempt():
+ bridge=DesktopBridge(None,SimpleNamespace(jobs={'j':SimpleNamespace(state='running')}))
+ bridge._window=Mock();bridge._window.create_confirmation_dialog.return_value=False
+ bridge._close_pending.set()
+ assert bridge.request_close()=={'closed':False}
+ assert not bridge._close_pending.is_set()
+ assert not bridge._close_authorized
+ bridge._window.destroy.assert_not_called()
+
+
+def test_authorized_close_never_dispatches_javascript_again():
+ bridge=DesktopBridge(None);bridge._window=Mock();bridge.notify_ready()
+ bridge._close_authorized=True
+ assert bridge._on_closing() is True
+ bridge._window.run_js.assert_not_called()
+
+
+def test_native_close_worker_handles_failed_js_without_losing_save_choice():
+ bridge=DesktopBridge(None);bridge._window=Mock()
+ bridge._window.run_js.side_effect=RuntimeError('The renderer is unavailable')
+ bridge._window.create_confirmation_dialog.return_value=False
+ bridge._close_pending.set();bridge._ask_ui_to_close()
+ assert not bridge._close_pending.is_set()
+ assert not bridge._close_authorized
+ bridge._window.destroy.assert_not_called()
diff --git a/ui/app.js b/ui/app.js
index c99beff..f082ec0 100644
--- a/ui/app.js
+++ b/ui/app.js
@@ -209,7 +209,7 @@ boot();
// Native close uses a save handshake, not eval (our CSP deliberately forbids it).
window.graphpaperRequestClose=async()=>{
try{await flush();if(window.pywebview?.api?.request_close)await window.pywebview.api.request_close();}
- catch(e){toast('Not closed: '+e.message,true);}
+ catch(e){await window.pywebview?.api?.cancel_close?.().catch(()=>{});toast('Not closed: '+e.message,true);}
};
function notifyDesktopReady(){window.pywebview?.api?.notify_ready?.().catch(()=>{});}
window.addEventListener('pywebviewready',notifyDesktopReady);