Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .github/workflows/build-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: Build Check

on:
pull_request:
branches:
- main

jobs:
build:
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Read version
id: version
shell: bash
run: |
VERSION=$(cat VERSION | tr -d '[:space:]')
echo "version=$VERSION" >> $GITHUB_OUTPUT

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.14'

- name: Install dependencies
run: pip install -r requirements.txt

- name: Build exe with PyInstaller
run: python build/build.py

- name: Verify exe exists
shell: bash
run: |
if [ -f "dist/FromSoftModManager/FromSoftModManager.exe" ]; then
echo "✅ PyInstaller build successful"
else
echo "❌ FromSoftModManager.exe not found"
exit 1
fi

- name: Install Inno Setup
run: choco install innosetup -y --no-progress

- name: Compile installer
run: iscc "/DAppVersion=${{ steps.version.outputs.version }}" build\installer.iss

- name: Verify installer exists
shell: bash
run: |
INSTALLER="dist/FromSoftModManager_Setup_v${{ steps.version.outputs.version }}.exe"
if [ -f "$INSTALLER" ]; then
echo "✅ Installer build successful"
else
echo "❌ Installer not found at $INSTALLER"
exit 1
fi
45 changes: 44 additions & 1 deletion .github/workflows/create-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Read version
id: version
Expand All @@ -25,6 +27,47 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=v$VERSION" >> $GITHUB_OUTPUT

- name: Generate release notes from commits
id: notes
shell: bash
run: |
# Find the previous tag
PREV_TAG=$(git tag --sort=-v:refname | head -n 1)
if [ -z "$PREV_TAG" ]; then
RANGE="HEAD"
else
RANGE="${PREV_TAG}..HEAD"
fi
echo "Previous tag: $PREV_TAG"
echo "Range: $RANGE"

# Build release notes from commit titles and descriptions
NOTES="## What's Changed"$'\n\n'
while IFS= read -r line; do
if [ -z "$line" ]; then
continue
fi
# Split at first | to get hash, title, and body
HASH=$(echo "$line" | cut -d'|' -f1)
TITLE=$(echo "$line" | cut -d'|' -f2)
BODY=$(echo "$line" | cut -d'|' -f3-)

NOTES+="- **${TITLE}** (\`${HASH}\`)"$'\n'
if [ -n "$BODY" ]; then
# Indent each line of the body
while IFS= read -r bline; do
bline=$(echo "$bline" | sed 's/^[[:space:]]*//')
if [ -n "$bline" ]; then
NOTES+=" ${bline}"$'\n'
fi
done <<< "$BODY"
fi
NOTES+=$'\n'
done < <(git log "$RANGE" --pretty=format:"%h|%s|%b" --no-merges)

# Write to file to avoid escaping issues
echo "$NOTES" > release_notes.md

- name: Set up Python
uses: actions/setup-python@v5
with:
Expand Down Expand Up @@ -55,7 +98,7 @@ jobs:
name: Release v${{ steps.version.outputs.version }}
draft: false
prerelease: false
generate_release_notes: true
body_path: release_notes.md
files: |
dist/FromSoftModManager_Setup_v${{ steps.version.outputs.version }}.exe
dist/FromSoftModManager_v${{ steps.version.outputs.version }}_portable.zip
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mods/

# Build artifacts
build/output/
build/_version.iss
build_pyinstaller/
dist/
*.spec
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.0.2
2.0.3
29 changes: 28 additions & 1 deletion app/config/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,47 @@
"""

import os
import sys
import json
from datetime import datetime
from pathlib import Path

APP_NAME = "FromSoftModManager"
_APP_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

# When running as a PyInstaller build, __file__ resolves inside _internal/
# which is wrong for user data. Use the exe directory instead so that
# config.json and mods/ live next to the exe and persist across updates.
if getattr(sys, "frozen", False):
_APP_DIR = os.path.dirname(sys.executable)
else:
_APP_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

CONFIG_FILE = os.path.join(_APP_DIR, "config.json")
_DEFAULT_MODS_DIR = os.path.join(_APP_DIR, "mods")


class ConfigManager:
def __init__(self):
self._migrate_legacy_config()
self._config = self._load()

# ------------------------------------------------------------------
# Migration
# ------------------------------------------------------------------
@staticmethod
def _migrate_legacy_config():
"""Move config.json out of _internal/ if it was created there by an
older build. Only relevant for PyInstaller builds."""
if not getattr(sys, "frozen", False):
return
legacy = os.path.join(os.path.dirname(sys.executable), "_internal", "config.json")
if os.path.isfile(legacy) and not os.path.isfile(CONFIG_FILE):
try:
import shutil
shutil.move(legacy, CONFIG_FILE)
except OSError:
pass

# ------------------------------------------------------------------
# Low-level load / save
# ------------------------------------------------------------------
Expand Down
151 changes: 151 additions & 0 deletions app/services/update_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""
App self-update service — check GitHub releases and download the installer.
"""

import os
import sys
import subprocess
import tempfile
import urllib.request

GITHUB_API = "https://api.github.com/repos/spikehockey75/FromSoftModManager/releases/latest"
USER_AGENT = "FromSoftModManager/2.0"


