From 5e68076bbb54d186f17fd74f8f94f8023de6bef6 Mon Sep 17 00:00:00 2001 From: HRiggs Date: Tue, 16 Sep 2025 23:31:51 -0400 Subject: [PATCH] Steam Based ID Finiding, Version Control, Storage --- .gitea/workflows/release.yml | 8 + sealoader_gui.py | 282 +++++++++++++++++++++++++++++++++-- sealoader_version.py | 1 + steam_required_ids.py | 39 +++++ 4 files changed, 317 insertions(+), 13 deletions(-) create mode 100644 sealoader_version.py diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index f84ff83..e1ee600 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -29,6 +29,14 @@ jobs: shell: powershell run: | $ErrorActionPreference = 'Stop' + # Stamp version into sealoader_version.py from release tag + if ($env:GITHUB_EVENT_NAME -eq 'release') { + $tag = '${{ github.event.release.tag_name }}' + } else { + $tag = (git describe --tags --always) 2>$null + if (-not $tag) { $tag = "0.0.0-dev" } + } + ("__version__ = '" + $tag + "'") | Out-File -FilePath sealoader_version.py -Encoding UTF8 -Force # Bundle PNG resources referenced at runtime pyinstaller --noconfirm --onefile --windowed sealoader_gui.py --name SeaLoader ` --add-data "SeaLoader.png;." ` diff --git a/sealoader_gui.py b/sealoader_gui.py index 71f02c0..ec67514 100644 --- a/sealoader_gui.py +++ b/sealoader_gui.py @@ -3,15 +3,18 @@ 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 +from tkinter import messagebox, ttk, filedialog -from steam_required_ids import extract_required_item_ids, resolve_workshop_names +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 @@ -70,6 +73,74 @@ def read_installed_mods(ini_path: str) -> Dict[str, InstalledMod]: 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 @@ -86,6 +157,16 @@ def enable_mods_in_ini(ini_path: str, ids_to_enable: List[str]) -> Tuple[int, in 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 @@ -118,7 +199,11 @@ def enable_mods_in_ini(ini_path: str, ids_to_enable: List[str]) -> Tuple[int, in enabled_count += 1 break if not found_key: - missing_count += 1 + # 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) @@ -139,6 +224,8 @@ class SeaLoaderApp(tk.Tk): 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._style() self._layout() @@ -158,6 +245,23 @@ class SeaLoaderApp(tk.Tk): 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() + 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) @@ -175,6 +279,9 @@ class SeaLoaderApp(tk.Tk): 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") @@ -188,6 +295,15 @@ class SeaLoaderApp(tk.Tk): 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) @@ -198,7 +314,7 @@ class SeaLoaderApp(tk.Tk): 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", "Enabled"], [0.30, 0.55, 0.15]) + 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") @@ -213,6 +329,8 @@ class SeaLoaderApp(tk.Tk): 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.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 @@ -226,6 +344,10 @@ class SeaLoaderApp(tk.Tk): 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")) + # Update label (hidden by default) + self.update_var = tk.StringVar(value="") + self.update_lbl = ttk.Label(footer, textvariable=self.update_var, style="TLabel", foreground=ACCENT) + self.update_lbl.pack(side=tk.RIGHT) def _create_tree(self, parent, columns: List[str], ratios: List[float]): tree = ttk.Treeview(parent, columns=columns, show="headings", height=12) @@ -253,18 +375,40 @@ class SeaLoaderApp(tk.Tk): def _load_installed_mods(self) -> None: try: - self.installed_mods = read_installed_mods(self.ini_path) + 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._flash_widget(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(): - status = "Yes" if mod.enabled else "No" + 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)) @@ -300,7 +444,8 @@ class SeaLoaderApp(tk.Tk): def task(): try: - ids = extract_required_item_ids(url) + 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] @@ -318,6 +463,8 @@ class SeaLoaderApp(tk.Tk): self._refresh_required_list(names_map) self.fetch_btn.configure(state=tk.NORMAL) self.enable_btn.configure(state=tk.NORMAL) + # Flash Enable button to guide next step + self._maybe_flash_enable() self.after(0, update) @@ -361,14 +508,13 @@ class SeaLoaderApp(tk.Tk): ) text = ( "Usage:\n\n" - "1) Download all required mods from Steam Workshop.\n" - " - Using subscribe to all wil work, you must load into seapower after they have downloaded then close the game.\n" - "2) Paste a Steam Workshop URL at the top and click 'Fetch Required Mods'.\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" - "3) Installed mods load automatically from usersettings.ini (left list).\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" - "5) Mods marked Missing must be downloaded from the workshop see step 1.\n\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" @@ -438,6 +584,116 @@ class SeaLoaderApp(tk.Tk): 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.") + 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 not self._did_flash_fetch and self.url_var.get().strip(): + self._did_flash_fetch = True + self._flash_widget(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._flash_widget(self.enable_btn, entry=False) + + # 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 + # Configure your Gitea server/repo here + owner_repo = os.getenv("SEALOADER_REPO", "HudsonRiggs/SeaLoader") + server = os.getenv("SEALOADER_GITEA", "https://git.hudsonriggs.systems") + url = f"{server}/api/v1/repos/{owner_repo}/releases/latest" + resp = requests.get(url, timeout=10) + if resp.status_code != 200: + return + data = resp.json() + latest = data.get("tag_name") or data.get("name") or "" + if latest and latest != __version__: + def show(): + self.update_var.set(f"Update available: {latest} (current {__version__})") + # Flash the label + self._flash_widget(self.update_lbl, entry=False, cycles=8, interval_ms=400) + # Click opens latest release + self.update_lbl.bind("", lambda e: self._open_link(data.get("html_url") or f"{server}/{owner_repo}/releases")) + self.after(0, show) + except Exception: + pass + def main() -> None: ini_path = DEFAULT_INI_PATH diff --git a/sealoader_version.py b/sealoader_version.py new file mode 100644 index 0000000..e728ace --- /dev/null +++ b/sealoader_version.py @@ -0,0 +1 @@ +__version__ = "0.0.0-dev" diff --git a/steam_required_ids.py b/steam_required_ids.py index 5151c79..5523654 100644 --- a/steam_required_ids.py +++ b/steam_required_ids.py @@ -136,6 +136,45 @@ def extract_required_item_ids(url: str) -> List[str]: return sorted(found_ids, key=int) +def extract_required_item_ids_for_id(pub_id: str) -> List[str]: + """Fetch required items for a specific Workshop item ID, including itself.""" + page_url = f"https://steamcommunity.com/sharedfiles/filedetails/?id={pub_id}" + html = fetch_page(page_url) + found_ids = extract_required_item_ids_from_html(html) + if pub_id: + found_ids.add(pub_id) + return sorted(found_ids, key=int) + + +def expand_required_ids_recursive(initial_ids: List[str], max_pages: int = 200) -> List[str]: + """Expand a set of Workshop IDs by following 'Required items' recursively. + + - Starts from initial_ids + - For each id, fetches its page and collects its required items + - Continues breadth-first until no new IDs are found or max_pages is reached + """ + queue: List[str] = [i for i in initial_ids if i and i.isdigit()] + visited: Set[str] = set() + all_ids: Set[str] = set(queue) + + while queue and len(visited) < max_pages: + current = queue.pop(0) + if current in visited: + continue + visited.add(current) + try: + deps = extract_required_item_ids_for_id(current) + except Exception: + deps = [current] + for dep in deps: + if dep not in all_ids: + all_ids.add(dep) + if dep not in visited: + queue.append(dep) + + return sorted(all_ids, key=int) + + def resolve_workshop_names(ids: List[str], timeout: int = 20) -> Dict[str, str]: """Resolve Workshop IDs to human-readable titles using Steam API, with HTML fallback.