Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
5b57e0c39b
|
|||
|
e36994be55
|
|||
|
a39184f28a
|
|||
|
b6382b2808
|
72
README.md
72
README.md
@@ -1,3 +1,73 @@
|
|||||||
# SeaLoader
|
# SeaLoader
|
||||||
|
|
||||||
A Modloader based off of the workshop
|
SeaLoader is a minimal, dark-themed helper app for Sea Power that fetches a scenario’s required Steam Workshop mods, resolves names, checks for transitive dependencies, and enables matching mods in your `usersettings.ini`. It also supports subscribing to missing mods, scanning the Steam Workshop folder for installed content, and self-updating.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Fetch required mods from a Workshop URL and resolve their names
|
||||||
|
- Recursive dependency expansion: also fetches each required mod’s own requirements
|
||||||
|
- Two-pane UI:
|
||||||
|
- Installed Mods (from Steam Workshop folder) with status: Enabled, Disabled, or Disabled (Not in INI)
|
||||||
|
- Scenario Required Mods with Installed/Missing status
|
||||||
|
- Enable Matching Mods: writes to `[LoadOrder]` and appends missing workshop IDs as new `ModNDirectory=<id>,True`
|
||||||
|
- Subscribe Missing Mods: opens Steam deep links per mod; step-by-step flow with remaining count
|
||||||
|
- Refresh: reload Installed Mods and re-fetch Required Mods (if URL provided)
|
||||||
|
- Guided flashing to lead the user through steps (URL → Fetch → Subscribe/Refresh → Enable)
|
||||||
|
- Game Path persistence: remembers the Steam game path next to `usersettings.ini`
|
||||||
|
- Version display and update check (Gitea API + RSS fallback), with clickable link
|
||||||
|
- Auto-update install (EXE only): download zip, replace EXE safely, relaunch
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
- Windows 10+
|
||||||
|
- Python 3.10+ (for running from source) or the packaged EXE
|
||||||
|
- Steam client installed and logged in (for Subscribe Missing Mods)
|
||||||
|
|
||||||
|
## Run from source
|
||||||
|
```bash
|
||||||
|
python -m pip install -r requirements.txt
|
||||||
|
python sealoader_gui.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
1. Game Path: Click Browse and select your Sea Power install folder (e.g., `.../steamapps/common/Sea Power`).
|
||||||
|
- If previously saved, SeaLoader loads it automatically on start.
|
||||||
|
2. Paste a Workshop URL and click Fetch Required Mods.
|
||||||
|
- The right list shows all required Workshop IDs and names, including transitive dependencies.
|
||||||
|
3. If some are Missing:
|
||||||
|
- Click Subscribe Missing Mods. A per-mod dialog will open; it shows how many remain.
|
||||||
|
- After subscribing, click Refresh to reload Installed Mods and re-fetch required.
|
||||||
|
4. Click Enable Matching Mods to enable all installed required mods.
|
||||||
|
- A backup `usersettings.ini.bak` is created.
|
||||||
|
|
||||||
|
Statuses in Installed Mods:
|
||||||
|
- Enabled: Present in INI and enabled
|
||||||
|
- Disabled: Present in INI but disabled
|
||||||
|
- Disabled (Not in INI): Found in workshop folder but no INI entry yet (Enable adds it)
|
||||||
|
|
||||||
|
## Self-update
|
||||||
|
- SeaLoader shows current and latest versions in the footer.
|
||||||
|
- If a newer version exists, a modal dialog appears with options:
|
||||||
|
- Download: opens the release page
|
||||||
|
- Auto-Install (EXE only): downloads `SeaLoader_Windows_x64.zip`, replaces `SeaLoader.exe`, and relaunches
|
||||||
|
- Latest version is discovered via Gitea API with RSS fallback: `releases.rss`.
|
||||||
|
|
||||||
|
## Build (Gitea Actions)
|
||||||
|
- Workflow: `.gitea/workflows/release.yml`
|
||||||
|
- On release: stamps `sealoader_version.py` with the tag, bundles PNGs, and builds onefile EXE via PyInstaller
|
||||||
|
- Uploads `SeaLoader_Windows_x64.zip` as the release asset
|
||||||
|
- Runner: Windows; steps use `powershell`
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- `sealoader_gui.py`: GUI application
|
||||||
|
- `steam_required_ids.py`: Workshop scraping, ID resolution, recursive expansion
|
||||||
|
- `sealoader_version.py`: version stamp used by the update checker
|
||||||
|
- `requirements.txt`: dependencies (requests, beautifulsoup4)
|
||||||
|
- `.gitea/workflows/release.yml`: release build pipeline
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- Footer icons not visible in EXE: ensure `SeaLoader.png` and `hrsys.png` are present at build; the workflow includes them via `--add-data`.
|
||||||
|
- Update checker says unavailable: RSS fallback is in place; ensure `SEALOADER_GITEA` and `SEALOADER_REPO` env vars are correct if using a mirror.
|
||||||
|
- Subscribe Missing Mods not opening Steam: ensure Steam client is running and you’re signed in.
|
||||||
|
- INI not updated: verify `usersettings.ini` path and that `[LoadOrder]` exists. SeaLoader will append new entries if needed.
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
Created by HudsonRiggs.Systems.
|
||||||
282
sealoader_gui.py
282
sealoader_gui.py
@@ -13,7 +13,7 @@ from typing import Dict, List, Tuple
|
|||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import messagebox, ttk, filedialog
|
from tkinter import messagebox, ttk, filedialog
|
||||||
|
|
||||||
from steam_required_ids import extract_required_item_ids, resolve_workshop_names, expand_required_ids_recursive
|
from steam_required_ids import extract_required_item_ids, resolve_workshop_names
|
||||||
from sealoader_version import __version__
|
from sealoader_version import __version__
|
||||||
|
|
||||||
|
|
||||||
@@ -226,6 +226,7 @@ class SeaLoaderApp(tk.Tk):
|
|||||||
self._icon_img = None
|
self._icon_img = None
|
||||||
self._did_flash_fetch = False
|
self._did_flash_fetch = False
|
||||||
self._did_flash_enable = False
|
self._did_flash_enable = False
|
||||||
|
self._flash_states = {}
|
||||||
|
|
||||||
self._style()
|
self._style()
|
||||||
self._layout()
|
self._layout()
|
||||||
@@ -257,6 +258,8 @@ class SeaLoaderApp(tk.Tk):
|
|||||||
if saved:
|
if saved:
|
||||||
self.game_path_var.set(saved)
|
self.game_path_var.set(saved)
|
||||||
self._load_installed_mods()
|
self._load_installed_mods()
|
||||||
|
if not self.url_var.get().strip():
|
||||||
|
self._start_flash(self.url_entry, entry=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -328,6 +331,8 @@ class SeaLoaderApp(tk.Tk):
|
|||||||
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 = ttk.Button(bottom, text="Disable All Mods", command=self._on_disable_all)
|
||||||
self.disable_all_btn.pack(side=tk.LEFT, padx=(8, 0))
|
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 = ttk.Button(bottom, text="Subscribe Missing Mods", command=self._on_subscribe_missing)
|
||||||
self.subscribe_btn.pack(side=tk.RIGHT, padx=(0, 8))
|
self.subscribe_btn.pack(side=tk.RIGHT, padx=(0, 8))
|
||||||
@@ -344,10 +349,12 @@ class SeaLoaderApp(tk.Tk):
|
|||||||
link_lbl = ttk.Label(footer, text="Created by HudsonRiggs.Systems", style="TLabel", foreground=MUTED)
|
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.pack(side=tk.LEFT, padx=(6, 0))
|
||||||
link_lbl.bind("<Button-1>", lambda e: self._open_link("https://hudsonriggs.systems"))
|
link_lbl.bind("<Button-1>", lambda e: self._open_link("https://hudsonriggs.systems"))
|
||||||
# Update label (hidden by default)
|
# Version labels at bottom-right
|
||||||
self.update_var = tk.StringVar(value="")
|
self.version_lbl = ttk.Label(footer, text=f"v{__version__}", style="TLabel", foreground=MUTED)
|
||||||
self.update_lbl = ttk.Label(footer, textvariable=self.update_var, style="TLabel", foreground=ACCENT)
|
self.version_lbl.pack(side=tk.RIGHT)
|
||||||
self.update_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)
|
||||||
@@ -393,7 +400,7 @@ class SeaLoaderApp(tk.Tk):
|
|||||||
# Auto-load mods when path selected
|
# Auto-load mods when path selected
|
||||||
self._load_installed_mods()
|
self._load_installed_mods()
|
||||||
# Flash the URL box to guide next step
|
# Flash the URL box to guide next step
|
||||||
self._flash_widget(self.url_entry, entry=True)
|
self._start_flash(self.url_entry, entry=True)
|
||||||
# Persist selection
|
# Persist selection
|
||||||
try:
|
try:
|
||||||
self._save_game_path(path)
|
self._save_game_path(path)
|
||||||
@@ -445,7 +452,7 @@ class SeaLoaderApp(tk.Tk):
|
|||||||
def task():
|
def task():
|
||||||
try:
|
try:
|
||||||
base_ids = extract_required_item_ids(url)
|
base_ids = extract_required_item_ids(url)
|
||||||
ids = expand_required_ids_recursive(base_ids)
|
ids = 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]
|
||||||
@@ -463,8 +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)
|
||||||
# Flash Enable button to guide next step
|
# Stop flashing Fetch now that step is complete
|
||||||
self._maybe_flash_enable()
|
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)
|
||||||
|
|
||||||
@@ -501,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 = (
|
||||||
@@ -603,6 +623,10 @@ class SeaLoaderApp(tk.Tk):
|
|||||||
if not queue:
|
if not queue:
|
||||||
self.subscribe_btn.configure(state=tk.NORMAL)
|
self.subscribe_btn.configure(state=tk.NORMAL)
|
||||||
messagebox.showinfo("SeaLoader", "Finished opening subscriptions in Steam.")
|
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
|
return
|
||||||
mod_id = queue[0]
|
mod_id = queue[0]
|
||||||
remaining = len(queue) - 1
|
remaining = len(queue) - 1
|
||||||
@@ -643,14 +667,72 @@ class SeaLoaderApp(tk.Tk):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def _on_url_changed(self, *_args):
|
def _on_url_changed(self, *_args):
|
||||||
if not self._did_flash_fetch and self.url_var.get().strip():
|
if self.url_var.get().strip():
|
||||||
|
if not self._did_flash_fetch:
|
||||||
self._did_flash_fetch = True
|
self._did_flash_fetch = True
|
||||||
self._flash_widget(self.fetch_btn, entry=False)
|
# 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:
|
def _maybe_flash_enable(self) -> None:
|
||||||
if self.required_ids and not self._did_flash_enable:
|
if self.required_ids and not self._did_flash_enable:
|
||||||
self._did_flash_enable = True
|
self._did_flash_enable = True
|
||||||
self._flash_widget(self.enable_btn, entry=False)
|
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
|
# Simple storage for game path next to INI
|
||||||
def _storage_file_path(self) -> Path:
|
def _storage_file_path(self) -> Path:
|
||||||
@@ -674,26 +756,174 @@ class SeaLoaderApp(tk.Tk):
|
|||||||
def _check_for_updates_async(self) -> None:
|
def _check_for_updates_async(self) -> None:
|
||||||
try:
|
try:
|
||||||
import requests
|
import requests
|
||||||
|
import re
|
||||||
# Configure your Gitea server/repo here
|
# Configure your Gitea server/repo here
|
||||||
owner_repo = os.getenv("SEALOADER_REPO", "HudsonRiggs/SeaLoader")
|
owner_repo = os.getenv("SEALOADER_REPO", "HRiggs/SeaLoader")
|
||||||
server = os.getenv("SEALOADER_GITEA", "https://git.hudsonriggs.systems")
|
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)
|
latest_tag = None
|
||||||
if resp.status_code != 200:
|
release_url = None
|
||||||
return
|
|
||||||
|
# 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()
|
data = resp.json()
|
||||||
latest = data.get("tag_name") or data.get("name") or ""
|
latest_tag = (data.get("tag_name") or data.get("name") or "").strip()
|
||||||
if latest and latest != __version__:
|
release_url = data.get("html_url") or f"{server}/{owner_repo}/releases"
|
||||||
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:
|
except Exception:
|
||||||
pass
|
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')
|
||||||
|
pid_val = os.getpid()
|
||||||
|
target_val = current_exe.replace("'", "''")
|
||||||
|
source_val = new_exe_path.replace("'", "''")
|
||||||
|
ps = """
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$pidToWait = {pid}
|
||||||
|
$target = '{target}'.Replace("'","''")
|
||||||
|
$source = '{source}'.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
|
||||||
|
""".format(pid=pid_val, target=target_val, source=source_val)
|
||||||
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user