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

This commit is contained in:
2025-09-17 00:11:10 -04:00
parent 5e68076bbb
commit b6382b2808

View File

@@ -226,6 +226,7 @@ class SeaLoaderApp(tk.Tk):
self._icon_img = None
self._did_flash_fetch = False
self._did_flash_enable = False
self._flash_states = {}
self._style()
self._layout()
@@ -257,6 +258,8 @@ class SeaLoaderApp(tk.Tk):
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
@@ -328,6 +331,8 @@ class SeaLoaderApp(tk.Tk):
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.subscribe_btn = ttk.Button(bottom, text="Subscribe Missing Mods", command=self._on_subscribe_missing)
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.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)
# 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]):
tree = ttk.Treeview(parent, columns=columns, show="headings", height=12)
@@ -393,7 +400,7 @@ class SeaLoaderApp(tk.Tk):
# 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)
self._start_flash(self.url_entry, entry=True)
# Persist selection
try:
self._save_game_path(path)
@@ -463,8 +470,10 @@ 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()
# 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)
@@ -501,6 +510,17 @@ class SeaLoaderApp(tk.Tk):
else:
msg_lines.append("All required mods were found.")
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:
ini_hint = (
@@ -603,6 +623,10 @@ class SeaLoaderApp(tk.Tk):
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
@@ -643,14 +667,72 @@ class SeaLoaderApp(tk.Tk):
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)
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._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
def _storage_file_path(self) -> Path:
@@ -674,26 +756,171 @@ class SeaLoaderApp(tk.Tk):
def _check_for_updates_async(self) -> None:
try:
import requests
import re
# 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")
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)
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:
ini_path = DEFAULT_INI_PATH