Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ad6a2aca3 | |||
| 36ac3dbd1d | |||
| 24d1ae71d9 | |||
| 9cef99f0ff | |||
| f409ffad12 | |||
| c1f7eaa153 | |||
| b455db0db8 | |||
| 1fb284cb5c | |||
| ebd8d81924 | |||
| 362084b829 | |||
| 656cf2d07d | |||
| bfe51f6ad4 | |||
| 2a3107a78d | |||
| 48826e21d6 | |||
| 15c476c12d | |||
| 6c4d7244c3 | |||
| 9fb80102c8 | |||
| 7028258084 | |||
| 5966eb0ffc | |||
| 90c8db0b04 | |||
| 9240bf1243 | |||
| 88f8dcb0e7 | |||
| 9cb3c79dbe | |||
| 75e3b59442 | |||
| 030c2307c2 | |||
| 27d54f15a2 | |||
| 5e18c2b766 | |||
| 1c85341b1f |
@@ -22,7 +22,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
electron-launcher:
|
||||
electron-launcher-windows:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 45
|
||||
defaults:
|
||||
@@ -50,3 +50,32 @@ jobs:
|
||||
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
|
||||
timeout-minutes: 45
|
||||
defaults:
|
||||
run:
|
||||
working-directory: tools/fractured-launcher-electron
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: npm
|
||||
cache-dependency-path: tools/fractured-launcher-electron/package-lock.json
|
||||
|
||||
- name: Install and pack (AppImage)
|
||||
run: |
|
||||
npm ci
|
||||
npm run pack:linux
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fractured-launcher-electron-linux-${{ github.run_id }}
|
||||
if-no-files-found: warn
|
||||
path: |
|
||||
tools/fractured-launcher-electron/dist/*.AppImage
|
||||
tools/fractured-launcher-electron/dist/*.yml
|
||||
tools/fractured-launcher-electron/dist/*.blockmap
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
# Primary path for player-facing binaries: every *published* GitHub Release on this repo
|
||||
# is mirrored to your self-hosted Gitea (same tag). No public GitHub distro repo.
|
||||
#
|
||||
# Triggers:
|
||||
# - release: published / released → GitHub “Release” (not a raw git tag alone).
|
||||
# - workflow_dispatch → Actions → this workflow → “Run workflow” (enter tag).
|
||||
#
|
||||
# Troubleshooting: “Re-run failed jobs” on an OLD run replays the *original* workflow
|
||||
# YAML (e.g. still runs `npm run pack:win` without --publish never). After changing this
|
||||
# file on default branch, start a *new* run via “Run workflow”, not Re-run on a pre-fix run.
|
||||
#
|
||||
# Important: pushing only a git tag does NOT run this — you must create/publish a
|
||||
# Release on github.com (Releases → Draft/new release → Publish). The workflow
|
||||
# definition must exist on the repo DEFAULT branch (GitHub runs it from there).
|
||||
#
|
||||
# Steps: Windows (NSIS+portable) + Linux (AppImage) in parallel, launcher from DEFAULT BRANCH
|
||||
# overlay on tag checkout → merge with GitHub release assets → upload all to Gitea.
|
||||
#
|
||||
# Secrets: GITEA_BASE_URL, GITEA_TOKEN, GITEA_OWNER, GITEA_REPO
|
||||
# Optional variable: GITEA_TARGET_REF (see tools/fractured-launcher-electron/README.md)
|
||||
#
|
||||
# Job guard: edit `if:` if github.repository is not Dawnforger/Fractured.
|
||||
|
||||
name: Sync release to Gitea
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published, released]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Git tag only (e.g. v0.7.11-paragon-foo). NOT the release title — open the release and copy the tag next to the title.'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: gitea-release-sync-${{ github.repository }}-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.event.release.tag_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
meta:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'Dawnforger/Fractured'
|
||||
outputs:
|
||||
tag: ${{ steps.t.outputs.tag }}
|
||||
steps:
|
||||
- name: Resolve tag
|
||||
id: t
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
RAW="${{ github.event.inputs.tag }}"
|
||||
else
|
||||
RAW="${{ github.event.release.tag_name }}"
|
||||
fi
|
||||
TAG="$(printf '%s' "$RAW" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
if [ -z "$TAG" ]; then
|
||||
echo '::error::Tag input is empty. Paste the git tag (e.g. v0.7.11-…).'
|
||||
exit 1
|
||||
fi
|
||||
if printf '%s' "$TAG" | grep -q '[[:space:]]'; then
|
||||
echo '::error::Tag contains whitespace — that is usually the **release title**, not the tag. On GitHub → Releases → open the release → copy the **tag** (short ref like v0.7.11-…), not the long title line.'
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
{
|
||||
echo "tag<<__TAG_EOF__"
|
||||
echo "$TAG"
|
||||
echo "__TAG_EOF__"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-electron:
|
||||
needs: meta
|
||||
if: github.repository == 'Dawnforger/Fractured'
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.meta.outputs.tag }}
|
||||
|
||||
# Release tags often point at server/game commits that predate launcher lib fixes.
|
||||
# Always pack the launcher from default branch so app.asar includes the full tree.
|
||||
- name: Overlay launcher from default branch
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DB="${{ github.event.repository.default_branch }}"
|
||||
git fetch --no-tags --depth=1 origin "+refs/heads/${DB}:refs/remotes/origin/${DB}"
|
||||
git checkout "origin/${DB}" -- tools/fractured-launcher-electron
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: npm
|
||||
cache-dependency-path: tools/fractured-launcher-electron/package-lock.json
|
||||
|
||||
- name: Install and pack (NSIS + portable)
|
||||
working-directory: tools/fractured-launcher-electron
|
||||
run: |
|
||||
npm ci
|
||||
npm run pack:win
|
||||
|
||||
- name: Stage launcher files for upload
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path launcher-publish | Out-Null
|
||||
Copy-Item tools/fractured-launcher-electron/dist/*.exe launcher-publish/
|
||||
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:
|
||||
name: electron-dist-windows
|
||||
path: launcher-publish/
|
||||
|
||||
build-electron-linux:
|
||||
needs: meta
|
||||
if: github.repository == 'Dawnforger/Fractured'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.meta.outputs.tag }}
|
||||
|
||||
- name: Overlay launcher from default branch
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DB="${{ github.event.repository.default_branch }}"
|
||||
git fetch --no-tags --depth=1 origin "+refs/heads/${DB}:refs/remotes/origin/${DB}"
|
||||
git checkout "origin/${DB}" -- tools/fractured-launcher-electron
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: npm
|
||||
cache-dependency-path: tools/fractured-launcher-electron/package-lock.json
|
||||
|
||||
- name: Install and pack (AppImage)
|
||||
working-directory: tools/fractured-launcher-electron
|
||||
run: |
|
||||
npm ci
|
||||
npm run pack:linux
|
||||
|
||||
- name: Stage Linux launcher for upload
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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
|
||||
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
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: electron-dist-linux
|
||||
path: launcher-linux-publish/
|
||||
|
||||
sync-gitea:
|
||||
needs: [meta, build-electron, build-electron-linux]
|
||||
if: github.repository == 'Dawnforger/Fractured'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITEA_BASE_URL: ${{ secrets.GITEA_BASE_URL }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_OWNER: ${{ secrets.GITEA_OWNER }}
|
||||
GITEA_REPO: ${{ secrets.GITEA_REPO }}
|
||||
GITEA_TARGET_REF: ${{ vars.GITEA_TARGET_REF }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Script may not exist on older release tags; always use default branch.
|
||||
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-windows
|
||||
path: /tmp/electron-win
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: electron-dist-linux
|
||||
path: /tmp/electron-linux
|
||||
|
||||
- name: Merge GitHub release assets + Electron build
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ needs.meta.outputs.tag }}"
|
||||
mkdir -p combined
|
||||
mkdir -p /tmp/from-main
|
||||
if gh release download "$TAG" -R "${{ github.repository }}" -D /tmp/from-main 2>/tmp/dl.err; then
|
||||
shopt -s nullglob
|
||||
for f in /tmp/from-main/*; do
|
||||
if [ -f "$f" ]; then
|
||||
cp -f "$f" combined/
|
||||
fi
|
||||
done
|
||||
echo "Merged assets from ${{ github.repository }} release $TAG"
|
||||
else
|
||||
echo "GitHub release download note (continuing with launcher only):"
|
||||
cat /tmp/dl.err || true
|
||||
fi
|
||||
shopt -s nullglob
|
||||
for f in /tmp/electron-win/* /tmp/electron-linux/*; do
|
||||
if [ -f "$f" ]; then
|
||||
cp -f "$f" combined/
|
||||
fi
|
||||
done
|
||||
ls -la combined/
|
||||
|
||||
- name: Upload to Gitea
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for v in GITEA_BASE_URL GITEA_TOKEN GITEA_OWNER GITEA_REPO; do
|
||||
if [ -z "${!v:-}" ]; then
|
||||
echo "Missing secret $v — add it under repo Settings → Secrets and variables → Actions." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
bash tools/fractured-launcher-electron/scripts/upload-release-to-gitea.sh combined "${{ needs.meta.outputs.tag }}"
|
||||
@@ -21,6 +21,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(10, 1),
|
||||
(17, 1),
|
||||
(53, 1),
|
||||
(66, 1),
|
||||
(72, 1),
|
||||
(75, 1),
|
||||
(78, 1),
|
||||
@@ -30,6 +31,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(118, 1),
|
||||
(120, 1),
|
||||
(122, 1),
|
||||
(126, 1),
|
||||
(130, 1),
|
||||
(131, 1),
|
||||
(132, 1),
|
||||
@@ -52,6 +54,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(469, 1),
|
||||
(475, 1),
|
||||
(498, 1),
|
||||
(526, 1),
|
||||
(527, 1),
|
||||
(528, 1),
|
||||
(543, 1),
|
||||
@@ -73,22 +76,28 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(676, 1),
|
||||
(686, 1),
|
||||
(687, 1),
|
||||
(688, 1),
|
||||
(689, 1),
|
||||
(691, 1),
|
||||
(693, 1),
|
||||
(694, 1),
|
||||
(697, 1),
|
||||
(698, 1),
|
||||
(702, 1),
|
||||
(703, 1),
|
||||
(706, 1),
|
||||
(710, 1),
|
||||
(712, 1),
|
||||
(740, 1),
|
||||
(755, 1),
|
||||
(759, 1),
|
||||
(768, 1),
|
||||
(770, 1),
|
||||
(772, 1),
|
||||
(774, 1),
|
||||
(779, 1),
|
||||
(781, 1),
|
||||
(783, 1),
|
||||
(845, 1),
|
||||
(853, 1),
|
||||
(871, 1),
|
||||
@@ -104,6 +113,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(1038, 1),
|
||||
(1044, 1),
|
||||
(1064, 1),
|
||||
(1066, 1),
|
||||
(1079, 1),
|
||||
(1082, 1),
|
||||
(1098, 1),
|
||||
@@ -176,6 +186,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(2812, 1),
|
||||
(2825, 1),
|
||||
(2893, 1),
|
||||
(2894, 1),
|
||||
(2908, 1),
|
||||
(2912, 1),
|
||||
(2944, 1),
|
||||
@@ -194,6 +205,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(3565, 1),
|
||||
(3566, 1),
|
||||
(3567, 1),
|
||||
(3714, 1),
|
||||
(3738, 1),
|
||||
(4987, 1),
|
||||
(5116, 1),
|
||||
@@ -205,7 +217,9 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(5185, 1),
|
||||
(5209, 1),
|
||||
(5211, 1),
|
||||
(5215, 1),
|
||||
(5215, 1);
|
||||
|
||||
INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(5217, 1),
|
||||
(5221, 1),
|
||||
(5225, 1),
|
||||
@@ -215,11 +229,10 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(5308, 1),
|
||||
(5384, 1),
|
||||
(5484, 1),
|
||||
(5487, 1),
|
||||
(5500, 1),
|
||||
(5502, 1),
|
||||
(5504, 1);
|
||||
|
||||
INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(5504, 1),
|
||||
(5675, 1),
|
||||
(5676, 1),
|
||||
(5697, 1),
|
||||
@@ -244,6 +257,8 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(6770, 1),
|
||||
(6785, 1),
|
||||
(6789, 1),
|
||||
(6795, 1),
|
||||
(6807, 1),
|
||||
(6940, 1),
|
||||
(7294, 1),
|
||||
(7302, 1),
|
||||
@@ -283,6 +298,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(11418, 1),
|
||||
(11419, 1),
|
||||
(11420, 1),
|
||||
(12051, 1),
|
||||
(13159, 1),
|
||||
(13161, 1),
|
||||
(13163, 1),
|
||||
@@ -297,6 +313,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(16857, 1),
|
||||
(16914, 1),
|
||||
(18499, 1),
|
||||
(19263, 1),
|
||||
(19740, 1),
|
||||
(19742, 1),
|
||||
(19746, 1),
|
||||
@@ -323,7 +340,6 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(20252, 1),
|
||||
(20484, 1),
|
||||
(20736, 1),
|
||||
(21084, 1),
|
||||
(21562, 1),
|
||||
(21849, 1),
|
||||
(22568, 1),
|
||||
@@ -331,6 +347,8 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(22812, 1),
|
||||
(22842, 1),
|
||||
(23028, 1),
|
||||
(23161, 1),
|
||||
(23214, 1),
|
||||
(23920, 1),
|
||||
(23922, 1),
|
||||
(24275, 1),
|
||||
@@ -349,6 +367,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(29722, 1),
|
||||
(29858, 1),
|
||||
(29893, 1),
|
||||
(30449, 1),
|
||||
(30451, 1),
|
||||
(30455, 1),
|
||||
(30482, 1),
|
||||
@@ -372,12 +391,14 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(33745, 1),
|
||||
(33763, 1),
|
||||
(33786, 1),
|
||||
(33943, 1),
|
||||
(34026, 1),
|
||||
(34074, 1),
|
||||
(34428, 1),
|
||||
(34433, 1),
|
||||
(34477, 1),
|
||||
(34600, 1),
|
||||
(34767, 1),
|
||||
(35715, 1),
|
||||
(35717, 1),
|
||||
(36936, 1),
|
||||
@@ -398,7 +419,9 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(47541, 1),
|
||||
(47568, 1),
|
||||
(47897, 1),
|
||||
(48018, 1),
|
||||
(48018, 1);
|
||||
|
||||
INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(48020, 1),
|
||||
(48045, 1),
|
||||
(48263, 1),
|
||||
@@ -416,14 +439,19 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(49576, 1),
|
||||
(49998, 1),
|
||||
(50464, 1),
|
||||
(50769, 1),
|
||||
(50842, 1),
|
||||
(51505, 1),
|
||||
(51514, 1),
|
||||
(51722, 1),
|
||||
(51723, 1),
|
||||
(52610, 1);
|
||||
|
||||
INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(51730, 1),
|
||||
(52127, 1),
|
||||
(52610, 1),
|
||||
(53140, 1),
|
||||
(53142, 1),
|
||||
(53271, 1),
|
||||
(53351, 1),
|
||||
(53407, 1),
|
||||
(53408, 1),
|
||||
(53600, 1),
|
||||
@@ -432,15 +460,23 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(54428, 1),
|
||||
(55342, 1),
|
||||
(55694, 1),
|
||||
(56222, 1),
|
||||
(56641, 1),
|
||||
(56815, 1),
|
||||
(57330, 1),
|
||||
(57755, 1),
|
||||
(57934, 1),
|
||||
(57994, 1),
|
||||
(60192, 1),
|
||||
(61846, 1),
|
||||
(61999, 1),
|
||||
(62078, 1),
|
||||
(62124, 1),
|
||||
(62757, 1),
|
||||
(64382, 1),
|
||||
(64843, 1);
|
||||
(64843, 1),
|
||||
(64901, 1),
|
||||
(66842, 1),
|
||||
(66843, 1),
|
||||
(66844, 1);
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
-- mod-paragon: backfill paragon_spell_ae_cost rows for spells newly exposed
|
||||
-- by the Character Advancement panel after removing the over-aggressive
|
||||
-- ClassMask=0 filter from tools/_gen_paragon_advancement_spells_lua.py.
|
||||
--
|
||||
-- The base file (data/sql/db-world/base/paragon_spell_ae_cost.sql) was
|
||||
-- regenerated alongside this migration so fresh deployments already have
|
||||
-- these rows. Existing servers do not re-run base files on content change,
|
||||
-- so this update inserts the new (spell_id, ae_cost) pairs idempotently.
|
||||
-- INSERT IGNORE keeps any per-row tuning a server operator may have already
|
||||
-- applied to spell_ids that happen to overlap.
|
||||
--
|
||||
-- New ids include: 51505 Lava Burst (Shaman), 12051 Evocation / 1066 Aqueous
|
||||
-- Form / Hex / Mage Ward / Spellsteal (Mage), 53351 Kill Shot / 19263
|
||||
-- Deterrence / 53271 Master's Call (Hunter), 3714 Path of Frost / 57330
|
||||
-- Horn of Winter / 56815 Rune Strike / 61999 Raise Ally / 56222 Dark Command
|
||||
-- (DK), and 39 other trainer-taught class abilities whose stock
|
||||
-- SkillLineAbility.dbc rows have ClassMask=0 (the skill line itself pins the
|
||||
-- class for these rows; ClassMask is redundant on class-spec lines).
|
||||
|
||||
INSERT IGNORE INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(66, 1), -- Invisibility (Mage)
|
||||
(126, 1), -- Eye of Kilrogg (Warlock)
|
||||
(526, 1), -- Cure Toxins (Shaman)
|
||||
(688, 1), -- Summon Imp (Warlock)
|
||||
(691, 1), -- Summon Felhunter (Warlock)
|
||||
(697, 1), -- Summon Voidwalker (Warlock)
|
||||
(712, 1), -- Summon Succubus (Warlock)
|
||||
(768, 1), -- Cat Form (Druid)
|
||||
(783, 1), -- Travel Form (Druid)
|
||||
(1066, 1), -- Aqueous Form (Mage)
|
||||
(2894, 1), -- Fire Resistance Totem (Shaman)
|
||||
(3714, 1), -- Path of Frost (DK)
|
||||
(5215, 1), -- Prowl (Druid)
|
||||
(5487, 1), -- Bear Form (Druid)
|
||||
(5504, 1), -- Conjure Refreshment (Mage)
|
||||
(6795, 1), -- Growl (Druid)
|
||||
(6807, 1), -- Maul (Druid)
|
||||
(12051, 1), -- Evocation (Mage)
|
||||
(19263, 1), -- Deterrence (Hunter)
|
||||
(23161, 1), -- Summon Dreadsteed (Warlock)
|
||||
(23214, 1), -- Summon Charger (Paladin)
|
||||
(30449, 1), -- Spellsteal (Mage)
|
||||
(33943, 1), -- Flight Form (Druid)
|
||||
(34767, 1), -- Summon Felguard (Warlock)
|
||||
(48018, 1), -- Demonic Circle: Summon (Warlock)
|
||||
(50769, 1), -- Revive (Druid)
|
||||
(51505, 1), -- Lava Burst (Shaman)
|
||||
(51514, 1), -- Hex (Shaman)
|
||||
(51730, 1), -- Earthliving Weapon (Shaman)
|
||||
(52127, 1), -- Water Shield (Shaman)
|
||||
(52610, 1), -- Savage Roar (Druid)
|
||||
(53271, 1), -- Master's Call (Hunter)
|
||||
(53351, 1), -- Kill Shot (Hunter)
|
||||
(56222, 1), -- Dark Command (DK)
|
||||
(56815, 1), -- Rune Strike (DK)
|
||||
(57330, 1), -- Horn of Winter (DK)
|
||||
(61999, 1), -- Raise Ally (DK)
|
||||
(64843, 1), -- Divine Hymn (Priest)
|
||||
(64901, 1), -- Hymn of Hope (Priest)
|
||||
(66842, 1), -- Call of the Elements (Shaman totem set)
|
||||
(66843, 1), -- Call of the Ancestors (Shaman totem set)
|
||||
(66844, 1); -- Call of the Spirits (Shaman totem set)
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+336
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env bash
|
||||
# Collect VPS evidence for Paragon / DBUpdater / binary staleness triage.
|
||||
# Run ON the VPS (Linux). Safe: read-only; does not restart services.
|
||||
#
|
||||
# Usage (from clone):
|
||||
# bash scripts/vps-paragon-diagnostics.sh
|
||||
#
|
||||
# Optional environment:
|
||||
# FRACTURED_REPO — absolute path to Fractured git root (default: parent of scripts/)
|
||||
# FRACTURED_WS_BIN — path to worldserver binary (default: auto-detect)
|
||||
# FRACTURED_WORLDSERVER_CONF — path to worldserver.conf (default: guess from BIN + common layouts)
|
||||
# FRACTURED_SYSTEMD_UNITS — space-separated units to try (default: "fractured-world worldserver ac-worldserver")
|
||||
# FRACTURED_MYSQL — prefix to invoke mysql, e.g. 'mysql -uacore -pacore -h127.0.0.1'
|
||||
# (default Fractured local DB user/password are often both "acore"; use ~/.my.cnf if you prefer not to pass -p on the command line)
|
||||
# If unset, SQL blocks are printed for manual copy-paste only.
|
||||
# FRACTURED_SPELL_IDS — space-separated spell IDs for spell_dbc spot-check (defaults to common DK rune spenders)
|
||||
# FRACTURED_DIAG_OUTPUT — full log file path (default: <repo>/var/vps-paragon-diagnostics-last.txt)
|
||||
#
|
||||
# All output is mirrored to the log file (tee) while still printing to the terminal.
|
||||
# Default path lives under var/ (gitignored in this repo). Open that file in Cursor,
|
||||
# scp it down, or: git add -f var/vps-paragon-diagnostics-last.txt if you intend to commit it.
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO="${FRACTURED_REPO:-$(cd "$SCRIPT_DIR/.." && pwd)}"
|
||||
|
||||
DIAG_OUT="${FRACTURED_DIAG_OUTPUT:-$REPO/var/vps-paragon-diagnostics-last.txt}"
|
||||
mkdir -p "$(dirname "$DIAG_OUT")"
|
||||
exec > >(tee "$DIAG_OUT") 2>&1
|
||||
echo "Logging to: $DIAG_OUT"
|
||||
|
||||
hr() { printf '\n%s\n' "================================================================================"; }
|
||||
sub() { printf '\n-- %s\n' "$1"; }
|
||||
|
||||
detect_worldserver_bin() {
|
||||
local bin="" es path u units
|
||||
if [[ -n "${FRACTURED_WS_BIN:-}" ]]; then
|
||||
readlink -f "$FRACTURED_WS_BIN" 2>/dev/null && return
|
||||
echo "$FRACTURED_WS_BIN"
|
||||
return
|
||||
fi
|
||||
|
||||
units="${FRACTURED_SYSTEMD_UNITS:-fractured-world worldserver ac-worldserver}"
|
||||
for u in $units; do
|
||||
if systemctl is-active --quiet "$u" 2>/dev/null || systemctl is-enabled --quiet "$u" 2>/dev/null; then
|
||||
es=$(systemctl show "$u" -p ExecStart --value 2>/dev/null || true)
|
||||
if [[ -n "$es" ]]; then
|
||||
if [[ "$es" == \{*path=* ]]; then
|
||||
path=$(printf '%s' "$es" | sed -n 's/.*path=\([^;]*\).*/\1/p')
|
||||
else
|
||||
path=$(printf '%s' "$es" | awk '{print $1}' | sed 's/^path=//')
|
||||
fi
|
||||
if [[ -n "$path" && -x "$path" ]]; then
|
||||
readlink -f "$path" 2>/dev/null && return
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
local pid
|
||||
pid=$(pgrep -xo worldserver 2>/dev/null || true)
|
||||
if [[ -n "$pid" ]]; then
|
||||
readlink -f "/proc/$pid/exe" 2>/dev/null && return
|
||||
fi
|
||||
|
||||
if command -v worldserver >/dev/null 2>&1; then
|
||||
readlink -f "$(command -v worldserver)" 2>/dev/null && return
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
guess_worldserver_conf() {
|
||||
local bin="$1"
|
||||
local d cands=()
|
||||
[[ -z "$bin" ]] && return
|
||||
d=$(dirname "$bin")
|
||||
cands+=("$d/../etc/worldserver.conf")
|
||||
cands+=("$d/../../etc/worldserver.conf")
|
||||
cands+=("$HOME/azeroth-server/etc/worldserver.conf")
|
||||
cands+=("$HOME/env/dist/etc/worldserver.conf")
|
||||
for f in "${cands[@]}"; do
|
||||
f=$(readlink -f "$f" 2>/dev/null || true)
|
||||
if [[ -n "$f" && -f "$f" ]]; then
|
||||
echo "$f"
|
||||
return
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
}
|
||||
|
||||
binary_strings_paths() {
|
||||
local ws="$1"
|
||||
[[ -z "$ws" || ! -f "$ws" ]] && return
|
||||
strings "$ws" 2>/dev/null | grep -iE '/(home|root|opt|srv|var)[^[:space:]]*/(Fractured|fractured|azeroth|AzerothCore|acore)' | sort -u | head -40
|
||||
}
|
||||
|
||||
hr
|
||||
echo "Fractured Paragon / native VPS diagnostics"
|
||||
echo "Date (UTC): $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo "Repo (expected): $REPO"
|
||||
|
||||
sub "1A — worldserver binary"
|
||||
WS=$(detect_worldserver_bin || true)
|
||||
if [[ -z "$WS" ]]; then
|
||||
echo "ERROR: Could not find worldserver. Set FRACTURED_WS_BIN=/full/path/to/worldserver and re-run."
|
||||
else
|
||||
echo "Binary: $WS"
|
||||
if stat -c 'binary mtime: %y' "$WS" 2>/dev/null; then
|
||||
:
|
||||
else
|
||||
stat -f 'binary mtime: %Sm' -t '%Y-%m-%d %H:%M:%S %z' "$WS" 2>/dev/null || stat "$WS"
|
||||
fi
|
||||
fi
|
||||
|
||||
sub "1B — repo HEAD + Paragon_Essence.cpp mtime"
|
||||
if [[ -d "$REPO/.git" ]]; then
|
||||
(cd "$REPO" && git log -1 --format='HEAD commit: %h %ci %s')
|
||||
else
|
||||
echo "WARN: not a git repo: $REPO (set FRACTURED_REPO)"
|
||||
fi
|
||||
PE="$REPO/modules/mod-paragon/src/Paragon_Essence.cpp"
|
||||
if [[ -f "$PE" ]]; then
|
||||
if stat -c 'Paragon_Essence.cpp mtime: %y' "$PE" 2>/dev/null; then
|
||||
:
|
||||
else
|
||||
stat -f 'Paragon_Essence.cpp mtime: %Sm' -t '%Y-%m-%d %H:%M:%S %z' "$PE" 2>/dev/null || stat "$PE"
|
||||
fi
|
||||
else
|
||||
echo "WARN: missing $PE"
|
||||
fi
|
||||
|
||||
sub "1C — strings heuristics (0 can mean stripped binary — use 1A+1B)"
|
||||
if [[ -n "$WS" && -f "$WS" ]]; then
|
||||
c1=$(strings "$WS" 2>/dev/null | grep -c 'CLASS_PARAGON' || true)
|
||||
c2=$(strings "$WS" 2>/dev/null | grep -c 'C BUILD SAVE_CURRENT' || true)
|
||||
c3=$(strings "$WS" 2>/dev/null | grep -c 'character_paragon_build_share_archive' || true)
|
||||
echo "CLASS_PARAGON count: $c1"
|
||||
echo "C BUILD SAVE_CURRENT count: $c2"
|
||||
echo "character_paragon_build_share_archive count: $c3"
|
||||
else
|
||||
echo "(skipped — no binary)"
|
||||
fi
|
||||
|
||||
sub "1D — binary fingerprint (compare sha256 across dev vs VPS)"
|
||||
if [[ -n "$WS" && -f "$WS" ]]; then
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$WS"
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$WS"
|
||||
else
|
||||
echo "(no sha256sum — install coreutils)"
|
||||
fi
|
||||
echo "Embedded revision / version strings (first matches):"
|
||||
strings "$WS" 2>/dev/null | grep -iE 'azerothcore|revision|git|commit|build.*20[0-9]{2}' | head -25 || echo "(none matched)"
|
||||
else
|
||||
echo "(skipped — no binary)"
|
||||
fi
|
||||
|
||||
CONF="${FRACTURED_WORLDSERVER_CONF:-}"
|
||||
if [[ -z "$CONF" && -n "$WS" ]]; then
|
||||
CONF=$(guess_worldserver_conf "$WS")
|
||||
fi
|
||||
|
||||
sub "2B — worldserver.conf (updater / source / rates / paragon)"
|
||||
if [[ -n "$CONF" && -f "$CONF" ]]; then
|
||||
echo "Using conf: $CONF"
|
||||
grep -E '^SourceDirectory|^Updates\.EnableDatabases|^Updates\.AutoSetup|^[[:space:]]*SourceDirectory|^[[:space:]]*Updates\.EnableDatabases|^[[:space:]]*Updates\.AutoSetup' "$CONF" 2>/dev/null || echo "(no matching lines or unreadable)"
|
||||
echo "--- Rate.RunicPower (if set) ---"
|
||||
grep -iE '^Rate\.RunicPower|^[[:space:]]*Rate\.RunicPower' "$CONF" 2>/dev/null || echo "(not set — server uses default)"
|
||||
echo "--- Paragon.* module options (if any) ---"
|
||||
grep -iE '^Paragon\.|^[[:space:]]*Paragon\.' "$CONF" 2>/dev/null || echo "(no Paragon.* keys in worldserver.conf — check etc/modules/mod_paragon.conf)"
|
||||
else
|
||||
echo "WARN: worldserver.conf not found. Set FRACTURED_WORLDSERVER_CONF=/path/to/worldserver.conf"
|
||||
fi
|
||||
|
||||
if [[ -n "$WS" && -f "$WS" ]]; then
|
||||
ETCGuess=$(readlink -f "$(dirname "$WS")/../etc" 2>/dev/null || true)
|
||||
MPC="$ETCGuess/modules/mod_paragon.conf"
|
||||
if [[ -f "$MPC" ]]; then
|
||||
sub "2B2 — mod_paragon.conf Paragon.* toggles (non-comment)"
|
||||
grep -E '^Paragon\.' "$MPC" 2>/dev/null | head -40 || echo "(no uncommented Paragon.* lines)"
|
||||
fi
|
||||
fi
|
||||
|
||||
sub "2A — path-like strings from binary (candidate source roots)"
|
||||
if [[ -n "$WS" && -f "$WS" ]]; then
|
||||
binary_strings_paths "$WS" || true
|
||||
else
|
||||
echo "(skipped)"
|
||||
fi
|
||||
|
||||
sub "Resolved source root for 2D"
|
||||
RESOLVED=""
|
||||
if [[ -n "$CONF" && -f "$CONF" ]]; then
|
||||
sd=$(awk -F= '/^[[:space:]]*SourceDirectory[[:space:]]*=/ {
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2);
|
||||
gsub(/^["'\'']|["'\'']$/, "", $2);
|
||||
print $2; exit }' "$CONF" 2>/dev/null || true)
|
||||
if [[ -n "${sd:-}" ]]; then
|
||||
RESOLVED="$sd"
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$RESOLVED" ]]; then
|
||||
RESOLVED="$REPO"
|
||||
fi
|
||||
echo "Using RESOLVED=$RESOLVED (from SourceDirectory if set in conf, else FRACTURED_REPO)"
|
||||
|
||||
sub "2D — Paragon SQL dirs under RESOLVED"
|
||||
for subdir in \
|
||||
"$RESOLVED/modules/mod-paragon/data/sql/db-world/updates/" \
|
||||
"$RESOLVED/modules/mod-paragon/data/sql/db-characters/updates/"; do
|
||||
if [[ -d "$subdir" ]]; then
|
||||
echo "Listing: $subdir"
|
||||
ls -la "$subdir" 2>/dev/null | tail -15
|
||||
else
|
||||
echo "MISSING: $subdir"
|
||||
fi
|
||||
done
|
||||
|
||||
sub "CMake build dir hints (common Fractured layouts)"
|
||||
for cand in "$REPO/var/build/obj" "$REPO/build" "$REPO/../build"; do
|
||||
if [[ -f "$cand/CMakeCache.txt" ]]; then
|
||||
echo "Found CMakeCache: $cand/CMakeCache.txt"
|
||||
grep -E '^CMAKE_HOME_DIRECTORY:|^MODULES:|^CMAKE_INSTALL_PREFIX:' "$cand/CMakeCache.txt" 2>/dev/null | head -5
|
||||
fi
|
||||
done
|
||||
|
||||
sub "DATABASE — updates rows (2026_05_10 / paragon)"
|
||||
SQL_WORLD=$(cat <<'EOS'
|
||||
SELECT name, hash, speed FROM updates
|
||||
WHERE name LIKE '2026_05_10%' OR name LIKE '%paragon%'
|
||||
ORDER BY name DESC LIMIT 30;
|
||||
EOS
|
||||
)
|
||||
SQL_CHAR="$SQL_WORLD"
|
||||
|
||||
if [[ -n "${FRACTURED_MYSQL:-}" ]]; then
|
||||
echo "--- acore_world ---"
|
||||
$FRACTURED_MYSQL acore_world -e "$SQL_WORLD" || echo "(mysql failed for acore_world)"
|
||||
echo "--- acore_characters ---"
|
||||
$FRACTURED_MYSQL acore_characters -e "$SQL_CHAR" || echo "(mysql failed for acore_characters)"
|
||||
|
||||
sub "DATABASE — DBC parity for runes / Paragon (acore_world)"
|
||||
# Common DK rune spenders (WotLK). Override: export FRACTURED_SPELL_IDS='45477 45462'
|
||||
SPELL_IDS="${FRACTURED_SPELL_IDS:-45477 45462 49923 55050 56815}"
|
||||
IDS_CSV=$(echo "$SPELL_IDS" | tr ' ' ',')
|
||||
echo "--- spell_dbc table size (world DB overrides; 0 rows = all spells from disk DBC only) ---"
|
||||
$FRACTURED_MYSQL acore_world -e "SELECT COUNT(*) AS spell_dbc_rows FROM spell_dbc;" 2>/dev/null || echo "(spell_dbc missing or no access)"
|
||||
echo "--- acore_world.version (last core revision written by worldserver) ---"
|
||||
$FRACTURED_MYSQL acore_world -e "SELECT * FROM version LIMIT 5;" 2>/dev/null || echo "(version table missing?)"
|
||||
|
||||
echo "--- chrclasses_dbc class 6 + 12 (DisplayPower: 0=mana, 5=POWER_RUNE in AC) ---"
|
||||
$FRACTURED_MYSQL acore_world -e "
|
||||
SELECT ID, DisplayPower, Name_Lang_enUS FROM chrclasses_dbc WHERE ID IN (6,12);
|
||||
" 2>/dev/null || echo "(query failed — chrclasses_dbc missing?)"
|
||||
echo "Note: If only ID=12 appears, class 6 (DK) is not overridden in DB — loaded from disk DBC (normal)."
|
||||
|
||||
echo "--- spell_dbc: are sample DK spells overridden in DB? ---"
|
||||
spell_sample_n=$($FRACTURED_MYSQL acore_world -N -B -e \
|
||||
"SELECT COUNT(*) FROM spell_dbc WHERE ID IN ($IDS_CSV);" 2>/dev/null || echo 0)
|
||||
echo "Row count in spell_dbc for sample IDs ($SPELL_IDS): ${spell_sample_n:-0}"
|
||||
if [[ "${spell_sample_n:-0}" == "0" ]]; then
|
||||
echo "=> 0 means those spells use on-disk Spell.dbc only; the sample block below will be empty (not an error)."
|
||||
fi
|
||||
echo "--- spell_dbc sample (PowerType 5 = POWER_RUNE in AC) ---"
|
||||
$FRACTURED_MYSQL acore_world -e "
|
||||
SELECT ID, PowerType, ManaCost, RuneCostID FROM spell_dbc WHERE ID IN ($IDS_CSV);
|
||||
" 2>/dev/null || echo "(query failed — spell_dbc missing or wrong schema)"
|
||||
echo "--- spellrunecost join for sample IDs (empty if no spell_dbc rows above) ---"
|
||||
$FRACTURED_MYSQL acore_world -e "
|
||||
SELECT s.ID AS spell_id, s.PowerType, s.RuneCostID, r.Blood, r.Unholy, r.Frost, r.RunicPower
|
||||
FROM spell_dbc s
|
||||
LEFT JOIN spellrunecost_dbc r ON r.ID = s.RuneCostID
|
||||
WHERE s.ID IN ($IDS_CSV);
|
||||
" 2>/dev/null || echo "(join failed — check spellrunecost_dbc)"
|
||||
|
||||
echo "--- spell_dbc suspicious overrides: RuneCostID>0 but PowerType!=5 (can break rune checks) ---"
|
||||
$FRACTURED_MYSQL acore_world -e "
|
||||
SELECT ID, PowerType, ManaCost, RuneCostID FROM spell_dbc
|
||||
WHERE RuneCostID > 0 AND PowerType <> 5
|
||||
ORDER BY ID LIMIT 40;
|
||||
" 2>/dev/null || echo "(query failed)"
|
||||
echo "Compare counts/IDs to dev: unexpected rows here warrant a DB diff."
|
||||
|
||||
echo "--- spell_dbc POWER_RUNE (5) spells with RuneCostID (sample) ---"
|
||||
$FRACTURED_MYSQL acore_world -e "
|
||||
SELECT ID, PowerType, RuneCostID FROM spell_dbc
|
||||
WHERE PowerType = 5 AND RuneCostID > 0
|
||||
ORDER BY ID LIMIT 15;
|
||||
" 2>/dev/null || echo "(query failed)"
|
||||
else
|
||||
echo "FRACTURED_MYSQL not set — run manually (example: export FRACTURED_MYSQL='mysql -uUSER -hHOST')"
|
||||
echo "acore_world:"
|
||||
echo "$SQL_WORLD"
|
||||
echo "acore_characters:"
|
||||
echo "$SQL_CHAR"
|
||||
echo ""
|
||||
echo "Optional DBC parity (acore_world) — run after connecting:"
|
||||
echo " SELECT ID, DisplayPower, Name_Lang_enUS FROM chrclasses_dbc WHERE ID IN (6,12);"
|
||||
echo " SELECT ID, PowerType, ManaCost, RuneCostID FROM spell_dbc WHERE ID IN (45477,45462,49923,55050,56815);"
|
||||
echo " SELECT s.ID, s.RuneCostID, r.Blood, r.Unholy, r.Frost, r.RunicPower FROM spell_dbc s"
|
||||
echo " LEFT JOIN spellrunecost_dbc r ON r.ID = s.RuneCostID WHERE s.ID IN (45477,45462,49923,55050,56815);"
|
||||
fi
|
||||
|
||||
sub "mod_paragon.conf vs .dist (install etc)"
|
||||
ETC=""
|
||||
if [[ -n "$WS" ]]; then
|
||||
ETC=$(readlink -f "$(dirname "$WS")/../etc" 2>/dev/null || true)
|
||||
fi
|
||||
if [[ -z "$ETC" || ! -d "$ETC" ]]; then
|
||||
ETC=$(readlink -f "$HOME/azeroth-server/etc" 2>/dev/null || true)
|
||||
fi
|
||||
if [[ -n "$ETC" && -d "$ETC/modules" ]]; then
|
||||
MP="$ETC/modules/mod_paragon.conf"
|
||||
MPD="$ETC/modules/mod_paragon.conf.dist"
|
||||
if [[ -f "$MP" && -f "$MPD" ]]; then
|
||||
diff -u "$MP" "$MPD" 2>/dev/null | head -80 || true
|
||||
else
|
||||
echo "ETC=$ETC — mod_paragon.conf or .dist missing (MP=$MP MPD=$MPD)"
|
||||
fi
|
||||
else
|
||||
echo "Could not find install etc/modules (set paths manually for diff)."
|
||||
fi
|
||||
|
||||
hr
|
||||
echo "DELIVERABLE for maintainer:"
|
||||
echo "1) Paste 1A–1D (binary mtime, git HEAD, strings, sha256 + revision strings)."
|
||||
echo "2) Paste DATABASE blocks: updates + DBC parity (chrclasses 12, spell_dbc, spellrunecost join)."
|
||||
echo "3) Paste 2A path strings + 2D listings (or MISSING lines)."
|
||||
echo "4) From dev: same 1D sha256 of worldserver OR same SQL block — proves binary/data parity."
|
||||
echo "5) ONE sentence: exact in-game symptom."
|
||||
echo "Done."
|
||||
echo ""
|
||||
echo "Full transcript: $DIAG_OUT"
|
||||
@@ -12017,6 +12017,28 @@ void Player::learnSkillRewardedSpells(uint32 skill_id, uint32 skill_value)
|
||||
uint32 raceMask = getRaceMask();
|
||||
uint32 classMask = getClassMask();
|
||||
|
||||
// Fractured / Paragon: the Character Advancement panel is the sole
|
||||
// authority over which class abilities a Paragon owns. The skill-line
|
||||
// cascade re-fires from _LoadSkills (every login), UpdateSkillsForLevel
|
||||
// (every level-up), UpdateSkillPro (every weapon-skill tick on a
|
||||
// training dummy), and SetSkill (first time a class skill is granted).
|
||||
// Each of those re-grants every SLA-tagged class ability on the
|
||||
// matching skill line — leaking Blood Presence / Death Coil / Death
|
||||
// Grip / etc. back into the spellbook within seconds even after the
|
||||
// player intentionally refunded them via the panel. Skip the cascade
|
||||
// for class-category skill lines on Paragon characters; mod-paragon
|
||||
// calls Player::learnSpell directly for the abilities the player
|
||||
// actually purchased, including their attached passives. Profession,
|
||||
// weapon, language, and racial skill cascades stay enabled so things
|
||||
// like recipe auto-learn, weapon proficiencies, and racial perks
|
||||
// still work.
|
||||
if (getClass() == CLASS_PARAGON)
|
||||
{
|
||||
if (SkillLineEntry const* sl = sSkillLineStore.LookupEntry(skill_id))
|
||||
if (sl->categoryId == SKILL_CATEGORY_CLASS)
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all abilities for this skill and sort by MinSkillLineRank (lowest to highest)
|
||||
auto abilities = GetSkillLineAbilitiesBySkillLine(skill_id);
|
||||
std::vector<SkillLineAbilityEntry const*> sortedAbilities(abilities.begin(), abilities.end());
|
||||
|
||||
@@ -5368,6 +5368,18 @@ void SpellMgr::LoadSpellInfoCorrections()
|
||||
LockEntry* key = const_cast<LockEntry*>(sLockStore.LookupEntry(36)); // 3366 Opening, allows to open without proper key
|
||||
key->Type[2] = LOCK_KEY_NONE;
|
||||
|
||||
// Fractured / Paragon: DK weapon-line "passives" Forceful Deflection and
|
||||
// Runic Focus ship in 3.3.5a Spell.dbc without SPELL_ATTR0_PASSIVE set.
|
||||
// SpellInfo::IsPassive() is therefore false, and mod-paragon's panel-learn
|
||||
// diff treats them as castable actives and revokes them — while true
|
||||
// actives (Blood Presence, Death Coil, Death Grip, ...) must stay
|
||||
// stripped. Mark these two passive in-memory so the panel policy matches
|
||||
// the spellbook UX for every class (stock DK benefits too).
|
||||
ApplySpellFix({ 49410, 61455 }, [](SpellInfo* spellInfo)
|
||||
{
|
||||
spellInfo->Attributes |= SPELL_ATTR0_PASSIVE;
|
||||
});
|
||||
|
||||
// Fractured: strip reagent requirements from every player-class spell at
|
||||
// load time. Filtered by SpellFamilyName != 0 so that profession spells
|
||||
// (cooking, alchemy, enchanting, blacksmithing, jewelcrafting, leatherworking,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Fractured Launcher (Electron)
|
||||
|
||||
Windows launcher with **no extra console window**, **native Browse folder** dialog, GitHub **release assets** + repo file sync, **realmlist**, optional **auth**, **Play**, and **auto-update** (via `electron-updater`). This is the **only** supported client launcher in this repo.
|
||||
**Windows** and **Linux (AppImage)** launcher with **no extra console window**, **native Browse folder** dialog, **Gitea or GitHub** release assets + GitHub repo file sync, **realmlist**, optional **auth**, **Play**, and **auto-update** (via `electron-updater`). This is the **only** supported client launcher in this repo.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -14,7 +14,12 @@ npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
On first run, `launcher.json` is created next to the app (dev: in this folder). By default **`github.repo`** is **`Fractured-Distro`** (public release assets); no token is required for patches. Set **GITHUB_TOKEN** only if you override `github` to a **private** repo.
|
||||
On first run, **`launcher.json`** is created: **dev** — next to the app in this folder; **Windows packaged** — beside the `.exe`; **Linux AppImage / macOS packaged** — under Electron **`app.getPath('userData')`** (typically under **`~/.config/`**, folder name from the app; AppImage mount is read-only so config cannot live beside the binary).
|
||||
|
||||
### Where patches download from
|
||||
|
||||
- **Recommended (self-hosted Gitea):** set **`gitea.base_url`**, **`gitea.owner`**, **`gitea.repo`** in `launcher.json` (see **`default-launcher.json`**). Players need **`GITEA_TOKEN`** (or the env name in **`gitea.token_env`**) if the Gitea repo is **private** — same trade-off as any private host (per-player token, SSO proxy, or a read-only deploy token you accept distributing).
|
||||
- **Fallback:** if **`gitea.base_url`** is empty, **`from_release`** uses the **GitHub** Releases API against **`github.owner` / `github.repo`** (defaults to this **`Fractured`** repo for non-release paths), with optional **`GITHUB_TOKEN`** for private assets.
|
||||
|
||||
## Build Windows installers
|
||||
|
||||
@@ -30,63 +35,139 @@ Produces under **`dist/`**:
|
||||
| `Fractured-Launcher-${version}-Setup.exe` (NSIS) | **Recommended for players** — supports seamless **auto-update** and restart. |
|
||||
| `Fractured-Launcher-${version}-Windows-Portable.exe` | No installer; players replace the file manually. Auto-update is **less reliable** than NSIS. |
|
||||
|
||||
## Build Linux AppImage
|
||||
|
||||
```bash
|
||||
cd tools/fractured-launcher-electron
|
||||
npm install
|
||||
npm run pack:linux
|
||||
```
|
||||
|
||||
Produces **`dist/Fractured-Launcher-${version}-Linux-x86_64.AppImage`**. Same **`lib/baked-gitea-channel.js`** and **`default-launcher.json`** as Windows; run on **Linux** (or use **Fractured launcher CI** / **Sync release to Gitea**, which upload this file to Gitea with the Windows installers).
|
||||
|
||||
**Quick local test (avoids tag snapshot / CI):**
|
||||
- **Linux:** from repo root, **`bash tools/fractured-launcher-electron/scripts/manual-pack-linux.sh`** → **`dist/*.AppImage`**.
|
||||
- **Windows:** on a Windows machine, **`cd tools/fractured-launcher-electron`**, **`npm ci`**, **`npm run pack:win`** → **`dist/*.exe`**.
|
||||
|
||||
### Hardcoded Gitea channel (non-token)
|
||||
|
||||
**`lib/baked-gitea-channel.js`** exports **`base_url`**, **`owner`**, **`repo`**, **`release_tag`**. Set those strings once in the repo (same values you use for CI upload — not secret). At runtime **`config-store`** merges them into **`gitea.*`** so **`launcher.json`** does not need those fields; **`GITEA_TOKEN`** (or **`gitea.token_env`**) is still only for **private** Gitea. Leave a field **`''`** in the baked file to fall back to **`default-launcher.json`** / user **`launcher.json`** for that key.
|
||||
|
||||
**`npm run pack:win`** is plain **electron-builder** — no inject step, no extra JSON beside the app.
|
||||
|
||||
## Auto-update behaviour
|
||||
|
||||
- **Packaged** builds only (`npm run pack:win` output). In `npm start` dev mode, update checks are skipped (button still explains that).
|
||||
- **~5 seconds** after launch, then **every 6 hours**, the app checks for a newer version.
|
||||
- **No implicit GitHub feed:** the app does **not** guess `package.json` → `repository` anymore. Without configuration you get a clear “skipped” message instead of a **404** on a private repo.
|
||||
- **Configured feeds** (first match wins): **`update_feed_url` / `LAUNCHER_UPDATE_URL`** (generic `latest.yml`); or **`gitea`** block filled in + **`GITEA_TOKEN`** when the instance is private (resolves `…/releases/download/{tag}/`); or **`GITHUB_TOKEN`** + **`github.owner` / `github.repo`** for **private** GitHub releases only.
|
||||
- **~5 seconds** after launch, then **every 6 hours**, the app checks when a feed is configured.
|
||||
- When a download finishes, a dialog offers **Restart now** (calls `quitAndInstall`) or **Later**.
|
||||
- **Manual check:** button **Check launcher updates** in the UI.
|
||||
|
||||
### Where updates are hosted
|
||||
### Where launcher updates are hosted
|
||||
|
||||
**`package.json`** → `build.publish` targets **`Dawnforger/Fractured-Distro`** (public). `electron-updater` reads **`latest.yml`** from the **latest** release there; players do not need a GitHub token for launcher updates.
|
||||
**`npm run publish:win`** runs **`electron-builder` with `--publish never`** — artifacts stay in **`dist/`**; CI uploads them to Gitea when you **publish a GitHub release**. For ad-hoc uploads, use **`scripts/upload-release-to-gitea.sh`**. For launcher auto-update, prefer:
|
||||
|
||||
**Private GitHub** (if you change `build.publish` or `github` back to a private repo): set **`GH_TOKEN`** / **`GITHUB_TOKEN`** / **`github.token_env`** before starting the launcher so the updater can authenticate.
|
||||
- Set **`update_feed_url`** (or **`LAUNCHER_UPDATE_URL`**) to a **generic** HTTPS base URL where **`latest.yml`** and the installer files are hosted (often the same Gitea release attachment URLs pattern your reverse proxy exposes), **or**
|
||||
- Keep publishing to a GitHub release only for **`latest.yml`** + installers if you accept that small metadata/binary channel there.
|
||||
|
||||
**Public generic feed** (optional CDN): set **`update_feed_url`** or **`LAUNCHER_UPDATE_URL`** to a folder that hosts `latest.yml` + installers; optional Bearer token if the host requires it.
|
||||
**Private GitHub** updater: set **`GH_TOKEN`** / **`GITHUB_TOKEN`** / **`github.token_env`** as documented in `lib/auto-update.js` behaviour.
|
||||
|
||||
**Generic feed:** optional Bearer token via the same token envs if your static host checks `Authorization`.
|
||||
|
||||
### Publishing a new launcher version
|
||||
|
||||
1. Bump **`version`** in `package.json` (semver, e.g. `1.0.1`).
|
||||
2. Create a **GitHub personal access token** with `repo` (or `public_repo` for public repos).
|
||||
3. From this directory:
|
||||
1. Bump **`version`** in `package.json` on `main` (or your release branch) and merge.
|
||||
2. Create a **GitHub release** (tag + attach patches / `Wow.exe` if needed) and click **Publish** — **Sync release to Gitea** builds **Windows + Linux** launcher artifacts and mirrors everything to Gitea.
|
||||
3. Local check: **`npm run pack:win`** (on Windows) or **`npm run pack:linux`** / **`scripts/manual-pack-linux.sh`**, then **`scripts/upload-release-to-gitea.sh`** with the same **`GITEA_*`** env vars as CI if you need a manual upload.
|
||||
|
||||
```bash
|
||||
set GH_TOKEN=ghp_your_token_here
|
||||
npm run publish:win
|
||||
## Sync to Gitea (patches + launcher binaries)
|
||||
|
||||
CI workflow **Sync release to Gitea** (`.github/workflows/gitea-release-sync.yml`) runs on **every published GitHub release** on this repo:
|
||||
|
||||
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).
|
||||
|
||||
**GitHub Actions secrets** (repository → Settings → Secrets and variables → Actions):
|
||||
|
||||
| Secret | Example |
|
||||
|--------|---------|
|
||||
| **`GITEA_BASE_URL`** | `https://git.yourdomain.com` (no trailing slash) |
|
||||
| **`GITEA_TOKEN`** | Gitea personal access token with permission to manage releases and attachments on the target repo |
|
||||
| **`GITEA_OWNER`** | Organization or username on Gitea |
|
||||
| **`GITEA_REPO`** | Repository name — must already have **at least one commit** (Gitea returns HTTP 422 “repo is empty” for zero-commit repos; push e.g. a README on **`main`** or set **`GITEA_TARGET_REF`** to your default branch) |
|
||||
|
||||
**Optional variable** (Settings → Variables): **`GITEA_TARGET_REF`** — default branch/commitish used **only when the workflow must create a new Gitea release** and Gitea needs `target_commitish` (defaults to **`main`** in the upload script if unset).
|
||||
|
||||
**Player `launcher.json`:** packaged builds should already include **`gitea.base_url` / `owner` / `repo`** from the bake step above. Players only need to set **`GITEA_TOKEN`** (or your **`token_env`**) if the Gitea repo is **private**. To point at another instance, edit **`gitea`** in **`launcher.json`**:
|
||||
|
||||
```json
|
||||
"gitea": {
|
||||
"base_url": "https://git.yourdomain.com",
|
||||
"owner": "myorg",
|
||||
"repo": "fractured-patches",
|
||||
"release_tag": "latest",
|
||||
"token_env": "GITEA_TOKEN"
|
||||
}
|
||||
```
|
||||
|
||||
That builds NSIS + portable and **uploads** update metadata and installers to the configured GitHub repo’s **releases** (see [electron-builder publish](https://www.electron.build/configuration/publish)).
|
||||
**Manual upload:** `bash scripts/upload-release-to-gitea.sh /path/to/files v1.0.0` with the same env vars as CI.
|
||||
|
||||
Players on an older NSIS install will pick up the next version automatically on the next check.
|
||||
### Sync did not run / Gitea unchanged — checklist
|
||||
|
||||
## Public distro repo (patches + launcher binaries)
|
||||
1. **Git tag ≠ GitHub Release** — Only **Releases** (published on the GitHub **Releases** page) trigger this workflow. If your teammate only **`git push --tags`**, create a **Release** from that tag and click **Publish** (or run **Actions → Sync release to Gitea → Run workflow** and enter the tag).
|
||||
2. **Manual run: tag vs title** — **Run workflow** must receive the **git tag** (e.g. `v0.7.11-paragon-…`), copied from the release page’s tag badge. Pasting the **release title** (long line with spaces/parentheses) breaks `git fetch` with `invalid refspec`.
|
||||
3. **Draft release** — Must click **Publish release**; drafts do not mirror.
|
||||
4. **Workflow on default branch** — GitHub runs `release` workflows from the **default branch** (e.g. `main`). Ensure `.github/workflows/gitea-release-sync.yml` is merged there.
|
||||
5. **Repo name guard** — Jobs use `if: github.repository == 'Dawnforger/Fractured'`. Forks or renames must change that line or runs are skipped.
|
||||
6. **Secrets** — **`GITEA_BASE_URL`**, **`GITEA_TOKEN`**, **`GITEA_OWNER`**, **`GITEA_REPO`** must be set under **Settings → Secrets and variables → Actions**. A failed “Upload to Gitea” step usually prints which is missing.
|
||||
7. **Actions tab** — Open the latest **Sync release to Gitea** run; a red **build-electron** (old tag without `package-lock.json`, etc.) or **Upload to Gitea** step shows the real error.
|
||||
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.
|
||||
|
||||
Default **`default-launcher.json`** uses **`github.repo`: `Fractured-Distro`** so **`from_release`** assets (`patch-Z.MPQ`, `Wow-patched.exe`, …) download from **[Dawnforger/Fractured-Distro releases](https://github.com/Dawnforger/Fractured-Distro/releases)** — **no player token**.
|
||||
### Private Gitea token for players
|
||||
|
||||
Publish assets there by:
|
||||
Do **not** embed a shared admin PAT in a shipped `launcher.json`. Prefer read-only tokens scoped to one repo, short-lived tokens, or a small auth service that redirects to signed URLs.
|
||||
|
||||
- **GitHub Actions** — workflow **Sync release to Fractured-Distro** (`.github/workflows/distro-release-sync.yml`): on **release published** on `Dawnforger/Fractured`, it **builds the Electron launcher** from that tag on Windows (`npm run pack:win`), **downloads every asset** attached to that release (patches, `Wow-patched.exe`, …), merges them (launcher files overwrite on duplicate names), and creates or updates the **same tag** on **`Fractured-Distro`**. Requires repository secret **`DISTRO_SYNC_TOKEN`**. **Manual:** Actions → run workflow with an existing tag.
|
||||
- **Locally:** `scripts/publish-to-distro.sh` (see script header) if you need to upload without a full release cycle.
|
||||
**Release asset names** must match **`files[].source`** when **`from_release`**: true. Use **`release_tag`**: `"latest"` or a pinned tag matching both GitHub and Gitea.
|
||||
|
||||
If your public repo slug is not `Fractured-Distro`, edit **`DISTRO_REPO`** in the workflow / script and **`github.repo`** in `launcher.json`.
|
||||
## Patch versions (same filenames, different bytes)
|
||||
|
||||
### Private `github.repo` (optional)
|
||||
The launcher does **not** read Git commits. For **turn-key** updates when asset names stay fixed (e.g. **`Wow-patched.exe`** — add more **`files`** entries for any extra MPQs you ship):
|
||||
|
||||
For a **private** release source, set `GITHUB_TOKEN` (or `github.token_env`) with read access — **do not** embed a shared PAT in shipped `launcher.json` for all players.
|
||||
1. Ship **`patch-manifest.json`** next to those files on **every** release (Gitea/GitHub attachment). It lists a **`version`** label (any string you bump per release, e.g. `v0.9.0-client`) and a **`sha256`** per **`files[].source`** name.
|
||||
2. With **`patch_manifest.enabled`**: true (default in **`default-launcher.json`**), **Download updates** first fetches the manifest from the same release channel. If the files already on disk match those checksums, the player sees **“already match build … (nothing to download)”** — no redundant downloads.
|
||||
3. After a real download, the launcher **re-hashes** installed files and compares to the manifest; mismatch → clear error. It also writes **`.fractured/patch-state.json`** under the WoW folder so the UI can show **“Installed client files: …”**.
|
||||
|
||||
**Release asset names** must match **`files[].source`** exactly when **`from_release`**: true. Use **`release_tag`: `"latest"`** for the newest release, or pin a tag.
|
||||
If **`patch-manifest.json`** is missing on a release, the launcher falls back to **always downloading** all configured files (same as before).
|
||||
|
||||
**Generate the manifest** when you cut a release (paths are your local patch binaries):
|
||||
|
||||
```bash
|
||||
cd /path/to/staging
|
||||
node tools/fractured-launcher-electron/scripts/generate-patch-manifest.js v0.9.0-client Wow-patched.exe > patch-manifest.json
|
||||
```
|
||||
|
||||
Attach **`patch-manifest.json`** together with the MPQ/exe to the GitHub release (CI sync copies it to Gitea with everything else).
|
||||
|
||||
## CI
|
||||
|
||||
Workflow **Fractured launcher CI** (`.github/workflows/fractured-launcher-ci.yml`) runs on pushes/PRs under `tools/fractured-launcher-electron/`: Windows **`npm run pack:win`** and **artifacts** (`*.exe`, `latest.yml`, blockmaps). **Actions → Fractured launcher CI → Run workflow** runs it manually.
|
||||
Workflow **Fractured launcher CI** (`.github/workflows/fractured-launcher-ci.yml`) runs on pushes/PRs under `tools/fractured-launcher-electron/`: **Windows** (`npm run pack:win`) and **Linux** (`npm run pack:linux`) jobs, each **`electron-builder … --publish never`**. **Actions → Fractured launcher CI → Run workflow** runs it manually.
|
||||
|
||||
**Sync release to Gitea** (`.github/workflows/gitea-release-sync.yml`) uses the same pack commands. If you see `GH_TOKEN` / `GitHubPublisher` errors in logs, the job is almost certainly an old **Re-run failed jobs** — open **Actions → Sync release to Gitea → Run workflow**, enter the tag, and start a **new** run instead.
|
||||
|
||||
## Config
|
||||
|
||||
Schema is defined by **`default-launcher.json`** (shipped in the app; first run copies to `launcher.json` beside the executable):
|
||||
Schema is defined by **`default-launcher.json`** (shipped in the app; first run copies to **`launcher.json`** — beside the **Windows** exe, or under **`userData`** on **Linux/macOS** packaged builds):
|
||||
|
||||
- **`game_dir`**: WoW 3.3.5a root (contains `Wow.exe`).
|
||||
- **`update_feed_url`**: optional generic HTTPS base for launcher auto-update (overrides default GitHub feed when set).
|
||||
- **`github`**: `owner`, `repo`, `ref` (repo file paths), **`release_tag`** (`latest` or e.g. `v1.0.0`), **`token_env`** (env var name for a PAT when using private sources).
|
||||
- **`files`**: `source`, `dest`, `backup`, **`from_release`** (asset name on GitHub release vs repo path).
|
||||
- **`update_feed_url`**: optional generic HTTPS base for launcher auto-update.
|
||||
- **`launcher_updates_from_github`**: default **`false`**. Only when **`true`** will a **`GITHUB_TOKEN`** (or **`github.token_env`**) enable **electron-updater**’s GitHub provider against **`github.owner` / `github.repo`**. Leave **`false`** when launcher binaries and **`latest.yml`** live on **Gitea** (use **`gitea`** + token instead) so a stray GitHub token does not produce “No published versions on GitHub”.
|
||||
- **`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.
|
||||
- **`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`**.
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
{
|
||||
"game_dir": "",
|
||||
"update_feed_url": "",
|
||||
"launcher_updates_from_github": false,
|
||||
"gitea": {
|
||||
"base_url": "",
|
||||
"owner": "",
|
||||
"repo": "",
|
||||
"release_tag": "latest",
|
||||
"token_env": "GITEA_TOKEN"
|
||||
},
|
||||
"github": {
|
||||
"owner": "Dawnforger",
|
||||
"repo": "Fractured-Distro",
|
||||
"repo": "Fractured",
|
||||
"ref": "main",
|
||||
"release_tag": "latest",
|
||||
"token_env": "GITHUB_TOKEN"
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"source": "patch-Z.MPQ",
|
||||
"dest": "Data/patch-Z.MPQ",
|
||||
"backup": true,
|
||||
"from_release": true
|
||||
},
|
||||
{
|
||||
"source": "Wow-patched.exe",
|
||||
"dest": "Wow.exe",
|
||||
"backup": true,
|
||||
"from_release": true
|
||||
}
|
||||
],
|
||||
"patch_manifest": {
|
||||
"enabled": true,
|
||||
"source": "patch-manifest.json",
|
||||
"from_release": true
|
||||
},
|
||||
"files": [],
|
||||
"realmlist": {
|
||||
"enabled": true,
|
||||
"line": "set realmlist fracturedwow.ddns.net:47497",
|
||||
|
||||
@@ -2,28 +2,51 @@
|
||||
|
||||
const { dialog } = require('electron');
|
||||
const { autoUpdater } = require('electron-updater');
|
||||
const { useGiteaReleases, getGiteaUpdaterFeedBase } = require('./gitea-release');
|
||||
|
||||
/**
|
||||
* @param {import('electron').App} app
|
||||
* @param {() => import('electron').BrowserWindow | null} getMainWindow
|
||||
* @param {{ updateFeedUrl?: string, githubOwner?: string, githubRepo?: string, token?: string }} opts
|
||||
* @param {{
|
||||
* updateFeedUrl?: string,
|
||||
* githubOwner?: string,
|
||||
* githubRepo?: string,
|
||||
* githubToken?: string,
|
||||
* giteaToken?: string,
|
||||
* allowGithubLauncherUpdates?: boolean,
|
||||
* config?: object,
|
||||
* }} opts
|
||||
*/
|
||||
function setupAutoUpdater(app, getMainWindow, opts = {}) {
|
||||
async function setupAutoUpdater(app, getMainWindow, opts = {}) {
|
||||
if (!app.isPackaged) {
|
||||
return {
|
||||
checkNow: async () => ({ skipped: true, reason: 'development build' }),
|
||||
};
|
||||
}
|
||||
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
const token = String(opts.token || '').trim();
|
||||
const ghToken = String(opts.githubToken || '').trim();
|
||||
const giteaTok = String(opts.giteaToken || '').trim();
|
||||
const envGeneric = String(process.env.LAUNCHER_UPDATE_URL || '').trim();
|
||||
const configGeneric = String(opts.updateFeedUrl || '').trim();
|
||||
const genericUrl = envGeneric || configGeneric;
|
||||
const owner = String(opts.githubOwner || 'Dawnforger').trim();
|
||||
const repo = String(opts.githubRepo || 'Fractured').trim();
|
||||
let genericUrl = envGeneric || configGeneric;
|
||||
let genericAuthHeader = '';
|
||||
|
||||
if (!genericUrl && opts.config && useGiteaReleases(opts.config)) {
|
||||
const gfb = await getGiteaUpdaterFeedBase(opts.config);
|
||||
if (gfb && gfb.url) {
|
||||
genericUrl = gfb.url;
|
||||
const t = String(gfb.token || giteaTok || '').trim();
|
||||
if (t) genericAuthHeader = `token ${t}`;
|
||||
}
|
||||
} else if (genericUrl) {
|
||||
if (giteaTok) genericAuthHeader = `token ${giteaTok}`;
|
||||
else if (ghToken) genericAuthHeader = `Bearer ${ghToken}`;
|
||||
}
|
||||
|
||||
const owner = String(opts.githubOwner || '').trim();
|
||||
const repo = String(opts.githubRepo || '').trim();
|
||||
|
||||
let feedConfigured = false;
|
||||
|
||||
if (genericUrl) {
|
||||
const base = genericUrl.replace(/\/?$/, '/');
|
||||
@@ -31,22 +54,37 @@ function setupAutoUpdater(app, getMainWindow, opts = {}) {
|
||||
provider: 'generic',
|
||||
url: base,
|
||||
});
|
||||
if (token) {
|
||||
if (genericAuthHeader) {
|
||||
autoUpdater.requestHeaders = {
|
||||
...autoUpdater.requestHeaders,
|
||||
Authorization: `Bearer ${token}`,
|
||||
Authorization: genericAuthHeader,
|
||||
};
|
||||
}
|
||||
} else if (token && owner && repo) {
|
||||
feedConfigured = true;
|
||||
} else if (opts.allowGithubLauncherUpdates && ghToken && owner && repo) {
|
||||
autoUpdater.setFeedURL({
|
||||
provider: 'github',
|
||||
owner,
|
||||
repo,
|
||||
private: true,
|
||||
token,
|
||||
token: ghToken,
|
||||
});
|
||||
feedConfigured = true;
|
||||
}
|
||||
|
||||
if (!feedConfigured) {
|
||||
const reason =
|
||||
'No update channel configured. Set launcher.json → update_feed_url (HTTPS folder with latest.yml), ' +
|
||||
'or fill gitea.base_url/owner/repo (+ GITEA_TOKEN for private), ' +
|
||||
'or set launcher_updates_from_github to true with GITHUB_TOKEN for private GitHub release feeds.';
|
||||
return {
|
||||
checkNow: async () => ({ skipped: true, reason }),
|
||||
};
|
||||
}
|
||||
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
const send = (msg) => {
|
||||
const w = getMainWindow();
|
||||
if (w && !w.isDestroyed()) {
|
||||
@@ -63,9 +101,8 @@ function setupAutoUpdater(app, getMainWindow, opts = {}) {
|
||||
const m = (err && (err.message || String(err))) || '';
|
||||
if (/404|releases\.atom|HttpError:\s*404/i.test(m)) {
|
||||
send(
|
||||
'Launcher update: could not read GitHub releases (404). ' +
|
||||
'If the repo is private, set GITHUB_TOKEN (or your token_env) so the launcher can authenticate, ' +
|
||||
'or set update_feed_url in launcher.json to a public HTTPS folder that contains latest.yml.'
|
||||
'Launcher update: 404 (no latest.yml or wrong URL). For Gitea use gitea.* + token, or set update_feed_url. ' +
|
||||
'For private GitHub set GITHUB_TOKEN.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Production Gitea mirror (non-secret). Edit here and ship — no inject script,
|
||||
* no fractured-release-channel.json, no CI env needed for these fields.
|
||||
* Token stays in env: GITEA_TOKEN or launcher.json → gitea.token_env.
|
||||
*/
|
||||
module.exports = {
|
||||
// http:// kept as-is; bare host gets https in gitea-release.js
|
||||
base_url: 'http://brassnet.ddns.net:33983',
|
||||
owner: 'Dawnsorrow',
|
||||
repo: 'Fractured-Distro',
|
||||
release_tag: 'latest',
|
||||
};
|
||||
@@ -2,6 +2,26 @@
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { normalizeWinGameDir } = require('./win-game-dir');
|
||||
|
||||
/** 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', 'Wow-patched.exe']);
|
||||
|
||||
function mergeFilesList(defaults, user) {
|
||||
const dep = (e) => DEPRECATED_FILE_SOURCES.has(String(e && e.source ? e.source : '').trim());
|
||||
if (Array.isArray(user.files) && user.files.length) {
|
||||
const filtered = user.files.map((e) => ({ ...e })).filter((e) => !dep(e));
|
||||
if (filtered.length) return filtered;
|
||||
}
|
||||
const defList = Array.isArray(defaults.files) ? defaults.files : [];
|
||||
return defList.map((e) => ({ ...e })).filter((e) => !dep(e));
|
||||
}
|
||||
|
||||
function userFilesContainDeprecated(user) {
|
||||
const files = user && user.files;
|
||||
if (!Array.isArray(files)) return false;
|
||||
return files.some((e) => DEPRECATED_FILE_SOURCES.has(String(e && e.source ? e.source : '').trim()));
|
||||
}
|
||||
|
||||
function mergeConfig(defaults, user) {
|
||||
return {
|
||||
@@ -11,17 +31,44 @@ function mergeConfig(defaults, user) {
|
||||
user.update_feed_url != null && user.update_feed_url !== ''
|
||||
? user.update_feed_url
|
||||
: defaults.update_feed_url,
|
||||
launcher_updates_from_github:
|
||||
user.launcher_updates_from_github != null
|
||||
? user.launcher_updates_from_github
|
||||
: defaults.launcher_updates_from_github,
|
||||
github: { ...defaults.github, ...(user.github || {}) },
|
||||
gitea: { ...defaults.gitea, ...(user.gitea || {}) },
|
||||
patch_manifest: { ...defaults.patch_manifest, ...(user.patch_manifest || {}) },
|
||||
launch: { ...defaults.launch, ...(user.launch || {}) },
|
||||
auth: user.auth != null ? { ...defaults.auth, ...user.auth } : defaults.auth,
|
||||
realmlist: user.realmlist != null ? { ...defaults.realmlist, ...user.realmlist } : defaults.realmlist,
|
||||
files: Array.isArray(user.files) && user.files.length ? user.files : defaults.files,
|
||||
files: mergeFilesList(defaults, user),
|
||||
};
|
||||
}
|
||||
|
||||
/** Hardcoded Gitea host/repo (see lib/baked-gitea-channel.js). Non-empty baked values win. */
|
||||
function applyBakedGitea(cfg) {
|
||||
let baked;
|
||||
try {
|
||||
baked = require('./baked-gitea-channel');
|
||||
} catch {
|
||||
return cfg;
|
||||
}
|
||||
if (!baked || typeof baked !== 'object') return cfg;
|
||||
cfg.gitea = { ...(cfg.gitea || {}) };
|
||||
for (const k of ['base_url', 'owner', 'repo', 'release_tag']) {
|
||||
const v = baked[k];
|
||||
if (v != null && String(v).trim() !== '') cfg.gitea[k] = String(v).trim();
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
function getConfigPath(app) {
|
||||
if (process.env.FRACTURED_LAUNCHER_CONFIG) return process.env.FRACTURED_LAUNCHER_CONFIG;
|
||||
if (app && app.isPackaged) {
|
||||
// AppImage (and macOS .app) run from a read-only mount — cannot write beside execPath.
|
||||
if (process.platform === 'linux' || process.platform === 'darwin') {
|
||||
return path.join(app.getPath('userData'), 'launcher.json');
|
||||
}
|
||||
return path.join(path.dirname(process.execPath), 'launcher.json');
|
||||
}
|
||||
return path.join(__dirname, '..', 'launcher.json');
|
||||
@@ -33,11 +80,16 @@ async function loadConfig(app) {
|
||||
const defaults = JSON.parse(await fs.readFile(defPath, 'utf8'));
|
||||
try {
|
||||
const user = JSON.parse(await fs.readFile(p, 'utf8'));
|
||||
return { configPath: p, config: mergeConfig(defaults, user) };
|
||||
const config = applyBakedGitea(mergeConfig(defaults, user));
|
||||
if (userFilesContainDeprecated(user)) {
|
||||
await fs.writeFile(p, JSON.stringify(config, null, 2), 'utf8');
|
||||
}
|
||||
return { configPath: p, config };
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') {
|
||||
await fs.writeFile(p, JSON.stringify(defaults, null, 2), 'utf8');
|
||||
return { configPath: p, config: JSON.parse(JSON.stringify(defaults)) };
|
||||
const initial = applyBakedGitea(mergeConfig(defaults, {}));
|
||||
await fs.writeFile(p, JSON.stringify(initial, null, 2), 'utf8');
|
||||
return { configPath: p, config: JSON.parse(JSON.stringify(initial)) };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
@@ -48,7 +100,7 @@ async function saveGameDir(configPath, gameDir) {
|
||||
const defaults = JSON.parse(await fs.readFile(defPath, 'utf8'));
|
||||
const user = JSON.parse(await fs.readFile(configPath, 'utf8'));
|
||||
user.game_dir = gameDir;
|
||||
const merged = mergeConfig(defaults, user);
|
||||
const merged = applyBakedGitea(mergeConfig(defaults, user));
|
||||
await fs.writeFile(configPath, JSON.stringify(merged, null, 2), 'utf8');
|
||||
return merged;
|
||||
}
|
||||
@@ -56,8 +108,9 @@ async function saveGameDir(configPath, gameDir) {
|
||||
function resolveGameDir(cfg, configPath) {
|
||||
const gd = cfg.game_dir;
|
||||
if (!gd) return '';
|
||||
if (path.isAbsolute(gd)) return path.normalize(gd);
|
||||
return path.normalize(path.join(path.dirname(configPath), gd));
|
||||
const abs = path.isAbsolute(gd) ? path.normalize(gd) : path.normalize(path.join(path.dirname(configPath), gd));
|
||||
if (process.platform === 'win32') return normalizeWinGameDir(abs);
|
||||
return abs;
|
||||
}
|
||||
|
||||
module.exports = { getConfigPath, loadConfig, saveGameDir, resolveGameDir, mergeConfig };
|
||||
module.exports = { getConfigPath, loadConfig, saveGameDir, resolveGameDir, mergeConfig, applyBakedGitea };
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
'use strict';
|
||||
|
||||
const { downloadBodyToFile, fetchOrThrow } = require('./http-download');
|
||||
|
||||
function normalizeGiteaBaseUrl(raw) {
|
||||
let b = String(raw || '').trim().replace(/\/+$/, '');
|
||||
if (!b) return '';
|
||||
if (!/^https?:\/\//i.test(b)) b = `https://${b}`;
|
||||
return b;
|
||||
}
|
||||
|
||||
function giteaApiBase(cfg) {
|
||||
const base = normalizeGiteaBaseUrl(cfg.gitea.base_url);
|
||||
return `${base}/api/v1`;
|
||||
}
|
||||
|
||||
function giteaToken(cfg) {
|
||||
const name = cfg.gitea && cfg.gitea.token_env;
|
||||
if (name && process.env[name]) return String(process.env[name]).trim();
|
||||
return String(process.env.GITEA_TOKEN || '').trim();
|
||||
}
|
||||
|
||||
function giteaHeaders(token, json = false) {
|
||||
const h = { 'User-Agent': 'Fractured-Launcher-Electron' };
|
||||
if (json) h.Accept = 'application/json';
|
||||
if (token) h.Authorization = `token ${token}`;
|
||||
return h;
|
||||
}
|
||||
|
||||
function useGiteaReleases(cfg) {
|
||||
const g = cfg.gitea;
|
||||
if (!g) return false;
|
||||
return !!(String(g.base_url || '').trim() && String(g.owner || '').trim() && String(g.repo || '').trim());
|
||||
}
|
||||
|
||||
async function fetchGiteaReleaseRecord(cfg) {
|
||||
const api = giteaApiBase(cfg);
|
||||
const { owner, repo } = cfg.gitea;
|
||||
const tag = (cfg.gitea.release_tag || 'latest').trim() || 'latest';
|
||||
const token = giteaToken(cfg);
|
||||
|
||||
let listUrl;
|
||||
if (tag.toLowerCase() === 'latest') {
|
||||
listUrl = `${api}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/latest`;
|
||||
} else {
|
||||
listUrl = `${api}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`;
|
||||
}
|
||||
|
||||
const res = await fetchOrThrow(listUrl, { headers: giteaHeaders(token, true) });
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
let hint = '';
|
||||
if (res.status === 404) hint = ' (wrong tag / no release / check base_url owner repo)';
|
||||
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)}`);
|
||||
}
|
||||
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 || [];
|
||||
let downloadUrl = '';
|
||||
for (const a of list) {
|
||||
if (a.name !== assetName) continue;
|
||||
downloadUrl = a.browser_download_url || a.download_url || '';
|
||||
break;
|
||||
}
|
||||
if (!downloadUrl) {
|
||||
const names = list.map((x) => x.name).filter(Boolean);
|
||||
throw new Error(`Gitea release asset "${assetName}" not found; attachments: ${names.join(', ') || '(none)'}`);
|
||||
}
|
||||
|
||||
const h = { Accept: 'application/octet-stream' };
|
||||
if (token) h.Authorization = `token ${token}`;
|
||||
const dl = await fetchOrThrow(downloadUrl, { headers: h, redirect: 'follow' });
|
||||
await downloadBodyToFile(dl, destPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base URL for electron-updater generic provider (expects latest.yml under this path).
|
||||
* Matches Gitea’s pattern: …/owner/repo/releases/download/{tag}/latest.yml
|
||||
*/
|
||||
async function getGiteaUpdaterFeedBase(cfg) {
|
||||
if (!useGiteaReleases(cfg)) return null;
|
||||
const api = giteaApiBase(cfg);
|
||||
const { owner, repo } = cfg.gitea;
|
||||
const tag = (cfg.gitea.release_tag || 'latest').trim() || 'latest';
|
||||
const token = giteaToken(cfg);
|
||||
let listUrl;
|
||||
if (tag.toLowerCase() === 'latest') {
|
||||
listUrl = `${api}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/latest`;
|
||||
} else {
|
||||
listUrl = `${api}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`;
|
||||
}
|
||||
const res = await fetchOrThrow(listUrl, { headers: giteaHeaders(token, true) });
|
||||
if (!res.ok) return null;
|
||||
const rel = await res.json();
|
||||
const tagName = rel.tag_name;
|
||||
if (!tagName || typeof tagName !== 'string') return null;
|
||||
const root = normalizeGiteaBaseUrl(cfg.gitea.base_url);
|
||||
const url = `${root}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/download/${encodeURIComponent(tagName)}/`;
|
||||
return { url, token };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
downloadGiteaReleaseAsset,
|
||||
fetchGiteaReleaseRecord,
|
||||
listGiteaReleaseAttachmentNames,
|
||||
giteaToken,
|
||||
useGiteaReleases,
|
||||
getGiteaUpdaterFeedBase,
|
||||
};
|
||||
@@ -3,7 +3,8 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { githubToken } = require('./github-token');
|
||||
const { fetchToFile, downloadBodyToFile } = require('./http-download');
|
||||
const { downloadGiteaReleaseAsset, useGiteaReleases, listGiteaReleaseAttachmentNames } = require('./gitea-release');
|
||||
const { fetchToFile, downloadBodyToFile, fetchOrThrow } = require('./http-download');
|
||||
|
||||
function encodeRepoPath(repoPath) {
|
||||
let p = String(repoPath || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||
@@ -34,7 +35,7 @@ async function downloadGitHubRepoFile(cfg, repoPath, destPath) {
|
||||
}
|
||||
|
||||
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${enc}?ref=${encodeURIComponent(ref)}`;
|
||||
const res = await fetch(apiUrl, { headers: ghHeaders(token, true) });
|
||||
const res = await fetchOrThrow(apiUrl, { headers: ghHeaders(token, true) });
|
||||
const body = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`GitHub contents API ${res.status}: ${body.slice(0, 800)}`);
|
||||
@@ -64,7 +65,7 @@ async function downloadGitHubRepoFile(cfg, repoPath, destPath) {
|
||||
throw new Error(`unexpected GitHub response for ${repoPath}`);
|
||||
}
|
||||
|
||||
async function downloadReleaseAsset(cfg, assetName, destPath) {
|
||||
async function fetchGitHubReleaseJson(cfg) {
|
||||
const token = githubToken(cfg);
|
||||
const tag = (cfg.github.release_tag || 'latest').trim() || 'latest';
|
||||
const { owner, repo } = cfg.github;
|
||||
@@ -74,15 +75,35 @@ async function downloadReleaseAsset(cfg, assetName, destPath) {
|
||||
} else {
|
||||
listUrl = `https://api.github.com/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`;
|
||||
}
|
||||
const res = await fetch(listUrl, { headers: ghHeaders(token, true) });
|
||||
const res = await fetchOrThrow(listUrl, { headers: ghHeaders(token, true) });
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
let hint = '';
|
||||
if (res.status === 404) hint = ' (wrong tag or private repo without token?)';
|
||||
if (res.status === 404) {
|
||||
hint =
|
||||
' (wrong tag, private repo without token, or releases live on Gitea — set gitea.base_url, gitea.owner, gitea.repo in launcher.json)';
|
||||
}
|
||||
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)}`);
|
||||
}
|
||||
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 || [];
|
||||
let assetURL = '';
|
||||
for (const a of assets) {
|
||||
@@ -107,8 +128,14 @@ async function downloadReleaseAsset(cfg, assetName, destPath) {
|
||||
h.Authorization = `Bearer ${token}`;
|
||||
h['X-GitHub-Api-Version'] = '2022-11-28';
|
||||
}
|
||||
const dl = await fetch(assetURL, { headers: h, redirect: 'follow' });
|
||||
const dl = await fetchOrThrow(assetURL, { headers: h, redirect: 'follow' });
|
||||
await downloadBodyToFile(dl, destPath);
|
||||
}
|
||||
|
||||
module.exports = { downloadGitHubRepoFile, downloadReleaseAsset, encodeRepoPath };
|
||||
module.exports = {
|
||||
downloadGitHubRepoFile,
|
||||
downloadReleaseAsset,
|
||||
encodeRepoPath,
|
||||
fetchGitHubReleaseJson,
|
||||
listReleaseAttachmentNames,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,42 @@ const { createWriteStream } = require('fs');
|
||||
const { pipeline } = require('stream/promises');
|
||||
const { Readable } = require('stream');
|
||||
|
||||
function safeUrlForLog(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return `${u.origin}${u.pathname}`;
|
||||
} catch {
|
||||
return String(url || '').split('?')[0].slice(0, 200);
|
||||
}
|
||||
}
|
||||
|
||||
function explainFetchFailure(err, url) {
|
||||
const msg = err && err.message ? err.message : String(err);
|
||||
const cause = err && err.cause;
|
||||
const code = cause && cause.code ? cause.code : '';
|
||||
const combined = `${msg} ${code}`;
|
||||
const hints = [];
|
||||
if (/CERT|TLS|SSL|UNABLE_TO_VERIFY|SELF_SIGNED|certificate|unknown ca|unable to verify/i.test(combined)) {
|
||||
hints.push(
|
||||
'TLS certificate not trusted — install a valid cert on Gitea, or trust your CA system-wide, or set NODE_EXTRA_CA_CERTS to a .pem bundle (self-signed mirrors)'
|
||||
);
|
||||
}
|
||||
if (/ECONNREFUSED/.test(combined)) hints.push('connection refused (wrong host/port or server down)');
|
||||
if (/ENOTFOUND|EAI_AGAIN/.test(combined)) hints.push('DNS lookup failed');
|
||||
if (/ETIMEDOUT|TIMEOUT/i.test(combined)) hints.push('connection timed out');
|
||||
const hintStr = hints.length ? ` ${hints.join(' ')}` : '';
|
||||
return new Error(`${msg}${hintStr} — ${safeUrlForLog(url)}`);
|
||||
}
|
||||
|
||||
/** Wrap global fetch with clearer errors for TLS/DNS/refused (Electron reports bare "fetch failed"). */
|
||||
async function fetchOrThrow(url, init) {
|
||||
try {
|
||||
return await fetch(url, init);
|
||||
} catch (e) {
|
||||
throw explainFetchFailure(e, url);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadBodyToFile(res, destPath) {
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => '');
|
||||
@@ -30,11 +66,11 @@ async function downloadBodyToFile(res, destPath) {
|
||||
}
|
||||
|
||||
async function fetchToFile(url, headers, destPath) {
|
||||
const res = await fetch(url, {
|
||||
const res = await fetchOrThrow(url, {
|
||||
headers,
|
||||
redirect: 'follow',
|
||||
});
|
||||
await downloadBodyToFile(res, destPath);
|
||||
}
|
||||
|
||||
module.exports = { fetchToFile, downloadBodyToFile };
|
||||
module.exports = { fetchToFile, downloadBodyToFile, fetchOrThrow, safeUrlForLog };
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { downloadReleaseAsset, downloadGitHubRepoFile } = require('./github');
|
||||
|
||||
async function sha256File(absPath) {
|
||||
const buf = await fs.readFile(absPath);
|
||||
return createHash('sha256').update(buf).digest('hex');
|
||||
}
|
||||
|
||||
function stateDir(gameDir) {
|
||||
return path.join(gameDir, '.fractured');
|
||||
}
|
||||
|
||||
function statePath(gameDir) {
|
||||
return path.join(stateDir(gameDir), 'patch-state.json');
|
||||
}
|
||||
|
||||
async function readPatchState(gameDir) {
|
||||
if (!gameDir) return null;
|
||||
try {
|
||||
const t = await fs.readFile(statePath(gameDir), 'utf8');
|
||||
return JSON.parse(t);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writePatchState(gameDir, manifestVersion, fileShas) {
|
||||
const p = statePath(gameDir);
|
||||
await fs.mkdir(path.dirname(p), { recursive: true });
|
||||
const body = {
|
||||
client_build: manifestVersion,
|
||||
updated_at: new Date().toISOString(),
|
||||
files: fileShas,
|
||||
};
|
||||
const tmp = p + '.tmp';
|
||||
await fs.writeFile(tmp, JSON.stringify(body, null, 2), 'utf8');
|
||||
await fs.rename(tmp, p);
|
||||
}
|
||||
|
||||
function validateManifest(m) {
|
||||
if (!m || m.version == null || String(m.version).trim() === '') return false;
|
||||
if (!m.files || typeof m.files !== 'object') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and parse patch-manifest.json (or custom name). Returns null on any failure.
|
||||
*/
|
||||
async function loadManifest(cfg) {
|
||||
const pm = cfg.patch_manifest;
|
||||
if (!pm || !pm.enabled || !String(pm.source || '').trim()) return null;
|
||||
const tmp = path.join(os.tmpdir(), `fr-patch-manifest-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
|
||||
try {
|
||||
if (pm.from_release) {
|
||||
await downloadReleaseAsset(cfg, String(pm.source).trim(), tmp);
|
||||
} else {
|
||||
await downloadGitHubRepoFile(cfg, String(pm.source).trim(), tmp);
|
||||
}
|
||||
const raw = await fs.readFile(tmp, 'utf8');
|
||||
await fs.unlink(tmp).catch(() => {});
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
await fs.unlink(tmp).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True if every from_release file on disk matches manifest sha256.
|
||||
*/
|
||||
async function patchesMatchManifest(cfg, manifest, onStatus) {
|
||||
if (!validateManifest(manifest)) return false;
|
||||
const { buildResolvedReleaseFiles } = require('./release-sync');
|
||||
const entries = await buildResolvedReleaseFiles(cfg, manifest);
|
||||
const gameDir = cfg.game_dir;
|
||||
for (const entry of entries) {
|
||||
if (!entry.from_release) continue;
|
||||
const spec = manifest.files[entry.source];
|
||||
if (!spec || !spec.sha256) return false;
|
||||
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
|
||||
const destAbs = path.join(gameDir, ...parts);
|
||||
let disk;
|
||||
try {
|
||||
disk = await sha256File(destAbs);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (disk.toLowerCase() !== String(spec.sha256).trim().toLowerCase()) return false;
|
||||
}
|
||||
if (onStatus) {
|
||||
onStatus(`Client files already match build ${manifest.version} (nothing to download).`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function verifyInstalledAgainstManifest(cfg, manifest) {
|
||||
if (!validateManifest(manifest)) return;
|
||||
const { buildResolvedReleaseFiles } = require('./release-sync');
|
||||
const entries = await buildResolvedReleaseFiles(cfg, manifest);
|
||||
for (const entry of entries) {
|
||||
if (!entry.from_release) continue;
|
||||
const spec = manifest.files[entry.source];
|
||||
if (!spec || !spec.sha256) {
|
||||
throw new Error(
|
||||
`patch-manifest.json is missing a sha256 for "${entry.source}" — regenerate the manifest for this release.`
|
||||
);
|
||||
}
|
||||
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
|
||||
const destAbs = path.join(cfg.game_dir, ...parts);
|
||||
const disk = await sha256File(destAbs);
|
||||
if (disk.toLowerCase() !== String(spec.sha256).trim().toLowerCase()) {
|
||||
throw new Error(
|
||||
`${entry.source}: checksum mismatch after install (expected ${spec.sha256.slice(0, 12)}…, got ${disk.slice(0, 12)}…). Try syncing again.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function recordPatchState(cfg, manifest) {
|
||||
if (!validateManifest(manifest)) return;
|
||||
const { buildResolvedReleaseFiles } = require('./release-sync');
|
||||
const entries = await buildResolvedReleaseFiles(cfg, manifest);
|
||||
const shas = {};
|
||||
for (const entry of entries) {
|
||||
if (!entry.from_release) continue;
|
||||
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
|
||||
const destAbs = path.join(cfg.game_dir, ...parts);
|
||||
try {
|
||||
shas[entry.source] = await sha256File(destAbs);
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
await writePatchState(cfg.game_dir, String(manifest.version), shas);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadManifest,
|
||||
validateManifest,
|
||||
patchesMatchManifest,
|
||||
verifyInstalledAgainstManifest,
|
||||
recordPatchState,
|
||||
readPatchState,
|
||||
statePath,
|
||||
};
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const fsSync = require('fs');
|
||||
const { downloadGitHubRepoFile, downloadReleaseAsset } = require('./github');
|
||||
const { normalizeWinGameDir } = require('./win-game-dir');
|
||||
const { loadManifest } = require('./patch-manifest');
|
||||
const { buildResolvedReleaseFiles } = require('./release-sync');
|
||||
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
@@ -13,19 +17,44 @@ function backupSuffix() {
|
||||
}
|
||||
|
||||
function wowExePath(cfg) {
|
||||
const gd = normalizeWinGameDir(cfg.game_dir || '');
|
||||
const exe = (cfg.launch && cfg.launch.exe) || 'Wow.exe';
|
||||
const parts = exe.replace(/\\/g, '/').split('/').filter(Boolean);
|
||||
return path.join(cfg.game_dir, ...parts);
|
||||
const primary = path.join(gd, ...parts);
|
||||
if (process.platform === 'win32' && gd && fsSync.existsSync(primary)) return primary;
|
||||
if (process.platform === 'win32' && gd) {
|
||||
try {
|
||||
const base = path.basename(primary);
|
||||
const dir = path.dirname(primary);
|
||||
const names = fsSync.readdirSync(dir);
|
||||
const hit = names.find((n) => n.toLowerCase() === base.toLowerCase());
|
||||
if (hit) {
|
||||
const alt = path.join(dir, hit);
|
||||
if (fsSync.statSync(alt).isFile()) return alt;
|
||||
}
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return primary;
|
||||
}
|
||||
|
||||
function wowInstallValid(cfg) {
|
||||
if (!cfg.game_dir) return false;
|
||||
return require('fs').existsSync(wowExePath(cfg));
|
||||
const p = wowExePath(cfg);
|
||||
return fsSync.existsSync(p) && fsSync.statSync(p).isFile();
|
||||
}
|
||||
|
||||
/** WoW expects patch MPQ names with a literal .MPQ extension (case-sensitive clients). */
|
||||
function normalizeMpqDestinationPath(absPath) {
|
||||
const s = String(absPath || '');
|
||||
return /\.mpq$/i.test(s) ? s.replace(/\.mpq$/i, '.MPQ') : s;
|
||||
}
|
||||
|
||||
async function installFile(cfg, entry) {
|
||||
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
|
||||
const destAbs = path.join(cfg.game_dir, ...parts);
|
||||
const root = normalizeWinGameDir(cfg.game_dir || '');
|
||||
const destAbs = normalizeMpqDestinationPath(path.join(root, ...parts));
|
||||
if (entry.backup) {
|
||||
try {
|
||||
const st = await fs.stat(destAbs);
|
||||
@@ -66,14 +95,19 @@ async function applyRealmlist(cfg) {
|
||||
const r = String(rel).trim().replace(/\\/g, '/');
|
||||
if (!r) continue;
|
||||
const segs = r.split('/').filter(Boolean);
|
||||
const abs = path.join(cfg.game_dir, ...segs);
|
||||
const abs = path.join(normalizeWinGameDir(cfg.game_dir || ''), ...segs);
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
await fs.writeFile(abs, content, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
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} …`);
|
||||
try {
|
||||
await installFile(cfg, f);
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
'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 mpqDestFromSource(source) {
|
||||
const base = path.basename(String(source || ''));
|
||||
const stem = base.replace(/\.mpq$/i, '');
|
||||
return `Data/enUS/${stem}.MPQ`;
|
||||
}
|
||||
|
||||
function destForReleaseSource(source, cfg) {
|
||||
const base = path.basename(String(source || ''));
|
||||
if (/\.mpq$/i.test(base)) return mpqDestFromSource(source);
|
||||
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: mpqDestFromSource(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,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Under Wine, the folder picker often returns a Unix absolute path (/home/...).
|
||||
* Windows Node does not resolve that to the WoW install; map to Wine's Z: drive
|
||||
* (Z: == / on typical Wine prefixes).
|
||||
*/
|
||||
function normalizeWinGameDir(gameDir) {
|
||||
if (process.platform !== 'win32') return String(gameDir || '').trim();
|
||||
let s = String(gameDir || '').trim();
|
||||
if (!s) return s;
|
||||
s = s.replace(/\//g, path.win32.sep);
|
||||
if (s.startsWith('\\\\')) return path.normalize(s);
|
||||
if (/^[A-Za-z]:/.test(s)) return path.normalize(s);
|
||||
if (s.startsWith(path.win32.sep)) return path.win32.normalize(`Z:${s}`);
|
||||
return path.normalize(s);
|
||||
}
|
||||
|
||||
module.exports = { normalizeWinGameDir };
|
||||
@@ -4,7 +4,9 @@ const { app, BrowserWindow, ipcMain, dialog, Menu } = require('electron');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
const { loadConfig, saveGameDir, resolveGameDir } = require('./lib/config-store');
|
||||
const { normalizeWinGameDir } = require('./lib/win-game-dir');
|
||||
const { applyPatches, wowExePath, wowInstallValid, doAuth } = require('./lib/patch');
|
||||
const { readPatchState } = require('./lib/patch-manifest');
|
||||
const { setupAutoUpdater } = require('./lib/auto-update');
|
||||
|
||||
let mainWindow;
|
||||
@@ -46,16 +48,23 @@ async function readMergedConfig() {
|
||||
app.whenReady().then(async () => {
|
||||
createWindow();
|
||||
const { config } = await loadConfig(app);
|
||||
const tokenEnv = config.github && config.github.token_env;
|
||||
const token =
|
||||
(tokenEnv && String(process.env[tokenEnv] || '').trim()) ||
|
||||
const ghEnv = config.github && config.github.token_env;
|
||||
const githubToken =
|
||||
(ghEnv && String(process.env[ghEnv] || '').trim()) ||
|
||||
String(process.env.GH_TOKEN || process.env.GITHUB_TOKEN || '').trim();
|
||||
const giteaEnv = config.gitea && config.gitea.token_env;
|
||||
const giteaToken =
|
||||
(giteaEnv && String(process.env[giteaEnv] || '').trim()) ||
|
||||
String(process.env.GITEA_TOKEN || '').trim();
|
||||
const updateFeedUrl = String(process.env.LAUNCHER_UPDATE_URL || config.update_feed_url || '').trim();
|
||||
autoUpdateApi = setupAutoUpdater(app, () => mainWindow, {
|
||||
autoUpdateApi = await setupAutoUpdater(app, () => mainWindow, {
|
||||
updateFeedUrl,
|
||||
config,
|
||||
githubOwner: config.github && config.github.owner,
|
||||
githubRepo: config.github && config.github.repo,
|
||||
token,
|
||||
githubToken,
|
||||
giteaToken,
|
||||
allowGithubLauncherUpdates: config.launcher_updates_from_github === true,
|
||||
});
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
@@ -68,12 +77,18 @@ app.on('window-all-closed', () => {
|
||||
|
||||
ipcMain.handle('launcher:load', async () => {
|
||||
const { configPath, config } = await readMergedConfig();
|
||||
let clientBuild = '';
|
||||
if (wowInstallValid(config)) {
|
||||
const st = await readPatchState(config.game_dir);
|
||||
if (st && st.client_build) clientBuild = String(st.client_build);
|
||||
}
|
||||
return {
|
||||
configPath,
|
||||
gameDir: config.game_dir || '',
|
||||
authEnabled: !!(config.auth && config.auth.enabled),
|
||||
wowExe: (config.launch && config.launch.exe) || 'Wow.exe',
|
||||
wowOk: wowInstallValid(config),
|
||||
clientBuild,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -81,7 +96,8 @@ ipcMain.handle('launcher:saveGameDir', async (_e, dir) => {
|
||||
const trimmed = String(dir || '').trim();
|
||||
if (!trimmed) throw new Error('folder path is empty');
|
||||
const { configPath } = await loadConfig(app);
|
||||
const norm = path.normalize(trimmed);
|
||||
const norm =
|
||||
process.platform === 'win32' ? normalizeWinGameDir(path.normalize(trimmed)) : path.normalize(trimmed);
|
||||
const probe = { ...(await readMergedConfig()).config, game_dir: norm };
|
||||
if (!wowInstallValid(probe)) {
|
||||
throw new Error(`That folder does not contain ${(probe.launch && probe.launch.exe) || 'Wow.exe'}`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fractured-launcher-electron",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.9",
|
||||
"description": "Fractured WoW launcher (Electron) — no console window, native folder picker, auto-update",
|
||||
"main": "main.js",
|
||||
"repository": {
|
||||
@@ -9,8 +9,9 @@
|
||||
},
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"pack:win": "electron-builder --win nsis portable --x64",
|
||||
"publish:win": "electron-builder --win nsis portable --x64 --publish always"
|
||||
"pack:win": "electron-builder --win nsis portable --x64 --publish never",
|
||||
"pack:linux": "electron-builder --linux AppImage --x64 --publish never",
|
||||
"publish:win": "electron-builder --win nsis portable --x64 --publish never"
|
||||
},
|
||||
"author": "",
|
||||
"license": "GPL-3.0",
|
||||
@@ -27,13 +28,7 @@
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"publish": [
|
||||
{
|
||||
"provider": "github",
|
||||
"owner": "Dawnforger",
|
||||
"repo": "Fractured-Distro"
|
||||
}
|
||||
],
|
||||
"publish": null,
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.cjs",
|
||||
@@ -41,6 +36,10 @@
|
||||
"renderer.js",
|
||||
"styles.css",
|
||||
"default-launcher.json",
|
||||
"lib/win-game-dir.js",
|
||||
"lib/baked-gitea-channel.js",
|
||||
"lib/gitea-release.js",
|
||||
"lib/patch-manifest.js",
|
||||
"lib/**/*"
|
||||
],
|
||||
"win": {
|
||||
@@ -62,6 +61,18 @@
|
||||
},
|
||||
"portable": {
|
||||
"artifactName": "Fractured-Launcher-${version}-Windows-Portable.${ext}"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": ["x64"]
|
||||
}
|
||||
],
|
||||
"category": "Game"
|
||||
},
|
||||
"appImage": {
|
||||
"artifactName": "Fractured-Launcher-${version}-Linux-x86_64.${ext}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# Push a one-file README so the Gitea repo is non-empty (fixes HTTP 422 "repo is empty"
|
||||
# when CI creates a release). Safe to re-run only if the repo still has no commits;
|
||||
# if it already has history, skip or use the Gitea web UI instead.
|
||||
#
|
||||
# Usage:
|
||||
# export GITEA_BASE_URL=https://git.example.com
|
||||
# export GITEA_OWNER=myorg
|
||||
# export GITEA_REPO=fractured-patches
|
||||
# ./bootstrap-gitea-repo.sh
|
||||
#
|
||||
# Or pass an explicit clone URL (HTTPS or SSH):
|
||||
# ./bootstrap-gitea-repo.sh https://git.example.com/myorg/fractured-patches.git
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
BRANCH="${GITEA_TARGET_REF:-main}"
|
||||
|
||||
if [ "${1:-}" != "" ]; then
|
||||
URL="$1"
|
||||
else
|
||||
: "${GITEA_BASE_URL:?Set GITEA_BASE_URL or pass clone URL as first argument}"
|
||||
: "${GITEA_OWNER:?Set GITEA_OWNER or pass clone URL as first argument}"
|
||||
: "${GITEA_REPO:?Set GITEA_REPO or pass clone URL as first argument}"
|
||||
BASE="${GITEA_BASE_URL%/}"
|
||||
URL="${BASE}/${GITEA_OWNER}/${GITEA_REPO}.git"
|
||||
fi
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
cd "$TMP"
|
||||
|
||||
git init -q
|
||||
git checkout -q -b "$BRANCH"
|
||||
|
||||
cat >README.md <<'EOF'
|
||||
# Fractured release mirror
|
||||
|
||||
Release assets (launcher builds, patches, `patch-manifest.json`, etc.) are uploaded here by **GitHub Actions** (“Sync release to Gitea”) from the main Fractured repository.
|
||||
|
||||
This initial commit exists because **Gitea requires at least one commit** in the repository before releases can be created.
|
||||
EOF
|
||||
|
||||
git add README.md
|
||||
git commit -q -m "chore: initial commit (required for Gitea releases)"
|
||||
|
||||
git remote add origin "$URL"
|
||||
git push -u origin "$BRANCH"
|
||||
|
||||
echo "Pushed initial README to $URL (branch $BRANCH)."
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build patch-manifest.json for a release (same names as files[].source in launcher.json).
|
||||
*
|
||||
* Usage (from a folder containing the patch binaries — list every files[].source name):
|
||||
* node generate-patch-manifest.js v0.9.0-client Wow-patched.exe
|
||||
*
|
||||
* Prints JSON to stdout — redirect to file:
|
||||
* node generate-patch-manifest.js v0.9.0-client Wow-patched.exe > patch-manifest.json
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const version = process.argv[2];
|
||||
const names = process.argv.slice(3);
|
||||
if (!version || names.length === 0) {
|
||||
console.error('Usage: generate-patch-manifest.js <version-label> <file1> [file2 ...]');
|
||||
console.error(' Example: generate-patch-manifest.js v0.9.0-client Wow-patched.exe');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const out = { version, files: {} };
|
||||
for (const f of names) {
|
||||
const base = path.basename(f);
|
||||
const buf = fs.readFileSync(f);
|
||||
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
||||
out.files[base] = { sha256 };
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Local Linux AppImage build (uses current tree — no tag snapshot). Run from repo root or this dir.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
echo "==> npm ci"
|
||||
npm ci
|
||||
echo "==> npm run pack:linux (AppImage x64)"
|
||||
npm run pack:linux
|
||||
echo "==> dist/:"
|
||||
ls -la dist/
|
||||
@@ -3,7 +3,7 @@
|
||||
#
|
||||
# Usage (from repo root or this directory):
|
||||
# export GH_TOKEN=ghp_... # PAT with repo/releases on the distro repo
|
||||
# ./tools/fractured-launcher-electron/scripts/publish-to-distro.sh v1.0.0 patch-Z.MPQ Wow-patched.exe
|
||||
# ./tools/fractured-launcher-electron/scripts/publish-to-distro.sh v1.0.0 Wow-patched.exe
|
||||
#
|
||||
# Optional:
|
||||
# DISTRO_REPO=YourOrg/Fratured-Distro # if your GitHub slug differs
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
# Upload all files in a directory as attachments on a Gitea release (create release if missing).
|
||||
#
|
||||
# Usage:
|
||||
# export GITEA_BASE_URL=https://git.example.com
|
||||
# export GITEA_TOKEN=gta_...
|
||||
# export GITEA_OWNER=myorg
|
||||
# export GITEA_REPO=fractured-patches
|
||||
# export GITEA_TARGET_REF=main # optional, used when creating a new release (tag must not exist yet)
|
||||
# ./upload-release-to-gitea.sh /path/to/combined v1.0.0
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
COMBINED_DIR="${1:?first arg: directory of files to attach}"
|
||||
TAG="${2:?second arg: release tag (e.g. v1.0.0)}"
|
||||
|
||||
: "${GITEA_BASE_URL:?Set GITEA_BASE_URL (no trailing slash required)}"
|
||||
: "${GITEA_TOKEN:?Set GITEA_TOKEN}"
|
||||
: "${GITEA_OWNER:?Set GITEA_OWNER}"
|
||||
: "${GITEA_REPO:?Set GITEA_REPO}"
|
||||
|
||||
BASE="${GITEA_BASE_URL%/}"
|
||||
API="$BASE/api/v1"
|
||||
TARGET="${GITEA_TARGET_REF:-main}"
|
||||
AUTH_H=(-H "Authorization: token ${GITEA_TOKEN}" -H "Accept: application/json")
|
||||
|
||||
TAG_ENC=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$TAG")
|
||||
REL_JSON=$(mktemp)
|
||||
trap 'rm -f "$REL_JSON"' EXIT
|
||||
|
||||
code=$(curl -sS -o "$REL_JSON" -w "%{http_code}" "${AUTH_H[@]}" \
|
||||
"$API/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/tags/${TAG_ENC}")
|
||||
|
||||
if [ "$code" = "200" ]; then
|
||||
rel_id=$(jq -r '.id' "$REL_JSON")
|
||||
elif [ "$code" = "404" ]; then
|
||||
body=$(jq -n \
|
||||
--arg tag "$TAG" \
|
||||
--arg name "Fractured $TAG" \
|
||||
--arg body "Synced from GitHub Actions (Fractured)." \
|
||||
--arg target "$TARGET" \
|
||||
'{tag_name:$tag,name:$name,body:$body,draft:false,prerelease:false,target_commitish:$target}')
|
||||
code=$(curl -sS -o "$REL_JSON" -w "%{http_code}" -X POST "${AUTH_H[@]}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$body" \
|
||||
"$API/repos/${GITEA_OWNER}/${GITEA_REPO}/releases")
|
||||
if [ "$code" != "201" ] && [ "$code" != "200" ]; then
|
||||
echo "Gitea create release failed HTTP $code:" >&2
|
||||
cat "$REL_JSON" >&2
|
||||
if [ "$code" = "422" ] && jq -e '.message == "repo is empty"' "$REL_JSON" >/dev/null 2>&1; then
|
||||
echo >&2
|
||||
echo "Gitea does not allow releases on a repo with zero commits. Fix: push at least one commit" >&2
|
||||
echo "to ${GITEA_OWNER}/${GITEA_REPO} (e.g. add README.md on branch ${TARGET} via web UI or git push)," >&2
|
||||
echo "or set Actions variable GITEA_TARGET_REF to an existing default branch name." >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
rel_id=$(jq -r '.id' "$REL_JSON")
|
||||
else
|
||||
echo "Gitea GET release by tag failed HTTP $code:" >&2
|
||||
cat "$REL_JSON" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$rel_id" ] || [ "$rel_id" = "null" ]; then
|
||||
echo "Could not resolve Gitea release id" >&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")
|
||||
|
||||
shopt -s nullglob
|
||||
files=("$COMBINED_DIR"/*)
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
echo "No files in $COMBINED_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for f in "${files[@]}"; do
|
||||
[ -f "$f" ] || continue
|
||||
echo "Uploading $(basename "$f") …"
|
||||
curl -fsS -X POST "${AUTH_H[@]}" \
|
||||
-F "attachment=@${f}" \
|
||||
"$API/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/${rel_id}/assets"
|
||||
done
|
||||
|
||||
echo "Gitea release $TAG (id=$rel_id) updated with ${#files[@]} file(s)."
|
||||
Reference in New Issue
Block a user