All checks were successful
Build and Upload Release (Windows EXE) / Build Windows EXE (release) Successful in 2m29s
940 lines
38 KiB
Python
940 lines
38 KiB
Python
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=<id or name>,<True|False>
|
|
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("<F1>", 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("<Button-1>", 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("<Button-1>", 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("<Configure>", 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 \"<path-to-usersettings.ini>\"\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/<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("<Button-1>", 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()
|
|
|
|
|