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 @@ ![GraphPaper — Graph-first AI writing studio](docs/images/graphpaper-header.svg) ### 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);