"""Disk selection, installer packaging, DMG creation and build summary.""" import os import platform import plistlib import shutil import subprocess import sys import threading import time import webbrowser from pathlib import Path import tkinter as tk from tkinter import ttk, messagebox, filedialog from knoe.screen import TerminalConsole from knoe import screen as ui from knoe import config as inst_config 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, "Prole", 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( "", 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("<>", 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( "", 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("", _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("", _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("", _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, "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", ) 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