feat(launcher): Linux play wrapper, patch UX, Gitea sync cleanup

- Play on Linux: use launch.linux_wrapper (wine) or linux_steam_uri; chmod .exe after install
- Windows: retry EBUSY on MPQ replace; download to .new before rename
- After successful sync: remove .bak-* backups; realmlist only Data/enUS (ignore enGB)
- Gitea/distro merge: skip Fractured-Launcher* from GitHub assets (CI default-branch build wins)
- Omit blockmap and builder-debug from staged artifacts and Gitea uploads; upload script validates before clearing attachments
- README and launcher version 1.0.12

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Docker Build
2026-05-10 23:20:15 -05:00
parent 8ad6a2aca3
commit f88a303327
10 changed files with 250 additions and 43 deletions
+13 -2
View File
@@ -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
+2 -3
View File
@@ -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
+11 -4
View File
@@ -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
+5 -2
View File
@@ -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\...`** (Wines 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/<name>.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 Steams 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.
@@ -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": []
}
}
+116 -18
View File
@@ -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: `<orig>.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.');
}
+38 -3
View File
@@ -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,
@@ -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": {
@@ -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
}
@@ -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)."