mirror of
https://github.com/dredx/prole.git
synced 2026-09-24 16:44:33 +00:00
- Add build-context helper to copy Docker context safely (ignore runtime data, keep symlinks) - Update UI and core actions to use ~/.prole/build and shared copy helper - Add/adjust tests and scripts; introduce knoe ops helpers and update manifests Co-authored-by: Junie <junie@jetbrains.com>
832 lines
31 KiB
Python
832 lines
31 KiB
Python
"""Build screen, terminal console overlay and process management."""
|
|
|
|
import os
|
|
import platform
|
|
import re
|
|
import shlex
|
|
import signal
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
import webbrowser
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox, filedialog
|
|
from knoe.screen import TerminalConsole
|
|
from knoe import deploy as inst_deploy
|
|
from knoe.build import get_build_command as inst_get_build_command
|
|
from knoe import screen as ui
|
|
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,
|
|
"Prole",
|
|
fill="#6e6e73",
|
|
font=("SF Pro Text", 32, "bold"),
|
|
anchor="ne",
|
|
)
|
|
ui.canvas_text(
|
|
self,
|
|
right_margin,
|
|
85,
|
|
"Infrastructure Automated.",
|
|
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)
|