"""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("", _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("", _enter_build) self.root.bind("", _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("", _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("", _follow) self._terminal_follow_bound = True def _stop_terminal_follow(self): if getattr(self, "_terminal_follow_bound", False): try: self.root.unbind("") 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)