From 3c1e7c2de6dedd1eaab95e8ec0d9d3ac57eb2a41 Mon Sep 17 00:00:00 2001 From: Charles Madjeri <80175305+charlesmadjeri@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:12:24 +0200 Subject: [PATCH 1/3] feat(windows): .exe setup and installer Signed-off-by: Charles Madjeri <80175305+charlesmadjeri@users.noreply.github.com> --- .github/workflows/install-and-launch.yml | 123 ++++- .gitignore | 7 + README.md | 13 +- docs/developer_lucy_packages.md | 12 + windows/Lucy.py | 261 ++------- windows/README.md | 104 ++-- windows/assets/lucy-icon.ico | Bin 0 -> 97422 bytes windows/build_installer.ps1 | 61 +++ windows/generate_releases.py | 78 +++ windows/install_ops.py | 667 +++++++++++++++++++++++ windows/install_runner.py | 146 +++++ windows/installer/Lucy.nsi | 345 ++++++++++++ windows/releases.json | 12 + windows/releases.nsh | 5 + 14 files changed, 1568 insertions(+), 266 deletions(-) create mode 100644 windows/assets/lucy-icon.ico create mode 100644 windows/build_installer.ps1 create mode 100644 windows/generate_releases.py create mode 100644 windows/install_ops.py create mode 100644 windows/install_runner.py create mode 100644 windows/installer/Lucy.nsi create mode 100644 windows/releases.json create mode 100644 windows/releases.nsh 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/.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/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/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/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 0000000000000000000000000000000000000000..5dce64a81afe73ee602562a3106e9afa8e7a7e73 GIT binary patch literal 97422 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 Rp$`B6002ovPDHLkV1gX2pez6Y literal 0 HcmV?d00001 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 From 4cc87d9ca0c3aee2e2b0cf990c0eea1ad09ae2b9 Mon Sep 17 00:00:00 2001 From: m-brl <103381146+m-brl@users.noreply.github.com> Date: Sun, 14 Jun 2026 22:43:11 +0200 Subject: [PATCH 2/3] feat(CI): add release workflow Signed-off-by: m-brl <103381146+m-brl@users.noreply.github.com> --- .github/workflows/release.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/workflows/release.yml 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 }} + From d26eb94b336a8e5821e27e68dc344644db21e3ca Mon Sep 17 00:00:00 2001 From: Antoine ESMAN Date: Mon, 15 Jun 2026 18:18:41 +0100 Subject: [PATCH 3/3] fix: nav hint and STOPPED status when exiting with code 0 (#13) --- Lucy.py | 8 ++-- config/launcher_config.json | 2 + launcher.py | 80 +++++++++++++++++++++++++------------ 3 files changed, 60 insertions(+), 30 deletions(-) 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/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/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)