"""Sidebar navigation, page transitions, footer and app identity.""" import os import platform from pathlib import Path import tkinter as tk from tkinter import ttk, messagebox, filedialog from installer import config as inst_config from installer.core.env import get_resource_path class NavigationMixin: """Sidebar navigation, page transitions, footer and app identity.""" def _set_app_identity(self): """Set the installer identity: process/menu name and Dock icon on macOS. Notes: - We set Tk's appname for consistency. - On macOS, attempt to set the process/menu name to 'Prole Database Installer' via NSProcessInfo if PyObjC is available. - Prefer the Prole Tools.app .icns from the built app; fallback to local PNG/GIF. """ # Set Tk application name try: self.root.tk.call("tk", "appname", "Prole Database Installer") except Exception: pass # macOS: set process/menu name and Dock icon via AppKit/Foundation if platform.system() == "Darwin": # Try to set the visible process name for the menu bar try: from Foundation import NSProcessInfo NSProcessInfo.processInfo().setProcessName_("Prole Database Installer") except Exception: pass # Also try to retitle the first main menu item so the menu next to the Apple logo reads 'Prole Database Installer' try: from AppKit import NSApplication app = NSApplication.sharedApplication() main_menu = app.mainMenu() if main_menu is not None and main_menu.numberOfItems() > 0: first_item = main_menu.itemAtIndex_(0) if first_item is not None: first_item.setTitle_("Prole Database Installer") except Exception: pass icns_candidates = [ get_resource_path( "prole-app/dist/Prole Tools.app/Contents/Resources/Prole Tools.icns" ), get_resource_path( "prole-app/dist/Prole Tools.app/Contents/Resources/Prole.icns" ), ] icns_path = next((p for p in icns_candidates if p.exists()), None) if icns_path is not None: try: # Use PyObjC if available from AppKit import NSApplication, NSImage img = NSImage.alloc().initWithContentsOfFile_(str(icns_path)) if img is not None: NSApplication.sharedApplication().setApplicationIconImage_(img) return except Exception: pass # If no .icns was found, try setting Dock icon from configured PNG try: from AppKit import NSApplication, NSImage cfg_icon = inst_config.get_ui_icon_image_path() if cfg_icon.exists(): png_img = NSImage.alloc().initWithContentsOfFile_(str(cfg_icon)) if png_img is not None: NSApplication.sharedApplication().setApplicationIconImage_( png_img ) # do not return; still set Tk icon below for consistency except Exception: pass # Fallback: Tk icon from image assets (PNG/GIF) # Prefer config-defined icon image try: cfg_icon = inst_config.get_ui_icon_image_path() except Exception: cfg_icon = get_resource_path("img/proleIcon.png") img_candidates = [ cfg_icon, get_resource_path("img/prole-type.png"), get_resource_path("img/prole-type.gif"), get_resource_path("img/Prole.png"), get_resource_path("img/proleLogoSepia.png"), ] for p in img_candidates: try: if p.exists(): self._app_iconphoto = tk.PhotoImage(file=str(p)) try: self.root.iconphoto(True, self._app_iconphoto) except Exception: pass break except Exception: continue def show_config_dialog(self): """Show configuration dialog for Docker image import directory.""" dialog = tk.Toplevel(self.root) dialog.title("Configuration") dialog.geometry("500x200") dialog.resizable(False, False) dialog.transient(self.root) dialog.grab_set() # Center on parent x = self.root.winfo_x() + (self.root.winfo_width() // 2) - 250 y = self.root.winfo_y() + (self.root.winfo_height() // 2) - 100 dialog.geometry(f"+{x}+{y}") container = tk.Frame(dialog, padx=20, pady=20) container.pack(fill="both", expand=True) tk.Label( container, text="Docker Image Import Directory:", font=("SF Pro Text", 12, "bold"), ).pack(anchor="w") tk.Label( container, text="Images found here will be loaded instead of pulled from Docker Hub.", font=("SF Pro Text", 10), fg="#666666", ).pack(anchor="w", pady=(0, 10)) row = tk.Frame(container) row.pack(fill="x") entry = tk.Entry( row, textvariable=self.docker_import_dir, font=("SF Pro Text", 11), bg="white", fg="black", insertbackground="black", ) entry.pack(side="left", fill="x", expand=True, padx=(0, 5)) def browse(): path = filedialog.askdirectory( initialdir=self.docker_import_dir.get() or Path.home() ) if path: self.docker_import_dir.set(path) tk.Button(row, text="Browse...", command=browse).pack(side="right") def save(): path = self.docker_import_dir.get().strip() self.prole_cfg_data["Global"]["DOCKER_IMPORT_DIR"] = path self._save_prole_cfg() dialog.destroy() btns = tk.Frame(container) btns.pack(fill="x", pady=(20, 0)) tk.Button(btns, text="Save", command=save, width=10).pack(side="right") tk.Button(btns, text="Cancel", command=dialog.destroy, width=10).pack( side="right", padx=10 ) def configure_styles(self): """Configure ttk styles""" base_bg = "white" self.style.configure("TFrame", background="white") self.style.configure( "TLabel", background="white", foreground="black", font=("SF Pro Text", 11) ) self.style.configure("Header.TLabel", font=("SF Pro Text", 16, "bold")) self.style.configure("Small.TLabel", font=("SF Pro Text", 10)) # Consistent button styling self.style.configure( "Nav.TButton", font=("SF Pro Text", 11), padding=(12, 8), background="#F5F5DC", bordercolor="#F5F5DC", lightcolor="#F5F5DC", darkcolor="#F5F5DC", relief="flat", ) # Map state colors to avoid black boxes on hover/active self.style.map( "Nav.TButton", background=[("active", "#E5E5D5"), ("pressed", "#D5D5C5")], bordercolor=[("active", "#E5E5D5"), ("pressed", "#D5D5C5")], lightcolor=[("active", "#E5E5D5"), ("pressed", "#D5D5C5")], darkcolor=[("active", "#E5E5D5"), ("pressed", "#D5D5C5")], ) # Notebook styling to match light theme and avoid dark mode shifts on macOS self.style.theme_use("default") self.style.configure( "TNotebook", background="white", borderwidth=0, highlightthickness=0 ) self.style.configure( "TNotebook.Tab", background="#F5F5DC", foreground="black", lightcolor="#F5F5DC", bordercolor="#CCCCCC", darkcolor="#F5F5DC", borderwidth=1, padding=[10, 5], ) self.style.map( "TNotebook.Tab", background=[("selected", "white")], bordercolor=[("selected", "#CCCCCC")], lightcolor=[("selected", "white")], focuscolor=[("selected", "white")], ) # Ensure common controls inherit white background try: self.style.configure("TCheckbutton", background=base_bg, foreground="black") self.style.configure( "TCombobox", fieldbackground="white", background=base_bg ) except Exception: pass def _create_sidebar_nav(self): """Create the left-hand navigation menu.""" tk.Label( self.sidebar, text="INSTALLER", bg="#F5F5DC", fg="#8B8B7A", font=("SF Pro Text", 10, "bold"), anchor="w", ).pack(fill="x", padx=20, pady=(20, 10)) for text, page_id in self.nav_items: lbl = tk.Label( self.sidebar, text=text, bg="#F5F5DC", fg="black", font=("SF Pro Text", 11), anchor="w", padx=20, pady=5, cursor="hand2", ) lbl.pack(fill="x") lbl.bind("", lambda e, p=page_id: self.show_page(p)) self.nav_widgets[page_id] = lbl def _update_nav_highlight(self, active_id): """Highlight the current active page in the sidebar.""" # Handle dep_id mapping to 'Dependencies' if active_id.startswith("dep_"): active_id = "deps_summary" for page_id, widget in self.nav_widgets.items(): if page_id == active_id: widget.configure(bg="#E5E5D5", font=("SF Pro Text", 11, "bold")) else: widget.configure(bg="#F5F5DC", font=("SF Pro Text", 11)) def create_navigation(self): pass def on_prev(self): # Custom prev navigation for dependency pages when filtering current_id = self.pages[self.page_index][0] # Close Terminal if leaving build summary via Prev if current_id == "build_summary": try: self.close_build_terminal() except Exception: pass if current_id.startswith("dep_"): seq = self._dep_navigation_sequence() try: i = seq.index(current_id) except ValueError: i = -1 if i > 0: self.show_page(seq[i - 1]) return else: # Go back to summary if there is no previous in sequence self.show_page("deps_summary") return if current_id == "deps_summary": self.show_page("welcome") return if current_id == "network_scan": # Go back to last relevant dep or deps_summary if self.all_dependencies_installed(): self.show_page("deps_summary") else: seq = self._dep_navigation_sequence() self.show_page(seq[-1] if seq else "deps_summary") return if current_id == "env_setup": self.show_page("network_scan") return if current_id == "init_cluster": if getattr(self, "_showing_service_overlay", False): self._showing_service_overlay = False self.show_page("init_cluster") return self.show_page("env_setup") return if current_id == "database_options": self.show_page("init_cluster") return if current_id == "init_password": self.show_page("init_db_build") return if current_id == "init_db_build": self.show_page("database_options") return if current_id == "init_scripts": self.show_page("init_password") return if current_id == "kerberos_config": self.show_page("init_scripts") return if current_id == "gitops_config": self.show_page("kerberos_config") return if current_id == "supabase_config": self.show_page("gitops_config") return if current_id == "ollama_config": self.show_page("supabase_config") return if current_id == "init_cnpg_deploy": self.show_page("ollama_config") return if current_id == "create_installer": self.show_page("init_cnpg_deploy") return # Default prev if self.page_index > 0: self.show_page(self.page_index - 1) return def on_next(self): # Special handling for dynamic labels current_id = self.pages[self.page_index][0] print( f"[DEBUG] on_next: current_id='{current_id}', page_index={self.page_index}" ) if current_id == "welcome": self.show_page("deps_summary") return if current_id == "deps_summary": # Determine where to go from summary # Always go to the next dependency or Network Scan if self.all_dependencies_installed(): print("[DEBUG] on_next: all deps installed, going to network_scan") self.show_page("network_scan") return # Go to first missing dependency page seq = self._dep_navigation_sequence() print(f"[DEBUG] on_next: missing deps sequence: {seq}") if seq: self.show_page(seq[0]) else: print( "[DEBUG] on_next: all deps seem OK in sequence, going to network_scan" ) self.show_page("network_scan") return if current_id.startswith("dep_"): # Navigate within dependency sequence seq = self._dep_navigation_sequence() print(f"[DEBUG] on_next: dep sequence: {seq}") try: i = seq.index(current_id) except ValueError: i = -1 if i >= 0 and i < len(seq) - 1: self.show_page(seq[i + 1]) return else: # After last relevant dep page, go back to summary if anything is still missing if not self.all_dependencies_installed(): print( "[DEBUG] on_next: some deps still missing, returning to deps_summary" ) self.show_page("deps_summary") else: print( "[DEBUG] on_next: all deps now installed, going to network_scan" ) self.show_page("network_scan") return if current_id == "network_scan": # Capture network scan info try: self.prole_cfg_data["Network"][ "KDC_AUTO_DETECTED" ] = self.kerberos_kdc.get() self.prole_cfg_data["Network"]["KERBEROS_AUTO_ENABLED"] = str( self.kerberos_enabled.get() ) self._save_prole_cfg() except Exception: pass self.show_page("env_setup") return if current_id == "env_setup": # Collect values, validate, write env.sh, then go to Database Creation vals = {} try: for k in ( "PROLE_HOME", "PROLE_CONF", "PROLE_DATA", "PROLE_LOGS", "PROLE_SERVICE", ): vals[k] = self._env_entries[k].get().strip() self.prole_cfg_data["System Environment"][k] = vals[k] vals["NAMESPACE"] = (self.db_namespace.get() or "").strip() except Exception: vals = self._env_defaults() # Basic validation: require non-empty PROLE_HOME if not vals.get("PROLE_HOME"): try: messagebox.showerror("Environment", "Please specify PROLE_HOME") except Exception: pass return try: self._save_env_to_file(vals) # Reload our environment so subsequent steps (Build) inherit it self._after_env_saved() except Exception as e: try: messagebox.showwarning("Environment", f"Could not save env.sh: {e}") except Exception: pass return self.show_page("init_cluster") return if current_id == "init_password": p1 = self.db_password.get() p2 = self.db_password_confirm.get() if not p1: try: messagebox.showerror("Password", "Password cannot be empty.") except Exception: pass return if p1 != p2: try: messagebox.showerror("Password", "Passwords do not match.") except Exception: pass return if not self._validate_port_forward_overlaps(): return self._run_preparation_overlay() return if current_id == "init_db_build": self.show_page("init_password") return if current_id == "build": if getattr(self, "_built_success", False): self.show_page("create_installer") else: self.perform_build() return if current_id == "init_cluster": if not self._validate_and_save_cluster_config(): return if not self._cluster_ready_for_navigation(): return if getattr(self, "_all_services_green", False): # All common services healthy; skip repair/redeploy and proceed self._common_services_success = True self.show_page("database_options") return if not getattr(self, "_common_services_success", False): self._run_service_layer_overlay(next_page="database_options") return self.show_page("database_options") return if current_id == "database_options": if self._generate_prole_db_dockerfile(): self.show_page("init_db_build") return if current_id == "init_scripts": self.prole_cfg_data["Initialization Scripts"]["STATUS"] = ( "Completed" if getattr(self, "_scripts_success", False) else "Attempted" ) self._save_prole_cfg() if self.kerberos_enabled.get(): self.show_page("kerberos_config") else: self.show_page("gitops_config") return if current_id == "kerberos_config": # Capture kerberos config self.prole_cfg_data["Kerberos Authentication"]["ENABLED"] = str( self.kerberos_enabled.get() ) self.prole_cfg_data["Kerberos Authentication"][ "REALM" ] = self.kerberos_realm.get() self.prole_cfg_data["Kerberos Authentication"][ "KDC" ] = self.kerberos_kdc.get() self.prole_cfg_data["Kerberos Authentication"][ "SERVER" ] = self.kerberos_kdc.get() self.prole_cfg_data["Kerberos Authentication"][ "USER" ] = self.kerberos_user.get() self.prole_cfg_data["Kerberos Authentication"][ "PASSWORD" ] = self.kerberos_password.get() self.prole_cfg_data["Kerberos Authentication"]["AD_PORT_FORWARD"] = ( os.environ.get("KRB5_AD_PORT_FORWARD", "1") ) self.prole_cfg_data["Kerberos Authentication"]["AD_TCP_PORTS"] = ( os.environ.get("KRB5_AD_TCP_PORTS", "88 389 445 464 636") ) self.prole_cfg_data["Kerberos Authentication"]["AD_UDP_PORTS"] = ( os.environ.get("KRB5_AD_UDP_PORTS", "88 464") ) self.prole_cfg_data["Kerberos Authentication"]["AD_PROXY_HOST_NETWORK"] = ( os.environ.get("KRB5_AD_PROXY_HOST_NETWORK", "1") ) self.prole_cfg_data["Kerberos Authentication"]["AD_PROXY_IMAGE"] = ( os.environ.get("KRB5_AD_PROXY_IMAGE", "alpine/socat") ) self.prole_cfg_data["Kerberos Authentication"]["AD_PROXY_SERVICE"] = ( os.environ.get("KRB5_AD_SERVICE_NAME", "prole-kerberos-ad-dc") ) self._save_prole_cfg() self.show_page("gitops_config") return if current_id == "gitops_config": self.prole_cfg_data["Optional Features"]["GITOPS_ENABLED"] = str( self.gitops_enabled.get() ) gitops_status = ( "Skipped" if not self.gitops_enabled.get() else ( "Deployed" if getattr(self, "_gitops_success", False) else "Attempted" ) ) self.prole_cfg_data["GitOps"] = { "STATUS": gitops_status, "GITOPS_NAMESPACE": (self.gitops_namespace.get() or "gitea").strip() or "gitea", } self._save_prole_cfg() self.show_page("supabase_config") return if current_id == "supabase_config": self.prole_cfg_data["Optional Features"]["SUPABASE_ENABLED"] = str( self.supabase_enabled.get() ) supabase_status = ( "Skipped" if not self.supabase_enabled.get() else ( "Deployed" if getattr(self, "_supabase_success", False) else "Attempted" ) ) self.prole_cfg_data["Supabase"] = { "STATUS": supabase_status, } self._save_prole_cfg() self.show_page("ollama_config") return if current_id == "ollama_config": self._save_ollama_config() self.show_page("init_cnpg_deploy") return if current_id == "init_cnpg_deploy": self.prole_cfg_data["Deployment"]["STATUS"] = ( "Deployed" if getattr(self, "_cnpg_success", False) else "Attempted" ) self._save_prole_cfg() self.show_page("create_installer") return if current_id == "create_installer": print("[DEBUG] on_next: at create_installer, Finish clicked. Closing.") if "Install" in self.prole_cfg_data: self.prole_cfg_data["Install"]["STATUS"] = "Finished" self._save_prole_cfg() self.root.destroy() return # Default next if self.page_index < len(self.pages) - 1: print(f"[DEBUG] on_next: default next to index {self.page_index + 1}") self.show_page(self.page_index + 1) else: print("[DEBUG] on_next: already at last page") return def update_footer(self): # Default states for tk.Button self.prev_button.configure(state="normal") self.next_button.configure(state="normal") first = self.page_index == 0 # Base label self.next_button.configure(text="Next") if getattr(self, "_showing_service_overlay", False): if not getattr(self, "_common_services_success", False): self.next_button.configure(state="disabled") else: self.next_button.configure(state="normal") # Page-specific adjustments pid = self.pages[self.page_index][0] if pid == "build": # Build page: show Build or Next depending on state if getattr(self, "_built_success", False): self.next_button.configure(text="Next") else: self.next_button.configure(text="Build") elif pid == "supabase_config": # Supabase screen: allow Next when disabled or after success if self.supabase_enabled.get(): if getattr(self, "_supabase_success", False): self.next_button.configure(state="normal") else: self.next_button.configure(state="disabled") else: self.next_button.configure(state="normal") elif pid == "gitops_config": if self.gitops_enabled.get(): if getattr(self, "_gitops_success", False): self.next_button.configure(state="normal") else: self.next_button.configure(state="disabled") else: self.next_button.configure(state="normal") elif pid == "init_scripts": # Initialization Scripts: Next is disabled until success if getattr(self, "_scripts_success", False): self.next_button.configure(state="normal") else: self.next_button.configure(state="disabled") elif pid == "kerberos_config": if self.kerberos_enabled.get(): if getattr(self, "_kerberos_success", False): self.next_button.configure(state="normal") else: self.next_button.configure(state="disabled") else: self.next_button.configure(state="normal") # Visibility rules self.prev_button.pack_forget() self.next_button.pack_forget() if self.deploy_button: self.deploy_button.pack_forget() if self.launch_button: self.launch_button.pack_forget() if self.gear_button: self.gear_button.pack_forget() if pid == "init_password" and self.gear_button: self.gear_button.pack(side="left", padx=(20, 0), pady=12) if pid == "create_installer": self.next_button.configure(text="Finish") self.next_button.pack(side="right", padx=(0, 20), pady=12) return if first: self.next_button.pack(side="right", padx=(0, 20), pady=12) else: # [Prev] [Next] clustered right self.next_button.pack(side="right", padx=(0, 20), pady=12) self.prev_button.pack(side="right", padx=(0, 8), pady=12) def _splash_should_hide_nav(self) -> bool: """Deprecated: Welcome page no longer performs dependency checks or gates navigation.""" return False