From b6382b2808bc88ac05ec64e2ea483fccb9fe163d Mon Sep 17 00:00:00 2001 From: HRiggs Date: Wed, 17 Sep 2025 00:11:10 -0400 Subject: [PATCH] Auto Update, Fix Versioning, Refresh Button and Better Guide --- sealoader_gui.py | 279 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 253 insertions(+), 26 deletions(-) diff --git a/sealoader_gui.py b/sealoader_gui.py index ec67514..7d4b59a 100644 --- a/sealoader_gui.py +++ b/sealoader_gui.py @@ -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("", 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("", 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/ + 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("", 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