add desktop build support

This commit is contained in:
matei jordache
2026-03-23 17:03:36 -07:00
parent 87b6905fba
commit 080eefbef6
14 changed files with 528 additions and 52 deletions

8
.gitignore vendored
View File

@@ -1,3 +1,9 @@
*__pycache__*
*.egg-info/
.pytest_cache/
pytest-cache-files-*/
desktop-build/
desktop-dist/
frontend/node_modules/
frontend/dist/
frontend/dist/
.venv/

204
README.md Normal file
View File

@@ -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.

View File

@@ -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()
# ---------------------------------------------------------------------------

54
backend/runtime_paths.py Normal file
View File

@@ -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)

View File

@@ -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):

111
desktop.py Normal file
View File

@@ -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()

View File

@@ -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": [

View File

@@ -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",

15
package-lock.json generated Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "argonode",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "argonode",
"hasInstallScript": true,
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
}
}
}
}

17
package.json Normal file
View File

@@ -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"
}
}

35
pyproject.toml Normal file
View File

@@ -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*"]

2
pytest.ini Normal file
View File

@@ -0,0 +1,2 @@
[pytest]
norecursedirs = .git .venv .pytest_cache frontend/node_modules frontend/dist pytest-cache-files-*

4
requirements.txt Normal file
View File

@@ -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 .

48
scripts/build-desktop.ps1 Normal file
View File

@@ -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"