diff --git a/.github/workflows/distro-release-sync.yml b/.github/workflows/distro-release-sync.yml index f8e8d16..6bcd5cc 100644 --- a/.github/workflows/distro-release-sync.yml +++ b/.github/workflows/distro-release-sync.yml @@ -74,8 +74,6 @@ jobs: if (Test-Path tools/fractured-launcher-electron/dist/latest.yml) { Copy-Item tools/fractured-launcher-electron/dist/latest.yml launcher-publish/ } - Get-ChildItem tools/fractured-launcher-electron/dist/*.blockmap -ErrorAction SilentlyContinue | - Copy-Item -Destination launcher-publish/ - uses: actions/upload-artifact@v4 with: @@ -87,6 +85,13 @@ jobs: if: github.repository == 'Dawnforger/Fractured' runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + sparse-checkout: | + tools/fractured-launcher-electron/scripts + sparse-checkout-cone-mode: true + - uses: actions/download-artifact@v4 with: name: electron-dist @@ -97,6 +102,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail + . tools/fractured-launcher-electron/scripts/release-sync-filters.sh TAG="${{ needs.meta.outputs.tag }}" mkdir -p combined mkdir -p /tmp/from-main @@ -104,6 +110,11 @@ jobs: shopt -s nullglob for f in /tmp/from-main/*; do if [ -f "$f" ]; then + bn=$(basename "$f") + if should_skip_merge_from_github "$bn"; then + echo "Skipping GitHub release asset (CI launcher or excluded): $bn" + continue + fi cp -f "$f" combined/ fi done diff --git a/.github/workflows/fractured-launcher-ci.yml b/.github/workflows/fractured-launcher-ci.yml index ff97f58..8efba9c 100644 --- a/.github/workflows/fractured-launcher-ci.yml +++ b/.github/workflows/fractured-launcher-ci.yml @@ -49,7 +49,6 @@ jobs: path: | tools/fractured-launcher-electron/dist/*.exe tools/fractured-launcher-electron/dist/latest.yml - tools/fractured-launcher-electron/dist/*.blockmap electron-launcher-linux: runs-on: ubuntu-latest @@ -77,5 +76,5 @@ jobs: if-no-files-found: warn path: | tools/fractured-launcher-electron/dist/*.AppImage - tools/fractured-launcher-electron/dist/*.yml - tools/fractured-launcher-electron/dist/*.blockmap + tools/fractured-launcher-electron/dist/latest.yml + tools/fractured-launcher-electron/dist/latest-linux.yml diff --git a/.github/workflows/gitea-release-sync.yml b/.github/workflows/gitea-release-sync.yml index fae7fee..9ac9f09 100644 --- a/.github/workflows/gitea-release-sync.yml +++ b/.github/workflows/gitea-release-sync.yml @@ -104,6 +104,7 @@ jobs: working-directory: tools/fractured-launcher-electron run: | npm ci + node -p "'Launcher package.json version: ' + require('./package.json').version" npm run pack:win - name: Stage launcher files for upload @@ -114,8 +115,6 @@ jobs: if (Test-Path tools/fractured-launcher-electron/dist/latest.yml) { Copy-Item tools/fractured-launcher-electron/dist/latest.yml launcher-publish/ } - Get-ChildItem tools/fractured-launcher-electron/dist/*.blockmap -ErrorAction SilentlyContinue | - Copy-Item -Destination launcher-publish/ - uses: actions/upload-artifact@v4 with: @@ -150,6 +149,7 @@ jobs: working-directory: tools/fractured-launcher-electron run: | npm ci + node -p "'Launcher package.json version: ' + require('./package.json').version" npm run pack:linux - name: Stage Linux launcher for upload @@ -159,8 +159,9 @@ jobs: mkdir -p launcher-linux-publish shopt -s nullglob cp -f tools/fractured-launcher-electron/dist/*.AppImage launcher-linux-publish/ 2>/dev/null || true - cp -f tools/fractured-launcher-electron/dist/*.yml launcher-linux-publish/ 2>/dev/null || true - cp -f tools/fractured-launcher-electron/dist/*.blockmap launcher-linux-publish/ 2>/dev/null || true + for f in tools/fractured-launcher-electron/dist/latest.yml tools/fractured-launcher-electron/dist/latest-linux.yml; do + if [ -f "$f" ]; then cp -f "$f" launcher-linux-publish/; fi + done ls -la launcher-linux-publish/ if ! compgen -G "launcher-linux-publish/*.AppImage" > /dev/null; then echo "No AppImage under dist/ — electron-builder linux target failed" >&2 @@ -206,6 +207,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail + . tools/fractured-launcher-electron/scripts/release-sync-filters.sh TAG="${{ needs.meta.outputs.tag }}" mkdir -p combined mkdir -p /tmp/from-main @@ -213,6 +215,11 @@ jobs: shopt -s nullglob for f in /tmp/from-main/*; do if [ -f "$f" ]; then + bn=$(basename "$f") + if should_skip_merge_from_github "$bn"; then + echo "Skipping GitHub release asset (CI launcher or excluded): $bn" + continue + fi cp -f "$f" combined/ fi done diff --git a/tools/fractured-launcher-electron/README.md b/tools/fractured-launcher-electron/README.md index 1fd9546..f1d7a63 100644 --- a/tools/fractured-launcher-electron/README.md +++ b/tools/fractured-launcher-electron/README.md @@ -88,7 +88,7 @@ CI workflow **Sync release to Gitea** (`.github/workflows/gitea-release-sync.yml 1. Triggers on **release published** on **`Dawnforger/Fractured`** (or **workflow_dispatch** with a tag). 2. Builds **Windows** (NSIS + portable) and **Linux** (AppImage) in parallel, each using **`tools/fractured-launcher-electron` from the default branch** (overlaid onto the tag checkout), so older release tags never ship a launcher missing new **`lib/*.js`** files. 3. Downloads **all assets** attached to that **GitHub** release (MPQs, patched `Wow.exe`, etc.). -4. Merges with the built launcher artifacts and uploads everything to a **Gitea release** with the **same tag** (existing attachments on that Gitea release are replaced). +4. Merges with the built launcher artifacts and uploads to a **Gitea release** with the **same tag** (existing attachments on that Gitea release are replaced). **Launcher installers** attached on GitHub (**`Fractured-Launcher*`**, case-insensitive) are **not** merged — the **CI build from the default branch** is the only source of launcher binaries, so an old installer on the GitHub release cannot “stick” on Gitea next to a newer build. **`*.blockmap`** and **`builder-debug.yml`** are omitted from the merge and from Gitea uploads. **GitHub Actions secrets** (repository → Settings → Secrets and variables → Actions): @@ -127,6 +127,9 @@ CI workflow **Sync release to Gitea** (`.github/workflows/gitea-release-sync.yml 8. **HTTP 422 `repo is empty`** — The Gitea repo has **no commits** yet. Push any initial commit (e.g. **Add README** in the Gitea web UI, or `git push` to **`main`**). Optionally set **`GITEA_TARGET_REF`** to match your real default branch if it is not **`main`**. From this repo you can run **`scripts/bootstrap-gitea-repo.sh`** (see script header for `GITEA_*` env or pass the HTTPS/SSH clone URL as the first argument). 9. **`sync Wow.exe: fetch failed`** — Often **HTTPS/TLS** to Gitea; use **`http://…`** in **`lib/baked-gitea-channel.js`** if you only serve plain HTTP, or fix certs / **`NODE_EXTRA_CA_CERTS`**. Ensure **`Wow-patched.exe`** exists on the release (**`release_tag`**: `latest` vs pinned). Errors include the failing URL when possible. 10. **Wine + Windows portable** — If the folder picker returns **`/home/...`**, the launcher maps it to **`Z:\home\...`** (Wine’s Unix root). **`Wow.exe`** is matched case-insensitively for Linux-backed folders. Re-save the WoW folder after upgrading if validation still fails. +11. **`EBUSY` / file locked on Windows** — The client (or antivirus) may keep **`.MPQ`** files open. The launcher **retries** for a short window and downloads the new file **before** replacing the old one; if sync still fails, **exit WoW** (and any tool previewing that folder) and run **Download updates** again. +12. **`.bak-*` clutter** — When **Download updates** finishes without error, the launcher removes matching **`*.bak-YYYYMMDD-HHmmss`** files from earlier runs (same pattern it uses when replacing files). Failed syncs do not delete backups. +13. **Gitea still shows an old launcher version** — The sync workflow overlays **`tools/fractured-launcher-electron` from the default branch**, so **`package.json`** there defines the built version. Ensure launcher changes are **merged to `main`** before publishing the GitHub release (or re-run **Actions → Sync release to Gitea** after merging). Previously, **Fractured-Launcher\*** files **attached on the GitHub release** were merged too, which could leave **two** installer versions on Gitea; those assets are now skipped in favor of CI-only builds. ### Private Gitea token for players @@ -170,4 +173,4 @@ Schema is defined by **`default-launcher.json`** (shipped in the app; first run - **`github`**: used for **non-release** repo paths (`from_release`: false) and for **GitHub** **`from_release`** when **`gitea.base_url`** is empty. - **`patch_manifest`**: **`enabled`**, **`source`** (default `patch-manifest.json`), **`from_release`** — checksum-based skip + verify (see above). - **`files`**: default **`[]`**. **Download updates** resolves what to pull in order: (**1**) non-empty **`files`** if you set explicit **`source`** → **`dest`** pairs; (**2**) else each key in **`patch-manifest.json`** on the release (recommended); (**3**) else release attachments except launcher artifacts (`Fractured-Launcher*`, `*.blockmap`, `latest*.yml`, `.AppImage`, `patch-manifest.json`): **`.MPQ`** → **`Data/enUS/.MPQ`** (extension forced to **`.MPQ`** caps for client compatibility), one **`.exe`** → **`launch.exe`**. Multiple `.exe` attachments require a manifest. Legacy **`Wow-patched.exe`** entries are removed when merging **`launcher.json`**. -- **`realmlist`**, **`auth`**, **`launch`**. +- **`realmlist`**, **`auth`**, **`launch`** (`**exe**`, **`args`**). Only **`Data/enUS/realmlist.wtf`** is written: any **`realmlist.paths`** entry that is not the enUS file is ignored (so **`enGB`** is never created). On **Linux**, **Play** never runs `Wow.exe` as a native process (that yields **EACCES**). Use **`launch.linux_wrapper`** (default **`["wine"]`**) so the launcher runs e.g. **`wine /path/Wow.exe` …`args`**, or set **`launch.linux_steam_uri`** to a Steam URI (e.g. **`steam://rungameid/…`** for a **non-Steam game** shortcut — the number is shown in Steam’s shortcut properties). Optional **`linux_steam_binary`** defaults to **`steam`**; for Flatpak Steam use **`linux_steam_spawn`**: **`["flatpak", "run", "com.valvesoftware.Steam"]`** (the URI is appended as the last argument). After a **successful** **Download updates** run, the launcher deletes prior **`*.bak-YYYYMMDD-HHmmss`** backup files it created under the WoW folder. diff --git a/tools/fractured-launcher-electron/default-launcher.json b/tools/fractured-launcher-electron/default-launcher.json index 5554fc0..55d847a 100644 --- a/tools/fractured-launcher-electron/default-launcher.json +++ b/tools/fractured-launcher-electron/default-launcher.json @@ -25,7 +25,7 @@ "realmlist": { "enabled": true, "line": "set realmlist fracturedwow.ddns.net:47497", - "paths": ["Data/enUS/realmlist.wtf", "Data/enGB/realmlist.wtf"] + "paths": ["Data/enUS/realmlist.wtf"] }, "auth": { "enabled": false, @@ -37,6 +37,9 @@ "launch": { "exe": "Wow.exe", "args": [], - "linux_wrapper": ["wine"] + "linux_wrapper": ["wine"], + "linux_steam_uri": "", + "linux_steam_binary": "", + "linux_steam_spawn": [] } } diff --git a/tools/fractured-launcher-electron/lib/patch.js b/tools/fractured-launcher-electron/lib/patch.js index 3bd4c3b..283f04f 100644 --- a/tools/fractured-launcher-electron/lib/patch.js +++ b/tools/fractured-launcher-electron/lib/patch.js @@ -16,6 +16,42 @@ function backupSuffix() { return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`; } +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +/** Windows often returns EBUSY/EPERM when WoW or AV still has an MPQ open. */ +function isRetryableFsLockError(e) { + const c = e && e.code; + if (!c) return false; + if (c === 'EBUSY' || c === 'EPERM' || c === 'EACCES') return true; + if (process.platform === 'win32' && (c === 'UNKNOWN' || c === 'EUNKNOWN')) return true; + return false; +} + +async function retryFsLock(op, opts) { + const attempts = (opts && opts.attempts) || (process.platform === 'win32' ? 30 : 10); + const delayMs = (opts && opts.delayMs) || 500; + let last; + for (let i = 0; i < attempts; i++) { + try { + return await op(); + } catch (e) { + last = e; + if (!isRetryableFsLockError(e)) throw e; + if (i === attempts - 1) break; + await sleep(delayMs); + } + } + const hint = + process.platform === 'win32' + ? ' Close World of Warcraft and any launcher using this folder, then try again.' + : ' Close programs using this file, then try again.'; + const err = new Error(String((last && last.message) || last) + hint); + err.code = last && last.code; + throw err; +} + function wowExePath(cfg) { const gd = normalizeWinGameDir(cfg.game_dir || ''); const exe = (cfg.launch && cfg.launch.exe) || 'Wow.exe'; @@ -51,34 +87,88 @@ function normalizeMpqDestinationPath(absPath) { return /\.mpq$/i.test(s) ? s.replace(/\.mpq$/i, '.MPQ') : s; } +/** Matches backup names from installFile: `.bak-YYYYMMDD-HHmmss`. */ +const LAUNCHER_BACKUP_BASENAME_RE = /\.bak-\d{8}-\d{6}$/; + +async function removeLauncherBackupFiles(gameDir) { + const root = normalizeWinGameDir(gameDir || ''); + if (!root) return; + const stack = [root]; + while (stack.length) { + const dir = stack.pop(); + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch (_) { + continue; + } + for (const d of entries) { + const abs = path.join(dir, d.name); + if (d.isDirectory()) { + stack.push(abs); + } else if (d.isFile() && LAUNCHER_BACKUP_BASENAME_RE.test(d.name)) { + try { + await fs.unlink(abs); + } catch (e) { + if (e.code !== 'ENOENT') { + /* best effort: sync already succeeded */ + } + } + } + } + } +} + +function isEnUsRealmlistPath(rel) { + const n = String(rel || '') + .trim() + .replace(/\\/g, '/') + .toLowerCase(); + return n.endsWith('/enus/realmlist.wtf') || n === 'enus/realmlist.wtf'; +} + async function installFile(cfg, entry) { const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean); const root = normalizeWinGameDir(cfg.game_dir || ''); const destAbs = normalizeMpqDestinationPath(path.join(root, ...parts)); - if (entry.backup) { - try { - const st = await fs.stat(destAbs); - if (st.isFile()) { - const bak = `${destAbs}.bak-${backupSuffix()}`; - await fs.rename(destAbs, bak); - } - } catch (e) { - if (e.code !== 'ENOENT') throw e; - } - } else { - try { - await fs.unlink(destAbs); - } catch (e) { - if (e.code !== 'ENOENT') throw e; - } - } const tmp = destAbs + '.new'; + if (entry.from_release) { await downloadReleaseAsset(cfg, entry.source, tmp); } else { await downloadGitHubRepoFile(cfg, entry.source, tmp); } - await fs.rename(tmp, destAbs); + + async function removeOrBackupExisting() { + if (entry.backup) { + try { + const st = await fs.stat(destAbs); + if (st.isFile()) { + const bak = `${destAbs}.bak-${backupSuffix()}`; + await fs.rename(destAbs, bak); + } + } catch (e) { + if (e.code !== 'ENOENT') throw e; + } + } else { + try { + await fs.unlink(destAbs); + } catch (e) { + if (e.code !== 'ENOENT') throw e; + } + } + } + + await retryFsLock(() => removeOrBackupExisting()); + await retryFsLock(() => fs.rename(tmp, destAbs)); + + if (process.platform === 'linux' && /\.exe$/i.test(destAbs)) { + try { + await fs.chmod(destAbs, 0o755); + } catch (_) { + /* non-fatal */ + } + } } async function applyRealmlist(cfg) { @@ -91,6 +181,8 @@ async function applyRealmlist(cfg) { const content = line + '\n'; let paths = cfg.realmlist.paths; if (!paths || !paths.length) paths = ['Data/enUS/realmlist.wtf']; + paths = paths.filter(isEnUsRealmlistPath); + if (!paths.length) paths = ['Data/enUS/realmlist.wtf']; for (const rel of paths) { const r = String(rel).trim().replace(/\\/g, '/'); if (!r) continue; @@ -119,6 +211,12 @@ async function applyPatches(cfg, onStatus) { if (onStatus) onStatus('Applying realmlist …'); await applyRealmlist(cfg); } + if (onStatus) onStatus('Removing old backup copies …'); + try { + await removeLauncherBackupFiles(cfg.game_dir); + } catch (_) { + /* Patches and realmlist already applied; leave .bak files if cleanup cannot run. */ + } if (onStatus) onStatus('All patches applied.'); } diff --git a/tools/fractured-launcher-electron/main.js b/tools/fractured-launcher-electron/main.js index 50860a3..080924f 100644 --- a/tools/fractured-launcher-electron/main.js +++ b/tools/fractured-launcher-electron/main.js @@ -145,9 +145,44 @@ ipcMain.handle('launcher:checkUpdates', async () => { ipcMain.handle('launcher:play', async () => { const { config } = await readMergedConfig(); const exe = wowExePath(config); - const args = (config.launch && config.launch.args) || []; - const child = spawn(exe, args, { - cwd: config.game_dir, + const gameArgs = (config.launch && config.launch.args) || []; + const lc = config.launch || {}; + const cwd = config.game_dir; + + let cmd; + let spawnArgs; + if (process.platform === 'linux') { + const steamUri = String(lc.linux_steam_uri || '').trim(); + const steamSpawn = Array.isArray(lc.linux_steam_spawn) ? lc.linux_steam_spawn.filter(Boolean) : []; + if (steamUri) { + if (steamSpawn.length) { + cmd = steamSpawn[0]; + spawnArgs = [...steamSpawn.slice(1), steamUri]; + } else { + const bin = String(lc.linux_steam_binary || 'steam').trim() || 'steam'; + cmd = bin; + spawnArgs = [steamUri]; + } + } else { + const wrap = Array.isArray(lc.linux_wrapper) ? lc.linux_wrapper.filter(Boolean) : []; + if (wrap.length) { + cmd = wrap[0]; + spawnArgs = [...wrap.slice(1), exe, ...gameArgs]; + } else { + throw new Error( + 'On Linux, Wow.exe is a Windows program and cannot be run directly. ' + + 'Set launch.linux_steam_uri (e.g. steam://rungameid/… for your Steam shortcut) ' + + 'or launch.linux_wrapper (e.g. ["wine"]) in launcher.json.' + ); + } + } + } else { + cmd = exe; + spawnArgs = gameArgs; + } + + const child = spawn(cmd, spawnArgs, { + cwd, detached: true, stdio: 'ignore', windowsHide: true, diff --git a/tools/fractured-launcher-electron/package.json b/tools/fractured-launcher-electron/package.json index be7b63d..cd4faf7 100644 --- a/tools/fractured-launcher-electron/package.json +++ b/tools/fractured-launcher-electron/package.json @@ -1,6 +1,6 @@ { "name": "fractured-launcher-electron", - "version": "1.0.9", + "version": "1.0.12", "description": "Fractured WoW launcher (Electron) — no console window, native folder picker, auto-update", "main": "main.js", "repository": { diff --git a/tools/fractured-launcher-electron/scripts/release-sync-filters.sh b/tools/fractured-launcher-electron/scripts/release-sync-filters.sh new file mode 100644 index 0000000..a06c159 --- /dev/null +++ b/tools/fractured-launcher-electron/scripts/release-sync-filters.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Shared filters for GitHub → Gitea / distro release merges and Gitea uploads. +# shellcheck shell=bash + +# Skip when copying assets from `gh release download` into combined/: CI-built launcher is authoritative. +should_skip_merge_from_github() { + local l + l=$(printf '%s' "${1##*/}" | tr '[:upper:]' '[:lower:]') + case "$l" in + fractured-launcher*) return 0 ;; + *.blockmap) return 0 ;; + builder-debug.yml|builder-debug.yaml) return 0 ;; + esac + return 1 +} + +# Skip when POSTing attachments to Gitea (belt-and-suspenders if something slips into combined/). +should_skip_gitea_upload() { + local l + l=$(printf '%s' "${1##*/}" | tr '[:upper:]' '[:lower:]') + case "$l" in + *.blockmap) return 0 ;; + builder-debug.yml|builder-debug.yaml) return 0 ;; + esac + return 1 +} diff --git a/tools/fractured-launcher-electron/scripts/upload-release-to-gitea.sh b/tools/fractured-launcher-electron/scripts/upload-release-to-gitea.sh index 631de33..59123e2 100644 --- a/tools/fractured-launcher-electron/scripts/upload-release-to-gitea.sh +++ b/tools/fractured-launcher-electron/scripts/upload-release-to-gitea.sh @@ -11,6 +11,10 @@ # set -euo pipefail +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=release-sync-filters.sh +. "$SCRIPT_DIR/release-sync-filters.sh" + COMBINED_DIR="${1:?first arg: directory of files to attach}" TAG="${2:?second arg: release tag (e.g. v1.0.0)}" @@ -67,12 +71,6 @@ if [ -z "$rel_id" ] || [ "$rel_id" = "null" ]; then exit 1 fi -while read -r aid; do - [ -z "$aid" ] || [ "$aid" = "null" ] && continue - curl -fsS -X DELETE "${AUTH_H[@]}" \ - "$API/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/${rel_id}/assets/${aid}" || true -done < <(jq -r '(.attachments // .assets // [])[] | .id' "$REL_JSON") - shopt -s nullglob files=("$COMBINED_DIR"/*) if [ "${#files[@]}" -eq 0 ]; then @@ -80,12 +78,39 @@ if [ "${#files[@]}" -eq 0 ]; then exit 1 fi +uploadable=0 for f in "${files[@]}"; do [ -f "$f" ] || continue - echo "Uploading $(basename "$f") …" + bn=$(basename "$f") + if should_skip_gitea_upload "$bn"; then + continue + fi + uploadable=$((uploadable + 1)) +done +if [ "$uploadable" -eq 0 ]; then + echo "No files to upload after exclusions (check $COMBINED_DIR) — not clearing Gitea attachments." >&2 + exit 1 +fi + +while read -r aid; do + [ -z "$aid" ] || [ "$aid" = "null" ] && continue + curl -fsS -X DELETE "${AUTH_H[@]}" \ + "$API/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/${rel_id}/assets/${aid}" || true +done < <(jq -r '(.attachments // .assets // [])[] | .id' "$REL_JSON") + +uploaded=0 +for f in "${files[@]}"; do + [ -f "$f" ] || continue + bn=$(basename "$f") + if should_skip_gitea_upload "$bn"; then + echo "Skipping upload (excluded): $bn" + continue + fi + echo "Uploading $bn …" curl -fsS -X POST "${AUTH_H[@]}" \ -F "attachment=@${f}" \ "$API/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/${rel_id}/assets" + uploaded=$((uploaded + 1)) done -echo "Gitea release $TAG (id=$rel_id) updated with ${#files[@]} file(s)." +echo "Gitea release $TAG (id=$rel_id) updated with $uploaded file(s)."