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