diff --git a/installer/deploy.py b/installer/deploy.py index 0920ef5..5a5643b 100644 --- a/installer/deploy.py +++ b/installer/deploy.py @@ -3,14 +3,15 @@ Deploy/build helpers for the Prole installer (root-level package). Encapsulates Xcode tools check and building the native Prole app. -Also defines the Deploy page (new logic): - 1) Start Docker (ensure it's running) - 2) Start Ollama serve - 3) Start the prole-workstation container with required ports +Also defines the Deploy page (final milestone): + - Mark completed: Build Prole macOS App, Build Prole Workstation Docker Image, Verify Dependencies + - New: Start Prole Workstation (show docker run command in the black console, wait for Enter, run, verify) + - New: Install Prole.app (drag-to-install pop-up; mark complete when closed) """ from __future__ import annotations from pathlib import Path +import os import threading import platform import subprocess @@ -94,27 +95,118 @@ def _create_deploy_row(app, parent, step: dict): status = ttk.Label(row, text='Pending', style='Dim.TLabel') status.pack(side='right', padx=8) app.deploy_widgets[step['name']] = {'canvas': canvas, 'label': status, 'step': step} - _draw_status(canvas, 'pending') + # Initialize status icon/text + init = step.get('status', 'pending') + _draw_status(canvas, init) + try: + if init in ('success', 'completed'): + status.configure(text='Done') + elif init == 'running': + status.configure(text='Running…') + else: + status.configure(text=init.title()) + except Exception: + pass def create_deploy_page(app): """Create the Deploy page UI and register it via installer.main. - New simplified 3-step flow: - 1) Ensure Docker is running - 2) Start Ollama serve - 3) Run prole-workstation container (5901, 6667, 6697) + Final milestone flow for Deploy page: + - Show 3 completed steps from earlier phases + - Start Prole Workstation (docker run) + - Install Prole.app (drag-to-install pop-up) """ f = ttk.Frame(app.page_area) f.place(x=0, y=0, relwidth=1, relheight=1) - ttk.Label(f, text='Deploy', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6)) - ttk.Label(f, text='We will bring services online in order: Docker, Ollama, and the prole-workstation container.', style='Body.TLabel', wraplength=800).pack(anchor='w', padx=24) + ttk.Label(f, text='Setup & Deploy', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6)) + ttk.Label( + f, + text='We will verify dependencies and then complete final deployment steps.', + style='Body.TLabel', + wraplength=800 + ).pack(anchor='w', padx=24) + # ----- Dependencies section (merged onto the first page) ----- + deps_box = ttk.LabelFrame(f, text='Dependencies') + deps_box.pack(fill='x', padx=16, pady=(12, 8)) + deps_wrap = ttk.Frame(deps_box) + deps_wrap.pack(fill='x', padx=8, pady=8) + app._deploy_dep_widgets = {} + + def _dep_icon(canvas: tk.Canvas, status: str): + canvas.delete('all') + if status == 'ok': + canvas.create_oval(2, 2, 18, 18, fill='#34c759', outline='') + canvas.create_line(5, 10, 9, 14, fill='white', width=2) + canvas.create_line(9, 14, 16, 6, fill='white', width=2) + elif status == 'checking': + canvas.create_oval(2, 2, 18, 18, fill='#ffd60a', outline='') + elif status == 'missing': + canvas.create_oval(2, 2, 18, 18, fill='#ff3b30', outline='') + else: + canvas.create_oval(2, 2, 18, 18, outline='#b0b0b0') + + def _create_dep_row(parent, dep: dict): + row = ttk.Frame(parent) + row.pack(fill='x', pady=4) + cnv = tk.Canvas(row, width=20, height=20, highlightthickness=0) + cnv.pack(side='left', padx=(4, 8)) + name = ttk.Label(row, text=dep['name'], style='Body.TLabel') + name.pack(side='left') + status = ttk.Label(row, text='Checking…', style='Dim.TLabel') + status.pack(side='right', padx=8) + app._deploy_dep_widgets[dep['id']] = {'canvas': cnv, 'label': status, 'dep': dep} + _dep_icon(cnv, 'checking') + + try: + from . import config as _cfg + deps = list(_cfg.DEPENDENCIES) + except Exception: + deps = [] + for d in deps: + _create_dep_row(deps_wrap, d) + + # Background thread to check dependencies live + def _check_deps_bg(): + try: + from . import config as _cfg2 + for d in deps: + try: + installed, location, version = _cfg2.get_dep_info(d) + except Exception: + installed, location, version = False, None, None + w = app._deploy_dep_widgets.get(d['id']) + if not w: + continue + lbl = w['label'] + cnv = w['canvas'] + try: + if installed: + _dep_icon(cnv, 'ok') + txt = version or 'Installed' + lbl.configure(text=txt) + else: + _dep_icon(cnv, 'missing') + lbl.configure(text='Missing') + except Exception: + pass + except Exception: + pass + + try: + threading.Thread(target=_check_deps_bg, daemon=True).start() + except Exception: + _check_deps_bg() + + # ----- Final steps (on the same merged page) ----- app.deploy_steps = [ - {'name': 'Start Docker (ensure it is running)', 'status': 'pending'}, - {'name': 'Start Ollama serve', 'status': 'pending'}, - {'name': 'Start prole-workstation container (5901, 6667, 6697)', 'status': 'pending'}, + {'name': 'Build Prole macOS App', 'status': 'completed'}, + {'name': 'Build Prole Workstation Docker Image', 'status': 'completed'}, + {'name': 'Verify Dependencies', 'status': 'completed'}, + {'name': 'Start Prole Workstation', 'status': 'pending'}, + {'name': 'Install Prole.app', 'status': 'pending'}, ] app.deploy_widgets = {} container = ttk.Frame(f) @@ -124,32 +216,176 @@ def create_deploy_page(app): inst_main.register_page(app, 'deploy', f) - # Kick off the steps asynchronously so UI stays responsive - def _run_steps(): - _update_step(app, app.deploy_steps[0]['name'], 'running') - if _ensure_docker_running(): - _update_step(app, app.deploy_steps[0]['name'], 'success') - else: - _update_step(app, app.deploy_steps[0]['name'], 'error') - return - - _update_step(app, app.deploy_steps[1]['name'], 'running') - if _ensure_ollama_running(): - _update_step(app, app.deploy_steps[1]['name'], 'success') - else: - _update_step(app, app.deploy_steps[1]['name'], 'error') - return - - _update_step(app, app.deploy_steps[2]['name'], 'running') - if _ensure_workstation_container(): - _update_step(app, app.deploy_steps[2]['name'], 'success') - else: - _update_step(app, app.deploy_steps[2]['name'], 'error') - + # Prepare console overlay to display the docker run command try: - threading.Thread(target=_run_steps, daemon=True).start() + # Place the console below the step list + app._ensure_console_overlay(radio_bottom_y=160) except Exception: pass + + # Compose docker run command for workstation + project_root = cfg.PROJECT_ROOT + version = ws.get_workstation_version(project_root) + # Ports and volume mapping per spec + port_flags = ( + "-p 127.0.0.1:5901:5901 " + "-p 127.0.0.1:6667:6667 " + "-p 127.0.0.1:6697:6697" + ) + host_prole = os.path.expanduser('~/dev/prole') + docker_run_cmd = ( + f"docker rm -f prole-workstation >/dev/null 2>&1 || true; " + f"docker run -d --name prole-workstation --restart unless-stopped " + f"{port_flags} -v \"{host_prole}\":/prole prole-workstation:{version}" + ) + + # Show the command preview and wait for Enter + try: + user = os.environ.get('USER') or 'user' + host = (platform.node() or 'host').split('.')[0] + preview_line = f"[{user}@{host}]# {docker_run_cmd}" + if hasattr(app, '_console_set_preview'): + app._console_set_preview(preview_line) + except Exception: + pass + + # Handlers for steps 4 and 5 + state = {'started': False} + + def _open_drag_install(): + """Open a simple drag-to-install window; mark complete when closed.""" + try: + win = tk.Toplevel(app.root) + win.title('Install Prole.app') + win.geometry('520x320') + ttk.Label(win, text='Drag Prole.app to Applications', style='Title.TLabel').pack(pady=(16, 8)) + body = ttk.Frame(win) + body.pack(expand=True, fill='both', padx=20, pady=10) + # Left: Prole.app icon (from configured icon path) + left = ttk.Frame(body) + left.pack(side='left', expand=True, fill='both') + right = ttk.Frame(body) + right.pack(side='right', expand=True, fill='both') + # Try to load images if available + icon_path = cfg.get_ui_icon_image_path() + app._drag_img_prole = None + app._drag_img_apps = None + try: + from PIL import Image, ImageTk # type: ignore + if icon_path.exists(): + img = Image.open(str(icon_path)).resize((128, 128)) + app._drag_img_prole = ImageTk.PhotoImage(img) + # A simple generic Applications icon (fallback: text only) + except Exception: + pass + cnv_l = tk.Canvas(left, width=200, height=200, highlightthickness=0) + cnv_l.pack(expand=True) + if app._drag_img_prole is not None: + cnv_l.create_image(100, 100, image=app._drag_img_prole) + else: + cnv_l.create_text(100, 100, text='Prole.app', font=('Helvetica', 14)) + cnv_r = tk.Canvas(right, width=200, height=200, highlightthickness=0) + cnv_r.pack(expand=True) + cnv_r.create_text(100, 80, text='Applications', font=('Helvetica', 14)) + # Action buttons + btns = ttk.Frame(win) + btns.pack(side='bottom', pady=12) + def open_apps(): + try: + subprocess.Popen(["open", "/Applications"]) # type: ignore + except Exception: + pass + ttk.Button(btns, text='Open Applications Folder', command=open_apps).pack() + def on_close(): + try: + _update_step(app, 'Install Prole.app', 'success') + except Exception: + pass + try: + win.destroy() + except Exception: + pass + win.protocol('WM_DELETE_WINDOW', on_close) + except Exception: + # Even if popup fails, don't crash the deploy page + pass + + def _start_workstation_and_verify(): + # Kick off docker run and then verify container is running + try: + _update_step(app, 'Start Prole Workstation', 'running') + except Exception: + pass + try: + app._append_console("\nStarting Prole Workstation container...\n") + except Exception: + pass + try: + subprocess.run(["bash", "-lc", docker_run_cmd]) + except Exception: + pass + # Verify container is running + import time as _t + ok = False + for _ in range(30): # ~30 * 0.5s = 15s + try: + r = subprocess.run(["bash", "-lc", "docker inspect -f '{{.State.Running}}' prole-workstation || echo false"], capture_output=True, text=True, timeout=4) + if r.returncode == 0 and 'true' in (r.stdout or '').lower(): + ok = True + break + except Exception: + pass + _t.sleep(0.5) + try: + if ok: + _update_step(app, 'Start Prole Workstation', 'success') + app._append_console("Prole Workstation is running.\n") + else: + _update_step(app, 'Start Prole Workstation', 'error') + app._append_console("Failed to start Prole Workstation. See Docker for details.\n") + except Exception: + pass + + def _on_enter(_evt=None): + # Only handle once + if state['started']: + return + state['started'] = True + try: + app._console_press_enter() + except Exception: + pass + # While docker deploy is running, pop up the drag-to-install window + try: + threading.Thread(target=_open_drag_install, daemon=True).start() + except Exception: + _open_drag_install() + # Start docker run + verification in background + try: + threading.Thread(target=_start_workstation_and_verify, daemon=True).start() + except Exception: + _start_workstation_and_verify() + + # Bind Enter to trigger the start when the user is ready + try: + app.root.bind('', _on_enter) + app.root.bind('', _on_enter) + except Exception: + pass + + # Navigation footer with Next button to proceed/trigger actions + def _noop(): + return None + try: + btns = ui.create_nav_footer(f, [(1, 'Back'), (2, 'Next')], {1: _noop, 2: _on_enter}) + # Optionally expose next_button like install.py does + try: + app.next_button = btns.get(2) + except Exception: + pass + except Exception: + pass + return f diff --git a/installer/screen.py b/installer/screen.py index e6c6694..60285b6 100644 --- a/installer/screen.py +++ b/installer/screen.py @@ -158,6 +158,12 @@ def create_nav_footer(parent, buttons: list[tuple[int, str]], commands: dict[int footer = ttk.Frame(parent) footer.pack(fill='x', side='bottom') + # Ensure stable geometry even when there are no buttons + try: + footer.configure(height=60) + footer.pack_propagate(False) + except Exception: + pass # Flexible spacer to push buttons to the right spacer = ttk.Frame(footer)