19 Commits
0.0.1 ... 0.0.7

Author SHA1 Message Date
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 453 additions and 68 deletions

View File

@@ -8,7 +8,7 @@ on:
jobs:
build-windows-exe:
name: Build Windows EXE
runs-on: windows-latest
runs-on: windows
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -19,22 +19,32 @@ jobs:
python-version: '3.11'
- name: Install dependencies
shell: pwsh
shell: powershell
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
shell: powershell
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"
# 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;." `
--add-data "hrsys.png;." `
--icon SeaLoader.ico
- name: Prepare artifact
shell: pwsh
shell: powershell
run: |
New-Item -ItemType Directory -Force -Path dist_upload | Out-Null
Copy-Item dist\SeaLoader.exe dist_upload\SeaLoader.exe
@@ -44,7 +54,7 @@ jobs:
- name: Upload asset to Release
if: ${{ github.event_name == 'release' && github.event.action == 'published' }}
shell: pwsh
shell: powershell
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
@@ -56,11 +66,5 @@ jobs:
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
# CI artifact upload removed for GHES compatibility

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 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
@@ -69,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
@@ -85,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
@@ -117,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)
@@ -138,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()
@@ -157,6 +245,23 @@ class SeaLoaderApp(tk.Tk):
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()
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)
@@ -174,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")
@@ -187,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)
@@ -197,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")
@@ -209,9 +326,29 @@ class SeaLoaderApp(tk.Tk):
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.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"))
# 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)
for col in columns:
@@ -219,12 +356,10 @@ class SeaLoaderApp(tk.Tk):
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.configure(yscrollcommand=vsb.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
@@ -240,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))
@@ -287,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]
@@ -305,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)
@@ -351,9 +511,10 @@ class SeaLoaderApp(tk.Tk):
"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"
"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"
@@ -370,6 +531,169 @@ class SeaLoaderApp(tk.Tk):
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.")
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("<Button-1>", 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

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)
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)
if current_id and current_id in found_ids:
found_ids.remove(current_id)
if current_id:
found_ids.add(current_id)
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.