mirror of
https://github.com/dredx/prole.git
synced 2026-09-24 18:34:31 +00:00
472 lines
17 KiB
Python
472 lines
17 KiB
Python
"""
|
|
Deploy/build helpers for the Prole installer (root-level package).
|
|
|
|
Encapsulates Xcode tools check and building the native Prole app.
|
|
|
|
Also defines the Deploy page (final milestone):
|
|
- Mark completed: Build Prole macOS App, Build Prole Workstation Docker Image, Verify Dependencies
|
|
- New: Start Prole Workstation (show docker run command in the black console, wait for Enter, run, verify)
|
|
- New: Install Prole.app (drag-to-install pop-up; mark complete when closed)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
import os
|
|
import threading
|
|
import platform
|
|
import subprocess
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox
|
|
|
|
from . import main as inst_main
|
|
from . import screen as ui
|
|
from . import workstation as ws
|
|
from . import config as cfg
|
|
|
|
|
|
def check_xcode_tools() -> bool:
|
|
if platform.system() != "Darwin":
|
|
return False
|
|
try:
|
|
result = subprocess.run(["xcrun", "--find", "swiftc"], capture_output=True, text=True, timeout=10)
|
|
return result.returncode == 0
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def build_prole_app(project_root: Path) -> None:
|
|
if platform.system() != "Darwin":
|
|
raise Exception("Building the macOS app requires macOS.")
|
|
if not check_xcode_tools():
|
|
raise Exception("Xcode Command Line Tools not found. Please run: xcode-select --install")
|
|
|
|
# Use the top-level prole-app project directory
|
|
prole_app_dir = Path(project_root) / "prole-app"
|
|
build_script = prole_app_dir / "build.sh"
|
|
if not build_script.exists():
|
|
raise Exception(
|
|
f"Build script not found at {build_script}"
|
|
)
|
|
|
|
result = subprocess.run(["bash", str(build_script), "build"], cwd=prole_app_dir, capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
raise Exception(f"Prole build failed: {result.stderr or result.stdout}")
|
|
|
|
app_path = prole_app_dir / "dist" / "Prole.app"
|
|
if not app_path.exists():
|
|
raise Exception("Build completed but Prole.app was not found in dist/")
|
|
|
|
try:
|
|
msg = (
|
|
"Prole.app has been built successfully.\n\n"
|
|
f"Location: {app_path}\n\n"
|
|
"Would you like to copy it to /Applications?"
|
|
)
|
|
if messagebox.askyesno("Prole Built", msg):
|
|
dest = Path("/Applications") / "Prole.app"
|
|
subprocess.run(["cp", "-R", str(app_path), str(dest)], check=True)
|
|
messagebox.showinfo("Copied", f"Copied to {dest}")
|
|
except Exception as copy_err:
|
|
print(f"Copy to /Applications failed: {copy_err}")
|
|
|
|
|
|
# ---------------- Screen (UI) helpers ----------------
|
|
def _draw_status(canvas: tk.Canvas, status: str):
|
|
canvas.delete('all')
|
|
if status == 'success' or status == 'completed':
|
|
canvas.create_oval(2, 2, 18, 18, fill='#34c759', outline='')
|
|
canvas.create_line(5, 10, 9, 14, fill='white', width=2)
|
|
canvas.create_line(9, 14, 16, 6, fill='white', width=2)
|
|
elif status == 'running':
|
|
canvas.create_oval(2, 2, 18, 18, fill='#ffd60a', outline='')
|
|
elif status == 'error':
|
|
canvas.create_oval(2, 2, 18, 18, fill='#ff3b30', outline='')
|
|
else:
|
|
canvas.create_oval(2, 2, 18, 18, outline='#b0b0b0')
|
|
|
|
|
|
def _create_deploy_row(app, parent, step: dict):
|
|
row = ttk.Frame(parent)
|
|
row.pack(fill='x', pady=6)
|
|
canvas = tk.Canvas(row, width=20, height=20, highlightthickness=0)
|
|
canvas.pack(side='left', padx=8)
|
|
lbl = ttk.Label(row, text=step['name'], style='Body.TLabel')
|
|
lbl.pack(side='left')
|
|
status = ttk.Label(row, text='Pending', style='Dim.TLabel')
|
|
status.pack(side='right', padx=8)
|
|
app.deploy_widgets[step['name']] = {'canvas': canvas, 'label': status, 'step': step}
|
|
# Initialize status icon/text
|
|
init = step.get('status', 'pending')
|
|
_draw_status(canvas, init)
|
|
try:
|
|
if init in ('success', 'completed'):
|
|
status.configure(text='Done')
|
|
elif init == 'running':
|
|
status.configure(text='Running…')
|
|
else:
|
|
status.configure(text=init.title())
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def create_deploy_page(app):
|
|
"""Create the Deploy page UI and register it via installer.main.
|
|
|
|
Final milestone flow for Deploy page:
|
|
- Show 3 completed steps from earlier phases
|
|
- Start Prole Workstation (docker run)
|
|
- Install Prole.app (drag-to-install pop-up)
|
|
"""
|
|
f = ttk.Frame(app.page_area)
|
|
f.place(x=0, y=0, relwidth=1, relheight=1)
|
|
|
|
ttk.Label(f, text='Setup & Deploy', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6))
|
|
ttk.Label(
|
|
f,
|
|
text='We will verify dependencies and then complete final deployment steps.',
|
|
style='Body.TLabel',
|
|
wraplength=800
|
|
).pack(anchor='w', padx=24)
|
|
|
|
# ----- Dependencies section (merged onto the first page) -----
|
|
deps_box = ttk.LabelFrame(f, text='Dependencies')
|
|
deps_box.pack(fill='x', padx=16, pady=(12, 8))
|
|
deps_wrap = ttk.Frame(deps_box)
|
|
deps_wrap.pack(fill='x', padx=8, pady=8)
|
|
app._deploy_dep_widgets = {}
|
|
|
|
def _dep_icon(canvas: tk.Canvas, status: str):
|
|
canvas.delete('all')
|
|
if status == 'ok':
|
|
canvas.create_oval(2, 2, 18, 18, fill='#34c759', outline='')
|
|
canvas.create_line(5, 10, 9, 14, fill='white', width=2)
|
|
canvas.create_line(9, 14, 16, 6, fill='white', width=2)
|
|
elif status == 'checking':
|
|
canvas.create_oval(2, 2, 18, 18, fill='#ffd60a', outline='')
|
|
elif status == 'missing':
|
|
canvas.create_oval(2, 2, 18, 18, fill='#ff3b30', outline='')
|
|
else:
|
|
canvas.create_oval(2, 2, 18, 18, outline='#b0b0b0')
|
|
|
|
def _create_dep_row(parent, dep: dict):
|
|
row = ttk.Frame(parent)
|
|
row.pack(fill='x', pady=4)
|
|
cnv = tk.Canvas(row, width=20, height=20, highlightthickness=0)
|
|
cnv.pack(side='left', padx=(4, 8))
|
|
name = ttk.Label(row, text=dep['name'], style='Body.TLabel')
|
|
name.pack(side='left')
|
|
status = ttk.Label(row, text='Checking…', style='Dim.TLabel')
|
|
status.pack(side='right', padx=8)
|
|
app._deploy_dep_widgets[dep['id']] = {'canvas': cnv, 'label': status, 'dep': dep}
|
|
_dep_icon(cnv, 'checking')
|
|
|
|
try:
|
|
from . import config as _cfg
|
|
deps = list(_cfg.DEPENDENCIES)
|
|
except Exception:
|
|
deps = []
|
|
for d in deps:
|
|
_create_dep_row(deps_wrap, d)
|
|
|
|
# Background thread to check dependencies live
|
|
def _check_deps_bg():
|
|
try:
|
|
from . import config as _cfg2
|
|
for d in deps:
|
|
try:
|
|
installed, location, version = _cfg2.get_dep_info(d)
|
|
except Exception:
|
|
installed, location, version = False, None, None
|
|
w = app._deploy_dep_widgets.get(d['id'])
|
|
if not w:
|
|
continue
|
|
lbl = w['label']
|
|
cnv = w['canvas']
|
|
try:
|
|
if installed:
|
|
_dep_icon(cnv, 'ok')
|
|
txt = version or 'Installed'
|
|
lbl.configure(text=txt)
|
|
else:
|
|
_dep_icon(cnv, 'missing')
|
|
lbl.configure(text='Missing')
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
threading.Thread(target=_check_deps_bg, daemon=True).start()
|
|
except Exception:
|
|
_check_deps_bg()
|
|
|
|
# ----- Final steps (on the same merged page) -----
|
|
app.deploy_steps = [
|
|
{'name': 'Build Prole macOS App', 'status': 'completed'},
|
|
{'name': 'Build Prole Workstation Docker Image', 'status': 'completed'},
|
|
{'name': 'Verify Dependencies', 'status': 'completed'},
|
|
{'name': 'Start Prole Workstation', 'status': 'pending'},
|
|
{'name': 'Install Prole.app', 'status': 'pending'},
|
|
]
|
|
app.deploy_widgets = {}
|
|
container = ttk.Frame(f)
|
|
container.pack(fill='both', expand=True, padx=16, pady=8)
|
|
for step in app.deploy_steps:
|
|
_create_deploy_row(app, container, step)
|
|
|
|
inst_main.register_page(app, 'deploy', f)
|
|
|
|
# Prepare console overlay to display the docker run command
|
|
try:
|
|
# Place the console below the step list
|
|
app._ensure_console_overlay(radio_bottom_y=160)
|
|
except Exception:
|
|
pass
|
|
|
|
# Compose docker run command for workstation
|
|
project_root = cfg.PROJECT_ROOT
|
|
version = ws.get_workstation_version(project_root)
|
|
# Ports and volume mapping per spec
|
|
port_flags = (
|
|
"-p 127.0.0.1:5901:5901 "
|
|
"-p 127.0.0.1:6667:6667 "
|
|
"-p 127.0.0.1:6697:6697"
|
|
)
|
|
host_prole = os.path.expanduser('~/dev/prole')
|
|
docker_run_cmd = (
|
|
f"docker rm -f prole-workstation >/dev/null 2>&1 || true; "
|
|
f"docker run -d --name prole-workstation --restart unless-stopped "
|
|
f"{port_flags} -v \"{host_prole}\":/prole prole-workstation:{version}"
|
|
)
|
|
|
|
# Show the command preview and wait for Enter
|
|
try:
|
|
user = os.environ.get('USER') or 'user'
|
|
host = (platform.node() or 'host').split('.')[0]
|
|
preview_line = f"[{user}@{host}]# {docker_run_cmd}"
|
|
if hasattr(app, '_console_set_preview'):
|
|
app._console_set_preview(preview_line)
|
|
except Exception:
|
|
pass
|
|
|
|
# Handlers for steps 4 and 5
|
|
state = {'started': False}
|
|
|
|
def _open_drag_install():
|
|
"""Open a simple drag-to-install window; mark complete when closed."""
|
|
try:
|
|
win = tk.Toplevel(app.root)
|
|
win.title('Install Prole.app')
|
|
win.geometry('520x320')
|
|
ttk.Label(win, text='Drag Prole.app to Applications', style='Title.TLabel').pack(pady=(16, 8))
|
|
body = ttk.Frame(win)
|
|
body.pack(expand=True, fill='both', padx=20, pady=10)
|
|
# Left: Prole.app icon (from configured icon path)
|
|
left = ttk.Frame(body)
|
|
left.pack(side='left', expand=True, fill='both')
|
|
right = ttk.Frame(body)
|
|
right.pack(side='right', expand=True, fill='both')
|
|
# Try to load images if available
|
|
icon_path = cfg.get_ui_icon_image_path()
|
|
app._drag_img_prole = None
|
|
app._drag_img_apps = None
|
|
try:
|
|
from PIL import Image, ImageTk # type: ignore
|
|
if icon_path.exists():
|
|
img = Image.open(str(icon_path)).resize((128, 128))
|
|
app._drag_img_prole = ImageTk.PhotoImage(img)
|
|
# A simple generic Applications icon (fallback: text only)
|
|
except Exception:
|
|
pass
|
|
cnv_l = tk.Canvas(left, width=200, height=200, highlightthickness=0)
|
|
cnv_l.pack(expand=True)
|
|
if app._drag_img_prole is not None:
|
|
cnv_l.create_image(100, 100, image=app._drag_img_prole)
|
|
else:
|
|
cnv_l.create_text(100, 100, text='Prole.app', font=('Helvetica', 14))
|
|
cnv_r = tk.Canvas(right, width=200, height=200, highlightthickness=0)
|
|
cnv_r.pack(expand=True)
|
|
cnv_r.create_text(100, 80, text='Applications', font=('Helvetica', 14))
|
|
# Action buttons
|
|
btns = ttk.Frame(win)
|
|
btns.pack(side='bottom', pady=12)
|
|
def open_apps():
|
|
try:
|
|
subprocess.Popen(["open", "/Applications"]) # type: ignore
|
|
except Exception:
|
|
pass
|
|
ttk.Button(btns, text='Open Applications Folder', command=open_apps).pack()
|
|
def on_close():
|
|
try:
|
|
_update_step(app, 'Install Prole.app', 'success')
|
|
except Exception:
|
|
pass
|
|
try:
|
|
win.destroy()
|
|
except Exception:
|
|
pass
|
|
win.protocol('WM_DELETE_WINDOW', on_close)
|
|
except Exception:
|
|
# Even if popup fails, don't crash the deploy page
|
|
pass
|
|
|
|
def _start_workstation_and_verify():
|
|
# Kick off docker run and then verify container is running
|
|
try:
|
|
_update_step(app, 'Start Prole Workstation', 'running')
|
|
except Exception:
|
|
pass
|
|
try:
|
|
app._append_console("\nStarting Prole Workstation container...\n")
|
|
except Exception:
|
|
pass
|
|
try:
|
|
subprocess.run(["bash", "-lc", docker_run_cmd])
|
|
except Exception:
|
|
pass
|
|
# Verify container is running
|
|
import time as _t
|
|
ok = False
|
|
for _ in range(30): # ~30 * 0.5s = 15s
|
|
try:
|
|
r = subprocess.run(["bash", "-lc", "docker inspect -f '{{.State.Running}}' prole-workstation || echo false"], capture_output=True, text=True, timeout=4)
|
|
if r.returncode == 0 and 'true' in (r.stdout or '').lower():
|
|
ok = True
|
|
break
|
|
except Exception:
|
|
pass
|
|
_t.sleep(0.5)
|
|
try:
|
|
if ok:
|
|
_update_step(app, 'Start Prole Workstation', 'success')
|
|
app._append_console("Prole Workstation is running.\n")
|
|
else:
|
|
_update_step(app, 'Start Prole Workstation', 'error')
|
|
app._append_console("Failed to start Prole Workstation. See Docker for details.\n")
|
|
except Exception:
|
|
pass
|
|
|
|
def _on_enter(_evt=None):
|
|
# Only handle once
|
|
if state['started']:
|
|
return
|
|
state['started'] = True
|
|
try:
|
|
app._console_press_enter()
|
|
except Exception:
|
|
pass
|
|
# While docker deploy is running, pop up the drag-to-install window
|
|
try:
|
|
threading.Thread(target=_open_drag_install, daemon=True).start()
|
|
except Exception:
|
|
_open_drag_install()
|
|
# Start docker run + verification in background
|
|
try:
|
|
threading.Thread(target=_start_workstation_and_verify, daemon=True).start()
|
|
except Exception:
|
|
_start_workstation_and_verify()
|
|
|
|
# Bind Enter to trigger the start when the user is ready
|
|
try:
|
|
app.root.bind('<Return>', _on_enter)
|
|
app.root.bind('<KP_Enter>', _on_enter)
|
|
except Exception:
|
|
pass
|
|
|
|
# Navigation footer with Next button to proceed/trigger actions
|
|
def _noop():
|
|
return None
|
|
try:
|
|
btns = ui.create_nav_footer(f, [(1, 'Back'), (2, 'Next')], {1: _noop, 2: _on_enter})
|
|
# Optionally expose next_button like install.py does
|
|
try:
|
|
app.next_button = btns.get(2)
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
return f
|
|
|
|
|
|
# ---------------- New deploy step helpers ----------------
|
|
def _update_step(app, name: str, status: str):
|
|
w = app.deploy_widgets.get(name)
|
|
if not w:
|
|
return
|
|
canvas = w.get('canvas')
|
|
label = w.get('label')
|
|
try:
|
|
_draw_status(canvas, status)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
if status == 'running':
|
|
label.configure(text='Running…')
|
|
elif status in ('success', 'completed'):
|
|
label.configure(text='Done')
|
|
elif status == 'error':
|
|
label.configure(text='Error')
|
|
else:
|
|
label.configure(text=status.title())
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _ensure_docker_running(timeout: int = 120) -> bool:
|
|
"""Return True if Docker responds to `docker info`; try to start Desktop on macOS."""
|
|
def docker_ok() -> bool:
|
|
try:
|
|
r = subprocess.run(["bash", "-lc", "docker info >/dev/null 2>&1"], timeout=8)
|
|
return r.returncode == 0
|
|
except Exception:
|
|
return False
|
|
|
|
if docker_ok():
|
|
return True
|
|
|
|
# Try to start Docker Desktop on macOS
|
|
if platform.system() == 'Darwin':
|
|
try:
|
|
subprocess.Popen(["open", "-a", "Docker"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
except Exception:
|
|
pass
|
|
|
|
# Wait until docker is ready or timeout
|
|
import time as _t
|
|
start = _t.time()
|
|
while _t.time() - start < timeout:
|
|
if docker_ok():
|
|
return True
|
|
_t.sleep(2)
|
|
return False
|
|
|
|
|
|
def _ensure_workstation_container() -> bool:
|
|
"""Run or start the prole-workstation container with required ports."""
|
|
version = ws.get_workstation_version(cfg.PROJECT_ROOT)
|
|
# If container exists and is running → success
|
|
try:
|
|
exists = subprocess.run(["bash", "-lc", "docker ps -a --format '{{.Names}}' | grep -w prole-workstation"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
if exists.returncode == 0:
|
|
# Check running state
|
|
running = subprocess.run(["bash", "-lc", "docker ps --format '{{.Names}}' | grep -w prole-workstation"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
if running.returncode == 0:
|
|
return True
|
|
# Start it
|
|
started = subprocess.run(["bash", "-lc", "docker start prole-workstation"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
return started.returncode == 0
|
|
except Exception:
|
|
pass
|
|
|
|
cmd = (
|
|
"docker run -d --name prole-workstation "
|
|
"-p 5901:5901 -p 6667:6667 -p 6697:6697 "
|
|
f"prole-workstation:{version}"
|
|
)
|
|
try:
|
|
res = subprocess.run(["bash", "-lc", cmd], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
return res.returncode == 0
|
|
except Exception:
|
|
return False
|