#!/usr/bin/env python3 """ Prole Service Dependencies Installer Desktop application for installing, deploying, and validating Prole services """ import tkinter as tk from tkinter import ttk, scrolledtext, messagebox import subprocess import threading import os import sys import webbrowser import time import platform from pathlib import Path import shlex import socket import signal # Refactor: import shared helpers from the root-level installer package from installer import config as inst_config from installer.build import get_build_command as inst_get_build_command from installer import deploy as inst_deploy from installer import screen as ui # On macOS, set the process name as early as possible so the menu bar shows 'Prole Installer' if platform.system() == 'Darwin': try: from Foundation import NSProcessInfo NSProcessInfo.processInfo().setProcessName_("Prole Installer") except Exception: pass # Get the project root directory (kept local for clarity in this legacy entry) PROJECT_ROOT = Path(__file__).parent.absolute() def is_apple_silicon(): """Check if running on Apple Silicon (ARM64).""" return inst_config.is_apple_silicon() def get_docker_build_platform_args(): """Get Docker build platform arguments for Apple Silicon (from config).""" return inst_config.get_docker_build_platform_args() class ProleInstaller: def __init__(self, root): self.screens = None self.deploy_environment = None self.root = root self.root.title("Prole Installer") # Best-effort: set app identity (menu title / dock icon) early try: self._set_app_identity() except Exception: pass # Center window on screen window_width = 1000 window_height = 700 screen_width = root.winfo_screenwidth() screen_height = root.winfo_screenheight() center_x = int(screen_width / 2 - window_width / 2) center_y = int(screen_height / 2 - window_height / 2) self.root.geometry(f"{window_width}x{window_height}+{center_x}+{center_y}") # Light background overall self.root.configure(bg='#f5f5f7') # Style configuration self.style = ttk.Style() # Prefer native Aqua widgets on macOS; otherwise use a stable light theme try: if platform.system() == 'Darwin' and 'aqua' in self.style.theme_names(): self.style.theme_use('aqua') else: self.style.theme_use('clam') except Exception: # Fallback to whatever default exists pass self.configure_styles() # Create main container self.container = ttk.Frame(root) self.container.pack(fill='both', expand=True) # "Slide" area with a full-frame background on the lowest layer self.slide_area = ttk.Frame(self.container) self.slide_area.pack(fill='both', expand=True) # Background canvas paints the image as a full-cover background self._bg_pil = None self._bg_tk = None self.bg_canvas = tk.Canvas(self.slide_area, highlightthickness=0, bd=0) self.bg_canvas.pack(fill='both', expand=True) self._bg_item = None try: from PIL import Image, ImageTk # optional # Use the full-frame background image from config try: bg_path = inst_config.get_ui_background_image_path() except Exception: bg_path = PROJECT_ROOT / 'img' / 'proleLogoSepia.png' if bg_path.exists(): self._bg_pil = Image.open(str(bg_path)).convert('RGBA') def _render_bg(event=None): if not self._bg_pil: return cw = max(1, self.bg_canvas.winfo_width()) ch = max(1, self.bg_canvas.winfo_height()) iw, ih = self._bg_pil.size # Cover algorithm: scale so that image covers the canvas fully scale = max(cw / iw, ch / ih) nw, nh = max(1, int(iw * scale)), max(1, int(ih * scale)) img = self._bg_pil.resize((nw, nh), Image.LANCZOS) # center crop (no need to crop since canvas can clip) self._bg_tk = ImageTk.PhotoImage(img) # Only replace the previous background image; do NOT clear all canvas content if self._bg_item is not None: try: self.bg_canvas.delete(self._bg_item) except Exception: pass self._bg_item = ui.canvas_image(self, cw // 2, ch // 2, self._bg_tk, anchor='center') # Ensure the background sits behind all other items try: self.bg_canvas.tag_lower(self._bg_item) except Exception: pass # Defensive: refresh footer once background is in place try: self.update_footer() except Exception: pass self.bg_canvas.bind('', _render_bg) # Initial render after window shows self.root.after(100, _render_bg) except Exception: # If PIL not available or image missing, leave plain background pass # Initialize validation attributes before creating screens self.validation_running = False self.validation_thread = None # Welcome/splash dependency scan state self._welcome_scan_started = False self.splash_scan_running = False self.splash_scan_done_at = None self.splash_scan_started_at = None self._splash_status_item = None # Capture expected host (short hostname) at app start for safety guards try: self.expected_host = (platform.node() or socket.gethostname()).split('.')[0] except Exception: self.expected_host = None # Footer navigation (Prev / Next / Finish) via centralized helper btns = ui.create_nav_footer( parent=self.container, buttons=[(1, 'Prev'), (2, 'Next'), (3, 'Finish')], commands={1: self.on_prev, 2: self.on_next, 3: self.on_finish}, style_name='Nav.TButton', ) # Extract references for existing logic self.footer = btns.get('_footer') # type: ignore[assignment] self.prev_button = btns.get(1) self.next_button = btns.get(2) self.finish_button = btns.get(3) # Wizard pages setup self.pages = [] # list of (page_id, frame) self.page_index = 0 # Content will be rendered directly on the background canvas to avoid # any opaque rectangles obscuring the image. # Maintain a tiny overlay layer only for small interactive widgets (if any). # removed deprecated wizard_frame overlay (legacy) self._canvas_page = None self._canvas_items = [] self.canvas_renderers = {} # Keep track of small overlay widgets placed above the canvas so we can # cleanly remove them on page switches (e.g., radiobuttons, consoles) self._overlay_widgets = [] # Cursor blink timer id for command preview self._cursor_blink_after_id = None self._cursor_blink_visible = False # Shared dependency catalog from installer.config self.dependencies = list(inst_config.DEPENDENCIES) # Create pages self.page_frames = {} self.verify_mode = tk.BooleanVar(value=False) # Register page ids (renderers will draw directly on canvas) self._register_canvas_renderer('welcome', self._render_welcome_page) self._register_canvas_renderer('deps_summary', self._render_deps_summary_page) for dep in self.dependencies: self._register_canvas_renderer(f"dep_{dep['id']}", lambda d=dep: self._render_dependency_page(d)) self._register_canvas_renderer('build', self._render_build_page) self._register_canvas_renderer('build_summary', self._render_build_summary_page) # Mirror old pages list ordering for navigation self._register_page('welcome', None) self._register_page('deps_summary', None) for dep in self.dependencies: self._register_page(f"dep_{dep['id']}", None) self._register_page('build', None) self._register_page('build_summary', None) # Initialize page and footer self.show_page(0) self.update_footer() # ---------------- App identity (title, Dock icon) ---------------- def _set_app_identity(self): """Set the installer identity: process/menu name and Dock icon on macOS. Notes: - We set Tk's appname for consistency. - On macOS, attempt to set the process/menu name to 'Prole Installer' via NSProcessInfo if PyObjC is available. - Prefer the Prole.app .icns from the built app; fallback to local PNG/GIF. """ # Set Tk application name try: self.root.tk.call('tk', 'appname', 'Prole Installer') except Exception: pass # macOS: set process/menu name and Dock icon via AppKit/Foundation if platform.system() == 'Darwin': # Try to set the visible process name for the menu bar try: from Foundation import NSProcessInfo NSProcessInfo.processInfo().setProcessName_("Prole Installer") except Exception: pass # Also try to retitle the first main menu item so the menu next to the Apple logo reads 'Prole Installer' try: from AppKit import NSApplication app = NSApplication.sharedApplication() main_menu = app.mainMenu() if main_menu is not None and main_menu.numberOfItems() > 0: first_item = main_menu.itemAtIndex_(0) if first_item is not None: first_item.setTitle_("Prole Installer") except Exception: pass icns_candidates = [ PROJECT_ROOT / 'prole-app' / 'dist' / 'Prole.app' / 'Contents' / 'Resources' / 'AppIcon.icns', PROJECT_ROOT / 'prole-app' / 'dist' / 'Prole.app' / 'Contents' / 'Resources' / 'Prole.icns', ] icns_path = next((p for p in icns_candidates if p.exists()), None) if icns_path is not None: try: # Use PyObjC if available from AppKit import NSApplication, NSImage img = NSImage.alloc().initWithContentsOfFile_(str(icns_path)) if img is not None: NSApplication.sharedApplication().setApplicationIconImage_(img) return except Exception: pass # If no .icns was found, try setting Dock icon from configured PNG try: from AppKit import NSApplication, NSImage cfg_icon = inst_config.get_ui_icon_image_path() if cfg_icon.exists(): png_img = NSImage.alloc().initWithContentsOfFile_(str(cfg_icon)) if png_img is not None: NSApplication.sharedApplication().setApplicationIconImage_(png_img) # do not return; still set Tk icon below for consistency except Exception: pass # Fallback: Tk icon from image assets (PNG/GIF) # Prefer config-defined icon image try: cfg_icon = inst_config.get_ui_icon_image_path() except Exception: cfg_icon = PROJECT_ROOT / 'img' / 'proleIcon.png' img_candidates = [ cfg_icon, PROJECT_ROOT / 'img' / 'prole-type.png', PROJECT_ROOT / 'img' / 'prole-type.gif', PROJECT_ROOT / 'img' / 'Prole.png', PROJECT_ROOT / 'img' / 'proleLogoSepia.png', ] for p in img_candidates: try: if p.exists(): self._app_iconphoto = tk.PhotoImage(file=str(p)) try: self.root.iconphoto(True, self._app_iconphoto) except Exception: pass break except Exception: continue def configure_styles(self): """Configure ttk styles""" base_bg = '#f5f5f7' self.style.configure('Title.TLabel', background=base_bg, foreground='#1d1d1f', font=('Helvetica', 22, 'bold')) self.style.configure('Body.TLabel', background=base_bg, foreground='#1d1d1f', font=('Helvetica', 12)) self.style.configure('Dim.TLabel', background=base_bg, foreground='#6e6e73', font=('Helvetica', 11)) self.style.configure('Card.TFrame', background=base_bg, relief='flat') # Buttons: keep native look; ensure readable foreground try: self.style.configure('TButton', foreground='#1d1d1f') except Exception: pass # Ensure checkbuttons and other common controls inherit light background try: self.style.configure('TCheckbutton', background=base_bg, foreground='#1d1d1f') self.style.configure('TCombobox', fieldbackground='white', background=base_bg) except Exception: pass def create_navigation(self): # Deprecated top navigation retained for compatibility; not used in wizard redesign pass def show_screen(self, screen_id): # No-op in wizard redesign return # ---------------- Wizard pages ---------------- def _page_container(self): # Deprecated: pages are drawn directly on the canvas to avoid opaque overlays. return None def _register_page(self, page_id, frame): self.pages.append((page_id, frame)) self.page_frames[page_id] = frame def show_page(self, index_or_id): # Clear any previously drawn canvas content self._clear_canvas_page() # Hide any legacy frames if they exist for _, f in self.pages: try: if f is not None: f.place_forget() f.pack_forget() except Exception: pass # Resolve index if isinstance(index_or_id, int): idx = max(0, min(index_or_id, len(self.pages) - 1)) else: idx = next((i for i, (pid, _) in enumerate(self.pages) if pid == index_or_id), 0) self.page_index = idx pid, frame = self.pages[self.page_index] # Render the page directly on the canvas if we have a renderer if pid in self.canvas_renderers: try: self.canvas_renderers[pid]() except Exception as e: # Fallback: show an error message on canvas ui.canvas_text(self, 32, 32, f"Error rendering page '{pid}': {e}", fill='#1d1d1f', font=('Helvetica', 12)) elif frame is not None: # Legacy fallback (should not be used) frame.place(relx=0.5, rely=0.5, anchor='center', relwidth=0.94, relheight=0.9) self.update_footer() # ---------------- Canvas page rendering ---------------- def _register_canvas_renderer(self, page_id, func): self.canvas_renderers[page_id] = func def _clear_canvas_page(self): if self._canvas_items: for item in self._canvas_items: try: self.bg_canvas.delete(item) except Exception: pass self._canvas_items = [] # Also unbind any page-specific bindings self.bg_canvas.unbind('') self.bg_canvas.config(cursor='') # Remove any overlay widgets we placed for the previous page if getattr(self, '_overlay_widgets', None): for w in self._overlay_widgets: try: w.place_forget() except Exception: pass try: w.destroy() except Exception: pass self._overlay_widgets = [] # Stop any blinking cursor if active if getattr(self, '_cursor_blink_after_id', None): try: self.root.after_cancel(self._cursor_blink_after_id) except Exception: pass self._cursor_blink_after_id = None self._cursor_blink_visible = False # Stop any page-specific resize binding for overlays if getattr(self, '_overlay_bind_id', None): try: self.slide_area.unbind('', self._overlay_bind_id) except Exception: pass self._overlay_bind_id = None # Unbind Enter/Return shortcuts that may have been set by a page try: self.root.unbind('') self.root.unbind('') except Exception: pass # If a build process is running and we leave the page, terminate it safely if getattr(self, '_running_process', None): try: self._terminate_running_process() except Exception: pass def _render_title(self, text, y=40): ui.render_title(self, text, y) def _render_paragraph(self, text, y, wrap=860): ui.render_paragraph(self, text, y, wrap) def _render_welcome_page(self): # Opening page should NOT perform any dependency checks. self._render_title('Welcome to Prole', y=40) msg = 'Thanks for joining Prole. We will prepare your system and install the software needed to build and run Prole.' self._render_paragraph(msg, y=90) # Ensure footer is updated (Next button visible) self.update_footer() def _start_welcome_dependency_scan(self): self.splash_scan_running = True self.splash_scan_done_at = None self.splash_scan_started_at = time.time() deps = list(self.dependencies) total = len(deps) results = {} def set_status(text: str): try: if self._splash_status_item is not None: # Replace existing text to avoid creating many items self.bg_canvas.itemconfig(self._splash_status_item, text=text) except Exception: pass def worker(): missing_names = [] for idx, dep in enumerate(deps, start=1): try: ok, location, version = self.get_dep_info(dep) except Exception: ok, location, version = False, None, None results[dep['id']] = (ok, location, version) # Update status text progressively if ok: txt = f"[{idx}/{total}] {dep['name']}: Installed" else: txt = f"[{idx}/{total}] {dep['name']}: Not installed" missing_names.append(dep['name']) self.root.after(0, lambda s=txt: set_status(s)) # tiny sleep to keep UI responsive without being too fast try: time.sleep(0.02) except Exception: pass # Final message final_msg = 'All dependencies installed.' if not missing_names else ( 'Preparing to install ... ' + ', '.join(missing_names) ) def on_done(): set_status(final_msg) self.splash_scan_running = False self.splash_scan_done_at = time.time() # Re-evaluate footer to potentially show Next self.update_footer() # Important: schedule a follow-up refresh slightly after the debounce window # so the Next button becomes visible without requiring further UI events. try: self.root.after(600, self.update_footer) except Exception: pass self.root.after(0, on_done) t = threading.Thread(target=worker, daemon=True) t.start() # Failsafe refresh slightly after the 8s gating window in case no UI events fire. try: self.root.after(9000, self.update_footer) except Exception: pass def _render_deps_summary_page(self): self._render_title('Dependencies', y=40) y = 90 # Draw each dependency row: status dot, name, info row_gap = 28 left = 56 text_x = left + 28 # Try to get recent results by checking synchronously (cheap) or show pending results = {} any_missing = False for dep in self.dependencies: ok, location, version = self.get_dep_info(dep) results[dep['id']] = (ok, location, version) for dep in self.dependencies: did = dep['id'] ok, _, version = results.get(did, (False, None, None)) # status dot if ok: fill = '#34c759' else: fill = '#ff3b30' any_missing = True self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill=fill, outline='')) # name self._canvas_items.append(ui.canvas_text(self, text_x, y-2, dep['name'], fill='#1d1d1f', font=('Helvetica', 12, 'bold'))) # info info_text = '' if ok: info_text = self.normalize_version(version) if version else '' else: info_text = 'Not installed' if info_text: self._canvas_items.append(ui.canvas_text(self, text_x + 150, y, info_text, fill='#6e6e73', font=('Helvetica', 11))) y += row_gap # Verify all checkbox (canvas-drawn toggle) toggle_y = y + 10 box_x = 52 box = ui.canvas_rectangle(self, box_x, toggle_y, box_x+16, toggle_y+16, outline='#6e6e73', width=2) self._canvas_items.append(box) label = ui.canvas_text(self, box_x+24, toggle_y-2, 'Verify all dependencies', fill='#1d1d1f', font=('Helvetica', 12)) self._canvas_items.append(label) # check mark if enabled if self.verify_mode.get(): self._canvas_items.append(ui.canvas_line(self, box_x+3, toggle_y+9, box_x+7, toggle_y+13, fill='#1d1d1f', width=2)) self._canvas_items.append(ui.canvas_line(self, box_x+7, toggle_y+13, box_x+14, toggle_y+5, fill='#1d1d1f', width=2)) # Message line msg_y = toggle_y + 28 msg_text = '' if any_missing: missing = [d['name'] for d in self.dependencies if not results.get(d['id'], (False, None, None))[0]] if missing: msg_text = f"Preparing to install ... {', '.join(missing)}" else: msg_text = 'All dependencies installed.' if msg_text: self._canvas_items.append(ui.canvas_text(self, 48, msg_y, msg_text, fill='#6e6e73', font=('Helvetica', 11))) # Bind toggle click def _on_click(event): ex, ey = event.x, event.y if box_x <= ex <= box_x+16 and toggle_y <= ey <= toggle_y+16: self.verify_mode.set(not self.verify_mode.get()) self._clear_canvas_page() self._render_deps_summary_page() self.update_footer() self.bg_canvas.bind('', _on_click) def _render_dependency_page(self, dep): self._render_title(dep['name'], y=40) self._render_paragraph(dep['description'], y=80) # Status ok, location, version = self.get_dep_info(dep) y = 140 left = 56 if ok: self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#34c759', outline='')) self._canvas_items.append(ui.canvas_text(self, left+26, y-2, 'Installed', fill='#1d1d1f', font=('Helvetica', 12, 'bold'))) if location: self._canvas_items.append(ui.canvas_text(self, left+26, y+26, f'Location: {location}', fill='#6e6e73', font=('Helvetica', 11))) if version: ver = self.normalize_version(version) self._canvas_items.append(ui.canvas_text(self, left+26, y+46, f'Version: {ver}', fill='#6e6e73', font=('Helvetica', 11))) else: self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#ff9f0a', outline='')) self._canvas_items.append(ui.canvas_text(self, left+26, y-2, 'Not installed', fill='#1d1d1f', font=('Helvetica', 12, 'bold'))) # Click to install link (if available) if dep.get('install_cmd'): link_y = y + 30 link_text = ui.render_link(self, left+26, link_y, 'Click to install') self._canvas_items.append(link_text) self.bg_canvas.config(cursor='hand2') def _on_click(event): ex, ey = event.x, event.y bbox = self.bg_canvas.bbox(link_text) if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]: self.open_terminal_with_command(dep.get('install_cmd')) self.bg_canvas.bind('', _on_click) def _render_build_page(self): # Title and instructions on canvas (fully transparent background) self._render_title('Build', y=40) self._render_paragraph('Choose a target and build the artifacts.', y=90) # Canvas-drawn radio buttons (no ttk widgets to avoid grey/white boxes) if not hasattr(self, 'deploy_env_value'): self.deploy_env_value = 'Dev' radio_y = 130 left = 56 spacing = 110 # Draw three radio options self._build_radio_items = [] options = [('Dev', left), ('Service', left + spacing), ('Prod', left + spacing * 2)] for label, x in options: # outer circle r = 9 circle = ui.canvas_oval(self, x, radio_y, x + 2*r, radio_y + 2*r, outline='#1d1d1f', width=2) self._canvas_items.append(circle) # selected dot if self.deploy_env_value == label: dot = ui.canvas_oval(self, x+4, radio_y+4, x+2*r-4, radio_y+2*r-4, fill='#1d1d1f', outline='') self._canvas_items.append(dot) text = ui.canvas_text(self, x + 2*r + 8, radio_y - 2, label, fill='#1d1d1f', font=('Helvetica', 12)) self._canvas_items.append(text) self._build_radio_items.append((label, circle, text)) # Click handling for radio selection def _on_click(event): ex, ey = event.x, event.y for label, circle, text in self._build_radio_items: bbox_c = self.bg_canvas.bbox(circle) bbox_t = self.bg_canvas.bbox(text) hit = False if bbox_c and bbox_c[0] <= ex <= bbox_c[2] and bbox_c[1] <= ey <= bbox_c[3]: hit = True if bbox_t and bbox_t[0] <= ex <= bbox_t[2] and bbox_t[1] <= ey <= bbox_t[3]: hit = True if hit: self.deploy_env_value = label # Re-render only radios by re-drawing the page self._clear_canvas_page() self._render_build_page() self.update_footer() break self.bg_canvas.bind('', _on_click) # Embedded console overlay (semi-transparent black backdrop + scrolled text) self._ensure_console_overlay(radio_bottom_y=radio_y + 24) # Show a command preview with PS1-style prompt and blinking cursor preview = self._compose_build_preview() self._console_set_preview(preview) # Ensure state holders exist if not hasattr(self, 'last_build_log_path'): self.last_build_log_path = None # Update Next button label/state self.update_footer() # Bind Enter to trigger Build on this page def _enter_build(_evt=None): self.perform_build() try: self.root.bind('', _enter_build) self.root.bind('', _enter_build) except Exception: pass def _render_deploy_page(self): self._render_title('Deploy', y=40) self._render_paragraph('Preparing to deploy. We will verify steps and perform actions as needed.', y=90) # Render simple list of steps (static view). Runtime updates can redraw as needed. y = 140 left = 56 if not hasattr(self, 'deploy_steps'): self.deploy_steps = [ {'name': 'Build Prole macOS app', 'status': 'pending'}, {'name': 'Build workstation Docker image', 'status': 'pending'}, {'name': 'Check Docker is running', 'status': 'pending'}, {'name': 'Install Prole.app', 'status': 'pending'}, ] for step in self.deploy_steps: # status circle (pending empty) self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, outline='#b0b0b0')) self._canvas_items.append(ui.canvas_text(self, left+26, y-2, step['name'], fill='#1d1d1f', font=('Helvetica', 12))) y += 26 def on_prev(self): # Custom prev navigation for dependency pages when filtering current_id = self.pages[self.page_index][0] # Close Terminal if leaving build summary via Prev if current_id == 'build_summary': try: self.close_build_terminal() except Exception: pass if current_id.startswith('dep_'): seq = self._dep_navigation_sequence() try: i = seq.index(current_id) except ValueError: i = -1 if i > 0: self.show_page(seq[i - 1]) return else: # Go back to summary if there is no previous in sequence self.show_page('deps_summary') return # Default prev if self.page_index > 0: self.show_page(self.page_index - 1) def on_next(self): # Special handling for dynamic labels current_id = self.pages[self.page_index][0] if current_id == 'deps_summary': # Determine where to go from summary # Always go to the next dependency or build if self.all_dependencies_installed(): self.show_page('build') return # Go to first missing dependency page seq = self._dep_navigation_sequence() target = seq[0] if seq else 'build' self.show_page(target) return if current_id == 'build': # Build page button: Deploy on success (and then show summary), otherwise (re)run build if getattr(self, '_built_success', False): try: self.open_drag_install_window() except Exception: pass # Always navigate to Build Summary per spec self.show_page('build_summary') return # Start or rerun build; Build Summary opens automatically on completion self.perform_build() return # No longer using external Deploy page if current_id.startswith('dep_'): # Navigate within dependency sequence seq = self._dep_navigation_sequence() try: i = seq.index(current_id) except ValueError: i = -1 if i >= 0 and i < len(seq) - 1: self.show_page(seq[i + 1]) return else: # After last relevant dep page, go to build self.show_page('build') return if self.page_index < len(self.pages) - 1: self.show_page(self.page_index + 1) def on_finish(self): # Close app on Finish self.root.quit() def update_footer(self): # Default hidden states self.prev_button.state(['!disabled']) self.next_button.state(['!disabled']) self.finish_button.state(['!disabled']) first = self.page_index == 0 last = self.page_index == len(self.pages) - 1 # Base labels self.next_button.configure(text='Next') self.finish_button.configure(text='Finish') # Page-specific adjustments pid = self.pages[self.page_index][0] # Welcome page no longer gates navigation; treat as normal first page if pid == 'deps_summary': # Dependencies screens use Prev/Next wording self.next_button.configure(text='Next') if pid == 'build': # Build page: show Build/Deploy/Next depending on state if getattr(self, '_built_success', False): self.next_button.configure(text='Deploy') elif getattr(self, '_build_attempted', False): # Attempted and failed → keep as Build (no Next on Build page) self.next_button.configure(text='Build') else: self.next_button.configure(text='Build') # Visibility rules if first: self.prev_button.pack_forget() self.finish_button.pack_forget() if not self.next_button.winfo_ismapped(): self.next_button.pack(side='right', padx=(0, 8), pady=10) elif last: # Special-case: Build is not the true final if we have build_summary. # On Build page (last before summary), show Prev + Build/Deploy/Next; hide Finish. if pid == 'build': if not self.prev_button.winfo_ismapped(): self.prev_button.pack(side='left', padx=(16, 8), pady=10) if not self.next_button.winfo_ismapped(): self.next_button.pack(side='right', padx=(0, 8), pady=10) self.finish_button.pack_forget() else: if not self.prev_button.winfo_ismapped(): self.prev_button.pack(side='left', padx=(16, 8), pady=10) self.next_button.pack_forget() if not self.finish_button.winfo_ismapped(): self.finish_button.pack(side='right', padx=(0, 20), pady=10) else: # Middle pages: Prev + Next if not self.prev_button.winfo_ismapped(): self.prev_button.pack(side='left', padx=(16, 8), pady=10) if not self.next_button.winfo_ismapped(): self.next_button.pack(side='right', padx=(0, 8), pady=10) self.finish_button.pack_forget() def _splash_should_hide_nav(self) -> bool: """Deprecated: Welcome page no longer performs dependency checks or gates navigation.""" return False def _create_page_welcome(self): f = self._page_container() ttk.Label(f, text='Welcome to Prole', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6)) msg = ( 'Thanks for joining Prole. We will prepare your system and install the software needed to build and run Prole.' ) ttk.Label(f, text=msg, style='Body.TLabel', wraplength=800, justify='left').pack(anchor='w', padx=24) self._register_page('welcome', f) def _create_page_dependencies_summary(self): f = self._page_container() ttk.Label(f, text='Dependencies', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6)) self.deps_container = ttk.Frame(f) self.deps_container.pack(fill='both', expand=True, padx=16, pady=8) self.dep_status = {} for dep in self.dependencies: row = ttk.Frame(self.deps_container) row.pack(fill='x', pady=6) indicator = tk.Canvas(row, width=18, height=18, highlightthickness=0) indicator.pack(side='left', padx=8) name = ttk.Label(row, text=dep['name'], style='Body.TLabel') name.pack(side='left') info = ttk.Label(row, text='', style='Dim.TLabel') info.pack(side='left', padx=10) self.dep_status[dep['id']] = {'canvas': indicator, 'info': info, 'dep': dep} # Verify all checkbox (controls navigation behavior) chk = ttk.Checkbutton(f, text='Verify all dependencies', variable=self.verify_mode, command=self.update_footer) chk.pack(anchor='w', padx=24, pady=(4, 0)) # Message label must be created before refresh to avoid AttributeError self.deps_msg = ttk.Label(f, text='', style='Dim.TLabel') self.deps_msg.pack(anchor='w', padx=24, pady=(8, 8)) # Now we can safely populate the UI. Start async scan to avoid startup delay self.deps_msg.configure(text='Checking dependencies...') self.start_dependency_scan() self._register_page('deps_summary', f) def start_dependency_scan(self): """Scan dependencies in a background thread to avoid blocking UI startup.""" if self.validation_running: return self.validation_running = True # Hold incremental results so we can update the status line and icons progressively self._dep_scan_results = {} def worker(): for dep in self.dependencies: try: ok, location, version = self.get_dep_info(dep) except Exception: ok, location, version = False, None, None did = dep['id'] # Save and apply incrementally self._dep_scan_results[did] = (ok, location, version) self.root.after(0, lambda d=did, o=ok, l=location, v=version: self._apply_dependency_incremental(d, o, l, v)) try: time.sleep(0.02) except Exception: pass # After all are processed, finalize pass to unify any remaining labels self.root.after(0, lambda: self._apply_dependency_scan(dict(self._dep_scan_results))) t = threading.Thread(target=worker, daemon=True) self.validation_thread = t t.start() def _apply_dependency_incremental(self, dep_id: str, ok: bool, location, version): """Update the dependencies page row and message as each check completes.""" if not hasattr(self, 'dep_status'): return slot = self.dep_status.get(dep_id) if not slot: return # Update dot icon and info text for this row self._draw_status(slot['canvas'], 'success' if ok else 'error') if ok: norm_ver = self.normalize_version(version) if version else '' slot['info'].configure(text=(norm_ver or '')) else: slot['info'].configure(text='Not installed') # Update the message line with current missing list if hasattr(self, '_dep_scan_results'): missing = [self.dep_status[d]['dep']['name'] for d, res in self._dep_scan_results.items() if not res[0] and d in self.dep_status] if hasattr(self, 'deps_msg') and self.deps_msg is not None: if missing: self.deps_msg.configure(text=f"Preparing to install ... {', '.join(missing)}") else: self.deps_msg.configure(text='All dependencies installed.') # Footer may need to react if verify mode is enabled self.update_footer() def _apply_dependency_scan(self, results: dict): """Apply scan results to the summary UI and footer labels.""" self.validation_running = False any_missing = False for did, slot in self.dep_status.items(): ok, location, version = results.get(did, (False, None, None)) self._draw_status(slot['canvas'], 'success' if ok else 'error') if ok: norm_ver = self.normalize_version(version) if version else '' slot['info'].configure(text=(norm_ver or '')) else: any_missing = True slot['info'].configure(text='Not installed') has_msg = hasattr(self, 'deps_msg') and self.deps_msg is not None if any_missing: missing = [slot['dep']['name'] for did, slot in self.dep_status.items() if not results.get(did, (False, None, None))[0]] if missing and has_msg: self.deps_msg.configure(text=f"Preparing to install ... {', '.join(missing)}") else: if has_msg: self.deps_msg.configure(text='All dependencies installed.') self.update_footer() def _create_page_dependency(self, dep): f = self._page_container() ttk.Label(f, text=dep['name'], style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6)) ttk.Label(f, text=dep['description'], style='Body.TLabel', wraplength=800, justify='left').pack(anchor='w', padx=24) status_var = tk.StringVar(value='Checking...') loc_var = tk.StringVar(value='') ver_var = tk.StringVar(value='') info_frame = ttk.Frame(f) info_frame.pack(fill='x', padx=24, pady=12) # Pretty status row with an icon status_row = ttk.Frame(info_frame) status_row.pack(anchor='w', fill='x') status_icon = tk.Canvas(status_row, width=18, height=18, highlightthickness=0) status_icon.pack(side='left', padx=(0, 8), pady=(2, 0)) ttk.Label(status_row, textvariable=status_var, style='Body.TLabel').pack(side='left') # Detail rows (hidden when not installed) loc_label = ttk.Label(info_frame, textvariable=loc_var, style='Dim.TLabel') ver_label = ttk.Label(info_frame, textvariable=ver_var, style='Dim.TLabel') loc_label.pack(anchor='w') ver_label.pack(anchor='w') link = ttk.Label(f, text='Click to install', foreground='#0a84ff', cursor='hand2', style='Body.TLabel') link.pack(anchor='w', padx=24, pady=(12, 0)) def _draw_status_icon(canvas, status): ui.canvas_clear(canvas) if status == 'installed': ui.canvas_oval_on(canvas, 1, 1, 17, 17, fill='#34c759', outline='') ui.canvas_line_on(canvas, 4, 9, 8, 13, fill='white', width=2) ui.canvas_line_on(canvas, 8, 13, 15, 5, fill='white', width=2) elif status == 'missing': # Friendly warning dot with exclamation ui.canvas_oval_on(canvas, 1, 1, 17, 17, fill='#ff9f0a', outline='') ui.canvas_line_on(canvas, 9, 5, 9, 11, fill='white', width=2) ui.canvas_oval_on(canvas, 8, 13, 10, 15, fill='white', outline='white') def check_then_update(): ok, location, version = self.get_dep_info(dep) if ok: status_var.set('Installed') _draw_status_icon(status_icon, 'installed') loc_var.set(f'Location: {location or ""}') norm_ver = self.normalize_version(version) if version else None ver_var.set(f'Version: {norm_ver or ""}') # Ensure details visible try: loc_label.pack_configure() ver_label.pack_configure() link.pack_forget() except Exception: pass else: status_var.set('Not installed') _draw_status_icon(status_icon, 'missing') # Hide undefined details instead of showing dashes try: loc_label.pack_forget() ver_label.pack_forget() except Exception: pass try: # Keep install link visible if we have a command if dep.get('install_cmd') and not link.winfo_ismapped(): link.pack(anchor='w', padx=24, pady=(12, 0)) except Exception: pass self.root.after(50, check_then_update) def do_install(event=None): self.open_terminal_with_command(dep.get('install_cmd')) if dep.get('install_cmd'): link.bind('', do_install) else: try: link.pack_forget() except Exception: pass self._register_page(f'dep_{dep["id"]}', f) def _dep_navigation_sequence(self, force_all: bool = False): """Return a list of dependency page ids to traverse next. - If force_all is True or verify_mode is True: include all dep pages in defined order. - Else: include only missing dependency pages based on current checks. """ if force_all or self.verify_mode.get(): return [f'dep_{d["id"]}' for d in self.dependencies] seq = [] for d in self.dependencies: ok, _, _ = self.get_dep_info(d) if not ok: seq.append(f'dep_{d["id"]}') return seq def _create_page_build(self): f = self._page_container() ttk.Label(f, text='Build', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6)) ttk.Label(f, text='Choose a target and build the artifacts.', style='Body.TLabel').pack(anchor='w', padx=24) wrap = ttk.Frame(f) wrap.pack(anchor='w', padx=24, pady=12) ttk.Label(wrap, text='Target Environment:', style='Body.TLabel').pack(side='left') self.deploy_environment = tk.StringVar(value='Dev') ttk.Combobox(wrap, textvariable=self.deploy_environment, values=['Dev', 'Service', 'Prod'], state='readonly', width=18).pack(side='left', padx=10) # Build output console self.build_output = scrolledtext.ScrolledText(f, height=12, bg='#fafafa', fg='#1d1d1f') self.build_output.pack(fill='both', expand=True, padx=24, pady=12) self._register_page('build', f) def _create_page_deploy(self): f = self._page_container() ttk.Label(f, text='Deploy', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6)) ttk.Label(f, text='Preparing to deploy. We will verify steps and perform actions as needed.', style='Body.TLabel', wraplength=800).pack(anchor='w', padx=24) # Reuse existing deploy steps UI but on light theme self.deploy_steps = [ {'name': 'Build Prole macOS app', 'status': 'pending'}, {'name': 'Check Docker is running', 'status': 'pending'}, {'name': 'Check or configure container registry', 'status': 'pending'}, {'name': 'Ensure target cluster', 'status': 'pending'}, {'name': 'Build prole-db Docker image', 'status': 'pending'}, {'name': 'Tag Docker image for registry', 'status': 'pending'}, {'name': 'Push image to registry', 'status': 'pending'}, {'name': 'Import image to k3d cluster (Dev only)', 'status': 'pending'}, {'name': 'Install LaunchAgent for port-forwards (Dev)', 'status': 'pending'}, ] self.deploy_widgets = {} container = ttk.Frame(f) container.pack(fill='both', expand=True, padx=16, pady=8) for step in self.deploy_steps: self._create_deploy_row(container, step) self._register_page('deploy', f) def _create_deploy_row(self, parent, step): 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) self.deploy_widgets[step['name']] = {'canvas': canvas, 'label': status, 'step': step} self._draw_status(canvas, 'pending') def _draw_status(self, canvas, status): ui.canvas_clear(canvas) if status == 'success': ui.canvas_oval_on(canvas, 2, 2, 18, 18, fill='#34c759', outline='') ui.canvas_line_on(canvas, 5, 10, 9, 14, fill='white', width=2) ui.canvas_line_on(canvas, 9, 14, 16, 6, fill='white', width=2) elif status == 'running': ui.canvas_oval_on(canvas, 2, 2, 18, 18, fill='#ffd60a', outline='') elif status == 'error': ui.canvas_oval_on(canvas, 2, 2, 18, 18, fill='#ff3b30', outline='') else: ui.canvas_oval_on(canvas, 2, 2, 18, 18, outline='#b0b0b0') # --------------- Dependency helpers --------------- def refresh_dependencies_ui(self): """Deprecated synchronous refresh retained for compatibility. Prefer start_dependency_scan -> _apply_dependency_scan. """ self.start_dependency_scan() def all_dependencies_installed(self): for dep in self.dependencies: ok, _, _ = self.get_dep_info(dep) if not ok: return False return True def get_dep_info(self, dep): """Delegate dependency probing to installer.config.get_dep_info.""" return inst_config.get_dep_info(dep) def normalize_version(self, text: str) -> str: """Normalize versions via installer.config.normalize_version.""" return inst_config.normalize_version(text) def open_terminal_with_command(self, command: str | None): if not command: return try: # Always create a brand-new Terminal window and paste via clipboard if platform.system() == 'Darwin': win_id = self._terminal_create_new_window() if win_id: # Position reasonably l, t, r, b = self._compute_terminal_bounds(radio_bottom_y=120) self._terminal_set_bounds_by_id(win_id, l, t, r, b) self._terminal_paste_by_id(win_id, command, press_enter=False) return # Fallback: copy to clipboard and open Terminal subprocess.run(['bash', '-lc', f'printf %s {shlex.quote(command)} | pbcopy && open -a Terminal']) except Exception: webbrowser.open_new_tab('https://brew.sh') # ---------------- Build integration ---------------- def perform_build(self): """Run Prole build in an embedded console (Scoped bash subprocess).""" # Prepare logs dir and file try: self._build_attempted = True except Exception: pass # reset success flag until proven otherwise try: self._built_success = False except Exception: pass logs_dir = PROJECT_ROOT / 'logs' try: logs_dir.mkdir(parents=True, exist_ok=True) except Exception: pass ts = time.strftime('%Y%m%d-%H%M%S') log_path = logs_dir / f'build-{ts}.log' self.last_build_log_path = str(log_path) # Compose build command env = getattr(self, 'deploy_env_value', 'Dev') base_cmd = self.get_build_command(env) # Hostname safety guard: ensure commands run only on the machine that launched the installer guard = '' if getattr(self, 'expected_host', None): eh = self.expected_host guard = f'host=$(hostname -s); if [ "$host" != "{eh}" ]; then echo "ERROR: wrong host $host (expected {eh})"; exit 1; fi; ' # Simulate pressing Enter on the previewed command; do not echo a duplicate command self._console_press_enter() # Compose a verbose wrapped build command with environment diagnostics and tracing full_cmd = guard + self._compose_verbose_build_command(base_cmd) # Run in embedded console self._run_in_console(full_cmd, self.last_build_log_path, on_complete=lambda rc: self._on_build_complete(rc)) def _compose_verbose_build_command(self, base_cmd: str) -> str: """Wrap the provided build command with a verbose, diagnostic-rich shell script. Adds: - Timestamps and section headers - Platform/OS/tooling info (uname, macOS version, Xcode/Swift, Java, Maven, Docker, Git) - set -euxo pipefail for tracing and early failure - Echo of the actual build command """ # Use portable bash; guard external tool probes to avoid hard failures prologue = r''' echo "====[PROLE] Build started $(date '+%Y-%m-%d %H:%M:%S %Z')"; echo "---- System -------------------------------------------------------"; uname -a || true; printf "ARCH=%s\n" "$(uname -m)" || true; if command -v sw_vers >/dev/null 2>&1; then sw_vers || true; fi; echo "---- Tooling ------------------------------------------------------"; if command -v xcodebuild >/dev/null 2>&1; then xcodebuild -version || true; else echo "xcodebuild: not found"; fi; if command -v swift >/dev/null 2>&1; then swift --version || true; else echo "swift: not found"; fi; if command -v java >/dev/null 2>&1; then java -version 2>&1 | sed 's/^/java: /'; else echo "java: not found"; fi; if command -v mvn >/dev/null 2>&1; then mvn -v || true; else echo "mvn: not found"; fi; if command -v docker >/dev/null 2>&1; then docker version || true; else echo "docker: not found"; fi; echo "---- Git ----------------------------------------------------------"; if command -v git >/dev/null 2>&1; then \ (git -C "$(pwd)" rev-parse --is-inside-work-tree >/dev/null 2>&1 && \ echo "repo: $(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)")" && \ echo "branch: $(git rev-parse --abbrev-ref HEAD 2>/dev/null)" && \ echo "commit: $(git rev-parse --short HEAD 2>/dev/null)" && \ git status --porcelain=v1 | sed 's/^/ /' || true) || echo "not a git repo"; \ else echo "git: not found"; fi; echo "-------------------------------------------------------------------"; ''' # The actual build with tracing and timing wrapped = f""" ( set -euxo pipefail {prologue} echo "====[PROLE] Executing build command:"; printf '%s\n' {shlex.quote(base_cmd)}; echo "-------------------------------------------------------------------"; start_ts=$(date +%s || echo 0); {base_cmd} rc=$? end_ts=$(date +%s || echo 0); dur=$((end_ts - start_ts)); echo "-------------------------------------------------------------------"; if [ $rc -eq 0 ]; then echo "====[PROLE] Build finished OK in ${{dur}}s at $(date '+%Y-%m-%d %H:%M:%S %Z')"; else echo "====[PROLE] Build FAILED (rc=$rc) in ${{dur}}s at $(date '+%Y-%m-%d %H:%M:%S %Z')"; fi exit $rc ) """ return wrapped def _on_build_complete(self, returncode: int): # After build completes, update state and navigate to Build Summary try: self._built_success = (returncode == 0) except Exception: self._built_success = False # If success on macOS, open the drag & drop install Finder window now if getattr(self, '_built_success', False): try: self.open_drag_install_window() except Exception: pass # Re-enable Next and go to summary try: self.next_button.state(['!disabled']) except Exception: pass self.show_page('build_summary') # ---------------- Embedded console helpers ---------------- def _ensure_console_overlay(self, radio_bottom_y: int = 160): """Create semi-transparent black backdrop and a ScrolledText console overlay. The overlay is placed within slide_area between given top and bottom margins. """ # Compute geometry within slide area geom = self._compute_console_geometry(radio_bottom_y) left, top, width, height = geom # Draw a stippled rectangle on the canvas to simulate ~60% opacity rect = ui.canvas_rectangle(self, left, top, left + width, top + height, fill='#000000', outline='') try: # Apply stipple directly on the canvas item if supported self.bg_canvas.itemconfig(rect, stipple='gray50') except Exception: pass self._canvas_items.append(rect) # Create ScrolledText overlay txt = scrolledtext.ScrolledText(self.slide_area, wrap='word', bg='#000000', fg='#ffffff', insertbackground='#ffffff', font=('Menlo', 11), relief='flat', bd=0, highlightthickness=0) txt.place(x=left + 8, y=top + 8, width=max(50, width - 16), height=max(50, height - 16)) self._overlay_widgets.append(txt) self._console_text = txt # Keep overlay positioned on resize def _on_resize(_evt=None): l, t, w, h = self._compute_console_geometry(radio_bottom_y) try: self.bg_canvas.coords(rect, l, t, l + w, t + h) except Exception: pass try: txt.place(x=l + 8, y=t + 8, width=max(50, w - 16), height=max(50, h - 16)) except Exception: pass # Bind to slide area; store bind id to unbind later self._overlay_bind_id = self.slide_area.bind('', _on_resize) def _compute_console_geometry(self, radio_bottom_y: int) -> tuple[int, int, int, int]: """Return (left, top, width, height) for the console overlay area.""" try: w = self.slide_area.winfo_width() h = self.slide_area.winfo_height() except Exception: w, h = 1000, 620 margin = 24 top = max(radio_bottom_y + 10, 120) bottom = max(top + 180, h - 16) # ensure some height height = max(160, bottom - top - 80) if bottom - top > 260 else max(140, h - top - 24) # Recompute bottom based on height bottom = min(h - 24, top + height) left = margin width = max(300, w - 2 * margin) return (left, top, width, bottom - top) def _append_console(self, text: str): txt = getattr(self, '_console_text', None) if not txt: return try: txt.configure(state='normal') txt.insert('end', text) txt.see('end') txt.configure(state='disabled') except Exception: pass def _console_press_enter(self): """Simulate pressing Enter on the console preview line: remove blinking cursor if present and add a newline.""" txt = getattr(self, '_console_text', None) if not txt: return # Stop cursor blinking if getattr(self, '_cursor_blink_after_id', None): try: self.root.after_cancel(self._cursor_blink_after_id) except Exception: pass self._cursor_blink_after_id = None self._cursor_blink_visible = False try: txt.configure(state='normal') # If last char is our fake cursor, remove it try: last_char = txt.get('end-2c', 'end-1c') if last_char in ('_', '|'): txt.delete('end-2c', 'end-1c') except Exception: pass txt.insert('end', '\n') txt.see('end') txt.configure(state='disabled') except Exception: pass # ----- Command preview & blinking cursor helpers ----- def _get_user_host(self) -> tuple[str, str]: try: user = os.environ.get('USER') or os.getlogin() except Exception: user = 'user' host = getattr(self, 'expected_host', None) or (platform.node() or 'host').split('.')[0] return user, host def _compose_build_preview(self) -> str: env = getattr(self, 'deploy_env_value', 'Dev') cmd = self.get_build_command(env) user, host = self._get_user_host() return f"[{user}@{host}]# {cmd}" def _console_set_preview(self, line: str): """Clear console and show a single-line preview with blinking cursor.""" txt = getattr(self, '_console_text', None) if not txt: return # Stop any previous blinking first if getattr(self, '_cursor_blink_after_id', None): try: self.root.after_cancel(self._cursor_blink_after_id) except Exception: pass self._cursor_blink_after_id = None self._cursor_blink_visible = False try: txt.configure(state='normal') txt.delete('1.0', 'end') txt.insert('end', line) txt.see('end') txt.configure(state='disabled') except Exception: return # Start blinking cursor at end of line def blink(): t = getattr(self, '_console_text', None) if t is None: self._cursor_blink_after_id = None return try: t.configure(state='normal') # Remove existing cursor if self._cursor_blink_visible: # Delete last character if it's our cursor end_index = t.index('end-1c') if end_index and end_index != '1.0': last_char = t.get('end-2c', 'end-1c') if last_char in ('_', '|'): t.delete('end-2c', 'end-1c') self._cursor_blink_visible = False else: # Append cursor t.insert('end', '_') self._cursor_blink_visible = True t.see('end') t.configure(state='disabled') except Exception: self._cursor_blink_after_id = None return # schedule next toggle self._cursor_blink_after_id = self.root.after(600, blink) self._cursor_blink_after_id = self.root.after(600, blink) def _run_in_console(self, command: str, log_path: str, on_complete=None): """Run a bash -lc command in a background subprocess and stream output to the console and a log file. This function does not echo the command into the console so that the UI behaves like pressing Enter on the previously previewed command line. """ # Ensure console exists if not getattr(self, '_console_text', None): self._ensure_console_overlay(160) # Stop cursor blinking before starting execution if getattr(self, '_cursor_blink_after_id', None): try: self.root.after_cancel(self._cursor_blink_after_id) except Exception: pass self._cursor_blink_after_id = None self._cursor_blink_visible = False # Terminate any previous process if getattr(self, '_running_process', None): self._terminate_running_process() # Open log file try: self._console_log_fp = open(log_path, 'a', buffering=1, encoding='utf-8') except Exception: self._console_log_fp = None # Start process group for safe termination def preexec(): try: os.setsid() except Exception: pass try: proc = subprocess.Popen(['bash', '-lc', command], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, preexec_fn=preexec) self._running_process = proc except Exception as e: self._append_console(f"Failed to start process: {e}\n") if self._console_log_fp: try: self._console_log_fp.write(f"Failed to start process: {e}\n") except Exception: pass self._running_process = None return # Disable Next while running try: self.next_button.configure(text='Building…') self.next_button.state(['disabled']) except Exception: pass # Reader thread def reader(): rc = None try: for line in proc.stdout: if line is None: break self.root.after(0, lambda s=line: self._append_console(s)) if self._console_log_fp: try: self._console_log_fp.write(line) except Exception: pass rc = proc.wait() except Exception: pass finally: if self._console_log_fp: try: self._console_log_fp.flush() self._console_log_fp.close() except Exception: pass self._console_log_fp = None self._running_process = None if on_complete: self.root.after(0, lambda: on_complete(rc if rc is not None else -1)) t = threading.Thread(target=reader, daemon=True) t.start() def _terminate_running_process(self): proc = getattr(self, '_running_process', None) if not proc: return try: pgid = os.getpgid(proc.pid) os.killpg(pgid, signal.SIGTERM) except Exception: try: proc.terminate() except Exception: pass # best-effort kill after short delay try: for _ in range(10): if proc.poll() is not None: break time.sleep(0.05) if proc.poll() is None: try: pgid = os.getpgid(proc.pid) os.killpg(pgid, signal.SIGKILL) except Exception: proc.kill() except Exception: pass self._running_process = None # ---------------- Build helpers (Terminal window management) ---------------- def _terminal_create_new_window(self) -> str | None: """Create a brand-new Terminal window (never reuse existing) and return its id.""" if platform.system() != 'Darwin': return None osa = ''' tell application "Terminal" to activate delay 0.05 tell application "System Events" if exists process "Terminal" then tell process "Terminal" set frontmost to true try click menu item "New Window" of menu "Shell" of menu bar 1 on error keystroke "n" using {command down} end try end tell end if end tell delay 0.1 tell application "Terminal" try set _w to front window set _id to id of _w do script "" in _w return _id on error return "" end try end tell ''' try: result = subprocess.run(['osascript', '-e', osa], capture_output=True, text=True) if result.returncode == 0: sid = result.stdout.strip() return sid or None except Exception: pass return None def _terminal_set_bounds_by_id(self, win_id: str, l: int, t: int, r: int, b: int): if platform.system() != 'Darwin' or not win_id: return osa = f'''tell application "Terminal" to try set the bounds of every window whose id is {win_id} to {{{l}, {t}, {r}, {b}}} end try''' try: subprocess.run(['osascript', '-e', osa]) except Exception: pass def _terminal_paste_by_id(self, win_id: str, text: str, press_enter: bool = False): if platform.system() != 'Darwin' or not win_id: return # Put text on clipboard and paste into our specific window try: subprocess.run(['bash', '-lc', f'printf %s {shlex.quote(text)} | pbcopy']) except Exception: pass osa = ''' tell application "Terminal" try set _wins to every window whose id is {win_id} if (count of _wins) > 0 then set front window to item 1 of _wins end try activate end tell delay 0.05 tell application "System Events" keystroke "v" using {command down} end tell ''' if press_enter: osa += '\n' + 'tell application "System Events" to key code 36' try: subprocess.run(['osascript', '-e', osa]) except Exception: pass def _compute_terminal_bounds(self, radio_bottom_y: int = 160) -> tuple[int, int, int, int]: """Compute terminal window bounds (left, top, right, bottom) to fit inside the installer window between the radio buttons and the footer.""" try: # Window absolute position x0 = self.root.winfo_rootx() y0 = self.root.winfo_rooty() w = self.root.winfo_width() h = self.root.winfo_height() except Exception: # Reasonable defaults x0, y0, w, h = 200, 200, 1000, 700 margin = 24 top = y0 + radio_bottom_y + 10 bottom = y0 + h - 90 # leave space for footer left = x0 + margin right = x0 + w - margin # Ensure minimum height if bottom - top < 160: bottom = top + 160 return (left, top, right, bottom) def open_build_terminal_for_canvas_area(self, radio_bottom_y: int = 160): """Open a brand-new Terminal.app window and size it to nestle inside the installer.""" if platform.system() != 'Darwin': return l, t, r, b = self._compute_terminal_bounds(radio_bottom_y) win_id = self._terminal_create_new_window() if win_id: self._terminal_set_bounds_by_id(win_id, l, t, r, b) self.build_terminal_window_id = win_id def _start_terminal_follow(self, radio_bottom_y: int = 160): """Bind window Configure to keep Terminal bounds anchored to installer area.""" if platform.system() != 'Darwin': return self._terminal_follow_rby = radio_bottom_y if getattr(self, '_terminal_follow_bound', False): return def _follow(_evt=None): # Debounce slightly if getattr(self, '_terminal_follow_after', None): try: self.root.after_cancel(self._terminal_follow_after) except Exception: pass def _do(): if not getattr(self, 'build_terminal_window_id', None): return l, t, r, b = self._compute_terminal_bounds(self._terminal_follow_rby) self._terminal_set_bounds_by_id(self.build_terminal_window_id, l, t, r, b) self._terminal_follow_after = self.root.after(60, _do) self.root.bind('', _follow) self._terminal_follow_bound = True def _stop_terminal_follow(self): if getattr(self, '_terminal_follow_bound', False): try: self.root.unbind('') except Exception: pass self._terminal_follow_bound = False def paste_into_terminal(self, text: str, press_enter: bool = False): """Paste text into Terminal by targeting the build window id if available.""" if platform.system() != 'Darwin': return win_id = getattr(self, 'build_terminal_window_id', None) if win_id: self._terminal_paste_by_id(win_id, text, press_enter=press_enter) return # Fallback to generic new window win_id = self._terminal_create_new_window() if win_id: self._terminal_paste_by_id(win_id, text, press_enter=press_enter) def close_build_terminal(self): if platform.system() != 'Darwin': return # stop following window self._stop_terminal_follow() win_id = getattr(self, 'build_terminal_window_id', None) if not win_id: # attempt to close front window politely osa = 'tell application "Terminal" to if (count of windows) > 0 then close front window' else: osa = f'tell application "Terminal" to try\nclose (every window whose id is {win_id})\nend try' try: subprocess.run(['osascript', '-e', osa]) except Exception: pass def get_build_command(self, env: str) -> str: """Delegate to installer.build.get_build_command.""" return inst_get_build_command(PROJECT_ROOT, env) # ---------------- Build Summary page ---------------- def _render_build_summary_page(self): # Build Summary: show combined stdout/stderr from last build log self._render_title('Build Summary', y=40) if getattr(self, '_built_success', False): self._render_paragraph('✅ Build completed successfully. You can now drag Prole.app into Applications. Output below:', y=90) else: # Troubleshooting header tips = ( "❌ Build failed. Troubleshooting tips:\n" "• Ensure Xcode Command Line Tools are installed: xcode-select --install\n" "• Verify Swift toolchain and SPM networking (try again; network hiccups can happen)\n" "• If on Apple Silicon, ensure dependencies target arm64 or install via Homebrew\n" "• Clean derived data / SPM cache if needed: rm -rf ~/Library/Developer/Xcode/DerivedData\n" "• Check Docker status if Docker-related steps are used\n" ) self._render_paragraph(tips, y=90) logp = getattr(self, 'last_build_log_path', None) # Place a scrolled text overlay to show the log try: # Use same geometry helper as console but with DARK theme (match Build console) l, t, w, h = self._compute_console_geometry(120) txt = scrolledtext.ScrolledText(self.slide_area, wrap='word', bg='#000000', fg='#ffffff', insertbackground='#ffffff', font=('Menlo', 11), relief='flat', bd=0, highlightthickness=0) txt.place(x=l, y=t, width=max(200, w), height=max(140, h)) self._overlay_widgets.append(txt) if logp and os.path.exists(logp): try: with open(logp, 'r', encoding='utf-8', errors='ignore') as fp: content = fp.read() txt.insert('1.0', content) except Exception as e: txt.insert('1.0', f"Failed to read log: {e}\n") else: txt.insert('1.0', 'No build log available.') txt.configure(state='disabled') # Keep overlay positioned on resize def _on_resize(_evt=None): try: l2, t2, w2, h2 = self._compute_console_geometry(120) txt.place(x=l2, y=t2, width=max(200, w2), height=max(140, h2)) except Exception: pass self._overlay_bind_id = self.slide_area.bind('', _on_resize) except Exception: # Fallback: just show path self._render_paragraph('No build log could be displayed.', y=120) # Add link to open the log file in Finder/TextEdit y_links = 90 + 24 if logp: link = ui.render_link(self, 56, y_links, 'Open build log file') self._canvas_items.append(link) def _open_log(event): ex, ey = event.x, event.y bbox = self.bg_canvas.bbox(link) if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]: try: if platform.system() == 'Darwin': subprocess.run(['open', logp]) else: webbrowser.open(f'file://{logp}') except Exception: pass self.bg_canvas.bind('', _open_log) # On success, also offer to open the dist folder if getattr(self, '_built_success', False): try: dist_dir = str(self._get_prole_dist_dir()) except Exception: dist_dir = None if dist_dir: y_links += 24 link2 = ui.render_link(self, 56, y_links, 'Open dist folder') self._canvas_items.append(link2) def _open_dist(event): ex, ey = event.x, event.y bbox = self.bg_canvas.bbox(link2) if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]: try: if platform.system() == 'Darwin': subprocess.run(['open', dist_dir]) else: webbrowser.open(f'file://{dist_dir}') except Exception: pass self.bg_canvas.bind('', _open_dist) # ---------------- Drag-and-drop install (macOS Finder) ---------------- def _get_prole_dist_dir(self) -> Path: return PROJECT_ROOT / 'prole-app' / 'dist' def ensure_applications_symlink(self): try: dist = self._get_prole_dist_dir() dist.mkdir(parents=True, exist_ok=True) link_path = dist / 'Applications' target = Path('/Applications') if link_path.exists() or link_path.is_symlink(): # If an existing correct symlink, leave as-is try: if link_path.is_symlink() and Path(os.readlink(link_path)) == target: return except Exception: pass # Otherwise attempt to remove and recreate try: if link_path.is_dir() and not link_path.is_symlink(): # do not remove a real Applications directory accidentally return link_path.unlink(missing_ok=True) except Exception: pass os.symlink(str(target), str(link_path)) except Exception: pass def open_drag_install_window(self): """Open a Finder window to the dist folder with an Applications symlink for drag-and-drop install.""" if platform.system() != 'Darwin': # Non-macOS: do nothing return try: dist = self._get_prole_dist_dir() app_path = dist / 'Prole.app' # Ensure symlink exists self.ensure_applications_symlink() # Open Finder to the dist folder osa = f''' tell application "Finder" activate try set theFolder to POSIX file "{str(dist)}" as alias make new Finder window to theFolder set current view of front Finder window to icon view set toolbar visible of front Finder window to true set statusbar visible of front Finder window to true select {{}} end try end tell ''' subprocess.run(['osascript', '-e', osa]) # If app exists, also reveal it if app_path.exists(): subprocess.run(['open', '-R', str(app_path)]) except Exception: try: # Fallback: open the folder normally subprocess.run(['open', str(self._get_prole_dist_dir())]) except Exception: pass def create_install_screen(self): """Create the dependency installer screen""" frame = tk.Frame(self.container, bg='#1a1a1a') self.screens['install'] = frame # Title title = ttk.Label(frame, text="Install Dependencies", style='Title.TLabel') title.pack(pady=(0, 30)) # Instructions instructions = tk.Label(frame, text="Install the following dependencies to proceed with Prole deployment:", bg='#1a1a1a', fg='#aaaaaa', font=('Helvetica', 11)) instructions.pack(pady=(0, 20)) # Dependencies list deps_frame = tk.Frame(frame, bg='#1a1a1a') deps_frame.pack(fill='both', expand=True) dependencies = [ { 'name': 'Docker', 'description': 'Container platform for running Prole services', 'url': 'https://www.docker.com/products/docker-desktop', 'install_cmd': None, 'check_cmd': 'docker --version' }, { 'name': 'Homebrew', 'description': 'Package manager for macOS', 'url': 'https://brew.sh', 'install_cmd': '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"', 'check_cmd': 'brew --version' }, { 'name': 'k3d', 'description': 'Lightweight wrapper to run k3s in Docker', 'url': 'https://k3d.io', 'install_cmd': 'brew install k3d', 'check_cmd': 'k3d --version' }, { 'name': 'kubectl', 'description': 'Kubernetes command-line tool', 'url': 'https://kubernetes.io/docs/tasks/tools/', 'install_cmd': 'brew install kubectl', 'check_cmd': 'kubectl version --client' }, { 'name': 'Helm', 'description': 'Kubernetes package manager', 'url': 'https://helm.sh', 'install_cmd': 'brew install helm', 'check_cmd': 'helm version' }, { 'name': 'krew', 'description': 'Kubectl plugin manager', 'url': 'https://krew.sigs.k8s.io', 'install_cmd': 'brew install krew', 'check_cmd': 'kubectl krew version' }, { 'name': 'cmctl', 'description': 'cert-manager CLI tool', 'url': 'https://cert-manager.io', 'install_cmd': 'brew install cmctl', 'check_cmd': 'cmctl version' } ] self.dep_status = {} for dep in dependencies: self.create_dependency_card(deps_frame, dep) # Generate installer script button script_btn = tk.Button(frame, text="Generate Installer Script", command=self.generate_installer_script, bg='#4a9eff', fg='white', activebackground='#3a8eef', font=('Helvetica', 12, 'bold'), padx=30, pady=15, cursor='hand2', relief='flat') script_btn.pack(pady=20) def create_dependency_card(self, parent, dep): """Create a dependency card with status and download link""" card = tk.Frame(parent, bg='#2a2a2a', relief='flat', bd=1) card.pack(fill='x', pady=5, padx=10) # Left side - info info_frame = tk.Frame(card, bg='#2a2a2a') info_frame.pack(side='left', fill='both', expand=True, padx=15, pady=15) name_label = tk.Label(info_frame, text=dep['name'], bg='#2a2a2a', fg='#ffffff', font=('Helvetica', 13, 'bold'), anchor='w') name_label.pack(fill='x') desc_label = tk.Label(info_frame, text=dep['description'], bg='#2a2a2a', fg='#aaaaaa', font=('Helvetica', 10), anchor='w') desc_label.pack(fill='x', pady=(5, 0)) # Right side - status and actions action_frame = tk.Frame(card, bg='#2a2a2a') action_frame.pack(side='right', padx=15, pady=15) # Status indicator status_label = tk.Label(action_frame, text="Checking...", bg='#2a2a2a', fg='#ffaa00', font=('Helvetica', 10)) status_label.pack(side='left', padx=10) self.dep_status[dep['name']] = {'label': status_label, 'dep': dep} # Download button download_btn = tk.Button(action_frame, text="Download", command=lambda url=dep['url']: webbrowser.open(url), bg='#28a745', fg='white', activebackground='#218838', font=('Helvetica', 10), padx=15, pady=5, cursor='hand2', relief='flat') download_btn.pack(side='left', padx=5) # Check status self.check_dependency(dep['name']) def check_dependency(self, name): """Check if a dependency is installed""" dep_info = self.dep_status[name] dep = dep_info['dep'] label = dep_info['label'] def check(): try: result = subprocess.run(dep['check_cmd'].split(), capture_output=True, text=True, timeout=5) if result.returncode == 0: label.configure(text="✓ Installed", fg='#28a745') else: label.configure(text="✗ Not Installed", fg='#dc3545') except Exception: label.configure(text="✗ Not Installed", fg='#dc3545') threading.Thread(target=check, daemon=True).start() def generate_installer_script(self): """Generate installer script by composing per-dependency templates from installer/scripts.""" script_path = PROJECT_ROOT / 'install_dependencies.sh' scripts_dir = PROJECT_ROOT / 'installer' / 'scripts' # Expected order of templates expected = [ 'install_prole_homebrew.sh', 'install_prole_ollama.sh', 'install_prole_k3d.sh', 'install_prole_kubectl.sh', 'install_prole_helm.sh', 'install_prole_krew.sh', 'install_prole_cmctl.sh', 'install_prole_kubectl_plugins.sh', ] def _validate_and_strip(path: Path) -> str: """Validate template format and return content stripped of shebang and initial set -e* line.""" try: text = path.read_text(encoding='utf-8') except Exception as e: raise RuntimeError(f"Failed to read {path.name}: {e}") lines = text.splitlines() if not lines: raise RuntimeError(f"Template {path.name} is empty") # Validate shebang if not lines[0].startswith('#!/bin/bash'): raise RuntimeError(f"Template {path.name} must start with #!/bin/bash") # Ensure there is a set -e or set -euo pipefail somewhere within first 10 lines has_set = any('set -e' in l for l in lines[:10]) if not has_set: raise RuntimeError(f"Template {path.name} must set '-e' or 'set -euo pipefail'") # Strip shebang i = 1 # Optionally strip empty/comment lines immediately after shebang while i < len(lines) and lines[i].strip() == '': i += 1 # If next non-empty is a "set -e*" line, drop it to avoid duplicates in composed script if i < len(lines) and lines[i].lstrip().startswith('set -e'): i += 1 body = "\n".join(lines[i:]).strip() + "\n" return body # Compose the final script errors: list[str] = [] parts: list[str] = [] header = ( "#!/bin/bash\n" "# Prole Dependencies Installer Script\n" "# Generated by Prole Installer\n\n" "set -euo pipefail\n\n" "echo \"Installing Prole dependencies...\"\n\n" ) parts.append(header) for fname in expected: p = scripts_dir / fname if not p.exists(): errors.append(f"Missing template: {fname}") continue try: body = _validate_and_strip(p) parts.append(f"# ---- {fname} ----\n") parts.append(body) parts.append("\n") except Exception as e: errors.append(str(e)) parts.append('echo "All dependencies installed successfully!"\n') if errors: messagebox.showerror("Template error", "\n".join(errors)) return try: with open(script_path, 'w', encoding='utf-8', newline='\n') as f: f.write("".join(parts)) os.chmod(script_path, 0o755) messagebox.showinfo("Success", f"Installer script generated at:\n{script_path}\n\n" "You can run it with: ./install_dependencies.sh") except Exception as e: messagebox.showerror("Error", f"Failed to generate script: {str(e)}") def create_deploy_screen(self): """Create the Deploy screen""" frame = tk.Frame(self.container, bg='#1a1a1a') self.screens['deploy'] = frame # Title title = ttk.Label(frame, text="Build and Deploy", style='Title.TLabel') title.pack(pady=(0, 30)) # Instructions instructions = tk.Label(frame, text="Build and deploy Prole services to k3d cluster", bg='#1a1a1a', fg='#aaaaaa', font=('Helvetica', 11)) instructions.pack(pady=(0, 20)) # Environment selector env_frame = tk.Frame(frame, bg='#1a1a1a') env_frame.pack(pady=(0, 10), fill='x') env_label = tk.Label(env_frame, text="Target Environment:", bg='#1a1a1a', fg='#dddddd', font=('Helvetica', 11)) env_label.pack(side='left', padx=(0, 10)) self.deploy_environment = tk.StringVar(value='Dev') env_combo = ttk.Combobox(env_frame, textvariable=self.deploy_environment, values=['Dev', 'Service', 'Prod'], state='readonly', width=18) env_combo.pack(side='left') env_help = tk.Label(env_frame, text="Dev = local k3d (prole-dev-cluster), Service = remote k3s at retropie.prole.org:6443, Prod = stretch of prole-service-cluster", bg='#1a1a1a', fg='#888888', font=('Helvetica', 9)) env_help.pack(side='left', padx=(12, 0)) # Deploy button deploy_btn = tk.Button(frame, text="Build and Deploy", command=self.start_deployment, bg='#4a9eff', fg='white', activebackground='#3a8eef', font=('Helvetica', 16, 'bold'), padx=40, pady=20, cursor='hand2', relief='flat') deploy_btn.pack(pady=20) # Progress frame progress_frame = tk.Frame(frame, bg='#1a1a1a') progress_frame.pack(fill='both', expand=True, pady=20) # Progress list # Initialize deploy steps; some names are updated dynamically when deployment starts self.deploy_steps = [ {'name': 'Build ProleStatus macOS app', 'status': 'pending'}, {'name': 'Check Docker is running', 'status': 'pending'}, {'name': 'Check or configure container registry', 'status': 'pending'}, {'name': 'Ensure target cluster', 'status': 'pending'}, {'name': 'Build prole-db Docker image', 'status': 'pending'}, {'name': 'Tag Docker image for registry', 'status': 'pending'}, {'name': 'Push image to registry', 'status': 'pending'}, {'name': 'Import image to k3d cluster (Dev only)', 'status': 'pending'}, ] self.deploy_widgets = {} for step in self.deploy_steps: self.create_deploy_step_widget(progress_frame, step) def create_deploy_step_widget(self, parent, step): """Create a widget for a deployment step""" step_frame = tk.Frame(parent, bg='#2a2a2a', relief='flat') step_frame.pack(fill='x', pady=5, padx=10) # Status indicator status_canvas = tk.Canvas(step_frame, width=30, height=30, bg='#2a2a2a', highlightthickness=0) status_canvas.pack(side='left', padx=15, pady=15) # Step name name_label = tk.Label(step_frame, text=step['name'], bg='#2a2a2a', fg='#ffffff', font=('Helvetica', 11), anchor='w') name_label.pack(side='left', fill='x', expand=True, padx=10) # Status text status_label = tk.Label(step_frame, text="Pending", bg='#2a2a2a', fg='#aaaaaa', font=('Helvetica', 10)) status_label.pack(side='right', padx=15) self.deploy_widgets[step['name']] = { 'canvas': status_canvas, 'label': status_label, 'step': step } # Draw initial pending state self.update_deploy_step_status(step['name'], 'pending') def update_deploy_step_status(self, step_name, status): """Update the status of a deployment step""" widget = self.deploy_widgets[step_name] canvas = widget['canvas'] label = widget['label'] step = widget['step'] step['status'] = status canvas.delete('all') if status == 'pending': ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#666', width=2) label.configure(text="Pending", fg='#aaaaaa') elif status == 'running': ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#ffaa00', width=2, fill='#ffaa00') label.configure(text="Running...", fg='#ffaa00') elif status == 'completed': ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#28a745', width=2, fill='#28a745') ui.canvas_text_on(canvas, 15, 15, '✓', fill='white', font=('Helvetica', 16, 'bold')) label.configure(text="Completed", fg='#28a745') elif status == 'error': ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#dc3545', width=2, fill='#dc3545') ui.canvas_text_on(canvas, 15, 15, '✗', fill='white', font=('Helvetica', 16, 'bold')) label.configure(text="Error", fg='#dc3545') elif status == 'skipped': ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#666', width=2, fill='#444444') label.configure(text="Skipped", fg='#888888') def start_deployment(self): """Start the deployment process""" threading.Thread(target=self.run_deployment, daemon=True).start() def run_deployment(self): """Run the deployment steps""" try: # Capture environment selection and prepare dynamic labels env = self.deploy_environment.get().strip() if env not in ('Dev', 'Service', 'Prod'): env = 'Dev' # Update step labels to reflect environment self.deploy_widgets['Ensure target cluster']['step']['name'] = f"Ensure target cluster ({env})" self.deploy_widgets['Ensure target cluster']['label'].master.master.children['!label'].configure(text=f"Ensure target cluster ({env})") # Step 0: Build Prole macOS app self.update_deploy_step_status('Build Prole macOS app', 'running') self.build_prole_app() self.update_deploy_step_status('Build Prole macOS app', 'completed') # Step 1: Check Docker self.update_deploy_step_status('Check Docker is running', 'running') if not self.check_docker_running(): self.update_deploy_step_status('Check Docker is running', 'error') messagebox.showerror("Error", "Docker is not running. Please start Docker Desktop.") return self.update_deploy_step_status('Check Docker is running', 'completed') # Step 2: Registry discovery/config self.update_deploy_step_status('Check or configure container registry', 'running') self.registry_url = self.ensure_registry_available(env) self.update_deploy_step_status('Check or configure container registry', 'completed') # Step 3: Ensure target cluster as per environment self.update_deploy_step_status('Ensure target cluster', 'running') self.create_or_select_cluster(env) self.update_deploy_step_status('Ensure target cluster', 'completed') # Step 3: Build Docker image self.update_deploy_step_status('Build prole-db Docker image', 'running') self.build_docker_image() self.update_deploy_step_status('Build prole-db Docker image', 'completed') # Step 4: Tag image self.update_deploy_step_status('Tag Docker image for registry', 'running') self.tag_docker_image() self.update_deploy_step_status('Tag Docker image for registry', 'completed') # Step 5: Push to registry self.update_deploy_step_status('Push image to registry', 'running') self.push_docker_image() self.update_deploy_step_status('Push image to registry', 'completed') # Step 6: Import to k3d (Dev only) if env == 'Dev': self.update_deploy_step_status('Import image to k3d cluster (Dev only)', 'running') self.import_k3d_image(cluster_name='prole-dev-cluster') self.update_deploy_step_status('Import image to k3d cluster (Dev only)', 'completed') else: self.update_deploy_step_status('Import image to k3d cluster (Dev only)', 'skipped') # Step 7: Install LaunchAgent for port-forwards (Dev) if env == 'Dev': self.update_deploy_step_status('Install LaunchAgent for port-forwards (Dev)', 'running') ok = self.install_launchagent_port_forwards() if ok: self.update_deploy_step_status('Install LaunchAgent for port-forwards (Dev)', 'completed') else: self.update_deploy_step_status('Install LaunchAgent for port-forwards (Dev)', 'error') return else: self.update_deploy_step_status('Install LaunchAgent for port-forwards (Dev)', 'skipped') messagebox.showinfo("Success", "Deployment completed successfully!") except Exception as e: messagebox.showerror("Error", f"Deployment failed: {str(e)}") def install_launchagent_port_forwards(self) -> bool: """Create/update the user LaunchAgent and helper script to manage kubectl port-forwards. - Helper script: ~/Library/Application Support/Prole/bin/prole-kpf.sh - LaunchAgent: ~/Library/LaunchAgents/org.prole.prole-db.kpf-dev.plist """ try: home = Path.home() bin_dir = home / 'Library' / 'Application Support' / 'Prole' / 'bin' run_dir = home / 'Library' / 'Application Support' / 'Prole' / 'run' plist_path = home / 'Library' / 'LaunchAgents' / 'org.prole.prole-db.kpf-dev.plist' script_path = bin_dir / 'prole-kpf.sh' bin_dir.mkdir(parents=True, exist_ok=True) run_dir.mkdir(parents=True, exist_ok=True) plist_path.parent.mkdir(parents=True, exist_ok=True) helper_script = """#!/bin/sh set -eu LABEL="org.prole.prole-db.kpf-dev" PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" RUNDIR="$HOME/Library/Application Support/Prole/run" PIDFILE="$RUNDIR/kpf.pids" ensure_rundir() { mkdir -p "$RUNDIR" } list_cmds() { # Enumerate ProleCommands array using PlistBuddy if /usr/libexec/PlistBuddy -c "Print :ProleCommands" "$PLIST" >/dev/null 2>&1; then i=0 while true; do if ! val=$(/usr/libexec/PlistBuddy -c "Print :ProleCommands:$i" "$PLIST" 2>/dev/null); then break fi echo "$val" i=$((i+1)) done fi } start() { ensure_rundir : > "$PIDFILE" IFS='\n' for cmd in $(list_cmds); do [ -z "$cmd" ] && continue (sh -lc "$cmd") & echo $! >> "$PIDFILE" done wait } stop() { if [ -f "$PIDFILE" ]; then while read -r pid; do [ -z "$pid" ] && continue kill "$pid" 2>/dev/null || true done < "$PIDFILE" rm -f "$PIDFILE" fi } status() { if [ ! -f "$PIDFILE" ]; then echo "not running" exit 3 fi alive=0 total=0 while read -r pid; do [ -z "$pid" ] && continue total=$((total+1)) if kill -0 "$pid" 2>/dev/null; then alive=$((alive+1)); fi done < "$PIDFILE" echo "$alive/$total running" } case "${1:-}" in start) start ;; stop) stop ;; restart) stop; start ;; status) status ;; *) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 2 ;; esac """ script_path.write_text(helper_script) os.chmod(script_path, 0o755) # Default commands for Dev environment (k3d) default_cmds = [ "kubectl port-forward svc/prometheus-community-kube-prometheus 9090", "kubectl -n kubernetes-dashboard port-forward svc/kubernetes-dashboard-kong-proxy 8443:443", "kubectl port-forward svc/prole-db-rw 5432:5432 --address 0.0.0.0", "kubectl port-forward svc/prometheus-community-grafana 3000:80 --address 0.0.0.0", ] # Create plist dict from plistlib import dumps as plist_dumps plist_dict = { 'Label': 'org.prole.prole-db.kpf-dev', 'RunAtLoad': True, 'KeepAlive': True, # Ensure PATH has common locations for kubectl 'EnvironmentVariables': { 'PATH': os.environ.get('PATH', '/usr/local/bin:/usr/bin:/bin') }, 'StandardOutPath': str(home / 'Library' / 'Logs' / 'org.prole.prole-db.kpf-dev.out.log'), 'StandardErrorPath': str(home / 'Library' / 'Logs' / 'org.prole.prole-db.kpf-dev.err.log'), 'ProgramArguments': [str(script_path), 'start'], 'ProleCommands': default_cmds, } plist_bytes = plist_dumps(plist_dict) plist_path.write_bytes(plist_bytes) # Reload the agent uid = os.getuid() # Try to kickstart first subprocess.run(['launchctl', 'kickstart', '-k', f'gui/{uid}/org.prole.prole-db.kpf-dev'], capture_output=True) # Bootout and bootstrap to ensure it's loaded, then kickstart subprocess.run(['launchctl', 'bootout', f'gui/{uid}', f'gui/{uid}/org.prole.prole-db.kpf-dev'], capture_output=True) subprocess.run(['launchctl', 'bootstrap', f'gui/{uid}', str(plist_path)], check=True) subprocess.run(['launchctl', 'kickstart', '-k', f'gui/{uid}/org.prole.prole-db.kpf-dev'], check=True) return True except Exception as e: print(f"LaunchAgent setup failed: {e}") return False def check_xcode_tools(self): """Check if Xcode Command Line Tools are installed via deploy helper.""" return inst_deploy.check_xcode_tools() def build_prole_app(self): """Delegate building the native ProleStatus app to deploy helper.""" return inst_deploy.build_prole_app(PROJECT_ROOT) def check_docker_running(self): """Check if Docker is running""" try: result = subprocess.run(['docker', 'ps'], capture_output=True, timeout=10) return result.returncode == 0 except Exception: return False def ensure_registry_available(self, env: str) -> str: """Detect if k8s.prole.org:5000 is reachable; if so use it. Otherwise ensure a local registry is available. Returns the registry URL (host:port) to be used for tagging/pushing. For Dev, may create a local k3d-managed registry; for other envs, still prefer the external if reachable. """ def http_ping_registry(host: str, port: int) -> bool: try: import http.client conn = http.client.HTTPConnection(host, port, timeout=3) conn.request('GET', '/v2/') resp = conn.getresponse() # Docker registry typically returns 200 or 401 for /v2/ return resp.status in (200, 401) except Exception: return False # Prefer the shared registry if reachable if http_ping_registry('k8s.prole.org', 5000): return 'k8s.prole.org:5000' # Otherwise, ensure a local registry (localhost:5000) exists/started # Use k3d registry helper for Dev; for non-Dev, we still create/use local for pushing reg_name = 'prole-registry' # Check if k3d is installed k3d_exists = subprocess.run(['which', 'k3d'], capture_output=True).returncode == 0 if k3d_exists: # List registries lst = subprocess.run(['k3d', 'registry', 'list'], capture_output=True, text=True) if reg_name not in (lst.stdout or ''): # Create registry exposed on 0.0.0.0:5000 subprocess.run(['k3d', 'registry', 'create', reg_name, '--port', '0.0.0.0:5000'], check=True) return 'localhost:5000' def create_or_select_cluster(self, env: str): """Ensure target cluster depending on environment selection.""" if env == 'Dev': self.create_or_recreate_k3d_dev_cluster() elif env in ('Service', 'Prod'): # For now, just check kubectl availability and inform user. Real connectivity requires kubeconfig. kubectl = subprocess.run(['which', 'kubectl'], capture_output=True) if kubectl.returncode != 0: raise Exception("kubectl not found. Please install kubectl and configure access to the target cluster.") # Optionally, try a quick cluster-info; don't fail hard on auth errors. subprocess.run(['kubectl', 'version', '--client'], check=True) else: raise Exception(f"Unknown environment: {env}") def create_or_recreate_k3d_dev_cluster(self): """Create or restart local k3d cluster named prole-dev-cluster and wire it to the chosen registry.""" cluster_name = 'prole-dev-cluster' result = subprocess.run(['k3d', 'cluster', 'list'], capture_output=True, text=True) if cluster_name in (result.stdout or ''): subprocess.run(['k3d', 'cluster', 'delete', cluster_name], check=True) # Determine registry integration args reg_args = [] if getattr(self, 'registry_url', None): # If using the local k3d registry, we want to create or use it if self.registry_url.startswith('localhost:5000'): # Creating with --registry-create ensures it's available and integrated reg_args = ['--registry-create', f'prole-registry:0.0.0.0:5000'] else: reg_args = ['--registry-use', self.registry_url] cmd = ['k3d', 'cluster', 'create', cluster_name, '-a', '2', '--wait'] + reg_args + ['--timestamps'] subprocess.run(cmd, check=True, cwd=PROJECT_ROOT) def build_docker_image(self): """Build prole-db Docker image""" # Base local image tag (before pushing to registry) image_tag = 'prole-db:17.5-027' build_cmd = ['docker', 'build', '-t', image_tag] # Add platform flag for Apple Silicon (ARM64 needs amd64 for compatibility) build_cmd.extend(get_docker_build_platform_args()) build_cmd.append('.') result = subprocess.run(build_cmd, check=True, cwd=PROJECT_ROOT / 'prole-db', capture_output=True, text=True) if result.returncode != 0: raise Exception(f"Failed to build image: {result.stderr}") # Keep a reference for later steps self.local_image_tag = image_tag def build_mssql_docker_image(self, image_tag='prole-mssql-db:latest'): """Build mssql Docker image (with platform detection for Apple Silicon)""" build_cmd = ['docker', 'build', '-t', image_tag] # Add platform flag for Apple Silicon (mssql requires amd64) build_cmd.extend(get_docker_build_platform_args()) build_cmd.append('.') result = subprocess.run(build_cmd, check=True, cwd=PROJECT_ROOT / 'mssql', capture_output=True, text=True) if result.returncode != 0: raise Exception(f"Failed to build mssql image: {result.stderr}") return result def tag_docker_image(self): """Tag Docker image for registry""" registry = getattr(self, 'registry_url', 'localhost:5000') image = getattr(self, 'local_image_tag', 'prole-db:17.5-027') self.remote_image_tag = f"{registry}/prole-db:17.5-027" subprocess.run(['docker', 'tag', image, self.remote_image_tag], check=True, capture_output=True) def push_docker_image(self): """Push Docker image to registry""" remote_tag = getattr(self, 'remote_image_tag', None) if not remote_tag: raise Exception('Remote image tag not set') result = subprocess.run(['docker', 'push', remote_tag], check=True, capture_output=True, text=True) if result.returncode != 0: raise Exception(f"Failed to push image: {result.stderr}") def import_k3d_image(self, cluster_name='prole-dev-cluster'): """Import image to k3d cluster (only for Dev).""" subprocess.run(['k3d', 'image', 'import', 'prole-db:17.5-027', '-c', cluster_name], check=True, capture_output=True) def create_validate_screen(self): """Create the Validate screen""" frame = tk.Frame(self.container, bg='#1a1a1a') self.screens['validate'] = frame # Title title = ttk.Label(frame, text="Validate Deployment", style='Title.TLabel') title.pack(pady=(0, 20)) # Prometheus link prometheus_frame = tk.Frame(frame, bg='#1a1a1a') prometheus_frame.pack(pady=(0, 20)) prometheus_label = tk.Label(prometheus_frame, text="Prometheus: ", bg='#1a1a1a', fg='#aaaaaa', font=('Helvetica', 11)) prometheus_label.pack(side='left') prometheus_link = tk.Label(prometheus_frame, text="http://localhost:9090", bg='#1a1a1a', fg='#4a9eff', font=('Helvetica', 11, 'underline'), cursor='hand2') prometheus_link.pack(side='left') prometheus_link.bind('', lambda e: webbrowser.open('http://localhost:9090')) # Status display status_frame = tk.Frame(frame, bg='#1a1a1a') status_frame.pack(fill='both', expand=True, pady=10) status_label = ttk.Label(status_frame, text="Cluster Status", style='Heading.TLabel') status_label.pack(anchor='w', pady=(0, 10)) # Status text area self.status_text = scrolledtext.ScrolledText(status_frame, bg='#0a0a0a', fg='#4a9eff', font=('Courier', 10), wrap='word', relief='flat', bd=1) self.status_text.pack(fill='both', expand=True) # Auto-refresh checkbox refresh_frame = tk.Frame(frame, bg='#1a1a1a') refresh_frame.pack(pady=10) self.auto_refresh_var = tk.BooleanVar(value=True) refresh_check = tk.Checkbutton(refresh_frame, text="Auto-refresh every 10 seconds", variable=self.auto_refresh_var, bg='#1a1a1a', fg='#aaaaaa', selectcolor='#2a2a2a', activebackground='#1a1a1a', activeforeground='#aaaaaa', font=('Helvetica', 10), command=self.toggle_auto_refresh) refresh_check.pack(side='left', padx=10) # Manual refresh button refresh_btn = tk.Button(refresh_frame, text="Refresh Now", command=self.refresh_status, bg='#4a9eff', fg='white', activebackground='#3a8eef', font=('Helvetica', 10), padx=15, pady=5, cursor='hand2', relief='flat') refresh_btn.pack(side='left', padx=10) # Start auto-refresh self.refresh_status() self.toggle_auto_refresh() def toggle_auto_refresh(self): """Toggle auto-refresh""" if self.auto_refresh_var.get(): if not self.validation_running: self.validation_running = True self.validation_thread = threading.Thread(target=self.auto_refresh_loop, daemon=True) self.validation_thread.start() else: self.validation_running = False def auto_refresh_loop(self): """Auto-refresh loop""" while self.validation_running: time.sleep(10) if self.validation_running: self.root.after(0, self.refresh_status) def refresh_status(self): """Refresh the cluster status""" def update(): try: full_status = "" # Get k3d cluster list try: cluster_result = subprocess.run(['k3d', 'cluster', 'list'], capture_output=True, text=True, timeout=5) full_status += f"=== k3d Cluster Status ===\n{cluster_result.stdout}\n\n" except Exception as e: full_status += f"=== k3d Cluster Status ===\nError: {str(e)}\n\n" # Get kubectl cnpg status try: result = subprocess.run(['kubectl', 'cnpg', 'status', 'prole-db'], capture_output=True, text=True, timeout=10) if result.returncode == 0: status_output = result.stdout else: status_output = f"Error: {result.stderr}\n\nNote: Make sure kubectl cnpg plugin is installed:\n kubectl krew install cnpg" full_status += f"=== CloudNativePG Status ===\n{status_output}\n" except FileNotFoundError: full_status += "=== CloudNativePG Status ===\nError: kubectl not found. Please install dependencies first.\n" except subprocess.TimeoutExpired: full_status += "=== CloudNativePG Status ===\nError: Command timed out\n" except Exception as e: full_status += f"=== CloudNativePG Status ===\nError: {str(e)}\n" full_status += f"\nLast updated: {time.strftime('%Y-%m-%d %H:%M:%S')}" self.status_text.delete('1.0', tk.END) self.status_text.insert('1.0', full_status) except Exception as e: self.status_text.delete('1.0', tk.END) self.status_text.insert('1.0', f"Error: {str(e)}") threading.Thread(target=update, daemon=True).start() def main(): # Root window root = tk.Tk() root.title("Prole Installer") # Center window similarly to legacy UI window_width, window_height = 1000, 700 try: sw, sh = root.winfo_screenwidth(), root.winfo_screenheight() cx, cy = int(sw / 2 - window_width / 2), int(sh / 2 - window_height / 2) root.geometry(f"{window_width}x{window_height}+{cx}+{cy}") except Exception: root.geometry(f"{window_width}x{window_height}") # Launch the main installer window immediately with background and welcome page. # The lightweight dependency verification runs on the welcome page and gates the footer there. ProleInstaller(root) root.mainloop() if __name__ == '__main__': main()