From cd4065c8adb8d54116c96a5e643fe414440e31f1 Mon Sep 17 00:00:00 2001 From: TqvNoCode <92263302+TqvNoCode@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:56:31 +0700 Subject: [PATCH 1/3] bug fix for window app --- src/math_app/main.py | 10 +- .../presentation/test_shutdown_lifecycle.py | 173 +++++++++++++----- 2 files changed, 137 insertions(+), 46 deletions(-) diff --git a/src/math_app/main.py b/src/math_app/main.py index 824550f..ebecdf5 100644 --- a/src/math_app/main.py +++ b/src/math_app/main.py @@ -100,6 +100,7 @@ def get_available_port(host: str = DEFAULT_HOST, default_port: int = DEFAULT_POR import socket with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: try: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((host, default_port)) return default_port except OSError: @@ -128,6 +129,11 @@ def _handle_signal(signum: int, frame: Any) -> None: sys.stderr.flush() except Exception: pass + try: + from nicegui import run + run.tear_down() + except Exception: + pass os._exit(0) is_shutting_down = True @@ -140,6 +146,8 @@ def _handle_signal(signum: int, frame: Any) -> None: try: signal.signal(signal.SIGINT, _handle_signal) signal.signal(signal.SIGTERM, _handle_signal) + if hasattr(signal, "SIGBREAK"): + signal.signal(signal.SIGBREAK, _handle_signal) except (ValueError, OSError): pass @@ -172,7 +180,7 @@ def main() -> None: port=port, reload=False, native=use_native, - window_size=(1200, 800), + window_size=(1200, 800) if use_native else None, ) except KeyboardInterrupt: pass diff --git a/tests/unit/presentation/test_shutdown_lifecycle.py b/tests/unit/presentation/test_shutdown_lifecycle.py index 557a1fe..bfa868b 100644 --- a/tests/unit/presentation/test_shutdown_lifecycle.py +++ b/tests/unit/presentation/test_shutdown_lifecycle.py @@ -1,74 +1,157 @@ """Tests for clean application lifecycle and bounded shutdown.""" +from __future__ import annotations + +import os +import re import signal import subprocess import sys import time +import urllib.error import urllib.request +from pathlib import Path +from typing import Any + import pytest +PROJECT_ROOT = Path(__file__).resolve().parents[3] -def test_clean_shutdown_without_calculation(): - """Verify application starts and shuts down cleanly on SIGINT within 5 seconds.""" - p = subprocess.Popen( +SHUTDOWN_SIGNAL = ( + getattr(signal, "CTRL_BREAK_EVENT", signal.SIGINT) + if sys.platform == "win32" + else signal.SIGINT +) + +VALID_RETURNCODES = ( + (0, 1, 0xC000013A, -1073741510) + if sys.platform == "win32" + else (0, 1, -signal.SIGINT) +) + + +def _start_app_process() -> subprocess.Popen[bytes]: + env = os.environ.copy() + # NiceGUI checks PYTEST_CURRENT_TEST to enforce test fixtures mode with + # NICEGUI_SCREEN_TEST_PORT. As a standalone subprocess, strip this variable. + env.pop("PYTEST_CURRENT_TEST", None) + + kwargs: dict[str, Any] = { + "stdout": subprocess.PIPE, + "stderr": subprocess.PIPE, + "cwd": str(PROJECT_ROOT), + "env": env, + } + if sys.platform == "win32": + kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + + return subprocess.Popen( [sys.executable, "run.py", "--browser"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + **kwargs, ) - time.sleep(2.5) - p.send_signal(signal.SIGINT) + + +def _ensure_process_terminated(p: subprocess.Popen[bytes]) -> None: + """Reliable cleanup safeguard so failed tests do not leak child processes.""" + if p.poll() is None: + try: + p.kill() + p.communicate(timeout=3) + except Exception: + pass + + +def test_clean_shutdown_without_calculation(): + """Verify application starts and shuts down cleanly on signal within deadline.""" + p = _start_app_process() try: - stdout, stderr = p.communicate(timeout=6) - assert p.returncode in (0, 1, -signal.SIGINT) + # Verify application remains alive while starting up + time.sleep(2.0) + assert p.poll() is None, f"Application exited prematurely with code {p.returncode}" + + # Request graceful shutdown + p.send_signal(SHUTDOWN_SIGNAL) + try: + stdout, stderr = p.communicate(timeout=6) + except subprocess.TimeoutExpired: + pytest.fail("Application failed to shut down cleanly within timeout") + + assert p.returncode in VALID_RETURNCODES assert b"asyncio.exceptions.CancelledError" not in stderr assert b"leaked semaphore objects" not in stderr - except subprocess.TimeoutExpired: - p.kill() - pytest.fail("Application failed to shut down cleanly within timeout") + finally: + _ensure_process_terminated(p) def test_clean_shutdown_after_page_load(): """Verify application starts, responds to HTTP request, and shuts down cleanly.""" - p = subprocess.Popen( - [sys.executable, "run.py", "--browser"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - time.sleep(2.5) - # Ping the server + p = _start_app_process() try: - with urllib.request.urlopen("http://127.0.0.1:8080/", timeout=3) as resp: - assert resp.status == 200 - except Exception: - pass # If port differs or already bound, shutdown still must work + # Bounded readiness loop to verify the server is ready and serving HTTP requests + readiness_deadline = time.monotonic() + 10.0 + server_ready = False + last_error: Exception | None = None + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) - p.send_signal(signal.SIGINT) - try: - stdout, stderr = p.communicate(timeout=6) - assert p.returncode in (0, 1, -signal.SIGINT) + # Dynamically determine the URL NiceGUI bound to + url = "http://127.0.0.1:8080" + if p.stdout is not None: + line = p.stdout.readline().decode("utf-8", errors="replace") + m = re.search(r"http://[^\s]+", line) + if m: + url = m.group(0).rstrip("/") + + target_url = f"{url}/" + while time.monotonic() < readiness_deadline: + assert p.poll() is None, f"Application exited prematurely with code {p.returncode}" + try: + with opener.open(target_url, timeout=1.0) as resp: + if resp.status == 200: + server_ready = True + break + except (urllib.error.URLError, TimeoutError, OSError) as exc: + last_error = exc + time.sleep(0.1) + + assert server_ready, ( + f"Server failed to respond with HTTP 200 at {target_url} within readiness deadline. Last error: {last_error}" + ) + + # Request graceful shutdown + p.send_signal(SHUTDOWN_SIGNAL) + try: + stdout, stderr = p.communicate(timeout=6) + except subprocess.TimeoutExpired: + pytest.fail("Application failed to shut down cleanly after web request") + + assert p.returncode in VALID_RETURNCODES assert b"asyncio.exceptions.CancelledError" not in stderr assert b"leaked semaphore objects" not in stderr - except subprocess.TimeoutExpired: - p.kill() - pytest.fail("Application failed to shut down cleanly after web request") + finally: + _ensure_process_terminated(p) def test_repeated_sigint_does_not_deadlock(): - """Verify repeated SIGINT triggers immediate exit without 30s timeout hang.""" - p = subprocess.Popen( - [sys.executable, "run.py", "--browser"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - time.sleep(2.5) - p.send_signal(signal.SIGINT) - time.sleep(0.05) - p.send_signal(signal.SIGINT) + """Verify repeated interrupt signals trigger immediate exit without deadlock.""" + p = _start_app_process() try: - stdout, stderr = p.communicate(timeout=6) - assert p.returncode in (0, 1, -signal.SIGINT) + # Verify application remains alive while starting up + time.sleep(2.0) + assert p.poll() is None, f"Application exited prematurely with code {p.returncode}" + + # Request graceful shutdown + p.send_signal(SHUTDOWN_SIGNAL) + time.sleep(0.05) + # Send repeated signal to verify prompt exit without hanging + p.send_signal(SHUTDOWN_SIGNAL) + + try: + stdout, stderr = p.communicate(timeout=6) + except subprocess.TimeoutExpired: + pytest.fail("Repeated interrupt signals deadlocked on shutdown") + + assert p.returncode in VALID_RETURNCODES assert b"asyncio.exceptions.CancelledError" not in stderr assert b"leaked semaphore objects" not in stderr - except subprocess.TimeoutExpired: - p.kill() - pytest.fail("Repeated SIGINT deadlocked on shutdown") + finally: + _ensure_process_terminated(p) From 762c7d73dd64ab16dae2acade36ec4a8d3fbd02e Mon Sep 17 00:00:00 2001 From: TqvNoCode <92263302+TqvNoCode@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:21:59 +0700 Subject: [PATCH 2/3] bug fix for window app --- src/math_app/main.py | 11 ++ .../presentation/test_shutdown_lifecycle.py | 132 ++++++++++++++++-- 2 files changed, 131 insertions(+), 12 deletions(-) diff --git a/src/math_app/main.py b/src/math_app/main.py index ebecdf5..13a90fe 100644 --- a/src/math_app/main.py +++ b/src/math_app/main.py @@ -141,6 +141,11 @@ def _handle_signal(signum: int, frame: Any) -> None: from nicegui import app app.shutdown() except Exception: + try: + from nicegui import run + run.tear_down() + except Exception: + pass os._exit(0) try: @@ -181,10 +186,16 @@ def main() -> None: reload=False, native=use_native, window_size=(1200, 800) if use_native else None, + timeout_graceful_shutdown=3.0, ) except KeyboardInterrupt: pass finally: + try: + from nicegui import run + run.tear_down() + except Exception: + pass sys.stdout.flush() sys.stderr.flush() diff --git a/tests/unit/presentation/test_shutdown_lifecycle.py b/tests/unit/presentation/test_shutdown_lifecycle.py index bfa868b..b8ca4e5 100644 --- a/tests/unit/presentation/test_shutdown_lifecycle.py +++ b/tests/unit/presentation/test_shutdown_lifecycle.py @@ -7,11 +7,12 @@ import signal import subprocess import sys +import tempfile import time import urllib.error import urllib.request from pathlib import Path -from typing import Any +from typing import IO, Any import pytest @@ -30,22 +31,99 @@ ) -def _start_app_process() -> subprocess.Popen[bytes]: +class _StdoutReader: + """Non-blocking reader wrapper around temporary file capture.""" + + def __init__(self, raw_file: IO[bytes]) -> None: + self._raw_file = raw_file + self._pos = 0 + + def readline(self, timeout: float = 5.0) -> bytes: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + self._raw_file.seek(self._pos) + line = self._raw_file.readline() + if line and line.endswith(b"\n"): + self._pos = self._raw_file.tell() + return line + time.sleep(0.05) + self._raw_file.seek(self._pos) + line = self._raw_file.readline() + self._pos = self._raw_file.tell() + return line + + def read(self) -> bytes: + self._raw_file.seek(self._pos) + data = self._raw_file.read() + self._pos = self._raw_file.tell() + return data + + +class ManagedPopen(subprocess.Popen[bytes]): + """Subprocess manager that captures stdout and stderr via temporary files. + + On Windows, when descendant processes (e.g. browser or background workers) + inherit anonymous pipe handles, Popen.communicate() deadlocks in ReadFile + waiting for an EOF that never arrives even after the main application process + exits cleanly. Writing to temporary files decouples process lifecycle from + handle inheritance, ensuring bounded, reliable shutdown verification without + pipe deadlock across all platforms. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self._stdout_file: IO[bytes] = tempfile.TemporaryFile() + self._stderr_file: IO[bytes] = tempfile.TemporaryFile() + kwargs["stdout"] = self._stdout_file + kwargs["stderr"] = self._stderr_file + super().__init__(*args, **kwargs) + self.stdout = _StdoutReader(self._stdout_file) # type: ignore[assignment] + self.stderr = _StdoutReader(self._stderr_file) # type: ignore[assignment] + + def communicate( # type: ignore[override] + self, + input: bytes | None = None, + timeout: float | None = None, + ) -> tuple[bytes, bytes]: + try: + self.wait(timeout=timeout) + except subprocess.TimeoutExpired as exc: + self._stdout_file.seek(0) + self._stderr_file.seek(0) + exc.stdout = self._stdout_file.read() + exc.stderr = self._stderr_file.read() + raise + + self._stdout_file.seek(0) + self._stderr_file.seek(0) + stdout = self._stdout_file.read() + stderr = self._stderr_file.read() + return stdout, stderr + + def close_files(self) -> None: + try: + self._stdout_file.close() + except Exception: + pass + try: + self._stderr_file.close() + except Exception: + pass + + +def _start_app_process() -> ManagedPopen: env = os.environ.copy() # NiceGUI checks PYTEST_CURRENT_TEST to enforce test fixtures mode with # NICEGUI_SCREEN_TEST_PORT. As a standalone subprocess, strip this variable. env.pop("PYTEST_CURRENT_TEST", None) kwargs: dict[str, Any] = { - "stdout": subprocess.PIPE, - "stderr": subprocess.PIPE, "cwd": str(PROJECT_ROOT), "env": env, } if sys.platform == "win32": kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP - return subprocess.Popen( + return ManagedPopen( [sys.executable, "run.py", "--browser"], **kwargs, ) @@ -55,10 +133,19 @@ def _ensure_process_terminated(p: subprocess.Popen[bytes]) -> None: """Reliable cleanup safeguard so failed tests do not leak child processes.""" if p.poll() is None: try: - p.kill() + if sys.platform == "win32": + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(p.pid)], + capture_output=True, + check=False, + ) + else: + p.kill() p.communicate(timeout=3) except Exception: pass + if hasattr(p, "close_files"): + p.close_files() def test_clean_shutdown_without_calculation(): @@ -73,8 +160,15 @@ def test_clean_shutdown_without_calculation(): p.send_signal(SHUTDOWN_SIGNAL) try: stdout, stderr = p.communicate(timeout=6) - except subprocess.TimeoutExpired: - pytest.fail("Application failed to shut down cleanly within timeout") + except subprocess.TimeoutExpired as exc: + out_str = exc.stdout.decode("utf-8", errors="replace") if exc.stdout else "" + err_str = exc.stderr.decode("utf-8", errors="replace") if exc.stderr else "" + pytest.fail( + f"Application failed to shut down cleanly within timeout.\n" + f"Process alive: {p.poll() is None}, returncode: {p.returncode}\n" + f"STDOUT:\n{out_str}\n" + f"STDERR:\n{err_str}" + ) assert p.returncode in VALID_RETURNCODES assert b"asyncio.exceptions.CancelledError" not in stderr @@ -121,8 +215,15 @@ def test_clean_shutdown_after_page_load(): p.send_signal(SHUTDOWN_SIGNAL) try: stdout, stderr = p.communicate(timeout=6) - except subprocess.TimeoutExpired: - pytest.fail("Application failed to shut down cleanly after web request") + except subprocess.TimeoutExpired as exc: + out_str = exc.stdout.decode("utf-8", errors="replace") if exc.stdout else "" + err_str = exc.stderr.decode("utf-8", errors="replace") if exc.stderr else "" + pytest.fail( + f"Application failed to shut down cleanly after web request.\n" + f"Process alive: {p.poll() is None}, returncode: {p.returncode}\n" + f"STDOUT:\n{out_str}\n" + f"STDERR:\n{err_str}" + ) assert p.returncode in VALID_RETURNCODES assert b"asyncio.exceptions.CancelledError" not in stderr @@ -147,8 +248,15 @@ def test_repeated_sigint_does_not_deadlock(): try: stdout, stderr = p.communicate(timeout=6) - except subprocess.TimeoutExpired: - pytest.fail("Repeated interrupt signals deadlocked on shutdown") + except subprocess.TimeoutExpired as exc: + out_str = exc.stdout.decode("utf-8", errors="replace") if exc.stdout else "" + err_str = exc.stderr.decode("utf-8", errors="replace") if exc.stderr else "" + pytest.fail( + f"Repeated interrupt signals deadlocked on shutdown.\n" + f"Process alive: {p.poll() is None}, returncode: {p.returncode}\n" + f"STDOUT:\n{out_str}\n" + f"STDERR:\n{err_str}" + ) assert p.returncode in VALID_RETURNCODES assert b"asyncio.exceptions.CancelledError" not in stderr From c1fb268a0dd0ad5f99abd0dacfd4e535ce4c982d Mon Sep 17 00:00:00 2001 From: TqvNoCode <92263302+TqvNoCode@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:25:30 +0700 Subject: [PATCH 3/3] fix bug and optimize --- README.md | 18 +++++- README.vi.md | 18 +++++- assets/icon.ico | Bin 0 -> 370070 bytes mathweave-studio-onefile.spec | 100 ++++++++++++++++++++++++++++++++++ mathweave-studio.spec | 63 +++++++++++++-------- pyproject.toml | 1 + run.py | 4 +- src/math_app/main.py | 74 +++++++++++++++++++++++-- uv.lock | 2 + 9 files changed, 247 insertions(+), 33 deletions(-) create mode 100644 assets/icon.ico create mode 100644 mathweave-studio-onefile.spec diff --git a/README.md b/README.md index 8e6d73e..27af44e 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,20 @@ Open your browser at: **[http://localhost:8080](http://localhost:8080)** For complete Arch Linux instructions—including future AUR installation placeholders, system dependencies, running with standard virtual environments, and uninstallation—see the [Arch Linux Installation Guide](docs/INSTALL_ARCH.md). +### Windows Standalone Desktop Application + +Download `MathWeaveStudio-Beta.exe` from GitHub Releases. +Double-click the executable to launch MathWeave Studio directly in a native desktop window without installing Python or uv. + +> **System Prerequisites for Windows Native Mode:** +> MathWeave Studio uses Microsoft Edge WebView2 for its native desktop window. WebView2 is pre-installed on Windows 11 and modern Windows 10 versions. If running on a stripped or offline Windows edition without WebView2, install the official [Microsoft Edge WebView2 Evergreen Runtime](https://go.microsoft.com/fwlink/p/?LinkId=2124703), or launch with `--browser` from the command prompt. + +#### Building Standalone EXE on Windows: +```powershell +uv sync --all-groups +uv run pyinstaller mathweave-studio-onefile.spec --clean --noconfirm +``` + --- ## Documentation @@ -103,11 +117,11 @@ For complete Arch Linux instructions—including future AUR installation placeho MathWeave Studio maintains an automated test suite enforcing Clean Architecture boundary isolation, solver mathematical validity, and UI stability: ```bash -# Run all 206 automated tests +# Run all 229 automated tests uv run pytest ``` -All 206 tests pass with 100% success rate. +All 229 tests pass with 100% success rate. --- diff --git a/README.vi.md b/README.vi.md index 73045e7..40afc1a 100644 --- a/README.vi.md +++ b/README.vi.md @@ -89,6 +89,20 @@ Mở trình duyệt web tại: **[http://localhost:8080](http://localhost:8080)* Để xem hướng dẫn chi tiết trên Arch Linux—bao gồm trạng thái gói AUR/yay, gói hệ thống, chạy với môi trường ảo tiêu chuẩn và cách gỡ cài đặt sạch sẽ—vui lòng xem [Hướng dẫn cài đặt trên Arch Linux](docs/INSTALL_ARCH.md). +### Ứng dụng Desktop Độc lập trên Windows + +Tải tệp `MathWeaveStudio-Beta.exe` từ GitHub Releases. +Nhấp đúp vào tệp EXE để mở MathWeave Studio trực tiếp trong cửa sổ ứng dụng desktop mà không cần cài đặt Python hay uv. + +> **Yêu cầu hệ thống cho chế độ Desktop Windows:** +> MathWeave Studio sử dụng Microsoft Edge WebView2 để tạo cửa sổ ứng dụng native. WebView2 đã được tích hợp sẵn trên Windows 11 và các bản cập nhật Windows 10 tiêu chuẩn. Đối với các phiên bản Windows rút gọn hoặc ngoại tuyến chưa có WebView2, vui lòng cài đặt [Microsoft Edge WebView2 Evergreen Runtime](https://go.microsoft.com/fwlink/p/?LinkId=2124703), hoặc chạy ứng dụng từ dòng lệnh với tham số `--browser`. + +#### Đóng gói tệp EXE trên Windows: +```powershell +uv sync --all-groups +uv run pyinstaller mathweave-studio-onefile.spec --clean --noconfirm +``` + --- ## Tài liệu dự án @@ -103,11 +117,11 @@ Mở trình duyệt web tại: **[http://localhost:8080](http://localhost:8080)* MathWeave Studio duy trì bộ kiểm thử tự động toàn diện kiểm soát ranh giới kiến trúc Clean Architecture, tính chính xác của các thuật toán và độ ổn định của giao diện: ```bash -# Chạy toàn bộ 206 bài kiểm thử tự động +# Chạy toàn bộ 229 bài kiểm thử tự động uv run pytest ``` -Toàn bộ 206 bài kiểm thử đều vượt qua với tỷ lệ thành công 100%. +Toàn bộ 229 bài kiểm thử đều vượt qua với tỷ lệ thành công 100%. --- diff --git a/assets/icon.ico b/assets/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..268734796499a48d12018dd98dd31f4b38cae7e3 GIT binary patch literal 370070 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.18.0", "numpy>=2.5.4", "plotly>=7.1.0", + "pythonnet>=3.0.0; sys_platform == 'win32'", "pywebview>=6.2.1", "qtpy>=2.4.3", "sympy>=1.14.0", diff --git a/run.py b/run.py index 13821a7..3d412ec 100644 --- a/run.py +++ b/run.py @@ -9,6 +9,7 @@ from __future__ import annotations +import multiprocessing import sys from pathlib import Path @@ -20,5 +21,6 @@ from math_app.main import main -if __name__ in {"__main__", "__mp_main__"}: +if __name__ == "__main__": + multiprocessing.freeze_support() main() diff --git a/src/math_app/main.py b/src/math_app/main.py index 13a90fe..87dc98c 100644 --- a/src/math_app/main.py +++ b/src/math_app/main.py @@ -2,6 +2,8 @@ from __future__ import annotations +from pathlib import Path + from nicegui import ui from math_app import __title__ @@ -29,6 +31,18 @@ DEFAULT_PORT = 8080 +def get_resource_path(relative_path: str | Path) -> Path: + """Resolve absolute path to a resource, supporting both development and PyInstaller frozen bundles.""" + import sys + from pathlib import Path + + if hasattr(sys, "_MEIPASS"): + base_dir = Path(sys._MEIPASS) + else: + base_dir = Path(__file__).resolve().parent.parent.parent + return (base_dir / relative_path).resolve() + + def build_application() -> SolveProblemUseCase: """Wire together solvers, registry, and application use cases.""" registry = DefaultSolverRegistry() @@ -159,25 +173,70 @@ def _handle_signal(signum: int, frame: Any) -> None: def main() -> None: """Application main entrypoint.""" + import multiprocessing import os import sys + # In Windows / frozen environment, prevent spawned child workers from executing main + if multiprocessing.current_process().name != "MainProcess": + return + use_case = build_application() create_app(use_case) _setup_signal_handlers() port = get_available_port(DEFAULT_HOST, DEFAULT_PORT) - # Allow explicit flag --browser or default to native window when display is available + # Allow explicit flag --browser or default to native desktop window use_native = "--browser" not in sys.argv - if use_native and not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")): - use_native = False + if use_native and sys.platform.startswith("linux"): + # On Linux, only activate native mode when an X11 or Wayland display server is running + if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")): + use_native = False - from nicegui import app if use_native: - app.native.start_args["gui"] = "qt" + from nicegui import app + if sys.platform == "win32": + # On Windows, pywebview uses WinForms + EdgeChromium (WebView2). + # Verify EdgeChromium availability or report actionable diagnostics. + try: + from webview.platforms.winforms import _is_chromium + if not _is_chromium(): + msg = ( + "Microsoft Edge WebView2 Runtime chưa được cài đặt trên hệ thống Windows này.\n" + "MathWeave Studio yêu cầu WebView2 Runtime để chạy ở chế độ cửa sổ ứng dụng desktop.\n\n" + "Vui lòng tải và cài đặt Microsoft Edge WebView2 Runtime từ:\n" + "https://go.microsoft.com/fwlink/p/?LinkId=2124703\n\n" + "Hoặc khởi chạy ứng dụng từ dòng lệnh với tham số --browser để mở qua trình duyệt." + ) + sys.stderr.write(f"\n[ERROR] {msg}\n\n") + sys.stderr.flush() + try: + import ctypes + ctypes.windll.user32.MessageBoxW( + 0, + msg, + "MathWeave Studio — Cần WebView2 Runtime", + 0x10, # MB_ICONERROR + ) + except Exception: + pass + sys.exit(1) + except ImportError: + pass + elif sys.platform.startswith("linux"): + app.native.start_args["gui"] = "qt" + app.native.window_args["resizable"] = True + ico_path = get_resource_path("assets/icon.ico") + png_path = get_resource_path("assets/icon.png") + favicon_path: str | None = None + if ico_path.is_file(): + favicon_path = str(ico_path) + elif png_path.is_file(): + favicon_path = str(png_path) + try: ui.run( host=DEFAULT_HOST, @@ -186,6 +245,7 @@ def main() -> None: reload=False, native=use_native, window_size=(1200, 800) if use_native else None, + favicon=favicon_path, timeout_graceful_shutdown=3.0, ) except KeyboardInterrupt: @@ -200,5 +260,7 @@ def main() -> None: sys.stderr.flush() -if __name__ in {"__main__", "__mp_main__"}: +if __name__ == "__main__": + import multiprocessing + multiprocessing.freeze_support() main() diff --git a/uv.lock b/uv.lock index e9b2b91..e3227f4 100644 --- a/uv.lock +++ b/uv.lock @@ -671,6 +671,7 @@ dependencies = [ { name = "nicegui" }, { name = "numpy" }, { name = "plotly" }, + { name = "pythonnet", marker = "sys_platform == 'win32'" }, { name = "pywebview" }, { name = "qtpy" }, { name = "sympy" }, @@ -690,6 +691,7 @@ requires-dist = [ { name = "nicegui", specifier = ">=3.18.0" }, { name = "numpy", specifier = ">=2.5.4" }, { name = "plotly", specifier = ">=7.1.0" }, + { name = "pythonnet", marker = "sys_platform == 'win32'", specifier = ">=3.0.0" }, { name = "pywebview", specifier = ">=6.2.1" }, { name = "qtpy", specifier = ">=2.4.3" }, { name = "sympy", specifier = ">=1.14.0" },