From 080eefbef6217366cae60d4b948992215d9ba01a Mon Sep 17 00:00:00 2001 From: matei jordache Date: Mon, 23 Mar 2026 17:03:36 -0700 Subject: [PATCH] add desktop build support --- .gitignore | 8 +- README.md | 204 +++++++++++++++++++++++++++++++++++++ backend/nodes/io.py | 5 +- backend/runtime_paths.py | 54 ++++++++++ backend/server.py | 30 ++++-- desktop.py | 111 ++++++++++++++++++++ frontend/package-lock.json | 43 +------- frontend/package.json | 4 + package-lock.json | 15 +++ package.json | 17 ++++ pyproject.toml | 35 +++++++ pytest.ini | 2 + requirements.txt | 4 + scripts/build-desktop.ps1 | 48 +++++++++ 14 files changed, 528 insertions(+), 52 deletions(-) create mode 100644 README.md create mode 100644 backend/runtime_paths.py create mode 100644 desktop.py create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 pyproject.toml create mode 100644 pytest.ini create mode 100644 requirements.txt create mode 100644 scripts/build-desktop.ps1 diff --git a/.gitignore b/.gitignore index 5d28cd2..b413b1b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ *__pycache__* +*.egg-info/ +.pytest_cache/ +pytest-cache-files-*/ +desktop-build/ +desktop-dist/ frontend/node_modules/ -frontend/dist/ \ No newline at end of file +frontend/dist/ +.venv/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f3902a5 --- /dev/null +++ b/README.md @@ -0,0 +1,204 @@ +# Argonode + +Argonode is a node-based image analysis application with: + +- a Python backend built on `aiohttp` +- a React + Vite frontend +- an optional desktop wrapper built with `pywebview` + +The backend serves node definitions, runs workflows, manages file I/O, and streams previews/results over WebSocket. The frontend provides the graph editor and UI. The desktop build packages both together as a Windows application. + +## Project Layout + +```text +argonode/ + backend/ Python server, execution engine, nodes + frontend/ React/Vite app + tests/ Python tests + desktop.py Local desktop launcher + scripts/ Build helpers, including Windows exe packaging +``` + +## Requirements + +- Python `3.10+` +- Node.js `18+` +- npm `9+` +- Windows is recommended for the desktop `.exe` packaging flow + +## First-Time Setup + +Create a virtual environment if you do not already have one: + +```powershell +python -m venv .venv +``` + +Install Python dependencies: + +```powershell +.\.venv\Scripts\python.exe -m pip install -r requirements.txt +``` + +Install Node dependencies from the repo root: + +```powershell +npm install +``` + +Optional extras: + +```powershell +.\.venv\Scripts\python.exe -m pip install -e .[dev] +.\.venv\Scripts\python.exe -m pip install -e .[spm] +.\.venv\Scripts\python.exe -m pip install -e .[desktop] +``` + +- `dev`: test tooling +- `spm`: optional SPM/AFM file readers like `gwyfile`, `nanonispy`, and `igor` +- `desktop`: desktop launcher and PyInstaller packaging tools + +## Running the Local Web Version + +This is the normal browser-based development flow. + +In terminal 1, start the backend: + +```powershell +npm run backend +``` + +This starts the Python server at `http://127.0.0.1:8188`. + +In terminal 2, start the Vite frontend: + +```powershell +npm run dev +``` + +Open the Vite URL shown in the terminal, typically: + +```text +http://127.0.0.1:5173 +``` + +Notes: + +- The frontend dev server proxies API and WebSocket requests to the backend. +- If you want the frontend accessible from other devices on your LAN, run: + +```powershell +npm run dev -- --host 0.0.0.0 +``` + +## Running the Local Desktop Version + +The desktop launcher starts the Python server internally and opens a native window with `pywebview`. + +Build the frontend first: + +```powershell +npm run build +``` + +Then launch the desktop app from source: + +```powershell +npm run desktop +``` + +Notes: + +- `npm run desktop` uses the built frontend from `frontend/dist`. +- If you change frontend code, run `npm run build` again before starting the desktop version. + +## Building the Windows `.exe` + +The repo includes a packaging script that: + +1. builds the frontend +2. installs desktop build dependencies +3. runs PyInstaller + +Build the desktop bundle: + +```powershell +npm run build:desktop +``` + +Or run the script directly: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\build-desktop.ps1 +``` + +The packaged app is written to: + +```text +desktop-dist/Argonode/ +``` + +Main executable: + +```text +desktop-dist/Argonode/Argonode.exe +``` + +### One-File Build + +The default build uses PyInstaller `--onedir`, which is more reliable for scientific Python packages like NumPy, SciPy, and Matplotlib. + +If you still want to try a single-file executable: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\build-desktop.ps1 -OneFile +``` + +## Data Directories + +During normal source-based development, input/output folders live under the repo root. + +In the packaged desktop app, writable data is stored under: + +```text +%LOCALAPPDATA%\Argonode\ +``` + +Specifically: + +```text +%LOCALAPPDATA%\Argonode\input +%LOCALAPPDATA%\Argonode\output +``` + +You can override the packaged app data directory with: + +```powershell +$env:ARGONODE_APPDATA="C:\path\to\custom\data" +``` + +## Useful Commands + +```powershell +npm run dev +npm run build +npm run preview +npm run backend +npm run desktop +npm run build:desktop +.\.venv\Scripts\python.exe -m pytest -q +``` + +## Testing + +Run the Python test suite with: + +```powershell +.\.venv\Scripts\python.exe -m pytest -q +``` + +## Known Notes + +- The frontend production build currently emits a large chunk warning from Vite. This does not block builds. +- The desktop app relies on WebView2 on Windows through `pywebview`. +- Optional SPM readers are not installed unless you explicitly install the `spm` extra. diff --git a/backend/nodes/io.py b/backend/nodes/io.py index 2a89049..40cfae9 100644 --- a/backend/nodes/io.py +++ b/backend/nodes/io.py @@ -9,10 +9,11 @@ from pathlib import Path from backend.node_registry import register_node from backend.data_types import DataField, encode_preview, image_to_uint8 +from backend.runtime_paths import input_dir, output_dir # Resolved at server startup so nodes know where to look -INPUT_DIR = Path(__file__).parent.parent.parent / "input" -OUTPUT_DIR = Path(__file__).parent.parent.parent / "output" +INPUT_DIR = input_dir() +OUTPUT_DIR = output_dir() # --------------------------------------------------------------------------- diff --git a/backend/runtime_paths.py b/backend/runtime_paths.py new file mode 100644 index 0000000..bab42db --- /dev/null +++ b/backend/runtime_paths.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path + +APP_NAME = "Argonode" + + +def project_root() -> Path: + return Path(__file__).resolve().parent.parent + + +def resource_root() -> Path: + if getattr(sys, "frozen", False): + return Path(getattr(sys, "_MEIPASS", Path(sys.executable).resolve().parent)) + return project_root() + + +def frontend_dir() -> Path: + bundled = resource_root() / "frontend" + if bundled.exists(): + return bundled + return project_root() / "frontend" + + +def frontend_dist_dir() -> Path: + return frontend_dir() / "dist" + + +def app_data_dir() -> Path: + override = os.getenv("ARGONODE_APPDATA") + if override: + return Path(override).expanduser().resolve() + + if getattr(sys, "frozen", False): + local_appdata = os.getenv("LOCALAPPDATA") + base_dir = Path(local_appdata) if local_appdata else Path.home() / "AppData" / "Local" + return (base_dir / APP_NAME).resolve() + + return project_root() + + +def input_dir() -> Path: + return app_data_dir() / "input" + + +def output_dir() -> Path: + return app_data_dir() / "output" + + +def ensure_runtime_dirs() -> None: + input_dir().mkdir(parents=True, exist_ok=True) + output_dir().mkdir(parents=True, exist_ok=True) diff --git a/backend/server.py b/backend/server.py index 31a0e91..0ee5615 100644 --- a/backend/server.py +++ b/backend/server.py @@ -24,17 +24,23 @@ from __future__ import annotations import asyncio import json import logging -import uuid from pathlib import Path from aiohttp import web, WSMsgType +from backend.runtime_paths import ( + ensure_runtime_dirs, + frontend_dir, + frontend_dist_dir, + input_dir, + output_dir, +) log = logging.getLogger(__name__) -FRONTEND_DIR = Path(__file__).parent.parent / "frontend" -DIST_DIR = FRONTEND_DIR / "dist" -INPUT_DIR = Path(__file__).parent.parent / "input" -OUTPUT_DIR = Path(__file__).parent.parent / "output" +FRONTEND_DIR = frontend_dir() +DIST_DIR = frontend_dist_dir() +INPUT_DIR = input_dir() +OUTPUT_DIR = output_dir() # --------------------------------------------------------------------------- @@ -67,8 +73,7 @@ def create_app(loop: asyncio.AbstractEventLoop) -> web.Application: from backend.node_registry import get_all_node_info from backend.execution import ExecutionEngine, new_prompt_id - INPUT_DIR.mkdir(exist_ok=True) - OUTPUT_DIR.mkdir(exist_ok=True) + ensure_runtime_dirs() engine = ExecutionEngine() websockets: set[web.WebSocketResponse] = set() @@ -104,7 +109,11 @@ def create_app(loop: asyncio.AbstractEventLoop) -> web.Application: # Serve Vite build output if available, else raw frontend if (DIST_DIR / "index.html").exists(): return web.FileResponse(DIST_DIR / "index.html") - return web.FileResponse(FRONTEND_DIR / "index.html") + if (FRONTEND_DIR / "index.html").exists(): + return web.FileResponse(FRONTEND_DIR / "index.html") + raise web.HTTPInternalServerError( + reason="Frontend build not found. Run `npm run build` before launching the packaged app." + ) async def get_nodes(request: web.Request) -> web.Response: info = get_all_node_info() @@ -244,9 +253,10 @@ def create_app(loop: asyncio.AbstractEventLoop) -> web.Application: app.router.add_get("/ws", websocket_handler) # Serve frontend static files (Vite build or raw) - if DIST_DIR.exists(): + if (DIST_DIR / "assets").exists(): app.router.add_static("/assets", DIST_DIR / "assets") - app.router.add_static("/static", FRONTEND_DIR) + if FRONTEND_DIR.exists(): + app.router.add_static("/static", FRONTEND_DIR) # CORS — allow any origin (local dev only) async def _cors_middleware(app_, handler): diff --git a/desktop.py b/desktop.py new file mode 100644 index 0000000..4c70508 --- /dev/null +++ b/desktop.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import asyncio +import logging +import socket +import threading +import time +import urllib.request + +import webview +from aiohttp import web + +from backend.runtime_paths import app_data_dir, ensure_runtime_dirs +from backend.server import create_app + +HOST = "127.0.0.1" +WINDOW_TITLE = "Argonode" + + +def _pick_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind((HOST, 0)) + return int(sock.getsockname()[1]) + + +def _wait_for_server(url: str, timeout: float = 15.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=0.5): + return + except Exception: + time.sleep(0.1) + raise RuntimeError(f"Timed out waiting for server at {url}") + + +def _run_server(host: str, port: int, ready: threading.Event, state: dict[str, object]) -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + state["loop"] = loop + + async def start() -> None: + app = create_app(loop) + runner = web.AppRunner(app, access_log=None) + await runner.setup() + site = web.TCPSite(runner, host, port) + await site.start() + state["runner"] = runner + ready.set() + + try: + loop.run_until_complete(start()) + loop.run_forever() + except Exception as exc: + state["error"] = exc + ready.set() + raise + finally: + runner = state.get("runner") + if isinstance(runner, web.AppRunner): + loop.run_until_complete(runner.cleanup()) + loop.close() + + +def main() -> None: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-8s %(name)s - %(message)s", + ) + + ensure_runtime_dirs() + + port = _pick_free_port() + base_url = f"http://{HOST}:{port}" + ready = threading.Event() + state: dict[str, object] = {} + + server_thread = threading.Thread( + target=_run_server, + args=(HOST, port, ready, state), + name="argonode-server", + daemon=True, + ) + server_thread.start() + ready.wait(timeout=15.0) + + if "error" in state: + raise RuntimeError("Argonode server failed to start") from state["error"] + + _wait_for_server(f"{base_url}/nodes") + + window = webview.create_window( + WINDOW_TITLE, + base_url, + width=1600, + height=1000, + min_size=(1100, 720), + ) + + def _shutdown() -> None: + loop = state.get("loop") + if isinstance(loop, asyncio.AbstractEventLoop): + loop.call_soon_threadsafe(loop.stop) + + window.events.closed += _shutdown + logging.getLogger(__name__).info("Using app data directory: %s", app_data_dir()) + webview.start() + + +if __name__ == "__main__": + main() diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5075927..471f968 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,10 @@ "devDependencies": { "@vitejs/plugin-react": "^4.3.0", "vite": "^5.4.0" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=9.0.0" } }, "node_modules/@babel/code-frame": { @@ -838,9 +842,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -855,9 +856,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -872,9 +870,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -889,9 +884,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -906,9 +898,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -923,9 +912,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -940,9 +926,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -957,9 +940,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -974,9 +954,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -991,9 +968,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1008,9 +982,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1025,9 +996,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1042,9 +1010,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/frontend/package.json b/frontend/package.json index 6720568..b489587 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,6 +2,10 @@ "name": "argonode-frontend", "private": true, "type": "module", + "engines": { + "node": ">=18.0.0", + "npm": ">=9.0.0" + }, "scripts": { "dev": "vite", "build": "vite build", diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..789cac5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,15 @@ +{ + "name": "argonode", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "argonode", + "hasInstallScript": true, + "engines": { + "node": ">=18.0.0", + "npm": ">=9.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b27a7b7 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "argonode", + "private": true, + "engines": { + "node": ">=18.0.0", + "npm": ">=9.0.0" + }, + "scripts": { + "postinstall": "npm --prefix frontend install", + "dev": "npm --prefix frontend run dev", + "build": "npm --prefix frontend run build", + "preview": "npm --prefix frontend run preview", + "backend": "python -m backend.main", + "desktop": "python desktop.py", + "build:desktop": "powershell -ExecutionPolicy Bypass -File scripts\\build-desktop.ps1" + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..538b571 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,35 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "argonode" +version = "0.1.0" +description = "Node-based image analysis app with a Python backend and React frontend." +readme = "GWYDDION_FEATURE_GAP.md" +requires-python = ">=3.10" +dependencies = [ + "aiohttp>=3.9,<4", + "matplotlib>=3.8,<4", + "numpy>=1.26,<3", + "pillow>=10,<12", + "scikit-image>=0.22,<1", + "scipy>=1.12,<2", +] + +[project.optional-dependencies] +spm = [ + "gwyfile>=0.2", + "igor>=0.3", + "nanonispy>=1.1", +] +dev = [ + "pytest>=8,<9", +] +desktop = [ + "pyinstaller>=6,<7", + "pywebview>=5,<6", +] + +[tool.setuptools.packages.find] +include = ["backend*"] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..e64a3bf --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +norecursedirs = .git .venv .pytest_cache frontend/node_modules frontend/dist pytest-cache-files-* diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..74877e1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +# Core runtime dependencies are defined in pyproject.toml. +# Install them from the repo root with: +# python -m pip install -r requirements.txt +-e . diff --git a/scripts/build-desktop.ps1 b/scripts/build-desktop.ps1 new file mode 100644 index 0000000..ac4c302 --- /dev/null +++ b/scripts/build-desktop.ps1 @@ -0,0 +1,48 @@ +param( + [switch]$OneFile +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +Set-Location $repoRoot + +$pythonExe = if (Test-Path ".\.venv\Scripts\python.exe") { + ".\.venv\Scripts\python.exe" +} else { + "python" +} +$frontendDist = Join-Path $repoRoot "frontend\dist" + +Write-Host "Building frontend bundle..." +npm run build + +Write-Host "Installing desktop build dependencies..." +& $pythonExe -m pip install -e ".[desktop]" + +$mode = if ($OneFile) { "--onefile" } else { "--onedir" } + +$pyInstallerArgs = @( + "-m", "PyInstaller", + "desktop.py", + "--noconfirm", + "--clean", + "--name", "Argonode", + "--windowed", + $mode, + "--distpath", "desktop-dist", + "--workpath", "desktop-build", + "--specpath", "desktop-build", + "--add-data", "${frontendDist};frontend/dist", + "--collect-all", "matplotlib", + "--collect-all", "scipy", + "--collect-all", "skimage", + "--collect-all", "webview" +) + +Write-Host "Packaging desktop app..." +& $pythonExe @pyInstallerArgs + +Write-Host "Desktop build complete." +Write-Host "Output folder: $repoRoot\desktop-dist\Argonode"