mirror of
https://github.com/dredx/prole.git
synced 2026-09-27 07:14:29 +00:00
Separation of concerns: merge silent/UI actions & modularize screens. ProleInstallerBase (actions.py): Created shared base class with 37 deduplicated methods previously duplicated between ProleSilentInstaller and ProleInstaller. Namespace, environment, secret, deployment, port-forward, authority/repair, image, and logging helpers now defined once. Subclasses override _get_input() to bridge their data-access layers. screens.py -> screens/ package (18 mixin modules): Split 10,234-line monolithic screens.py into focused mixin modules: base, navigation, welcome, dependencies, network, environment, database, cluster, services, security, ollama, supabase, docker, build, packaging, deploy, validate, cfg. __init__.py composes ProleInstaller from all mixins and re-exports has_display(), main() for full backward compatibility. All 37 tests pass with no regressions.
727 lines
29 KiB
Python
727 lines
29 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 installer.screen import TerminalConsole
|
|
from installer import deploy as inst_deploy
|
|
from installer.build import get_build_command as inst_get_build_command
|
|
from installer import screen as ui
|
|
from installer.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 installer.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)
|