Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef02839ea0 | |||
| 377927b878 | |||
| a251e56c59 | |||
| 7de018f7eb | |||
| abb25f56d1 | |||
| 7a92231614 |
@@ -0,0 +1,150 @@
|
||||
# When a release is published on this repo (or manual dispatch):
|
||||
# 1. Builds the Electron launcher from that tag (npm run pack:win).
|
||||
# 2. Downloads any assets attached to the same release on this repo (patches, Wow exe, …).
|
||||
# 3. Merges them (launcher files win on name collision) and creates/updates the matching
|
||||
# release on Fractured-Distro.
|
||||
#
|
||||
# Setup (GitHub → Settings → Secrets and variables → Actions):
|
||||
# DISTRO_SYNC_TOKEN — PAT with releases write on Fractured-Distro (see repo README).
|
||||
#
|
||||
# Change DISTRO_REPO or the job `if:` if your GitHub slugs differ.
|
||||
|
||||
name: Sync release to Fractured-Distro
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag on this repo (must exist; e.g. v1.0.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
DISTRO_REPO: Dawnforger/Fractured-Distro
|
||||
|
||||
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: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
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 }}
|
||||
|
||||
- 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
|
||||
path: launcher-publish/
|
||||
|
||||
sync-distro:
|
||||
needs: [meta, build-electron]
|
||||
if: github.repository == 'Dawnforger/Fractured'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: electron-dist
|
||||
path: /tmp/electron
|
||||
|
||||
- name: Merge main 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 "Main release download note (continuing with launcher only):"
|
||||
cat /tmp/dl.err || true
|
||||
fi
|
||||
shopt -s nullglob
|
||||
for f in /tmp/electron/*; do
|
||||
if [ -f "$f" ]; then
|
||||
cp -f "$f" combined/
|
||||
fi
|
||||
done
|
||||
echo "Combined directory:"
|
||||
ls -la combined/
|
||||
|
||||
- name: Upload to Fractured-Distro
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.DISTRO_SYNC_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "Missing secret DISTRO_SYNC_TOKEN (PAT with access to $DISTRO_REPO)."
|
||||
exit 1
|
||||
fi
|
||||
TAG="${{ needs.meta.outputs.tag }}"
|
||||
shopt -s nullglob
|
||||
files=(combined/*)
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
echo "Nothing to upload (Electron pack produced no files?)."
|
||||
exit 1
|
||||
fi
|
||||
SRC_URL="https://github.com/${{ github.repository }}/releases/tag/${TAG}"
|
||||
if gh release view "$TAG" -R "$DISTRO_REPO" &>/dev/null; then
|
||||
gh release upload "$TAG" -R "$DISTRO_REPO" "${files[@]}" --clobber
|
||||
echo "Uploaded (clobber) to $DISTRO_REPO release $TAG"
|
||||
else
|
||||
gh release create "$TAG" -R "$DISTRO_REPO" \
|
||||
--title "Fractured $TAG" \
|
||||
--notes "Synced from [$TAG]($SRC_URL) on ${{ github.repository }}. Includes CI-built Electron launcher + release assets." \
|
||||
"${files[@]}"
|
||||
echo "Created $DISTRO_REPO release $TAG with ${#files[@]} asset(s)."
|
||||
fi
|
||||
@@ -0,0 +1,52 @@
|
||||
# Verifies Electron launcher Windows pack and uploads installers for testing.
|
||||
|
||||
name: Fractured launcher CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [master, main]
|
||||
paths:
|
||||
- 'tools/fractured-launcher-electron/**'
|
||||
- '.github/workflows/fractured-launcher-ci.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'tools/fractured-launcher-electron/**'
|
||||
- '.github/workflows/fractured-launcher-ci.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: fractured-launcher-ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
electron-launcher:
|
||||
runs-on: windows-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 (NSIS + portable)
|
||||
run: |
|
||||
npm ci
|
||||
npm run pack:win
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fractured-launcher-electron-windows-${{ github.run_id }}
|
||||
if-no-files-found: warn
|
||||
path: |
|
||||
tools/fractured-launcher-electron/dist/*.exe
|
||||
tools/fractured-launcher-electron/dist/latest.yml
|
||||
tools/fractured-launcher-electron/dist/*.blockmap
|
||||
@@ -7,6 +7,13 @@ re-downloaded without bloating `git clone`.
|
||||
|
||||
This file is the table of contents and install guide.
|
||||
|
||||
**Launcher (Windows):** The maintained client launcher lives in
|
||||
[`tools/fractured-launcher-electron/`](../../tools/fractured-launcher-electron/)
|
||||
(see its README for build and config). **Public downloads** for the launcher
|
||||
and mirrored patch assets are pushed to
|
||||
[Fractured-Distro releases](https://github.com/Dawnforger/Fractured-Distro/releases)
|
||||
when a release is published here (workflow **Sync release to Fractured-Distro**).
|
||||
|
||||
---
|
||||
|
||||
## What ships in a release
|
||||
@@ -14,15 +21,21 @@ This file is the table of contents and install guide.
|
||||
| Artifact | Size | Purpose |
|
||||
|---|---|---|
|
||||
| `patch-enUS-4.MPQ` | ~5 MB | DBC + GlueXML bake. Adds `CLASS_PARAGON` (id 12), the character-create slot, glue strings, game-table DBCs, and a patched `Spell.dbc`: **(1)** `RuneCostID` zeroed on every rune-cost spell so non–Death Knight clients still send DK casts (rune costs are shown via `RuneFrame.lua`); **(2)** `Reagent[]` / `ReagentCount[]` zeroed on every spell whose `SpellFamilyName` is non-zero (all class abilities), while profession crafts (`SpellFamilyName == 0`) keep their materials. Both edits mirror server load-time corrections so client preflight and server validation stay aligned. Required for character creation as Paragon to even show up. |
|
||||
| `patch-enUS-5.MPQ` | ~50 KB | FrameXML overrides. Replaces stock `PlayerFrame.lua` / `RuneFrame.lua` / `ComboFrame.lua` / `UnitFrame.lua` / `SpellBookFrame.lua` + `SpellBookFrame.xml` with Paragon-aware versions: rune simulator, combo-point simulator, server-authoritative resource sync over the `PARAA` addon channel, action-button usability + click guards, an expanded spellbook (higher `MAX_SPELLS`, 24 skill-line tabs instead of stock 8) so all-class spells render, Paragon stat tooltips on the character sheet, and a tooltip post-processor that appends ", Paragon" to the "Classes:" line on class-restricted gear / glyphs (the server bypasses `AllowableClass` for class 12, but the engine paints the line red and omits Paragon — the Lua hook recolors it green and adds the name so the player can tell it's wearable). The paper-doll **ammo slot** follows stock visibility rules (shown for hunters / ranged weapons; hidden when `UnitHasRelicSlot` applies). |
|
||||
| `patch-enUS-6.MPQ` | ~160 KB | The `ParagonAdvancement` addon. Replaces the talent pane (`N` key) for Paragon characters with the Character Advancement panel: per-class spell tabs, talent grid, Overview/Search tabs, AE/TE currency, commit / reset / preview, login-time toast suppression. |
|
||||
| `patch-enUS-5.MPQ` | ~57 KB | FrameXML overrides. Replaces stock `PlayerFrame.lua` / `RuneFrame.lua` / `ComboFrame.lua` / `UnitFrame.lua` / `SpellBookFrame.lua` + `SpellBookFrame.xml` with Paragon-aware versions: rune simulator, combo-point simulator, server-authoritative resource sync over the `PARAA` addon channel, action-button usability + click guards, an expanded spellbook (higher `MAX_SPELLS`, 24 skill-line tabs instead of stock 8) so all-class spells render, Paragon stat tooltips on the character sheet (including filtering duplicate “attack power from strength” lines so the paper doll matches server AP), a tooltip post-processor that appends ", Paragon" to the "Classes:" line on class-restricted gear / glyphs (the server bypasses `AllowableClass` for class 12, but the engine paints the line red and omits Paragon — the Lua hook recolors it green and adds the name so the player can tell it's wearable), and **PetFrame** re-anchored so the **pet unit frame sits below the rune row** for Paragon (stock layout had runes overlapping the pet portrait). The paper-doll **ammo slot** follows stock visibility rules (shown for hunters / ranged weapons; hidden when `UnitHasRelicSlot` applies). |
|
||||
| `patch-enUS-6.MPQ` | ~134 KB | The `ParagonAdvancement` addon. Replaces the talent pane (`N` key) for Paragon characters with the Character Advancement panel: per-class spell tabs, talent grid, Overview/Search tabs, AE/TE currency, commit / reset / preview, login-time toast suppression, a **PETS** tab with live hunter pet talent trees (preview learn, no TE/AE cost), a dedicated **Reset Pet Talents** control (server `PARAA` `C RESET PET TALENTS` — instant, no gold, no confirmation; requires matching worldserver), bottom-row **Reset all Abilities / Reset Build / Reset all Talents** disabled while on the PETS tab so those paths cannot dismiss the pet or unlearn Tame Beast, and a **Builds** page (full-pane overlay opened from the bottom-row Builds button) for saving named, icon-tagged loadouts: New Build (+) icon picker reuses `MACRO_ICON_FILENAMES`, right-click for edit/delete, shift-left-click to favorite (favorites bubble to the top), left-click pops a Load Build confirm. Build swaps reset + refund AE/TE, re-spend on the saved recipe, and **park hunter pets** to `PET_SAVE_NOT_IN_SLOT` so their name/talents/exp are preserved across swaps. |
|
||||
| `Wow.exe` | ~7.5 MB | 3.3.5a (build 12340) client byte-patched to skip the MPQ signature check so custom `patch-enUS-N.MPQ` files load. Diff against stock is a few bytes; everything else is unchanged. |
|
||||
|
||||
Server and client work as a pair: the addon talks to `mod-paragon` on the
|
||||
worldserver via `WHISPER` addon-channel messages with the `PARAA` prefix
|
||||
(currency push, spell/talent snapshot, commit, combo points, rune
|
||||
cooldowns, learn-toast silence window). Mismatched versions usually
|
||||
manifest as the panel rendering blank or AE/TE reading 0/0.
|
||||
cooldowns, learn-toast silence window, **`C RESET PET TALENTS`**
|
||||
for hunter pet talent resets from the Character Advancement PETS tab,
|
||||
and the **build catalog** verbs `Q BUILDS` / `C BUILD NEW` / `C BUILD
|
||||
EDIT` / `C BUILD DELETE` / `C BUILD FAVORITE` / `C BUILD LOAD` for the
|
||||
saved-loadout system on the Builds page). Build swaps require the
|
||||
matching worldserver image because the swap path is server-driven
|
||||
(snapshot → reset → re-spend → pet park/unpark). Mismatched versions
|
||||
usually manifest as the panel rendering blank or AE/TE reading 0/0.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
-- mod-paragon Character Advancement: Build catalog (saved loadouts).
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- A "build" is a named, icon-tagged loadout of panel-purchased spells and
|
||||
-- talent ranks. Each Paragon character can save many builds and swap
|
||||
-- between them via the Builds page in the Character Advancement panel.
|
||||
--
|
||||
-- Swap workflow (see HandleBuildLoad in Paragon_Builds.cpp):
|
||||
-- 1. If a build is currently active, snapshot the player's current
|
||||
-- panel-purchased spells + per-spec talent ranks into that build's
|
||||
-- recipe rows (overwriting the stored recipe).
|
||||
-- 2. If the active build's hunter pet is currently summoned, unsummon
|
||||
-- it to PET_SAVE_NOT_IN_SLOT and store its `pet_number` on the
|
||||
-- active build row so it can be restored on swap-back.
|
||||
-- 3. Reset all panel-bought abilities and talents (refunding AE/TE).
|
||||
-- 4. Re-buy each spell + talent in the target build's recipe (charging
|
||||
-- AE/TE; aborts if insufficient AE/TE -- player keeps refunded
|
||||
-- currency in that case and active becomes NULL).
|
||||
-- 5. Move the target build's parked pet (if any) back to current.
|
||||
-- 6. Update active_build pointer.
|
||||
--
|
||||
-- Pet ownership: a parked pet sits in `character_pet` with slot=100
|
||||
-- (PET_SAVE_NOT_IN_SLOT), exactly like the engine's stable-master
|
||||
-- offload, but tied to the build via `pet_number` instead of any
|
||||
-- in-game stable slot. Build deletion drops the parked pet rows
|
||||
-- entirely (PET_SAVE_AS_DELETED equivalent) -- player is warned.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `character_paragon_builds` (
|
||||
`build_id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`guid` INT UNSIGNED NOT NULL COMMENT 'characters.guid',
|
||||
`name` VARCHAR(32) NOT NULL,
|
||||
`icon` VARCHAR(64) NOT NULL DEFAULT 'INV_Misc_QuestionMark',
|
||||
`is_favorite` TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`pet_number` INT UNSIGNED NULL COMMENT 'character_pet.id of parked hunter pet, NULL when no pet bound to this build',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`build_id`),
|
||||
KEY `idx_guid` (`guid`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='mod-paragon: saved Character Advancement build catalog';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `character_paragon_build_spells` (
|
||||
`build_id` INT UNSIGNED NOT NULL,
|
||||
`spell_id` INT UNSIGNED NOT NULL,
|
||||
PRIMARY KEY (`build_id`, `spell_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='mod-paragon: per-build recipe -- panel-purchased spells';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `character_paragon_build_talents` (
|
||||
`build_id` INT UNSIGNED NOT NULL,
|
||||
`spec` TINYINT UNSIGNED NOT NULL COMMENT '0 = primary spec, 1 = secondary (dual spec)',
|
||||
`talent_id` SMALLINT UNSIGNED NOT NULL,
|
||||
`rank` TINYINT UNSIGNED NOT NULL,
|
||||
PRIMARY KEY (`build_id`, `spec`, `talent_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='mod-paragon: per-build recipe -- panel-purchased talent ranks per spec';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `character_paragon_active_build` (
|
||||
`guid` INT UNSIGNED NOT NULL COMMENT 'characters.guid',
|
||||
`build_id` INT UNSIGNED NOT NULL COMMENT 'character_paragon_builds.build_id (per-character active pointer)',
|
||||
PRIMARY KEY (`guid`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='mod-paragon: pointer to whichever build is currently loaded (one row per Paragon character)';
|
||||
@@ -0,0 +1,30 @@
|
||||
-- mod-paragon Character Advancement: Builds catalog schema cleanup.
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Two changes:
|
||||
-- 1. Drop `is_favorite` -- the favorite flag and shift-click-to-favorite
|
||||
-- flow are removed. Builds are now ordered solely by build_id ASC.
|
||||
-- 2. Add `share_code` CHAR(6) -- a random alphanumeric token generated
|
||||
-- server-side at build creation that uniquely identifies a saved
|
||||
-- build across the realm. Players exchange codes out-of-band and
|
||||
-- use the BuildsPane "Load Build!" share box to import a copy of
|
||||
-- the build (name + icon + spell + talent recipe) into their own
|
||||
-- catalog. The copy gets a fresh share_code so re-sharing is
|
||||
-- always traceable to the latest owner; the original isn't touched.
|
||||
--
|
||||
-- The column is NULL-tolerant so any rows that pre-date this migration
|
||||
-- (created under 2026_05_10_03's schema) coexist cleanly. The server
|
||||
-- backfills NULLs lazily in PushBuildCatalog -- the next time a player
|
||||
-- opens the BuildsPane on a Paragon character, any of their builds that
|
||||
-- still have a NULL share_code will get one generated and persisted.
|
||||
--
|
||||
-- Charset: 31 unambiguous chars (A-Z minus I/O minus 0/1) gives 31^6 ~=
|
||||
-- 887M codes; collision retry on insert keeps probability of a duplicate
|
||||
-- vanishing for any realistic catalog size.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE `character_paragon_builds`
|
||||
DROP COLUMN `is_favorite`,
|
||||
ADD COLUMN `share_code` CHAR(6) NULL DEFAULT NULL
|
||||
COMMENT 'random alphanumeric token for import-by-code; lazily generated'
|
||||
AFTER `icon`,
|
||||
ADD UNIQUE INDEX `uk_share_code` (`share_code`);
|
||||
@@ -0,0 +1,34 @@
|
||||
-- mod-paragon: preserve superseded share codes as importable snapshots.
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- When an active build is updated (Learn All), the live row gets a new
|
||||
-- share_code and a fresh recipe. Older codes the player posted to Discord
|
||||
-- must keep working: each retired code is frozen here with its spell/talent
|
||||
-- recipe so `C BUILD IMPORT <code>` still materializes that exact loadout.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `character_paragon_build_share_archive` (
|
||||
`share_code` CHAR(6) NOT NULL COMMENT 'retired code (same charset as live builds)',
|
||||
`name` VARCHAR(32) NOT NULL,
|
||||
`icon` VARCHAR(64) NOT NULL DEFAULT 'INV_Misc_QuestionMark',
|
||||
`archived_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`share_code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='mod-paragon: frozen build metadata for retired share codes';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `character_paragon_build_share_archive_spells` (
|
||||
`share_code` CHAR(6) NOT NULL,
|
||||
`spell_id` INT UNSIGNED NOT NULL,
|
||||
PRIMARY KEY (`share_code`, `spell_id`),
|
||||
KEY `idx_share` (`share_code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='mod-paragon: spell recipe rows for an archived share code';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `character_paragon_build_share_archive_talents` (
|
||||
`share_code` CHAR(6) NOT NULL,
|
||||
`spec` TINYINT UNSIGNED NOT NULL,
|
||||
`talent_id` SMALLINT UNSIGNED NOT NULL,
|
||||
`rank` TINYINT UNSIGNED NOT NULL,
|
||||
PRIMARY KEY (`share_code`, `spec`, `talent_id`),
|
||||
KEY `idx_share` (`share_code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='mod-paragon: talent recipe rows for an archived share code';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,12 @@
|
||||
|
||||
#include "Chat.h"
|
||||
#include "Config.h"
|
||||
#include "Creature.h"
|
||||
#include "CreatureData.h"
|
||||
#include "GameTime.h"
|
||||
#include "Log.h"
|
||||
#include "ObjectGuid.h"
|
||||
#include "Pet.h"
|
||||
#include "Player.h"
|
||||
#include "ScriptMgr.h"
|
||||
#include "SharedDefines.h"
|
||||
@@ -44,43 +47,142 @@ public:
|
||||
if (!player || player->getClass() != CLASS_PARAGON)
|
||||
return std::nullopt;
|
||||
|
||||
// Death Knight rune / runic power ability stack (narrow on purpose).
|
||||
if (unitClass == CLASS_DEATH_KNIGHT && context == CLASS_CONTEXT_ABILITY)
|
||||
// ============================================================
|
||||
// Ability stack -- claim ALL nine vanilla classes.
|
||||
// ============================================================
|
||||
// CLASS_CONTEXT_ABILITY is read by every class-specific spell
|
||||
// gate in core / scripts: DK rune mechanics (Spell.cpp,
|
||||
// SpellEffects.cpp, spell_dk.cpp, SpellAuraEffects.cpp),
|
||||
// Warrior Titan's Grip / Bladestorm (Player.cpp 3783, 15432,
|
||||
// PlayerUpdates.cpp 1547), Paladin Rebuke (Player.cpp 15441),
|
||||
// Shaman dual-wield bookkeeping (Player.cpp 5028), Hunter pet
|
||||
// / Hunter's Mark gates (spell_item.cpp 3718), Druid Insect
|
||||
// Swarm / Wild Growth (SpellAuraEffects.cpp 2153, 2232),
|
||||
// Priest Spirit of Redemption out-of-bounds check (Unit.cpp
|
||||
// 14238), Rogue pickpocketing (LootHandler.cpp 86/165/385,
|
||||
// Vehicle.cpp 80). Paragon learns abilities from every class
|
||||
// through Character Advancement, so claiming all of them lets
|
||||
// every gated spell script execute its class-specific branch
|
||||
// for our players. The only downside is double-pathed scripts
|
||||
// (e.g. a spell with both warrior and rogue branches) will
|
||||
// pick whichever the script tests first -- acceptable.
|
||||
if (context == CLASS_CONTEXT_ABILITY)
|
||||
return true;
|
||||
|
||||
// Warrior ability stack: enables warrior-spec ability gates anywhere
|
||||
// they're checked. None of the currently-traced sites in core/scripts
|
||||
// gate on (CLASS_WARRIOR, CLASS_CONTEXT_ABILITY), so this is a safe
|
||||
// forward-compatible claim. Rage generation itself is gated on
|
||||
// HasActivePowerType(POWER_RAGE) and is wired below.
|
||||
if (unitClass == CLASS_WARRIOR && context == CLASS_CONTEXT_ABILITY)
|
||||
return true;
|
||||
|
||||
// Reactive melee states: Overpower-on-dodge (warrior), Counterattack window (hunter).
|
||||
// We intentionally do NOT claim CLASS_ROGUE here: that context skips the generic
|
||||
// AURA_STATE_DEFENSE update on dodge (Riposte path) in Unit::ProcDamageAndSpellFor.
|
||||
// ============================================================
|
||||
// Reactive melee states.
|
||||
// ============================================================
|
||||
// Warrior dodge -> AURA_STATE_DEFENSE (Overpower window).
|
||||
// Hunter parry -> AURA_STATE_HUNTER_PARRY (Counterattack).
|
||||
// We intentionally do NOT claim CLASS_ROGUE here:
|
||||
// Unit::ProcDamageAndSpellFor (Unit.cpp 12824) skips the
|
||||
// generic AURA_STATE_DEFENSE update on dodge for rogues so
|
||||
// Riposte can take over. Claiming rogue would silently kill
|
||||
// Overpower for Paragon, and Riposte already works for us via
|
||||
// the warrior-style state we already grant.
|
||||
if (context == CLASS_CONTEXT_ABILITY_REACTIVE)
|
||||
{
|
||||
if (unitClass == CLASS_WARRIOR || unitClass == CLASS_HUNTER)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Unified relic / ranged slot for class 12.
|
||||
// ----------------------------------------------------------------
|
||||
// CLASS_CONTEXT_EQUIP_RELIC is read in exactly two places in core
|
||||
// (PlayerStorage.cpp): FindEquipSlot's INVTYPE_RELIC switch, which
|
||||
// routes Librams/Idols/Totems/Misc/Sigils into EQUIPMENT_SLOT_RANGED
|
||||
// for the matching class only, and CanEquipUniqueItem's per-subclass
|
||||
// proficiency gate. By claiming this context for paladin/druid/
|
||||
// shaman/warlock/dk we let Paragon drop any of those relics into the
|
||||
// ranged slot exactly the same way each native class does, with no
|
||||
// core patch and no other side effects (the constant is not read
|
||||
// anywhere else in the codebase).
|
||||
// ============================================================
|
||||
// Pet ownership contexts.
|
||||
// ============================================================
|
||||
// CLASS_CONTEXT_PET is read by Pet::AddToWorld, Pet::CreateBase
|
||||
// AtCreatureInfo, Pet::InitStatsForLevel (twice -- the
|
||||
// MAX_PET_TYPE bootstrap branch and the per-class attack-time
|
||||
// scaling), Pet::IsPermanentPetFor, Player::SummonPet,
|
||||
// Player::CanResummonPet, Spell::EffectTameCreature,
|
||||
// SpellEffects.cpp (CreateTamedPet debug effects, Eyes of the
|
||||
// Beast), spell_generic.cpp 1760 (charm-as-pet conversion),
|
||||
// and PlayerGossip.cpp's hunter stable check.
|
||||
//
|
||||
// Bows/guns/crossbows already equip via the regular
|
||||
// INVTYPE_RANGED/RANGEDRIGHT routing -- weapon proficiencies for
|
||||
// class 12 are seeded by the Paragon proficiency SQL migrations, so
|
||||
// they pass the GetSkillValue check in CanEquipUniqueItem.
|
||||
// The cleanest disambiguation is by the *active pet's* shape:
|
||||
// HUNTER_PET -> hunter (beast tame)
|
||||
// SUMMON_PET + DEMON type -> warlock (Imp/VW/Succ/...)
|
||||
// SUMMON_PET + UNDEAD type -> DK ghoul / Army of Dead
|
||||
// SUMMON_PET + ELEMENTAL type -> mage water / shaman fire
|
||||
// For HUNTER specifically the no-pet case is also claimed so
|
||||
// Tame Beast's EffectTameCreature gate passes during cast.
|
||||
if (context == CLASS_CONTEXT_PET)
|
||||
{
|
||||
Pet const* activePet = const_cast<Player*>(player)->GetPet();
|
||||
|
||||
// Hunter beast: claim during taming OR when a HUNTER_PET is
|
||||
// already active. This is what makes Tame Beast / Call Pet
|
||||
// / pet stable / Counterattack pet aura feedback work.
|
||||
if (unitClass == CLASS_HUNTER)
|
||||
{
|
||||
if (!activePet || activePet->getPetType() == HUNTER_PET)
|
||||
return true;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// All other classes only claim when an active SUMMON_PET is
|
||||
// present. We then disambiguate by the creature's type
|
||||
// because warlock / DK / mage / shaman all use SUMMON_PET.
|
||||
if (!activePet || activePet->getPetType() != SUMMON_PET)
|
||||
return std::nullopt;
|
||||
|
||||
CreatureTemplate const* tmpl = activePet->GetCreatureTemplate();
|
||||
if (!tmpl)
|
||||
return std::nullopt;
|
||||
|
||||
switch (unitClass)
|
||||
{
|
||||
case CLASS_WARLOCK:
|
||||
// Drives Master Demonologist / Demonic Knowledge /
|
||||
// Demonic Pact propagation, last-pet-spell tracking
|
||||
// (Pet.cpp 112), and IsPermanentPetFor (Pet.cpp
|
||||
// 2288) so demon pets persist across logins.
|
||||
if (tmpl->type == CREATURE_TYPE_DEMON)
|
||||
return true;
|
||||
break;
|
||||
case CLASS_DEATH_KNIGHT:
|
||||
// Risen Ghoul + Army of the Dead. Player.cpp 14354
|
||||
// and Pet.cpp 243 / 1046 / 2290 read this; without
|
||||
// it the ghoul is invisible to the owner mid-load
|
||||
// and ScriptedAI hooks on the ghoul mis-route.
|
||||
if (tmpl->type == CREATURE_TYPE_UNDEAD)
|
||||
return true;
|
||||
break;
|
||||
case CLASS_MAGE:
|
||||
// Glyph-of-Eternal-Water permanent Water Elemental
|
||||
// (entry 510, 37994). Used by Pet.cpp 1047/2292.
|
||||
if (tmpl->type == CREATURE_TYPE_ELEMENTAL)
|
||||
return true;
|
||||
break;
|
||||
case CLASS_SHAMAN:
|
||||
// Fire Elemental / Earth Elemental. The base
|
||||
// engine spawns these as creatures rather than
|
||||
// proper Pet instances in most code paths, so the
|
||||
// claim mostly matters for the Pet.cpp 1045 stat
|
||||
// bootstrap when one is loaded as a SUMMON_PET.
|
||||
if (tmpl->type == CREATURE_TYPE_ELEMENTAL)
|
||||
return true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Warlock pet-charm context (Enslave Demon -- Unit.cpp 14828,
|
||||
// 14894, 15025). Without this claim, charming a demon as a
|
||||
// Paragon doesn't get the warlock-flavor charm semantics
|
||||
// (faction-set-on-charm, action-bar layout, charm-break logic).
|
||||
if (unitClass == CLASS_WARLOCK && context == CLASS_CONTEXT_PET_CHARM)
|
||||
return true;
|
||||
|
||||
// ============================================================
|
||||
// Equipment contexts.
|
||||
// ============================================================
|
||||
// CLASS_CONTEXT_EQUIP_RELIC: PlayerStorage.cpp 224-240 +
|
||||
// 2475-2493. Routes Librams/Idols/Totems/Misc/Sigils into
|
||||
// EQUIPMENT_SLOT_RANGED for the matching class. Claim every
|
||||
// relic-bearing class so a Paragon can drop any of them into
|
||||
// the ranged slot.
|
||||
if (context == CLASS_CONTEXT_EQUIP_RELIC)
|
||||
{
|
||||
switch (unitClass)
|
||||
@@ -96,6 +198,67 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
// CLASS_CONTEXT_EQUIP_ARMOR_CLASS: PlayerStorage.cpp 2326,
|
||||
// 2330, 2503-2523. At level 40 each class auto-learns its
|
||||
// top armor proficiency. Paragon should pick up plate (via
|
||||
// paladin/DK), shields (paladin/warrior/shaman), mail
|
||||
// (hunter/shaman), and leather (rogue) so the level-40 train
|
||||
// event grants Paragon full proficiency and we don't have to
|
||||
// hand-curate it through the Paragon proficiency SQL.
|
||||
if (context == CLASS_CONTEXT_EQUIP_ARMOR_CLASS)
|
||||
{
|
||||
switch (unitClass)
|
||||
{
|
||||
case CLASS_PALADIN:
|
||||
case CLASS_WARRIOR:
|
||||
case CLASS_DEATH_KNIGHT:
|
||||
case CLASS_HUNTER:
|
||||
case CLASS_SHAMAN:
|
||||
case CLASS_DRUID:
|
||||
case CLASS_ROGUE:
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// CLASS_CONTEXT_EQUIP_SHIELDS: PlayerStorage.cpp 2467-2469.
|
||||
// Lets a Paragon equip shields without a paladin/warrior/
|
||||
// shaman skill gate.
|
||||
if (context == CLASS_CONTEXT_EQUIP_SHIELDS)
|
||||
{
|
||||
switch (unitClass)
|
||||
{
|
||||
case CLASS_PALADIN:
|
||||
case CLASS_WARRIOR:
|
||||
case CLASS_SHAMAN:
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// CLASS_CONTEXT_WEAPON_SWAP: PlayerStorage.cpp 1920, 2838 --
|
||||
// rogue uses cooldown spell 6123 instead of 6119 on weapon
|
||||
// swap (Quick Draw / Combat Potency interactions). Claim
|
||||
// rogue so Paragon picks up the same cooldown spell.
|
||||
if (context == CLASS_CONTEXT_WEAPON_SWAP && unitClass == CLASS_ROGUE)
|
||||
return true;
|
||||
|
||||
// ============================================================
|
||||
// Contexts we DELIBERATELY DO NOT claim:
|
||||
// ============================================================
|
||||
// CLASS_CONTEXT_STATS -- Paragon has its own STR/AGI->AP and
|
||||
// INT/SPI->SP curves wired in StatSystem.cpp's CLASS_PARAGON
|
||||
// branch (level*2 + STR + AGI - 20 etc.). Claiming any
|
||||
// vanilla class here would override our curves with theirs.
|
||||
//
|
||||
// CLASS_CONTEXT_INIT, _TELEPORT, _QUEST, _TAXI, _SKILL,
|
||||
// _GRAVEYARD, _CLASS_TRAINER, _TALENT_POINT_CALC -- all
|
||||
// used by DK Ebon Hold / druid Moonglade starting-zone
|
||||
// scripts. Paragon doesn't go through those zones and we
|
||||
// don't want our players bound to Acherus or trapped in
|
||||
// the DK starting quest gates.
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
|
||||
Executable
+181
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fractured / AzerothCore — native VPS rolling update (git + compile).
|
||||
#
|
||||
# Run from anywhere; resolves the repository root from this script's location.
|
||||
# Typical production layout: sources in ~/src/Fractured, install prefix in ~/azeroth-server
|
||||
# (see docs/DEPLOY_LINUX_VPS.md).
|
||||
#
|
||||
# What this does:
|
||||
# 1. git pull on the current branch (optional; can skip)
|
||||
# 2. ./acore.sh compiler build — or compiler all for a full clean rebuild
|
||||
#
|
||||
# Database migrations from data/sql/updates/ run when you next start worldserver/authserver
|
||||
# (Updates.* / SourceDirectory in *.conf). This script does not start or stop daemons unless
|
||||
# you pass --run-after or set FRACTURED_POST_UPDATE_CMD.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/vps-update-server.sh
|
||||
# bash scripts/vps-update-server.sh --full
|
||||
# bash scripts/vps-update-server.sh --no-pull
|
||||
# bash scripts/vps-update-server.sh --dry-run
|
||||
# FRACTURED_POST_UPDATE_CMD='sudo systemctl restart fractured-world' bash scripts/vps-update-server.sh --run-after
|
||||
# bash scripts/vps-update-server.sh --run-after 'sudo systemctl restart fractured-world'
|
||||
#
|
||||
# Environment:
|
||||
# FRACTURED_GIT_REMOTE — remote name (default: origin)
|
||||
# FRACTURED_POST_UPDATE_CMD — shell command run after a successful compile (if --run-after is passed without an argument, this is used)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
NO_PULL=0
|
||||
FULL_BUILD=0
|
||||
COMPILE_ONLY=0
|
||||
DRY_RUN=0
|
||||
DO_RUN_AFTER=0
|
||||
POST_UPDATE_CMD="${FRACTURED_POST_UPDATE_CMD:-}"
|
||||
GIT_REMOTE="${FRACTURED_GIT_REMOTE:-origin}"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Fractured VPS update — git pull + compiler (see header in script for full notes).
|
||||
|
||||
Usage:
|
||||
bash scripts/vps-update-server.sh [options]
|
||||
|
||||
Options:
|
||||
--no-pull Skip git pull (only compile current tree).
|
||||
--full ./acore.sh compiler all (clean + configure + compile).
|
||||
--compile-only ./acore.sh compiler compile (incremental).
|
||||
--dry-run Print commands without running them.
|
||||
--run-after [CMD] Run shell command after successful compile. If CMD is omitted,
|
||||
uses FRACTURED_POST_UPDATE_CMD from the environment.
|
||||
|
||||
Environment:
|
||||
FRACTURED_GIT_REMOTE Git remote (default: origin).
|
||||
FRACTURED_POST_UPDATE_CMD Used with bare --run-after.
|
||||
EOF
|
||||
}
|
||||
|
||||
run() {
|
||||
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||
printf '[dry-run] '
|
||||
printf '%q ' "$@"
|
||||
printf '\n'
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h | --help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--no-pull)
|
||||
NO_PULL=1
|
||||
shift
|
||||
;;
|
||||
--full)
|
||||
FULL_BUILD=1
|
||||
shift
|
||||
;;
|
||||
--compile-only)
|
||||
COMPILE_ONLY=1
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=1
|
||||
shift
|
||||
;;
|
||||
--run-after)
|
||||
DO_RUN_AFTER=1
|
||||
shift
|
||||
if [[ $# -gt 0 && "$1" != -* ]]; then
|
||||
POST_UPDATE_CMD="$1"
|
||||
shift
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "error: unknown option: $1" >&2
|
||||
echo "Try: bash scripts/vps-update-server.sh --help" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$FULL_BUILD" -eq 1 && "$COMPILE_ONLY" -eq 1 ]]; then
|
||||
echo "error: use only one of --full or --compile-only" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -d "$ROOT/.git" ]]; then
|
||||
echo "error: not a git clone: $ROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$ROOT/acore.sh" ]]; then
|
||||
echo "error: acore.sh not found under $ROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$ROOT/conf/config.sh" ]]; then
|
||||
echo "error: missing $ROOT/conf/config.sh — copy conf/dist/config.sh and edit (see DEPLOY_LINUX_VPS.md)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$ROOT"
|
||||
|
||||
if [[ "$DO_RUN_AFTER" -eq 1 && -z "${POST_UPDATE_CMD// }" ]]; then
|
||||
echo "error: --run-after needs a command or FRACTURED_POST_UPDATE_CMD set in the environment." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
current_branch() {
|
||||
git symbolic-ref -q --short HEAD || git rev-parse --short HEAD
|
||||
}
|
||||
|
||||
if [[ "$NO_PULL" -eq 0 ]]; then
|
||||
ref="$(current_branch)"
|
||||
if [[ "$ref" == "HEAD" ]]; then
|
||||
echo "error: detached HEAD; checkout a branch or use --no-pull." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "==> git pull $GIT_REMOTE $ref"
|
||||
run git pull "$GIT_REMOTE" "$ref"
|
||||
else
|
||||
echo "==> skipping git pull (--no-pull)"
|
||||
fi
|
||||
|
||||
echo "==> ensuring acore.sh and JSONPath are executable"
|
||||
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||
run chmod +x acore.sh deps/jsonpath/JSONPath.sh
|
||||
else
|
||||
chmod +x acore.sh deps/jsonpath/JSONPath.sh 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [[ "$FULL_BUILD" -eq 1 ]]; then
|
||||
echo "==> ./acore.sh compiler all (clean, configure, compile)"
|
||||
run ./acore.sh compiler all
|
||||
elif [[ "$COMPILE_ONLY" -eq 1 ]]; then
|
||||
echo "==> ./acore.sh compiler compile (incremental; build dir must exist)"
|
||||
run ./acore.sh compiler compile
|
||||
else
|
||||
echo "==> ./acore.sh compiler build (configure + compile)"
|
||||
run ./acore.sh compiler build
|
||||
fi
|
||||
|
||||
if [[ "$DO_RUN_AFTER" -eq 1 ]]; then
|
||||
echo "==> post-update: $POST_UPDATE_CMD"
|
||||
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||
printf '[dry-run] eval %q\n' "$POST_UPDATE_CMD"
|
||||
else
|
||||
# shellcheck disable=SC2086
|
||||
eval "$POST_UPDATE_CMD"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Done. Restart authserver/worldserver (or your service manager) when ready so new binaries and SQL updates apply."
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
launcher.json
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,92 @@
|
||||
# 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.
|
||||
|
||||
## Requirements
|
||||
|
||||
- [Node.js](https://nodejs.org/) 20+ (includes npm)
|
||||
|
||||
## Run from source
|
||||
|
||||
```bash
|
||||
cd tools/fractured-launcher-electron
|
||||
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.
|
||||
|
||||
## Build Windows installers
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run pack:win
|
||||
```
|
||||
|
||||
Produces under **`dist/`**:
|
||||
|
||||
| Artifact | Purpose |
|
||||
|----------|---------|
|
||||
| `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. |
|
||||
|
||||
## 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.
|
||||
- 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
|
||||
|
||||
**`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.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
### 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:
|
||||
|
||||
```bash
|
||||
set GH_TOKEN=ghp_your_token_here
|
||||
npm run publish:win
|
||||
```
|
||||
|
||||
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)).
|
||||
|
||||
Players on an older NSIS install will pick up the next version automatically on the next check.
|
||||
|
||||
## Public distro repo (patches + launcher binaries)
|
||||
|
||||
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**.
|
||||
|
||||
Publish assets there by:
|
||||
|
||||
- **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.
|
||||
|
||||
If your public repo slug is not `Fractured-Distro`, edit **`DISTRO_REPO`** in the workflow / script and **`github.repo`** in `launcher.json`.
|
||||
|
||||
### Private `github.repo` (optional)
|
||||
|
||||
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.
|
||||
|
||||
**Release asset names** must match **`files[].source`** exactly when **`from_release`**: true. Use **`release_tag`: `"latest"`** for the newest release, or pin a tag.
|
||||
|
||||
## 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.
|
||||
|
||||
## Config
|
||||
|
||||
Schema is defined by **`default-launcher.json`** (shipped in the app; first run copies to `launcher.json` beside the executable):
|
||||
|
||||
- **`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).
|
||||
- **`realmlist`**, **`auth`**, **`launch`**.
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"game_dir": "",
|
||||
"update_feed_url": "",
|
||||
"github": {
|
||||
"owner": "Dawnforger",
|
||||
"repo": "Fractured-Distro",
|
||||
"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
|
||||
}
|
||||
],
|
||||
"realmlist": {
|
||||
"enabled": true,
|
||||
"line": "set realmlist fracturedwow.ddns.net:47497",
|
||||
"paths": ["Data/enUS/realmlist.wtf", "Data/enGB/realmlist.wtf"]
|
||||
},
|
||||
"auth": {
|
||||
"enabled": false,
|
||||
"url": "https://auth.your-realm.example/api/launcher/login",
|
||||
"method": "POST",
|
||||
"username_field": "username",
|
||||
"password_field": "password"
|
||||
},
|
||||
"launch": {
|
||||
"exe": "Wow.exe",
|
||||
"args": [],
|
||||
"linux_wrapper": ["wine"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" />
|
||||
<title>Fractured Launcher</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Fractured Launcher</h1>
|
||||
<p class="sub">Point at your 3.3.5a client, download patches, then play.</p>
|
||||
</header>
|
||||
|
||||
<section class="card">
|
||||
<label class="lbl">World of Warcraft folder (contains <span id="wowExeName">Wow.exe</span>)</label>
|
||||
<div class="row">
|
||||
<input type="text" id="gameDir" placeholder="Browse… or paste the folder that contains Wow.exe" />
|
||||
<button type="button" id="btnBrowse">Browse…</button>
|
||||
<button type="button" id="btnSaveFolder" class="primary">Save folder</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card hidden" id="authCard">
|
||||
<label class="lbl">Account</label>
|
||||
<div class="row stack">
|
||||
<input type="text" id="username" autocomplete="username" placeholder="Username" />
|
||||
<input type="password" id="password" autocomplete="current-password" placeholder="Password" />
|
||||
<button type="button" id="btnAuth" class="primary">Sign in</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card row-actions">
|
||||
<button type="button" id="btnCheckLauncher" class="ghost">Check launcher updates</button>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<button type="button" id="btnSync" class="primary wide" disabled>Download updates</button>
|
||||
<button type="button" id="btnPlay" class="success wide hidden" disabled>Play</button>
|
||||
</section>
|
||||
|
||||
<pre id="log" class="log" aria-live="polite"></pre>
|
||||
|
||||
<script src="renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,111 @@
|
||||
'use strict';
|
||||
|
||||
const { dialog } = require('electron');
|
||||
const { autoUpdater } = require('electron-updater');
|
||||
|
||||
/**
|
||||
* @param {import('electron').App} app
|
||||
* @param {() => import('electron').BrowserWindow | null} getMainWindow
|
||||
* @param {{ updateFeedUrl?: string, githubOwner?: string, githubRepo?: string, token?: string }} opts
|
||||
*/
|
||||
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 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();
|
||||
|
||||
if (genericUrl) {
|
||||
const base = genericUrl.replace(/\/?$/, '/');
|
||||
autoUpdater.setFeedURL({
|
||||
provider: 'generic',
|
||||
url: base,
|
||||
});
|
||||
if (token) {
|
||||
autoUpdater.requestHeaders = {
|
||||
...autoUpdater.requestHeaders,
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
}
|
||||
} else if (token && owner && repo) {
|
||||
autoUpdater.setFeedURL({
|
||||
provider: 'github',
|
||||
owner,
|
||||
repo,
|
||||
private: true,
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
const send = (msg) => {
|
||||
const w = getMainWindow();
|
||||
if (w && !w.isDestroyed()) {
|
||||
w.webContents.send('launcher:progress', msg);
|
||||
}
|
||||
};
|
||||
|
||||
autoUpdater.on('checking-for-update', () => send('Checking for launcher updates…'));
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
send(`Launcher update available: ${info.version}`);
|
||||
});
|
||||
autoUpdater.on('update-not-available', () => {});
|
||||
autoUpdater.on('error', (err) => {
|
||||
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.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (m && !/net::ERR|ENOTFOUND|ETIMEDOUT/i.test(m)) {
|
||||
send(`Launcher update: ${m.slice(0, 400)}`);
|
||||
}
|
||||
});
|
||||
autoUpdater.on('download-progress', (p) => {
|
||||
const pct = Math.round(p.percent || 0);
|
||||
send(`Launcher update download: ${pct}%`);
|
||||
});
|
||||
autoUpdater.on('update-downloaded', async (info) => {
|
||||
const win = getMainWindow();
|
||||
const r = await dialog.showMessageBox(win || undefined, {
|
||||
type: 'info',
|
||||
title: 'Launcher update',
|
||||
message: `Version ${info.version} is ready to install.`,
|
||||
detail: 'Restart the launcher now to finish. You can finish patching WoW after restart.',
|
||||
buttons: ['Restart now', 'Later'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
noLink: true,
|
||||
});
|
||||
if (r.response === 0) {
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
}
|
||||
});
|
||||
|
||||
const checkNow = async () => {
|
||||
const r = await autoUpdater.checkForUpdates();
|
||||
return { ok: true, updateInfo: r && r.updateInfo };
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
checkNow().catch(() => {});
|
||||
};
|
||||
setTimeout(tick, 5000);
|
||||
setInterval(tick, 6 * 60 * 60 * 1000);
|
||||
|
||||
return { checkNow };
|
||||
}
|
||||
|
||||
module.exports = { setupAutoUpdater };
|
||||
@@ -0,0 +1,63 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
|
||||
function mergeConfig(defaults, user) {
|
||||
return {
|
||||
...defaults,
|
||||
...user,
|
||||
update_feed_url:
|
||||
user.update_feed_url != null && user.update_feed_url !== ''
|
||||
? user.update_feed_url
|
||||
: defaults.update_feed_url,
|
||||
github: { ...defaults.github, ...(user.github || {}) },
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function getConfigPath(app) {
|
||||
if (process.env.FRACTURED_LAUNCHER_CONFIG) return process.env.FRACTURED_LAUNCHER_CONFIG;
|
||||
if (app && app.isPackaged) {
|
||||
return path.join(path.dirname(process.execPath), 'launcher.json');
|
||||
}
|
||||
return path.join(__dirname, '..', 'launcher.json');
|
||||
}
|
||||
|
||||
async function loadConfig(app) {
|
||||
const p = getConfigPath(app);
|
||||
const defPath = path.join(__dirname, '..', 'default-launcher.json');
|
||||
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) };
|
||||
} 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)) };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGameDir(configPath, gameDir) {
|
||||
const defPath = path.join(__dirname, '..', 'default-launcher.json');
|
||||
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);
|
||||
await fs.writeFile(configPath, JSON.stringify(merged, null, 2), 'utf8');
|
||||
return merged;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
module.exports = { getConfigPath, loadConfig, saveGameDir, resolveGameDir, mergeConfig };
|
||||
@@ -0,0 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
function githubToken(cfg) {
|
||||
const name = cfg.github && cfg.github.token_env;
|
||||
if (name && process.env[name]) return process.env[name];
|
||||
return process.env.GITHUB_TOKEN || '';
|
||||
}
|
||||
|
||||
module.exports = { githubToken };
|
||||
@@ -0,0 +1,114 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { githubToken } = require('./github-token');
|
||||
const { fetchToFile, downloadBodyToFile } = require('./http-download');
|
||||
|
||||
function encodeRepoPath(repoPath) {
|
||||
let p = String(repoPath || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||
if (!p) return '';
|
||||
return p.split('/').map((seg) => encodeURIComponent(seg)).join('/');
|
||||
}
|
||||
|
||||
function ghHeaders(token, json = false) {
|
||||
const h = {
|
||||
'User-Agent': 'Fractured-Launcher-Electron',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
};
|
||||
if (json) h.Accept = 'application/vnd.github+json';
|
||||
if (token) h.Authorization = `Bearer ${token}`;
|
||||
return h;
|
||||
}
|
||||
|
||||
async function downloadGitHubRepoFile(cfg, repoPath, destPath) {
|
||||
const token = githubToken(cfg);
|
||||
const enc = encodeRepoPath(repoPath);
|
||||
const ref = cfg.github.ref || 'main';
|
||||
const { owner, repo } = cfg.github;
|
||||
|
||||
if (!token) {
|
||||
const url = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${enc}`;
|
||||
await fetchToFile(url, {}, destPath);
|
||||
return;
|
||||
}
|
||||
|
||||
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${enc}?ref=${encodeURIComponent(ref)}`;
|
||||
const res = await fetch(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)}`);
|
||||
}
|
||||
const meta = JSON.parse(body);
|
||||
if (meta.type && meta.type !== 'file') {
|
||||
throw new Error(`not a file: ${repoPath}`);
|
||||
}
|
||||
if (meta.download_url) {
|
||||
const h = { Accept: 'application/octet-stream' };
|
||||
if (token) {
|
||||
h.Authorization = `Bearer ${token}`;
|
||||
h['X-GitHub-Api-Version'] = '2022-11-28';
|
||||
}
|
||||
await fetchToFile(meta.download_url, h, destPath);
|
||||
return;
|
||||
}
|
||||
if (meta.content && meta.encoding === 'base64') {
|
||||
const buf = Buffer.from(String(meta.content).replace(/\n/g, ''), 'base64');
|
||||
if (!buf.length) throw new Error('empty base64 content');
|
||||
await fs.mkdir(path.dirname(destPath), { recursive: true });
|
||||
const tmp = destPath + '.downloading';
|
||||
await fs.writeFile(tmp, buf);
|
||||
await fs.rename(tmp, destPath);
|
||||
return;
|
||||
}
|
||||
throw new Error(`unexpected GitHub response for ${repoPath}`);
|
||||
}
|
||||
|
||||
async function downloadReleaseAsset(cfg, assetName, destPath) {
|
||||
const token = githubToken(cfg);
|
||||
const tag = (cfg.github.release_tag || 'latest').trim() || 'latest';
|
||||
const { owner, repo } = cfg.github;
|
||||
let listUrl;
|
||||
if (tag.toLowerCase() === 'latest') {
|
||||
listUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
|
||||
} else {
|
||||
listUrl = `https://api.github.com/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`;
|
||||
}
|
||||
const res = await fetch(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 === 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);
|
||||
const assets = rel.assets || [];
|
||||
let assetURL = '';
|
||||
for (const a of assets) {
|
||||
if (a.name !== assetName) continue;
|
||||
if (token && a.url) {
|
||||
assetURL = a.url;
|
||||
break;
|
||||
}
|
||||
if (a.browser_download_url) {
|
||||
assetURL = a.browser_download_url;
|
||||
break;
|
||||
}
|
||||
assetURL = a.url;
|
||||
break;
|
||||
}
|
||||
if (!assetURL) {
|
||||
const names = assets.map((x) => x.name);
|
||||
throw new Error(`release asset "${assetName}" not found; attachments: ${names.join(', ')}`);
|
||||
}
|
||||
const h = { Accept: 'application/octet-stream' };
|
||||
if (token) {
|
||||
h.Authorization = `Bearer ${token}`;
|
||||
h['X-GitHub-Api-Version'] = '2022-11-28';
|
||||
}
|
||||
const dl = await fetch(assetURL, { headers: h, redirect: 'follow' });
|
||||
await downloadBodyToFile(dl, destPath);
|
||||
}
|
||||
|
||||
module.exports = { downloadGitHubRepoFile, downloadReleaseAsset, encodeRepoPath };
|
||||
@@ -0,0 +1,40 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const { createWriteStream } = require('fs');
|
||||
const { pipeline } = require('stream/promises');
|
||||
const { Readable } = require('stream');
|
||||
|
||||
async function downloadBodyToFile(res, destPath) {
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => '');
|
||||
throw new Error(`HTTP ${res.status}: ${errText.slice(0, 500)}`);
|
||||
}
|
||||
if (!res.body) {
|
||||
throw new Error('download has no body');
|
||||
}
|
||||
await fs.mkdir(path.dirname(destPath), { recursive: true });
|
||||
const tmp = destPath + '.downloading';
|
||||
let body = res.body;
|
||||
if (body && typeof body.pipe !== 'function') {
|
||||
body = Readable.fromWeb(body);
|
||||
}
|
||||
await pipeline(body, createWriteStream(tmp));
|
||||
const st = await fs.stat(tmp);
|
||||
if (st.size === 0) {
|
||||
await fs.unlink(tmp).catch(() => {});
|
||||
throw new Error('empty download');
|
||||
}
|
||||
await fs.rename(tmp, destPath);
|
||||
}
|
||||
|
||||
async function fetchToFile(url, headers, destPath) {
|
||||
const res = await fetch(url, {
|
||||
headers,
|
||||
redirect: 'follow',
|
||||
});
|
||||
await downloadBodyToFile(res, destPath);
|
||||
}
|
||||
|
||||
module.exports = { fetchToFile, downloadBodyToFile };
|
||||
@@ -0,0 +1,117 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { downloadGitHubRepoFile, downloadReleaseAsset } = require('./github');
|
||||
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
function backupSuffix() {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function wowExePath(cfg) {
|
||||
const exe = (cfg.launch && cfg.launch.exe) || 'Wow.exe';
|
||||
const parts = exe.replace(/\\/g, '/').split('/').filter(Boolean);
|
||||
return path.join(cfg.game_dir, ...parts);
|
||||
}
|
||||
|
||||
function wowInstallValid(cfg) {
|
||||
if (!cfg.game_dir) return false;
|
||||
return require('fs').existsSync(wowExePath(cfg));
|
||||
}
|
||||
|
||||
async function installFile(cfg, entry) {
|
||||
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
|
||||
const destAbs = path.join(cfg.game_dir, ...parts);
|
||||
if (entry.backup) {
|
||||
try {
|
||||
const st = await fs.stat(destAbs);
|
||||
if (st.isFile()) {
|
||||
const bak = `${destAbs}.bak-${backupSuffix()}`;
|
||||
await fs.rename(destAbs, bak);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') throw e;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await fs.unlink(destAbs);
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') throw e;
|
||||
}
|
||||
}
|
||||
const tmp = destAbs + '.new';
|
||||
if (entry.from_release) {
|
||||
await downloadReleaseAsset(cfg, entry.source, tmp);
|
||||
} else {
|
||||
await downloadGitHubRepoFile(cfg, entry.source, tmp);
|
||||
}
|
||||
await fs.rename(tmp, destAbs);
|
||||
}
|
||||
|
||||
async function applyRealmlist(cfg) {
|
||||
if (!cfg.realmlist || !cfg.realmlist.enabled) return;
|
||||
let line = String(cfg.realmlist.line || '').trim();
|
||||
if (!line) throw new Error('realmlist.line empty');
|
||||
if (!line.toLowerCase().startsWith('set realmlist ')) {
|
||||
line = `set realmlist ${line}`;
|
||||
}
|
||||
const content = line + '\n';
|
||||
let paths = cfg.realmlist.paths;
|
||||
if (!paths || !paths.length) paths = ['Data/enUS/realmlist.wtf'];
|
||||
for (const rel of paths) {
|
||||
const r = String(rel).trim().replace(/\\/g, '/');
|
||||
if (!r) continue;
|
||||
const segs = r.split('/').filter(Boolean);
|
||||
const abs = path.join(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 || []) {
|
||||
if (onStatus) onStatus(`Updating ${f.dest} …`);
|
||||
try {
|
||||
await installFile(cfg, f);
|
||||
} catch (e) {
|
||||
throw new Error(`sync ${f.dest}: ${e.message || e}`);
|
||||
}
|
||||
}
|
||||
if (cfg.realmlist && cfg.realmlist.enabled) {
|
||||
if (onStatus) onStatus('Applying realmlist …');
|
||||
await applyRealmlist(cfg);
|
||||
}
|
||||
if (onStatus) onStatus('All patches applied.');
|
||||
}
|
||||
|
||||
async function doAuth(cfg, username, password) {
|
||||
if (!cfg.auth || !cfg.auth.enabled) return;
|
||||
const u = String(username || '').trim();
|
||||
const p = String(password || '');
|
||||
if (!u || !p) throw new Error('username and password required');
|
||||
const body = {
|
||||
[cfg.auth.username_field || 'username']: u,
|
||||
[cfg.auth.password_field || 'password']: p,
|
||||
};
|
||||
const res = await fetch(cfg.auth.url, {
|
||||
method: cfg.auth.method || 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const t = await res.text();
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new Error(`login failed ${res.status}: ${t.slice(0, 400)}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
applyPatches,
|
||||
applyRealmlist,
|
||||
wowExePath,
|
||||
wowInstallValid,
|
||||
doAuth,
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
'use strict';
|
||||
|
||||
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 { applyPatches, wowExePath, wowInstallValid, doAuth } = require('./lib/patch');
|
||||
const { setupAutoUpdater } = require('./lib/auto-update');
|
||||
|
||||
let mainWindow;
|
||||
let autoUpdateApi = {
|
||||
checkNow: async () => ({ skipped: true, reason: 'not initialized' }),
|
||||
};
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 720,
|
||||
height: 640,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
},
|
||||
});
|
||||
Menu.setApplicationMenu(null);
|
||||
mainWindow.loadFile(path.join(__dirname, 'index.html'));
|
||||
mainWindow.once('ready-to-show', () => mainWindow.show());
|
||||
}
|
||||
|
||||
function sendProgress(msg) {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('launcher:progress', msg);
|
||||
}
|
||||
}
|
||||
|
||||
async function readMergedConfig() {
|
||||
const { configPath, config } = await loadConfig(app);
|
||||
const gameDir = resolveGameDir(config, configPath);
|
||||
const merged = { ...config, game_dir: gameDir };
|
||||
return { configPath, config: merged };
|
||||
}
|
||||
|
||||
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()) ||
|
||||
String(process.env.GH_TOKEN || process.env.GITHUB_TOKEN || '').trim();
|
||||
const updateFeedUrl = String(process.env.LAUNCHER_UPDATE_URL || config.update_feed_url || '').trim();
|
||||
autoUpdateApi = setupAutoUpdater(app, () => mainWindow, {
|
||||
updateFeedUrl,
|
||||
githubOwner: config.github && config.github.owner,
|
||||
githubRepo: config.github && config.github.repo,
|
||||
token,
|
||||
});
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
|
||||
ipcMain.handle('launcher:load', async () => {
|
||||
const { configPath, config } = await readMergedConfig();
|
||||
return {
|
||||
configPath,
|
||||
gameDir: config.game_dir || '',
|
||||
authEnabled: !!(config.auth && config.auth.enabled),
|
||||
wowExe: (config.launch && config.launch.exe) || 'Wow.exe',
|
||||
wowOk: wowInstallValid(config),
|
||||
};
|
||||
});
|
||||
|
||||
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 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'}`);
|
||||
}
|
||||
const c = await saveGameDir(configPath, norm);
|
||||
const merged = { ...c, game_dir: resolveGameDir(c, configPath) };
|
||||
return { ok: true, gameDir: merged.game_dir, wowOk: wowInstallValid(merged) };
|
||||
});
|
||||
|
||||
ipcMain.handle('launcher:pickFolder', async (_e, startDir) => {
|
||||
const win = BrowserWindow.getFocusedWindow() || mainWindow;
|
||||
const r = await dialog.showOpenDialog(win, {
|
||||
title: 'Select World of Warcraft 3.3.5a folder',
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
defaultPath: startDir && String(startDir).trim() ? String(startDir).trim() : undefined,
|
||||
});
|
||||
if (r.canceled || !r.filePaths || !r.filePaths[0]) return { canceled: true, path: '' };
|
||||
return { canceled: false, path: r.filePaths[0] };
|
||||
});
|
||||
|
||||
ipcMain.handle('launcher:auth', async (_e, { user, pass }) => {
|
||||
const { config } = await readMergedConfig();
|
||||
await doAuth(config, user, pass);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('launcher:sync', async () => {
|
||||
const { config } = await readMergedConfig();
|
||||
if (!wowInstallValid(config)) {
|
||||
throw new Error('Set a valid WoW folder (must contain Wow.exe) first.');
|
||||
}
|
||||
await applyPatches(config, sendProgress);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('launcher:checkUpdates', async () => {
|
||||
try {
|
||||
return await autoUpdateApi.checkNow();
|
||||
} catch (e) {
|
||||
const msg = e && (e.message || String(e));
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('launcher:play', async () => {
|
||||
const { config } = await readMergedConfig();
|
||||
const exe = wowExePath(config);
|
||||
const args = (config.launch && config.launch.args) || [];
|
||||
const child = spawn(exe, args, {
|
||||
cwd: config.game_dir,
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
shell: false,
|
||||
});
|
||||
child.unref();
|
||||
return { ok: true };
|
||||
});
|
||||
+5355
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "fractured-launcher-electron",
|
||||
"version": "1.0.1",
|
||||
"description": "Fractured WoW launcher (Electron) — no console window, native folder picker, auto-update",
|
||||
"main": "main.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Dawnforger/Fractured.git"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"pack:win": "electron-builder --win nsis portable --x64",
|
||||
"publish:win": "electron-builder --win nsis portable --x64 --publish always"
|
||||
},
|
||||
"author": "",
|
||||
"license": "GPL-3.0",
|
||||
"devDependencies": {
|
||||
"electron": "^33.2.1",
|
||||
"electron-builder": "^25.1.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.3.9"
|
||||
},
|
||||
"build": {
|
||||
"appId": "net.fractured.launcher",
|
||||
"productName": "Fractured Launcher",
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"publish": [
|
||||
{
|
||||
"provider": "github",
|
||||
"owner": "Dawnforger",
|
||||
"repo": "Fractured-Distro"
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.cjs",
|
||||
"index.html",
|
||||
"renderer.js",
|
||||
"styles.css",
|
||||
"default-launcher.json",
|
||||
"lib/**/*"
|
||||
],
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": ["x64"]
|
||||
},
|
||||
{
|
||||
"target": "portable",
|
||||
"arch": ["x64"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"artifactName": "Fractured-Launcher-${version}-Setup.${ext}"
|
||||
},
|
||||
"portable": {
|
||||
"artifactName": "Fractured-Launcher-${version}-Windows-Portable.${ext}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
'use strict';
|
||||
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('launcher', {
|
||||
load: () => ipcRenderer.invoke('launcher:load'),
|
||||
saveGameDir: (dir) => ipcRenderer.invoke('launcher:saveGameDir', dir),
|
||||
pickFolder: (startDir) => ipcRenderer.invoke('launcher:pickFolder', startDir),
|
||||
auth: (user, pass) => ipcRenderer.invoke('launcher:auth', { user, pass }),
|
||||
sync: () => ipcRenderer.invoke('launcher:sync'),
|
||||
checkUpdates: () => ipcRenderer.invoke('launcher:checkUpdates'),
|
||||
play: () => ipcRenderer.invoke('launcher:play'),
|
||||
onProgress: (cb) => {
|
||||
ipcRenderer.on('launcher:progress', (_e, msg) => cb(msg));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
'use strict';
|
||||
|
||||
const logEl = document.getElementById('log');
|
||||
const gameDirEl = document.getElementById('gameDir');
|
||||
const btnBrowse = document.getElementById('btnBrowse');
|
||||
const btnSave = document.getElementById('btnSaveFolder');
|
||||
const btnSync = document.getElementById('btnSync');
|
||||
const btnPlay = document.getElementById('btnPlay');
|
||||
const btnCheckLauncher = document.getElementById('btnCheckLauncher');
|
||||
const authCard = document.getElementById('authCard');
|
||||
const btnAuth = document.getElementById('btnAuth');
|
||||
const wowExeName = document.getElementById('wowExeName');
|
||||
|
||||
function log(msg) {
|
||||
logEl.textContent += (logEl.textContent ? '\n' : '') + msg;
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
function setError(e) {
|
||||
const m = e && (e.message || String(e));
|
||||
log('Error: ' + m);
|
||||
}
|
||||
|
||||
let authEnabled = false;
|
||||
let signedIn = false;
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const s = await window.launcher.load();
|
||||
authEnabled = s.authEnabled;
|
||||
signedIn = !s.authEnabled;
|
||||
wowExeName.textContent = s.wowExe || 'Wow.exe';
|
||||
gameDirEl.value = s.gameDir || '';
|
||||
authCard.classList.toggle('hidden', !authEnabled);
|
||||
btnSync.disabled = !s.wowOk || (authEnabled && !signedIn);
|
||||
btnPlay.classList.add('hidden');
|
||||
btnPlay.disabled = true;
|
||||
logEl.textContent = '';
|
||||
if (!s.gameDir) log('Choose your WoW installation folder.');
|
||||
else if (!s.wowOk) log('Folder does not look valid yet — pick the directory that contains ' + (s.wowExe || 'Wow.exe') + ', then Save folder.');
|
||||
else if (authEnabled && !signedIn) log('Sign in, then download updates.');
|
||||
else log('Ready — tap Download updates to sync from GitHub.');
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
}
|
||||
}
|
||||
|
||||
window.launcher.onProgress((msg) => log(msg));
|
||||
|
||||
btnBrowse.addEventListener('click', async () => {
|
||||
try {
|
||||
const start = gameDirEl.value.trim();
|
||||
const r = await window.launcher.pickFolder(start);
|
||||
if (!r.canceled && r.path) {
|
||||
gameDirEl.value = r.path;
|
||||
log('Selected: ' + r.path);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
}
|
||||
});
|
||||
|
||||
btnSave.addEventListener('click', async () => {
|
||||
try {
|
||||
const dir = gameDirEl.value.trim();
|
||||
if (!dir) {
|
||||
log('Pick a folder with Browse… first.');
|
||||
return;
|
||||
}
|
||||
const r = await window.launcher.saveGameDir(dir);
|
||||
gameDirEl.value = r.gameDir;
|
||||
btnSync.disabled = !r.wowOk || (authEnabled && !signedIn);
|
||||
log('Saved installation folder.');
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
}
|
||||
});
|
||||
|
||||
btnAuth.addEventListener('click', async () => {
|
||||
try {
|
||||
const u = document.getElementById('username').value;
|
||||
const p = document.getElementById('password').value;
|
||||
await window.launcher.auth(u, p);
|
||||
signedIn = true;
|
||||
log('Signed in.');
|
||||
btnSync.disabled = !gameDirEl.value.trim() || (authEnabled && !signedIn);
|
||||
const s = await window.launcher.load();
|
||||
btnSync.disabled = !s.wowOk;
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
}
|
||||
});
|
||||
|
||||
btnSync.addEventListener('click', async () => {
|
||||
btnSync.disabled = true;
|
||||
log('—');
|
||||
try {
|
||||
await window.launcher.sync();
|
||||
btnPlay.classList.remove('hidden');
|
||||
btnPlay.disabled = false;
|
||||
log('Done. You can launch the game.');
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
} finally {
|
||||
const s = await window.launcher.load().catch(() => null);
|
||||
btnSync.disabled = !s || !s.wowOk || (authEnabled && !signedIn);
|
||||
}
|
||||
});
|
||||
|
||||
btnPlay.addEventListener('click', async () => {
|
||||
try {
|
||||
await window.launcher.play();
|
||||
window.close();
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
}
|
||||
});
|
||||
|
||||
btnCheckLauncher.addEventListener('click', async () => {
|
||||
try {
|
||||
log('Checking for launcher updates…');
|
||||
const r = await window.launcher.checkUpdates();
|
||||
if (r && r.skipped) log('Launcher auto-update: ' + (r.reason || 'skipped (use a packaged build).'));
|
||||
else if (r && r.ok === false && r.error) setError(new Error(r.error));
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
}
|
||||
});
|
||||
|
||||
refresh();
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
# Upload local files to a GitHub release on the public distro repo (default: Dawnforger/Fractured-Distro).
|
||||
#
|
||||
# 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
|
||||
#
|
||||
# Optional:
|
||||
# DISTRO_REPO=YourOrg/Fratured-Distro # if your GitHub slug differs
|
||||
# SRC_TAG=v1.0.0 ./publish-to-distro.sh v1.0.0 # copy all assets from SOURCE_REPO release SRC_TAG
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
DISTRO_REPO="${DISTRO_REPO:-Dawnforger/Fractured-Distro}"
|
||||
SOURCE_REPO="${SOURCE_REPO:-Dawnforger/Fractured}"
|
||||
|
||||
if ! command -v gh >/dev/null 2>&1; then
|
||||
echo "Install GitHub CLI: https://cli.github.com/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "Set GH_TOKEN to a PAT with releases write access to ${DISTRO_REPO}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$#" -lt 1 ]; then
|
||||
echo "Usage: $0 <release-tag> [files...]"
|
||||
echo " or: SRC_TAG=v1.0.0 $0 <release-tag> # copies all assets from ${SOURCE_REPO} release SRC_TAG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG="$1"
|
||||
shift
|
||||
if [ "$#" -eq 0 ] && [ -z "${SRC_TAG:-}" ]; then
|
||||
echo "After the tag, list files to upload, or set SRC_TAG=... to copy all assets from ${SOURCE_REPO}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tmpdir=$(mktemp -d)
|
||||
cleanup() { rm -rf "$tmpdir"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
if [ "$#" -eq 0 ] && [ -n "${SRC_TAG:-}" ]; then
|
||||
echo "Downloading assets from ${SOURCE_REPO}@${SRC_TAG} …"
|
||||
gh release download "$SRC_TAG" -R "$SOURCE_REPO" -D "$tmpdir"
|
||||
else
|
||||
for f in "$@"; do
|
||||
if [ ! -f "$f" ]; then
|
||||
echo "Not a file: $f"
|
||||
exit 1
|
||||
fi
|
||||
cp -a "$f" "$tmpdir/"
|
||||
done
|
||||
fi
|
||||
|
||||
shopt -s nullglob
|
||||
files=("$tmpdir"/*)
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
echo "No files to upload."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if gh release view "$TAG" -R "$DISTRO_REPO" &>/dev/null; then
|
||||
gh release upload "$TAG" -R "$DISTRO_REPO" "${files[@]}" --clobber
|
||||
echo "Uploaded to https://github.com/${DISTRO_REPO}/releases/tag/${TAG}"
|
||||
else
|
||||
gh release create "$TAG" -R "$DISTRO_REPO" \
|
||||
--title "Fractured ${TAG}" \
|
||||
--notes "Published from ${SOURCE_REPO} (local script)." \
|
||||
"${files[@]}"
|
||||
echo "Created https://github.com/${DISTRO_REPO}/releases/tag/${TAG}"
|
||||
fi
|
||||
@@ -0,0 +1,126 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, Segoe UI, Roboto, sans-serif;
|
||||
background: #121018;
|
||||
color: #e8e4f0;
|
||||
padding: 20px 24px 28px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
header h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sub {
|
||||
margin: 0 0 18px;
|
||||
color: #9a92b0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.card {
|
||||
background: #1c1828;
|
||||
border: 1px solid #2e2840;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.lbl {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: #b8b0d0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.row.stack {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
#gameDir {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #3d3558;
|
||||
background: #0e0c14;
|
||||
color: #f0ecff;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
input[type='text'],
|
||||
input[type='password'] {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #3d3558;
|
||||
background: #0e0c14;
|
||||
color: #f0ecff;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
button {
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #4a4268;
|
||||
background: #2a243c;
|
||||
color: #e8e4f0;
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
button:hover:not(:disabled) {
|
||||
background: #352d4c;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button.primary {
|
||||
background: #4c3d8a;
|
||||
border-color: #5c4d9a;
|
||||
}
|
||||
button.primary:hover:not(:disabled) {
|
||||
background: #5a4a9e;
|
||||
}
|
||||
button.success {
|
||||
background: #1d6b45;
|
||||
border-color: #2a8a5a;
|
||||
margin-top: 10px;
|
||||
}
|
||||
button.success:hover:not(:disabled) {
|
||||
background: #258055;
|
||||
}
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
border-color: #4a4268;
|
||||
color: #b0a8d0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
button.ghost:hover:not(:disabled) {
|
||||
background: #241f34;
|
||||
}
|
||||
.row-actions {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
button.wide {
|
||||
width: 100%;
|
||||
}
|
||||
.log {
|
||||
margin: 12px 0 0;
|
||||
padding: 12px;
|
||||
background: #0a090e;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #2a2438;
|
||||
min-height: 120px;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
font-size: 0.78rem;
|
||||
color: #c4bdd8;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
Reference in New Issue
Block a user