def get_current_version() -> str:
"""Read the app version from the bundled VERSION file."""
if getattr(sys, "frozen", False):
base = os.path.join(sys._MEIPASS)
else:
base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
version_file = os.path.join(base, "VERSION")
try:
with open(version_file, "r", encoding="utf-8") as f:
return f.read().strip()
except Exception:
return "0.0.0"


def _parse_version(v: str) -> tuple:
"""Convert 'X.Y.Z' to a comparable tuple."""
try:
return tuple(int(x) for x in v.lstrip("v").split("."))
except (ValueError, AttributeError):
return (0, 0, 0)


def get_latest_release() -> dict:
"""Fetch the latest release info from GitHub.

Returns {"version", "download_url", "name"} on success,
or {"error": str} on failure.
"""
try:
import json
req = urllib.request.Request(
GITHUB_API,
headers={
"User-Agent": USER_AGENT,
"Accept": "application/vnd.github+json",
},
)
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode())

tag = data.get("tag_name", "")
assets = data.get("assets", [])

# Prefer the Setup installer exe
installer = next(
(a for a in assets if "Setup" in a["name"] and a["name"].endswith(".exe")),
None,
)
if not installer:
# Fall back to any exe or zip
installer = next(
(a for a in assets if a["name"].endswith((".exe", ".zip"))),
None,
)

return {
"version": tag.lstrip("v"),
"download_url": installer["browser_download_url"] if installer else "",
"name": installer["name"] if installer else "",
}
except Exception as e:
return {"error": str(e)}


def check_for_update() -> dict:
"""Compare current version with latest GitHub release.

Returns {"has_update", "current", "latest", "download_url"}.
On error returns {"has_update": False, "error": str}.
"""
current = get_current_version()
release = get_latest_release()

if "error" in release:
return {"has_update": False, "current": current, "error": release["error"]}

latest = release.get("version", "")
has_update = _parse_version(latest) > _parse_version(current)

return {
"has_update": has_update,
"current": current,
"latest": latest,
"download_url": release.get("download_url", ""),
"name": release.get("name", ""),
}


def download_and_run_installer(download_url: str, progress_callback=None) -> dict:
"""Download the installer exe and launch it.

progress_callback(message: str, percent: int)
Returns {"success": bool, "message": str}.
"""
if not download_url:
return {"success": False, "message": "No download URL available"}

if progress_callback:
progress_callback("Downloading update…", 5)

tmp_dir = tempfile.mkdtemp(prefix="fsmm_update_")
filename = download_url.rsplit("/", 1)[-1] or "FromSoftModManager_Setup.exe"
installer_path = os.path.join(tmp_dir, filename)

try:
req = urllib.request.Request(download_url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=120) as resp, \
open(installer_path, "wb") as f:
total = int(resp.headers.get("Content-Length", 0))
downloaded = 0
while True:
chunk = resp.read(65536)
if not chunk:
break
f.write(chunk)
downloaded += len(chunk)
if total and progress_callback:
pct = 5 + int((downloaded / total) * 85)
progress_callback(
f"Downloading… {downloaded // 1024}KB / {total // 1024}KB", pct
)
except Exception as e:
return {"success": False, "message": f"Download failed: {e}"}

if progress_callback:
progress_callback("Launching installer…", 95)

try:
# Launch the installer detached — it will close this app via CloseApplications
subprocess.Popen(
[installer_path],
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
if sys.platform == "win32" else 0,
)
return {"success": True, "message": "Installer launched"}
except Exception as e:
return {"success": False, "message": f"Could not launch installer: {e}"}
12 changes: 9 additions & 3 deletions app/ui/dialogs/add_mod_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,9 +267,15 @@ def _on_premium_fallback(self, mod_name: str, nexus_url: str):
self._progress_panel.setVisible(False)
webbrowser.open(nexus_url)
self._error_lbl.setText(
"Nexus Premium is required for direct API downloads.\n"
"The mod page has been opened in your browser — download "
"the file manually, then select it below."
"Free Nexus account — direct downloads require Premium.\n"
"The mod page has been opened in your browser.\n\n"
"Steps:\n"
" 1. Click the FILES tab on the Nexus page\n"
" 2. Click \"Manual Download\" on the file you want\n"
" 3. Wait for the download to finish\n"
" 4. Use the \"Install from ZIP\" section below to\n"
" browse to the downloaded .zip / .7z / .rar file\n"
" (usually in your Downloads folder)"
)
self._error_lbl.setStyleSheet("font-size:11px;color:#ff9800;")
self._error_lbl.setVisible(True)
Expand Down
5 changes: 3 additions & 2 deletions app/ui/dialogs/me3_setup_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ def _build(self):

# Icon + title row
title_row = QHBoxLayout()
icon_lbl = QLabel("⚙")
icon_lbl.setStyleSheet("font-size:32px;")
icon_lbl = QLabel("\uE713")
icon_lbl.setFont(QFont("Segoe MDL2 Assets", 24))
icon_lbl.setStyleSheet("color:#e0e0ec;")
title_row.addWidget(icon_lbl)

title = QLabel("Mod Engine 3 Required")
Expand Down
Loading