feat(launcher): sync release assets from manifest or attachment list (no fixed exe name)
- default files []: resolve sync list from patch-manifest keys, else discover release attachments (exclude launcher artifacts). - Explicit files[] still overrides; strip deprecated Wow-patched.exe on merge. - listReleaseAttachmentNames + fetchGiteaReleaseRecord helpers. - Version 1.0.7; README config docs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -169,4 +169,5 @@ Schema is defined by **`default-launcher.json`** (shipped in the app; first run
|
|||||||
- **`gitea`**: **`base_url`**, **`owner`**, **`repo`**, **`release_tag`**, **`token_env`** — when **`base_url`** is set (and owner/repo set), **`from_release`** downloads and (with token if needed) the **generic** updater feed use **Gitea**. **Required** for players if your CI mirrors patches/launchers to Gitea only.
|
- **`gitea`**: **`base_url`**, **`owner`**, **`repo`**, **`release_tag`**, **`token_env`** — when **`base_url`** is set (and owner/repo set), **`from_release`** downloads and (with token if needed) the **generic** updater feed use **Gitea**. **Required** for players if your CI mirrors patches/launchers to Gitea only.
|
||||||
- **`github`**: used for **non-release** repo paths (`from_release`: false) and for **GitHub** **`from_release`** when **`gitea.base_url`** is empty.
|
- **`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).
|
- **`patch_manifest`**: **`enabled`**, **`source`** (default `patch-manifest.json`), **`from_release`** — checksum-based skip + verify (see above).
|
||||||
- **`files`**, **`realmlist`**, **`auth`**, **`launch`**.
|
- **`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/`**, one **`.exe`** → **`launch.exe`**. Multiple `.exe` attachments require a manifest. Legacy **`Wow-patched.exe`** entries are removed when merging **`launcher.json`**.
|
||||||
|
- **`realmlist`**, **`auth`**, **`launch`**.
|
||||||
|
|||||||
@@ -21,14 +21,7 @@
|
|||||||
"source": "patch-manifest.json",
|
"source": "patch-manifest.json",
|
||||||
"from_release": true
|
"from_release": true
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [],
|
||||||
{
|
|
||||||
"source": "Wow-patched.exe",
|
|
||||||
"dest": "Wow.exe",
|
|
||||||
"backup": true,
|
|
||||||
"from_release": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"realmlist": {
|
"realmlist": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"line": "set realmlist fracturedwow.ddns.net:47497",
|
"line": "set realmlist fracturedwow.ddns.net:47497",
|
||||||
|
|||||||
@@ -5,14 +5,16 @@ const fs = require('fs').promises;
|
|||||||
const { normalizeWinGameDir } = require('./win-game-dir');
|
const { normalizeWinGameDir } = require('./win-game-dir');
|
||||||
|
|
||||||
/** Sources no longer shipped; drop from merged files so old launcher.json does not keep fetching them. */
|
/** Sources no longer shipped; drop from merged files so old launcher.json does not keep fetching them. */
|
||||||
const DEPRECATED_FILE_SOURCES = new Set(['patch-Z.MPQ']);
|
const DEPRECATED_FILE_SOURCES = new Set(['patch-Z.MPQ', 'Wow-patched.exe']);
|
||||||
|
|
||||||
function mergeFilesList(defaults, user) {
|
function mergeFilesList(defaults, user) {
|
||||||
const raw =
|
const dep = (e) => DEPRECATED_FILE_SOURCES.has(String(e && e.source ? e.source : '').trim());
|
||||||
Array.isArray(user.files) && user.files.length ? user.files.map((e) => ({ ...e })) : defaults.files.map((e) => ({ ...e }));
|
if (Array.isArray(user.files) && user.files.length) {
|
||||||
const filtered = raw.filter((e) => !DEPRECATED_FILE_SOURCES.has(String(e && e.source ? e.source : '').trim()));
|
const filtered = user.files.map((e) => ({ ...e })).filter((e) => !dep(e));
|
||||||
if (filtered.length) return filtered;
|
if (filtered.length) return filtered;
|
||||||
return defaults.files.map((e) => ({ ...e }));
|
}
|
||||||
|
const defList = Array.isArray(defaults.files) ? defaults.files : [];
|
||||||
|
return defList.map((e) => ({ ...e })).filter((e) => !dep(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
function userFilesContainDeprecated(user) {
|
function userFilesContainDeprecated(user) {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function useGiteaReleases(cfg) {
|
|||||||
return !!(String(g.base_url || '').trim() && String(g.owner || '').trim() && String(g.repo || '').trim());
|
return !!(String(g.base_url || '').trim() && String(g.owner || '').trim() && String(g.repo || '').trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadGiteaReleaseAsset(cfg, assetName, destPath) {
|
async function fetchGiteaReleaseRecord(cfg) {
|
||||||
const api = giteaApiBase(cfg);
|
const api = giteaApiBase(cfg);
|
||||||
const { owner, repo } = cfg.gitea;
|
const { owner, repo } = cfg.gitea;
|
||||||
const tag = (cfg.gitea.release_tag || 'latest').trim() || 'latest';
|
const tag = (cfg.gitea.release_tag || 'latest').trim() || 'latest';
|
||||||
@@ -54,7 +54,18 @@ async function downloadGiteaReleaseAsset(cfg, assetName, destPath) {
|
|||||||
if (res.status === 401 || res.status === 403) hint = ' (set GITEA_TOKEN or gitea.token_env)';
|
if (res.status === 401 || res.status === 403) hint = ' (set GITEA_TOKEN or gitea.token_env)';
|
||||||
throw new Error(`Gitea release ${res.status}${hint}: ${text.slice(0, 600)}`);
|
throw new Error(`Gitea release ${res.status}${hint}: ${text.slice(0, 600)}`);
|
||||||
}
|
}
|
||||||
const rel = JSON.parse(text);
|
return JSON.parse(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listGiteaReleaseAttachmentNames(cfg) {
|
||||||
|
const rel = await fetchGiteaReleaseRecord(cfg);
|
||||||
|
const list = rel.attachments || rel.assets || [];
|
||||||
|
return list.map((x) => x.name).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadGiteaReleaseAsset(cfg, assetName, destPath) {
|
||||||
|
const token = giteaToken(cfg);
|
||||||
|
const rel = await fetchGiteaReleaseRecord(cfg);
|
||||||
const list = rel.attachments || rel.assets || [];
|
const list = rel.attachments || rel.assets || [];
|
||||||
let downloadUrl = '';
|
let downloadUrl = '';
|
||||||
for (const a of list) {
|
for (const a of list) {
|
||||||
@@ -101,6 +112,8 @@ async function getGiteaUpdaterFeedBase(cfg) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
downloadGiteaReleaseAsset,
|
downloadGiteaReleaseAsset,
|
||||||
|
fetchGiteaReleaseRecord,
|
||||||
|
listGiteaReleaseAttachmentNames,
|
||||||
giteaToken,
|
giteaToken,
|
||||||
useGiteaReleases,
|
useGiteaReleases,
|
||||||
getGiteaUpdaterFeedBase,
|
getGiteaUpdaterFeedBase,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const { githubToken } = require('./github-token');
|
const { githubToken } = require('./github-token');
|
||||||
const { downloadGiteaReleaseAsset, useGiteaReleases } = require('./gitea-release');
|
const { downloadGiteaReleaseAsset, useGiteaReleases, listGiteaReleaseAttachmentNames } = require('./gitea-release');
|
||||||
const { fetchToFile, downloadBodyToFile, fetchOrThrow } = require('./http-download');
|
const { fetchToFile, downloadBodyToFile, fetchOrThrow } = require('./http-download');
|
||||||
|
|
||||||
function encodeRepoPath(repoPath) {
|
function encodeRepoPath(repoPath) {
|
||||||
@@ -65,10 +65,7 @@ async function downloadGitHubRepoFile(cfg, repoPath, destPath) {
|
|||||||
throw new Error(`unexpected GitHub response for ${repoPath}`);
|
throw new Error(`unexpected GitHub response for ${repoPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadReleaseAsset(cfg, assetName, destPath) {
|
async function fetchGitHubReleaseJson(cfg) {
|
||||||
if (useGiteaReleases(cfg)) {
|
|
||||||
return downloadGiteaReleaseAsset(cfg, assetName, destPath);
|
|
||||||
}
|
|
||||||
const token = githubToken(cfg);
|
const token = githubToken(cfg);
|
||||||
const tag = (cfg.github.release_tag || 'latest').trim() || 'latest';
|
const tag = (cfg.github.release_tag || 'latest').trim() || 'latest';
|
||||||
const { owner, repo } = cfg.github;
|
const { owner, repo } = cfg.github;
|
||||||
@@ -89,7 +86,24 @@ async function downloadReleaseAsset(cfg, assetName, destPath) {
|
|||||||
if (res.status === 401 || res.status === 403) hint = ' (set GITHUB_TOKEN or token_env PAT)';
|
if (res.status === 401 || res.status === 403) hint = ' (set GITHUB_TOKEN or token_env PAT)';
|
||||||
throw new Error(`releases list ${res.status}${hint}: ${text.slice(0, 600)}`);
|
throw new Error(`releases list ${res.status}${hint}: ${text.slice(0, 600)}`);
|
||||||
}
|
}
|
||||||
const rel = JSON.parse(text);
|
return JSON.parse(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listReleaseAttachmentNames(cfg) {
|
||||||
|
if (useGiteaReleases(cfg)) {
|
||||||
|
return listGiteaReleaseAttachmentNames(cfg);
|
||||||
|
}
|
||||||
|
const rel = await fetchGitHubReleaseJson(cfg);
|
||||||
|
const assets = rel.assets || [];
|
||||||
|
return assets.map((a) => a.name).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadReleaseAsset(cfg, assetName, destPath) {
|
||||||
|
if (useGiteaReleases(cfg)) {
|
||||||
|
return downloadGiteaReleaseAsset(cfg, assetName, destPath);
|
||||||
|
}
|
||||||
|
const token = githubToken(cfg);
|
||||||
|
const rel = await fetchGitHubReleaseJson(cfg);
|
||||||
const assets = rel.assets || [];
|
const assets = rel.assets || [];
|
||||||
let assetURL = '';
|
let assetURL = '';
|
||||||
for (const a of assets) {
|
for (const a of assets) {
|
||||||
@@ -118,4 +132,10 @@ async function downloadReleaseAsset(cfg, assetName, destPath) {
|
|||||||
await downloadBodyToFile(dl, destPath);
|
await downloadBodyToFile(dl, destPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { downloadGitHubRepoFile, downloadReleaseAsset, encodeRepoPath };
|
module.exports = {
|
||||||
|
downloadGitHubRepoFile,
|
||||||
|
downloadReleaseAsset,
|
||||||
|
encodeRepoPath,
|
||||||
|
fetchGitHubReleaseJson,
|
||||||
|
listReleaseAttachmentNames,
|
||||||
|
};
|
||||||
|
|||||||
@@ -75,8 +75,10 @@ async function loadManifest(cfg) {
|
|||||||
*/
|
*/
|
||||||
async function patchesMatchManifest(cfg, manifest, onStatus) {
|
async function patchesMatchManifest(cfg, manifest, onStatus) {
|
||||||
if (!validateManifest(manifest)) return false;
|
if (!validateManifest(manifest)) return false;
|
||||||
|
const { buildResolvedReleaseFiles } = require('./release-sync');
|
||||||
|
const entries = await buildResolvedReleaseFiles(cfg, manifest);
|
||||||
const gameDir = cfg.game_dir;
|
const gameDir = cfg.game_dir;
|
||||||
for (const entry of cfg.files || []) {
|
for (const entry of entries) {
|
||||||
if (!entry.from_release) continue;
|
if (!entry.from_release) continue;
|
||||||
const spec = manifest.files[entry.source];
|
const spec = manifest.files[entry.source];
|
||||||
if (!spec || !spec.sha256) return false;
|
if (!spec || !spec.sha256) return false;
|
||||||
@@ -98,7 +100,9 @@ async function patchesMatchManifest(cfg, manifest, onStatus) {
|
|||||||
|
|
||||||
async function verifyInstalledAgainstManifest(cfg, manifest) {
|
async function verifyInstalledAgainstManifest(cfg, manifest) {
|
||||||
if (!validateManifest(manifest)) return;
|
if (!validateManifest(manifest)) return;
|
||||||
for (const entry of cfg.files || []) {
|
const { buildResolvedReleaseFiles } = require('./release-sync');
|
||||||
|
const entries = await buildResolvedReleaseFiles(cfg, manifest);
|
||||||
|
for (const entry of entries) {
|
||||||
if (!entry.from_release) continue;
|
if (!entry.from_release) continue;
|
||||||
const spec = manifest.files[entry.source];
|
const spec = manifest.files[entry.source];
|
||||||
if (!spec || !spec.sha256) {
|
if (!spec || !spec.sha256) {
|
||||||
@@ -119,8 +123,10 @@ async function verifyInstalledAgainstManifest(cfg, manifest) {
|
|||||||
|
|
||||||
async function recordPatchState(cfg, manifest) {
|
async function recordPatchState(cfg, manifest) {
|
||||||
if (!validateManifest(manifest)) return;
|
if (!validateManifest(manifest)) return;
|
||||||
|
const { buildResolvedReleaseFiles } = require('./release-sync');
|
||||||
|
const entries = await buildResolvedReleaseFiles(cfg, manifest);
|
||||||
const shas = {};
|
const shas = {};
|
||||||
for (const entry of cfg.files || []) {
|
for (const entry of entries) {
|
||||||
if (!entry.from_release) continue;
|
if (!entry.from_release) continue;
|
||||||
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
|
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
|
||||||
const destAbs = path.join(cfg.game_dir, ...parts);
|
const destAbs = path.join(cfg.game_dir, ...parts);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ const fs = require('fs').promises;
|
|||||||
const fsSync = require('fs');
|
const fsSync = require('fs');
|
||||||
const { downloadGitHubRepoFile, downloadReleaseAsset } = require('./github');
|
const { downloadGitHubRepoFile, downloadReleaseAsset } = require('./github');
|
||||||
const { normalizeWinGameDir } = require('./win-game-dir');
|
const { normalizeWinGameDir } = require('./win-game-dir');
|
||||||
|
const { loadManifest } = require('./patch-manifest');
|
||||||
|
const { buildResolvedReleaseFiles } = require('./release-sync');
|
||||||
|
|
||||||
function pad2(n) {
|
function pad2(n) {
|
||||||
return String(n).padStart(2, '0');
|
return String(n).padStart(2, '0');
|
||||||
@@ -94,7 +96,12 @@ async function applyRealmlist(cfg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function applyPatches(cfg, onStatus) {
|
async function applyPatches(cfg, onStatus) {
|
||||||
for (const f of cfg.files || []) {
|
let manifest = null;
|
||||||
|
if (cfg.patch_manifest && cfg.patch_manifest.enabled) {
|
||||||
|
manifest = await loadManifest(cfg);
|
||||||
|
}
|
||||||
|
const entries = await buildResolvedReleaseFiles(cfg, manifest);
|
||||||
|
for (const f of entries) {
|
||||||
if (onStatus) onStatus(`Updating ${f.dest} …`);
|
if (onStatus) onStatus(`Updating ${f.dest} …`);
|
||||||
try {
|
try {
|
||||||
await installFile(cfg, f);
|
await installFile(cfg, f);
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const { listReleaseAttachmentNames } = require('./github');
|
||||||
|
|
||||||
|
/** Legacy launcher.json rows — ignored when merging explicit files. */
|
||||||
|
const DEPRECATED_SOURCES = new Set(['patch-Z.MPQ', 'Wow-patched.exe']);
|
||||||
|
|
||||||
|
function filterExplicitFiles(files) {
|
||||||
|
if (!Array.isArray(files)) return [];
|
||||||
|
return files
|
||||||
|
.filter((e) => e && String(e.source || '').trim())
|
||||||
|
.filter((e) => !DEPRECATED_SOURCES.has(String(e.source).trim()))
|
||||||
|
.map((e) => ({
|
||||||
|
source: String(e.source).trim(),
|
||||||
|
dest: String(e.dest || '').trim(),
|
||||||
|
backup: e.backup !== false,
|
||||||
|
from_release: e.from_release !== false,
|
||||||
|
}))
|
||||||
|
.filter((e) => e.dest);
|
||||||
|
}
|
||||||
|
|
||||||
|
function manifestLooksUsable(m) {
|
||||||
|
return !!(m && m.files && typeof m.files === 'object' && Object.keys(m.files).length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Launcher / updater attachments — never copied into the WoW folder. */
|
||||||
|
function isExcludedFromGameSync(fileName) {
|
||||||
|
const n = String(fileName || '');
|
||||||
|
const lower = n.toLowerCase();
|
||||||
|
if (lower === 'patch-manifest.json') return true;
|
||||||
|
if (/^fractured-launcher/i.test(n)) return true;
|
||||||
|
if (/\.blockmap$/i.test(n)) return true;
|
||||||
|
if (/^latest.*\.ya?ml$/i.test(n) || lower === 'latest.yml') return true;
|
||||||
|
if (lower.includes('builder-debug')) return true;
|
||||||
|
if (/\.appimage$/i.test(n)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function destForReleaseSource(source, cfg) {
|
||||||
|
const base = path.basename(String(source || ''));
|
||||||
|
if (/\.mpq$/i.test(base)) return `Data/${base}`;
|
||||||
|
if (/\.exe$/i.test(base)) return (cfg.launch && cfg.launch.exe) || 'Wow.exe';
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit `files` in config wins. Otherwise use patch-manifest keys if present,
|
||||||
|
* else discover attachments on the release (excluding launcher artifacts).
|
||||||
|
*/
|
||||||
|
async function buildResolvedReleaseFiles(cfg, manifestMaybeNull) {
|
||||||
|
const explicit = filterExplicitFiles(cfg.files);
|
||||||
|
if (explicit.length) return explicit;
|
||||||
|
|
||||||
|
const manifest = manifestMaybeNull;
|
||||||
|
if (manifestLooksUsable(manifest)) {
|
||||||
|
const keys = Object.keys(manifest.files).filter((k) => k && !isExcludedFromGameSync(k));
|
||||||
|
if (!keys.length) {
|
||||||
|
throw new Error('patch-manifest.json has no file entries — add files or attach assets to the release.');
|
||||||
|
}
|
||||||
|
return keys.map((source) => ({
|
||||||
|
source,
|
||||||
|
dest: destForReleaseSource(source, cfg),
|
||||||
|
backup: true,
|
||||||
|
from_release: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = await listReleaseAttachmentNames(cfg);
|
||||||
|
const game = names.filter((n) => n && !isExcludedFromGameSync(n));
|
||||||
|
if (!game.length) {
|
||||||
|
throw new Error(
|
||||||
|
'No patch files on this release (after excluding launcher installers). ' +
|
||||||
|
'Attach MPQ/exe assets or ship patch-manifest.json listing filenames.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exes = game.filter((n) => /\.exe$/i.test(n));
|
||||||
|
const mpqs = game.filter((n) => /\.mpq$/i.test(n));
|
||||||
|
const rest = game.filter((n) => !/\.(exe|mpq)$/i.test(n));
|
||||||
|
|
||||||
|
if (exes.length > 1) {
|
||||||
|
throw new Error(
|
||||||
|
`Release has multiple .exe files (${exes.join(', ')}). ` +
|
||||||
|
'Remove extras or publish patch-manifest.json with the exact filenames to install.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
for (const n of mpqs) {
|
||||||
|
out.push({
|
||||||
|
source: n,
|
||||||
|
dest: path.posix.join('Data', path.basename(n)),
|
||||||
|
backup: true,
|
||||||
|
from_release: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (exes.length === 1) {
|
||||||
|
out.push({
|
||||||
|
source: exes[0],
|
||||||
|
dest: (cfg.launch && cfg.launch.exe) || 'Wow.exe',
|
||||||
|
backup: true,
|
||||||
|
from_release: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const n of rest) {
|
||||||
|
out.push({
|
||||||
|
source: n,
|
||||||
|
dest: path.basename(n),
|
||||||
|
backup: true,
|
||||||
|
from_release: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildResolvedReleaseFiles,
|
||||||
|
filterExplicitFiles,
|
||||||
|
isExcludedFromGameSync,
|
||||||
|
DEPRECATED_SOURCES,
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fractured-launcher-electron",
|
"name": "fractured-launcher-electron",
|
||||||
"version": "1.0.6",
|
"version": "1.0.7",
|
||||||
"description": "Fractured WoW launcher (Electron) — no console window, native folder picker, auto-update",
|
"description": "Fractured WoW launcher (Electron) — no console window, native folder picker, auto-update",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
Reference in New Issue
Block a user