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__* *__pycache__*
*.egg-info/
.pytest_cache/
pytest-cache-files-*/
desktop-build/
desktop-dist/
frontend/node_modules/ 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.node_registry import register_node
from backend.data_types import DataField, encode_preview, image_to_uint8 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 # Resolved at server startup so nodes know where to look
INPUT_DIR = Path(__file__).parent.parent.parent / "input" INPUT_DIR = input_dir()
OUTPUT_DIR = Path(__file__).parent.parent.parent / "output" 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 asyncio
import json import json
import logging import logging
import uuid
from pathlib import Path from pathlib import Path
from aiohttp import web, WSMsgType 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__) log = logging.getLogger(__name__)
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" FRONTEND_DIR = frontend_dir()
DIST_DIR = FRONTEND_DIR / "dist" DIST_DIR = frontend_dist_dir()
INPUT_DIR = Path(__file__).parent.parent / "input" INPUT_DIR = input_dir()
OUTPUT_DIR = Path(__file__).parent.parent / "output" 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.node_registry import get_all_node_info
from backend.execution import ExecutionEngine, new_prompt_id from backend.execution import ExecutionEngine, new_prompt_id
INPUT_DIR.mkdir(exist_ok=True) ensure_runtime_dirs()
OUTPUT_DIR.mkdir(exist_ok=True)
engine = ExecutionEngine() engine = ExecutionEngine()
websockets: set[web.WebSocketResponse] = set() 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 # Serve Vite build output if available, else raw frontend
if (DIST_DIR / "index.html").exists(): if (DIST_DIR / "index.html").exists():
return web.FileResponse(DIST_DIR / "index.html") 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: async def get_nodes(request: web.Request) -> web.Response:
info = get_all_node_info() info = get_all_node_info()
@@ -244,9 +253,10 @@ def create_app(loop: asyncio.AbstractEventLoop) -> web.Application:
app.router.add_get("/ws", websocket_handler) app.router.add_get("/ws", websocket_handler)
# Serve frontend static files (Vite build or raw) # 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("/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) # CORS — allow any origin (local dev only)
async def _cors_middleware(app_, handler): 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": { "devDependencies": {
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"vite": "^5.4.0" "vite": "^5.4.0"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@@ -838,9 +842,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -855,9 +856,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -872,9 +870,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -889,9 +884,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -906,9 +898,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -923,9 +912,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -940,9 +926,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -957,9 +940,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -974,9 +954,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -991,9 +968,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1008,9 +982,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1025,9 +996,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1042,9 +1010,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [

View File

@@ -2,6 +2,10 @@
"name": "argonode-frontend", "name": "argonode-frontend",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
},
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "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"