mirror of
https://github.com/dredx/prole.git
synced 2026-09-24 18:24:32 +00:00
1155 lines
42 KiB
Python
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
|