20 Commits
0.0.1 ... 0.0.8

Author SHA1 Message Date
b6382b2808 Auto Update, Fix Versioning, Refresh Button and Better Guide
Some checks failed
Build and Upload Release (Windows EXE) / Build Windows EXE (release) Failing after 49s
2025-09-17 00:11:10 -04:00
5e68076bbb Steam Based ID Finiding, Version Control, Storage
All checks were successful
Build and Upload Release (Windows EXE) / Build Windows EXE (release) Successful in 2m32s
2025-09-16 23:31:51 -04:00
447365e7d1 Inlcude Footer PNG
All checks were successful
Build and Upload Release (Windows EXE) / Build Windows EXE (release) Successful in 3m31s
2025-09-16 15:23:00 -04:00
6b520a1bcc Add Footer, Remove Mods, and Add Main Mod
All checks were successful
Build and Upload Release (Windows EXE) / Build Windows EXE (release) Successful in 3m30s
2025-09-16 15:10:13 -04:00
ca4fd2c9d5 New ICO better Help Text 2025-09-16 14:45:51 -04:00
c23acb4472 powershell
Some checks failed
Build and Upload Release (Windows EXE) / Build Windows EXE (release) Failing after 3m41s
2025-09-16 14:33:17 -04:00
33fcd762f4 New Run On 2025-09-16 14:27:16 -04:00
ffbbf42d23 change runner location 2025-09-16 14:25:38 -04:00
8599690614 revert a51f341cf3
revert Attempt ubuntu action
2025-09-16 17:43:50 +00:00
572d328890 revert 9afa50820b
revert fix deploy
2025-09-16 17:43:38 +00:00
10ae32812f revert bd1c068e1c
revert add Ico
2025-09-16 17:43:28 +00:00
39cfe519c6 revert 900cb4c210
revert More Action Fixes
2025-09-16 17:43:16 +00:00
19a326f8bf revert 9223d5c5b2
revert Fix Workflow
2025-09-16 17:43:05 +00:00
8ea5de3f54 revert 992b629e24
revert Closer
2025-09-16 17:42:56 +00:00
992b629e24 Closer 2025-09-16 13:22:58 -04:00
9223d5c5b2 Fix Workflow 2025-09-16 13:21:18 -04:00
900cb4c210 More Action Fixes 2025-09-16 13:18:33 -04:00
bd1c068e1c add Ico 2025-09-16 13:15:25 -04:00
9afa50820b fix deploy 2025-09-16 13:09:37 -04:00
a51f341cf3 Attempt ubuntu action
Some checks failed
Build and Upload Release (Windows EXE from Ubuntu) / Build Windows EXE (PyInstaller via Docker on Ubuntu) (release) Failing after 3m58s
2025-09-16 12:54:21 -04:00
9 changed files with 680 additions and 68 deletions

View File

