import configparser import os import shutil import sys import threading import time import webbrowser import re from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Tuple import tkinter as tk from tkinter import messagebox, ttk, filedialog from steam_required_ids import extract_required_item_ids, resolve_workshop_names, expand_required_ids_recursive from sealoader_version import __version__ # Minimal dark theme colors BG = "#16181c" PANEL_BG = "#1f232a" FG = "#e6e6e6" MUTED = "#a0a6ad" ACCENT = "#4f8cff" DEFAULT_INI_PATH = str(Path.home() / "AppData" / "LocalLow" / "Triassic Games" / "Sea Power" / "usersettings.ini") @dataclass class InstalledMod: mod_id: str enabled: bool def read_installed_mods(ini_path: str) -> Dict[str, InstalledMod]: parser = configparser.ConfigParser() parser.optionxform = str if not os.path.exists(ini_path): raise FileNotFoundError(f"usersettings.ini not found at: {ini_path}") parser.read(ini_path, encoding="utf-8") if "[LoadOrder]" in "\n".join(open(ini_path, encoding="utf-8").readlines()): section = "LoadOrder" else: section = "LoadOrder" if section not in parser: return {} mods: Dict[str, InstalledMod] = {} for key, value in parser[section].items(): if not key.lower().startswith("mod"): continue # Format: ModXDirectory=, parts = value.split(",") if not parts: continue left_token = parts[0].strip() # Prefer numeric Workshop ID if present at start, else keep full token num_prefix = "" for ch in left_token: if ch.isdigit(): num_prefix += ch else: break mod_id = num_prefix if num_prefix else left_token enabled = False if len(parts) > 1: enabled = parts[1].strip().lower() == "true" mods[mod_id] = InstalledMod(mod_id=mod_id, enabled=enabled) return mods def _get_enabled_mod_ids_from_ini(ini_path: str) -> set[str]: try: mods = read_installed_mods(ini_path) except FileNotFoundError: return set() return {mid for mid, m in mods.items() if m.enabled} def _get_all_mod_ids_from_ini(ini_path: str) -> set[str]: try: mods = read_installed_mods(ini_path) except FileNotFoundError: return set() return set(mods.keys()) def _find_steamapps_dir_from_game_path(game_path: str) -> Path | None: p = Path(game_path).resolve() for parent in [p] + list(p.parents): if parent.name.lower() == "common" and parent.parent.name.lower() == "steamapps": return parent.parent if parent.name.lower() == "steamapps": return parent return None def _detect_app_id(steamapps_dir: Path, installdir_name: str) -> str | None: target = installdir_name.lower() for acf in steamapps_dir.glob("appmanifest_*.acf"): try: text = acf.read_text(encoding="utf-8", errors="ignore") except Exception: continue if '"installdir"' in text: for line in text.splitlines(): if '"installdir"' in line: parts = [seg for seg in line.split('"') if seg.strip()] if parts: val = parts[-1].strip().lower() if val == target: return acf.stem.replace("appmanifest_", "") return None def read_installed_mods_from_workshop(game_path: str, ini_path: str) -> Dict[str, InstalledMod]: if not game_path: return {} steamapps = _find_steamapps_dir_from_game_path(game_path) if not steamapps or not steamapps.exists(): return {} installdir = Path(game_path).name app_id = _detect_app_id(steamapps, installdir) if not app_id: return {} workshop_dir = steamapps / "workshop" / "content" / app_id if not workshop_dir.exists(): return {} enabled_ids = _get_enabled_mod_ids_from_ini(ini_path) mods: Dict[str, InstalledMod] = {} try: for child in workshop_dir.iterdir(): if child.is_dir() and child.name.isdigit(): mid = child.name mods[mid] = InstalledMod(mod_id=mid, enabled=(mid in enabled_ids)) except Exception: pass return mods def enable_mods_in_ini(ini_path: str, ids_to_enable: List[str]) -> Tuple[int, int]: parser = configparser.ConfigParser() parser.optionxform = str parser.read(ini_path, encoding="utf-8") section = "LoadOrder" if section not in parser: raise ValueError("[LoadOrder] section not found in usersettings.ini") # Backup before changes backup_path = ini_path + ".bak" shutil.copyfile(ini_path, backup_path) enabled_count = 0 missing_count = 0 # Determine next index for new Mod entries next_idx = 1 for key, _ in parser[section].items(): m = re.match(r"Mod(\d+)Directory", key, re.IGNORECASE) if m: try: next_idx = max(next_idx, int(m.group(1)) + 1) except Exception: pass # Maintain order and preserve original left token (may include name after ID) items = list(parser[section].items()) # Maintain order for target_id in ids_to_enable: found_key = None for key, value in items: if not key.lower().startswith("mod"): continue parts = value.split(",") if not parts: continue left_token = parts[0].strip() # Extract numeric prefix for matching num_prefix = "" for ch in left_token: if ch.isdigit(): num_prefix += ch else: break mod_id = num_prefix if num_prefix else left_token if mod_id == target_id: found_key = key current_enabled = False if len(parts) > 1: current_enabled = parts[1].strip().lower() == "true" if not current_enabled: # Preserve original left token, only toggle enabled flag new_value = f"{left_token},True" parser[section][key] = new_value enabled_count += 1 break if not found_key: # Append new entry at bottom for this workshop ID, disabled status will be set to True new_key = f"Mod{next_idx}Directory" parser[section][new_key] = f"{target_id},True" next_idx += 1 enabled_count += 1 with open(ini_path, "w", encoding="utf-8") as f: parser.write(f) return enabled_count, missing_count class SeaLoaderApp(tk.Tk): def __init__(self, ini_path: str): super().__init__() self.title("SeaLoader") self.configure(bg=BG) self.geometry("960x560") self.minsize(800, 480) self.ini_path = ini_path self.installed_mods: Dict[str, InstalledMod] = {} self.required_ids: List[str] = [] self.installed_names_map: Dict[str, str] = {} self._icon_img = None self._did_flash_fetch = False self._did_flash_enable = False self._flash_states = {} self._style() self._layout() self._load_installed_mods() # App icon from PNG (optional) try: icon_path = self._resource_path("SeaLoader.png") if icon_path.exists(): self._icon_img = tk.PhotoImage(file=str(icon_path)) self.iconphoto(True, self._icon_img) except Exception: pass # Help shortcut try: self.bind("", lambda e: self._on_help()) except Exception: pass # Guide: when URL is filled, flash Fetch try: self.url_var.trace_add('write', self._on_url_changed) except Exception: pass # Load saved game path (if any) and auto-load mods try: saved = self._load_saved_game_path() if saved: self.game_path_var.set(saved) self._load_installed_mods() if not self.url_var.get().strip(): self._start_flash(self.url_entry, entry=True) except Exception: pass # Check for updates asynchronously threading.Thread(target=self._check_for_updates_async, daemon=True).start() def _style(self) -> None: style = ttk.Style(self) try: style.theme_use("clam") except Exception: pass style.configure("TFrame", background=BG) style.configure("Panel.TFrame", background=PANEL_BG) style.configure("TLabel", background=PANEL_BG, foreground=FG) style.configure("Header.TLabel", background=BG, foreground=FG, font=("Segoe UI", 12, "bold")) style.configure("TButton", background=PANEL_BG, foreground=FG, borderwidth=0) style.map("TButton", background=[("active", ACCENT)]) style.configure("TEntry", fieldbackground=PANEL_BG, foreground=FG, insertcolor=FG) style.configure("TScrollbar", background=PANEL_BG, troughcolor=BG) style.configure("Treeview", background=PANEL_BG, fieldbackground=PANEL_BG, foreground=FG, borderwidth=0) style.configure("Treeview.Heading", background=PANEL_BG, foreground=MUTED) # Flash styles style.configure("Flash.TEntry", fieldbackground="#2b3a5a", foreground=FG, insertcolor=FG) style.configure("Flash.TButton", background=ACCENT, foreground="#0d1117") def _layout(self) -> None: top = ttk.Frame(self, style="TFrame") top.pack(fill=tk.X, padx=12, pady=(12, 6)) url_label = ttk.Label(top, text="Workshop URL:", style="TLabel") url_label.pack(side=tk.LEFT) self.url_var = tk.StringVar() self.url_entry = ttk.Entry(top, textvariable=self.url_var, width=70) self.url_entry.pack(side=tk.LEFT, padx=8, fill=tk.X, expand=True) self.fetch_btn = ttk.Button(top, text="Fetch Required Mods", command=self._on_fetch) self.fetch_btn.pack(side=tk.LEFT) # Game path row path_row = ttk.Frame(self, style="TFrame") path_row.pack(fill=tk.X, padx=12, pady=(0, 6)) ttk.Label(path_row, text="Game Path:", style="TLabel").pack(side=tk.LEFT) self.game_path_var = tk.StringVar() self.game_path_entry = ttk.Entry(path_row, textvariable=self.game_path_var, width=70) self.game_path_entry.pack(side=tk.LEFT, padx=8, fill=tk.X, expand=True) ttk.Button(path_row, text="Browse", command=self._browse_game_path).pack(side=tk.LEFT) main = ttk.Frame(self, style="TFrame") main.pack(fill=tk.BOTH, expand=True, padx=12, pady=6) left_panel = ttk.Frame(main, style="Panel.TFrame") left_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) right_panel = ttk.Frame(main, style="Panel.TFrame") right_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(8, 0)) left_header = ttk.Label(left_panel, text="Installed Mods", style="Header.TLabel") left_header.pack(anchor="w", padx=8, pady=8) self.installed_tree = self._create_tree(left_panel, ["Mod ID", "Name", "Status"], [0.30, 0.50, 0.20]) self.installed_tree.pack(fill=tk.BOTH, expand=True, padx=8, pady=(0, 8)) right_header = ttk.Label(right_panel, text="Scenario Required Mods", style="Header.TLabel") right_header.pack(anchor="w", padx=8, pady=8) self.required_tree = self._create_tree(right_panel, ["Mod ID", "Name", "Status"], [0.25, 0.60, 0.15]) self.required_tree.pack(fill=tk.BOTH, expand=True, padx=8, pady=(0, 8)) bottom = ttk.Frame(self, style="TFrame") bottom.pack(fill=tk.X, padx=12, pady=(0, 12)) self.enable_btn = ttk.Button(bottom, text="Enable Matching Mods", command=self._on_enable) self.enable_btn.pack(side=tk.LEFT) self.disable_all_btn = ttk.Button(bottom, text="Disable All Mods", command=self._on_disable_all) self.disable_all_btn.pack(side=tk.LEFT, padx=(8, 0)) self.refresh_btn = ttk.Button(bottom, text="Refresh", command=self._on_refresh) self.refresh_btn.pack(side=tk.LEFT, padx=(8, 0)) self.help_btn = ttk.Button(bottom, text="Help", command=self._on_help) self.subscribe_btn = ttk.Button(bottom, text="Subscribe Missing Mods", command=self._on_subscribe_missing) self.subscribe_btn.pack(side=tk.RIGHT, padx=(0, 8)) self.help_btn.pack(side=tk.RIGHT) # Footer attribution footer = ttk.Frame(self, style="TFrame") footer.pack(fill=tk.X, padx=12, pady=(0, 10)) self._footer_icon = self._load_png_icon("hrsys.png", max_px=18) if self._footer_icon is not None: icon_lbl = ttk.Label(footer, image=self._footer_icon, style="TLabel") icon_lbl.pack(side=tk.LEFT) icon_lbl.bind("", lambda e: self._open_link("https://hudsonriggs.systems")) link_lbl = ttk.Label(footer, text="Created by HudsonRiggs.Systems", style="TLabel", foreground=MUTED) link_lbl.pack(side=tk.LEFT, padx=(6, 0)) link_lbl.bind("", lambda e: self._open_link("https://hudsonriggs.systems")) # Version labels at bottom-right self.version_lbl = ttk.Label(footer, text=f"v{__version__}", style="TLabel", foreground=MUTED) self.version_lbl.pack(side=tk.RIGHT) self.latest_var = tk.StringVar(value="latest: checking…") self.latest_lbl = ttk.Label(footer, textvariable=self.latest_var, style="TLabel", foreground=MUTED) self.latest_lbl.pack(side=tk.RIGHT, padx=(0, 8)) def _create_tree(self, parent, columns: List[str], ratios: List[float]): tree = ttk.Treeview(parent, columns=columns, show="headings", height=12) for col in columns: tree.heading(col, text=col) tree.column(col, width=50, anchor=tk.W, stretch=True) vsb = ttk.Scrollbar(parent, orient="vertical", command=tree.yview) tree.configure(yscrollcommand=vsb.set) tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) vsb.pack(side=tk.LEFT, fill=tk.Y) self._bind_auto_columns(parent, tree, ratios) return tree def _bind_auto_columns(self, container, tree: ttk.Treeview, ratios: List[float]) -> None: ratios = [r / sum(ratios) for r in ratios] def on_resize(event): # Reserve some space for vertical scrollbar and paddings total = max(event.width - 18, 100) for col, ratio in zip(tree["columns"], ratios): tree.column(col, width=int(total * ratio)) container.bind("", on_resize, add="+") def _load_installed_mods(self) -> None: try: game_path = self.game_path_var.get().strip() if hasattr(self, "game_path_var") else "" if game_path: self.installed_mods = read_installed_mods_from_workshop(game_path, self.ini_path) else: self.installed_mods = {} except FileNotFoundError as e: messagebox.showerror("SeaLoader", str(e)) self.installed_mods = {} self._refresh_installed_list() self._resolve_installed_names_async() def _browse_game_path(self) -> None: path = filedialog.askdirectory(title="Select Sea Power install folder (steamapps/common/Sea Power)") if path: self.game_path_var.set(path) # Auto-load mods when path selected self._load_installed_mods() # Flash the URL box to guide next step self._start_flash(self.url_entry, entry=True) # Persist selection try: self._save_game_path(path) except Exception: pass def _refresh_installed_list(self) -> None: for row in self.installed_tree.get_children(): self.installed_tree.delete(row) ini_ids = _get_all_mod_ids_from_ini(self.ini_path) for mod in self.installed_mods.values(): if mod.enabled: status = "Enabled" else: status = "Disabled" if mod.mod_id in ini_ids else "Disabled (Not in INI)" display_name = self.installed_names_map.get(mod.mod_id, "") self.installed_tree.insert("", tk.END, values=(mod.mod_id, display_name, status)) def _resolve_installed_names_async(self) -> None: ids = [mid for mid in self.installed_mods.keys() if mid.isdigit()] ids_to_lookup = [mid for mid in ids if mid not in self.installed_names_map] if not ids_to_lookup: return def task(): try: names = resolve_workshop_names(ids_to_lookup) except Exception: names = {} def update(): if names: self.installed_names_map.update(names) self._refresh_installed_list() self.after(0, update) threading.Thread(target=task, daemon=True).start() def _on_fetch(self) -> None: url = self.url_var.get().strip() if not url: messagebox.showwarning("SeaLoader", "Please paste a Workshop URL.") return self.fetch_btn.configure(state=tk.DISABLED) self.enable_btn.configure(state=tk.DISABLED) def task(): try: base_ids = extract_required_item_ids(url) ids = expand_required_ids_recursive(base_ids) # Reuse installed cache to reduce API calls names_map = {i: self.installed_names_map[i] for i in ids if i in self.installed_names_map} ids_to_lookup = [i for i in ids if i not in names_map] if ids_to_lookup: fetched = resolve_workshop_names(ids_to_lookup) names_map.update(fetched) except Exception as exc: self.after(0, lambda: messagebox.showerror("SeaLoader", f"Failed to fetch IDs: {exc}")) self.after(0, lambda: self.fetch_btn.configure(state=tk.NORMAL)) self.after(0, lambda: self.enable_btn.configure(state=tk.NORMAL)) return def update(): self.required_ids = ids self._refresh_required_list(names_map) self.fetch_btn.configure(state=tk.NORMAL) self.enable_btn.configure(state=tk.NORMAL) # Stop flashing Fetch now that step is complete self._stop_flash(self.fetch_btn) # Decide next guided step based on missing self._guide_after_fetch() self.after(0, update) threading.Thread(target=task, daemon=True).start() def _refresh_required_list(self, names_map: Dict[str, str] | None = None) -> None: for row in self.required_tree.get_children(): self.required_tree.delete(row) installed_ids = set(self.installed_mods.keys()) names_map = names_map or {} for mod_id in self.required_ids: status = "Installed" if mod_id in installed_ids else "Missing" display_name = names_map.get(mod_id, "") self.required_tree.insert("", tk.END, values=(mod_id, display_name, status)) def _on_enable(self) -> None: if not self.required_ids: messagebox.showinfo("SeaLoader", "No required mods loaded. Fetch from URL first.") return try: enabled_count, missing_count = enable_mods_in_ini(self.ini_path, self.required_ids) except Exception as exc: messagebox.showerror("SeaLoader", f"Failed to update INI: {exc}") return # Reload to reflect changes self._load_installed_mods() self._refresh_required_list() msg_lines = [f"Enabled {enabled_count} mod(s)."] if missing_count: msg_lines.append(f"{missing_count} required mod(s) not found in usersettings.ini.") else: msg_lines.append("All required mods were found.") messagebox.showinfo("SeaLoader", "\n".join(msg_lines)) # Stop flashing Enable step after completion self._stop_flash(self.enable_btn) def _on_refresh(self) -> None: # Reload installed mods from current game path self._load_installed_mods() # If a URL is provided, refetch required mods as well if self.url_var.get().strip(): # Stop flashing refresh as this step has been taken self._stop_flash(self.refresh_btn) self._on_fetch() def _on_help(self) -> None: ini_hint = ( f"Default INI: {self.ini_path}\n\n" ) text = ( "Usage:\n\n" "1) Paste a Steam Workshop URL at the top and click 'Fetch Required Mods'.\n" " - Right list shows required Mod IDs, names and whether they are installed.\n" "2) Installed mods load automatically from usersettings.ini (left list).\n" "3) Click 'Subscribe Missing Mods' to subscribe to any missing Workshop items in Steam.\n" " - Steam desktop must be running and signed in; downloads happen in the background.\n" "4) Click 'Enable Matching Mods' to turn on any installed required mods.\n" " - A .bak backup of usersettings.ini is created before changes.\n\n" "Tips:\n" "- Run with a custom INI path: python sealoader_gui.py \"\"\n" "- Press F1 to open this help.\n\n" + ini_hint ) messagebox.showinfo("SeaLoader Help", text) def _resource_path(self, name: str) -> Path: try: base = getattr(sys, "_MEIPASS", None) if base: return Path(base) / name except Exception: pass return Path(__file__).resolve().parent / name def _open_link(self, url: str) -> None: try: webbrowser.open(url) except Exception: pass def _load_png_icon(self, filename: str, max_px: int = 18): try: png_path = self._resource_path(filename) if not png_path.exists(): return None img = tk.PhotoImage(file=str(png_path)) # Downscale if needed using integer subsample sx = max(img.width() // max_px, 1) sy = max(img.height() // max_px, 1) scale = max(sx, sy) if scale > 1: img = img.subsample(scale, scale) return img except Exception: return None def _on_disable_all(self) -> None: confirm = messagebox.askyesno("SeaLoader", "Disable all mods in usersettings.ini?") if not confirm: return try: self._disable_all_mods() except Exception as exc: messagebox.showerror("SeaLoader", f"Failed to disable all mods: {exc}") return self._load_installed_mods() messagebox.showinfo("SeaLoader", "All mods disabled.") def _disable_all_mods(self) -> None: parser = configparser.ConfigParser() parser.optionxform = str parser.read(self.ini_path, encoding="utf-8") section = "LoadOrder" if section not in parser: raise ValueError("[LoadOrder] section not found in usersettings.ini") shutil.copyfile(self.ini_path, self.ini_path + ".bak") for key, value in list(parser[section].items()): if not key.lower().startswith("mod"): continue parts = value.split(",") if not parts: continue left_token = parts[0].strip() parser[section][key] = f"{left_token},False" with open(self.ini_path, "w", encoding="utf-8") as f: parser.write(f) def _on_subscribe_missing(self) -> None: if not self.required_ids: messagebox.showinfo("SeaLoader", "Fetch a Workshop URL first to determine missing mods.") return installed_ids = set(self.installed_mods.keys()) missing = [i for i in self.required_ids if i not in installed_ids] if not missing: messagebox.showinfo("SeaLoader", "No missing mods to subscribe.") return self.subscribe_btn.configure(state=tk.DISABLED) # Process one-by-one with remaining counter in the dialog self._subscribe_queue = list(missing) self._subscribe_next() def _subscribe_next(self) -> None: queue: List[str] = getattr(self, "_subscribe_queue", []) if not queue: self.subscribe_btn.configure(state=tk.NORMAL) messagebox.showinfo("SeaLoader", "Finished opening subscriptions in Steam.") # Chain: after subscribing, guide to Refresh if getattr(self, "_guide_wait_refresh_after_subscribe", False): self._stop_flash(self.subscribe_btn) self._start_flash(self.refresh_btn, entry=False) return mod_id = queue[0] remaining = len(queue) - 1 try: self._open_link(f"steam://subscribe/{mod_id}") time.sleep(0.2) self._open_link(f"steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id={mod_id}") except Exception: pass # Show per-item info and proceed when dismissed messagebox.showinfo("SeaLoader", f"Opened subscribe for {mod_id}.\n{remaining} remaining.") # Advance queue and continue queue.pop(0) self._subscribe_queue = queue self.after(0, self._subscribe_next) # Guided flashing helpers def _flash_widget(self, widget: ttk.Widget, entry: bool = False, cycles: int = 6, interval_ms: int = 300) -> None: try: normal_style = widget.cget('style') or ("TEntry" if entry else "TButton") flash_style = "Flash.TEntry" if entry else "Flash.TButton" state = {"on": True, "count": cycles * 2} def tick(): if state["count"] <= 0: widget.configure(style=normal_style) return widget.configure(style=flash_style if state["on"] else normal_style) state["on"] = not state["on"] state["count"] -= 1 self.after(interval_ms, tick) tick() except Exception: try: widget.focus_set() except Exception: pass def _on_url_changed(self, *_args): if self.url_var.get().strip(): if not self._did_flash_fetch: self._did_flash_fetch = True # Stop URL flash and start flashing Fetch until fetch completes self._stop_flash(self.url_entry) self._start_flash(self.fetch_btn, entry=False) def _maybe_flash_enable(self) -> None: if self.required_ids and not self._did_flash_enable: self._did_flash_enable = True self._start_flash(self.enable_btn, entry=False) def _guide_after_fetch(self) -> None: installed_ids = set(self.installed_mods.keys()) missing = [i for i in self.required_ids if i not in installed_ids] if missing: # Prompt user to subscribe missing first self._stop_flash(self.enable_btn) self._start_flash(self.subscribe_btn, entry=False) # After subscribe series ends, flash refresh self._guide_wait_refresh_after_subscribe = True else: # No missing; go straight to enable self._stop_flash(self.subscribe_btn) self._start_flash(self.enable_btn, entry=False) # Continuous flashing (until stopped) def _start_flash(self, widget: ttk.Widget, entry: bool = False, interval_ms: int = 300) -> None: try: normal_style = widget.cget('style') or ("TEntry" if entry else "TButton") flash_style = "Flash.TEntry" if entry else "Flash.TButton" # Cancel existing self._stop_flash(widget) state = {"on": True, "normal": normal_style, "flash": flash_style, "running": True, "job": None} self._flash_states[widget] = state def tick(): st = self._flash_states.get(widget) if not st or not st["running"]: try: widget.configure(style=normal_style) except Exception: pass return try: widget.configure(style=flash_style if st["on"] else normal_style) except Exception: pass st["on"] = not st["on"] st["job"] = self.after(interval_ms, tick) tick() except Exception: try: widget.focus_set() except Exception: pass def _stop_flash(self, widget: ttk.Widget) -> None: st = self._flash_states.pop(widget, None) if st: st["running"] = False try: widget.configure(style=st["normal"]) # restore except Exception: pass # Simple storage for game path next to INI def _storage_file_path(self) -> Path: try: ini_dir = Path(self.ini_path).resolve().parent except Exception: ini_dir = Path.cwd() return ini_dir / "sealoader_game_path.txt" def _save_game_path(self, path: str) -> None: p = self._storage_file_path() p.write_text(path.strip(), encoding="utf-8") def _load_saved_game_path(self) -> str: p = self._storage_file_path() if p.exists(): return p.read_text(encoding="utf-8").strip() return "" # Update checker def _check_for_updates_async(self) -> None: try: import requests import re # Configure your Gitea server/repo here owner_repo = os.getenv("SEALOADER_REPO", "HRiggs/SeaLoader") server = os.getenv("SEALOADER_GITEA", "https://git.hudsonriggs.systems") latest_tag = None release_url = None # Try API first try: api_url = f"{server}/api/v1/repos/{owner_repo}/releases/latest" resp = requests.get(api_url, timeout=10) if resp.status_code == 200: data = resp.json() latest_tag = (data.get("tag_name") or data.get("name") or "").strip() release_url = data.get("html_url") or f"{server}/{owner_repo}/releases" except Exception: pass # Fallback to RSS if API didn't produce a tag if not latest_tag: try: rss_url = f"{server}/{owner_repo}/releases.rss" r2 = requests.get(rss_url, timeout=10) if r2.status_code == 200 and r2.text: # Find the first /releases/tag/ m = re.search(r"/releases/tag/([\w\.-]+)", r2.text) if m: latest_tag = m.group(1) release_url = f"{server}/{owner_repo}/releases/tag/{latest_tag}" except Exception: pass if latest_tag: def show_latest(): self.latest_var.set(f"latest: {latest_tag}") if latest_tag != __version__: # Open a modal dialog prompting to download or ignore self._show_update_dialog(latest_tag, release_url or f"{server}/{owner_repo}/releases") self.after(0, show_latest) else: self.after(0, lambda: self.latest_var.set("latest: unavailable")) except Exception: pass def _show_update_dialog(self, latest_tag: str, url: str) -> None: try: dlg = tk.Toplevel(self) dlg.title("Update Available") dlg.configure(bg=BG) dlg.resizable(False, False) dlg.transient(self) dlg.grab_set() frame = ttk.Frame(dlg, style="Panel.TFrame") frame.pack(fill=tk.BOTH, expand=True, padx=12, pady=12) ttk.Label(frame, text=f"A new version is available:", style="TLabel").pack(anchor="w") ttk.Label(frame, text=f"Current: {__version__}", style="TLabel").pack(anchor="w", pady=(4,0)) ttk.Label(frame, text=f"Latest: {latest_tag}", style="TLabel").pack(anchor="w") link = ttk.Label(frame, text=url, style="TLabel", foreground=ACCENT) link.pack(anchor="w", pady=(8, 12)) link.bind("", lambda e: self._open_link(url)) btns = ttk.Frame(frame, style="TFrame") btns.pack(fill=tk.X) ttk.Button(btns, text="Ignore", command=dlg.destroy).pack(side=tk.RIGHT) # Auto-Install button (only meaningful for frozen exe) auto_btn = ttk.Button(btns, text="Auto-Install", command=lambda: (dlg.destroy(), self._auto_install_update(latest_tag, url))) auto_btn.pack(side=tk.RIGHT, padx=(0,8)) if not getattr(sys, 'frozen', False): try: auto_btn.state(['disabled']) except Exception: pass ttk.Button(btns, text="Download", command=lambda: (self._open_link(url), dlg.destroy())).pack(side=tk.RIGHT, padx=(0,8)) # Center over parent try: self.update_idletasks() px = self.winfo_rootx() + (self.winfo_width() - dlg.winfo_reqwidth()) // 2 py = self.winfo_rooty() + (self.winfo_height() - dlg.winfo_reqheight()) // 2 dlg.geometry(f"+{px}+{py}") except Exception: pass except Exception: # Fallback to simple prompt if messagebox.askyesno("Update Available", f"Latest: {latest_tag}\nOpen download page?", parent=self): self._open_link(url) def _auto_install_update(self, latest_tag: str, release_page_url: str) -> None: try: import requests, zipfile, io, tempfile, subprocess owner_repo = os.getenv("SEALOADER_REPO", "HRiggs/SeaLoader") server = os.getenv("SEALOADER_GITEA", "https://git.hudsonriggs.systems") # Try to find asset URL via API for the given tag asset_url = None try: api = f"{server}/api/v1/repos/{owner_repo}/releases/tags/{latest_tag}" r = requests.get(api, timeout=15) if r.status_code == 200: data = r.json() attachments = data.get('assets') or data.get('attachments') or [] for a in attachments: name = a.get('name') or '' url = a.get('browser_download_url') or a.get('download_url') or a.get('url') if name == 'SeaLoader_Windows_x64.zip': asset_url = url if (url and url.startswith('http')) else (f"{server}{url}" if url else None) break except Exception: pass if not asset_url: # Fallback to conventional download URL asset_url = f"{server}/{owner_repo}/releases/download/{latest_tag}/SeaLoader_Windows_x64.zip" # Download zip resp = requests.get(asset_url, timeout=60, stream=True) resp.raise_for_status() buf = io.BytesIO(resp.content) # Extract SeaLoader.exe from zip to temp tmpdir = tempfile.mkdtemp(prefix='sealoader_update_') with zipfile.ZipFile(buf) as zf: target_member = None for m in zf.infolist(): if m.filename.endswith('SeaLoader.exe') and not m.is_dir(): target_member = m break if not target_member: messagebox.showerror("SeaLoader", "Update package missing SeaLoader.exe") return new_exe_path = os.path.join(tmpdir, 'SeaLoader.exe') with zf.open(target_member) as src, open(new_exe_path, 'wb') as dst: dst.write(src.read()) # Determine current exe path if not getattr(sys, 'frozen', False): messagebox.showinfo("SeaLoader", "Auto-install is only available in the packaged EXE.") return current_exe = sys.executable # Create updater PowerShell script ps_path = os.path.join(tmpdir, 'sealoader_updater.ps1') pid_val = os.getpid() target_val = current_exe.replace("'", "''") source_val = new_exe_path.replace("'", "''") ps = """ $ErrorActionPreference = 'Stop' $pidToWait = {pid} $target = '{target}'.Replace("'","''") $source = '{source}'.Replace("'","''") while (Get-Process -Id $pidToWait -ErrorAction SilentlyContinue) {{ Start-Sleep -Milliseconds 200 }} Move-Item -Force -LiteralPath $source -Destination $target Start-Process -FilePath $target Remove-Item -LiteralPath $PSCommandPath -Force """.format(pid=pid_val, target=target_val, source=source_val) with open(ps_path, 'w', encoding='utf-8') as f: f.write(ps) # Launch updater and exit subprocess.Popen(["powershell", "-ExecutionPolicy", "Bypass", "-NoProfile", "-File", ps_path], close_fds=True) self.after(200, self.destroy) except Exception as exc: messagebox.showerror("SeaLoader", f"Auto-install failed: {exc}") def main() -> None: ini_path = DEFAULT_INI_PATH if len(sys.argv) > 1: ini_path = sys.argv[1] app = SeaLoaderApp(ini_path) app.mainloop() if __name__ == "__main__": main()