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