@@ -8,7 +8,7 @@ on:
jobs: jobs:
build-windows-exe: build-windows-exe:
name: Build Windows EXE name: Build Windows EXE
runs-on: windows-latest runs-on: windows
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -19,22 +19,32 @@ jobs:
python-version: '3.11' python-version: '3.11'
- name: Install dependencies - name: Install dependencies
shell: pwsh shell: powershell
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
if (Test-Path requirements.txt) { pip install -r requirements.txt } if (Test-Path requirements.txt) { pip install -r requirements.txt }
pip install pyinstaller pip install pyinstaller
- name: Build EXE with PyInstaller - name: Build EXE with PyInstaller
shell: pwsh shell: powershell
run: | run: |
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
# Include SeaLoader.png so the packaged app icon in-app works # Stamp version into sealoader_version.py from release tag
$addData = "SeaLoader.png;." # Windows uses ';' for --add-data if ($env:GITHUB_EVENT_NAME -eq 'release') {
pyinstaller --noconfirm --onefile --windowed sealoader_gui.py --name SeaLoader --add-data "$addData" $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;." `
--add-data "hrsys.png;." `
--icon SeaLoader.ico
- name: Prepare artifact - name: Prepare artifact
shell: pwsh shell: powershell
run: | run: |
New-Item -ItemType Directory -Force -Path dist_upload | Out-Null New-Item -ItemType Directory -Force -Path dist_upload | Out-Null
Copy-Item dist\SeaLoader.exe dist_upload\SeaLoader.exe Copy-Item dist\SeaLoader.exe dist_upload\SeaLoader.exe
@@ -44,7 +54,7 @@ jobs:
- name: Upload asset to Release - name: Upload asset to Release
if: ${{ github.event_name == 'release' && github.event.action == 'published' }} if: ${{ github.event_name == 'release' && github.event.action == 'published' }}
shell: pwsh shell: powershell
env: env:
TOKEN: ${{ secrets.GITHUB_TOKEN }} TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }} REPO: ${{ github.repository }}
@@ -56,11 +66,5 @@ jobs:
Write-Host "Uploading asset to $uploadUrl" 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" Invoke-RestMethod -Method Post -Uri $uploadUrl -Headers @{ Authorization = "token $env:TOKEN" } -ContentType "application/zip" -InFile "SeaLoader_Windows_x64.zip"
- name: Upload artifact (CI logs) # CI artifact upload removed for GHES compatibility
uses: actions/upload-artifact@v4
with:
name: SeaLoader_Windows_x64
path: |
SeaLoader_Windows_x64.zip
dist/SeaLoader/SeaLoader.exe

54
.gitignore vendored Normal file
View File

@@ -0,0 +1,54 @@
# Python bytecode
__pycache__/
*.py[cod]
*$py.class
# Virtual environments
.venv/
venv/
env/
ENV/
.conda/
.python-version
# Testing / type-check / tooling caches
.pytest_cache/
.mypy_cache/
.ruff_cache/
.pyre/
.tox/
.coverage
.coverage.*
.cache/
nosetests.xml
coverage.xml
htmlcov/
# Build and packaging
build/
dist/
.eggs/
*.egg-info/
*.egg
pip-wheel-metadata/
*.manifest
*.spec
dist_upload/
SeaLoader.exe
SeaLoader_Windows_x64.zip
# Editors/IDE
.vscode/
.idea/
*.code-workspace
# OS junk
.DS_Store
Thumbs.db
# Local configuration
usersettings.ini
# Logs
*.log

BIN
SeaLoader.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
hrsys.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 2048 2048">
<!-- Generator: Adobe Illustrator 29.6.0, SVG Export Plug-In . SVG Version: 2.1.1 Build 207) -->
<defs>
<style>
.st0, .st1, .st2 {
fill: #f16465;
}
.st0, .st1, .st3 {
display: none;
}
.st1 {
font-family: FMBolyarSansPro-400, 'FM Bolyar Sans Pro';
font-size: 176.84px;
}
.st4 {
letter-spacing: .6em;
}
</style>
</defs>
<g id="Systems" class="st3">
<text/>
</g>
<g id="H">
<g>
<text class="st1" transform="translate(151.86 1585.59)"><tspan class="st4" x="0" y="0">SYSTEM</tspan><tspan x="1589.37" y="0">S</tspan></text>
<rect class="st0" x="43.86" y="1699.23" width="1960.29" height="16.64"/>
<g>
<path class="st2" d="M516.81,384.73c354.34.79,708.68,1.58,1063.01,2.37,25.33-.26,191.03.81,306.99,134.63,27.84,32.13,97.9,123.95,92.85,253.41-5.85,150-109.21,265.44-206.05,311.69-40.43,19.31-82.89,28.07-95.95,30.69-75.28,15.13-139.12,8.06-179.02.41,180.82,180.82,361.64,361.64,542.47,542.47h-281.15c-240.47-240.47-480.95-480.95-721.42-721.42h600.36c98.23-15.25,167-101.58,161.66-191.46-6.01-101.15-104.58-184.37-216.28-168.88h-897.5c-56.65-64.64-113.31-129.28-169.96-193.92Z"/>
<polygon class="st2" points="6.89 1663.27 6.89 385.2 190.13 385.88 189.56 945.29 940.52 945.29 1173.93 1166.01 1173.93 1663.27 991.26 1663.27 991.26 1125.42 192.09 1125.42 192.09 1663.27 6.89 1663.27"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -3,14 +3,18 @@ import os
import shutil import shutil
import sys import sys
import threading import threading
import time
import webbrowser
import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Dict, List, Tuple from typing import Dict, List, Tuple
import tkinter as tk 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 # Minimal dark theme colors
@@ -69,6 +73,74 @@ def read_installed_mods(ini_path: str) -> Dict[str, InstalledMod]:
return mods 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]: def enable_mods_in_ini(ini_path: str, ids_to_enable: List[str]) -> Tuple[int, int]:
parser = configparser.ConfigParser() parser = configparser.ConfigParser()
parser.optionxform = str parser.optionxform = str
@@ -85,6 +157,16 @@ def enable_mods_in_ini(ini_path: str, ids_to_enable: List[str]) -> Tuple[int, in
enabled_count = 0 enabled_count = 0
missing_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) # Maintain order and preserve original left token (may include name after ID)
items = list(parser[section].items()) items = list(parser[section].items())
# Maintain order # Maintain order
@@ -117,7 +199,11 @@ def enable_mods_in_ini(ini_path: str, ids_to_enable: List[str]) -> Tuple[int, in
enabled_count += 1 enabled_count += 1
break break
if not found_key: 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: with open(ini_path, "w", encoding="utf-8") as f:
parser.write(f) parser.write(f)
@@ -138,6 +224,9 @@ class SeaLoaderApp(tk.Tk):
self.required_ids: List[str] = [] self.required_ids: List[str] = []
self.installed_names_map: Dict[str, str] = {} self.installed_names_map: Dict[str, str] = {}
self._icon_img = None self._icon_img = None
self._did_flash_fetch = False
self._did_flash_enable = False
self._flash_states = {}
self._style() self._style()
self._layout() self._layout()
@@ -157,6 +246,25 @@ class SeaLoaderApp(tk.Tk):
self.bind("<F1>", lambda e: self._on_help()) self.bind("<F1>", lambda e: self._on_help())
except Exception: except Exception:
pass 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: def _style(self) -> None:
style = ttk.Style(self) style = ttk.Style(self)
@@ -174,6 +282,9 @@ class SeaLoaderApp(tk.Tk):
style.configure("TScrollbar", background=PANEL_BG, troughcolor=BG) style.configure("TScrollbar", background=PANEL_BG, troughcolor=BG)
style.configure("Treeview", background=PANEL_BG, fieldbackground=PANEL_BG, foreground=FG, borderwidth=0) style.configure("Treeview", background=PANEL_BG, fieldbackground=PANEL_BG, foreground=FG, borderwidth=0)
style.configure("Treeview.Heading", background=PANEL_BG, foreground=MUTED) 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: def _layout(self) -> None:
top = ttk.Frame(self, style="TFrame") top = ttk.Frame(self, style="TFrame")
@@ -187,6 +298,15 @@ class SeaLoaderApp(tk.Tk):
self.fetch_btn = ttk.Button(top, text="Fetch Required Mods", command=self._on_fetch) self.fetch_btn = ttk.Button(top, text="Fetch Required Mods", command=self._on_fetch)
self.fetch_btn.pack(side=tk.LEFT) 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 = ttk.Frame(self, style="TFrame")
main.pack(fill=tk.BOTH, expand=True, padx=12, pady=6) main.pack(fill=tk.BOTH, expand=True, padx=12, pady=6)
@@ -197,7 +317,7 @@ class SeaLoaderApp(tk.Tk):
left_header = ttk.Label(left_panel, text="Installed Mods", style="Header.TLabel") left_header = ttk.Label(left_panel, text="Installed Mods", style="Header.TLabel")
left_header.pack(anchor="w", padx=8, pady=8) 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)) 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 = ttk.Label(right_panel, text="Scenario Required Mods", style="Header.TLabel")
@@ -209,9 +329,33 @@ class SeaLoaderApp(tk.Tk):
bottom.pack(fill=tk.X, padx=12, pady=(0, 12)) 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 = ttk.Button(bottom, text="Enable Matching Mods", command=self._on_enable)
self.enable_btn.pack(side=tk.LEFT) 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.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) 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]): def _create_tree(self, parent, columns: List[str], ratios: List[float]):
tree = ttk.Treeview(parent, columns=columns, show="headings", height=12) tree = ttk.Treeview(parent, columns=columns, show="headings", height=12)
for col in columns: for col in columns:
@@ -219,12 +363,10 @@ class SeaLoaderApp(tk.Tk):
tree.column(col, width=50, anchor=tk.W, stretch=True) tree.column(col, width=50, anchor=tk.W, stretch=True)
vsb = ttk.Scrollbar(parent, orient="vertical", command=tree.yview) vsb = ttk.Scrollbar(parent, orient="vertical", command=tree.yview)
hsb = ttk.Scrollbar(parent, orient="horizontal", command=tree.xview) tree.configure(yscrollcommand=vsb.set)
tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
vsb.pack(side=tk.LEFT, fill=tk.Y) vsb.pack(side=tk.LEFT, fill=tk.Y)
hsb.pack(side=tk.BOTTOM, fill=tk.X)
self._bind_auto_columns(parent, tree, ratios) self._bind_auto_columns(parent, tree, ratios)
return tree return tree
@@ -240,18 +382,40 @@ class SeaLoaderApp(tk.Tk):
def _load_installed_mods(self) -> None: def _load_installed_mods(self) -> None:
try: 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: except FileNotFoundError as e:
messagebox.showerror("SeaLoader", str(e)) messagebox.showerror("SeaLoader", str(e))
self.installed_mods = {} self.installed_mods = {}
self._refresh_installed_list() self._refresh_installed_list()
self._resolve_installed_names_async() 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: def _refresh_installed_list(self) -> None:
for row in self.installed_tree.get_children(): for row in self.installed_tree.get_children():
self.installed_tree.delete(row) self.installed_tree.delete(row)
ini_ids = _get_all_mod_ids_from_ini(self.ini_path)
for mod in self.installed_mods.values(): 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, "") display_name = self.installed_names_map.get(mod.mod_id, "")
self.installed_tree.insert("", tk.END, values=(mod.mod_id, display_name, status)) self.installed_tree.insert("", tk.END, values=(mod.mod_id, display_name, status))
@@ -287,7 +451,8 @@ class SeaLoaderApp(tk.Tk):
def task(): def task():
try: 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 # 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} 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] ids_to_lookup = [i for i in ids if i not in names_map]
@@ -305,6 +470,10 @@ class SeaLoaderApp(tk.Tk):
self._refresh_required_list(names_map) self._refresh_required_list(names_map)
self.fetch_btn.configure(state=tk.NORMAL) self.fetch_btn.configure(state=tk.NORMAL)
self.enable_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) self.after(0, update)
@@ -341,6 +510,17 @@ class SeaLoaderApp(tk.Tk):
else: else:
msg_lines.append("All required mods were found.") msg_lines.append("All required mods were found.")
messagebox.showinfo("SeaLoader", "\n".join(msg_lines)) 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: def _on_help(self) -> None:
ini_hint = ( ini_hint = (
@@ -351,9 +531,10 @@ class SeaLoaderApp(tk.Tk):
"1) 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" " - Right list shows required Mod IDs, names and whether they are installed.\n"
"2) Installed mods load automatically from usersettings.ini (left list).\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" "3) Click 'Subscribe Missing Mods' to subscribe to any missing Workshop items in Steam.\n"
" - A .bak backup of usersettings.ini is created before changes.\n" " - Steam desktop must be running and signed in; downloads happen in the background.\n"
"4) Mods marked Missing must be installed separately in Sea Power.\n\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" "Tips:\n"
"- Run with a custom INI path: python sealoader_gui.py \"<path-to-usersettings.ini>\"\n" "- Run with a custom INI path: python sealoader_gui.py \"<path-to-usersettings.ini>\"\n"
"- Press F1 to open this help.\n\n" "- Press F1 to open this help.\n\n"
@@ -370,6 +551,376 @@ class SeaLoaderApp(tk.Tk):
pass pass
return Path(__file__).resolve().parent / name 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')
ps = f"""
$ErrorActionPreference = 'Stop'
$pidToWait = {os.getpid()}
$target = '{current_exe}'.Replace("'","''")
$source = '{new_exe_path}'.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
"""
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: def main() -> None:
ini_path = DEFAULT_INI_PATH ini_path = DEFAULT_INI_PATH

1
sealoader_version.py Normal file
View File

@@ -0,0 +1 @@
__version__ = "0.0.0-dev"

View File

@@ -128,14 +128,53 @@ def extract_required_item_ids(url: str) -> List[str]:
html = fetch_page(url) html = fetch_page(url)
found_ids = extract_required_item_ids_from_html(html) found_ids = extract_required_item_ids_from_html(html)
# Remove the current page's ID if present # Ensure the current page's ID is included (user wants main mod too)
current_id = parse_main_item_id(url) current_id = parse_main_item_id(url)
if current_id and current_id in found_ids: if current_id:
found_ids.remove(current_id) found_ids.add(current_id)
return sorted(found_ids, key=int) 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]: 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. """Resolve Workshop IDs to human-readable titles using Steam API, with HTML fallback.