Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
5e68076bbb
|
|||
|
447365e7d1
|
|||
|
6b520a1bcc
|
|||
|
ca4fd2c9d5
|
|||
|
c23acb4472
|
|||
|
33fcd762f4
|
|||
|
ffbbf42d23
|
|||
| 8599690614 | |||
| 572d328890 | |||
| 10ae32812f | |||
| 39cfe519c6 | |||
| 19a326f8bf | |||
| 8ea5de3f54 | |||
|
992b629e24
|
|||
|
9223d5c5b2
|
|||
|
900cb4c210
|
|||
|
bd1c068e1c
|
|||
|
9afa50820b
|
@@ -1,4 +1,4 @@
|
||||
name: Build and Upload Release (Windows EXE from Ubuntu)
|
||||
name: Build and Upload Release (Windows EXE)
|
||||
|
||||
on:
|
||||
release:
|
||||
@@ -7,61 +7,64 @@ on:
|
||||
|
||||
jobs:
|
||||
build-windows-exe:
|
||||
name: Build Windows EXE (PyInstaller via Docker on Ubuntu)
|
||||
runs-on: ubuntu-latest
|
||||
name: Build Windows EXE
|
||||
runs-on: windows
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure tools (ImageMagick for .ico)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y imagemagick
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Prepare icon (optional)
|
||||
- name: Install dependencies
|
||||
shell: powershell
|
||||
run: |
|
||||
if [ -f "SeaLoader.png" ]; then
|
||||
convert SeaLoader.png -resize 256x256 SeaLoader.ico || true
|
||||
fi
|
||||
python -m pip install --upgrade pip
|
||||
if (Test-Path requirements.txt) { pip install -r requirements.txt }
|
||||
pip install pyinstaller
|
||||
|
||||
- name: Build EXE with PyInstaller (Windows target via Docker)
|
||||
- name: Build EXE with PyInstaller
|
||||
shell: powershell
|
||||
run: |
|
||||
docker run --rm -v "$PWD":/src cdrx/pyinstaller-windows:python3 \
|
||||
"/bin/sh -lc 'set -e; if [ -f requirements.txt ]; then pip install -r requirements.txt; fi; pyinstaller --noconfirm --onefile --windowed sealoader_gui.py --name SeaLoader --add-data ""SeaLoader.png;."" $( [ -f SeaLoader.ico ] && echo --icon SeaLoader.ico )'"
|
||||
$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;." `
|
||||
--add-data "hrsys.png;." `
|
||||
--icon SeaLoader.ico
|
||||
|
||||
- name: Prepare artifact
|
||||
shell: powershell
|
||||
run: |
|
||||
mkdir -p dist_upload
|
||||
if [ -f dist/windows/SeaLoader.exe ]; then
|
||||
cp dist/windows/SeaLoader.exe dist_upload/SeaLoader.exe
|
||||
elif [ -f dist/SeaLoader.exe ]; then
|
||||
cp dist/SeaLoader.exe dist_upload/SeaLoader.exe
|
||||
else
|
||||
echo "SeaLoader.exe not found" && ls -R dist || true && exit 1
|
||||
fi
|
||||
[ -f README.md ] && cp README.md dist_upload/ || true
|
||||
[ -f LICENSE ] && cp LICENSE dist_upload/ || true
|
||||
zip -r9 SeaLoader_Windows_x64.zip dist_upload
|
||||
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: powershell
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
RELEASE_ID: ${{ github.event.release.id }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
run: |
|
||||
set -e
|
||||
UPLOAD_URL="$SERVER_URL/api/v1/repos/$REPO/releases/$RELEASE_ID/assets?name=SeaLoader_Windows_x64.zip"
|
||||
echo "Uploading asset to $UPLOAD_URL"
|
||||
curl -fSL -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/zip" \
|
||||
--data-binary @SeaLoader_Windows_x64.zip "$UPLOAD_URL"
|
||||
$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/windows/SeaLoader.exe
|
||||
# CI artifact upload removed for GHES compatibility
|
||||
|
||||
|
||||
54
.gitignore
vendored
Normal file
54
.gitignore
vendored
Normal 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
BIN
SeaLoader.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
Binary file not shown.
@@ -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 |
350
sealoader_gui.py
350
sealoader_gui.py
@@ -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
1
sealoader_version.py
Normal file
@@ -0,0 +1 @@
|
||||
__version__ = "0.0.0-dev"
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user