diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..f131a82 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,66 @@ +name: Build and Upload Release (Windows EXE) + +on: + release: + types: [published] + workflow_dispatch: {} + +jobs: + build-windows-exe: + name: Build Windows EXE + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + shell: pwsh + run: | + python -m pip install --upgrade pip + if (Test-Path requirements.txt) { pip install -r requirements.txt } + pip install pyinstaller + + - name: Build EXE with PyInstaller + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + # Include SeaLoader.png so the packaged app icon in-app works + $addData = "SeaLoader.png;." # Windows uses ';' for --add-data + pyinstaller --noconfirm --onefile --windowed sealoader_gui.py --name SeaLoader --add-data "$addData" + + - name: Prepare artifact + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path dist_upload | Out-Null + Copy-Item dist\SeaLoader.exe dist_upload\SeaLoader.exe + if (Test-Path README.md) { Copy-Item README.md dist_upload\ } + if (Test-Path LICENSE) { Copy-Item LICENSE dist_upload\ } + Compress-Archive -Path dist_upload\* -DestinationPath SeaLoader_Windows_x64.zip -Force + + - name: Upload asset to Release + if: ${{ github.event_name == 'release' && github.event.action == 'published' }} + shell: pwsh + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + RELEASE_ID: ${{ github.event.release.id }} + SERVER_URL: ${{ github.server_url }} + run: | + $ErrorActionPreference = 'Stop' + $uploadUrl = "$env:SERVER_URL/api/v1/repos/$env:REPO/releases/$env:RELEASE_ID/assets?name=SeaLoader_Windows_x64.zip" + Write-Host "Uploading asset to $uploadUrl" + Invoke-RestMethod -Method Post -Uri $uploadUrl -Headers @{ Authorization = "token $env:TOKEN" } -ContentType "application/zip" -InFile "SeaLoader_Windows_x64.zip" + + - name: Upload artifact (CI logs) + uses: actions/upload-artifact@v4 + with: + name: SeaLoader_Windows_x64 + path: | + SeaLoader_Windows_x64.zip + dist/SeaLoader/SeaLoader.exe + diff --git a/SeaLoader.png b/SeaLoader.png new file mode 100644 index 0000000..d16b393 Binary files /dev/null and b/SeaLoader.png differ diff --git a/SeaLoader.psd b/SeaLoader.psd new file mode 100644 index 0000000..919b05a Binary files /dev/null and b/SeaLoader.psd differ diff --git a/__pycache__/steam_required_ids.cpython-310.pyc b/__pycache__/steam_required_ids.cpython-310.pyc new file mode 100644 index 0000000..dd05ab8 Binary files /dev/null and b/__pycache__/steam_required_ids.cpython-310.pyc differ diff --git a/hrsys_logo.svg b/hrsys_logo.svg new file mode 100644 index 0000000..60d90e6 --- /dev/null +++ b/hrsys_logo.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + SYSTEMS + + + + + + + + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..55f34b6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.31.0 +beautifulsoup4>=4.12.2 diff --git a/sealoader_gui.py b/sealoader_gui.py new file mode 100644 index 0000000..ea3d64d --- /dev/null +++ b/sealoader_gui.py @@ -0,0 +1,385 @@ +import configparser +import os +import shutil +import sys +import threading +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Tuple + +import tkinter as tk +from tkinter import messagebox, ttk + +from steam_required_ids import extract_required_item_ids, resolve_workshop_names + + +# 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 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 + + # 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: + missing_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._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 + + 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) + + 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) + + 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", "Enabled"], [0.30, 0.55, 0.15]) + 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.help_btn = ttk.Button(bottom, text="Help", command=self._on_help) + self.help_btn.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) + 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) + hsb = ttk.Scrollbar(parent, orient="horizontal", command=tree.xview) + tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) + + tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + vsb.pack(side=tk.LEFT, fill=tk.Y) + hsb.pack(side=tk.BOTTOM, fill=tk.X) + + 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: + self.installed_mods = read_installed_mods(self.ini_path) + except FileNotFoundError as e: + messagebox.showerror("SeaLoader", str(e)) + self.installed_mods = {} + self._refresh_installed_list() + self._resolve_installed_names_async() + + def _refresh_installed_list(self) -> None: + for row in self.installed_tree.get_children(): + self.installed_tree.delete(row) + for mod in self.installed_mods.values(): + status = "Yes" if mod.enabled else "No" + 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: + ids = extract_required_item_ids(url) + # 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) + + 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)) + + 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 'Enable Matching Mods' to turn on any installed required mods.\n" + " - A .bak backup of usersettings.ini is created before changes.\n" + "4) Mods marked Missing must be installed separately in Sea Power.\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 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() + + diff --git a/steam_required_ids.py b/steam_required_ids.py new file mode 100644 index 0000000..f558b06 --- /dev/null +++ b/steam_required_ids.py @@ -0,0 +1,234 @@ +import argparse +import json +import re +import sys +from typing import Iterable, List, Set, Dict +from urllib.parse import urlparse, parse_qs + +import requests +from bs4 import BeautifulSoup + + +WORKSHOP_ITEM_ID_REGEX = re.compile(r"id=(\d+)") + + +def extract_id_from_href(href: str) -> str | None: + if not href: + return None + + # Accept absolute or relative Steam workshop/sharedfiles links + if "filedetails" not in href or "id=" not in href: + return None + + try: + parsed = urlparse(href) + # Handle relative URLs like "/sharedfiles/filedetails/?id=123" + query = parsed.query or href.split("?", 1)[1] if "?" in href else "" + qs = parse_qs(query) + if "id" in qs and qs["id"]: + candidate = qs["id"][0] + return candidate if candidate.isdigit() else None + except Exception: + match = WORKSHOP_ITEM_ID_REGEX.search(href) + if match: + return match.group(1) + return None + + +def parse_main_item_id(url: str) -> str | None: + try: + parsed = urlparse(url) + qs = parse_qs(parsed.query) + if "id" in qs and qs["id"]: + candidate = qs["id"][0] + return candidate if candidate.isdigit() else None + except Exception: + pass + return None + + +def collect_ids_from_elements(elements: Iterable) -> Set[str]: + ids: Set[str] = set() + for el in elements: + href = getattr(el, "get", None) + if callable(href): + link = el.get("href", "") + else: + link = "" + item_id = extract_id_from_href(link) + if item_id: + ids.add(item_id) + return ids + + +def extract_required_item_ids_from_html(html: str) -> Set[str]: + soup = BeautifulSoup(html, "html.parser") + + # Strategy 1: Look for a section headed "Required items" and parse links within + section_ids: Set[str] = set() + heading_candidates = soup.find_all(string=re.compile(r"^\s*Required\s+items\s*$", re.IGNORECASE)) + for heading in heading_candidates: + parent = heading.parent + if parent is None: + continue + + # Search within nearby container siblings/descendants for links + container = parent + for _ in range(3): # climb up a few levels to catch the full block + if container is None: + break + links = container.find_all("a", href=True) + section_ids |= collect_ids_from_elements(links) + container = container.parent + + if section_ids: + return section_ids + + # Strategy 2: Look for any block that contains the sentence used by Steam + hint_blocks = soup.find_all(string=re.compile(r"requires\s+all\s+of\s+the\s+following\s+other\s+items", re.IGNORECASE)) + for hint in hint_blocks: + container = hint.parent + for _ in range(3): + if container is None: + break + links = container.find_all("a", href=True) + section_ids |= collect_ids_from_elements(links) + container = container.parent + + if section_ids: + return section_ids + + # Strategy 3 (fallback): scan all anchors on the page + all_links = soup.find_all("a", href=True) + return collect_ids_from_elements(all_links) + + +def fetch_page(url: str, timeout: int = 20) -> str: + headers = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/126.0.0.0 Safari/537.36" + ), + "Accept-Language": "en-US,en;q=0.9", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + } + # Steam can occasionally require a cookie for age gates. Provide innocuous defaults. + cookies = { + "birthtime": "568022401", # 1987-12-20 + "lastagecheckage": "1-January-1990", + "mature_content": "1", + } + resp = requests.get(url, headers=headers, cookies=cookies, timeout=timeout) + resp.raise_for_status() + return resp.text + + +def extract_required_item_ids(url: str) -> List[str]: + html = fetch_page(url) + found_ids = extract_required_item_ids_from_html(html) + + # Remove the current page's ID if present + current_id = parse_main_item_id(url) + if current_id and current_id in found_ids: + found_ids.remove(current_id) + + return sorted(found_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. + + Uses ISteamRemoteStorage.GetPublishedFileDetails, batching up to 100 IDs per call. + Falls back to scraping each item's page if the API fails. + """ + id_list = [i for i in dict.fromkeys([i for i in ids if i and i.isdigit()])] + if not id_list: + return {} + + headers = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/126.0.0.0 Safari/537.36" + ), + } + + api_url = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/" + resolved: Dict[str, str] = {} + + try: + session = requests.Session() + session.headers.update(headers) + batch_size = 100 + for start in range(0, len(id_list), batch_size): + batch = id_list[start:start + batch_size] + data = {"itemcount": len(batch)} + for idx, pub_id in enumerate(batch): + data[f"publishedfileids[{idx}]"] = pub_id + resp = session.post(api_url, data=data, timeout=timeout) + resp.raise_for_status() + payload = resp.json() + details = payload.get("response", {}).get("publishedfiledetails", []) + for entry in details: + if entry.get("result") == 1: + title = entry.get("title") + pub_id = str(entry.get("publishedfileid")) + if pub_id and title: + resolved[pub_id] = title + except Exception: + # API failure; fall back to HTML scraping below + pass + + # Fallback for unresolved IDs: scrape the item page + unresolved = [i for i in id_list if i not in resolved] + for pub_id in unresolved: + try: + page_url = f"https://steamcommunity.com/sharedfiles/filedetails/?id={pub_id}" + html = fetch_page(page_url, timeout=timeout) + soup = BeautifulSoup(html, "html.parser") + name = None + og = soup.find("meta", attrs={"property": "og:title"}) + if og and og.get("content"): + name = og.get("content").strip() + if not name: + title_div = soup.find("div", class_="workshopItemTitle") + if title_div and title_div.text: + name = title_div.text.strip() + if name: + resolved[pub_id] = name + except Exception: + # Leave unresolved if both methods fail + pass + + return resolved + + +def main() -> None: + parser = argparse.ArgumentParser(description="Extract Steam Workshop 'Required items' IDs from a Workshop item page") + parser.add_argument("url", help="Steam Workshop item URL (e.g., https://steamcommunity.com/sharedfiles/filedetails/?id=XXXXXXXX)") + parser.add_argument("--json", action="store_true", help="Print JSON array instead of plain text") + args = parser.parse_args() + + try: + ids = extract_required_item_ids(args.url) + except requests.HTTPError as http_err: + print(f"HTTP error: {http_err}", file=sys.stderr) + sys.exit(2) + except Exception as exc: + print(f"Failed to extract IDs: {exc}", file=sys.stderr) + sys.exit(1) + + if args.json: + print(json.dumps(ids)) + else: + if not ids: + print("No required item IDs found.") + else: + print("\n".join(ids)) + + +if __name__ == "__main__": + main() + +