prole/knoe/ui/screens/packaging.py

1155 lines
42 KiB
Python

"""Disk selection, installer packaging, DMG creation and build summary."""
import os
import plistlib
import shutil
import subprocess
import sys
import threading
import time
import tkinter as tk
import webbrowser
from pathlib import Path
from tkinter import ttk, messagebox, filedialog
import platform
from knoe import config as inst_config
from knoe import screen as ui
from knoe.core.env import PROJECT_ROOT, get_resource_path
class PackagingScreenMixin:
"""Disk selection, installer packaging, DMG creation and build summary."""
def _render_disk_selection_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,
"Deployment Destination.",
fill="#6e6e73",
font=("SF Pro Text", 18),
anchor="ne",
)
self._render_title("Select Destination", y=150)
self._render_paragraph(
"Choose where you would like to deploy the built Prole application and its supporting artifacts.",
y=210,
)
# Container for the two main options
y_options = 300
x_center = content_width // 2
# We need large friendly images. I'll use placeholders if I can't find specific ones.
# But I'll try to use symbols or colors for now if images are missing.
try:
from PIL import Image, ImageTk
# Using proleIcon.png as a placeholder for both for now, but I'll add distinct styling
icon_path = PROJECT_ROOT / "img" / "proleIcon.png"
icon_img = Image.open(str(icon_path)).resize((128, 128), Image.LANCZOS)
self._disk_icon_tk = ImageTk.PhotoImage(icon_img)
except Exception:
self._disk_icon_tk = None
# Option 1: Removable Disk
frame_usb = tk.Frame(
self.bg_canvas,
bg="white",
highlightthickness=1,
highlightbackground="#CCCCCC",
padx=20,
pady=20,
)
usb_window = self.bg_canvas.create_window(
x_center - 250, y_options, window=frame_usb, anchor="n", width=350
)
self._overlay_widgets.append(frame_usb)
self._canvas_items.append(usb_window)
if self._disk_icon_tk:
lbl_img_usb = tk.Label(
frame_usb, image=self._disk_icon_tk, bg="white", cursor="hand2"
)
lbl_img_usb.pack()
lbl_img_usb.bind(
"<Button-1>", lambda e: self.selected_disk_type.set("removable")
)
tk.Radiobutton(
frame_usb,
text="USB / Flash Drive",
variable=self.selected_disk_type,
value="removable",
bg="white",
font=("SF Pro Text", 14, "bold"),
).pack(pady=10)
# Dropdown for removable disks
disk_names = [d[0] for d in self.removable_disks] or [
"No removable disks detected"
]
if not self.selected_removable_disk.get() and self.removable_disks:
self.selected_removable_disk.set(self.removable_disks[0][1])
self.disk_dropdown = ttk.Combobox(
frame_usb, values=disk_names, state="readonly", width=30
)
self.disk_dropdown.pack(pady=5)
if disk_names:
self.disk_dropdown.current(0)
def on_disk_select(event):
idx = self.disk_dropdown.current()
if idx >= 0 and idx < len(self.removable_disks):
self.selected_removable_disk.set(self.removable_disks[idx][1])
self.selected_disk_type.set("removable")
self.disk_dropdown.bind("<<ComboboxSelected>>", on_disk_select)
# Option 2: Local Folder
frame_local = tk.Frame(
self.bg_canvas,
bg="white",
highlightthickness=1,
highlightbackground="#CCCCCC",
padx=20,
pady=20,
)
local_window = self.bg_canvas.create_window(
x_center + 250, y_options, window=frame_local, anchor="n", width=350
)
self._overlay_widgets.append(frame_local)
self._canvas_items.append(local_window)
if self._disk_icon_tk:
lbl_img_local = tk.Label(
frame_local, image=self._disk_icon_tk, bg="white", cursor="hand2"
)
lbl_img_local.pack()
lbl_img_local.bind(
"<Button-1>", lambda e: self.selected_disk_type.set("local")
)
tk.Radiobutton(
frame_local,
text="Local Filesystem",
variable=self.selected_disk_type,
value="local",
bg="white",
font=("SF Pro Text", 14, "bold"),
).pack(pady=10)
# Path input and browse
path_frame = tk.Frame(frame_local, bg="white")
path_frame.pack(fill="x", pady=5)
ent_path = tk.Entry(
path_frame,
textvariable=self.selected_local_path,
font=("SF Pro Text", 10),
width=30,
bg="white",
fg="black",
insertbackground="black",
)
ent_path.pack(side="left", padx=(0, 5))
def browse_local():
from tkinter import filedialog
d = filedialog.askdirectory(initialdir=self.selected_local_path.get())
if d:
self.selected_local_path.set(d)
self.selected_disk_type.set("local")
btn_browse = tk.Button(
path_frame, text="Browse...", command=browse_local, bg="#F5F5DC"
)
btn_browse.pack(side="left")
def _render_build_summary_page(self):
# Ensure slide_area is visible for the build log console
self.slide_area.place(relx=0, rely=0, relwidth=1, relheight=1)
self.slide_area.lift()
# Build Summary: show combined stdout/stderr from last build log
self._render_title("Build Summary", y=40)
if getattr(self, "_built_success", False):
self._render_paragraph(
"✅ Build completed successfully. You can now drag Prole Tools.app into Applications. Output below:",
y=88,
)
else:
# Troubleshooting header
tips = (
"❌ Build failed. Troubleshooting tips:\n"
"• Ensure Xcode Command Line Tools are installed: xcode-select --install\n"
"• Verify Swift toolchain and SPM networking (try again; network hiccups can happen)\n"
"• If on Apple Silicon, ensure dependencies target arm64 or install via Homebrew\n"
"• Clean derived data / SPM cache if needed: rm -rf ~/Library/Developer/Xcode/DerivedData\n"
"• Check Docker status if Docker-related steps are used\n"
"• Check the logs below for specific errors"
)
self._render_paragraph(tips, y=88)
logp = getattr(self, "last_build_log_path", None)
# Place a scrollable console to show the log
try:
# Standardized Console Output area for build summary
self.build_summary_console = self._create_console_output(
y=160, title="Build Log Output", width=900, height=520
)
txt = self.build_summary_console.text
if logp and os.path.exists(logp):
try:
with open(logp, "r", encoding="utf-8", errors="ignore") as fp:
content = fp.read()
self.build_summary_console.write(content)
except Exception as e:
self.build_summary_console.write(f"Failed to read log: {e}\n")
else:
self.build_summary_console.write("No build log available.")
# Keep standard y for links
y_links = 760
except Exception:
# Fallback: just show path
self._render_paragraph("No build log could be displayed.", y=120)
y_links = 140
# Add link to open the log file in Finder/TextEdit
if logp:
link = ui.render_link(self, 56, y_links, "Open build log file")
self._canvas_items.append(link)
def _open_log(event):
ex, ey = event.x, event.y
bbox = self.bg_canvas.bbox(link)
if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]:
try:
if platform.system() == "Darwin":
subprocess.run(["open", logp])
else:
webbrowser.open(f"file://{logp}")
except Exception:
pass
self.bg_canvas.bind("<Button-1>", _open_log)
# On success, also offer DMG packaging and opening options
if getattr(self, "_built_success", False):
try:
dist_dir = str(self._get_prole_dist_dir())
except Exception:
dist_dir = None
if dist_dir:
y_links += 30
# Create DMG
link2 = ui.render_link(self, 56, y_links, "Create Prole Tools.dmg")
self._canvas_items.append(link2)
def _open_dist(event):
ex, ey = event.x, event.y
bbox = self.bg_canvas.bbox(link2)
if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]:
try:
# Build the DMG, then offer to open it
self.create_dmg()
self.open_dmg()
except Exception:
pass
self.bg_canvas.bind("<Button-1>", _open_dist)
# Also link to open dist folder (fallback)
y_links += 30
link3 = ui.render_link(self, 56, y_links, "Open dist folder")
self._canvas_items.append(link3)
def _open_dist_folder(event):
ex, ey = event.x, event.y
bbox = self.bg_canvas.bbox(link3)
if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]:
try:
if platform.system() == "Darwin":
subprocess.run(["open", dist_dir])
else:
webbrowser.open(f"file://{dist_dir}")
except Exception:
pass
self.bg_canvas.bind("<Button-1>", _open_dist_folder)
# ---------------- Drag-and-drop install (macOS Finder) ----------------
def _render_create_installer_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",
)
self._render_title("Post Install", y=150)
self._render_paragraph(
"Review installation logs and save the deployment artifacts.", y=200
)
# Tabs for output - using standardized appearance
ui.canvas_text(
self,
48,
260,
"Installation Logs",
fill="#1d1d1f",
font=("SF Pro Text", 12, "bold"),
)
# Use a background frame for the notebook to hide potential system borders
notebook_bg = tk.Frame(self.bg_canvas, bg="white", highlightthickness=0, bd=0)
self.install_tabs = ttk.Notebook(notebook_bg, style="TNotebook")
self.install_tabs.pack(fill="both", expand=True, padx=1, pady=1)
tab_window = self.bg_canvas.create_window(
48, 290, window=notebook_bg, anchor="nw", width=900, height=450
)
self._canvas_items.append(tab_window)
self._overlay_widgets.append(notebook_bg)
self._overlay_widgets.append(self.install_tabs)
self.install_consoles = {}
def _add_tab(title: str, content: str = "", script_name: str | None = None):
# Use a background frame to ensure NO borders are visible around the console
console_bg = tk.Frame(
self.install_tabs, bg="white", highlightthickness=0, bd=0
)
self.install_tabs.add(console_bg, text=title)
# Use TerminalConsole for consistent styling
console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0)
console.pack(fill="both", expand=True, padx=1, pady=1)
if content:
console.write(content)
if script_name:
self.install_consoles[script_name] = console
return console
# Add existing logs
logs = self._collect_install_logs()
for p in logs:
try:
text = p.read_text(encoding="utf-8", errors="ignore")
content = f"{p}\n\n{text}"
except Exception as e:
content = f"{p}\n\nFailed to read log: {e}\n"
_add_tab(p.name, content)
# Add prole.cfg tab
cfg_path = self._resolve_prole_conf_dir() / "prole.cfg"
try:
cfg_text = cfg_path.read_text(encoding="utf-8", errors="ignore")
cfg_content = f"{cfg_path}\n\n{cfg_text}"
except Exception as e:
cfg_content = f"{cfg_path}\n\nprole.cfg not found or unreadable: {e}\n"
_add_tab("prole.cfg", cfg_content)
# Add final_deployment.sh tab
_add_tab("final_deployment.sh", script_name="final_deployment.sh")
# Add build-a-bao.sh tab
_add_tab("build-a-bao.sh", script_name="build-a-bao.sh")
# Build-A-Bao Button
self._build_a_bao_button = tk.Button(
self.bg_canvas,
text="Build-A-Bao",
command=self.run_build_a_bao,
bg="#F5F5DC",
fg="black",
activebackground="#E5E5D5",
highlightbackground="#F5F5DC",
highlightthickness=0,
relief="flat",
font=("SF Pro Text", 11),
padx=16,
pady=8,
)
bao_btn_window = self.bg_canvas.create_window(
48, 760, window=self._build_a_bao_button, anchor="nw", width=180
)
self._canvas_items.append(bao_btn_window)
self._overlay_widgets.append(self._build_a_bao_button)
# Status Label
self._build_a_bao_status_label = ui.canvas_text(
self, 48, 802, "", fill="black", font=("SF Pro Text", 12)
)
self._canvas_items.append(self._build_a_bao_status_label)
def _get_prole_dist_dir(self) -> Path:
return PROJECT_ROOT / "prole-tools-app" / "dist"
def ensure_applications_symlink(self):
"""Deprecated: no longer create /Applications symlink inside the repo.
We now place the symlink only inside the DMG staging directory to avoid
confusing IDE indexers and to keep the workspace clean.
"""
return
def open_drag_install_window(self):
"""Open the Prole DMG in Finder (macOS) for drag-and-drop install."""
if platform.system() != "Darwin":
return
p = self._get_dmg_paths()
dmg = p["final_dmg"]
if not dmg.exists():
try:
self.create_dmg()
except Exception:
pass
try:
subprocess.run(["open", str(dmg)])
except Exception:
try:
subprocess.run(["open", str(p["dist"])])
except Exception:
pass
# ---------------- DMG Packaging ----------------
def _get_dmg_paths(self):
# Ensure we have a valid path for DMG output.
# Default to ~/Downloads if selected path is home or invalid.
raw_path = self.selected_local_path.get()
user_dist = Path(raw_path).expanduser()
if str(user_dist) == str(Path.home()):
user_dist = Path.home() / "Downloads"
self.selected_local_path.set(str(user_dist))
try:
user_dist.mkdir(parents=True, exist_ok=True)
except Exception:
user_dist = self._get_prole_dist_dir()
dmg_name = "Prole Tools.dmg"
tmp_dmg = user_dist / "Prole Tools.tmp.dmg"
final_dmg = user_dist / dmg_name
# Use a temporary staging area in the app's dist dir to keep user folder clean
app_dist = self._get_prole_dist_dir()
staging = app_dist / "dmg_stage"
bg_dir = staging / ".background"
bg_img = get_resource_path("img/proleLogoBlueprint.png")
return {
"dist": user_dist,
"tmp_dmg": tmp_dmg,
"final_dmg": final_dmg,
"staging": staging,
"bg_dir": bg_dir,
"bg_img": bg_img,
}
def create_dmg(self):
"""Create a DMG containing Prole Tools.app, a 'setup' binary, and an /Applications symlink."""
global installer_name
if platform.system() != "Darwin":
return
p = self._get_dmg_paths()
app_src = get_resource_path("prole-app/dist/Prole Tools.app")
if not app_src.exists():
print(f"Error: {app_src} not found. Run build first.")
return
staging = p["staging"]
try:
if staging.exists():
print(f"Cleaning staging area: {staging}")
shutil.rmtree(staging, ignore_errors=True)
staging.mkdir(parents=True, exist_ok=True)
# 1. Build static installer binary using PyInstaller
print("Building static installer binary...")
installer_name = "Install Prole Infrastructure"
icon_path = PROJECT_ROOT / "img" / "proleIconblueprint.png"
try:
# Use --onefile for a single executable
cmd = [
sys.executable,
"-m",
"PyInstaller",
"--onefile",
"--name",
installer_name,
"--clean",
"--noconsole", # It's a GUI app (tkinter)
]
if icon_path.exists():
cmd.extend(["--icon", str(icon_path)])
cmd.append("install.py")
subprocess.check_call(cmd)
setup_bin = PROJECT_ROOT / "dist" / installer_name
if setup_bin.exists():
dst_setup = staging / installer_name
if dst_setup.exists():
if dst_setup.is_dir():
shutil.rmtree(dst_setup)
else:
dst_setup.unlink()
shutil.copy2(setup_bin, dst_setup)
else:
print(
f"Error: PyInstaller failed to create '{installer_name}' binary."
)
except Exception as e:
messagebox.showerror("Error", f"Failed to create DMG: {e}")
print(f"Warning: Failed to build setup binary with PyInstaller: {e}")
# 2. Copy Prole Tools.app to staging (at root for drag-and-drop)
print("Copying Prole Tools.app to staging...")
dst_app = staging / "Prole Tools.app"
if dst_app.exists():
shutil.rmtree(dst_app)
if sys.version_info >= (3, 8):
shutil.copytree(app_src, dst_app, dirs_exist_ok=True)
else:
subprocess.check_call(["cp", "-R", str(app_src), str(dst_app)])
# Inject launcher wrapper into Prole Tools.app
macos_dir = dst_app / "Contents" / "MacOS"
launcher_path = macos_dir / "Prole Tools"
real_bin_path = macos_dir / "ProleTools.bin"
if launcher_path.exists() and launcher_path.is_file():
if real_bin_path.exists():
if real_bin_path.is_dir():
shutil.rmtree(real_bin_path)
else:
real_bin_path.unlink()
os.rename(launcher_path, real_bin_path)
script = """#!/bin/bash
set -euo pipefail
export PROLE_HOME="${PROLE_HOME:-$HOME/.prole}"
if [ -f "$PROLE_HOME/env.sh" ]; then
. "$PROLE_HOME/env.sh"
fi
DIR="$(cd "$(dirname "$0")" && pwd)"
exec "$DIR/ProleTools.bin" "$@"
"""
with open(launcher_path, "w") as fp:
fp.write(script)
os.chmod(launcher_path, 0o755)
# 3. Create Applications symlink
try:
os.symlink("/Applications", str(staging / "Applications"))
except FileExistsError:
pass
# 4. Background image
if p["bg_dir"].exists():
shutil.rmtree(p["bg_dir"])
p["bg_dir"].mkdir(parents=True, exist_ok=True)
if p["bg_img"].exists():
shutil.copy2(p["bg_img"], p["bg_dir"] / "background.png")
# 5. Create the DMG
tmp_dmg = p["tmp_dmg"]
final_dmg = p["final_dmg"]
if final_dmg.exists():
os.remove(final_dmg)
# Create the DMG using hdiutil
messagebox.showinfo(
"Creating DMG", "Building DMG image. This may take a minute..."
)
subprocess.check_call(
[
"hdiutil",
"create",
"-volname",
"Prole",
"-srcfolder",
str(staging),
"-ov",
"-format",
"UDRW", # Create as Read/Write initially to modify view options
str(tmp_dmg),
]
)
# Positions in DMG:
# [Install Prole Infrastructure] (left)
# [Prole Tools.app] (center/right)
# [Applications] (below Prole Tools.app)
# Note: We use the installer name in the AppleScript.
# Finder items need to match the actual file names on disk.
# \n in filename might be literal or interpreted.
# 6. Set DMG view options (large icons) using AppleScript
print("Configuring DMG view options...")
mount_point = Path("/Volumes/Prole")
try:
# Detach if already mounted
subprocess.run(
["hdiutil", "detach", str(mount_point)], capture_output=True
)
# Mount the temporary DMG
subprocess.check_call(["hdiutil", "attach", str(tmp_dmg), "-nobrowse"])
# Give it a moment to mount
time.sleep(2)
if mount_point.exists():
# Escape the installer name for AppleScript
# Use the name that actually exists on disk.
# PyInstaller might have replaced \n with something else in the filename if it was problematic,
# but usually it's literal in the FS if allowed.
applescript = f"""
tell application "Finder"
tell disk "Prole"
open
set current view of container window to icon view
set toolbar visible of container window to false
set statusbar visible of container window to false
set the_container to container window
set bounds of the_container to {{400, 100, 1000, 600}}
set icon_view_options to icon view options of the_container
set icon size of icon_view_options to 192
set arrangement of icon_view_options to not arranged
set background picture of icon_view_options to file ".background:background.png"
-- Position icons
set position of item "{installer_name}" of container window to {{150, 200}}
set position of item "Prole Tools.app" of container window to {{450, 200}}
set position of item "Applications" of container window to {{450, 400}}
update without registering applications
delay 2
close
end tell
end tell
"""
subprocess.run(["osascript", "-e", applescript])
# Detach
subprocess.check_call(["hdiutil", "detach", str(mount_point)])
# Convert to final compressed format
if final_dmg.exists():
os.remove(final_dmg)
subprocess.check_call(
[
"hdiutil",
"convert",
str(tmp_dmg),
"-format",
"UDZO",
"-o",
str(final_dmg),
]
)
if tmp_dmg.exists():
os.remove(tmp_dmg)
except Exception as e:
print(f"Warning: Failed to set DMG view options: {e}")
# Fallback: just rename tmp_dmg if conversion/AppleScript failed
if not final_dmg.exists():
os.rename(tmp_dmg, final_dmg)
messagebox.showinfo("Success", f"Successfully created {final_dmg}")
print(f"Successfully created {final_dmg}")
finally:
# Clean up staging directory
try:
if staging.exists():
shutil.rmtree(staging, ignore_errors=True)
# Also clean up PyInstaller artifacts
for d in ["build", "dist"]:
p_path = PROJECT_ROOT / d
if p_path.exists():
# Be careful not to delete 'dist' if it contains our final DMG
# However, create_dmg is usually run to build the DMG
# and PyInstaller artifacts are usually temporary in this context.
# We only remove them if they were created during this run.
pass
spec_file = PROJECT_ROOT / f"{installer_name}.spec"
if spec_file.exists():
os.remove(spec_file)
except Exception:
pass
def open_dmg(self):
"""Reveal the created DMG in Finder without mounting it inline.
This avoids blocking the installer process and lets macOS handle
mounting/ejecting normally when the user opens the DMG.
"""
if platform.system() != "Darwin":
return
p = self._get_dmg_paths()
dmg = p["final_dmg"]
if not dmg.exists():
# Try to create it first
self.create_dmg()
# Reveal the DMG in Finder (non-blocking); do NOT attach/mount inline
try:
subprocess.run(["open", "-R", str(dmg)])
except Exception:
try:
# Fallback: open the dist folder
subprocess.run(["open", str(p["dist"])])
except Exception:
pass
def create_install_screen(self):
"""Create the dependency installer screen"""
frame = tk.Frame(self.content_area, bg="#1a1a1a")
self.screens["install"] = frame
# Title
title = ttk.Label(frame, text="Install Dependencies", style="Title.TLabel")
title.pack(pady=(0, 30))
# Instructions
instructions = tk.Label(
frame,
text="Install the following dependencies to proceed with Prole deployment:",
bg="#1a1a1a",
fg="#aaaaaa",
font=("Helvetica", 11),
)
instructions.pack(pady=(0, 20))
# Dependencies list
deps_frame = tk.Frame(frame, bg="#1a1a1a")
deps_frame.pack(fill="both", expand=True)
dependencies = [
{
"name": "Docker",
"description": "Container platform for running Prole services",
"url": "https://www.docker.com/products/docker-desktop",
"install_cmd": None,
"check_cmd": "docker --version",
},
{
"name": "Homebrew",
"description": "Package manager for macOS",
"url": "https://brew.sh",
"install_cmd": '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
"check_cmd": "brew --version",
},
{
"name": "k3d",
"description": "Lightweight wrapper to run k3s in Docker",
"url": "https://k3d.io",
"install_cmd": "brew install k3d",
"check_cmd": "k3d --version",
},
{
"name": "kubectl",
"description": "Kubernetes command-line tool",
"url": "https://kubernetes.io/docs/tasks/tools/",
"install_cmd": "brew install kubectl",
"check_cmd": "kubectl version --client",
},
{
"name": "Helm",
"description": "Kubernetes package manager",
"url": "https://helm.sh",
"install_cmd": "brew install helm",
"check_cmd": "helm version",
},
{
"name": "krew",
"description": "Kubectl plugin manager",
"url": "https://krew.sigs.k8s.io",
"install_cmd": "brew install krew",
"check_cmd": "kubectl krew version",
},
{
"name": "cmctl",
"description": "cert-manager CLI tool",
"url": "https://cert-manager.io",
"install_cmd": "brew install cmctl",
"check_cmd": "cmctl version",
},
{
"name": "OpenTofu",
"description": "Open-source infrastructure as code tool",
"url": "https://opentofu.org",
"install_cmd": "brew install opentofu",
"check_cmd": "tofu --version",
},
]
self.dep_status = {}
for dep in dependencies:
self.create_dependency_card(deps_frame, dep)
# Generate installer script button
script_btn = tk.Button(
frame,
text="Generate Installer Script",
command=self.generate_installer_script,
bg="#4a9eff",
fg="white",
activebackground="#3a8eef",
font=("Helvetica", 12, "bold"),
padx=30,
pady=15,
cursor="hand2",
relief="flat",
)
script_btn.pack(pady=20)
def create_dependency_card(self, parent, dep):
"""Create a dependency card with status and download link"""
card = tk.Frame(parent, bg="#2a2a2a", relief="flat", bd=1)
card.pack(fill="x", pady=5, padx=10)
# Left side - info
info_frame = tk.Frame(card, bg="#2a2a2a")
info_frame.pack(side="left", fill="both", expand=True, padx=15, pady=15)
name_label = tk.Label(
info_frame,
text=dep["name"],
bg="#2a2a2a",
fg="#ffffff",
font=("Helvetica", 13, "bold"),
anchor="w",
)
name_label.pack(fill="x")
desc_label = tk.Label(
info_frame,
text=dep["description"],
bg="#2a2a2a",
fg="#aaaaaa",
font=("Helvetica", 10),
anchor="w",
)
desc_label.pack(fill="x", pady=(5, 0))
# Right side - status and actions
action_frame = tk.Frame(card, bg="#2a2a2a")
action_frame.pack(side="right", padx=15, pady=15)
# Status indicator
status_label = tk.Label(
action_frame,
text="Checking...",
bg="#2a2a2a",
fg="#ffaa00",
font=("Helvetica", 10),
)
status_label.pack(side="left", padx=10)
self.dep_status[dep["name"]] = {"label": status_label, "dep": dep}
# Download button
download_btn = tk.Button(
action_frame,
text="Download",
command=lambda url=dep["url"]: webbrowser.open(url),
bg="#28a745",
fg="white",
activebackground="#218838",
font=("Helvetica", 10),
padx=15,
pady=5,
cursor="hand2",
relief="flat",
)
download_btn.pack(side="left", padx=5)
# Check status
self.check_dependency(dep["name"])
def check_dependency(self, name):
"""Check if a dependency is installed"""
dep_info = self.dep_status[name]
dep = dep_info["dep"]
label = dep_info["label"]
def check():
try:
env = inst_config._augment_env_for_brew(os.environ.copy())
result = subprocess.run(
["bash", "-c", dep["check_cmd"]],
capture_output=True,
text=True,
timeout=5,
env=env,
)
if result.returncode == 0:
label.configure(text="✓ Installed", fg="#28a745")
else:
label.configure(text="✗ Not Installed", fg="#dc3545")
except Exception:
label.configure(text="✗ Not Installed", fg="#dc3545")
threading.Thread(target=check, daemon=True).start()
def generate_installer_script(self):
"""Generate installer script by composing per-dependency templates from knoe/scripts."""
script_path = PROJECT_ROOT / "install_dependencies.sh"
scripts_dir = PROJECT_ROOT / "knoe" / "scripts"
# Expected order of templates
expected = [
"install_prole_homebrew.sh",
"install_prole_k3d.sh",
"install_prole_kubectl.sh",
"install_prole_helm.sh",
"install_prole_krew.sh",
"install_prole_cmctl.sh",
"install_prole_opentofu.sh",
"install_prole_kubectl_plugins.sh",
]
def _validate_and_strip(path: Path) -> str:
"""Validate template format and return content stripped of shebang and initial set -e* line."""
try:
text = path.read_text(encoding="utf-8")
except Exception as e:
raise RuntimeError(f"Failed to read {path.name}: {e}")
lines = text.splitlines()
if not lines:
raise RuntimeError(f"Template {path.name} is empty")
# Validate shebang
if not lines[0].startswith("#!/bin/bash"):
raise RuntimeError(f"Template {path.name} must start with #!/bin/bash")
# Ensure there is a set -e or set -euo pipefail somewhere within first 10 lines
has_set = any("set -e" in l for l in lines[:10])
if not has_set:
raise RuntimeError(
f"Template {path.name} must set '-e' or 'set -euo pipefail'"
)
# Strip shebang
i = 1
# Optionally strip empty/comment lines immediately after shebang
while i < len(lines) and lines[i].strip() == "":
i += 1
# If next non-empty is a "set -e*" line, drop it to avoid duplicates in composed script
if i < len(lines) and lines[i].lstrip().startswith("set -e"):
i += 1
body = "\n".join(lines[i:]).strip() + "\n"
return body
# Compose the final script
errors: list[str] = []
parts: list[str] = []
header = (
"#!/bin/bash\n"
"# Prole Dependencies Installer Script\n"
"# Generated by Prole Installer\n\n"
"set -euo pipefail\n\n"
'echo "Installing Prole dependencies..."\n\n'
)
parts.append(header)
for fname in expected:
p = scripts_dir / fname
if not p.exists():
errors.append(f"Missing template: {fname}")
continue
try:
body = _validate_and_strip(p)
parts.append(f"# ---- {fname} ----\n")
parts.append(body)
parts.append("\n")
except Exception as e:
errors.append(str(e))
parts.append('echo "All dependencies installed successfully!"\n')
if errors:
messagebox.showerror("Template error", "\n".join(errors))
return
try:
with open(script_path, "w", encoding="utf-8", newline="\n") as f:
f.write("".join(parts))
os.chmod(script_path, 0o755)
messagebox.showinfo(
"Success",
f"Installer script generated at:\n{script_path}\n\n"
"You can run it with: ./install_dependencies.sh",
)
except Exception as e:
messagebox.showerror("Error", f"Failed to generate script: {str(e)}")
def _collect_install_logs(self) -> list[Path]:
logs_dir = self._resolve_prole_logs_dir()
build_logs = []
init_logs = []
seen = set()
def _add(p: Path):
sp = str(p)
if sp in seen:
return
if not p.exists():
return
seen.add(sp)
if p.name.startswith("build-"):
build_logs.append(p)
elif (
p.name.startswith("init_")
or p.name.startswith("init-")
or p.name == "init_common_services.log"
):
init_logs.append(p)
for raw in getattr(self, "_install_run_log_paths", []):
try:
_add(Path(raw))
except Exception:
pass
try:
lbp = getattr(self, "last_build_log_path", None)
if lbp:
_add(Path(lbp))
except Exception:
pass
if not build_logs:
try:
candidates = sorted(
logs_dir.glob("build-*.log"), key=lambda p: p.stat().st_mtime
)
if candidates:
_add(candidates[-1])
except Exception:
pass
if not init_logs:
try:
candidates = sorted(logs_dir.glob("init_*.log"))
for p in candidates:
_add(p)
except Exception:
pass
try:
build_logs.sort(key=lambda p: p.stat().st_mtime)
except Exception:
pass
try:
init_logs.sort(key=lambda p: p.name)
except Exception:
pass
return build_logs + init_logs
def get_removable_disks(self):
"""Detect removable media on macOS using diskutil."""
self.removable_disks = []
if platform.system() != "Darwin":
return self.removable_disks
try:
# Get list of all disks
res = subprocess.run(
["diskutil", "list", "-plist"], capture_output=True, text=True
)
if res.returncode != 0:
return []
import plistlib
data = plistlib.loads(res.stdout.encode())
all_disks = data.get("AllDisks", [])
for disk in all_disks:
# Filter for whole disks to check if they are removable
if not disk.startswith("disk") or "s" in disk:
continue
info_res = subprocess.run(
["diskutil", "info", "-plist", disk], capture_output=True, text=True
)
if info_res.returncode == 0:
info = plistlib.loads(info_res.stdout.encode())
# Check for RemovableMedia or RemovableMediaOrExternalDevice
# Also check BusProtocol to catch most USB sticks if they don't report as removable
is_removable = (
info.get("RemovableMedia", False)
or info.get("RemovableMediaOrExternalDevice", False)
or info.get("BusProtocol") in ["USB", "FireWire", "Thunderbolt"]
)
# Ensure it's not the internal system drive if we are using protocol as a hint
if info.get("Internal", False) and info.get("BusProtocol") not in [
"USB"
]:
is_removable = False
if is_removable:
# Found a removable disk, now find its mounted volumes
# We look for partitions of this disk that are mounted
for d2 in all_disks:
if d2.startswith(disk + "s"):
v_res = subprocess.run(
["diskutil", "info", "-plist", d2],
capture_output=True,
text=True,
)
if v_res.returncode == 0:
v_info = plistlib.loads(v_res.stdout.encode())
mount_point = v_info.get("MountPoint")
volume_name = v_info.get(
"VolumeName"
) or v_info.get("DeviceIdentifier")
if mount_point:
self.removable_disks.append(
(volume_name, mount_point)
)
except Exception as e:
print(f"Error detecting disks: {e}")
return self.removable_disks