""" Deploy/build helpers for the Knoe installer (root-level package). Encapsulates Xcode tools check and building the native Knoe app. Also defines the Deploy page (final milestone): - Mark completed: Build Knoe macOS App, Verify Dependencies - New: Install Knoe.app (drag-to-install pop-up; mark complete when closed) """ from __future__ import annotations import platform import shutil import subprocess import threading from pathlib import Path from typing import Callable from . import config as cfg from .milestone import Milestone from .state import InstallerState 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_knoe_app_core(project_root: Path) -> Path: 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 knoe-app project directory knoe_app_dir = Path(project_root) / "knoe-app" build_script = knoe_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=knoe_app_dir, capture_output=True, text=True, ) if result.returncode != 0: raise Exception(f"Knoe build failed: {result.stderr or result.stdout}") app_path = knoe_app_dir / "dist" / "Knoe.app" if not app_path.exists(): raise Exception("Build completed but Knoe.app was not found in dist/") return app_path def build_knoe_app(project_root: Path) -> None: """Legacy UI flow for building and optionally copying Knoe.app.""" app_path = build_knoe_app_core(project_root) try: from tkinter import messagebox msg = ( "Knoe.app has been built successfully.\n\n" f"Location: {app_path}\n\n" "Would you like to copy it to /Applications?" ) if messagebox.askyesno("Knoe Built", msg): dest = Path("/Applications") / "Knoe.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}") class DeployMilestone(Milestone): """UI-agnostic deploy milestone.""" def __init__( self, project_root: Path, next_id: str | None = None, *, build_app: bool = True, copy_to_applications: bool = False, ) -> None: super().__init__("deploy", "Deploy") self.project_root = Path(project_root) self._next_id = next_id self.build_app = build_app self.copy_to_applications = copy_to_applications def validate(self, state: InstallerState) -> list[str] | None: if self.build_app and platform.system() != "Darwin": return ["Building the macOS app requires macOS."] return None def execute( self, state: InstallerState, progress: Callable[[str, float | None], None] | None = None, ) -> None: if not self.build_app: if progress: progress("Deploy step skipped (build_app disabled).", 1.0) return if progress: progress("Building Knoe.app", 0.2) app_path = build_knoe_app_core(self.project_root) state.data["deploy.app_path"] = str(app_path) if self.copy_to_applications: dest = Path("/Applications") / "Knoe.app" if dest.exists(): state.data["deploy.copy_skipped"] = ( f"Destination already exists: {dest}" ) else: shutil.copytree(app_path, dest) state.data["deploy.copied_to"] = str(dest) if progress: progress("Deploy completed", 1.0) def next(self, state: InstallerState) -> str | None: return self._next_id # ---------------- Screen (UI) helpers ---------------- def _draw_status(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): import tkinter as tk from tkinter import ttk 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 knoe.main. Final milestone flow for Deploy page: - Show completed steps from earlier phases - Install Knoe.app (drag-to-install pop-up) """ import tkinter as tk from tkinter import ttk from . import main as inst_main from . import screen as ui 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 Knoe macOS App", "status": "completed"}, {"name": "Verify Dependencies", "status": "completed"}, {"name": "Install Knoe.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 # Handlers for step 3 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 Knoe.app") win.geometry("520x320") ttk.Label( win, text="Drag Knoe.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: Knoe.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_knoe = 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_knoe = 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_knoe is not None: cnv_l.create_image(100, 100, image=app._drag_img_knoe) else: cnv_l.create_text(100, 100, text="Knoe.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 Knoe.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 _on_enter(_evt=None): # Only handle once if state["started"]: return state["started"] = True try: app._console_press_enter() except Exception: pass # While 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() # Bind Enter to trigger the start when the user is ready try: app.root.bind("", _on_enter) app.root.bind("", _on_enter) except Exception: pass # Navigation footer with Next button to proceed/trigger actions def _noop(): return None try: btns = ui.create_nav_footer( f, [(1, "Back"), (2, "Next")], {1: _noop, 2: _on_enter} ) # Optionally expose next_button like install.py does try: app.next_button = btns.get(2) except Exception: pass except Exception: pass return f # ---------------- New deploy step helpers ---------------- def _update_step(app, name: str, status: str): w = app.deploy_widgets.get(name) if not w: return canvas = w.get("canvas") label = w.get("label") try: _draw_status(canvas, status) except Exception: pass try: if status == "running": label.configure(text="Running…") elif status in ("success", "completed"): label.configure(text="Done") elif status == "error": label.configure(text="Error") else: label.configure(text=status.title()) except Exception: pass def _ensure_docker_running(timeout: int = 120) -> bool: """Return True if Docker responds to `docker info`; try to start Desktop on macOS.""" def docker_ok() -> bool: try: r = subprocess.run( ["bash", "-lc", "docker info >/dev/null 2>&1"], timeout=8 ) return r.returncode == 0 except Exception: return False if docker_ok(): return True # Try to start Docker Desktop on macOS if platform.system() == "Darwin": try: subprocess.Popen( ["open", "-a", "Docker"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) except Exception: pass # Wait until docker is ready or timeout import time as _t start = _t.time() while _t.time() - start < timeout: if docker_ok(): return True _t.sleep(2) return False