prole/knoe/ui/screens/build.py

832 lines
31 KiB
Python

"""Build screen, terminal console overlay and process management."""
import os
import re
import shlex
import signal
import subprocess
import threading
import time
import tkinter as tk
import webbrowser
from tkinter import ttk, messagebox, filedialog
import platform
from knoe import deploy as inst_deploy
from knoe import screen as ui
from knoe.build import get_build_command as inst_get_build_command
from knoe.core.env import PROJECT_ROOT
class BuildScreenMixin:
"""Build screen, terminal console overlay and process management."""
def _render_build_page(self):
# Letterhead at top right
content_width = self.bg_canvas.winfo_width() or 975
right_margin = content_width - 48
ui.canvas_text(
self,
right_margin,
40,
"knoe.dev",
fill="#6e6e73",
font=("SF Pro Text", 32, "bold"),
anchor="ne",
)
ui.canvas_text(
self,
right_margin,
85,
"infrastructure.auto()",
fill="#6e6e73",
font=("SF Pro Text", 18),
anchor="ne",
)
# Shifted up to accommodate radios and standard console position
self._render_title("Build Prole Tools.app", y=80)
self._render_paragraph(
"Build and prepare Prole Tools.app for deployment.", y=130
)
# 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 = 180
left = 56
spacing = 150
# 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 = 10
circle = ui.canvas_oval(
self, x, radio_y, x + 2 * r, radio_y + 2 * r, outline="black", width=2
)
self._canvas_items.append(circle)
# selected dot
if self.deploy_env_value == label:
dot = ui.canvas_oval(
self,
x + 5,
radio_y + 5,
x + 2 * r - 5,
radio_y + 2 * r - 5,
fill="black",
outline="",
)
self._canvas_items.append(dot)
text = ui.canvas_text(
self,
x + 2 * r + 10,
radio_y - 2,
label,
fill="black",
font=("SF Pro Text", 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("<Button-1>", _on_click)
# Output Console (Consistent size and location: y=260, width=900, height=520)
self._build_console = self._create_console_output(
y=230, title="Build Output", width=900, height=550
)
self._console_text = (
self._build_console.text
) # For compatibility with _append_console and others
# 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("<Return>", _enter_build)
self.root.bind("<KP_Enter>", _enter_build)
except Exception:
pass
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
# Use a background frame to ensure NO borders are visible around the console
console_bg = tk.Frame(f, bg="white", highlightthickness=0, bd=0)
console_bg.pack(fill="both", expand=True, padx=24, pady=12)
self.build_output_console = ui.TerminalConsole(
console_bg, highlightthickness=0, bd=0
)
self.build_output_console.pack(fill="both", expand=True, padx=1, pady=1)
self.build_output = self.build_output_console.text
# self._register_page('build', f)
def perform_build(self):
"""Run Prole build in an embedded console (Scoped bash subprocess)."""
self._action_flags["build.run_build"] = True
# Ensure environment exists and is readable before attempting build
try:
self.ensure_prole_env()
except Exception as e:
# Print a readable error to the console area and abort
try:
self._console_press_enter()
except Exception:
pass
err_msg = str(e).replace("'", "'\\''")
err = f"echo 'ERROR: {err_msg}' && exit 1"
self._run_in_console(err, None, on_complete=lambda rc: None)
return
# 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 = self._resolve_prole_logs_dir()
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)
self._record_install_log(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 check for removable disks
try:
self._built_success = returncode == 0
except Exception:
self._built_success = False
# Re-enable Next button
try:
self.next_button.configure(state="normal")
except Exception:
pass
# Navigate to disk_selection if removable disks exist, otherwise build_summary
if getattr(self, "_built_success", False):
disks = self.get_removable_disks()
if disks:
self.show_page("disk_selection")
else:
self.show_page("build_summary")
else:
# Build failed, stay on build page
pass
# ---------------- Embedded console helpers ----------------
def _ensure_console_overlay(self, radio_bottom_y: int = 160):
"""Create semi-transparent backdrop and a ScrolledText console overlay.
The overlay is placed within slide_area between given top and bottom margins.
"""
# Ensure slide_area is visible
self.slide_area.place(relx=0, rely=0, relwidth=1, relheight=1)
self.slide_area.lift()
# Compute geometry within slide area
geom = self._compute_console_geometry(radio_bottom_y)
left, top, width, height = geom
# Use a background frame to ensure NO borders are visible around the console
console_bg = tk.Frame(self.slide_area, bg="white", highlightthickness=0, bd=0)
console_bg.place(x=left, y=top, width=width, height=height)
# Create TerminalConsole overlay
console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0)
console.pack(fill="both", expand=True, padx=1, pady=1)
self._overlay_widgets.append(console_bg)
self._overlay_widgets.append(console)
self._console_text = console.text
# Keep overlay positioned on resize
def _on_resize(_evt=None):
l, t, w, h = self._compute_console_geometry(radio_bottom_y)
try:
console_bg.place(x=l, y=t, width=w, height=h)
except Exception:
pass
# Bind to slide area; store bind id to unbind later
self._overlay_bind_id = self.slide_area.bind("<Configure>", _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 = 1300 * 0.75, 910 - 64 # Content area size approx
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:
if not txt.winfo_exists():
return
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…", state="disabled")
except Exception:
pass
# Reader thread
def reader():
rc = None
try:
for line in proc.stdout:
if line is None:
break
self.safe_after(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.safe_after(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, 1300, 910
margin = 24
top = y0 + radio_bottom_y + 10
bottom = y0 + h - 90 # leave space for footer
# Offset left by sidebar width (325) + divider (1)
left = x0 + 326 + 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("<Configure>", _follow)
self._terminal_follow_bound = True
def _stop_terminal_follow(self):
if getattr(self, "_terminal_follow_bound", False):
try:
self.root.unbind("<Configure>")
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 knoe.build.get_build_command."""
return inst_get_build_command(PROJECT_ROOT, env)
# ---------------- Build Summary page ----------------
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 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)