diff --git a/.github/workflows/install-and-launch.yml b/.github/workflows/install-and-launch.yml
index 3fa1371..5b1c3ba 100644
--- a/.github/workflows/install-and-launch.yml
+++ b/.github/workflows/install-and-launch.yml
@@ -98,6 +98,95 @@ jobs:
- name: Launch smoke test (headless, no TTY)
run: ./launch_lucy.sh --headless ros2 doctor --report
+ # Windows-specific CI. NOTE: GitHub-hosted Windows runners cannot run Linux
+ # containers (no Hyper-V/nested virtualization, no WSL2), so the heavy install
+ # step (docker build of the ROS image + colcon) CANNOT run here — that is
+ # covered for both amd64/arm64 by the Linux `install-and-launch` job above.
+ # This job verifies the Windows-only pieces on real x64 AND arm64 hardware:
+ # - host CPU -> Docker platform detection (install_ops.host_container_platform)
+ # - Lucy.exe builds (PyInstaller) and its bundled --cli imports work
+ # - the NSIS installer compiles
+ windows-build-test:
+ name: Windows build & installer test (${{ matrix.arch }})
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - arch: x64
+ runner: windows-latest
+ expected_platform: linux/amd64
+ - arch: arm64
+ runner: windows-11-arm
+ expected_platform: linux/arm64
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 30
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.x'
+
+ - name: Generate releases manifest
+ run: python windows/generate_releases.py
+
+ - name: Verify host architecture detection
+ shell: pwsh
+ run: |
+ $expected = "${{ matrix.expected_platform }}"
+ $got = (python -c "import sys; sys.path.insert(0,'windows'); import install_ops; print(install_ops.host_container_platform())").Trim()
+ Write-Host "host_container_platform() -> $got (expected $expected)"
+ if ($got -ne $expected) {
+ Write-Error "Arch detection mismatch: got '$got', expected '$expected'"
+ exit 1
+ }
+
+ - name: Install PyInstaller
+ run: python -m pip install pyinstaller
+
+ - name: Build Lucy.exe
+ shell: pwsh
+ run: |
+ python -m PyInstaller --noconfirm --onefile --name Lucy `
+ --icon windows/assets/lucy-icon.ico `
+ --hidden-import install_ops `
+ --hidden-import install_runner `
+ --paths windows `
+ windows/Lucy.py
+ if (-not (Test-Path "dist/Lucy.exe")) { Write-Error "Lucy.exe not produced"; exit 1 }
+
+ - name: Smoke-test Lucy.exe CLI (bundled imports + prereq report)
+ shell: pwsh
+ run: |
+ dist\Lucy.exe --cli check-prereqs
+ $code = $LASTEXITCODE
+ Write-Host "check-prereqs exit code: $code"
+ # 0 (all present) or 1 (a prereq missing, e.g. no Docker on CI) both mean
+ # the exe ran fine; >1 = crash. Reset the propagated exit code explicitly.
+ if ($code -gt 1) { Write-Error "Lucy.exe --cli check-prereqs crashed (exit $code)"; exit 1 }
+ exit 0
+
+ - name: Install NSIS
+ run: choco install nsis -y --no-progress
+
+ - name: Build Lucy-Setup.exe (NSIS)
+ shell: pwsh
+ run: |
+ $makensis = "${env:ProgramFiles(x86)}\NSIS\makensis.exe"
+ & $makensis "/DMyAppVersion=0.0.0-ci" windows/installer/Lucy.nsi
+ if (-not (Test-Path "dist/Lucy-Setup-0.0.0-ci.exe")) { Write-Error "Installer not produced"; exit 1 }
+
+ - name: Upload Windows artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: lucy-windows-${{ matrix.arch }}
+ path: |
+ dist/Lucy.exe
+ dist/Lucy-Setup-0.0.0-ci.exe
+ if-no-files-found: error
+
build-and-release-windows-exe:
name: Build and Release Windows Executable
# Only run this job when a new tag is pushed
@@ -115,15 +204,37 @@ jobs:
with:
python-version: '3.x'
+ - name: Generate releases manifest
+ run: python windows/generate_releases.py
+
- name: Install PyInstaller
- run: pip install pyinstaller
+ run: python -m pip install pyinstaller
- - name: Build the executable
- run: pyinstaller --onefile --name Lucy windows/Lucy.py
+ - name: Build Lucy.exe
+ shell: pwsh
+ run: |
+ python -m PyInstaller --noconfirm --onefile --name Lucy `
+ --icon windows/assets/lucy-icon.ico `
+ --hidden-import install_ops `
+ --hidden-import install_runner `
+ --paths windows `
+ windows/Lucy.py
+
+ - name: Install NSIS
+ run: choco install nsis -y
+
+ - name: Build Lucy-Setup.exe
+ shell: pwsh
+ run: |
+ $version = "${{ github.ref_name }}" -replace '^v',''
+ if (-not $version) { $version = "0.0.0" }
+ $makensis = "${env:ProgramFiles(x86)}\NSIS\makensis.exe"
+ & $makensis "/DMyAppVersion=$version" windows/installer/Lucy.nsi
- - name: Create Release and Upload Asset
+ - name: Create Release and Upload Assets
uses: softprops/action-gh-release@v2
with:
- files: dist/Lucy.exe
- # This creates a draft release. Remove `draft: true` to publish it automatically.
+ files: |
+ dist/Lucy.exe
+ dist/Lucy-Setup-*.exe
draft: true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..b43f931
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,16 @@
+name: Trigger Release
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Version number'
+ required: true
+ type: string
+
+jobs:
+ create-release:
+ uses: Sentience-Robotics/.github/.github/workflows/release.yaml@master
+ with:
+ version: ${{ inputs.version }}
+
diff --git a/.gitignore b/.gitignore
index 5dfe0d2..f501042 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,3 +32,10 @@ __pycache__/
# Local repo override (forks/branches; takes precedence over config/repos.json)
config/repos.json.local
+
+# End-user install profile (written by installer / Lucy.exe)
+config/install.profile.json
+
+# Windows executable files
+dist/
+*.spec
\ No newline at end of file
diff --git a/Lucy.py b/Lucy.py
index 5630949..ff8558a 100644
--- a/Lucy.py
+++ b/Lucy.py
@@ -36,13 +36,13 @@ def set_dev_mode(is_enabled):
def run_command(command, interactive=False):
"""Runs a command.
-
+
If interactive is True, runs natively in the terminal.
"""
print(f"--- Running: {' '.join(command)} ---")
try:
if interactive:
- # Inherit standard IO to maintain terminal size and TTY functionality
+ # Inherit standard IO to maintain terminal size and TTY functionality
return subprocess.run(command).returncode
else:
# Popen is fine for non-interactive scripts like install/build
@@ -91,7 +91,7 @@ def main_tui(stdscr):
continue
prefix = "> " if current_idx == i else " "
-
+
if option == "Developer Mode":
checkbox = "[x]" if is_dev_mode else "[ ]"
stdscr.addstr(2 + i, 4, f"{prefix}{checkbox} {option}")
@@ -167,7 +167,7 @@ def check_initial_size():
if task.get("interactive", False):
print(f"--- Session finished with exit code {rc} ---")
break
-
+
task_name = task.get("name")
if task_name in ["Install", "Rebuild"] and rc == 0:
print(f"\n--- Task '{task_name}' finished successfully. ---")
diff --git a/README.md b/README.md
index cd3390c..4fcb6ac 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ Workspace bringup for the Lucy / InMoov humanoid. Everything (ROS 2 Humble, Gaze
Linux GUI forwarding uses `xhost` (preinstalled). On Wayland run `xhost +local:docker` if windows don't open — see [GUI](#gui-rviz-and-gazebo).
-> **Windows users:** see the [Windows README](windows/README.md) for step-by-step install instructions (including the Docker Desktop "uncheck WSL 2" note) and the native `windows/Lucy.py` manager.
+> **Windows users:** see the [Windows README](windows/README.md) — **`Lucy-Setup.exe`** to install/update, **`Lucy.exe`** to launch.
## Get the repository
@@ -48,13 +48,16 @@ python3 Lucy.py
### Windows
+**Installer (recommended):** download `Lucy-Setup.exe` from [GitHub Releases](https://github.com/Sentience-Robotics/lucy_ws/releases), then see the [Windows README](windows/README.md).
+
+**From source (developers):**
+
```bash
-python windows/Lucy.py
+python windows/Lucy.py --cli install # first time
+python windows/Lucy.py # launch
```
-> **Windows** additionally needs a third-party X Server — see the [Windows README](windows/README.md).
-
-> **First run:** in the TUI, choose **`Install / Update`** before anything else. It clones the sub-repositories, builds the Docker image and the workspace (this can take a while). Only once it finishes should you use **`Launch`**.
+> **Windows** additionally needs a third-party X Server for RViz/Gazebo — see the [Windows README](windows/README.md). End users should use **`Lucy-Setup.exe`** instead of manual install.
### Opening the Control Panel
diff --git a/config/launcher_config.json b/config/launcher_config.json
index 8a013a8..7e584ef 100644
--- a/config/launcher_config.json
+++ b/config/launcher_config.json
@@ -130,6 +130,8 @@
"dependencies": ["core"],
"conflicts": [],
"command": "ros2 run lucy_cli tui",
+ "readiness_check": "test -f /tmp/lucy_cli.ready",
+ "nav_hint": "Ctrl-B W",
"default_on": false
},
{
diff --git a/docs/developer_lucy_packages.md b/docs/developer_lucy_packages.md
index 40407d5..c992121 100644
--- a/docs/developer_lucy_packages.md
+++ b/docs/developer_lucy_packages.md
@@ -65,6 +65,18 @@ Use the same structure as `repos.json` — list only the repos you want to overr
Delete the file to fall back to the tracked `repos.json`.
+### Windows install profile (`config/install.profile.json`)
+
+On Windows, **`Lucy-Setup.exe`** (or `Lucy.exe --cli …`) writes **`config/install.profile.json`** (gitignored) to record install choices: `lucy_ws` version, `repos_branch` (default `master`), `fetch_method` (`git` or `zip`), and whether **developer install** was selected. The file is created automatically on first install.
+
+| Windows | Linux/macOS equivalent |
+|---------|------------------------|
+| `Lucy-Setup.exe` → Fresh install | `./install.sh` |
+| `Lucy-Setup.exe` → Update | `./install.sh` / `./install.sh --update` |
+| `Lucy-Setup.exe` → Repair | `./install.sh --repair` |
+| `Lucy.exe` (no args) | `./launch_lucy.sh` / **Launch** in `Lucy.py` |
+| `Lucy.exe --cli build-only` | `./install.sh --build-only` |
+
## `launch_lucy.sh`
Builds the Docker image if needed, mounts the workspace at `/workspace`, sources the built ROS overlay, then:
diff --git a/launcher.py b/launcher.py
index b36ef44..442382d 100644
--- a/launcher.py
+++ b/launcher.py
@@ -106,13 +106,22 @@ def _target():
pass
threading.Thread(target=_target, daemon=True).start()
-def _pane_dead(pkg_id):
- """True if the package's tmux window exists but its process has exited.
- remain-on-exit keeps the dead pane (and its error output) for debugging."""
- return run_shell_command(
- f"tmux list-panes -t lucy_ws:{pkg_id} -F '#{{pane_dead}}' 2>/dev/null | grep -q '^1$'",
- capture_output=True,
- )
+def _pane_exit_status(pkg_id):
+ """Exit code of the package's dead tmux pane, or None if it isn't dead.
+ remain-on-exit keeps the dead pane (and its output) so we can read the code:
+ 0 is a clean exit (STOPPED), anything else (incl. signal death) a crash (CRASHED)."""
+ out = subprocess.run(
+ f"tmux list-panes -t lucy_ws:{pkg_id} -F '#{{pane_dead}}:#{{pane_dead_status}}' 2>/dev/null",
+ shell=True, capture_output=True, text=True,
+ ).stdout
+ for line in out.splitlines():
+ dead, _, status = line.strip().partition(":")
+ if dead == "1":
+ try:
+ return int(status)
+ except ValueError:
+ return -1 # signal death reports no status; treat as a crash
+ return None
class Package:
def __init__(self, data, running_modifiers):
@@ -144,12 +153,15 @@ def __init__(self, data, running_modifiers):
# Access URL shown after [RUNNING] (control panel / VNC endpoints). May
# reference env vars as ${VAR} — expanded at render time.
self.url = data.get('url')
+ # Navigation hint for non-web packages (e.g. "Ctrl-B W" for tmux windows).
+ self.nav_hint = data.get('nav_hint', '')
# is_running = window/process exists; ready = readiness probe passed;
- # pane_dead = service window kept open after its process crashed.
+ # pane_dead = window kept open (remain-on-exit) after its process exited.
self.is_running = False
self.ready = False
self.pane_dead = False
+ self.pane_exit_status = None
self.update_running_status(running_modifiers)
# Robot-package radios are mutually exclusive
@@ -180,12 +192,10 @@ def update_running_status(self, running_modifiers):
else:
self.ready = True
- # A service whose window is still up (remain-on-exit) but whose process
- # has exited: reported as CRASHED while the error stays visible.
- self.pane_dead = (
- self.is_running and bool(self.readiness_check)
- and not self.ready and _pane_dead(self.id)
- )
+ # Window still up (remain-on-exit) but the process has exited.
+ # The exit code distinguishes a clean stop from a crash (see get_pkg_status).
+ self.pane_exit_status = _pane_exit_status(self.id) if self.is_running else None
+ self.pane_dead = self.pane_exit_status is not None
def is_complex_command(self):
return isinstance(self.command, dict)
@@ -270,7 +280,8 @@ def _disable_with_dependents(self, pkg):
def toggle(self, pkg_id):
pkg = self.get_by_id(pkg_id)
- if not pkg: return None
+ if not pkg:
+ return None
if pkg_id in _pkg_stop_times and not pkg.selected:
return "Still stopping…"
if not pkg.selected:
@@ -298,10 +309,13 @@ def get_pkg_status(pkg):
if time.time() - _pkg_stop_times[pkg.id] < STOPPING_TIMEOUT:
return "stopping"
_pkg_stop_times.pop(pkg.id, None) # gave up; fall through to real state
- # Service window left open by a crash (remain-on-exit): CRASHED right away,
- # no waiting for the loading timeout, so the error can be read in tmux.
+ # Window left open by an exited process (remain-on-exit).
+ # Reported right away so the output can be read in tmux: exit 0 is a clean stop, else a crash.
if pkg.pane_dead:
_pkg_start_times.pop(pkg.id, None)
+ if pkg.pane_exit_status == 0:
+ _intended_running.discard(pkg.id)
+ return "stopped"
return "crashed"
if pkg.ready:
_pkg_start_times.pop(pkg.id, None)
@@ -337,6 +351,12 @@ def _vnc_hint(pkg, state):
return "(VNC)"
return ""
+def _nav_hint(pkg):
+ """Navigation hint for packages without a web URL (e.g. tmux terminal windows)."""
+ if not pkg.nav_hint or not pkg.is_running:
+ return ""
+ return f"({pkg.nav_hint})"
+
def _status_url(pkg):
"""Expanded access URL for a package, or '' if it has none / an env var in it
is unset (so we never show a half-resolved 'localhost:${...}')."""
@@ -436,8 +456,9 @@ def draw_section(title, color, items, offset, gap=1, indent_all=False):
else:
indent = ""
status = get_pkg_status(p)
+ hint = _vnc_hint(p, state) or _nav_hint(p)
_draw_pkg_row(stdscr, row + i, 4, prefix, indent, checkbox, p.name, attr,
- status, _vnc_hint(p, state), _status_url(p))
+ status, hint, _status_url(p))
row += len(items) + 1
row = 2
@@ -542,11 +563,11 @@ def apply_changes(state):
else:
pkg.is_running = False
- # Second Pass: Start processes that should be turned on (never re-launch one
- # that is still shutting down). A crashed service (pane_dead) is relaunched
- # too, after reaping the dead window left open for debugging.
+ # Second Pass: Start processes that should be turned on (never re-launch one that is still shutting down).
+ # A crashed service (non-zero exit) is relaunched too, after reaping the dead window; a clean exit (STOPPED) is left alone.
for pkg in state.packages:
- if pkg.selected and pkg.id not in _pkg_stop_times and (not pkg.is_running or pkg.pane_dead):
+ crashed = pkg.pane_dead and pkg.pane_exit_status != 0
+ if pkg.selected and pkg.id not in _pkg_stop_times and (not pkg.is_running or crashed):
if pkg.pane_dead:
run_shell_command(f"tmux kill-window -t lucy_ws:{pkg.id} 2>/dev/null")
pkg.pane_dead = False
@@ -574,10 +595,17 @@ def apply_changes(state):
_pkg_start_times[mod.id] = time.time()
_intended_running.add(mod.id)
elif pkg.type in ['tool', 'interface']:
- run_shell_command(f"tmux new-window -d -t lucy_ws -n {pkg.id} '{pkg.command}; echo \"--- Process finished, press any key to close ---\"; read'")
- # Don't auto-switch to GUI tools that render on the VNC desktop
- # (e.g. rqt) — their window is in the viewer, not this terminal.
- if not pkg.runs_on_vnc:
+ if pkg.type == 'interface':
+ # remain-on-exit leaves the dead pane so the launcher can read the exit code (STOPPED vs CRASHED).
+ # A wrapper shell on `read` would keep the pane alive and mask the exit.
+ run_shell_command(
+ f"tmux new-window -d -t lucy_ws -n {pkg.id} '{pkg.command}'; "
+ f"tmux set-window-option -t lucy_ws:{pkg.id} remain-on-exit on"
+ )
+ else:
+ run_shell_command(f"tmux new-window -d -t lucy_ws -n {pkg.id} '{pkg.command}; echo \"--- Process finished, press any key to close ---\"; read'")
+ # Only auto-switch to tool windows (e.g. console), not interfaces which manage their own terminal visibility (lucy_cli, control_panel).
+ if not pkg.runs_on_vnc and pkg.type == 'tool':
last_launched_window = pkg.id
_pkg_start_times[pkg.id] = time.time()
_intended_running.add(pkg.id)
diff --git a/version.txt b/version.txt
new file mode 100644
index 0000000..8308b63
--- /dev/null
+++ b/version.txt
@@ -0,0 +1 @@
+v0.1.1
diff --git a/windows/Lucy.py b/windows/Lucy.py
index c399a23..0f4017b 100644
--- a/windows/Lucy.py
+++ b/windows/Lucy.py
@@ -1,63 +1,55 @@
-# This script provides a native Windows TUI for managing the Lucy workspace.
-# It replicates the logic of the .sh scripts by calling git and docker directly.
-# This script is designed to be compiled into a standalone .exe file.
+# Windows launcher for the Lucy workspace.
#
-# PREREQUISITES for running from source:
-# 1. Python 3
-# 2. Git for Windows (must be in your PATH)
-# 3. Docker Desktop for Windows (must be running)
+# Compiled to Lucy.exe via PyInstaller. Default behaviour: start the workspace
+# (Docker + tmux + launcher). Install/update/repair is handled by Lucy-Setup.exe
+# via the hidden --cli mode (see windows/install_runner.py).
#
+# PREREQUISITES:
+# 1. Docker Desktop for Windows (must be running)
+# 2. Workspace must be installed (run Lucy-Setup.exe first)
import os
import subprocess
import sys
-import json
-# --- Platform Check ---
if sys.platform != "win32":
print("Error: This script is designed for Windows only.", file=sys.stderr)
sys.exit(1)
-# --- Configuration ---
-# When running as a PyInstaller executable, the script is extracted to a temp folder.
-# We need to determine the project root relative to the executable's location.
+_WINDOWS_DIR = os.path.dirname(os.path.abspath(__file__))
+if _WINDOWS_DIR not in sys.path:
+ sys.path.insert(0, _WINDOWS_DIR)
+
+import install_ops # noqa: E402
+
if getattr(sys, 'frozen', False):
- # Running as a compiled executable
PROJECT_ROOT = os.path.dirname(sys.executable)
else:
- # Running as a .py script
- PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-
-ENV_FILE = os.path.join(PROJECT_ROOT, ".env")
-# config/repos.json.local (gitignored) overrides the tracked config/repos.json
-# so contributors can point repos at their own forks/branches. Falls back to
-# repos.json when no local override exists (mirrors install.sh).
-_REPOS_FILE_DEFAULT = os.path.join(PROJECT_ROOT, "config", "repos.json")
-_REPOS_FILE_LOCAL = os.path.join(PROJECT_ROOT, "config", "repos.json.local")
-REPOS_FILE = _REPOS_FILE_LOCAL if os.path.exists(_REPOS_FILE_LOCAL) else _REPOS_FILE_DEFAULT
-DOCKERFILE = os.path.join(PROJECT_ROOT, "Dockerfile.humble")
-IMAGE_NAME = "lucy_ros2:humble"
+ PROJECT_ROOT = os.path.dirname(_WINDOWS_DIR)
+
+IMAGE_NAME = install_ops.IMAGE_NAME
WORKSPACE_DIR_HOST = PROJECT_ROOT
-WORKSPACE_DIR_CONTAINER = "/workspace"
+WORKSPACE_DIR_CONTAINER = install_ops.WORKSPACE_CONTAINER
+
+ROSBRIDGE_PORT = 9090
+LCP_DEFAULT_PORT = 5000
+
+_CLI_MODES = frozenset(('install', 'update', 'repair', 'build-only', 'check-prereqs'))
-# --- Helper Functions ---
def run_command(command, check=True, interactive=False):
"""Runs a command, streaming its output if not interactive."""
print(f"--- Running: {' '.join(command)} ---")
try:
if interactive:
- # For interactive commands, run directly and attach to the terminal.
return subprocess.run(command, check=check).returncode
- else:
- # For non-interactive commands, capture and stream output.
- process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
- for line in iter(process.stdout.readline, ''):
- print(line.strip())
- process.wait()
- if check and process.returncode != 0:
- raise subprocess.CalledProcessError(process.returncode, command)
- return process.returncode
+ process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
+ for line in iter(process.stdout.readline, ''):
+ print(line.rstrip())
+ process.wait()
+ if check and process.returncode != 0:
+ raise subprocess.CalledProcessError(process.returncode, command)
+ return process.returncode
except FileNotFoundError:
print(f"Error: Command '{command[0]}' not found. Is it in your PATH?")
return -1
@@ -65,108 +57,8 @@ def run_command(command, check=True, interactive=False):
print(f"Command failed with exit code {e.returncode}")
return e.returncode
-def get_dev_mode():
- if not os.path.exists(ENV_FILE):
- return False
- with open(ENV_FILE, "r") as f:
- for line in f:
- if line.strip().startswith("DEV="):
- return line.strip().split("=")[1].lower() == "true"
- return False
-
-def set_dev_mode(is_enabled):
- lines = []
- dev_found = False
- if os.path.exists(ENV_FILE):
- with open(ENV_FILE, "r") as f:
- lines = f.readlines()
- with open(ENV_FILE, "w") as f:
- for line in lines:
- if line.strip().startswith("DEV="):
- f.write(f"DEV={str(is_enabled).lower()}\n")
- dev_found = True
- else:
- f.write(line)
- if not dev_found:
- f.write(f"DEV={str(is_enabled).lower()}\n")
-
-def _format_volume_mapping(host_path, container_path):
- """
- Return a Docker -v mapping string without extra quotes.
- Normalize host path to an absolute path and use forward slashes to avoid
- passing literal quote characters into the docker CLI.
- """
- host_abs = os.path.abspath(host_path)
- # Use forward slashes to reduce issues with escaping backslashes;
- # Docker Desktop accepts Windows-style paths with forward slashes.
- host_normalized = host_abs.replace('\\', '/')
- return f"{host_normalized}:{container_path}"
-
-# --- Core Logic Functions ---
-
-def clone_or_update_repos():
- """Clones or updates repositories based on repos.json."""
- is_dev = get_dev_mode()
- print(f"Developer mode is {'ON' if is_dev else 'OFF'}.")
-
- with open(REPOS_FILE, 'r') as f:
- repos = json.load(f)['repos']
-
- src_dir = os.path.join(PROJECT_ROOT, 'src')
- os.makedirs(src_dir, exist_ok=True)
-
- for repo in repos:
- repo_name = repo['name']
- repo_path = os.path.join(src_dir, repo_name)
- url_key = 'url_ssh' if is_dev else 'url_https'
- repo_url = repo[url_key]
- branch = repo['branch']
-
- if os.path.exists(os.path.join(repo_path, '.git')):
- print(f"Updating repo: {repo_name}")
- run_command(['git', '-C', repo_path, 'fetch'])
- run_command(['git', '-C', repo_path, 'checkout', branch])
- run_command(['git', '-C', repo_path, 'pull'])
- else:
- print(f"Cloning repo: {repo_name}")
- run_command(['git', 'clone', '-b', branch, repo_url, repo_path])
-
-def build_docker_image():
- """Builds the main Docker image."""
- print("Building Docker image...")
- run_command(['docker', 'build', '-t', IMAGE_NAME, '-f', DOCKERFILE, '.'], check=True)
-
-def build_workspace():
- """Runs the colcon build process inside the container."""
- print("Building workspace inside the container...")
- inner_cmd = (
- 'source /opt/ros/humble/setup.bash && '
- '[ -f /opt/gz_ros2_control_ws/install/setup.bash ] && source /opt/gz_ros2_control_ws/install/setup.bash; '
- 'cd /workspace && '
- 'rosdep install --from-paths src --ignore-src -r -y --skip-keys="audio_common" && '
- 'colcon build --symlink-install && '
- 'if [ -f src/lucy_control_panel/package.json ]; then '
- '(cd src/lucy_control_panel && yarn install); '
- 'fi'
- )
- volume_mapping = _format_volume_mapping(WORKSPACE_DIR_HOST, WORKSPACE_DIR_CONTAINER)
- # Do NOT include an extra 'bash' argument; the image sets ENTRYPOINT to /bin/bash.
- # Provide '-c' so the entrypoint receives the command string correctly.
- docker_cmd = [
- 'docker', 'run', '--rm',
- '-v', volume_mapping,
- IMAGE_NAME,
- '-c', inner_cmd
- ]
- run_command(docker_cmd)
-
-
-ROSBRIDGE_PORT = 9090
-LCP_DEFAULT_PORT = 5000
-
def _read_lcp_env_value(key):
- """Read a VITE_* value from src/lucy_control_panel/.env (last wins), or None."""
env_path = os.path.join(PROJECT_ROOT, 'src', 'lucy_control_panel', '.env')
if not os.path.exists(env_path):
return None
@@ -183,7 +75,6 @@ def _read_lcp_env_value(key):
def _lcp_container_port():
- """Container port Vite listens on (VITE_PORT in the LCP .env), default 5000."""
val = _read_lcp_env_value('VITE_PORT')
if val and val.isdigit():
return int(val)
@@ -191,7 +82,6 @@ def _lcp_container_port():
def _lcp_scheme():
- """Scheme the LCP serves on: https when VITE_HTTPS=true, else http."""
val = _read_lcp_env_value('VITE_HTTPS')
if val and val.strip().lower() == 'true':
return 'https'
@@ -199,10 +89,8 @@ def _lcp_scheme():
def _docker_gui_args():
- """Return Docker args for optional GUI/X11 forwarding."""
gui_display = os.environ.get('DOCKER_GUI_DISPLAY', os.environ.get('DISPLAY', '')).strip()
if sys.platform == 'win32' and not gui_display:
- # Docker Desktop can reach the Windows X server at host.docker.internal.
gui_display = 'host.docker.internal:0'
if not gui_display:
@@ -258,12 +146,16 @@ def _docker_gui_diagnostics(gui_display, gui_args):
" sys.exit(1)\n"
)
- docker_cmd = ['docker', 'run', '--rm'] + gui_args + [IMAGE_NAME, '-c', f'python3 -c "{python_check}"']
+ docker_cmd = ['docker', 'run', '--rm'] + install_ops.docker_run_platform_args(PROJECT_ROOT) + gui_args + [IMAGE_NAME, '-c', f'python3 -c "{python_check}"']
run_command(docker_cmd, check=False)
def launch_workspace():
- """Launches the main tmux session in the container."""
+ """Start Docker, attach to the Lucy Control Center launcher inside tmux."""
+ if not os.path.isfile(os.path.join(PROJECT_ROOT, 'install', 'setup.bash')):
+ print("Workspace not built. Run Lucy-Setup.exe to install or update first.", file=sys.stderr)
+ sys.exit(1)
+
print("Launching workspace...")
container_script = (
@@ -277,7 +169,7 @@ def launch_workspace():
"tmux attach-session -t lucy_ws"
)
- volume_mapping = _format_volume_mapping(WORKSPACE_DIR_HOST, WORKSPACE_DIR_CONTAINER)
+ volume_mapping = install_ops.format_volume_mapping(WORKSPACE_DIR_HOST, WORKSPACE_DIR_CONTAINER)
gui_args = _docker_gui_args()
display_value = os.environ.get('DOCKER_GUI_DISPLAY', os.environ.get('DISPLAY', ''))
if sys.platform == 'win32' and not display_value:
@@ -289,20 +181,17 @@ def launch_workspace():
else:
print("No DISPLAY configured; running without GUI.")
- # Publish the same container port Vite listens on, otherwise the URL the
- # launcher prints would silently point at the wrong port.
lcp_container_port = _lcp_container_port()
lcp_host_port = lcp_container_port
lcp_scheme = _lcp_scheme()
- # Remove the extra 'bash' token; pass '-c' so the ENTRYPOINT (/bin/bash) runs the script.
docker_cmd = [
'docker', 'run', '-it', '--rm',
+ *install_ops.docker_run_platform_args(PROJECT_ROOT),
'--name', 'lucy_dev_win',
'-p', f'{ROSBRIDGE_PORT}:9090',
'-p', f'{lcp_host_port}:{lcp_container_port}',
'-v', volume_mapping,
- # Mirror launch_lucy.sh: the launcher builds the LCP access URL from these.
'-e', f'LUCY_LCP_PUBLISHED_HOST_PORT={lcp_host_port}',
'-e', f'LUCY_LCP_CONTAINER_PORT={lcp_container_port}',
'-e', f'LUCY_LCP_SCHEME={lcp_scheme}',
@@ -310,75 +199,27 @@ def launch_workspace():
IMAGE_NAME,
'-c', container_script
]
-
+
run_command(docker_cmd, interactive=True)
-# --- Main TUI ---
-
-def main():
- while True:
- is_dev_mode = get_dev_mode()
- dev_status = "ON" if is_dev_mode else "OFF"
-
- print("\n--- Lucy Workspace Manager (Native Windows) ---")
- print("1. Launch")
- print("---------------------------------------------")
- print("2. Install/Update")
- print("3. Rebuild")
- print("4. Exit")
- print("---------------------------------------------")
- print(f"5. Developer Mode [{'x' if is_dev_mode else ' '}]")
-
- try:
- choice = input("\nEnter your choice (1-5) [1]: ").strip()
- except KeyboardInterrupt:
- break
-
- # Pressing Enter with no input defaults to Launch (option 1).
- if not choice:
- choice = '1'
-
- if choice == '1':
- launch_workspace()
-
- elif choice == '2':
- try:
- clone_or_update_repos()
- build_docker_image()
- build_workspace()
- print("\n--- Task 'Install' finished successfully. ---")
- input("Press Enter to return to the menu.")
- except Exception as e:
- print(f"Install failed: {e}")
- input("\nPress Enter to continue...")
-
- elif choice == '3':
- try:
- build_workspace()
- print("\n--- Task 'Rebuild' finished successfully. ---")
- input("Press Enter to return to the menu.")
- except Exception as e:
- print(f"Rebuild failed: {e}")
- input("\nPress Enter to continue...")
-
- elif choice == '4':
- break
-
- elif choice == '5':
- set_dev_mode(not is_dev_mode)
- print(f"Developer mode set to: {'ON' if not is_dev_mode else 'OFF'}")
- input("\nPress Enter to continue...")
-
- else:
- print("Invalid choice, please try again.")
- input("\nPress Enter to continue...")
+def _is_cli_invocation():
+ return len(sys.argv) > 1 and (sys.argv[1] == '--cli' or sys.argv[1] in _CLI_MODES)
+
+
+def _run_cli():
+ """Install/update/repair — used by Lucy-Setup.exe, not exposed in the default UX."""
+ from install_runner import main as install_main
+ argv = [a for a in sys.argv[1:] if a != '--cli']
+ return install_main(argv)
+
if __name__ == "__main__":
- # This needs to be at the top level for PyInstaller to see it.
os.chdir(PROJECT_ROOT)
try:
- main()
+ if _is_cli_invocation():
+ sys.exit(_run_cli())
+ launch_workspace()
except KeyboardInterrupt:
print("\nExiting.")
except Exception as e:
diff --git a/windows/README.md b/windows/README.md
index 9f7ad3d..df95db4 100644
--- a/windows/README.md
+++ b/windows/README.md
@@ -1,24 +1,24 @@
-# Windows Native TUI
+# Windows launcher and installer
-This directory contains a Windows-native version of the main TUI (`Lucy.py`). It provides the same functionality as the Linux/macOS script but is designed to be run directly on Windows.
+On Windows, Lucy is split into two programs:
-## How it Works
+| Program | Purpose |
+|---------|---------|
+| **`Lucy-Setup.exe`** | Install, update, repair, pick version, developer mode |
+| **`Lucy.exe`** | Launch the workspace (Docker → Lucy Control Center) |
-The script is a standalone Python application that calls `git.exe` and `docker.exe` directly. It does not have any external dependencies and can be run in a standard Windows Command Prompt or PowerShell.
-
-It can also be compiled into a single `.exe` file using a tool like PyInstaller.
+`windows/Lucy.py` is the PyInstaller source for `Lucy.exe`. It launches the workspace directly — there is no install menu. Use **`Lucy-Setup.exe`** for all install lifecycle tasks.
## Prerequisites
Install the following before running the project. After each installation, close and reopen any terminal so the updated `PATH` is picked up.
-1. **Python 3**: Download from [python.org/downloads](https://www.python.org/downloads/).
- - During setup, tick **"Add python.exe to PATH"** so `python` works from any terminal.
-2. **Git for Windows**: Download from [git-scm.com/install/windows](https://git-scm.com/install/windows).
- - The default options are fine; this also installs **Git Bash**.
-3. **Docker Desktop**: Download from [docs.docker.com/desktop/setup/install/windows-install](https://docs.docker.com/desktop/setup/install/windows-install/).
+1. **Docker Desktop**: Download from [docs.docker.com/desktop/setup/install/windows-install](https://docs.docker.com/desktop/setup/install/windows-install/).
- During installation, **uncheck "Use WSL 2 instead of Hyper-V"** unless you are an advanced/dev user who specifically needs the WSL 2 backend.
- - After install, **start Docker Desktop** and wait until it reports "running" before launching the project.
+ - After install, **start Docker Desktop** and wait until it reports "running" before launching Lucy.
+2. **Git for Windows** (optional but recommended): Download from [git-scm.com/install/windows](https://git-scm.com/install/windows).
+ - Without Git, the installer downloads sub-repositories as ZIP archives.
+3. **Python 3** (manual dev workflow only): Download from [python.org/downloads](https://www.python.org/downloads/).
4. **Windows X server** (optional): Required for GUI apps such as `rqt` inside the Docker container.
- We recommend [VcXsrv](https://github.com/marchaesen/vcxsrv/releases).
- Start VcXsrv on display `0`, allow TCP connections, and disable access control if needed.
@@ -26,66 +26,80 @@ Install the following before running the project. After each installation, close
> If you intend to solely use the control panel visualizer alongside command line tools, you can skip the installation of a third-party Windows X Server.
-## Installation
+### CPU architecture (x64 / ARM64)
-### 1. Get the repository
+The installer detects the host CPU automatically and builds the matching Docker image — `linux/amd64` on Intel/AMD PCs, `linux/arm64` on Windows-on-ARM devices. Native ARM detection works even though `Lucy.exe` itself is an x64 build running under emulation (it reads the true arch from `PROCESSOR_ARCHITEW6432`). To force a platform, set `LUCY_DOCKER_PLATFORM` (e.g. `linux/amd64`) before running, or drop a `.lucy-docker-platform` file in the install folder.
-You can either clone it with Git (recommended, makes updates easy) or download a ZIP.
+## Installation (end users)
-**Option A — Clone with Git (recommended):**
+Download **`Lucy-Setup.exe`** from the [GitHub Releases](https://github.com/Sentience-Robotics/lucy_ws/releases) page (built automatically on version tags).
-```bash
-git clone https://github.com/Sentience-Robotics/lucy_ws.git
-```
+The installer:
+
+- Installs Lucy to `%LOCALAPPDATA%\Programs\Lucy` (no admin required)
+- Creates a **Start Menu** shortcut to `Lucy.exe`
+- Lets you choose **Fresh install**, **Update**, or **Repair**
+- Lets you pick a **lucy_ws version** (latest `master` or a release tag)
+- **Always runs Install/Update** after setup (opens a console — clones sub-repos on `master`, builds Docker image and workspace, then launches Lucy)
+- Offers **Developer install** (off by default): requires Git, uses SSH clones and `DEV=true`
+
+After setup, open **Lucy** from the Start Menu — it launches the workspace directly.
-**Option B — Download the ZIP:**
+To **update** or **repair**, run **`Lucy-Setup.exe`** again and pick the matching install mode.
-- Open the repository page on GitHub, click the green **Code** button, then **Download ZIP**.
-- Extract the archive to a folder of your choice (for example `C:\Users\\lucy_ws`).
+### Control Panel
-### 2. Open a terminal and navigate to the project
+In the **Lucy Control Center**, enable **Core + Control Panel**. Once it is running, the **Lucy Control Panel is accessible in your browser at [http://localhost:5000](http://localhost:5000)** (or **http://localhost:5001** if port 5000 is already in use). The launcher also prints the exact URL next to the Control Panel entry once it is up.
-1. Open a terminal (see [Terminal choice](#terminal-choice) below). The quickest way: press **Win + R**, type `powershell`, and press Enter.
-2. Change into the folder where you cloned/extracted the repo using `cd`. For example:
+## Manual install (developers)
+
+Clone the repo, then run install via CLI (same logic as the installer):
```powershell
cd C:\Users\\lucy_ws
+python windows/Lucy.py --cli install --repos-branch master
```
-> Tip: in File Explorer you can open the folder, then type `powershell` in the address bar and press Enter to open a terminal already pointing at that folder.
-
-3. Confirm you are in the right place — you should see `windows`, `config`, `Dockerfile.humble`, etc.:
+Then launch:
```powershell
-dir
+python windows/Lucy.py
```
-### 3. Run the manager
+Or use the root Linux manager if you prefer WSL/Git Bash: `python3 Lucy.py` (full menu — see the main [README](../README.md)).
-From the project root, run the script using Python:
+### Advanced CLI (installer internals)
-```bash
-python windows/Lucy.py
+`Lucy.exe --cli` is used by `Lucy-Setup.exe` and available for scripting:
+
+```powershell
+Lucy.exe --cli check-prereqs
+Lucy.exe --cli install --repos-branch master
+Lucy.exe --cli update
+Lucy.exe --cli repair
+Lucy.exe --cli install --developer --refresh-workspace --lucy-ws-ref v1.0.0 --lucy-ws-ref-type tag
```
-**The first time, you must choose `Install / Update` in the menu** to clone the sub-repositories, build the Docker image, and build the workspace. This can take a while. Only after it completes should you use `Launch`.
+### Building the installer locally
-### 4. Launch and open the Control Panel
+Requires [NSIS](https://nsis.sourceforge.io/Download) and PyInstaller:
-Once the install has finished, run the manager again and choose **`Launch`**. In the **Lucy Control Center**, enable the **Control Panel** (and **Core**).
+```powershell
+powershell -ExecutionPolicy Bypass -File windows/build_installer.ps1
+```
-Once it is running, the **Lucy Control Panel is accessible in your browser at [http://localhost:5000](http://localhost:5000)** (or **http://localhost:5001** if port 5000 is already in use). The launcher also prints the exact URL next to the Control Panel entry once it is up.
+Outputs `dist\Lucy.exe` and `dist\Lucy-Setup-.exe`.
-## Usage
+### Application icon
-Running `python windows/Lucy.py` from the project root presents a simple numbered menu to manage the workspace (Launch, Install/Update, Rebuild, Developer Mode, Exit). Pressing Enter with no input defaults to **Launch**.
+The icon is [`windows/assets/lucy-icon.ico`](assets/lucy-icon.ico). To regenerate from a square logo JPG:
-**On a fresh setup, always run `Install / Update` first**, then `Launch`.
+```powershell
+pip install pillow
+python -c "from PIL import Image; Image.open('path\to\lucy-logo.jpg').save('windows/assets/lucy-icon.ico', sizes=[(256,256),(128,128),(64,64),(48,48),(32,32),(16,16)])"
+```
## Terminal choice
-To run the project, you will need to have access to a terminal, you have multiple choices:
-
-- Default "command" application, will require `windows/Lucy.py`
-- WSL, you will use the default `Lucy.py`. Be sure to enable WSL support in docker if you are using docker desktop
-- Git bash, you will use the default `Lucy.py`.
\ No newline at end of file
+- **Native Windows:** use `Lucy.exe` (installed) or `python windows/Lucy.py` (from a clone).
+- **WSL / Git Bash:** use the root `Lucy.py` and `install.sh` / `launch_lucy.sh` instead.
diff --git a/windows/assets/lucy-icon.ico b/windows/assets/lucy-icon.ico
new file mode 100644
index 0000000..5dce64a
Binary files /dev/null and b/windows/assets/lucy-icon.ico differ
diff --git a/windows/build_installer.ps1 b/windows/build_installer.ps1
new file mode 100644
index 0000000..adce087
--- /dev/null
+++ b/windows/build_installer.ps1
@@ -0,0 +1,61 @@
+# Build Lucy.exe and Lucy-Setup.exe on Windows.
+# Requires: Python 3, pip, PyInstaller, NSIS (https://nsis.sourceforge.io/Download)
+#
+# Usage (from repo root):
+# powershell -ExecutionPolicy Bypass -File windows/build_installer.ps1
+# powershell -ExecutionPolicy Bypass -File windows/build_installer.ps1 -Version 1.0.0
+
+param(
+ [string]$Version = ""
+)
+
+$ErrorActionPreference = "Stop"
+$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
+Set-Location $Root
+
+Write-Host "=== Generating releases manifest ==="
+python windows/generate_releases.py
+
+Write-Host "=== Building Lucy.exe (PyInstaller) ==="
+python -m pip install --quiet pyinstaller
+$icon = Join-Path $Root "windows\assets\lucy-icon.ico"
+if (-not (Test-Path $icon)) {
+ Write-Error "Missing icon: $icon"
+}
+python -m PyInstaller --noconfirm --onefile --name Lucy `
+ --icon $icon `
+ --hidden-import install_ops `
+ --hidden-import install_runner `
+ --paths (Join-Path $Root "windows") `
+ (Join-Path $Root "windows\Lucy.py")
+
+if (-not (Test-Path "dist\Lucy.exe")) {
+ Write-Error "PyInstaller did not produce dist\Lucy.exe"
+}
+
+$MakeNsis = @(
+ (Get-Command makensis -ErrorAction SilentlyContinue).Source,
+ "${env:ProgramFiles(x86)}\NSIS\makensis.exe",
+ "$env:ProgramFiles\NSIS\makensis.exe"
+) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
+
+if (-not $MakeNsis) {
+ Write-Warning "NSIS (makensis) not found. Lucy.exe is at dist\Lucy.exe"
+ Write-Warning "Install NSIS to build Lucy-Setup.exe: https://nsis.sourceforge.io/Download"
+ exit 0
+}
+
+if (-not $Version) {
+ try {
+ $tag = git describe --tags --exact-match 2>$null
+ if ($tag -match '^v(.+)$') { $Version = $Matches[1] }
+ } catch {}
+}
+if (-not $Version) { $Version = "0.0.0-dev" }
+
+Write-Host "=== Building Lucy-Setup.exe (NSIS, version $Version) ==="
+& $MakeNsis "/DMyAppVersion=$Version" (Join-Path $Root "windows\installer\Lucy.nsi")
+
+Write-Host "=== Done ==="
+Write-Host " dist\Lucy.exe"
+Write-Host " dist\Lucy-Setup-$Version.exe"
diff --git a/windows/generate_releases.py b/windows/generate_releases.py
new file mode 100644
index 0000000..944aa65
--- /dev/null
+++ b/windows/generate_releases.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+"""Generate windows/releases.json from lucy_ws git tags (run at installer build time)."""
+
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+
+def _escape_nsis(text: str) -> str:
+ """Escape a string for an NSIS double-quoted argument."""
+ return text.replace('$', '$$').replace('"', '$\\"')
+
+
+def write_nsh(out_nsh: Path, releases: list[dict]) -> None:
+ """Emit an NSIS include providing macro LUCY_ADD_RELEASES ."""
+ lines = [
+ "; Auto-generated by windows/generate_releases.py — do not edit.",
+ "!define LUCY_RELEASES_INCLUDED",
+ "!macro LUCY_ADD_RELEASES HWND",
+ ]
+ for rel in releases:
+ lines.append(f' ${{NSD_CB_AddString}} ${{HWND}} "{_escape_nsis(rel["label"])}"')
+ lines.append("!macroend")
+ out_nsh.write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
+def main() -> int:
+ root = Path(__file__).resolve().parents[1]
+ out = root / "windows" / "releases.json"
+ out_nsh = root / "windows" / "releases.nsh"
+
+ try:
+ result = subprocess.run(
+ ["git", "tag", "-l", "v*"],
+ cwd=root,
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ tags = sorted(
+ [t.strip() for t in result.stdout.splitlines() if t.strip()],
+ reverse=True,
+ )
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ tags = []
+
+ releases = [
+ {
+ "id": "latest",
+ "label": "Latest (master)",
+ "ref": "master",
+ "ref_type": "branch",
+ "recommended": True,
+ }
+ ]
+ for tag in tags:
+ releases.append({
+ "id": tag,
+ "label": tag,
+ "ref": tag,
+ "ref_type": "tag",
+ "recommended": False,
+ })
+
+ payload = {"generated_by": "windows/generate_releases.py", "releases": releases}
+ out.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
+ print(f"Wrote {out} ({len(releases)} entries)")
+
+ write_nsh(out_nsh, releases)
+ print(f"Wrote {out_nsh} ({len(releases)} entries)")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/windows/install_ops.py b/windows/install_ops.py
new file mode 100644
index 0000000..307d72d
--- /dev/null
+++ b/windows/install_ops.py
@@ -0,0 +1,667 @@
+"""
+Shared install logic for Lucy on Windows.
+
+Used by windows/Lucy.py (launcher + CLI) and the NSIS installer via install_runner.py.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import platform
+import shutil
+import subprocess
+import sys
+import tempfile
+import urllib.request
+import zipfile
+from datetime import datetime, timezone
+from typing import Callable, Optional
+
+# Official install documentation (keep in sync with windows/README.md).
+REQUIREMENT_DOCS = {
+ "python": ("Python 3", "https://www.python.org/downloads/"),
+ "git": ("Git for Windows", "https://git-scm.com/install/windows"),
+ "docker": ("Docker Desktop", "https://docs.docker.com/desktop/setup/install/windows-install/"),
+ "xserver": ("Windows X server (optional)", "https://github.com/marchaesen/vcxsrv/releases"),
+}
+
+LUCY_WS_GITHUB = "Sentience-Robotics/lucy_ws"
+DEFAULT_REPOS_BRANCH = "master"
+IMAGE_NAME = "lucy_ros2:humble"
+WORKSPACE_CONTAINER = "/workspace"
+DOCKER_PLATFORM_FILE = ".lucy-docker-platform"
+DOCKER_IMAGE_LABEL = "lucy.dockerfile.sha256"
+
+InstallMode = str # "install" | "update" | "repair" | "build-only"
+
+
+class PrerequisiteError(Exception):
+ """Raised when required prerequisites are missing."""
+
+ def __init__(self, issues: list[dict]):
+ self.issues = issues
+ super().__init__(self._format_issues(issues))
+
+ @staticmethod
+ def _format_issues(issues: list[dict]) -> str:
+ lines = []
+ for item in issues:
+ if item.get("url"):
+ lines.append(f"Missing {item['name']}. Install it: {item['url']}")
+ else:
+ lines.append(f"Missing {item['name']}: {item.get('detail', '')}")
+ return "\n".join(lines)
+
+
+def _repos_config_path(project_root: str) -> str:
+ local_path = os.path.join(project_root, "config", "repos.json.local")
+ default_path = os.path.join(project_root, "config", "repos.json")
+ return local_path if os.path.exists(local_path) else default_path
+
+
+def install_profile_path(project_root: str) -> str:
+ return os.path.join(project_root, "config", "install.profile.json")
+
+
+def load_install_profile(project_root: str) -> dict:
+ path = install_profile_path(project_root)
+ if not os.path.exists(path):
+ return {}
+ with open(path, "r", encoding="utf-8") as f:
+ return json.load(f)
+
+
+def save_install_profile(project_root: str, profile: dict) -> None:
+ path = install_profile_path(project_root)
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, "w", encoding="utf-8") as f:
+ json.dump(profile, f, indent=2)
+ f.write("\n")
+
+
+def default_profile(developer: bool = False, fetch_method: str = "git") -> dict:
+ return {
+ "lucy_ws_ref": "master",
+ "lucy_ws_ref_type": "branch",
+ "repos_branch": DEFAULT_REPOS_BRANCH,
+ "fetch_method": fetch_method,
+ "developer": developer,
+ "installed_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
+ }
+
+
+def merge_profile(project_root: str, **overrides) -> dict:
+ profile = default_profile()
+ profile.update(load_install_profile(project_root))
+ profile.update({k: v for k, v in overrides.items() if v is not None})
+ return profile
+
+
+def _run_quiet(cmd: list[str], timeout: int = 30) -> subprocess.CompletedProcess:
+ return subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ check=False,
+ )
+
+
+def git_available() -> bool:
+ try:
+ result = _run_quiet(["git", "--version"])
+ return result.returncode == 0
+ except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
+ return False
+
+
+def docker_available() -> bool:
+ try:
+ result = _run_quiet(["docker", "version"])
+ return result.returncode == 0
+ except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
+ return False
+
+
+def python_available() -> bool:
+ try:
+ result = _run_quiet([sys.executable, "--version"])
+ return result.returncode == 0
+ except OSError:
+ return False
+
+
+def git_identity_warnings() -> list[str]:
+ warnings = []
+ for key, label in (("user.name", "user.name"), ("user.email", "user.email")):
+ result = _run_quiet(["git", "config", "--global", key])
+ if result.returncode != 0 or not result.stdout.strip():
+ warnings.append(f"Git {label} is not set (needed only if you commit changes).")
+ return warnings
+
+
+def check_prerequisites(
+ developer: bool = False,
+ require_python: bool = False,
+) -> tuple[list[dict], list[str]]:
+ """
+ Return (blocking_issues, warnings).
+ Each blocking issue: {id, name, url, detail}.
+ """
+ issues: list[dict] = []
+ warnings: list[str] = []
+
+ if require_python and not python_available():
+ name, url = REQUIREMENT_DOCS["python"]
+ issues.append({"id": "python", "name": name, "url": url, "detail": "python not found"})
+
+ if not docker_available():
+ name, url = REQUIREMENT_DOCS["docker"]
+ issues.append({
+ "id": "docker",
+ "name": name,
+ "url": url,
+ "detail": "docker not found or Docker Desktop is not running",
+ })
+
+ if developer or not git_available():
+ if not git_available():
+ name, url = REQUIREMENT_DOCS["git"]
+ entry = {"id": "git", "name": name, "url": url, "detail": "git not found in PATH"}
+ if developer:
+ issues.append(entry)
+ else:
+ warnings.append(
+ f"{name} not found; sub-repositories will be downloaded as ZIP archives. "
+ f"Install Git for full update support: {url}"
+ )
+ elif developer:
+ warnings.extend(git_identity_warnings())
+
+ return issues, warnings
+
+
+def print_prerequisite_report(issues: list[dict], warnings: list[str]) -> None:
+ for item in issues:
+ print(f"ERROR: Missing {item['name']}. Install it: {item.get('url', '')}")
+ if item.get("detail"):
+ print(f" {item['detail']}")
+ for msg in warnings:
+ print(f"WARNING: {msg}")
+
+
+def require_prerequisites(developer: bool = False, require_python: bool = False) -> None:
+ issues, warnings = check_prerequisites(developer=developer, require_python=require_python)
+ print_prerequisite_report(issues, warnings)
+ if issues:
+ raise PrerequisiteError(issues)
+
+
+def parse_repos(project_root: str, developer: bool, repos_branch: Optional[str] = None) -> list[dict]:
+ config_path = _repos_config_path(project_root)
+ with open(config_path, "r", encoding="utf-8") as f:
+ data = json.load(f)
+
+ repos = []
+ for repo in data.get("repos", []):
+ name = repo.get("name", "").strip()
+ if not name:
+ continue
+ branch = repos_branch or repo.get("branch", DEFAULT_REPOS_BRANCH)
+ url_https = (repo.get("url_https") or repo.get("url") or "").strip()
+ url_ssh = (repo.get("url_ssh") or "").strip()
+ url = (url_ssh or url_https) if developer else (url_https or url_ssh)
+ if url:
+ repos.append({"name": name, "branch": branch, "url": url})
+ return repos
+
+
+def _github_slug_from_url(url: str) -> str:
+ url = url.rstrip("/")
+ if url.endswith(".git"):
+ url = url[:-4]
+ if "github.com/" in url:
+ return url.split("github.com/", 1)[1]
+ raise ValueError(f"Unsupported repository URL (expected GitHub HTTPS): {url}")
+
+
+def github_zip_url(url: str, ref: str, ref_type: str = "branch") -> str:
+ slug = _github_slug_from_url(url)
+ if ref_type == "tag":
+ return f"https://github.com/{slug}/archive/refs/tags/{ref}.zip"
+ return f"https://github.com/{slug}/archive/refs/heads/{ref}.zip"
+
+
+def _safe_rmtree(path: str) -> None:
+ if os.path.isdir(path):
+ shutil.rmtree(path, ignore_errors=True)
+ elif os.path.exists(path):
+ os.remove(path)
+
+
+def remove_workspace_src_repo(project_root: str, name: str, run_command: Callable) -> None:
+ src_path = os.path.join(project_root, "src", name)
+ _safe_rmtree(src_path)
+ volume = format_volume_mapping(project_root, WORKSPACE_CONTAINER)
+ run_command(
+ [
+ "docker", "run", "--rm",
+ "-v", volume,
+ IMAGE_NAME,
+ "-c", f"rm -rf {WORKSPACE_CONTAINER}/src/{name}",
+ ],
+ check=False,
+ )
+
+
+def _extract_zip_to_dest(zip_path: str, dest: str, repo_name: str) -> None:
+ with zipfile.ZipFile(zip_path, "r") as zf:
+ top_levels = {name.split("/")[0] for name in zf.namelist() if name.strip()}
+ zf.extractall(dest)
+ if len(top_levels) != 1:
+ raise RuntimeError(f"Unexpected archive layout for {repo_name}: {top_levels}")
+ extracted = os.path.join(dest, next(iter(top_levels)))
+ final = os.path.join(dest, repo_name)
+ if os.path.exists(final):
+ _safe_rmtree(final)
+ os.rename(extracted, final)
+
+
+def fetch_repo_zip(
+ repo_name: str,
+ url: str,
+ branch: str,
+ dest: str,
+ log: Callable[[str], None],
+) -> None:
+ zip_url = github_zip_url(url, branch, ref_type="branch")
+ log(f"Downloading {repo_name} from {zip_url}")
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
+ with tempfile.TemporaryDirectory() as tmp:
+ zip_path = os.path.join(tmp, f"{repo_name}.zip")
+ urllib.request.urlretrieve(zip_url, zip_path)
+ parent = os.path.dirname(dest)
+ _extract_zip_to_dest(zip_path, parent, repo_name)
+
+
+def fetch_repo_git(
+ repo_name: str,
+ url: str,
+ branch: str,
+ dest: str,
+ mode: str,
+ run_command: Callable,
+ log: Callable[[str], None],
+) -> None:
+ git_dir = os.path.join(dest, ".git")
+ if mode == "repair" or not os.path.isdir(git_dir):
+ if os.path.exists(dest):
+ _safe_rmtree(dest)
+ log(f"Cloning {repo_name} (branch {branch}) ...")
+ run_command(["git", "clone", "-b", branch, url, dest])
+ return
+
+ log(f"Updating {repo_name} (branch {branch}) ...")
+ run_command(["git", "-C", dest, "fetch", "origin"])
+ checkout = run_command(["git", "-C", dest, "checkout", branch], check=False)
+ if checkout != 0:
+ run_command(["git", "-C", dest, "checkout", "-b", branch, f"origin/{branch}"])
+ pull = run_command(["git", "-C", dest, "pull", "--ff-only", "origin", branch], check=False)
+ if pull != 0:
+ raise RuntimeError(
+ f"Cannot fast-forward {repo_name} on {branch}. "
+ "Merge/rebase locally or run Repair."
+ )
+
+
+def fetch_repo(
+ repo_name: str,
+ url: str,
+ branch: str,
+ dest: str,
+ *,
+ mode: str,
+ fetch_method: str,
+ developer: bool,
+ run_command: Callable,
+ log: Callable[[str], None],
+) -> str:
+ """Fetch a repo; returns effective fetch_method used ('git' or 'zip')."""
+ use_git = fetch_method == "git" and git_available()
+ if developer and not use_git:
+ raise PrerequisiteError([{
+ "id": "git",
+ "name": REQUIREMENT_DOCS["git"][0],
+ "url": REQUIREMENT_DOCS["git"][1],
+ "detail": "Developer install requires Git",
+ }])
+
+ if use_git:
+ fetch_repo_git(repo_name, url, branch, dest, mode, run_command, log)
+ return "git"
+
+ if mode != "repair" and os.path.isdir(os.path.join(dest, ".git")):
+ log(f"Keeping existing git checkout for {repo_name} (git not available).")
+ return "git"
+
+ if os.path.exists(dest):
+ _safe_rmtree(dest)
+ fetch_repo_zip(repo_name, url, branch, dest, log)
+ return "zip"
+
+
+def install_repos(
+ project_root: str,
+ mode: InstallMode,
+ *,
+ developer: bool,
+ repos_branch: Optional[str],
+ fetch_method: str,
+ run_command: Callable,
+ log: Callable[[str], None] = print,
+) -> str:
+ """Install/update/repair sub-repositories. Returns effective fetch_method."""
+ if mode == "build-only":
+ return fetch_method
+
+ repos = parse_repos(project_root, developer, repos_branch)
+ if not repos:
+ raise RuntimeError("No repositories defined in config/repos.json")
+
+ src_dir = os.path.join(project_root, "src")
+ os.makedirs(src_dir, exist_ok=True)
+
+ effective_method = fetch_method
+ for repo in repos:
+ name = repo["name"]
+ dest = os.path.join(src_dir, name)
+ if mode == "repair":
+ log(f"Repair: removing src/{name} ...")
+ remove_workspace_src_repo(project_root, name, run_command)
+
+ used = fetch_repo(
+ name,
+ repo["url"],
+ repo["branch"],
+ dest,
+ mode=mode,
+ fetch_method=effective_method,
+ developer=developer,
+ run_command=run_command,
+ log=log,
+ )
+ if used == "zip":
+ effective_method = "zip"
+
+ if effective_method == "zip" and mode == "update":
+ log("NOTE: ZIP-based install — local changes under src/ were replaced.")
+
+ return effective_method
+
+
+def format_volume_mapping(host_path: str, container_path: str) -> str:
+ host_abs = os.path.abspath(host_path)
+ return host_abs.replace("\\", "/") + ":" + container_path
+
+
+def _native_machine() -> str:
+ """Best-effort *native* CPU arch, seeing through Windows x64 emulation.
+
+ A 64-bit x86 build of Lucy.exe runs emulated on Windows ARM, where
+ platform.machine() reports AMD64. PROCESSOR_ARCHITEW6432 holds the true
+ native arch in that WOW64/emulation case; fall back to the normal vars.
+ """
+ if sys.platform == "win32":
+ for var in ("PROCESSOR_ARCHITEW6432", "PROCESSOR_ARCHITECTURE"):
+ value = os.environ.get(var, "").strip().lower()
+ if value:
+ return value
+ return platform.machine().lower()
+
+
+def host_container_platform() -> str:
+ """Map the host CPU to a Docker Linux platform (mirrors docker/ensure_image.sh)."""
+ machine = _native_machine()
+ if machine in ("x86_64", "amd64", "x86"):
+ return "linux/amd64"
+ if machine in ("aarch64", "arm64"):
+ return "linux/arm64"
+ return f"linux/{machine}"
+
+
+def workspace_target_platform(project_root: str) -> str:
+ """Target platform: LUCY_DOCKER_PLATFORM, then .lucy-docker-platform, then host arch."""
+ override = os.environ.get("LUCY_DOCKER_PLATFORM", "").strip()
+ if override:
+ return override
+ marker = os.path.join(project_root, DOCKER_PLATFORM_FILE)
+ if os.path.isfile(marker):
+ try:
+ with open(marker, "r", encoding="utf-8") as f:
+ value = f.readline().strip()
+ if value:
+ return value
+ except OSError:
+ pass
+ return host_container_platform()
+
+
+def _platform_build_settings(target_platform: str) -> tuple[str, int, int]:
+ """Return (base_image, bootstrap_desktop, install_vnc) for a target platform.
+
+ osrf/ros:humble-desktop is amd64-only on Docker Hub; on arm64 use the
+ multi-arch ros:humble-ros-base-jammy and apt-install ros-humble-desktop.
+ """
+ if target_platform == "linux/arm64":
+ base_image, bootstrap_desktop, install_vnc = "ros:humble-ros-base-jammy", 1, 1
+ else:
+ base_image, bootstrap_desktop, install_vnc = "osrf/ros:humble-desktop", 0, 0
+ if os.environ.get("LUCY_FORCE_VNC", "").strip().lower() in ("1", "true", "yes"):
+ install_vnc = 1
+ return base_image, bootstrap_desktop, install_vnc
+
+
+def _dockerfile_build_hash(dockerfile: str) -> str:
+ """sha256 of the Dockerfile ignoring comments/blank lines (mirrors ensure_image.sh)."""
+ kept = []
+ with open(dockerfile, "r", encoding="utf-8") as f:
+ for line in f:
+ line = line.rstrip("\n")
+ stripped = line.strip()
+ if not stripped or stripped.startswith("#"):
+ continue
+ kept.append(line.rstrip())
+ payload = ("\n".join(kept) + "\n").encode("utf-8")
+ return hashlib.sha256(payload).hexdigest()
+
+
+def _current_image_label(image_name: str) -> Optional[str]:
+ try:
+ result = subprocess.run(
+ ["docker", "image", "inspect", image_name,
+ "--format", '{{index .Config.Labels "' + DOCKER_IMAGE_LABEL + '"}}'],
+ capture_output=True, text=True,
+ )
+ except FileNotFoundError:
+ return None
+ if result.returncode != 0:
+ return None
+ return result.stdout.strip()
+
+
+def docker_run_platform_args(project_root: str) -> list[str]:
+ """`--platform ` so the daemon never guesses the run architecture."""
+ return ["--platform", workspace_target_platform(project_root)]
+
+
+def build_docker_image(
+ project_root: str,
+ run_command: Callable,
+ log: Callable[[str], None] = print,
+ force_rebuild: bool = False,
+) -> None:
+ dockerfile = os.path.join(project_root, "Dockerfile.humble")
+ target_platform = workspace_target_platform(project_root)
+ base_image, bootstrap_desktop, install_vnc = _platform_build_settings(target_platform)
+ build_hash = _dockerfile_build_hash(dockerfile)
+ want_label = f"{build_hash}|{target_platform}|vnc={install_vnc}"
+
+ if not force_rebuild and _current_image_label(IMAGE_NAME) == want_label:
+ log(f"Docker image {IMAGE_NAME} is up to date ({target_platform}); skipping build.")
+ return
+
+ log(f"Building Docker image for {target_platform} (base: {base_image})...")
+ run_command([
+ "docker", "build",
+ "--platform", target_platform,
+ "-f", dockerfile,
+ "--build-arg", f"LUCY_FROM_PLATFORM={target_platform}",
+ "--build-arg", f"LUCY_BASE_IMAGE={base_image}",
+ "--build-arg", f"LUCY_BOOTSTRAP_DESKTOP={bootstrap_desktop}",
+ "--build-arg", f"LUCY_INSTALL_VNC={install_vnc}",
+ "--build-arg", f"DOCKERFILE_SHA256={build_hash}",
+ "--build-arg", f"LUCY_DOCKER_BUILD_PLATFORM={target_platform}",
+ "-t", IMAGE_NAME,
+ project_root,
+ ])
+
+
+def build_workspace(project_root: str, run_command: Callable, log: Callable[[str], None] = print) -> None:
+ log("Building workspace inside the container...")
+ inner_cmd = (
+ "source /opt/ros/humble/setup.bash && "
+ "[ -f /opt/gz_ros2_control_ws/install/setup.bash ] && "
+ "source /opt/gz_ros2_control_ws/install/setup.bash; "
+ "cd /workspace && "
+ 'rosdep install --from-paths src --ignore-src -r -y --skip-keys="audio_common thais_urdf" && '
+ "rm -rf build/camera_ros install/camera_ros && "
+ "colcon build --symlink-install && "
+ 'if [ -f src/lucy_control_panel/package.json ]; then '
+ "(cd src/lucy_control_panel && yarn install); "
+ "fi"
+ )
+ volume = format_volume_mapping(project_root, WORKSPACE_CONTAINER)
+ run_command([
+ "docker", "run", "--rm",
+ *docker_run_platform_args(project_root),
+ "-v", volume,
+ IMAGE_NAME,
+ "-c", inner_cmd,
+ ])
+
+
+def set_dev_mode(project_root: str, enabled: bool) -> None:
+ env_path = os.path.join(project_root, ".env")
+ lines: list[str] = []
+ dev_found = False
+ if os.path.exists(env_path):
+ with open(env_path, "r", encoding="utf-8") as f:
+ lines = f.readlines()
+ with open(env_path, "w", encoding="utf-8") as f:
+ for line in lines:
+ if line.strip().startswith("DEV="):
+ f.write(f"DEV={str(enabled).lower()}\n")
+ dev_found = True
+ else:
+ f.write(line)
+ if not dev_found:
+ f.write(f"DEV={str(enabled).lower()}\n")
+
+
+def run_install_flow(
+ project_root: str,
+ mode: InstallMode,
+ *,
+ developer: Optional[bool] = None,
+ repos_branch: Optional[str] = None,
+ fetch_method: Optional[str] = None,
+ run_command: Callable,
+ log: Callable[[str], None] = print,
+) -> dict:
+ """Full install/update/repair/build-only flow. Returns updated install profile."""
+ profile = merge_profile(project_root)
+ if developer is not None:
+ profile["developer"] = developer
+ if repos_branch is not None:
+ profile["repos_branch"] = repos_branch
+ if fetch_method is not None:
+ profile["fetch_method"] = fetch_method
+
+ dev = bool(profile.get("developer", False))
+ require_prerequisites(developer=dev)
+
+ if mode != "build-only":
+ effective = install_repos(
+ project_root,
+ mode,
+ developer=dev,
+ repos_branch=profile.get("repos_branch", DEFAULT_REPOS_BRANCH),
+ fetch_method=profile.get("fetch_method", "git" if git_available() else "zip"),
+ run_command=run_command,
+ log=log,
+ )
+ profile["fetch_method"] = effective
+
+ set_dev_mode(project_root, dev)
+ build_docker_image(project_root, run_command, log, force_rebuild=(mode == "repair"))
+
+ if mode in ("install", "update", "repair", "build-only"):
+ build_workspace(project_root, run_command, log)
+
+ profile["installed_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+ save_install_profile(project_root, profile)
+ return profile
+
+
+def fetch_lucy_ws_snapshot(
+ project_root: str,
+ ref: str,
+ ref_type: str,
+ *,
+ fetch_method: str,
+ run_command: Callable,
+ log: Callable[[str], None] = print,
+) -> None:
+ """Refresh lucy_ws workspace files from GitHub at ref (branch or tag)."""
+ url = f"https://github.com/{LUCY_WS_GITHUB}.git"
+ use_git = fetch_method == "git" and git_available()
+
+ if use_git and os.path.isdir(os.path.join(project_root, ".git")):
+ log(f"Updating lucy_ws to {ref} ...")
+ run_command(["git", "-C", project_root, "fetch", "origin"])
+ run_command(["git", "-C", project_root, "checkout", ref])
+ run_command(["git", "-C", project_root, "pull", "--ff-only", "origin", ref], check=False)
+ return
+
+ zip_url = (
+ f"https://github.com/{LUCY_WS_GITHUB}/archive/refs/tags/{ref}.zip"
+ if ref_type == "tag"
+ else f"https://github.com/{LUCY_WS_GITHUB}/archive/refs/heads/{ref}.zip"
+ )
+ log(f"Downloading lucy_ws snapshot from {zip_url}")
+ with tempfile.TemporaryDirectory() as tmp:
+ zip_path = os.path.join(tmp, "lucy_ws.zip")
+ urllib.request.urlretrieve(zip_url, zip_path)
+ extract_root = os.path.join(tmp, "extract")
+ os.makedirs(extract_root, exist_ok=True)
+ with zipfile.ZipFile(zip_path, "r") as zf:
+ top_levels = {n.split("/")[0] for n in zf.namelist() if n.strip()}
+ zf.extractall(extract_root)
+ if len(top_levels) != 1:
+ raise RuntimeError(f"Unexpected lucy_ws archive layout: {top_levels}")
+ source = os.path.join(extract_root, next(iter(top_levels)))
+ for name in os.listdir(source):
+ if name in (".git", "src", "build", "install", "log"):
+ continue
+ src = os.path.join(source, name)
+ dst = os.path.join(project_root, name)
+ if os.path.isdir(dst):
+ _safe_rmtree(dst)
+ elif os.path.exists(dst):
+ os.remove(dst)
+ if os.path.isdir(src):
+ shutil.copytree(src, dst)
+ else:
+ shutil.copy2(src, dst)
diff --git a/windows/install_runner.py b/windows/install_runner.py
new file mode 100644
index 0000000..3c01fc7
--- /dev/null
+++ b/windows/install_runner.py
@@ -0,0 +1,146 @@
+#!/usr/bin/env python3
+"""CLI entry point for Lucy Windows install flows (used by Lucy.exe and the NSIS installer)."""
+
+from __future__ import annotations
+
+import argparse
+import os
+import sys
+
+# Allow running as script from repo: python windows/install_runner.py
+_WINDOWS_DIR = os.path.dirname(os.path.abspath(__file__))
+if _WINDOWS_DIR not in sys.path:
+ sys.path.insert(0, _WINDOWS_DIR)
+
+import install_ops # noqa: E402
+
+
+def _project_root() -> str:
+ if getattr(sys, "frozen", False):
+ return os.path.dirname(sys.executable)
+ return os.path.dirname(_WINDOWS_DIR)
+
+
+def _make_run_command():
+ def run_command(command, check=True, interactive=False):
+ import subprocess
+ print(f"--- Running: {' '.join(command)} ---")
+ try:
+ if interactive:
+ return subprocess.run(command, check=check).returncode
+ process = subprocess.Popen(
+ command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
+ )
+ for line in iter(process.stdout.readline, ""):
+ print(line.rstrip())
+ process.wait()
+ if check and process.returncode != 0:
+ raise subprocess.CalledProcessError(process.returncode, command)
+ return process.returncode
+ except FileNotFoundError:
+ print(f"Error: Command '{command[0]}' not found. Is it in your PATH?")
+ if check:
+ raise
+ return -1
+ return run_command
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description="Lucy Windows install helper")
+ parser.add_argument(
+ "mode",
+ choices=["install", "update", "repair", "build-only", "check-prereqs"],
+ help="Install operation to run",
+ )
+ parser.add_argument("--developer", action="store_true", help="Developer install (requires git, SSH clones)")
+ parser.add_argument("--repos-branch", default="master", help="Branch for sub-repositories")
+ parser.add_argument("--lucy-ws-ref", default="master", help="lucy_ws git ref (branch or tag)")
+ parser.add_argument(
+ "--lucy-ws-ref-type",
+ choices=["branch", "tag"],
+ default="branch",
+ help="Whether --lucy-ws-ref is a branch or tag",
+ )
+ parser.add_argument(
+ "--fetch-method",
+ choices=["git", "zip", "auto"],
+ default="auto",
+ help="How to fetch repositories",
+ )
+ parser.add_argument(
+ "--refresh-workspace",
+ action="store_true",
+ help="Re-download lucy_ws files at --lucy-ws-ref before install",
+ )
+ parser.add_argument(
+ "--launch-after",
+ action="store_true",
+ help="Launch the workspace after a successful install",
+ )
+ args = parser.parse_args(argv)
+
+ root = _project_root()
+ os.chdir(root)
+ run_command = _make_run_command()
+
+ if args.mode == "check-prereqs":
+ issues, warnings = install_ops.check_prerequisites(developer=args.developer)
+ install_ops.print_prerequisite_report(issues, warnings)
+ return 1 if issues else 0
+
+ fetch_method = args.fetch_method
+ if fetch_method == "auto":
+ fetch_method = "git" if install_ops.git_available() else "zip"
+
+ profile = install_ops.merge_profile(
+ root,
+ lucy_ws_ref=args.lucy_ws_ref,
+ lucy_ws_ref_type=args.lucy_ws_ref_type,
+ repos_branch=args.repos_branch,
+ fetch_method=fetch_method,
+ developer=args.developer,
+ )
+ install_ops.save_install_profile(root, profile)
+
+ if args.refresh_workspace:
+ install_ops.fetch_lucy_ws_snapshot(
+ root,
+ args.lucy_ws_ref,
+ args.lucy_ws_ref_type,
+ fetch_method=fetch_method,
+ run_command=run_command,
+ )
+
+ try:
+ install_ops.run_install_flow(
+ root,
+ args.mode,
+ developer=args.developer,
+ repos_branch=args.repos_branch,
+ fetch_method=fetch_method,
+ run_command=run_command,
+ )
+ except install_ops.PrerequisiteError:
+ return 1
+ except Exception as exc:
+ print(f"Install failed: {exc}", file=sys.stderr)
+ return 1
+
+ print(f"--- Task '{args.mode}' finished successfully. ---")
+
+ if args.launch_after and args.mode != "check-prereqs":
+ print("\n--- Launching Lucy... ---")
+ return _launch_workspace()
+
+ return 0
+
+
+def _launch_workspace() -> int:
+ """Hand off to the workspace launcher in the current console window."""
+ import Lucy # noqa: WPS433 (Lucy.py exposes launch_workspace)
+ Lucy.launch_workspace()
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/windows/installer/Lucy.nsi b/windows/installer/Lucy.nsi
new file mode 100644
index 0000000..0ec03b2
--- /dev/null
+++ b/windows/installer/Lucy.nsi
@@ -0,0 +1,345 @@
+; Lucy Windows installer (NSIS) — bundles the workspace + Lucy.exe, then runs the
+; install via "Lucy.exe --cli ...". NSIS is used (instead of Inno Setup) because
+; nsExec::ExecToLog streams the long docker/colcon build output live into the
+; installer's details log.
+;
+; Build: windows/build_installer.ps1 (requires PyInstaller + NSIS / makensis)
+
+Unicode true
+
+!ifndef MyAppVersion
+ !define MyAppVersion "0.0.0-dev"
+!endif
+
+!define MyAppName "Lucy"
+!define MyAppPublisher "Sentience Robotics"
+!define MyAppURL "https://github.com/Sentience-Robotics/lucy_ws"
+!define MyAppExeName "Lucy.exe"
+
+!define DOC_DOCKER "https://docs.docker.com/desktop/setup/install/windows-install/"
+!define DOC_VCXSRV "https://github.com/marchaesen/vcxsrv/releases"
+!define DOC_GIT "https://git-scm.com/install/windows"
+!define DOC_PYTHON "https://www.python.org/downloads/"
+
+!include "MUI2.nsh"
+!include "nsDialogs.nsh"
+!include "LogicLib.nsh"
+!include "x64.nsh"
+
+Name "${MyAppName}"
+OutFile "..\..\dist\Lucy-Setup-${MyAppVersion}.exe"
+InstallDir "$LOCALAPPDATA\Programs\Lucy"
+RequestExecutionLevel user
+SetCompressor /SOLID lzma
+ShowInstDetails show
+
+VIProductVersion "0.0.0.0"
+VIAddVersionKey "ProductName" "${MyAppName}"
+VIAddVersionKey "ProductVersion" "${MyAppVersion}"
+VIAddVersionKey "CompanyName" "${MyAppPublisher}"
+VIAddVersionKey "FileDescription" "${MyAppName} Setup"
+VIAddVersionKey "FileVersion" "${MyAppVersion}"
+VIAddVersionKey "LegalCopyright" "${MyAppPublisher}"
+
+; ----------------------------------------------------------------------------
+; State collected from the wizard
+; ----------------------------------------------------------------------------
+Var InstallMode ; install | update | repair
+Var DeveloperInstall ; 1 | 0
+Var RefreshWorkspace ; 1 | 0
+Var SelectedRef ; e.g. master or v1.2.3
+Var SelectedRefType ; branch | tag
+Var InstallOk ; 1 only when prereqs + install succeeded
+
+; nsDialogs control handles
+Var ModeCombo
+Var VersionCombo
+Var DevCheck
+Var RefreshCheck
+Var DockerCheck
+Var PrereqText
+
+; ----------------------------------------------------------------------------
+; Release list (version dropdown) — generated by windows/generate_releases.py.
+; Provides macro LUCY_ADD_RELEASES . Fallback below if absent.
+; ----------------------------------------------------------------------------
+!include /NONFATAL "..\releases.nsh"
+!ifndef LUCY_RELEASES_INCLUDED
+ !macro LUCY_ADD_RELEASES HWND
+ ${NSD_CB_AddString} ${HWND} "Latest (master)"
+ !macroend
+!endif
+
+; ----------------------------------------------------------------------------
+; MUI configuration
+; ----------------------------------------------------------------------------
+!define MUI_ABORTWARNING
+!define MUI_ICON "..\assets\lucy-icon.ico"
+!define MUI_UNICON "..\assets\lucy-icon.ico"
+
+!define MUI_FINISHPAGE_RUN
+!define MUI_FINISHPAGE_RUN_TEXT "Launch Lucy now"
+!define MUI_FINISHPAGE_RUN_FUNCTION "LaunchLucy"
+!define MUI_FINISHPAGE_LINK "Lucy on GitHub"
+!define MUI_FINISHPAGE_LINK_LOCATION "${MyAppURL}"
+
+!insertmacro MUI_PAGE_WELCOME
+Page custom ModePageCreate ModePageLeave
+!insertmacro MUI_PAGE_DIRECTORY
+Page custom OptionsPageCreate OptionsPageLeave
+Page custom PrereqPageCreate PrereqPageLeave
+!insertmacro MUI_PAGE_INSTFILES
+!insertmacro MUI_PAGE_FINISH
+
+!insertmacro MUI_UNPAGE_CONFIRM
+!insertmacro MUI_UNPAGE_INSTFILES
+
+!insertmacro MUI_LANGUAGE "English"
+
+; ----------------------------------------------------------------------------
+; Defaults
+; ----------------------------------------------------------------------------
+Function .onInit
+ StrCpy $InstallMode "install"
+ StrCpy $DeveloperInstall "0"
+ StrCpy $RefreshWorkspace "1"
+ StrCpy $SelectedRef "master"
+ StrCpy $SelectedRefType "branch"
+ StrCpy $InstallOk "0"
+FunctionEnd
+
+; ----------------------------------------------------------------------------
+; Page 1 — install mode
+; ----------------------------------------------------------------------------
+Function ModePageCreate
+ !insertmacro MUI_HEADER_TEXT "Install mode" "Choose how to set up Lucy on this machine."
+ nsDialogs::Create 1018
+ Pop $0
+ ${If} $0 == error
+ Abort
+ ${EndIf}
+
+ ${NSD_CreateLabel} 0 0 100% 12u "Install mode:"
+ Pop $1
+
+ ${NSD_CreateDropList} 0 16u 100% 80u ""
+ Pop $ModeCombo
+ ${NSD_CB_AddString} $ModeCombo "Fresh install"
+ ${NSD_CB_AddString} $ModeCombo "Update existing"
+ ${NSD_CB_AddString} $ModeCombo "Repair"
+ ${NSD_CB_SelectString} $ModeCombo "Fresh install"
+
+ nsDialogs::Show
+FunctionEnd
+
+Function ModePageLeave
+ ${NSD_GetText} $ModeCombo $0
+ ${If} $0 == "Update existing"
+ StrCpy $InstallMode "update"
+ ${ElseIf} $0 == "Repair"
+ StrCpy $InstallMode "repair"
+ ${Else}
+ StrCpy $InstallMode "install"
+ ${EndIf}
+FunctionEnd
+
+; ----------------------------------------------------------------------------
+; Page 2 — options (version + developer + refresh)
+; ----------------------------------------------------------------------------
+Function OptionsPageCreate
+ !insertmacro MUI_HEADER_TEXT "Options" "Version and developer settings."
+ nsDialogs::Create 1018
+ Pop $0
+ ${If} $0 == error
+ Abort
+ ${EndIf}
+
+ ${NSD_CreateLabel} 0 0 100% 12u "lucy_ws version:"
+ Pop $1
+
+ ${NSD_CreateDropList} 0 16u 100% 80u ""
+ Pop $VersionCombo
+ !insertmacro LUCY_ADD_RELEASES $VersionCombo
+ SendMessage $VersionCombo ${CB_SETCURSEL} 0 0
+
+ ${NSD_CreateCheckbox} 0 44u 100% 12u "Developer install (requires Git; uses SSH clones and DEV mode)"
+ Pop $DevCheck
+
+ ${NSD_CreateCheckbox} 0 60u 100% 24u "Download selected lucy_ws version from GitHub (recommended when not using Latest)"
+ Pop $RefreshCheck
+ ${If} $RefreshWorkspace == "1"
+ ${NSD_Check} $RefreshCheck
+ ${EndIf}
+
+ nsDialogs::Show
+FunctionEnd
+
+Function OptionsPageLeave
+ ${NSD_GetState} $DevCheck $0
+ ${If} $0 == ${BST_CHECKED}
+ StrCpy $DeveloperInstall "1"
+ ${Else}
+ StrCpy $DeveloperInstall "0"
+ ${EndIf}
+
+ ${NSD_GetState} $RefreshCheck $0
+ ${If} $0 == ${BST_CHECKED}
+ StrCpy $RefreshWorkspace "1"
+ ${Else}
+ StrCpy $RefreshWorkspace "0"
+ ${EndIf}
+
+ ; Derive ref/type from the label. Tag entries use label == ref (ref_type=tag);
+ ; the "Latest (master)" entry maps to the master branch.
+ ${NSD_GetText} $VersionCombo $0
+ StrCpy $1 $0 6
+ ${If} $1 == "Latest"
+ StrCpy $SelectedRef "master"
+ StrCpy $SelectedRefType "branch"
+ ${Else}
+ StrCpy $SelectedRef $0
+ StrCpy $SelectedRefType "tag"
+ ${EndIf}
+FunctionEnd
+
+; ----------------------------------------------------------------------------
+; Page 3 — requirements (scrollable text + confirmation checkbox)
+; ----------------------------------------------------------------------------
+Function PrereqPageCreate
+ !insertmacro MUI_HEADER_TEXT "Requirements" "Scroll for the full list. URLs are selectable; copy one into your browser."
+
+ nsDialogs::Create 1018
+ Pop $0
+ ${If} $0 == error
+ Abort
+ ${EndIf}
+
+ StrCpy $PrereqText "Required:$\r$\n Docker Desktop$\r$\n ${DOC_DOCKER}$\r$\n Uncheck $\"Use WSL 2$\" unless you need it for advanced development.$\r$\n Configure Docker resources to use at least 5 GB RAM.$\r$\n$\r$\nOptional (GUI apps):$\r$\n VcXsrv$\r$\n ${DOC_VCXSRV}$\r$\n$\r$\nOptional (developers):$\r$\n Git for Windows$\r$\n ${DOC_GIT}$\r$\n Without Git, repositories are downloaded as ZIP archives.$\r$\n Python 3$\r$\n ${DOC_PYTHON}"
+
+ ; Read-only multiline edit; reliably displays/wraps and scrolls if needed.
+ ${NSD_CreateMLText} 0 0 100% -22u $PrereqText
+ Pop $1
+ ${NSD_Edit_SetReadOnly} $1 1
+
+ ${NSD_CreateCheckbox} 0 -18u 100% 12u "Docker Desktop is installed"
+ Pop $DockerCheck
+
+ nsDialogs::Show
+FunctionEnd
+
+Function PrereqPageLeave
+ ${NSD_GetState} $DockerCheck $0
+ ${If} $0 != ${BST_CHECKED}
+ MessageBox MB_OK|MB_ICONEXCLAMATION "Please confirm that Docker Desktop is installed before continuing."
+ Abort
+ ${EndIf}
+FunctionEnd
+
+; ----------------------------------------------------------------------------
+; Prerequisite failure dialog (mirrors the old Inno ShowPrerequisitesFailed)
+; ----------------------------------------------------------------------------
+Function ShowPrerequisitesFailed
+ StrCpy $0 "Required software is missing or not running, so the workspace install was not started.$\r$\n$\r$\nRequired:$\r$\n Docker Desktop must be installed and running.$\r$\n"
+ ${If} $DeveloperInstall == "1"
+ StrCpy $0 "$0$\r$\nDeveloper install also requires Git for Windows.$\r$\n"
+ ${EndIf}
+ StrCpy $0 "$0$\r$\nLucy was copied to your PC. After fixing the items above, run Lucy-Setup.exe again and choose Update.$\r$\n$\r$\nOpen the Docker Desktop install page now?"
+ MessageBox MB_YESNO|MB_ICONEXCLAMATION "$0" IDNO +2
+ ExecShell "open" "${DOC_DOCKER}"
+FunctionEnd
+
+; ----------------------------------------------------------------------------
+; Install
+; ----------------------------------------------------------------------------
+Section "Install"
+ SetOutPath "$INSTDIR"
+ File "..\..\dist\${MyAppExeName}"
+ File "..\..\Dockerfile.humble"
+ File "..\..\install.sh"
+ File "..\..\launch_lucy.sh"
+ File "..\..\Lucy.py"
+ File "..\..\README.md"
+ File /nonfatal "..\..\*.py"
+ File /nonfatal "..\..\*.sh"
+
+ SetOutPath "$INSTDIR\config"
+ File /r "..\..\config\*"
+
+ SetOutPath "$INSTDIR\docker"
+ File /r "..\..\docker\*"
+
+ SetOutPath "$INSTDIR\windows"
+ File /r /x "installer" /x "build_installer.ps1" "..\..\windows\*"
+
+ SetOutPath "$INSTDIR\windows\assets"
+ File "..\assets\lucy-icon.ico"
+
+ ; Shortcuts
+ CreateDirectory "$SMPROGRAMS\${MyAppName}"
+ CreateShortcut "$SMPROGRAMS\${MyAppName}\${MyAppName}.lnk" "$INSTDIR\${MyAppExeName}" "" "$INSTDIR\${MyAppExeName}"
+ CreateShortcut "$DESKTOP\${MyAppName}.lnk" "$INSTDIR\${MyAppExeName}" "" "$INSTDIR\${MyAppExeName}"
+
+ ; Add/Remove Programs entry (per-user)
+ WriteUninstaller "$INSTDIR\Uninstall.exe"
+ WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${MyAppName}" "DisplayName" "${MyAppName}"
+ WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${MyAppName}" "DisplayVersion" "${MyAppVersion}"
+ WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${MyAppName}" "Publisher" "${MyAppPublisher}"
+ WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${MyAppName}" "DisplayIcon" "$INSTDIR\${MyAppExeName}"
+ WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${MyAppName}" "UninstallString" "$\"$INSTDIR\Uninstall.exe$\""
+ WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${MyAppName}" "NoModify" 1
+ WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${MyAppName}" "NoRepair" 1
+
+ ; Verify prerequisites before the long-running install.
+ SetOutPath "$INSTDIR"
+ StrCpy $0 "$\"$INSTDIR\${MyAppExeName}$\" --cli check-prereqs"
+ ${If} $DeveloperInstall == "1"
+ StrCpy $0 "$0 --developer"
+ ${EndIf}
+ DetailPrint "Checking prerequisites..."
+ nsExec::ExecToLog $0
+ Pop $1
+ ${If} $1 != 0
+ Call ShowPrerequisitesFailed
+ DetailPrint "Prerequisites not met — skipped workspace install."
+ Goto done
+ ${EndIf}
+
+ ; Build the install command line.
+ StrCpy $2 "$\"$INSTDIR\${MyAppExeName}$\" --cli $InstallMode --repos-branch master --lucy-ws-ref $SelectedRef --lucy-ws-ref-type $SelectedRefType"
+ ${If} $DeveloperInstall == "1"
+ StrCpy $2 "$2 --developer"
+ ${EndIf}
+ ${If} $RefreshWorkspace == "1"
+ StrCpy $2 "$2 --refresh-workspace"
+ ${EndIf}
+
+ DetailPrint "Running $InstallMode (cloning repos + building Docker image + workspace; this can take a while)..."
+ nsExec::ExecToLog $2
+ Pop $3
+ ${If} $3 == 0
+ StrCpy $InstallOk "1"
+ DetailPrint "Install completed successfully."
+ ${Else}
+ MessageBox MB_OK|MB_ICONEXCLAMATION "Install finished with errors (exit code $3). See the log above for details."
+ ${EndIf}
+
+ done:
+SectionEnd
+
+Function LaunchLucy
+ ${If} $InstallOk == "1"
+ SetOutPath "$INSTDIR"
+ Exec '"$INSTDIR\${MyAppExeName}"'
+ ${EndIf}
+FunctionEnd
+
+; ----------------------------------------------------------------------------
+; Uninstall
+; ----------------------------------------------------------------------------
+Section "Uninstall"
+ Delete "$SMPROGRAMS\${MyAppName}\${MyAppName}.lnk"
+ RMDir "$SMPROGRAMS\${MyAppName}"
+ Delete "$DESKTOP\${MyAppName}.lnk"
+ DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${MyAppName}"
+ RMDir /r "$INSTDIR"
+SectionEnd
diff --git a/windows/releases.json b/windows/releases.json
new file mode 100644
index 0000000..b509b70
--- /dev/null
+++ b/windows/releases.json
@@ -0,0 +1,12 @@
+{
+ "generated_by": "windows/generate_releases.py",
+ "releases": [
+ {
+ "id": "latest",
+ "label": "Latest (master)",
+ "ref": "master",
+ "ref_type": "branch",
+ "recommended": true
+ }
+ ]
+}
diff --git a/windows/releases.nsh b/windows/releases.nsh
new file mode 100644
index 0000000..9401f31
--- /dev/null
+++ b/windows/releases.nsh
@@ -0,0 +1,5 @@
+; Auto-generated by windows/generate_releases.py — do not edit.
+!define LUCY_RELEASES_INCLUDED
+!macro LUCY_ADD_RELEASES HWND
+ ${NSD_CB_AddString} ${HWND} "Latest (master)"
+!macroend