mirror of
https://github.com/dredx/prole.git
synced 2026-09-27 16:24:30 +00:00
- Eliminated reliance on `bash -lc` for dependency lookups, using `shutil.which` for better cross-platform support. - Added `_resolve_brew_bin` fallback logic to handle specific Homebrew scenarios. - Updated tests to align with the new streamlined lookup mechanism. - Refactored subprocess checks to respect augmented environment configurations and reduce PATH issues.
1155 lines
42 KiB
Python
1155 lines
42 KiB
Python
"""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 installer.screen import TerminalConsole
|
|
from installer import screen as ui
|
|
from installer import config as inst_config
|
|
from installer.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(
|
|
"<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,
|
|
"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 installer/scripts."""
|
|
script_path = PROJECT_ROOT / "install_dependencies.sh"
|
|
scripts_dir = PROJECT_ROOT / "installer" / "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
|