""" 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 (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 import tkinter as tk from tkinter import ttk, messagebox from . import main as inst_main from . import screen as ui from . import workstation as ws from . import config as cfg def check_xcode_tools() -> bool: if platform.system() != "Darwin": return False try: result = subprocess.run(["xcrun", "--find", "swiftc"], capture_output=True, text=True, timeout=10) return result.returncode == 0 except Exception: return False def build_prole_app(project_root: Path) -> None: if platform.system() != "Darwin": raise Exception("Building the macOS app requires macOS.") if not check_xcode_tools(): raise Exception("Xcode Command Line Tools not found. Please run: xcode-select --install") # Use the top-level prole-app project directory prole_app_dir = Path(project_root) / "prole-app" build_script = prole_app_dir / "build.sh" if not build_script.exists(): raise Exception( f"Build script not found at {build_script}" ) result = subprocess.run(["bash", str(build_script), "build"], cwd=prole_app_dir, capture_output=True, text=True) if result.returncode != 0: raise Exception(f"Prole build failed: {result.stderr or result.stdout}") app_path = prole_app_dir / "dist" / "Prole.app" if not app_path.exists(): raise Exception("Build completed but Prole.app was not found in dist/") try: msg = ( "Prole.app has been built successfully.\n\n" f"Location: {app_path}\n\n" "Would you like to copy it to /Applications?" ) if messagebox.askyesno("Prole Built", msg): dest = Path("/Applications") / "Prole.app" subprocess.run(["cp", "-R", str(app_path), str(dest)], check=True) messagebox.showinfo("Copied", f"Copied to {dest}") except Exception as copy_err: print(f"Copy to /Applications failed: {copy_err}") # ---------------- Screen (UI) helpers ---------------- def _draw_status(canvas: tk.Canvas, status: str): canvas.delete('all') if status == 'success' or status == 'completed': 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 == 'running': canvas.create_oval(2, 2, 18, 18, fill='#ffd60a', outline='') elif status == 'error': canvas.create_oval(2, 2, 18, 18, fill='#ff3b30', outline='') else: canvas.create_oval(2, 2, 18, 18, outline='#b0b0b0') def _create_deploy_row(app, parent, step: dict): row = ttk.Frame(parent) row.pack(fill='x', pady=6) canvas = tk.Canvas(row, width=20, height=20, highlightthickness=0) canvas.pack(side='left', padx=8) lbl = ttk.Label(row, text=step['name'], style='Body.TLabel') lbl.pack(side='left') 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} # 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. 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='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': '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) container.pack(fill='both', expand=True, padx=16, pady=8) for step in app.deploy_steps: _create_deploy_row(app, container, step) inst_main.register_page(app, 'deploy', f) # Prepare console overlay to display the docker run command try: # 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 # ---------------- New deploy step helpers ---------------- def _update_step(app, name: str, status: str): w = app.deploy_widgets.get(name) if not w: return canvas = w.get('canvas') label = w.get('label') try: _draw_status(canvas, status) except Exception: pass try: if status == 'running': label.configure(text='Running…') elif status in ('success', 'completed'): label.configure(text='Done') elif status == 'error': label.configure(text='Error') else: label.configure(text=status.title()) except Exception: pass def _ensure_docker_running(timeout: int = 120) -> bool: """Return True if Docker responds to `docker info`; try to start Desktop on macOS.""" def docker_ok() -> bool: try: r = subprocess.run(["bash", "-lc", "docker info >/dev/null 2>&1"], timeout=8) return r.returncode == 0 except Exception: return False if docker_ok(): return True # Try to start Docker Desktop on macOS if platform.system() == 'Darwin': try: subprocess.Popen(["open", "-a", "Docker"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except Exception: pass # Wait until docker is ready or timeout import time as _t start = _t.time() while _t.time() - start < timeout: if docker_ok(): return True _t.sleep(2) return False def _ensure_workstation_container() -> bool: """Run or start the prole-workstation container with required ports.""" version = ws.get_workstation_version(cfg.PROJECT_ROOT) # If container exists and is running → success try: exists = subprocess.run(["bash", "-lc", "docker ps -a --format '{{.Names}}' | grep -w prole-workstation"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) if exists.returncode == 0: # Check running state running = subprocess.run(["bash", "-lc", "docker ps --format '{{.Names}}' | grep -w prole-workstation"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) if running.returncode == 0: return True # Start it started = subprocess.run(["bash", "-lc", "docker start prole-workstation"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) return started.returncode == 0 except Exception: pass cmd = ( "docker run -d --name prole-workstation " "-p 5901:5901 -p 6667:6667 -p 6697:6697 " f"prole-workstation:{version}" ) try: res = subprocess.run(["bash", "-lc", cmd], stdout=subprocess.PIPE, stderr=subprocess.PIPE) return res.returncode == 0 except Exception: return False