prole/installer/ui/screens/dependencies.py
chrisfu ec50f82cc8 refactor: deduplicate installer business logic & split screens.py into package
Separation of concerns: merge silent/UI actions & modularize screens.

ProleInstallerBase (actions.py): Created shared base class with 37 deduplicated methods previously duplicated between ProleSilentInstaller and ProleInstaller. Namespace, environment, secret, deployment, port-forward, authority/repair, image, and logging helpers now defined once. Subclasses override _get_input() to bridge their data-access layers.

screens.py -> screens/ package (18 mixin modules): Split 10,234-line monolithic screens.py into focused mixin modules: base, navigation, welcome, dependencies, network, environment, database, cluster, services, security, ollama, supabase, docker, build, packaging, deploy, validate, cfg. __init__.py composes ProleInstaller from all mixins and re-exports has_display(), main() for full backward compatibility.

All 37 tests pass with no regressions.
2026-02-20 14:43:20 -08:00

442 lines
21 KiB
Python

"""Dependency scanning, summary and per-dependency install screens."""
import re
import threading
import time
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from installer import config as inst_config
from installer import screen as ui
class DependenciesScreenMixin:
"""Dependency scanning, summary and per-dependency install screens."""
def _render_deps_summary_page(self):
self._render_title('Dependencies', y=40)
y = 100
# Draw each dependency row: status dot (blank initially), name, info
row_gap = 36
self.dep_status_items = {} # Store canvas IDs to update later
self.dep_install_buttons = {} # Store button window items
for dep in self.dependencies:
indent = 0
if dep.get('parent'):
indent = 24
left = 56 + indent
text_x = left + 28
# blank box (outline only initially)
dot = ui.canvas_rectangle(self, left, y, left+18, y+18, outline='#6e6e73', width=2)
self._canvas_items.append(dot)
# name
self._canvas_items.append(ui.canvas_text(self, text_x, y-2, dep['name'], fill='black', font=('SF Pro Text', 12, 'bold')))
# info placeholder
info = ui.canvas_text(self, text_x + 180, y, 'Checking...', fill='#6e6e73', font=('SF Pro Text', 11))
self._canvas_items.append(info)
self.dep_status_items[dep['id']] = {'dot': dot, 'info': info, 'y': y, 'left': left}
y += row_gap
# Message line
msg_y = y + 30
self.deps_msg_item = ui.canvas_text(self, 48, msg_y, 'Scanning system...', fill='#6e6e73', font=('SF Pro Text', 11))
self._canvas_items.append(self.deps_msg_item)
# Start async check
threading.Thread(target=self._run_delayed_deps_check, daemon=True).start()
def _render_dependency_page(self, dep):
self._render_title(dep['name'], y=40)
self._render_paragraph(dep['description'], y=88)
# Status
ok, location, version = self.get_dep_info(dep)
y = 180
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+32, y-2, 'Installed', fill='black', font=('SF Pro Text', 12, 'bold')))
if location:
self._canvas_items.append(ui.canvas_text(self, left+32, y+32, f'Location: {location}', fill='#6e6e73', font=('SF Pro Text', 11)))
if version:
ver = self.normalize_version(version)
self._canvas_items.append(ui.canvas_text(self, left+32, y+56, f'Version: {ver}', fill='#6e6e73', font=('SF Pro Text', 11)))
else:
# Check if we should auto-install
install_cmd = dep.get('install_cmd')
if install_cmd and self._installing_dep_id != dep['id']:
# Launch directly into installation
self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#ffd60a', outline=''))
self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Installing...', fill='black', font=('SF Pro Text', 12, 'bold')))
# Trigger console-based install for brew/pip
if 'brew install' in install_cmd or 'pip install' in install_cmd:
self.root.after(500, lambda: self._install_dep_in_console(dep))
else:
self.root.after(500, lambda: self.open_terminal_with_command(install_cmd))
elif self._installing_dep_id == dep['id']:
self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#ffd60a', outline=''))
self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Installing...', fill='black', font=('SF Pro Text', 12, 'bold')))
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+32, y-2, 'Not installed', fill='black', font=('SF Pro Text', 12, 'bold')))
# Click to install link (if available)
if install_cmd:
link_y = y + 40
link_text = ui.render_link(self, left+32, 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(install_cmd)
self.bg_canvas.bind('<Button-1>', _on_click)
def _install_dep_in_console(self, dep):
"""Run dependency installation in the embedded console."""
if self._installing_dep_id == dep['id']:
return
try:
self._action_flags[f"dependencies.{dep['id']}.install"] = True
except Exception:
pass
install_cmd = dep.get('install_cmd')
if not install_cmd:
return
self._installing_dep_id = dep['id']
# Prepare log file
logs_dir = self._resolve_env_dir('PROLE_LOGS', '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"install-{dep['id']}-{ts}.log"
# Show console
self._ensure_console_overlay(radio_bottom_y=160)
self._console_text.configure(state='normal')
self._console_text.delete('1.0', tk.END)
self._console_text.insert('end', f"Starting installation of {dep['name']}...\n")
self._console_text.insert('end', f"Command: {install_cmd}\n\n")
self._console_text.configure(state='disabled')
def on_done(rc):
self._installing_dep_id = None
if rc == 0:
self._append_console(f"\nSuccessfully installed {dep['name']}.\n")
# Refresh status and re-render page
self.root.after(1500, lambda: self.show_page(f"dep_{dep['id']}"))
else:
self._append_console(f"\nInstallation failed with exit code {rc}.\n")
# Enable next button so user can retry or proceed if they fixed it manually
try:
self.next_button.configure(state='normal', text='Next')
except Exception:
pass
self._run_in_console(install_cmd, str(log_path), on_complete=on_done)
# ---------------- Initialize Screen Handlers ----------------
def _run_delayed_deps_check(self):
results = {}
any_missing = False
for dep in self.dependencies:
ok, location, version = self.get_dep_info(dep)
results[dep['id']] = (ok, location, version)
# Update UI for this item
def update_item(did=dep['id'], ok=ok, version=version):
items = self.dep_status_items.get(did)
if not items: return
# Replace box with colored dot/check
self.bg_canvas.delete(items['dot'])
left = items.get('left', 56)
y = items['y']
if ok:
items['dot'] = ui.canvas_oval(self, left, y, left+18, y+18, fill='#34c759', outline='')
# Add a small white checkmark inside the green dot
self.bg_canvas.create_line(left+5, y+9, left+8, y+12, fill='white', width=2, tags=f"page_item_{self.pages[self.page_index][0]}")
self.bg_canvas.create_line(left+8, y+12, left+13, y+6, fill='white', width=2, tags=f"page_item_{self.pages[self.page_index][0]}")
else:
items['dot'] = ui.canvas_oval(self, left, y, left+18, y+18, fill='#ff3b30', outline='')
# Add "Click to install" button
btn = tk.Button(self.bg_canvas, text='Install', command=lambda d=did: self.show_page(f"dep_{d}"),
bg='#F5F5DC', fg='black', activebackground='#E5E5D5',
highlightbackground='#F5F5DC', highlightthickness=0,
relief='flat', font=('SF Pro Text', 10), padx=8, pady=2)
btn_window = self.bg_canvas.create_window(left + 350, y, window=btn, anchor='nw')
self._overlay_widgets.append(btn)
self._canvas_items.append(btn_window)
self.dep_install_buttons[did] = btn_window
info_text = self.normalize_version(version) if ok and version else ('Not installed' if not ok else '')
self.bg_canvas.itemconfig(items['info'], text=info_text)
self.safe_after(update_item)
time.sleep(0.1) # small delay to show it checking one by one
def final_update():
missing = [d['name'] for d in self.dependencies if not results.get(d['id'], (False, None, None))[0]]
if missing:
msg = f"Preparing to install ... {', '.join(missing)}"
else:
msg = 'All dependencies installed.'
try:
if self.bg_canvas.winfo_exists() and self.deps_msg_item in self.bg_canvas.find_all():
self.bg_canvas.itemconfig(self.deps_msg_item, text=msg)
except (tk.TclError, RuntimeError):
pass
self.update_footer()
self.safe_after(final_update)
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 _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('<Button-1>', 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():
res = [f'dep_{d["id"]}' for d in self.dependencies]
print(f"[DEBUG] _dep_navigation_sequence (force_all={force_all}): {res}")
return res
seq = []
for d in self.dependencies:
ok, _, _ = self.get_dep_info(d)
if not ok:
seq.append(f'dep_{d["id"]}')
print(f"[DEBUG] _dep_navigation_sequence: {seq}")
return seq
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.safe_after(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.safe_after(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()
# Update prole.cfg data
try:
status_str = f"installed (version {version})" if ok else "not installed"
self.prole_cfg_data['Dependencies'][dep_id] = status_str
self._save_prole_cfg()
except Exception:
pass
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 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:
print(f"[DEBUG] all_dependencies_installed: '{dep['id']}' is MISSING")
return False
print("[DEBUG] all_dependencies_installed: YES (all OK)")
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)