chore(launcher): Electron-only distro, CI sync with Windows pack
This commit is contained in:
@@ -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.
|
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
|
## What ships in a release
|
||||||
|
|||||||
@@ -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