prole/knoe/deploy.py

475 lines
15 KiB
Python

"""
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("<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