From ad8323f889b922073aea8f32be18acd34606a59e Mon Sep 17 00:00:00 2001 From: itsspin Date: Thu, 13 Aug 2026 18:26:22 -0500 Subject: [PATCH] Add Loremaster progression journal and secure updates --- .github/workflows/build-loremaster.yml | 32 +- README.md | 14 + docs/LOREMASTER_MILESTONE_2.md | 19 +- loremaster-desktop/README.md | 46 +- .../electron/item-intelligence.ts | 382 ++++++ loremaster-desktop/electron/main.ts | 511 +++++++- .../electron/portable-updater.ts | 712 +++++++++++ loremaster-desktop/electron/preload.ts | 10 + loremaster-desktop/electron/spinui-updater.ts | 1082 +++++++++++++++++ loremaster-desktop/package.json | 12 +- loremaster-desktop/pnpm-lock.yaml | 3 + .../scripts/test-item-intelligence.cjs | 59 + .../scripts/test-portable-updater.cjs | 201 +++ .../scripts/test-spinui-updater.cjs | 247 ++++ loremaster-desktop/src/App.tsx | 257 +++- loremaster-desktop/src/CombatArchive.tsx | 131 +- loremaster-desktop/src/LootChronicle.tsx | 239 ++++ loremaster-desktop/src/global.d.ts | 18 +- loremaster-desktop/src/protocol.ts | 147 +++ loremaster-desktop/src/styles.css | 236 +++- loremaster-desktop/src/themes.css | 129 ++ loremaster-desktop/src/visualIdentity.ts | 97 ++ loremaster/adventure_journal.py | 636 ++++++++++ loremaster/desktop_worker.py | 429 ++++++- loremaster/engine_protocol.py | 265 +++- loremaster/loremaster.py | 130 +- loremaster/raid_context.py | 154 +++ loremaster/tests/test_ability_categories.py | 83 ++ loremaster/tests/test_adventure_journal.py | 203 ++++ loremaster/tests/test_desktop_worker.py | 340 +++++- loremaster/tests/test_engine_protocol.py | 101 +- loremaster/tests/test_raid_context.py | 77 ++ loremaster/tests/test_weekly_tracker.py | 36 +- loremaster/weekly_tracker.py | 26 +- tools/audit_loremaster_desktop.py | 33 + tools/build_spinui_update_manifest.py | 314 +++++ tools/release_quality_gate.py | 18 + 37 files changed, 7264 insertions(+), 165 deletions(-) create mode 100644 loremaster-desktop/electron/item-intelligence.ts create mode 100644 loremaster-desktop/electron/portable-updater.ts create mode 100644 loremaster-desktop/electron/spinui-updater.ts create mode 100644 loremaster-desktop/scripts/test-item-intelligence.cjs create mode 100644 loremaster-desktop/scripts/test-portable-updater.cjs create mode 100644 loremaster-desktop/scripts/test-spinui-updater.cjs create mode 100644 loremaster-desktop/src/LootChronicle.tsx create mode 100644 loremaster-desktop/src/visualIdentity.ts create mode 100644 loremaster/adventure_journal.py create mode 100644 loremaster/raid_context.py create mode 100644 loremaster/tests/test_ability_categories.py create mode 100644 loremaster/tests/test_adventure_journal.py create mode 100644 loremaster/tests/test_raid_context.py create mode 100644 tools/build_spinui_update_manifest.py diff --git a/.github/workflows/build-loremaster.yml b/.github/workflows/build-loremaster.yml index 6d90d99..7a01ac0 100644 --- a/.github/workflows/build-loremaster.yml +++ b/.github/workflows/build-loremaster.yml @@ -99,6 +99,9 @@ jobs: - name: Install UI validation tooling run: python -m pip install --disable-pip-version-check pillow tzdata==2026.3 + - name: Verify update manifest builder + run: python tools/build_spinui_update_manifest.py --self-test + - name: Run release quality gate run: python tools/release_quality_gate.py @@ -123,11 +126,23 @@ jobs: Copy-Item -Force installer/INSTALL-MANUAL.md (Join-Path $uiPackage 'INSTALL.md') Compress-Archive -Path "$uiPackage/*" -DestinationPath package/SpinUI-UI.zip -CompressionLevel Optimal + $tag = '${{ github.event.release.tag_name || inputs.release_tag }}'.Trim() + $version = $tag -replace '^v', '' + if ($version -notmatch '^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$') { + $version = (Get-Content loremaster-desktop/package.json -Raw | ConvertFrom-Json).version + } + python tools/build_spinui_update_manifest.py ` + --archive package/SpinUI-UI.zip ` + --version $version ` + --output package/SpinUI-Update.json + - name: Upload UI test package uses: actions/upload-artifact@v6 with: name: SpinUI-UI - path: package/SpinUI-UI.zip + path: | + package/SpinUI-UI.zip + package/SpinUI-Update.json if-no-files-found: error build-loremaster: @@ -165,6 +180,9 @@ jobs: pnpm test:fixtures pnpm build pnpm test:gear + pnpm test:items + pnpm test:updates + pnpm test:skin-updates - name: Run release quality gate run: python tools/release_quality_gate.py @@ -255,7 +273,9 @@ jobs: run: | $manualPackage = Join-Path $PWD 'package/SpinUI-Manual' New-Item -ItemType Directory -Force -Path $manualPackage | Out-Null - Expand-Archive -LiteralPath package/ui-component/SpinUI-UI.zip -DestinationPath $manualPackage -Force + Copy-Item -Force package/ui-component/SpinUI-UI.zip package/SpinUI-UI.zip + Copy-Item -Force package/ui-component/SpinUI-Update.json package/SpinUI-Update.json + Expand-Archive -LiteralPath package/SpinUI-UI.zip -DestinationPath $manualPackage -Force New-Item -ItemType Directory -Force -Path dist-electron-release | Out-Null Copy-Item -Force package/loremaster-component/Loremaster.exe dist-electron-release/Loremaster.exe Copy-Item -Force dist-electron-release/Loremaster.exe $manualPackage @@ -278,6 +298,8 @@ jobs: # exact CI-built file when an antivirus heuristic questions it. $lines = foreach ($file in @( 'package/SpinUI-Manual.zip', + 'package/SpinUI-UI.zip', + 'package/SpinUI-Update.json', 'dist-electron-release/Loremaster.exe')) { $hash = (Get-FileHash -Algorithm SHA256 $file).Hash.ToLower() "$hash $(Split-Path -Leaf $file)" @@ -299,6 +321,8 @@ jobs: name: SpinUI-Windows-tools path: | dist-electron-release/Loremaster.exe + package/SpinUI-UI.zip + package/SpinUI-Update.json package/SHA256SUMS.txt if-no-files-found: error @@ -312,6 +336,8 @@ jobs: if (-not $tag) { throw 'release_tag cannot be empty' } $assets = @( 'package/SpinUI-Manual.zip', + 'package/SpinUI-UI.zip', + 'package/SpinUI-Update.json', 'package/SHA256SUMS.txt', 'dist-electron-release/Loremaster.exe' ) @@ -332,6 +358,8 @@ jobs: run: > gh release upload "${{ github.event.release.tag_name }}" package/SpinUI-Manual.zip + package/SpinUI-UI.zip + package/SpinUI-Update.json package/SHA256SUMS.txt dist-electron-release/Loremaster.exe --clobber diff --git a/README.md b/README.md index 014b367..f9476e3 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,10 @@ Loremaster turns the text log EverQuest already writes into a live **Adventurer' | Capability | What Loremaster does | |---|---| | **Encounter Lab** | Encounter, Session, and Records views with multi-enemy pulls plus actor, ability, healing, target, and a timeline grouped into two-second buckets. | +| **Durable Combat Archive** | Preserves encounter totals, duration, kills, zone, and raid context across restarts, while clearly labeling older summary-only records instead of fabricating actor or timeline detail. | +| **Automatic raid context** | Reads the logged Solo/Group instance suffix and D0–D4 label, then attaches exact zone, tier, mode, timestamp, and kill evidence to the weekly clear. Manual confirmation remains only for genuinely unknown tiers. | +| **Spoils Chronicle** | Stores and searches observed loot across restarts, including stacks, storage, currency, auto-sales, merges, and ranked upgrades; filters by item, source, zone, owner, and raid tier. | +| **Item intelligence** | Shows optional cached EQL Wiki stats, drops, quests, and notes beside the original loot evidence. Network lookup can be disabled without losing the local ledger or cached cards. | | **Adventure ledger** | XP/hour, time to level, kills, loot, all ten mote grades, coin and plat/hour, factions, skills, zones, and a bounded death recap. | | **Pets and charms** | Credits summoned pets and conservatively claimed charmed creatures; same-name charm totals are included but clearly labeled as estimates when the text log cannot distinguish actor IDs. | | **Optional DPS attribution** | Keeps total personal DPS unchanged while optionally exposing separate Self, Charmed pet, and Summoned pet damage/DPS rows for both the current encounter and session. | @@ -124,6 +128,7 @@ Loremaster turns the text log EverQuest already writes into a live **Adventurer' | **Alerts** | Opt-in banners and sound for tells, summons, deaths, charm breaks, big hits, name calls, and fight completion. Compact banners stay beside the Rune Seed with edge-safe Auto, Right, Left, Above, and Below placement choices. | | **Character continuity** | Follows standard `eqlog_*.txt` activity and supports manual log-folder selection. Packaged builds store selected records and settings under `%LOCALAPPDATA%\SpinsLoremaster`; source runs keep state beside `loremaster.py`. | | **Two native themes** | Vellum & Ember and Midnight Frost Glass are selectable in Settings so Loremaster can visually belong to either SpinUI skin. | +| **One-click verified updates** | The Settings Update Center shows the installed Loremaster version, verifies new portable builds before a rollback-safe restart, and can check, install, or repair Reloaded and Glass independently without touching EQ logs, character layouts, other skins, or the durable combat and loot journal. | ### Charm intelligence that respects the log @@ -382,11 +387,20 @@ Its first **BiS Gear Path** milestone imports the version-1 JSON produced by [EQ cd loremaster-desktop pnpm install --frozen-lockfile pnpm test:fixtures +pnpm test:updates +pnpm test:skin-updates pnpm build ``` Every UI release builds and publishes the portable Electron `Loremaster.exe` with its hidden parser engine. No installer or parallel legacy executable is produced. See the [live milestone and validation gates](docs/LOREMASTER_MILESTONE_2.md). +Loremaster's **Settings → SpinUI Update Center** can check the official release, +download and verify the portable app, then replace and relaunch it with automatic +rollback if the new renderer or parser does not become healthy. Reloaded and +Glass are verified from the same release and updated as exact, isolated skin +trees only after EverQuest closes. Updates preserve Loremaster preferences and +history, EQ logs, character UI INIs, and every unrelated skin. + ## Customizing and developing
diff --git a/docs/LOREMASTER_MILESTONE_2.md b/docs/LOREMASTER_MILESTONE_2.md index bcceb39..5ed7596 100644 --- a/docs/LOREMASTER_MILESTONE_2.md +++ b/docs/LOREMASTER_MILESTONE_2.md @@ -21,9 +21,13 @@ versioned UTF-8 JSONL and is the portable desktop shipped with UI releases. - Proven charm-break events crossing the process boundary into a short danger banner; unrelated fades still remain silent. - A conservative D0–D4 raid-reset ledger for Master Yael, Phinigel Autropos, - Lord Nagafen, Lady Vox, Innoruuk, and Cazic-Thule. Only local-player or - proven-pet kills count. Difficulty is explicit because the EQ log does not - reliably announce the selected tier. + Lord Nagafen, Lady Vox, Innoruuk, and Cazic-Thule. Evidenced self, pet, and + group participation can count. Difficulty and Solo/Group mode come from the + logged instance-entry line, with an explicit fallback only for unknown tiers. +- A Settings Update Center that displays the running version, checks the + official release, verifies portable and skin downloads, and can update + Loremaster, Reloaded, and Glass independently. App relaunch has automatic + rollback; skin replacement is isolated and blocked while EverQuest runs. - A single portable Windows test build containing the React application and its hidden parser engine. No installer target is produced. @@ -36,6 +40,11 @@ before the same binary is copied into the manual ZIP and attached to the UI release. The legacy Python GUI remains source/reference code and is not published as a competing executable. +The release also publishes `SpinUI-UI.zip`, `SpinUI-Update.json`, and +`SHA256SUMS.txt`. The manifest inventories every file in Reloaded and Glass so +Loremaster can reject partial, stale, modified, or unexpectedly expanded update +payloads before touching an installed skin. + ## Weekly tracker boundary The ledger answers “which D0–D4 raid lockouts were completed this reset?” It @@ -52,3 +61,7 @@ manually correctable. Plane of Sky is intentionally outside this weekly ledger. - The packaged engine passes a real subprocess JSONL handshake. - The unpacked Electron production application launches its bundled engine and exits cleanly through the smoke-only shutdown hook. +- Dedicated updater suites cover digest disagreement, download bounds, + traversal/path rejection, portable relaunch rollback, exact skin-tree + verification, EQ-running protection, and preservation of unrelated skins and + character layout INIs. diff --git a/loremaster-desktop/README.md b/loremaster-desktop/README.md index 2612057..74eb72a 100644 --- a/loremaster-desktop/README.md +++ b/loremaster-desktop/README.md @@ -47,20 +47,54 @@ MP3, OGG or M4A file selected through the native file picker. Custom audio is validated and size-limited by Electron's main process; the sandboxed renderer never receives general filesystem access. -Weekly D0–D4 progress comes from explicit raid difficulty plus combat-log boss -evidence, with a confirmation prompt when the difficulty is not known and a -manual correction grid. Loremaster deliberately does not scrape EverQuest's +Weekly D0–D4 progress now comes directly from the logged instance-entry line +and the later boss-kill evidence. Loremaster records the exact Solo/Group mode, +difficulty label, zone, timestamp, and evidence with the clear. A confirmation +prompt and manual grid remain as conservative fallbacks when the log truly does +not identify a tier. Loremaster deliberately does not scrape EverQuest's Instance Information window or reserve a global lockout-screen hotkey. +## Adventure memory and Spoils Chronicle + +Completed encounters and observed loot survive restarts in a local SQLite +journal using WAL mode, bounded live snapshots, and paged searches. The combat +Archive clearly distinguishes a durable summary from a current fight: totals, +duration, kills, zone, and raid context persist, while actor or timeline detail +is never invented for older summaries. + +The **Spoils Chronicle** recognizes ordinary corpse loot, stacks, auto-sales, +Dragon Hoard/depot and currency storage, inventory placement, item merges, and +automatic ranked-item upgrades. Search and filter the complete local history by +item, source, zone, owner, and D0–D4 context. Selecting an item can show cached +EQL Wiki stats, drops, quests, and notes alongside the original log evidence; +network lookup is optional and can be disabled while cached cards remain usable. +All network parsing runs in Electron's main process, never in the renderer. + +Combat actors retain stable, accessible colors across the Seed, HUD, and +Archive. Ability evidence is categorized as melee, spell, DoT, proc, pet, +damage shield, or healing, with unknown evidence kept explicitly unknown. + The Gear Path surface imports EQ Legends Tools' version-1 character-sheet JSON and EverQuest's `/outputfile inventory` TXT locally. It identifies goal items already equipped or held in bags/bank and groups missing pieces by source zone. Item/source metadata is refreshed on explicit user action and cached locally. Credit: [EQ Legends Tools](https://eqlegendstools.com/) by **FlammHammer**. -Because releases remain portable (no installer), Settings includes a safe -GitHub release check and opens the official release page when an update exists; -the app never silently replaces its running executable. +Because releases remain portable (no installer), Settings includes a complete +SpinUI Update Center. It checks the official release at most once per day, +shows the running Loremaster version, and downloads a newer portable build only +after the user chooses Update. The executable is authenticated against both +GitHub's asset digest and `SHA256SUMS.txt`, staged beside app data, then replaced +by a rollback-capable helper; the new build must report a healthy renderer and +parser start or the previous executable is restored. + +The same surface verifies `spinui_reloaded` and `spinui_glass` against the +release's exact file manifest. A skin install is blocked while `eqgame.exe` is +running, staged and verified before replacement, and limited to the selected +`uifiles` child with a rollback copy. Character layout INIs, EQ logs, other +skins, Loremaster settings, and the combat/loot journal are never update +targets. Automatic checks are enabled by default; downloads and installs always +remain explicit. See [milestone 2](../docs/LOREMASTER_MILESTONE_2.md) for the live scope and release validation gates. diff --git a/loremaster-desktop/electron/item-intelligence.ts b/loremaster-desktop/electron/item-intelligence.ts new file mode 100644 index 0000000..69f67f4 --- /dev/null +++ b/loremaster-desktop/electron/item-intelligence.ts @@ -0,0 +1,382 @@ +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +const EQL_WIKI_ORIGIN = "https://eqlwiki.com"; +const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const MAX_RESPONSE_BYTES = 2 * 1024 * 1024; +const MAX_CACHE_BYTES = 512 * 1024; +const MAX_QUERY_LENGTH = 160; +const CACHE_SCHEMA_VERSION = 1; +const MAX_CONCURRENT_REQUESTS = 4; + +const sectionParameters: Readonly> = { + dropsfrom: "Drops From", + soldby: "Sold by", + relatedquests: "Related quests", + quests: "Related quests", + playercrafted: "Player crafted", + tradeskillrecipes: "Tradeskill recipes", + recipes: "Tradeskill recipes", +}; + +const profileLabels: Readonly> = { + merchantvalue: "Merchant value", + focuseffect: "Focus Effect", + worneffect: "Worn Effect", + clickeffect: "Click Effect", + proceffect: "Proc Effect", +}; + +export interface ItemLookupView { + status: "ready" | "not-found" | "offline" | "error"; + requestedName: string; + title: string; + url: string; + stats: string[]; + notes: string[]; + sections: Record; + freshness: "live" | "cached" | "stale"; + detail: string; +} + +interface CachedItem extends Omit { + fetchedAt: number; +} + +interface CacheEnvelope { + schemaVersion: number; + item: CachedItem; +} + +function stringRows(value: unknown, limit: number): string[] { + if (!Array.isArray(value)) return []; + return value.slice(0, limit).map((row) => String(row).slice(0, 4_000)); +} + +function sanitizeCachedItem(value: unknown): CachedItem | null { + if (!value || typeof value !== "object") return null; + const raw = value as Partial; + const title = normalizeItemName(String(raw.title ?? "")); + if (title.length < 2 || !Number.isFinite(raw.fetchedAt)) return null; + let url = wikiUrl(title); + try { + const candidate = new URL(String(raw.url ?? "")); + if (candidate.protocol === "https:" && ["eqlwiki.com", "www.eqlwiki.com"].includes(candidate.hostname)) { + url = candidate.toString(); + } + } catch { /* a canonical EQL Wiki URL is supplied below */ } + const sections: Record = {}; + if (raw.sections && typeof raw.sections === "object" && !Array.isArray(raw.sections)) { + for (const [heading, rows] of Object.entries(raw.sections).slice(0, 16)) { + const cleaned = stringRows(rows, 24); + if (cleaned.length) sections[String(heading).slice(0, 120)] = cleaned; + } + } + return { + title, + url, + stats: stringRows(raw.stats, 48), + notes: stringRows(raw.notes, 24), + sections, + fetchedAt: Number(raw.fetchedAt), + }; +} + +export async function readLimitedResponse(response: Response): Promise { + if (!response.body) { + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > MAX_RESPONSE_BYTES) throw new Error("EQL Wiki response exceeded the 2 MB safety limit"); + return new TextDecoder().decode(bytes); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let total = 0; + let text = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_RESPONSE_BYTES) { + await reader.cancel("response-size-limit"); + throw new Error("EQL Wiki response exceeded the 2 MB safety limit"); + } + text += decoder.decode(value, { stream: true }); + } + return text + decoder.decode(); +} + +function emptyLookup( + requestedName: string, + status: ItemLookupView["status"], + detail: string, +): ItemLookupView { + return { + status, + requestedName, + title: requestedName, + url: wikiUrl(requestedName), + stats: [], + notes: [], + sections: {}, + freshness: "live", + detail, + }; +} + +export function normalizeItemName(value: string): string { + return String(value ?? "") + .replace(/[\u0000-\u001f\u007f]/g, " ") + .replace(/\s+\+[0-9]+\s*$/, "") + .replace(/\s+/g, " ") + .trim() + .slice(0, MAX_QUERY_LENGTH); +} + +function wikiUrl(value: string): string { + const slug = encodeURIComponent(normalizeItemName(value).replace(/\s+/g, "_")) + .replaceAll("%3A", ":") + .replaceAll("%27", "'") + .replaceAll("%28", "(") + .replaceAll("%29", ")"); + return `${EQL_WIKI_ORIGIN}/${slug}`; +} + +function decodeEntities(value: string): string { + const named: Readonly> = { + amp: "&", lt: "<", gt: ">", quot: "\"", apos: "'", nbsp: " ", + }; + return value.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (match, entity: string) => { + if (entity[0] === "#") { + const hex = entity[1]?.toLowerCase() === "x"; + const parsed = Number.parseInt(entity.slice(hex ? 2 : 1), hex ? 16 : 10); + return Number.isFinite(parsed) && parsed > 0 && parsed <= 0x10ffff + ? String.fromCodePoint(parsed) + : match; + } + return named[entity.toLowerCase()] ?? match; + }); +} + +function cleanWikiText(input: string, limit = 4_000): string[] { + let value = String(input ?? "").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " "); + value = value.slice(0, Math.max(limit * 8, 32_000)); + value = value + .replace(/<(?:script|style)\b[^>]*>[\s\S]*?<\/(?:script|style)\s*>/gi, "") + .replace(//gi, "\n") + .replace(//g, "") + .replace(/\[\[(?:[^\]|]+\|)?([^\]]+)\]\]/g, "$1") + .replace(/\[(?:https?:\/\/\S+)\s+([^\]]+)\]/g, "$1") + .replace(/\[(?:https?:\/\/[^\]]+)\]/g, ""); + for (let pass = 0; pass < 100; pass += 1) { + const next = value.replace(/\{\{[^{}]*\}\}/g, ""); + if (next === value) break; + value = next; + } + value = decodeEntities(value.replace(/<[^>]+>/g, "").replace(/'''?/g, "")); + const rows: string[] = []; + let consumed = 0; + for (const rawLine of value.split(/\r?\n/)) { + let line = rawLine.trim(); + if (!line) continue; + let depth = 0; + const marker = line.match(/^([*#:;]+)\s*(.*)$/); + if (marker) { + depth = Math.max(1, marker[1].replaceAll(":", "").length); + line = marker[2].trim(); + } + line = line.replace(/\s+/g, " ").replace(/^[\s-]+|[\s-]+$/g, ""); + if (!line) continue; + if (depth) line = `${" ".repeat(depth - 1)}\u2022 ${line}`; + const remaining = Math.max(0, limit - consumed); + if (!remaining) break; + line = line.slice(0, remaining); + rows.push(line); + consumed += line.length; + } + return rows; +} + +function templateParameters(wikiText: string): Record { + const parameters: Record = {}; + let current = ""; + let inItem = false; + for (let line of String(wikiText ?? "").split(/\r?\n/)) { + if (!inItem) { + const match = line.match(/\{\{\s*Itempage\b/i); + if (!match || match.index === undefined) continue; + inItem = true; + line = line.slice(match.index + match[0].length); + } + if (/^\s*\}\}\s*(?:<\/onlyinclude>)?\s*$/i.test(line)) break; + const parameter = line.match(/^\s*\|\s*([A-Za-z0-9_ ]+)\s*=\s*(.*)$/); + if (parameter) { + current = parameter[1].toLowerCase().replace(/[^a-z0-9]/g, ""); + parameters[current] = [parameter[2]]; + } else if (current) { + parameters[current].push(line); + } + } + return Object.fromEntries(Object.entries(parameters).map(([key, lines]) => [key, lines.join("\n").trim()])); +} + +export function parseItemPayload(payload: unknown, requestedName: string): CachedItem | null { + if (!payload || typeof payload !== "object") return null; + const parsed = (payload as { parse?: unknown }).parse; + if (!parsed || typeof parsed !== "object") return null; + const record = parsed as { title?: unknown; wikitext?: unknown }; + const rawWikiText = typeof record.wikitext === "object" && record.wikitext !== null + ? String((record.wikitext as { "*"?: unknown })["*"] ?? "") + : String(record.wikitext ?? ""); + const parameters = templateParameters(rawWikiText); + if (!parameters.itemname && !parameters.statsblock) return null; + const title = normalizeItemName(parameters.itemname || String(record.title ?? requestedName)); + if (!title) return null; + const sections: Record = {}; + for (const [parameter, section] of Object.entries(sectionParameters)) { + if (!parameters[parameter]) continue; + const rows = cleanWikiText(parameters[parameter]); + if (rows.length) sections[section] = [...(sections[section] ?? []), ...rows]; + } + const stats = cleanWikiText(parameters.statsblock ?? "", 2_600); + const structural = new Set(["itemname", "lucyimgid", "statsblock", "notes", ...Object.keys(sectionParameters)]); + for (const [key, value] of Object.entries(parameters)) { + if (structural.has(key) || !value.trim()) continue; + const rows = cleanWikiText(value, 800); + if (!rows.length) continue; + const label = profileLabels[key] ?? key.replace(/_/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); + stats.push(`${label}: ${rows[0]}`, ...rows.slice(1).map((row) => ` ${row}`)); + if (stats.length >= 40) break; + } + return { + title, + url: wikiUrl(title), + stats: stats.slice(0, 40), + notes: cleanWikiText(parameters.notes ?? "", 1_200), + sections, + fetchedAt: Date.now(), + }; +} + +export class ItemIntelligenceService { + private readonly cacheDirectory: string; + private readonly inflight = new Map>(); + private activeFetches = 0; + + constructor(userDataDirectory: string) { + this.cacheDirectory = path.join(userDataDirectory, "item-intelligence"); + } + + private cachePath(name: string): string { + const key = createHash("sha256").update(normalizeItemName(name).toLowerCase(), "utf8").digest("hex").slice(0, 32); + return path.join(this.cacheDirectory, `${key}.json`); + } + + private readCache(name: string): CachedItem | null { + try { + const target = this.cachePath(name); + if (statSync(target).size > MAX_CACHE_BYTES) return null; + const decoded = JSON.parse(readFileSync(target, "utf8")) as CacheEnvelope | CachedItem; + if (decoded && typeof decoded === "object" && "item" in decoded + && decoded.schemaVersion !== CACHE_SCHEMA_VERSION) return null; + const value = "item" in decoded ? decoded.item : decoded; + return sanitizeCachedItem(value); + } catch { + return null; + } + } + + private writeCache(name: string, item: CachedItem): void { + mkdirSync(this.cacheDirectory, { recursive: true }); + const target = this.cachePath(name); + const temporary = `${target}.${process.pid}.tmp`; + try { + const payload: CacheEnvelope = { schemaVersion: CACHE_SCHEMA_VERSION, item }; + writeFileSync(temporary, JSON.stringify(payload), "utf8"); + renameSync(temporary, target); + } finally { + try { unlinkSync(temporary); } catch { /* atomic rename already consumed it */ } + } + } + + private ready(name: string, item: CachedItem, freshness: ItemLookupView["freshness"]): ItemLookupView { + return { + status: "ready", + requestedName: name, + title: item.title, + url: item.url, + stats: item.stats, + notes: item.notes, + sections: item.sections, + freshness, + detail: freshness === "live" ? "Validated against EQL Wiki just now." + : freshness === "cached" ? "Loaded instantly from Loremaster's local EQL Wiki cache." + : "EQL Wiki is offline; showing the most recent cached profile.", + }; + } + + lookup(rawName: string, networkEnabled = true): Promise { + const name = normalizeItemName(rawName); + if (name.length < 2) return Promise.resolve(emptyLookup(name, "not-found", "Select a valid item name.")); + const cache = this.readCache(name); + if (cache && Date.now() - cache.fetchedAt <= CACHE_TTL_MS) { + return Promise.resolve(this.ready(name, cache, "cached")); + } + if (!networkEnabled) { + return Promise.resolve(cache + ? this.ready(name, cache, "stale") + : emptyLookup(name, "offline", "Network item lookups are disabled. Enable them in Settings or open EQL Wiki manually.")); + } + const key = name.toLowerCase(); + const existing = this.inflight.get(key); + if (existing) return existing; + if (this.activeFetches >= MAX_CONCURRENT_REQUESTS) { + return Promise.resolve(emptyLookup( + name, "error", "Too many item lookups are already active. Select the item again in a moment.")); + } + // Only duplicate item requests are coalesced. Independent selections are + // allowed to resolve concurrently so one offline page cannot hold every + // subsequent click behind its full timeout. + this.activeFetches += 1; + const request = this.fetchItem(name, cache).finally(() => { + this.activeFetches = Math.max(0, this.activeFetches - 1); + }); + this.inflight.set(key, request); + void request.finally(() => this.inflight.delete(key)); + return request; + } + + private async fetchItem(name: string, stale: CachedItem | null): Promise { + const query = new URLSearchParams({ + action: "parse", + format: "json", + page: name, + prop: "wikitext|sections", + redirects: "1", + }); + try { + const response = await fetch(`${EQL_WIKI_ORIGIN}/api.php?${query}`, { + headers: { + Accept: "application/json", + "User-Agent": "Spins-Loremaster/2.0 (https://github.com/itsspin/spinips)", + }, + signal: AbortSignal.timeout(8_000), + }); + if (!response.ok) throw new Error(`EQL Wiki returned HTTP ${response.status}`); + const contentLength = Number(response.headers.get("content-length") ?? 0); + if (contentLength > MAX_RESPONSE_BYTES) throw new Error("EQL Wiki response exceeded the 2 MB safety limit"); + const text = await readLimitedResponse(response); + const item = parseItemPayload(JSON.parse(text), name); + if (!item) return emptyLookup(name, "not-found", `No exact EQL Wiki item page was found for “${name}”.`); + try { this.writeCache(name, item); } catch { /* read-only data directories must not hide a valid response */ } + return this.ready(name, item, "live"); + } catch (error) { + if (stale) return this.ready(name, stale, "stale"); + const detail = error instanceof Error ? error.message : String(error); + const offline = /fetch|network|timeout|abort|ENOTFOUND|HTTP 5\d\d/i.test(detail); + return emptyLookup(name, offline ? "offline" : "error", offline + ? "EQL Wiki is unavailable and this item is not cached yet." + : `Item lookup failed: ${detail}`); + } + } +} diff --git a/loremaster-desktop/electron/main.ts b/loremaster-desktop/electron/main.ts index df0b177..d22fab2 100644 --- a/loremaster-desktop/electron/main.ts +++ b/loremaster-desktop/electron/main.ts @@ -13,6 +13,22 @@ import { type GearPlanView, type InventoryEntry, } from "./gear-plan"; +import { ItemIntelligenceService } from "./item-intelligence"; +import { + acknowledgePortableUpdateRelaunch, + PortableUpdateService, + type StagedPortableUpdate, + type UpdateCheckResult, + type UpdateProgress, +} from "./portable-updater"; +import { + EverQuestRunningError, + SPINUI_SKINS, + SpinUISkinUpdateService, + type SpinUISkinName, + type SpinUISkinStatus, + type SpinUIUpdateState, +} from "./spinui-updater"; const processStartedAt = performance.now(); app.commandLine.appendSwitch("autoplay-policy", "no-user-gesture-required"); @@ -33,6 +49,16 @@ let tray: Tray | null = null; let trayMinimizeNoticeShown = false; let topmostReassertTimers: NodeJS.Timeout[] = []; let topmostHeartbeatTimer: NodeJS.Timeout | null = null; +let itemIntelligence: ItemIntelligenceService | null = null; +let portableUpdater: PortableUpdateService | null = null; +let spinUISkinUpdater: SpinUISkinUpdateService | null = null; +let stagedPortableUpdate: StagedPortableUpdate | null = null; +let portableInstallHandoff = false; +let portableRelaunchAcknowledged = false; +let portableRelaunchAcknowledgeInFlight = false; +let rendererHealthyForUpdate = false; +let engineHealthyForUpdate = false; +let updateCenterState: UpdateCenterState | null = null; const SEED_SIZE = { width: 128, height: 74 } as const; const EXPANDED_SIZE = { width: 470, height: 580 } as const; @@ -90,6 +116,8 @@ export interface AlertSettings { interface DesktopSettings { logPath: string; + eqRoot: string; + autoCheckUpdates: boolean; raidDifficulty: number | null; bisBuildPath: string; inventoryPath: string; @@ -99,10 +127,33 @@ interface DesktopSettings { composition: string; splitCharmedPetDps: boolean; stanceAdvisorEnabled: boolean; + itemNetworkLookups: boolean; seedPosition: { x: number; y: number } | null; alerts: AlertSettings; } +type UpdateComponentId = "loremaster" | "spinui_reloaded" | "spinui_glass"; +type UpdateComponentPhase = + | "idle" | "checking" | "current" | "available" | "not-installed" | "modified" + | "downloading" | "verifying" | "ready" | "waiting-for-eq" | "installing" + | "restart-required" | "error"; +interface UpdateComponentState { + id: UpdateComponentId; + phase: UpdateComponentPhase; + currentVersion: string; + latestVersion: string; + progress: number | null; + detail: string; +} +interface UpdateCenterState { + currentVersion: string; + latestVersion: string; + lastCheckedAt: string; + eqRoot: string; + busy: boolean; + components: Record; +} + interface EngineHealth { state: "starting" | "searching" | "live" | "error" | "stopped"; detail: string; @@ -173,6 +224,8 @@ const defaultAlertSettings: AlertSettings = { const defaultSettings: DesktopSettings = { logPath: "", + eqRoot: "", + autoCheckUpdates: true, raidDifficulty: null, bisBuildPath: "", inventoryPath: "", @@ -182,6 +235,7 @@ const defaultSettings: DesktopSettings = { composition: "", splitCharmedPetDps: false, stanceAdvisorEnabled: false, + itemNetworkLookups: true, seedPosition: null, alerts: defaultAlertSettings, }; @@ -210,8 +264,13 @@ function readSettings(): DesktopSettings { return Number.isFinite(numeric) ? Math.max(low, Math.min(high, Math.round(numeric))) : fallback; }; const boolean = (candidate: unknown, fallback: boolean) => typeof candidate === "boolean" ? candidate : fallback; + const logPath = typeof value.logPath === "string" ? value.logPath : ""; + const eqRoot = resolveEqRoot(typeof value.eqRoot === "string" ? value.eqRoot : "") + || resolveEqRoot(logPath); return { - logPath: typeof value.logPath === "string" ? value.logPath : "", + logPath, + eqRoot, + autoCheckUpdates: boolean(value.autoCheckUpdates, true), raidDifficulty, bisBuildPath: typeof value.bisBuildPath === "string" ? value.bisBuildPath : "", inventoryPath: typeof value.inventoryPath === "string" ? value.inventoryPath : "", @@ -221,6 +280,7 @@ function readSettings(): DesktopSettings { composition: typeof value.composition === "string" ? value.composition.slice(0, 48) : "", splitCharmedPetDps: boolean(value.splitCharmedPetDps, false), stanceAdvisorEnabled: boolean(value.stanceAdvisorEnabled, false), + itemNetworkLookups: boolean(value.itemNetworkLookups, true), seedPosition, alerts: { alertsEnabled: boolean(alertValue.alertsEnabled, defaultAlertSettings.alertsEnabled), @@ -248,6 +308,24 @@ function readSettings(): DesktopSettings { } } +function resolveEqRoot(value: string): string { + const raw = value.trim().replace(/^"|"$/g, ""); + if (!raw) return ""; + let candidate = path.resolve(raw); + try { + if (statSync(candidate).isFile()) candidate = path.dirname(candidate); + } catch { + if (path.extname(candidate)) candidate = path.dirname(candidate); + } + for (let depth = 0; depth < 5; depth += 1) { + if (existsSync(path.join(candidate, "eqgame.exe"))) return candidate; + const parent = path.dirname(candidate); + if (parent === candidate) break; + candidate = parent; + } + return ""; +} + function saveSettings(settings: DesktopSettings): void { const target = settingsPath(); const temporary = `${target}.tmp`; @@ -255,12 +333,191 @@ function saveSettings(settings: DesktopSettings): void { renameSync(temporary, target); } +function maybeAcknowledgePortableRelaunch(): void { + if (portableRelaunchAcknowledged || portableRelaunchAcknowledgeInFlight + || !rendererHealthyForUpdate || !engineHealthyForUpdate) return; + portableRelaunchAcknowledgeInFlight = true; + void acknowledgePortableUpdateRelaunch(app.getPath("userData")).then((acknowledged) => { + portableRelaunchAcknowledged = acknowledged; + }).catch((error: unknown) => { + console.error("Could not acknowledge the verified Loremaster update relaunch", error); + }).finally(() => { + portableRelaunchAcknowledgeInFlight = false; + }); +} + +function updateMetadataPath(): string { + return path.join(app.getPath("userData"), "update-center.json"); +} + +function readUpdateMetadata(): { lastCheckedAt: string; lastNotifiedVersion: string } { + try { + const value = JSON.parse(readFileSync(updateMetadataPath(), "utf8")) as Record; + return { + lastCheckedAt: typeof value.lastCheckedAt === "string" ? value.lastCheckedAt : "", + lastNotifiedVersion: typeof value.lastNotifiedVersion === "string" ? value.lastNotifiedVersion : "", + }; + } catch { + return { lastCheckedAt: "", lastNotifiedVersion: "" }; + } +} + +function saveUpdateMetadata(value: { lastCheckedAt: string; lastNotifiedVersion: string }): void { + const target = updateMetadataPath(); + const temporary = `${target}.tmp`; + writeFileSync(temporary, `${JSON.stringify({ schemaVersion: 1, ...value }, null, 2)}\n`, "utf8"); + renameSync(temporary, target); +} + +function initialUpdateCenterState(settings = readSettings()): UpdateCenterState { + const currentVersion = app.getVersion(); + const metadata = readUpdateMetadata(); + const component = (id: UpdateComponentId, detail: string): UpdateComponentState => ({ + id, phase: "idle", currentVersion: id === "loremaster" ? currentVersion : "", + latestVersion: "", progress: null, detail, + }); + return { + currentVersion, + latestVersion: "", + lastCheckedAt: metadata.lastCheckedAt, + eqRoot: settings.eqRoot, + busy: false, + components: { + loremaster: component("loremaster", "Ready to check the official SpinUI release."), + spinui_reloaded: component("spinui_reloaded", settings.eqRoot ? "Ready to verify SpinUI Reloaded." : "Select your EverQuest folder to check this skin."), + spinui_glass: component("spinui_glass", settings.eqRoot ? "Ready to verify SpinUI Glass." : "Select your EverQuest folder to check this skin."), + }, + }; +} + +function publishUpdateCenter(): UpdateCenterState { + updateCenterState ??= initialUpdateCenterState(); + const snapshot = structuredClone(updateCenterState); + mainWindow?.webContents.send("updates:state", snapshot); + return snapshot; +} + +function portablePhase(value: UpdateProgress["phase"]): UpdateComponentPhase { + return value; +} + +function skinPhase(value: SpinUISkinStatus["phase"]): UpdateComponentPhase { + if (value === "missing") return "not-installed"; + if (value === "installed") return "current"; + return value; +} + +function mergePortableUpdate(progress: UpdateProgress, check?: UpdateCheckResult): void { + updateCenterState ??= initialUpdateCenterState(); + const current = updateCenterState.components.loremaster; + updateCenterState.components.loremaster = { + ...current, + phase: portablePhase(progress.phase), + currentVersion: check?.currentVersion || current.currentVersion || app.getVersion(), + latestVersion: check?.latestVersion || progress.version || current.latestVersion, + progress: ["downloading", "verifying", "installing"].includes(progress.phase) ? progress.percent : null, + detail: progress.detail, + }; + if (check) { + updateCenterState.latestVersion = check.latestVersion || updateCenterState.latestVersion; + } + updateCenterState.busy = ["downloading", "verifying", "installing"].includes(progress.phase) + || Boolean(spinUISkinUpdater?.getState().busy); + publishUpdateCenter(); +} + +function mergeSkinUpdate(state: SpinUIUpdateState): void { + updateCenterState ??= initialUpdateCenterState(); + updateCenterState.eqRoot = state.eqRoot || updateCenterState.eqRoot; + updateCenterState.latestVersion = state.latestVersion || updateCenterState.latestVersion; + updateCenterState.lastCheckedAt = state.lastCheckedAt || updateCenterState.lastCheckedAt; + for (const theme of SPINUI_SKINS) { + const skin = state.themes[theme]; + updateCenterState.components[theme] = { + id: theme, + phase: skinPhase(skin.phase), + currentVersion: skin.installedVersion || "", + latestVersion: skin.latestVersion || "", + progress: ["downloading", "verifying", "installing"].includes(skin.phase) ? skin.percent : null, + detail: skin.detail, + }; + } + updateCenterState.busy = state.busy + || ["downloading", "verifying", "installing"].includes(portableUpdater?.getProgress().phase ?? "idle"); + publishUpdateCenter(); +} + +function initializeUpdateServices(): void { + updateCenterState = initialUpdateCenterState(engine?.getState().settings ?? readSettings()); + portableUpdater = new PortableUpdateService({ + currentVersion: app.getVersion(), + userDataDir: app.getPath("userData"), + ...(app.isPackaged ? {} : { executablePath: null }), + }); + portableUpdater.subscribe((progress) => mergePortableUpdate(progress)); + spinUISkinUpdater = new SpinUISkinUpdateService({ + userDataDir: app.getPath("userData"), + eqRoot: updateCenterState.eqRoot || null, + }); + spinUISkinUpdater.subscribe(mergeSkinUpdate); +} + +async function checkAllUpdates(): Promise { + updateCenterState ??= initialUpdateCenterState(); + if (!portableUpdater || !spinUISkinUpdater) initializeUpdateServices(); + updateCenterState.busy = true; + publishUpdateCenter(); + const eqRoot = engine?.getState().settings.eqRoot || updateCenterState.eqRoot || undefined; + let portable: UpdateCheckResult | null = null; + try { + [portable] = await Promise.all([ + portableUpdater!.check(), + spinUISkinUpdater!.check(eqRoot), + ]); + mergePortableUpdate(portableUpdater!.getProgress(), portable); + mergeSkinUpdate(spinUISkinUpdater!.getState()); + } finally { + updateCenterState.busy = false; + } + const checkedAt = new Date().toISOString(); + updateCenterState.lastCheckedAt = checkedAt; + const metadata = readUpdateMetadata(); + const availableVersion = updateCenterState.components.loremaster.phase === "available" + ? updateCenterState.components.loremaster.latestVersion + : SPINUI_SKINS.some((theme) => ["available", "modified"].includes(updateCenterState!.components[theme].phase)) + ? updateCenterState.latestVersion : ""; + if (availableVersion && availableVersion !== metadata.lastNotifiedVersion) { + const changedComponents = (Object.values(updateCenterState.components) as UpdateComponentState[]) + .filter((component) => ["available", "modified"].includes(component.phase)) + .map((component) => component.id === "loremaster" ? "Loremaster" + : component.id === "spinui_reloaded" ? "SpinUI Reloaded" : "SpinUI Glass"); + alertWindow?.webContents.send("alerts:test", { + id: `update-${availableVersion}`, + severity: "info", + title: `UPDATE ${availableVersion} READY`, + target: `${changedComponents.join(" + ")} can be updated from Settings.`, + }); + if (tray && process.platform === "win32") { + tray.displayBalloon({ + title: `Loremaster ${availableVersion} update ready`, + content: `${changedComponents.join(" + ")} can be updated in one click from Settings.`, + iconType: "info", + }); + } + metadata.lastNotifiedVersion = availableVersion; + } + saveUpdateMetadata({ ...metadata, lastCheckedAt: checkedAt }); + return publishUpdateCenter(); +} + function newestEqLog(selectedPath: string): string { const cleaned = selectedPath.trim().replace(/^"|"$/g, ""); const candidates = cleaned ? [cleaned, path.join(cleaned, "Logs")] : [ "C:\\EQLegends\\Logs", "C:\\EQLegends", + "D:\\EQLegends\\Logs", "D:\\EQLegends", + "E:\\EQLegends\\Logs", "E:\\EQLegends", "C:\\Users\\Public\\Daybreak Game Company\\Installed Games\\EverQuest Legends\\Logs", "C:\\Users\\Public\\Daybreak Game Company\\Installed Games\\EverQuest Legends", "C:\\Users\\Public\\Daybreak Game Company\\Installed Games\\EverQuest\\Logs", @@ -293,6 +550,11 @@ class EngineSupervisor { private restartCount = 0; private restartTimer: NodeJS.Timeout | null = null; private attachmentRetryTimer: NodeJS.Timeout | null = null; + private journalRequestSequence = 0; + private readonly journalRequests = new Map void; + timer: NodeJS.Timeout; + }>(); private health: EngineHealth = { ...defaultHealth }; private snapshot: unknown = null; private settings = readSettings(); @@ -329,6 +591,8 @@ class EngineSupervisor { private publishHealth(health: EngineHealth): void { this.health = health; mainWindow?.webContents.send("engine:health", health); + if (health.state === "searching" || health.state === "live") engineHealthyForUpdate = true; + maybeAcknowledgePortableRelaunch(); } private spawnWorker(): void { @@ -382,6 +646,7 @@ class EngineSupervisor { child.once("error", (error) => this.handleSpawnFailure(error)); child.once("exit", (code) => { this.child = null; + this.settleJournalRequests(); if (this.stopping) return; this.publishHealth({ ...this.health, @@ -415,7 +680,14 @@ class EngineSupervisor { return; } if (event.protocolVersion !== 1 || typeof event.eventType !== "string") return; - if (event.eventType === "engine.snapshot" && event.snapshot) { + if (event.eventType === "engine.loot-query" && typeof event.requestId === "string") { + const pending = this.journalRequests.get(event.requestId); + if (pending) { + clearTimeout(pending.timer); + this.journalRequests.delete(event.requestId); + pending.resolve(event.result); + } + } else if (event.eventType === "engine.snapshot" && event.snapshot) { this.snapshot = event; mainWindow?.webContents.send("engine:snapshot", event); alertWindow?.webContents.send("engine:snapshot", event); @@ -443,12 +715,62 @@ class EngineSupervisor { this.child.stdin.write(`${JSON.stringify(command)}\n`); } + private settleJournalRequests(): void { + for (const pending of this.journalRequests.values()) { + clearTimeout(pending.timer); + pending.resolve({ rows: [], total: 0, offset: 0, hasMore: false }); + } + this.journalRequests.clear(); + } + + queryLoot(value: unknown): Promise { + const request = value && typeof value === "object" + ? value as Record : {}; + const scope = ["all", "mine", "others", "known"].includes(String(request.scope)) + ? String(request.scope) : "all"; + const rawTier = request.raidTier; + const raidTier = rawTier === "open" || rawTier === "all" + ? rawTier + : typeof rawTier === "number" && Number.isInteger(rawTier) && rawTier >= 0 && rawTier <= 4 + ? Number(rawTier) : "all"; + const filters = { + query: String(request.query ?? "").trim().slice(0, 160), + zone: String(request.zone ?? "").trim().slice(0, 160), + raidTier, + scope, + offset: Math.floor(clamp(Number(request.offset) || 0, 0, 1_000_000)), + limit: Math.floor(clamp(Number(request.limit) || 100, 1, 250)), + }; + if (!this.child?.stdin.writable) { + return Promise.resolve({ rows: [], total: 0, offset: filters.offset, hasMore: false }); + } + const requestId = `loot-${process.pid}-${++this.journalRequestSequence}`; + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.journalRequests.delete(requestId); + resolve({ rows: [], total: 0, offset: filters.offset, hasMore: false }); + }, 5_000); + this.journalRequests.set(requestId, { resolve, timer }); + this.send({ type: "engine.query-loot", requestId, filters }); + }); + } + + cacheItem(value: unknown): void { + if (!value || typeof value !== "object") return; + this.send({ type: "engine.cache-item", item: value }); + } + getState(): { health: EngineHealth; snapshot: unknown; settings: DesktopSettings; gearPlan: GearPlanView } { return { health: this.health, snapshot: this.snapshot, settings: this.settings, gearPlan: this.gearPlan }; } setLogPath(logPath: string): void { - this.settings = { ...this.settings, logPath }; + const detectedEqRoot = resolveEqRoot(logPath); + this.settings = { + ...this.settings, + logPath, + ...(detectedEqRoot && !this.settings.eqRoot ? { eqRoot: detectedEqRoot } : {}), + }; saveSettings(this.settings); const parserLogPath = newestEqLog(logPath); this.send({ type: "engine.set-log-path", logPath: parserLogPath || logPath }); @@ -484,7 +806,7 @@ class EngineSupervisor { this.send({ type: "engine.set-raid-difficulty", raidDifficulty }); } - updateDesktopSettings(patch: Partial> & { + updateDesktopSettings(patch: Partial> & { alerts?: Partial; }): DesktopSettings { const nextAlerts = patch.alerts ? { @@ -508,6 +830,9 @@ class EngineSupervisor { ...(typeof patch.composition === "string" ? { composition: patch.composition.trim().slice(0, 48) } : {}), ...(typeof patch.splitCharmedPetDps === "boolean" ? { splitCharmedPetDps: patch.splitCharmedPetDps } : {}), ...(typeof patch.stanceAdvisorEnabled === "boolean" ? { stanceAdvisorEnabled: patch.stanceAdvisorEnabled } : {}), + ...(typeof patch.itemNetworkLookups === "boolean" ? { itemNetworkLookups: patch.itemNetworkLookups } : {}), + ...(typeof patch.autoCheckUpdates === "boolean" ? { autoCheckUpdates: patch.autoCheckUpdates } : {}), + ...(typeof patch.eqRoot === "string" ? { eqRoot: resolveEqRoot(patch.eqRoot) } : {}), alerts: nextAlerts, }; saveSettings(this.settings); @@ -645,6 +970,7 @@ class EngineSupervisor { this.stopping = true; if (this.restartTimer) clearTimeout(this.restartTimer); if (this.attachmentRetryTimer) clearTimeout(this.attachmentRetryTimer); + this.settleJournalRequests(); this.send({ type: "engine.shutdown" }); const child = this.child; if (child) setTimeout(() => { @@ -1257,6 +1583,8 @@ function createWindow(): void { ? mainWindow.loadURL(rendererUrl(developmentUrl, rendererQuery)) : mainWindow.loadFile(path.join(app.getAppPath(), "dist", "index.html"), { query: rendererQuery }); void rendererReady.then(() => { + rendererHealthyForUpdate = true; + maybeAcknowledgePortableRelaunch(); mainWindow?.webContents.on("will-navigate", (event) => event.preventDefault()); mainWindow?.webContents.setZoomFactor(settings.fontScale); setWindowMode(false); @@ -1340,9 +1668,12 @@ function createWindow(): void { ? mainWindow?.webContents.executeJavaScript( "document.querySelector('.rune-seed')?.classList.add('attacking')") : undefined) - .then(() => screenshotView === "settings" || screenshotView?.startsWith("sounds") + .then(() => screenshotView === "settings" || screenshotView === "updates" || screenshotView?.startsWith("sounds") ? mainWindow?.webContents.executeJavaScript( "document.querySelector('button[aria-label=\"Open settings\"]')?.click()") + : screenshotView === "loot" + ? mainWindow?.webContents.executeJavaScript( + "document.querySelector('button[aria-label=\"Open observed loot chronicle\"]')?.click()") : screenshotView === "analysis" ? mainWindow?.webContents.executeJavaScript( "document.querySelector('button[aria-label=\"Open full combat breakdown\"]')?.click()") @@ -1357,6 +1688,9 @@ function createWindow(): void { .then(() => screenshotView?.startsWith("sounds") ? mainWindow?.webContents.executeJavaScript( "document.querySelector('.sound-studio')?.scrollIntoView({ block: 'start' })") + : screenshotView === "updates" + ? mainWindow?.webContents.executeJavaScript( + "document.querySelector('.update-center-card')?.scrollIntoView({ block: 'start' })") : undefined) .then(() => screenshotView === "sounds-menu" ? mainWindow?.webContents.executeJavaScript( @@ -1400,6 +1734,7 @@ function createWindow(): void { } ipcMain.handle("runtime:metrics", () => ({ + version: app.getVersion(), coldStartMs: Math.round(performance.now() - processStartedAt), residentMemoryMb: Math.round(process.memoryUsage().rss / 1024 / 1024), platform: process.platform, @@ -1512,6 +1847,9 @@ ipcMain.handle("settings:update", (_event, value: unknown) => { if (typeof raw.composition === "string") patch.composition = raw.composition.slice(0, 48); if (typeof raw.splitCharmedPetDps === "boolean") patch.splitCharmedPetDps = raw.splitCharmedPetDps; if (typeof raw.stanceAdvisorEnabled === "boolean") patch.stanceAdvisorEnabled = raw.stanceAdvisorEnabled; + if (typeof raw.itemNetworkLookups === "boolean") patch.itemNetworkLookups = raw.itemNetworkLookups; + if (typeof raw.autoCheckUpdates === "boolean") patch.autoCheckUpdates = raw.autoCheckUpdates; + if (typeof raw.eqRoot === "string" && raw.eqRoot.length <= 4096) patch.eqRoot = raw.eqRoot; if (raw.alerts && typeof raw.alerts === "object") { const candidate = raw.alerts as Record; const alerts: Partial = {}; @@ -1562,11 +1900,39 @@ ipcMain.handle("gear:choose-inventory", async () => { return result.filePaths[0]; }); ipcMain.handle("gear:refresh", () => engine?.refreshGearCatalog() ?? false); +ipcMain.handle("journal:query-loot", (_event, value: unknown) => ( + engine?.queryLoot(value) ?? Promise.resolve({ + rows: [], total: 0, offset: 0, hasMore: false, + }) +)); +ipcMain.handle("items:lookup", async (_event, value: unknown) => { + if (typeof value !== "string") { + return { + status: "not-found", + requestedName: "", + title: "", + url: "https://eqlwiki.com/", + stats: [], + notes: [], + sections: {}, + freshness: "live", + detail: "Select a valid item name.", + }; + } + itemIntelligence ??= new ItemIntelligenceService(app.getPath("userData")); + const result = await itemIntelligence.lookup( + value, engine?.getState().settings.itemNetworkLookups ?? true); + if (result.status === "ready") engine?.cacheItem(result); + return result; +}); ipcMain.handle("external:open", async (_event, value: unknown) => { if (typeof value !== "string") return false; try { const url = new URL(value); - const allowedHosts = new Set(["eqlegendstools.com", "www.eqlegendstools.com", "github.com"]); + const allowedHosts = new Set([ + "eqlegendstools.com", "www.eqlegendstools.com", "eqlwiki.com", + "www.eqlwiki.com", "github.com", + ]); if (url.protocol !== "https:" || !allowedHosts.has(url.hostname)) return false; await shell.openExternal(url.toString()); return true; @@ -1574,40 +1940,99 @@ ipcMain.handle("external:open", async (_event, value: unknown) => { return false; } }); -ipcMain.handle("updates:check", async () => { - const currentVersion = app.getVersion(); +ipcMain.handle("updates:get-state", () => publishUpdateCenter()); +ipcMain.handle("updates:check", () => checkAllUpdates()); +ipcMain.handle("updates:choose-eq-root", async () => { + if (!mainWindow || !engine) return null; + const result = await dialog.showOpenDialog(mainWindow, { + title: "Choose your EverQuest Legends folder", + defaultPath: engine.getState().settings.eqRoot || engine.getState().settings.logPath || undefined, + properties: ["openDirectory"], + }); + if (result.canceled || result.filePaths.length !== 1) return null; try { - const response = await fetch("https://api.github.com/repos/itsspin/spinips/releases/latest", { - headers: { - Accept: "application/vnd.github+json", - "User-Agent": `Loremaster/${currentVersion}`, - "X-GitHub-Api-Version": "2022-11-28", - }, + if (!spinUISkinUpdater) initializeUpdateServices(); + const eqRoot = await spinUISkinUpdater!.setEqRoot(result.filePaths[0]); + engine.updateDesktopSettings({ eqRoot }); + updateCenterState ??= initialUpdateCenterState(); + updateCenterState.eqRoot = eqRoot; + return await checkAllUpdates(); + } catch (error) { + await dialog.showMessageBox(mainWindow, { + type: "warning", + title: "EverQuest folder not recognized", + message: error instanceof Error ? error.message : String(error), + detail: "Choose the folder that directly contains eqgame.exe and the uifiles folder.", }); - if (!response.ok) throw new Error(`GitHub returned HTTP ${response.status}`); - const value = await response.json() as Record; - const latestVersion = String(value.tag_name ?? "").replace(/^v/i, ""); - const releaseUrl = String(value.html_url ?? "https://github.com/itsspin/spinips/releases/latest"); - return { - ok: true, - currentVersion, - latestVersion, - updateAvailable: Boolean(latestVersion && latestVersion !== currentVersion), - releaseUrl, - detail: latestVersion === currentVersion - ? "Loremaster is up to date." - : `Release ${latestVersion || "latest"} is available on GitHub.`, - }; + return publishUpdateCenter(); + } +}); +ipcMain.handle("updates:install", async (_event, value: unknown) => { + const allowed: readonly UpdateComponentId[] = ["loremaster", "spinui_reloaded", "spinui_glass"]; + if (!Array.isArray(value) || value.length < 1 || value.length > allowed.length) return publishUpdateCenter(); + const ids = [...new Set(value)].filter((id): id is UpdateComponentId => allowed.includes(id as UpdateComponentId)); + if (!ids.length || ids.length !== new Set(value).size) return publishUpdateCenter(); + if (!portableUpdater || !spinUISkinUpdater) initializeUpdateServices(); + updateCenterState ??= initialUpdateCenterState(); + const skins = ids.filter((id): id is SpinUISkinName => id !== "loremaster"); + try { + updateCenterState.busy = true; + publishUpdateCenter(); + if (skins.length) { + const eqRoot = engine?.getState().settings.eqRoot || updateCenterState.eqRoot; + await spinUISkinUpdater!.installAll(skins, eqRoot || undefined); + mergeSkinUpdate(spinUISkinUpdater!.getState()); + } + if (ids.includes("loremaster")) { + if (portableUpdater!.getProgress().phase !== "available" && !stagedPortableUpdate) { + const check = await portableUpdater!.check(); + mergePortableUpdate(portableUpdater!.getProgress(), check); + if (!check.updateAvailable) return publishUpdateCenter(); + } + stagedPortableUpdate ??= await portableUpdater!.stage(); + mergePortableUpdate(portableUpdater!.getProgress()); + const confirmation = mainWindow ? await dialog.showMessageBox(mainWindow, { + type: "info", + title: `Install Loremaster ${stagedPortableUpdate.version}`, + message: "The verified update is ready. Restart Loremaster now?", + detail: "Settings, combat and loot history, EQ logs, and UI layouts are preserved. If the new build cannot start its window and parser, the previous executable is restored automatically.", + buttons: ["INSTALL & RESTART", "LATER"], + defaultId: 0, + cancelId: 1, + }) : { response: 0 }; + if (confirmation.response !== 0) { + updateCenterState.components.loremaster = { + ...updateCenterState.components.loremaster, + phase: "ready", + progress: null, + detail: `Loremaster ${stagedPortableUpdate.version} is verified and ready.`, + }; + updateCenterState.busy = false; + return publishUpdateCenter(); + } + portableUpdater!.installAndRelaunch(stagedPortableUpdate); + portableInstallHandoff = true; + updateCenterState.busy = true; + publishUpdateCenter(); + setTimeout(() => app.quit(), 350); + } } catch (error) { - return { - ok: false, - currentVersion, - latestVersion: "", - updateAvailable: false, - releaseUrl: "https://github.com/itsspin/spinips/releases/latest", - detail: error instanceof Error ? error.message : String(error), - }; + if (!(error instanceof EverQuestRunningError) && mainWindow) { + await dialog.showMessageBox(mainWindow, { + type: "error", + title: "Update could not be completed", + message: error instanceof Error ? error.message : String(error), + detail: "Nothing outside the selected Loremaster or SpinUI component was changed.", + }); + } + mergeSkinUpdate(spinUISkinUpdater!.getState()); + mergePortableUpdate(portableUpdater!.getProgress()); + } finally { + if (!ids.includes("loremaster") || portableUpdater!.getProgress().phase === "error") { + updateCenterState.busy = false; + } } + return publishUpdateCenter(); }); ipcMain.on("window:set-mode", (_event, expanded: boolean) => { @@ -1624,10 +2049,20 @@ app.whenReady().then(() => { if (!ownsSingleInstance) return; engine = new EngineSupervisor(); engine.start(); + initializeUpdateServices(); createWindow(); ensureTray(); startTopmostHeartbeat(); screen.on("display-metrics-changed", scheduleTopmostReassertion); + const updateSettings = engine.getState().settings; + const lastCheck = readUpdateMetadata().lastCheckedAt; + const lastCheckMs = Date.parse(lastCheck); + const autoCheckDue = !Number.isFinite(lastCheckMs) || Date.now() - lastCheckMs >= 24 * 60 * 60 * 1000; + if (app.isPackaged && updateSettings.autoCheckUpdates && autoCheckDue + && !process.env.LOREMASTER_SCREENSHOT_PATH && !process.env.LOREMASTER_SMOKE_EXIT_MS) { + const timer = setTimeout(() => void checkAllUpdates(), 8_000 + Math.floor(Math.random() * 5_000)); + timer.unref?.(); + } const smokeExitMs = Number(process.env.LOREMASTER_SMOKE_EXIT_MS || 0); if (Number.isFinite(smokeExitMs) && smokeExitMs >= 250) { setTimeout(() => app.quit(), smokeExitMs); @@ -1641,7 +2076,9 @@ app.on("before-quit", () => { tray = null; engine?.stop(); }); -app.on("window-all-closed", () => app.quit()); +app.on("window-all-closed", () => { + if (!portableInstallHandoff) app.quit(); +}); app.on("second-instance", restoreLoremasterWindow); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); diff --git a/loremaster-desktop/electron/portable-updater.ts b/loremaster-desktop/electron/portable-updater.ts new file mode 100644 index 0000000..e5780d0 --- /dev/null +++ b/loremaster-desktop/electron/portable-updater.ts @@ -0,0 +1,712 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { createWriteStream, existsSync, statSync } from "node:fs"; +import { mkdir, open, rename, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { once } from "node:events"; + +const RELEASE_API = "https://api.github.com/repos/itsspin/spinips/releases/latest"; +const RELEASE_PAGE = "https://github.com/itsspin/spinips/releases/latest"; +const LOREMASTER_ASSET = "Loremaster.exe"; +const CHECKSUM_ASSET = "SHA256SUMS.txt"; +const MAX_RELEASE_BYTES = 2 * 1024 * 1024; +const MAX_CHECKSUM_BYTES = 1024 * 1024; +const MAX_EXECUTABLE_BYTES = 300 * 1024 * 1024; +const MIN_EXECUTABLE_BYTES = 1024 * 1024; +const MAX_REDIRECTS = 5; +const INACTIVITY_TIMEOUT_MS = 30_000; + +const OFFICIAL_DOWNLOAD_HOSTS = new Set([ + "api.github.com", + "github.com", + "objects.githubusercontent.com", + "release-assets.githubusercontent.com", +]); + +export type UpdatePhase = + | "idle" + | "checking" + | "current" + | "available" + | "downloading" + | "verifying" + | "ready" + | "installing" + | "error"; + +export interface UpdateProgress { + phase: UpdatePhase; + percent: number; + detail: string; + version?: string; + bytesReceived?: number; + totalBytes?: number; +} + +export interface ReleaseAsset { + name: string; + url: string; + size: number; + sha256: string; +} + +export interface PortableRelease { + version: string; + tag: string; + releaseUrl: string; + publishedAt: string; + notes: string; + executable: ReleaseAsset; + checksums: ReleaseAsset; +} + +export interface UpdateCheckResult { + ok: boolean; + currentVersion: string; + latestVersion: string; + updateAvailable: boolean; + releaseUrl: string; + detail: string; +} + +export interface StagedPortableUpdate { + version: string; + targetPath: string; + stagedPath: string; + expectedSha256: string; + helperPath: string; + healthPath: string; + healthToken: string; + releaseUrl: string; +} + +export interface PortableUpdaterOptions { + currentVersion: string; + userDataDir: string; + executablePath?: string | null; + fetchImpl?: typeof fetch; + spawnImpl?: typeof spawn; + minExecutableBytes?: number; + maxExecutableBytes?: number; + powershellPath?: string; +} + +type ProgressListener = (progress: UpdateProgress) => void; + +function boundedPercent(value: number): number { + return Math.max(0, Math.min(100, Math.round(value))); +} + +function normalizeVersion(value: string): string { + return value.trim().replace(/^v/i, ""); +} + +/** Compares release versions without adding an update-time runtime dependency. */ +export function compareVersions(left: string, right: string): number { + const parse = (raw: string) => { + const match = normalizeVersion(raw).match( + /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/, + ); + if (!match) throw new Error(`Unsupported release version: ${raw}`); + return { + core: [Number(match[1]), Number(match[2]), Number(match[3])], + prerelease: match[4]?.split(".") ?? [], + }; + }; + const a = parse(left); + const b = parse(right); + for (let index = 0; index < 3; index += 1) { + if (a.core[index] !== b.core[index]) return a.core[index] > b.core[index] ? 1 : -1; + } + if (!a.prerelease.length && !b.prerelease.length) return 0; + if (!a.prerelease.length) return 1; + if (!b.prerelease.length) return -1; + const length = Math.max(a.prerelease.length, b.prerelease.length); + for (let index = 0; index < length; index += 1) { + const av = a.prerelease[index]; + const bv = b.prerelease[index]; + if (av === undefined) return -1; + if (bv === undefined) return 1; + if (av === bv) continue; + const an = /^\d+$/.test(av) ? Number(av) : null; + const bn = /^\d+$/.test(bv) ? Number(bv) : null; + if (an !== null && bn !== null) return an > bn ? 1 : -1; + if (an !== null) return -1; + if (bn !== null) return 1; + return av > bv ? 1 : -1; + } + return 0; +} + +export function parseChecksums(text: string): Map { + const checksums = new Map(); + for (const rawLine of text.replace(/^\uFEFF/, "").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const match = line.match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/); + if (!match) throw new Error("The release checksum manifest is malformed."); + const name = match[2].trim().replace(/\\/g, "/"); + if (!name || name.includes("/") || name === "." || name === "..") { + throw new Error("The release checksum manifest contains an unsafe asset name."); + } + if (checksums.has(name)) throw new Error(`Duplicate checksum entry for ${name}.`); + checksums.set(name, match[1].toLowerCase()); + } + return checksums; +} + +export function resolvePortableExecutable( + environment: NodeJS.ProcessEnv = process.env, +): string | null { + const candidate = environment.PORTABLE_EXECUTABLE_FILE?.trim(); + if (!candidate || !path.isAbsolute(candidate) || path.extname(candidate).toLowerCase() !== ".exe") { + return null; + } + return path.normalize(candidate); +} + +function validateOfficialUrl(value: string): URL { + const url = new URL(value); + if (url.protocol !== "https:" || !OFFICIAL_DOWNLOAD_HOSTS.has(url.hostname.toLowerCase())) { + throw new Error("The release redirected outside the official GitHub download service."); + } + if (url.username || url.password) throw new Error("Authenticated release URLs are not accepted."); + return url; +} + +async function fetchOfficial( + fetchImpl: typeof fetch, + input: string, + init: RequestInit, +): Promise { + let url = validateOfficialUrl(input); + for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) { + const response = await fetchImpl(url, { ...init, redirect: "manual" }); + if (response.status < 300 || response.status >= 400) return response; + const location = response.headers.get("location"); + if (!location) throw new Error("GitHub returned a redirect without a destination."); + if (redirects === MAX_REDIRECTS) throw new Error("The release download redirected too many times."); + url = validateOfficialUrl(new URL(location, url).toString()); + } + throw new Error("The release download could not be resolved."); +} + +async function readNextChunk( + reader: ReadableStreamDefaultReader, +): Promise> { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + reader.read(), + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error("The release download stopped responding.")), + INACTIVITY_TIMEOUT_MS, + ); + timer.unref?.(); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +async function readLimitedBytes(response: Response, maximum: number): Promise { + const declared = Number(response.headers.get("content-length") ?? "0"); + if (Number.isFinite(declared) && declared > maximum) throw new Error("The release response is unexpectedly large."); + if (!response.body) throw new Error("GitHub returned an empty release response."); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { done, value } = await readNextChunk(reader); + if (done) break; + total += value.byteLength; + if (total > maximum) { + await reader.cancel(); + throw new Error("The release response exceeded its safe size limit."); + } + chunks.push(value); + } + const joined = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + joined.set(chunk, offset); + offset += chunk.byteLength; + } + return joined; +} + +function safeReleaseAsset(value: unknown): ReleaseAsset | null { + if (!value || typeof value !== "object") return null; + const asset = value as Record; + const name = typeof asset.name === "string" ? asset.name : ""; + const url = typeof asset.browser_download_url === "string" ? asset.browser_download_url : ""; + const size = typeof asset.size === "number" && Number.isFinite(asset.size) ? asset.size : 0; + const digest = typeof asset.digest === "string" ? asset.digest.toLowerCase() : ""; + const digestMatch = digest.match(/^sha256:([0-9a-f]{64})$/); + if (!name || !url || size < 0 || !digestMatch) return null; + validateOfficialUrl(url); + return { name, url, size, sha256: digestMatch[1] }; +} + +function releaseFromApi(value: unknown): PortableRelease { + if (!value || typeof value !== "object") throw new Error("GitHub returned invalid release metadata."); + const source = value as Record; + if (source.draft === true || source.prerelease === true) { + throw new Error("The latest GitHub response is not a stable public release."); + } + const tag = typeof source.tag_name === "string" ? source.tag_name.trim() : ""; + const version = normalizeVersion(tag); + compareVersions(version, version); + const assets = Array.isArray(source.assets) ? source.assets.map(safeReleaseAsset).filter(Boolean) as ReleaseAsset[] : []; + const exactAsset = (name: string) => { + const matches = assets.filter((asset) => asset.name === name); + if (matches.length !== 1) throw new Error(`Release must contain exactly one ${name} asset.`); + return matches[0]; + }; + const releaseUrl = typeof source.html_url === "string" ? source.html_url : RELEASE_PAGE; + validateOfficialUrl(releaseUrl); + return { + version, + tag, + releaseUrl, + publishedAt: typeof source.published_at === "string" ? source.published_at : "", + notes: typeof source.body === "string" ? source.body.slice(0, 16_384) : "", + executable: exactAsset(LOREMASTER_ASSET), + checksums: exactAsset(CHECKSUM_ASSET), + }; +} + +function safeStageName(version: string): string { + compareVersions(version, version); + return `v${version.replace(/[^0-9A-Za-z.+-]/g, "-")}`; +} + +function ensureTargetExecutable(value: string | null | undefined): string { + if (!value || !path.isAbsolute(value) || path.extname(value).toLowerCase() !== ".exe") { + throw new Error("This build is not running from a supported portable Loremaster executable."); + } + const normalized = path.normalize(value); + if (!existsSync(normalized) || !statSync(normalized).isFile()) { + throw new Error("The portable Loremaster executable could not be found."); + } + return normalized; +} + +function resolveWindowsPowerShell(environment: NodeJS.ProcessEnv = process.env): string { + const windowsRoot = environment.SystemRoot || environment.WINDIR; + if (!windowsRoot || !path.isAbsolute(windowsRoot)) { + throw new Error("The trusted Windows PowerShell location could not be resolved."); + } + const executable = path.join(windowsRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); + if (!existsSync(executable)) throw new Error("Windows PowerShell is unavailable for portable replacement."); + return executable; +} + +async function assertTargetDirectoryWritable(targetPath: string): Promise { + const probe = path.join( + path.dirname(targetPath), + `.loremaster-update-write-test-${process.pid}-${randomBytes(6).toString("hex")}`, + ); + try { + await writeFile(probe, "Loremaster update permission probe\n", { encoding: "utf8", flag: "wx" }); + } catch { + throw new Error("Loremaster cannot update this folder. Move it to a user-writable folder or run it with matching permissions."); + } finally { + await rm(probe, { force: true }).catch(() => undefined); + } +} + +export async function acknowledgePortableUpdateRelaunch( + userDataDir: string, + argv: readonly string[] = process.argv, +): Promise { + const tokenIndex = argv.indexOf("--loremaster-update-health-token"); + const pathIndex = argv.indexOf("--loremaster-update-health-path"); + if (tokenIndex < 0 || pathIndex < 0) return false; + const token = argv[tokenIndex + 1] ?? ""; + const healthPath = argv[pathIndex + 1] ?? ""; + if (!/^[0-9a-f]{64}$/i.test(token) || !path.isAbsolute(healthPath)) return false; + const updatesRoot = path.resolve(userDataDir, "updates"); + const resolvedHealth = path.resolve(healthPath); + const relative = path.relative(updatesRoot, resolvedHealth); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return false; + await mkdir(path.dirname(resolvedHealth), { recursive: true }); + const temporary = `${resolvedHealth}.${process.pid}.tmp`; + await writeFile(temporary, token, { encoding: "ascii", flag: "wx" }); + await rename(temporary, resolvedHealth); + return true; +} + +async function writeVerifiedDownload( + response: Response, + destination: string, + expectedSha256: string, + expectedBytes: number, + maximumBytes: number, + onProgress: (received: number, total: number) => void, +): Promise { + if (!response.ok) throw new Error(`GitHub returned HTTP ${response.status} while downloading ${path.basename(destination)}.`); + if (!response.body) throw new Error("GitHub returned an empty update download."); + const declared = Number(response.headers.get("content-length") ?? "0"); + if (Number.isFinite(declared) && declared > maximumBytes) throw new Error("The update executable is unexpectedly large."); + if (declared > 0 && declared !== expectedBytes) throw new Error("The update size differs from GitHub release metadata."); + const temporary = `${destination}.part`; + await rm(temporary, { force: true }); + const output = createWriteStream(temporary, { flags: "wx" }); + const digest = createHash("sha256"); + let received = 0; + try { + const reader = response.body.getReader(); + while (true) { + const { done, value } = await readNextChunk(reader); + if (done) break; + received += value.byteLength; + if (received > maximumBytes) { + await reader.cancel(); + throw new Error("The update executable exceeded its safe size limit."); + } + digest.update(value); + if (!output.write(value)) await once(output, "drain"); + onProgress(received, declared > 0 ? declared : 0); + } + output.end(); + await once(output, "close"); + if (received !== expectedBytes) throw new Error("The update download was incomplete."); + const actual = digest.digest("hex"); + if (actual !== expectedSha256.toLowerCase()) { + throw new Error("The downloaded Loremaster checksum did not match the release manifest."); + } + await rm(destination, { force: true }); + await rename(temporary, destination); + return received; + } catch (error) { + output.destroy(); + await rm(temporary, { force: true }).catch(() => undefined); + throw error; + } +} + +async function assertPortableExecutable(filePath: string, minimumBytes: number, maximumBytes: number): Promise { + const file = await open(filePath, "r"); + try { + const info = await file.stat(); + if (info.size < minimumBytes || info.size > maximumBytes) { + throw new Error("The staged Loremaster executable has an unexpected size."); + } + const header = Buffer.alloc(2); + const result = await file.read(header, 0, 2, 0); + if (result.bytesRead !== 2 || header[0] !== 0x4d || header[1] !== 0x5a) { + throw new Error("The staged update is not a Windows executable."); + } + } finally { + await file.close(); + } +} + +function replacementScript(): string { + return String.raw`param( + [Parameter(Mandatory=$true)][string]$TargetPath, + [Parameter(Mandatory=$true)][string]$StagedPath, + [Parameter(Mandatory=$true)][ValidatePattern('^[0-9a-fA-F]{64}$')][string]$ExpectedSha256, + [Parameter(Mandatory=$true)][string]$HealthPath, + [Parameter(Mandatory=$true)][ValidatePattern('^[0-9a-fA-F]{64}$')][string]$HealthToken, + [Parameter(Mandatory=$true)][int]$ParentPid +) +$ErrorActionPreference = 'Stop' +$target = [IO.Path]::GetFullPath($TargetPath) +$staged = [IO.Path]::GetFullPath($StagedPath) +if ([IO.Path]::GetExtension($target) -ine '.exe' -or [IO.Path]::GetExtension($staged) -ine '.exe') { + throw 'Updater paths must point to Windows executables.' +} +$deadline = [DateTime]::UtcNow.AddSeconds(90) +while ((Get-Process -Id $ParentPid -ErrorAction SilentlyContinue) -and [DateTime]::UtcNow -lt $deadline) { + Start-Sleep -Milliseconds 250 +} +if (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue) { throw 'Loremaster did not close before the update timeout.' } +if (-not (Test-Path -LiteralPath $target -PathType Leaf)) { throw 'Installed Loremaster executable was not found.' } +if (-not (Test-Path -LiteralPath $staged -PathType Leaf)) { throw 'Staged Loremaster update was not found.' } +$actual = (Get-FileHash -LiteralPath $staged -Algorithm SHA256).Hash.ToLowerInvariant() +if ($actual -ne $ExpectedSha256.ToLowerInvariant()) { throw 'Staged Loremaster checksum changed before installation.' } +$backup = "$target.previous" +$newTarget = "$target.new" +$replaceDeadline = [DateTime]::UtcNow.AddSeconds(90) +$replaced = $false +while (-not $replaced -and [DateTime]::UtcNow -lt $replaceDeadline) { + try { + Remove-Item -LiteralPath $newTarget -Force -ErrorAction SilentlyContinue + Copy-Item -LiteralPath $staged -Destination $newTarget -Force + Remove-Item -LiteralPath $backup -Force -ErrorAction SilentlyContinue + Move-Item -LiteralPath $target -Destination $backup -Force + Move-Item -LiteralPath $newTarget -Destination $target -Force + $replaced = $true + } catch { + Remove-Item -LiteralPath $newTarget -Force -ErrorAction SilentlyContinue + if (-not (Test-Path -LiteralPath $target) -and (Test-Path -LiteralPath $backup)) { + Move-Item -LiteralPath $backup -Destination $target -Force + } + Start-Sleep -Milliseconds 500 + } +} +if (-not $replaced) { throw 'The portable wrapper did not release Loremaster.exe before the update timeout.' } +Remove-Item -LiteralPath $HealthPath -Force -ErrorAction SilentlyContinue +$next = Start-Process -FilePath $target -WorkingDirectory ([IO.Path]::GetDirectoryName($target)) -ArgumentList @('--loremaster-update-health-token', $HealthToken, '--loremaster-update-health-path', $HealthPath) -PassThru +$healthDeadline = [DateTime]::UtcNow.AddSeconds(75) +$healthy = $false +while (-not $healthy -and [DateTime]::UtcNow -lt $healthDeadline -and -not $next.HasExited) { + if (Test-Path -LiteralPath $HealthPath -PathType Leaf) { + $healthy = ((Get-Content -LiteralPath $HealthPath -Raw).Trim() -eq $HealthToken) + } + if (-not $healthy) { Start-Sleep -Milliseconds 250 } +} +if (-not $healthy) { + if (-not $next.HasExited) { Stop-Process -Id $next.Id -Force -ErrorAction SilentlyContinue } + $rollbackDeadline = [DateTime]::UtcNow.AddSeconds(45) + $rolledBack = $false + while (-not $rolledBack -and [DateTime]::UtcNow -lt $rollbackDeadline) { + try { + Remove-Item -LiteralPath $target -Force + Move-Item -LiteralPath $backup -Destination $target -Force + $rolledBack = $true + } catch { Start-Sleep -Milliseconds 500 } + } + if ($rolledBack) { Start-Process -FilePath $target -WorkingDirectory ([IO.Path]::GetDirectoryName($target)) } + throw 'The updated Loremaster did not report a healthy start; the previous build was restored.' +} +Remove-Item -LiteralPath $backup -Force -ErrorAction SilentlyContinue +Remove-Item -LiteralPath $HealthPath -Force -ErrorAction SilentlyContinue +Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue +`; +} + +export class PortableUpdateService { + private readonly currentVersion: string; + private readonly userDataDir: string; + private readonly executablePath: string | null; + private readonly fetchImpl: typeof fetch; + private readonly spawnImpl: typeof spawn; + private readonly minExecutableBytes: number; + private readonly maxExecutableBytes: number; + private readonly powershellPath: string; + private readonly listeners = new Set(); + private progress: UpdateProgress = { phase: "idle", percent: 0, detail: "Ready to check for updates." }; + private activeDownload: Promise | null = null; + private checkedRelease: PortableRelease | null = null; + + constructor(options: PortableUpdaterOptions) { + compareVersions(options.currentVersion, options.currentVersion); + this.currentVersion = normalizeVersion(options.currentVersion); + this.userDataDir = path.resolve(options.userDataDir); + this.executablePath = options.executablePath === undefined + ? resolvePortableExecutable() + : options.executablePath; + this.fetchImpl = options.fetchImpl ?? fetch; + this.spawnImpl = options.spawnImpl ?? spawn; + this.minExecutableBytes = options.minExecutableBytes ?? MIN_EXECUTABLE_BYTES; + this.maxExecutableBytes = options.maxExecutableBytes ?? MAX_EXECUTABLE_BYTES; + this.powershellPath = options.powershellPath ?? resolveWindowsPowerShell(); + } + + subscribe(listener: ProgressListener): () => void { + this.listeners.add(listener); + listener(this.progress); + return () => this.listeners.delete(listener); + } + + getProgress(): UpdateProgress { + return { ...this.progress }; + } + + private emit(progress: UpdateProgress): void { + this.progress = { ...progress, percent: boundedPercent(progress.percent) }; + for (const listener of this.listeners) listener(this.getProgress()); + } + + async check(): Promise { + this.emit({ phase: "checking", percent: 10, detail: "Checking the official SpinUI release feed." }); + try { + const response = await fetchOfficial(this.fetchImpl, RELEASE_API, { + signal: AbortSignal.timeout(30_000), + headers: { + Accept: "application/vnd.github+json", + "User-Agent": `Loremaster/${this.currentVersion}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + if (!response.ok) throw new Error(`GitHub returned HTTP ${response.status}.`); + const bytes = await readLimitedBytes(response, MAX_RELEASE_BYTES); + const release = releaseFromApi(JSON.parse(Buffer.from(bytes).toString("utf8"))); + const updateAvailable = compareVersions(release.version, this.currentVersion) > 0; + this.checkedRelease = updateAvailable ? release : null; + const detail = updateAvailable + ? `Loremaster ${release.version} is ready to download.` + : `Loremaster ${this.currentVersion} is up to date.`; + this.emit({ + phase: updateAvailable ? "available" : "current", + percent: 100, + detail, + version: release.version, + }); + return { + ok: true, + currentVersion: this.currentVersion, + latestVersion: release.version, + updateAvailable, + releaseUrl: release.releaseUrl, + detail, + }; + } catch (error) { + this.checkedRelease = null; + const detail = error instanceof Error ? error.message : String(error); + this.emit({ phase: "error", percent: 0, detail }); + return { + ok: false, + currentVersion: this.currentVersion, + latestVersion: "", + updateAvailable: false, + releaseUrl: RELEASE_PAGE, + detail, + }; + } + } + + stage(): Promise { + if (this.activeDownload) return this.activeDownload; + const release = this.checkedRelease; + if (!release) return Promise.reject(new Error("Check for a newer official release before downloading it.")); + this.activeDownload = this.stageOnce(release).finally(() => { + this.activeDownload = null; + }); + return this.activeDownload; + } + + private async stageOnce(release: PortableRelease): Promise { + const targetPath = ensureTargetExecutable(this.executablePath); + await assertTargetDirectoryWritable(targetPath); + if (compareVersions(release.version, this.currentVersion) <= 0) { + throw new Error("The selected release is not newer than this Loremaster build."); + } + const stageDir = path.join(this.userDataDir, "updates", safeStageName(release.version)); + await mkdir(stageDir, { recursive: true }); + const stagedPath = path.join(stageDir, LOREMASTER_ASSET); + const helperPath = path.join(stageDir, "install-loremaster-update.ps1"); + const healthPath = path.join(stageDir, "healthy.txt"); + const healthToken = randomBytes(32).toString("hex"); + try { + this.emit({ phase: "downloading", percent: 2, detail: "Downloading the release checksum manifest.", version: release.version }); + const checksumResponse = await fetchOfficial(this.fetchImpl, release.checksums.url, { + signal: AbortSignal.timeout(30_000), + headers: { "User-Agent": `Loremaster/${this.currentVersion}` }, + }); + if (!checksumResponse.ok) throw new Error(`GitHub returned HTTP ${checksumResponse.status} for checksums.`); + const checksumBytes = await readLimitedBytes(checksumResponse, MAX_CHECKSUM_BYTES); + const checksums = parseChecksums(Buffer.from(checksumBytes).toString("utf8")); + const expectedSha256 = checksums.get(LOREMASTER_ASSET); + if (!expectedSha256) throw new Error(`The release manifest does not authenticate ${LOREMASTER_ASSET}.`); + if (expectedSha256 !== release.executable.sha256) { + throw new Error("GitHub's asset digest and the release checksum manifest do not agree."); + } + + this.emit({ phase: "downloading", percent: 5, detail: "Downloading the verified portable Loremaster update.", version: release.version }); + const executableResponse = await fetchOfficial(this.fetchImpl, release.executable.url, { + signal: AbortSignal.timeout(15 * 60_000), + headers: { "User-Agent": `Loremaster/${this.currentVersion}` }, + }); + const downloaded = await writeVerifiedDownload( + executableResponse, + stagedPath, + expectedSha256, + release.executable.size, + this.maxExecutableBytes, + (received, total) => this.emit({ + phase: "downloading", + percent: total > 0 ? 5 + (received / total) * 85 : 45, + detail: "Downloading the verified portable Loremaster update.", + version: release.version, + bytesReceived: received, + totalBytes: total || undefined, + }), + ); + this.emit({ + phase: "verifying", percent: 94, detail: "Verifying the Windows executable and preparing safe replacement.", + version: release.version, bytesReceived: downloaded, totalBytes: downloaded, + }); + await assertPortableExecutable(stagedPath, this.minExecutableBytes, this.maxExecutableBytes); + await writeFile(helperPath, replacementScript(), { encoding: "utf8", flag: "w" }); + await writeFile(path.join(stageDir, "update.json"), JSON.stringify({ + schemaVersion: 1, + version: release.version, + targetPath, + stagedPath, + expectedSha256, + healthPath, + healthToken, + releaseUrl: release.releaseUrl, + stagedAt: new Date().toISOString(), + }, null, 2), { encoding: "utf8", flag: "w" }); + const result = { + version: release.version, targetPath, stagedPath, expectedSha256, + helperPath, healthPath, healthToken, releaseUrl: release.releaseUrl, + }; + this.emit({ phase: "ready", percent: 100, detail: `Loremaster ${release.version} is verified and ready to install.`, version: release.version }); + return result; + } catch (error) { + await rm(`${stagedPath}.part`, { force: true }).catch(() => undefined); + const detail = error instanceof Error ? error.message : String(error); + this.emit({ phase: "error", percent: 0, detail, version: release.version }); + throw error; + } + } + + installAndRelaunch(update: StagedPortableUpdate, parentPid = process.pid): number { + const target = ensureTargetExecutable(update.targetPath); + if (path.normalize(target).toLowerCase() !== path.normalize(ensureTargetExecutable(this.executablePath)).toLowerCase()) { + throw new Error("The staged update does not target this portable Loremaster executable."); + } + if (!existsSync(update.stagedPath) || !existsSync(update.helperPath)) { + throw new Error("The verified staged update is no longer available."); + } + this.emit({ phase: "installing", percent: 100, detail: "Closing Loremaster, installing the update, and reopening it.", version: update.version }); + const child: ChildProcess = this.spawnImpl( + this.powershellPath, + [ + "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-File", update.helperPath, + "-TargetPath", update.targetPath, + "-StagedPath", update.stagedPath, + "-ExpectedSha256", update.expectedSha256, + "-HealthPath", update.healthPath, + "-HealthToken", update.healthToken, + "-ParentPid", String(parentPid), + ], + { + cwd: path.dirname(target), + detached: true, + stdio: "ignore", + windowsHide: true, + }, + ); + if (!child.pid) throw new Error("Windows could not start the Loremaster update helper."); + child.unref(); + return child.pid; + } + + async discard(update: StagedPortableUpdate): Promise { + const updatesRoot = path.resolve(this.userDataDir, "updates"); + const stageDir = path.resolve(path.dirname(update.stagedPath)); + const relative = path.relative(updatesRoot, stageDir); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("Refusing to remove an update outside Loremaster's staging directory."); + } + await rm(stageDir, { recursive: true, force: true }); + this.emit({ phase: "idle", percent: 0, detail: "The staged update was removed." }); + } +} + +export const portableUpdaterConstants = { + releaseApi: RELEASE_API, + releasePage: RELEASE_PAGE, + executableAsset: LOREMASTER_ASSET, + checksumAsset: CHECKSUM_ASSET, +}; diff --git a/loremaster-desktop/electron/preload.ts b/loremaster-desktop/electron/preload.ts index c9cc4b1..d9df3b5 100644 --- a/loremaster-desktop/electron/preload.ts +++ b/loremaster-desktop/electron/preload.ts @@ -13,7 +13,12 @@ contextBridge.exposeInMainWorld("loremasterDesktop", { chooseInventory: () => ipcRenderer.invoke("gear:choose-inventory"), refreshGearData: () => ipcRenderer.invoke("gear:refresh"), openExternal: (value: string) => ipcRenderer.invoke("external:open", value), + lookupItem: (name: string) => ipcRenderer.invoke("items:lookup", name), + queryLoot: (request: unknown) => ipcRenderer.invoke("journal:query-loot", request), + getUpdateState: () => ipcRenderer.invoke("updates:get-state"), checkForUpdates: () => ipcRenderer.invoke("updates:check"), + chooseUpdateEqRoot: () => ipcRenderer.invoke("updates:choose-eq-root"), + installUpdates: (ids: readonly string[]) => ipcRenderer.invoke("updates:install", ids), resetEngine: () => ipcRenderer.send("engine:reset"), testAlert: () => ipcRenderer.send("alerts:test"), chooseAlertSound: (kind: string) => ipcRenderer.invoke("alerts:choose-sound", kind), @@ -38,6 +43,11 @@ contextBridge.exposeInMainWorld("loremasterDesktop", { ipcRenderer.on("settings:changed", listener); return () => ipcRenderer.removeListener("settings:changed", listener); }, + onUpdateState: (callback: (state: unknown) => void) => { + const listener = (_event: Electron.IpcRendererEvent, value: unknown) => callback(value); + ipcRenderer.on("updates:state", listener); + return () => ipcRenderer.removeListener("updates:state", listener); + }, onTestAlert: (callback: (alert: unknown) => void) => { const listener = (_event: Electron.IpcRendererEvent, value: unknown) => callback(value); ipcRenderer.on("alerts:test", listener); diff --git a/loremaster-desktop/electron/spinui-updater.ts b/loremaster-desktop/electron/spinui-updater.ts new file mode 100644 index 0000000..b9feea8 --- /dev/null +++ b/loremaster-desktop/electron/spinui-updater.ts @@ -0,0 +1,1082 @@ +import { execFile } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { once } from "node:events"; +import { createReadStream, createWriteStream, existsSync } from "node:fs"; +import { + lstat, + mkdir, + readFile, + readdir, + realpath, + rename, + rm, + stat, + unlink, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import { compareVersions, parseChecksums } from "./portable-updater.js"; + +const RELEASE_API = "https://api.github.com/repos/itsspin/spinips/releases/latest"; +const RELEASE_PAGE = "https://github.com/itsspin/spinips/releases/latest"; +const ARCHIVE_ASSET = "SpinUI-UI.zip"; +const MANIFEST_ASSET = "SpinUI-Update.json"; +const CHECKSUM_ASSET = "SHA256SUMS.txt"; +const TREE_HASH_ALGORITHM = "sha256-path-size-content-v1"; +const RECEIPT_FILENAME = "spinui-update-receipts.json"; +const OWNED_UPDATE_DIRECTORY = ".loremaster-spinui-updates"; +const MAX_RELEASE_BYTES = 2 * 1024 * 1024; +const MAX_MANIFEST_BYTES = 8 * 1024 * 1024; +const MAX_CHECKSUM_BYTES = 1024 * 1024; +const MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024; +const MAX_THEME_FILES = 10_000; +const MAX_THEME_BYTES = 4 * 1024 * 1024 * 1024; +const MAX_THEME_FILE_BYTES = 512 * 1024 * 1024; +const MAX_REDIRECTS = 5; +const INACTIVITY_TIMEOUT_MS = 30_000; + +const OFFICIAL_DOWNLOAD_HOSTS = new Set([ + "api.github.com", + "github.com", + "objects.githubusercontent.com", + "release-assets.githubusercontent.com", +]); + +export const SPINUI_SKINS = ["spinui_reloaded", "spinui_glass"] as const; +export type SpinUISkinName = (typeof SPINUI_SKINS)[number]; + +export type SpinUISkinPhase = + | "idle" + | "checking" + | "missing" + | "current" + | "available" + | "modified" + | "downloading" + | "verifying" + | "waiting-for-eq" + | "installing" + | "installed" + | "error"; + +export interface SpinUISkinStatus { + name: SpinUISkinName; + phase: SpinUISkinPhase; + installed: boolean; + installedVersion: string | null; + latestVersion: string | null; + percent: number; + detail: string; + modified: boolean; +} + +export interface SpinUIUpdateState { + eqRoot: string | null; + latestVersion: string | null; + releaseUrl: string; + lastCheckedAt: string | null; + busy: boolean; + themes: Record; +} + +export interface SpinUISkinInstallResult { + theme: SpinUISkinName; + version: string; + targetPath: string; + backupPath: string | null; +} + +interface ReleaseAsset { + name: string; + url: string; + size: number; + sha256: string; +} + +interface SpinUIRelease { + version: string; + releaseUrl: string; + archive: ReleaseAsset; + manifestAsset: ReleaseAsset; + checksums: ReleaseAsset; + manifest: SpinUIManifest; +} + +interface SpinUIManifestFile { + path: string; + size: number; + sha256: string; +} + +interface SpinUIManifestTheme { + fileCount: number; + totalBytes: number; + treeSha256: string; + files: SpinUIManifestFile[]; +} + +interface SpinUIManifest { + schemaVersion: 1; + releaseVersion: string; + treeHashAlgorithm: typeof TREE_HASH_ALGORITHM; + archive: { + name: typeof ARCHIVE_ASSET; + size: number; + sha256: string; + }; + themes: Record; +} + +interface ReceiptEntry { + eqRoot: string; + targetPath: string; + theme: SpinUISkinName; + version: string; + treeSha256: string; + installedAt: string; +} + +interface ReceiptFile { + schemaVersion: 1; + installations: ReceiptEntry[]; +} + +type ExtractImplementation = (archivePath: string, destination: string) => Promise; +type EqProcessCheck = () => Promise; +type StateListener = (state: SpinUIUpdateState) => void; + +export interface SpinUISkinUpdaterOptions { + userDataDir: string; + eqRoot?: string | null; + fetchImpl?: typeof fetch; + extractImpl?: ExtractImplementation; + eqProcessCheck?: EqProcessCheck; + archiveMaximumBytes?: number; +} + +export class EverQuestRunningError extends Error { + constructor() { + super("Close EverQuest before installing SpinUI files, then try again."); + this.name = "EverQuestRunningError"; + } +} + +function status(name: SpinUISkinName, detail = "Ready to check this SpinUI theme."): SpinUISkinStatus { + return { + name, + phase: "idle", + installed: false, + installedVersion: null, + latestVersion: null, + percent: 0, + detail, + modified: false, + }; +} + +function initialState(eqRoot: string | null): SpinUIUpdateState { + return { + eqRoot, + latestVersion: null, + releaseUrl: RELEASE_PAGE, + lastCheckedAt: null, + busy: false, + themes: { + spinui_reloaded: status("spinui_reloaded"), + spinui_glass: status("spinui_glass"), + }, + }; +} + +function cloneState(state: SpinUIUpdateState): SpinUIUpdateState { + return { + ...state, + themes: { + spinui_reloaded: { ...state.themes.spinui_reloaded }, + spinui_glass: { ...state.themes.spinui_glass }, + }, + }; +} + +function boundedPercent(value: number): number { + return Math.max(0, Math.min(100, Math.round(value))); +} + +function normalizeVersion(value: string): string { + const normalized = value.trim().replace(/^v/i, ""); + compareVersions(normalized, normalized); + return normalized; +} + +function isSha256(value: unknown): value is string { + return typeof value === "string" && /^[0-9a-f]{64}$/i.test(value); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function compareUtf8(left: string, right: string): number { + return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8")); +} + +function safeRelativeThemePath(value: unknown): string { + if (typeof value !== "string" || !value || value.includes("\0") || value.includes("\\")) { + throw new Error("The SpinUI manifest contains an unsafe theme path."); + } + if (value.startsWith("/") || /^[A-Za-z]:/.test(value)) { + throw new Error("The SpinUI manifest contains an absolute theme path."); + } + const parts = value.split("/"); + if (parts.some((part) => { + const stem = part.split(".", 1)[0]; + return !part + || part === "." + || part === ".." + || part.endsWith(".") + || part.endsWith(" ") + || /[<>:"|?*\x00-\x1f]/.test(part) + || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(stem); + })) { + throw new Error("The SpinUI manifest contains an unsafe theme path."); + } + const normalized = path.posix.normalize(value); + if (normalized !== value) throw new Error("The SpinUI manifest contains a non-canonical theme path."); + return value; +} + +export function computeSpinUITreeSha256(files: readonly SpinUIManifestFile[]): string { + const digest = createHash("sha256"); + for (const file of files) { + digest.update(Buffer.from(file.path, "utf8")); + digest.update(Buffer.from([0])); + digest.update(Buffer.from(String(file.size), "ascii")); + digest.update(Buffer.from([0])); + digest.update(Buffer.from(file.sha256, "hex")); + digest.update(Buffer.from("\n", "ascii")); + } + return digest.digest("hex"); +} + +function parseTheme(value: unknown, name: SpinUISkinName): SpinUIManifestTheme { + if (!isRecord(value) || !Array.isArray(value.files)) { + throw new Error(`The SpinUI manifest is missing the ${name} inventory.`); + } + const rawFiles = value.files; + if (!Number.isSafeInteger(value.fileCount) || (value.fileCount as number) < 1 || (value.fileCount as number) > MAX_THEME_FILES) { + throw new Error(`The ${name} manifest file count is outside the safe limit.`); + } + if (!Number.isSafeInteger(value.totalBytes) || (value.totalBytes as number) < 1 || (value.totalBytes as number) > MAX_THEME_BYTES) { + throw new Error(`The ${name} manifest size is outside the safe limit.`); + } + if (!isSha256(value.treeSha256)) throw new Error(`The ${name} tree checksum is invalid.`); + if (rawFiles.length !== value.fileCount) throw new Error(`The ${name} manifest file count does not match its inventory.`); + + const seen = new Set(); + const files: SpinUIManifestFile[] = rawFiles.map((entry, index) => { + if (!isRecord(entry)) throw new Error(`The ${name} manifest contains an invalid file entry.`); + const relative = safeRelativeThemePath(entry.path); + const folded = relative.toLocaleLowerCase("en-US"); + if (seen.has(folded)) throw new Error(`The ${name} manifest contains a Windows-colliding path.`); + seen.add(folded); + if (!Number.isSafeInteger(entry.size) || (entry.size as number) < 0 || (entry.size as number) > MAX_THEME_FILE_BYTES) { + throw new Error(`The ${name} manifest contains an unsafe file size.`); + } + if (!isSha256(entry.sha256)) throw new Error(`The ${name} manifest contains an invalid file checksum.`); + if (index > 0 && compareUtf8(String(rawFiles[index - 1] && (rawFiles[index - 1] as Record).path), relative) >= 0) { + throw new Error(`The ${name} manifest inventory is not in deterministic order.`); + } + return { path: relative, size: entry.size as number, sha256: entry.sha256.toLowerCase() }; + }); + const totalBytes = files.reduce((total, file) => total + file.size, 0); + if (totalBytes !== value.totalBytes) throw new Error(`The ${name} manifest byte total is inconsistent.`); + const treeSha256 = computeSpinUITreeSha256(files); + if (treeSha256 !== value.treeSha256.toLowerCase()) throw new Error(`The ${name} manifest tree checksum is inconsistent.`); + return { + fileCount: files.length, + totalBytes, + treeSha256, + files, + }; +} + +export function parseSpinUIManifest(value: unknown): SpinUIManifest { + if (!isRecord(value) || value.schemaVersion !== 1 || value.treeHashAlgorithm !== TREE_HASH_ALGORITHM) { + throw new Error("This SpinUI update manifest format is not supported."); + } + const releaseVersion = normalizeVersion(String(value.releaseVersion ?? "")); + if (!isRecord(value.archive) || value.archive.name !== ARCHIVE_ASSET) { + throw new Error("The SpinUI manifest does not identify the official UI archive."); + } + if (!Number.isSafeInteger(value.archive.size) || (value.archive.size as number) < 1 || (value.archive.size as number) > MAX_ARCHIVE_BYTES) { + throw new Error("The SpinUI archive size is outside the safe limit."); + } + if (!isSha256(value.archive.sha256)) throw new Error("The SpinUI archive checksum is invalid."); + if (!isRecord(value.themes)) throw new Error("The SpinUI manifest has no theme inventories."); + const themeKeys = Object.keys(value.themes).sort(); + if (themeKeys.join("|") !== [...SPINUI_SKINS].sort().join("|")) { + throw new Error("The SpinUI manifest must contain exactly the Reloaded and Glass themes."); + } + return { + schemaVersion: 1, + releaseVersion, + treeHashAlgorithm: TREE_HASH_ALGORITHM, + archive: { + name: ARCHIVE_ASSET, + size: value.archive.size as number, + sha256: value.archive.sha256.toLowerCase(), + }, + themes: { + spinui_reloaded: parseTheme(value.themes.spinui_reloaded, "spinui_reloaded"), + spinui_glass: parseTheme(value.themes.spinui_glass, "spinui_glass"), + }, + }; +} + +function validateOfficialUrl(value: string): URL { + const url = new URL(value); + if (url.protocol !== "https:" || !OFFICIAL_DOWNLOAD_HOSTS.has(url.hostname.toLowerCase())) { + throw new Error("The SpinUI release redirected outside the official GitHub download service."); + } + if (url.username || url.password) throw new Error("Authenticated release URLs are not accepted."); + return url; +} + +async function fetchOfficial(fetchImpl: typeof fetch, input: string, init: RequestInit): Promise { + let url = validateOfficialUrl(input); + for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) { + const response = await fetchImpl(url, { ...init, redirect: "manual" }); + if (response.status < 300 || response.status >= 400) return response; + const location = response.headers.get("location"); + if (!location) throw new Error("GitHub returned a redirect without a destination."); + if (redirects === MAX_REDIRECTS) throw new Error("The SpinUI download redirected too many times."); + url = validateOfficialUrl(new URL(location, url).toString()); + } + throw new Error("The SpinUI release download could not be resolved."); +} + +async function readNextChunk(reader: ReadableStreamDefaultReader): Promise> { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + reader.read(), + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error("The SpinUI download stopped responding.")), INACTIVITY_TIMEOUT_MS); + timer.unref?.(); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +async function readLimitedBytes(response: Response, maximum: number): Promise { + if (!response.ok) throw new Error(`GitHub returned HTTP ${response.status}.`); + const declared = Number(response.headers.get("content-length") ?? "0"); + if (Number.isFinite(declared) && declared > maximum) throw new Error("The SpinUI release response is unexpectedly large."); + if (!response.body) throw new Error("GitHub returned an empty SpinUI release response."); + const chunks: Uint8Array[] = []; + const reader = response.body.getReader(); + let total = 0; + while (true) { + const { done, value } = await readNextChunk(reader); + if (done) break; + total += value.byteLength; + if (total > maximum) { + await reader.cancel(); + throw new Error("The SpinUI release response exceeded its safe size limit."); + } + chunks.push(value); + } + const joined = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + joined.set(chunk, offset); + offset += chunk.byteLength; + } + return joined; +} + +function safeReleaseAsset(value: unknown): ReleaseAsset | null { + if (!isRecord(value)) return null; + const name = typeof value.name === "string" ? value.name : ""; + const url = typeof value.browser_download_url === "string" ? value.browser_download_url : ""; + const size = typeof value.size === "number" && Number.isSafeInteger(value.size) ? value.size : -1; + const match = typeof value.digest === "string" ? value.digest.toLowerCase().match(/^sha256:([0-9a-f]{64})$/) : null; + if (!name || !url || size < 0 || !match) return null; + validateOfficialUrl(url); + return { name, url, size, sha256: match[1] }; +} + +function exactAsset(assets: ReleaseAsset[], name: string): ReleaseAsset { + const matches = assets.filter((asset) => asset.name === name); + if (matches.length !== 1) throw new Error(`The official release must contain exactly one ${name} asset.`); + return matches[0]; +} + +async function fetchVerifiedAsset(fetchImpl: typeof fetch, asset: ReleaseAsset, maximum: number): Promise { + if (asset.size > maximum) throw new Error(`${asset.name} exceeds its safe download limit.`); + const response = await fetchOfficial(fetchImpl, asset.url, { + signal: AbortSignal.timeout(60_000), + headers: { "User-Agent": "Loremaster-SpinUI-Updater" }, + }); + const bytes = await readLimitedBytes(response, maximum); + if (bytes.byteLength !== asset.size) throw new Error(`${asset.name} size differs from GitHub release metadata.`); + const actual = createHash("sha256").update(bytes).digest("hex"); + if (actual !== asset.sha256) throw new Error(`${asset.name} checksum differs from GitHub release metadata.`); + return bytes; +} + +async function fetchRelease(fetchImpl: typeof fetch): Promise { + const response = await fetchOfficial(fetchImpl, RELEASE_API, { + signal: AbortSignal.timeout(30_000), + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "Loremaster-SpinUI-Updater", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + const releaseBytes = await readLimitedBytes(response, MAX_RELEASE_BYTES); + const source = JSON.parse(Buffer.from(releaseBytes).toString("utf8")) as unknown; + if (!isRecord(source) || source.draft === true || source.prerelease === true) { + throw new Error("GitHub did not return a stable public SpinUI release."); + } + const version = normalizeVersion(String(source.tag_name ?? "")); + const assets = Array.isArray(source.assets) + ? source.assets.map(safeReleaseAsset).filter((asset): asset is ReleaseAsset => Boolean(asset)) + : []; + const archive = exactAsset(assets, ARCHIVE_ASSET); + const manifestAsset = exactAsset(assets, MANIFEST_ASSET); + const checksums = exactAsset(assets, CHECKSUM_ASSET); + const releaseUrl = typeof source.html_url === "string" ? source.html_url : RELEASE_PAGE; + validateOfficialUrl(releaseUrl); + + const checksumBytes = await fetchVerifiedAsset(fetchImpl, checksums, MAX_CHECKSUM_BYTES); + const checksumMap = parseChecksums(Buffer.from(checksumBytes).toString("utf8")); + for (const asset of [archive, manifestAsset]) { + if (checksumMap.get(asset.name) !== asset.sha256) { + throw new Error(`GitHub's ${asset.name} digest and SHA256SUMS.txt do not agree.`); + } + } + const manifestBytes = await fetchVerifiedAsset(fetchImpl, manifestAsset, MAX_MANIFEST_BYTES); + const manifest = parseSpinUIManifest(JSON.parse(Buffer.from(manifestBytes).toString("utf8"))); + if (manifest.releaseVersion !== version) throw new Error("The SpinUI manifest version does not match the GitHub release tag."); + if ( + manifest.archive.name !== archive.name + || manifest.archive.size !== archive.size + || manifest.archive.sha256 !== archive.sha256 + ) { + throw new Error("The SpinUI manifest does not authenticate the published UI archive."); + } + return { version, releaseUrl, archive, manifestAsset, checksums, manifest }; +} + +function directChild(root: string, name: string): string { + const candidate = path.resolve(root, name); + const relative = path.relative(path.resolve(root), candidate); + if (!relative || relative !== name || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("The SpinUI updater refused a path outside its owned directory."); + } + return candidate; +} + +async function isRegularFile(filePath: string): Promise { + try { + return (await lstat(filePath)).isFile(); + } catch { + return false; + } +} + +async function isRealDirectory(directory: string): Promise { + try { + const info = await lstat(directory); + return info.isDirectory() && !info.isSymbolicLink(); + } catch { + return false; + } +} + +export async function deriveEverQuestRoot(candidate: string): Promise { + if (!candidate?.trim()) throw new Error("Choose the EverQuest Legends folder containing eqgame.exe."); + let current = path.resolve(candidate.trim()); + try { + const info = await lstat(current); + if (info.isFile()) current = path.dirname(current); + else if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("The selected EverQuest path is not a real folder."); + } catch (error) { + if (error instanceof Error && error.message.includes("real folder")) throw error; + current = path.dirname(current); + } + for (let depth = 0; depth < 8; depth += 1) { + if (await isRegularFile(path.join(current, "eqgame.exe"))) { + const uiFiles = path.join(current, "uifiles"); + if (!await isRealDirectory(uiFiles)) throw new Error("The selected EverQuest folder has no usable uifiles directory."); + return realpath(current); + } + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + throw new Error("Could not find eqgame.exe above the selected location."); +} + +const execFileAsync = promisify(execFile); + +export async function isEverQuestRunning(): Promise { + if (process.platform !== "win32") return false; + const windowsRoot = process.env.SystemRoot || process.env.WINDIR; + if (!windowsRoot || !path.isAbsolute(windowsRoot)) { + throw new Error("The trusted Windows system directory could not be resolved."); + } + const tasklist = path.join(windowsRoot, "System32", "tasklist.exe"); + if (!existsSync(tasklist)) throw new Error("Windows Task List is unavailable."); + const { stdout } = await execFileAsync( + tasklist, + ["/FI", "IMAGENAME eq eqgame.exe", "/FO", "CSV", "/NH"], + { encoding: "utf8", windowsHide: true, timeout: 10_000, maxBuffer: 1024 * 1024 }, + ); + return /(?:^|[\r\n])\s*"?eqgame\.exe"?(?:,|\s|$)/i.test(stdout); +} + +async function sha256File(filePath: string): Promise { + const digest = createHash("sha256"); + const stream = createReadStream(filePath); + for await (const chunk of stream) digest.update(chunk as Buffer); + return digest.digest("hex"); +} + +async function scanTheme(directory: string): Promise { + const rootInfo = await lstat(directory); + if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) throw new Error("The SpinUI theme is not a real directory."); + const rows: SpinUIManifestFile[] = []; + const directories = new Set(); + const seen = new Set(); + + const visit = async (current: string, prefix: string): Promise => { + const entries = (await readdir(current, { withFileTypes: true })).sort((a, b) => compareUtf8(a.name, b.name)); + for (const entry of entries) { + const absolute = path.join(current, entry.name); + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + const info = await lstat(absolute); + if (info.isSymbolicLink()) throw new Error(`The SpinUI theme contains a symbolic link: ${relative}`); + if (info.isDirectory()) { + directories.add(relative); + await visit(absolute, relative); + continue; + } + if (!info.isFile()) throw new Error(`The SpinUI theme contains an unsupported entry: ${relative}`); + const safePath = safeRelativeThemePath(relative); + const folded = safePath.toLocaleLowerCase("en-US"); + if (seen.has(folded)) throw new Error("The SpinUI theme contains Windows-colliding paths."); + seen.add(folded); + if (rows.length >= MAX_THEME_FILES || info.size > MAX_THEME_FILE_BYTES) { + throw new Error("The installed SpinUI theme exceeds its safe scan limit."); + } + const digest = await sha256File(absolute); + const after = await stat(absolute); + if (after.size !== info.size || after.mtimeMs !== info.mtimeMs) { + throw new Error(`The SpinUI file changed while it was being verified: ${relative}`); + } + rows.push({ path: safePath, size: info.size, sha256: digest }); + } + }; + await visit(directory, ""); + rows.sort((a, b) => compareUtf8(a.path, b.path)); + if (!rows.length) throw new Error("The SpinUI theme contains no files."); + const totalBytes = rows.reduce((total, row) => total + row.size, 0); + if (totalBytes > MAX_THEME_BYTES) throw new Error("The installed SpinUI theme exceeds its safe size limit."); + const validDirectories = new Set(); + for (const row of rows) { + const parts = row.path.split("/"); + for (let index = 1; index < parts.length; index += 1) validDirectories.add(parts.slice(0, index).join("/")); + } + for (const directoryName of directories) { + if (!validDirectories.has(directoryName)) throw new Error(`The SpinUI theme contains an unexpected empty directory: ${directoryName}`); + } + return { + fileCount: rows.length, + totalBytes, + treeSha256: computeSpinUITreeSha256(rows), + files: rows, + }; +} + +function treeMatches(actual: SpinUIManifestTheme, expected: SpinUIManifestTheme): boolean { + return actual.fileCount === expected.fileCount + && actual.totalBytes === expected.totalBytes + && actual.treeSha256 === expected.treeSha256; +} + +async function removeOwnedEntry(entryPath: string, ownedRoot: string): Promise { + const relative = path.relative(path.resolve(ownedRoot), path.resolve(entryPath)); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("Refusing to remove a path outside Loremaster's SpinUI update directory."); + } + try { + const info = await lstat(entryPath); + if (info.isSymbolicLink() || !info.isDirectory()) await unlink(entryPath); + else await rm(entryPath, { recursive: true, force: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } +} + +async function ensureOwnedDirectory(parent: string, name: string): Promise { + const parentReal = await realpath(parent); + const candidate = directChild(parentReal, name); + try { + const info = await lstat(candidate); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new Error("Loremaster's SpinUI update directory is not a real folder."); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + await mkdir(candidate); + } + const candidateReal = await realpath(candidate); + if (path.dirname(candidateReal).toLocaleLowerCase("en-US") !== parentReal.toLocaleLowerCase("en-US")) { + throw new Error("Loremaster's SpinUI update directory resolves outside the EverQuest folder."); + } + return candidateReal; +} + +async function writeVerifiedArchive( + fetchImpl: typeof fetch, + asset: ReleaseAsset, + destination: string, + maximum: number, + onProgress: (received: number) => void, +): Promise { + if (asset.size > maximum) throw new Error("The SpinUI archive exceeds its safe download limit."); + const temporary = `${destination}.${process.pid}.${randomBytes(5).toString("hex")}.part`; + const response = await fetchOfficial(fetchImpl, asset.url, { + signal: AbortSignal.timeout(30 * 60_000), + headers: { "User-Agent": "Loremaster-SpinUI-Updater" }, + }); + if (!response.ok) throw new Error(`GitHub returned HTTP ${response.status} while downloading SpinUI.`); + if (!response.body) throw new Error("GitHub returned an empty SpinUI archive."); + const declared = Number(response.headers.get("content-length") ?? "0"); + if (declared > 0 && declared !== asset.size) throw new Error("The SpinUI archive size differs from GitHub metadata."); + const output = createWriteStream(temporary, { flags: "wx" }); + const digest = createHash("sha256"); + let received = 0; + try { + const reader = response.body.getReader(); + while (true) { + const { done, value } = await readNextChunk(reader); + if (done) break; + received += value.byteLength; + if (received > maximum || received > asset.size) { + await reader.cancel(); + throw new Error("The SpinUI archive exceeded its authenticated size."); + } + digest.update(value); + if (!output.write(value)) await once(output, "drain"); + onProgress(received); + } + output.end(); + await once(output, "close"); + if (received !== asset.size) throw new Error("The SpinUI archive download was incomplete."); + if (digest.digest("hex") !== asset.sha256) throw new Error("The downloaded SpinUI archive checksum did not match the release."); + await rm(destination, { force: true }); + await rename(temporary, destination); + } catch (error) { + output.destroy(); + await rm(temporary, { force: true }).catch(() => undefined); + throw error; + } +} + +async function defaultExtract(archivePath: string, destination: string): Promise { + const { default: extract } = await import("@electron-internal/extract-zip"); + await extract(archivePath, { dir: destination }); +} + +function receiptKey(eqRoot: string, theme: SpinUISkinName): string { + return `${path.resolve(eqRoot).toLocaleLowerCase("en-US")}|${theme}`; +} + +export class SpinUISkinUpdateService { + private readonly userDataDir: string; + private readonly fetchImpl: typeof fetch; + private readonly extractImpl: ExtractImplementation; + private readonly eqProcessCheck: EqProcessCheck; + private readonly archiveMaximumBytes: number; + private readonly listeners = new Set(); + private state: SpinUIUpdateState; + private checkedRelease: SpinUIRelease | null = null; + private operation: Promise | null = null; + + constructor(options: SpinUISkinUpdaterOptions) { + if (!path.isAbsolute(options.userDataDir)) throw new Error("Loremaster's update data directory must be absolute."); + this.userDataDir = path.resolve(options.userDataDir); + this.fetchImpl = options.fetchImpl ?? fetch; + this.extractImpl = options.extractImpl ?? defaultExtract; + this.eqProcessCheck = options.eqProcessCheck ?? isEverQuestRunning; + this.archiveMaximumBytes = options.archiveMaximumBytes ?? MAX_ARCHIVE_BYTES; + this.state = initialState(options.eqRoot?.trim() ? path.resolve(options.eqRoot) : null); + } + + subscribe(listener: StateListener): () => void { + this.listeners.add(listener); + listener(this.getState()); + return () => this.listeners.delete(listener); + } + + getState(): SpinUIUpdateState { + return cloneState(this.state); + } + + private emit(): void { + const snapshot = this.getState(); + for (const listener of this.listeners) listener(snapshot); + } + + private patchTheme(theme: SpinUISkinName, patch: Partial): void { + this.state.themes[theme] = { + ...this.state.themes[theme], + ...patch, + percent: boundedPercent(patch.percent ?? this.state.themes[theme].percent), + }; + this.emit(); + } + + private async exclusive(operation: () => Promise): Promise { + if (this.operation) throw new Error("Another SpinUI update operation is already running."); + this.state.busy = true; + this.emit(); + const running = operation(); + this.operation = running; + try { + return await running; + } finally { + this.operation = null; + this.state.busy = false; + this.emit(); + } + } + + async setEqRoot(candidate: string): Promise { + if (this.operation) throw new Error("Wait for the current SpinUI update operation to finish."); + const root = await deriveEverQuestRoot(candidate); + this.state.eqRoot = root; + this.emit(); + return root; + } + + private async resolveRoot(candidate?: string): Promise { + const selected = candidate?.trim() || this.state.eqRoot; + if (!selected) throw new Error("Choose the EverQuest Legends folder before updating SpinUI."); + const root = await deriveEverQuestRoot(selected); + this.state.eqRoot = root; + return root; + } + + async check(candidate?: string): Promise { + return this.exclusive(async () => { + for (const theme of SPINUI_SKINS) { + this.patchTheme(theme, { phase: "checking", percent: 10, detail: "Checking the official SpinUI release." }); + } + try { + const release = await fetchRelease(this.fetchImpl); + this.checkedRelease = release; + this.state.latestVersion = release.version; + this.state.releaseUrl = release.releaseUrl; + this.state.lastCheckedAt = new Date().toISOString(); + let root: string; + try { + root = await this.resolveRoot(candidate); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + for (const theme of SPINUI_SKINS) { + this.patchTheme(theme, { + phase: "missing", percent: 100, detail, + latestVersion: release.version, installed: false, installedVersion: null, modified: false, + }); + } + return this.getState(); + } + const receipts = await this.readReceipts(); + for (const theme of SPINUI_SKINS) { + await this.inspectInstalledTheme(root, theme, release, receipts); + } + return this.getState(); + } catch (error) { + this.checkedRelease = null; + const detail = error instanceof Error ? error.message : String(error); + for (const theme of SPINUI_SKINS) this.patchTheme(theme, { phase: "error", percent: 0, detail }); + return this.getState(); + } + }); + } + + private async inspectInstalledTheme( + root: string, + theme: SpinUISkinName, + release: SpinUIRelease, + receipts: Map, + ): Promise { + const uiFiles = await realpath(path.join(root, "uifiles")); + const target = directChild(uiFiles, theme); + const latestVersion = release.version; + if (!await isRealDirectory(target)) { + this.patchTheme(theme, { + phase: "missing", installed: false, installedVersion: null, latestVersion, + percent: 100, detail: `${theme} is not installed in this EverQuest folder.`, modified: false, + }); + return; + } + try { + const actual = await scanTheme(target); + const expected = release.manifest.themes[theme]; + if (treeMatches(actual, expected)) { + this.patchTheme(theme, { + phase: "current", installed: true, installedVersion: latestVersion, latestVersion, + percent: 100, detail: `${theme} is verified and up to date.`, modified: false, + }); + receipts.set(receiptKey(root, theme), { + eqRoot: root, + targetPath: target, + theme, + version: latestVersion, + treeSha256: expected.treeSha256, + installedAt: new Date().toISOString(), + }); + await this.writeReceipts(receipts); + return; + } + const receipt = receipts.get(receiptKey(root, theme)); + const unmodifiedOlderInstall = receipt + && receipt.version !== latestVersion + && receipt.treeSha256 === actual.treeSha256; + this.patchTheme(theme, { + phase: unmodifiedOlderInstall ? "available" : "modified", + installed: true, + installedVersion: receipt?.version ?? null, + latestVersion, + percent: 100, + detail: unmodifiedOlderInstall + ? `${theme} ${latestVersion} is available.` + : `${theme} differs from the verified release; Update will replace only this theme and retain a rollback copy.`, + modified: !unmodifiedOlderInstall, + }); + } catch (error) { + this.patchTheme(theme, { + phase: "modified", installed: true, latestVersion, percent: 100, modified: true, + detail: error instanceof Error ? error.message : String(error), + }); + } + } + + async install(theme: SpinUISkinName, candidate?: string): Promise { + if (!SPINUI_SKINS.includes(theme)) throw new Error("Unknown SpinUI theme."); + return this.exclusive(async () => { + const root = await this.resolveRoot(candidate); + const release = this.checkedRelease ?? await fetchRelease(this.fetchImpl); + this.checkedRelease = release; + this.state.latestVersion = release.version; + this.state.releaseUrl = release.releaseUrl; + return this.installOne(root, theme, release); + }); + } + + async installAll(themes: readonly SpinUISkinName[], candidate?: string): Promise { + const selected = [...new Set(themes)]; + if (!selected.length || selected.some((theme) => !SPINUI_SKINS.includes(theme))) { + throw new Error("Choose at least one valid SpinUI theme to update."); + } + return this.exclusive(async () => { + const root = await this.resolveRoot(candidate); + const release = this.checkedRelease ?? await fetchRelease(this.fetchImpl); + this.checkedRelease = release; + this.state.latestVersion = release.version; + this.state.releaseUrl = release.releaseUrl; + const archivePath = await this.ensureArchive(release, selected); + if (await this.eqProcessCheck()) { + for (const theme of selected) this.patchTheme(theme, { + phase: "waiting-for-eq", percent: 100, detail: "Update verified. Close EverQuest, then install again.", + }); + throw new EverQuestRunningError(); + } + const results: SpinUISkinInstallResult[] = []; + for (const theme of selected) results.push(await this.installOne(root, theme, release, archivePath)); + return results; + }); + } + + private async installOne( + root: string, + theme: SpinUISkinName, + release: SpinUIRelease, + preparedArchive?: string, + ): Promise { + const archivePath = preparedArchive ?? await this.ensureArchive(release, [theme]); + if (await this.eqProcessCheck()) { + this.patchTheme(theme, { + phase: "waiting-for-eq", percent: 100, + detail: "Update verified. Close EverQuest, then install again.", latestVersion: release.version, + }); + throw new EverQuestRunningError(); + } + const uiFiles = await realpath(path.join(root, "uifiles")); + const target = directChild(uiFiles, theme); + const ownedReal = await ensureOwnedDirectory(root, OWNED_UPDATE_DIRECTORY); + const stage = directChild(ownedReal, `stage-${theme}-${process.pid}-${randomBytes(6).toString("hex")}`); + const backupsReal = await ensureOwnedDirectory(ownedReal, "backups"); + const backup = directChild(backupsReal, theme); + let targetMoved = false; + let newTargetMoved = false; + let hadTarget = false; + try { + await mkdir(stage); + this.patchTheme(theme, { + phase: "verifying", percent: 85, detail: `Safely extracting and verifying ${theme}.`, latestVersion: release.version, + }); + await this.extractImpl(archivePath, stage); + const extractedTheme = directChild(stage, theme); + const extracted = await scanTheme(extractedTheme); + if (!treeMatches(extracted, release.manifest.themes[theme])) { + throw new Error(`The extracted ${theme} tree does not match the authenticated release manifest.`); + } + if (await this.eqProcessCheck()) throw new EverQuestRunningError(); + this.patchTheme(theme, { phase: "installing", percent: 94, detail: `Installing ${theme} with rollback protection.` }); + await removeOwnedEntry(backup, ownedReal); + try { + const targetInfo = await lstat(target); + if (!targetInfo.isDirectory() || targetInfo.isSymbolicLink()) { + throw new Error(`Refusing to replace ${theme} because it is not a real directory.`); + } + hadTarget = true; + await rename(target, backup); + targetMoved = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await rename(extractedTheme, target); + newTargetMoved = true; + const installed = await scanTheme(target); + if (!treeMatches(installed, release.manifest.themes[theme])) { + throw new Error(`The installed ${theme} tree failed its final verification.`); + } + const receipts = await this.readReceipts(); + receipts.set(receiptKey(root, theme), { + eqRoot: root, + targetPath: target, + theme, + version: release.version, + treeSha256: release.manifest.themes[theme].treeSha256, + installedAt: new Date().toISOString(), + }); + await this.writeReceipts(receipts); + this.patchTheme(theme, { + phase: "installed", installed: true, installedVersion: release.version, + latestVersion: release.version, percent: 100, modified: false, + detail: `${theme} ${release.version} installed and verified.`, + }); + return { theme, version: release.version, targetPath: target, backupPath: hadTarget ? backup : null }; + } catch (error) { + if (newTargetMoved) await removeOwnedEntry(target, uiFiles).catch(() => undefined); + if (targetMoved && await isRealDirectory(backup)) await rename(backup, target).catch(() => undefined); + const waiting = error instanceof EverQuestRunningError; + this.patchTheme(theme, { + phase: waiting ? "waiting-for-eq" : "error", + percent: waiting ? 100 : 0, + detail: error instanceof Error ? error.message : String(error), + }); + throw error; + } finally { + await removeOwnedEntry(stage, ownedReal).catch(() => undefined); + } + } + + private async ensureArchive(release: SpinUIRelease, themes: readonly SpinUISkinName[]): Promise { + const cacheDirectory = path.join(this.userDataDir, "updates", "spinui", `v${release.version}`); + await mkdir(cacheDirectory, { recursive: true }); + const destination = directChild(cacheDirectory, ARCHIVE_ASSET); + let validCached = false; + if (await isRegularFile(destination)) { + const info = await stat(destination); + validCached = info.size === release.archive.size && await sha256File(destination) === release.archive.sha256; + } + if (!validCached) { + for (const theme of themes) this.patchTheme(theme, { + phase: "downloading", percent: 2, detail: "Downloading the authenticated SpinUI package.", latestVersion: release.version, + }); + await writeVerifiedArchive( + this.fetchImpl, + release.archive, + destination, + this.archiveMaximumBytes, + (received) => { + const percent = release.archive.size > 0 ? 2 + (received / release.archive.size) * 78 : 40; + for (const theme of themes) this.patchTheme(theme, { + phase: "downloading", percent, detail: "Downloading the authenticated SpinUI package.", + }); + }, + ); + } + const finalInfo = await stat(destination); + if (finalInfo.size !== release.manifest.archive.size || await sha256File(destination) !== release.manifest.archive.sha256) { + await rm(destination, { force: true }); + throw new Error("The cached SpinUI archive failed manifest verification."); + } + return destination; + } + + private receiptPath(): string { + return path.join(this.userDataDir, RECEIPT_FILENAME); + } + + private async readReceipts(): Promise> { + const result = new Map(); + try { + const raw = await readFile(this.receiptPath(), "utf8"); + if (Buffer.byteLength(raw) > 1024 * 1024) return result; + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed) || parsed.schemaVersion !== 1 || !Array.isArray(parsed.installations)) return result; + for (const value of parsed.installations) { + if (!isRecord(value) || !SPINUI_SKINS.includes(value.theme as SpinUISkinName)) continue; + if (typeof value.eqRoot !== "string" || typeof value.targetPath !== "string" || typeof value.version !== "string") continue; + if (!isSha256(value.treeSha256) || typeof value.installedAt !== "string") continue; + const entry: ReceiptEntry = { + eqRoot: path.resolve(value.eqRoot), + targetPath: path.resolve(value.targetPath), + theme: value.theme as SpinUISkinName, + version: normalizeVersion(value.version), + treeSha256: value.treeSha256.toLowerCase(), + installedAt: value.installedAt, + }; + result.set(receiptKey(entry.eqRoot, entry.theme), entry); + } + } catch { + // Missing or damaged receipts never grant trust; a full tree verification + // will classify the installation and can safely recreate the receipt. + } + return result; + } + + private async writeReceipts(receipts: Map): Promise { + await mkdir(this.userDataDir, { recursive: true }); + const payload: ReceiptFile = { + schemaVersion: 1, + installations: [...receipts.values()].sort((a, b) => compareUtf8(receiptKey(a.eqRoot, a.theme), receiptKey(b.eqRoot, b.theme))), + }; + const destination = this.receiptPath(); + const temporary = `${destination}.${process.pid}.${randomBytes(5).toString("hex")}.tmp`; + await writeFile(temporary, `${JSON.stringify(payload, null, 2)}\n`, { encoding: "utf8", flag: "wx" }); + await rm(destination, { force: true }); + await rename(temporary, destination); + } +} + +export const spinUISkinUpdaterConstants = { + releaseApi: RELEASE_API, + releasePage: RELEASE_PAGE, + archiveAsset: ARCHIVE_ASSET, + manifestAsset: MANIFEST_ASSET, + checksumAsset: CHECKSUM_ASSET, + treeHashAlgorithm: TREE_HASH_ALGORITHM, + ownedUpdateDirectory: OWNED_UPDATE_DIRECTORY, +}; diff --git a/loremaster-desktop/package.json b/loremaster-desktop/package.json index a3c02dd..76e72b0 100644 --- a/loremaster-desktop/package.json +++ b/loremaster-desktop/package.json @@ -11,17 +11,21 @@ "preview": "electron .", "dist:windows": "pnpm build && electron-builder --win portable --x64 --publish never", "test:fixtures": "node scripts/validate-fixture.mjs", - "test:gear": "node scripts/test-gear-plan.cjs" + "test:gear": "node scripts/test-gear-plan.cjs", + "test:items": "node scripts/test-item-intelligence.cjs", + "test:updates": "node scripts/test-portable-updater.cjs", + "test:skin-updates": "node scripts/test-spinui-updater.cjs" }, "dependencies": { + "@electron-internal/extract-zip": "1.0.5", "react": "19.0.0", "react-dom": "19.0.0" }, "devDependencies": { - "@vitejs/plugin-react": "4.3.4", "@types/node": "22.10.10", "@types/react": "19.0.8", "@types/react-dom": "19.0.3", + "@vitejs/plugin-react": "4.3.4", "concurrently": "9.1.2", "cross-env": "7.0.3", "electron": "43.3.0", @@ -53,7 +57,9 @@ } ], "win": { - "target": ["portable"], + "target": [ + "portable" + ], "artifactName": "Loremaster.exe", "icon": "../loremaster/assets/loremaster.ico" } diff --git a/loremaster-desktop/pnpm-lock.yaml b/loremaster-desktop/pnpm-lock.yaml index a939c1f..b55f857 100644 --- a/loremaster-desktop/pnpm-lock.yaml +++ b/loremaster-desktop/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@electron-internal/extract-zip': + specifier: 1.0.5 + version: 1.0.5 react: specifier: 19.0.0 version: 19.0.0 diff --git a/loremaster-desktop/scripts/test-item-intelligence.cjs b/loremaster-desktop/scripts/test-item-intelligence.cjs new file mode 100644 index 0000000..9df3875 --- /dev/null +++ b/loremaster-desktop/scripts/test-item-intelligence.cjs @@ -0,0 +1,59 @@ +const assert = require("node:assert/strict"); +const { mkdtempSync, rmSync } = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { + ItemIntelligenceService, + normalizeItemName, + parseItemPayload, + readLimitedResponse, +} = require("../dist-electron/item-intelligence.js"); + +assert.equal(normalizeItemName(" Cloak of Flames +4 "), "Cloak of Flames"); +assert.equal(normalizeItemName("\u0000 Guise_of_the_Deceiver "), "Guise_of_the_Deceiver"); + +const item = parseItemPayload({ + parse: { + title: "Cloak of Flames", + wikitext: { + "*": [ + "{{Itempage", + "|itemname = Cloak of Flames", + "|statsblock = MAGIC ITEM
Slot: BACK
AC: 10", + "|dropsfrom = * [[Lord Nagafen]]", + "|relatedquests = * [[A Fiery Reward|Fiery Reward]]", + "|clickeffect = Haste", + "|notes = A famous cloak.", + "}}", + ].join("\n"), + }, + }, +}, "Cloak of Flames +4"); + +assert.ok(item); +assert.equal(item.title, "Cloak of Flames"); +assert.equal(item.url, "https://eqlwiki.com/Cloak_of_Flames"); +assert.deepEqual(item.stats.slice(0, 3), ["MAGIC ITEM", "Slot: BACK", "AC: 10"]); +assert.ok(item.stats.includes("Click Effect: Haste")); +assert.deepEqual(item.sections["Drops From"], ["• Lord Nagafen"]); +assert.deepEqual(item.sections["Related quests"], ["• Fiery Reward"]); +assert.deepEqual(item.notes, ["A famous cloak."]); +assert.equal(parseItemPayload({ error: { info: "missing" } }, "Missing Item"), null); + +(async () => { + assert.equal(await readLimitedResponse(new Response("small response")), "small response"); + const oversized = new Response(new Uint8Array(2 * 1024 * 1024 + 1)); + await assert.rejects(() => readLimitedResponse(oversized), /2 MB safety limit/); + const cacheRoot = mkdtempSync(path.join(os.tmpdir(), "loremaster-items-")); + try { + const offline = await new ItemIntelligenceService(cacheRoot).lookup("Uncached Item", false); + assert.equal(offline.status, "offline"); + assert.match(offline.detail, /disabled/i); + } finally { + rmSync(cacheRoot, { recursive: true, force: true }); + } + console.log("Item intelligence parser: ALL PASS"); +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/loremaster-desktop/scripts/test-portable-updater.cjs b/loremaster-desktop/scripts/test-portable-updater.cjs new file mode 100644 index 0000000..108f56c --- /dev/null +++ b/loremaster-desktop/scripts/test-portable-updater.cjs @@ -0,0 +1,201 @@ +const assert = require("node:assert/strict"); +const { createHash } = require("node:crypto"); +const { existsSync, readFileSync, writeFileSync } = require("node:fs"); +const { mkdtemp, mkdir, rm } = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); + +const { + acknowledgePortableUpdateRelaunch, + PortableUpdateService, + compareVersions, + parseChecksums, + portableUpdaterConstants, + resolvePortableExecutable, +} = require("../dist-electron/portable-updater.js"); + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function releaseJson(version, executable) { + return { + tag_name: `v${version}`, + html_url: `https://github.com/itsspin/spinips/releases/tag/v${version}`, + draft: false, + prerelease: false, + published_at: "2026-08-13T18:00:00Z", + body: "A verified test release.", + assets: [ + { + name: "Loremaster.exe", + browser_download_url: "https://github.com/itsspin/spinips/releases/download/v2.0.0/Loremaster.exe", + size: executable.length, + digest: `sha256:${sha256(executable)}`, + }, + { + name: "SHA256SUMS.txt", + browser_download_url: "https://github.com/itsspin/spinips/releases/download/v2.0.0/SHA256SUMS.txt", + size: 82, + digest: `sha256:${"f".repeat(64)}`, + }, + ], + }; +} + +function responseFor(value, headers = {}) { + return new Response(value, { status: 200, headers }); +} + +function fetchFixture(executable, checksum = sha256(executable)) { + return async (input) => { + const url = String(input); + if (url === portableUpdaterConstants.releaseApi) { + const body = JSON.stringify(releaseJson("2.0.0", executable)); + return responseFor(body, { "content-length": String(Buffer.byteLength(body)) }); + } + if (url.endsWith("/SHA256SUMS.txt")) { + return responseFor(`${checksum} Loremaster.exe\n`, { "content-length": "82" }); + } + if (url.endsWith("/Loremaster.exe")) { + return responseFor(executable, { "content-length": String(executable.length) }); + } + throw new Error(`Unexpected updater request: ${url}`); + }; +} + +async function main() { + assert.equal(compareVersions("1.2.3", "1.2.2"), 1); + assert.equal(compareVersions("v1.2.3", "1.2.3"), 0); + assert.equal(compareVersions("1.2.3-beta.2", "1.2.3-beta.10"), -1); + assert.equal(compareVersions("1.2.3", "1.2.3-rc.1"), 1); + assert.throws(() => compareVersions("nightly", "1.0.0"), /Unsupported release version/); + + const checksums = parseChecksums(`${"a".repeat(64)} *Loremaster.exe\r\n${"b".repeat(64)} SpinUI-Manual.zip\r\n`); + assert.equal(checksums.get("Loremaster.exe"), "a".repeat(64)); + assert.equal(checksums.get("SpinUI-Manual.zip"), "b".repeat(64)); + assert.throws(() => parseChecksums(`${"a".repeat(64)} ../Loremaster.exe`), /unsafe asset name/); + assert.throws(() => parseChecksums(`${"a".repeat(64)} Loremaster.exe\n${"b".repeat(64)} Loremaster.exe`), /Duplicate/); + + assert.equal(resolvePortableExecutable({ PORTABLE_EXECUTABLE_FILE: "relative.exe" }), null); + assert.equal(resolvePortableExecutable({ PORTABLE_EXECUTABLE_FILE: "C:\\Apps\\Loremaster.exe" }), "C:\\Apps\\Loremaster.exe"); + assert.equal(resolvePortableExecutable({ PORTABLE_EXECUTABLE_FILE: "C:\\Apps\\Loremaster.zip" }), null); + + const workspace = await mkdtemp(path.join(os.tmpdir(), "loremaster-updater-test-")); + try { + const targetDir = path.join(workspace, "portable app"); + const userDataDir = path.join(workspace, "user data"); + await mkdir(targetDir, { recursive: true }); + const targetPath = path.join(targetDir, "Loremaster.exe"); + writeFileSync(targetPath, Buffer.from("MZold-build")); + const executable = Buffer.concat([Buffer.from("MZ"), Buffer.alloc(4094, 0x5a)]); + const progress = []; + const service = new PortableUpdateService({ + currentVersion: "1.0.0", + userDataDir, + executablePath: targetPath, + fetchImpl: fetchFixture(executable), + minExecutableBytes: 32, + maxExecutableBytes: 8192, + }); + service.subscribe((value) => progress.push(value)); + const check = await service.check(); + assert.equal(check.ok, true); + assert.equal(check.updateAvailable, true); + assert.equal(check.latestVersion, "2.0.0"); + assert.equal(Object.hasOwn(check, "release"), false, "asset URLs must never be exposed to the renderer"); + + const [staged, sameStaged] = await Promise.all([service.stage(), service.stage()]); + assert.equal(staged.stagedPath, sameStaged.stagedPath, "concurrent downloads must coalesce"); + assert.deepEqual(readFileSync(staged.stagedPath), executable); + assert.equal(existsSync(staged.helperPath), true); + assert.match(readFileSync(staged.helperPath, "utf8"), /Get-FileHash/); + assert.match(readFileSync(staged.helperPath, "utf8"), /Move-Item -LiteralPath \$target/); + assert.equal(progress.at(-1).phase, "ready"); + + const spawnCalls = []; + const installService = new PortableUpdateService({ + currentVersion: "1.0.0", + userDataDir, + executablePath: targetPath, + fetchImpl: fetchFixture(executable), + minExecutableBytes: 32, + maxExecutableBytes: 8192, + spawnImpl: (command, args, options) => { + const child = { pid: 4321, unrefCalled: false, unref() { this.unrefCalled = true; } }; + spawnCalls.push({ command, args, options, child }); + return child; + }, + }); + assert.equal(installService.installAndRelaunch(staged, 1234), 4321); + assert.equal(spawnCalls.length, 1); + assert.match(spawnCalls[0].command, /System32\\WindowsPowerShell\\v1\.0\\powershell\.exe$/i); + assert.equal(spawnCalls[0].args.includes("-NoProfile"), true); + assert.equal(spawnCalls[0].args.includes("1234"), true); + assert.equal(spawnCalls[0].args.includes("-HealthToken"), true); + assert.equal(spawnCalls[0].options.detached, true); + assert.equal(spawnCalls[0].options.windowsHide, true); + assert.equal(spawnCalls[0].child.unrefCalled, true); + + await service.discard(staged); + assert.equal(existsSync(path.dirname(staged.stagedPath)), false); + + const corrupt = new PortableUpdateService({ + currentVersion: "1.0.0", + userDataDir: path.join(workspace, "corrupt-data"), + executablePath: targetPath, + fetchImpl: fetchFixture(executable, "0".repeat(64)), + minExecutableBytes: 32, + maxExecutableBytes: 8192, + }); + const corruptCheck = await corrupt.check(); + await assert.rejects(() => corrupt.stage(), /asset digest and the release checksum manifest/); + assert.equal(corrupt.getProgress().phase, "error"); + + const redirectAttack = new PortableUpdateService({ + currentVersion: "1.0.0", + userDataDir, + executablePath: targetPath, + fetchImpl: async () => new Response(null, { + status: 302, + headers: { location: "https://example.com/fake-release.json" }, + }), + }); + const attacked = await redirectAttack.check(); + assert.equal(attacked.ok, false); + assert.match(attacked.detail, /outside the official GitHub/); + + const currentService = new PortableUpdateService({ + currentVersion: "2.0.0", + userDataDir, + executablePath: targetPath, + fetchImpl: fetchFixture(executable), + }); + const current = await currentService.check(); + assert.equal(current.updateAvailable, false); + assert.equal(currentService.getProgress().phase, "current"); + await assert.rejects(() => currentService.stage(), /Check for a newer official release/); + + const healthDir = path.join(userDataDir, "updates", "v2.0.0"); + await mkdir(healthDir, { recursive: true }); + const healthPath = path.join(healthDir, "ready.txt"); + const healthToken = "c".repeat(64); + assert.equal(await acknowledgePortableUpdateRelaunch(userDataDir, [ + "Loremaster.exe", "--loremaster-update-health-token", healthToken, + "--loremaster-update-health-path", healthPath, + ]), true); + assert.equal(readFileSync(healthPath, "ascii"), healthToken); + assert.equal(await acknowledgePortableUpdateRelaunch(userDataDir, [ + "Loremaster.exe", "--loremaster-update-health-token", healthToken, + "--loremaster-update-health-path", path.join(workspace, "escaped.txt"), + ]), false); + } finally { + await rm(workspace, { recursive: true, force: true }); + } + process.stdout.write("Portable updater tests: PASS\n"); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/loremaster-desktop/scripts/test-spinui-updater.cjs b/loremaster-desktop/scripts/test-spinui-updater.cjs new file mode 100644 index 0000000..8b02b69 --- /dev/null +++ b/loremaster-desktop/scripts/test-spinui-updater.cjs @@ -0,0 +1,247 @@ +const assert = require("node:assert/strict"); +const { createHash } = require("node:crypto"); +const { existsSync, readFileSync } = require("node:fs"); +const { mkdir, mkdtemp, readFile, rm, writeFile } = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); + +const { + EverQuestRunningError, + SpinUISkinUpdateService, + computeSpinUITreeSha256, + deriveEverQuestRoot, + parseSpinUIManifest, + spinUISkinUpdaterConstants, +} = require("../dist-electron/spinui-updater.js"); + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function themeManifest(files) { + const rows = Object.entries(files) + .map(([filePath, content]) => ({ + path: filePath, + size: content.length, + sha256: sha256(content), + })) + .sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path))); + return { + fileCount: rows.length, + totalBytes: rows.reduce((total, row) => total + row.size, 0), + treeSha256: computeSpinUITreeSha256(rows), + files: rows, + }; +} + +function makeFixtureRelease(overrides = {}) { + const archive = Buffer.from("PK\x03\x04authenticated-spinui-fixture"); + const themeFiles = { + spinui_reloaded: { + "EQUI.xml": Buffer.from("reloaded-2.0.0\n"), + "art/frame.tga": Buffer.from("TGA-reloaded-2.0.0"), + }, + spinui_glass: { + "EQUI.xml": Buffer.from("glass-2.0.0\n"), + "art/frame.tga": Buffer.from("TGA-glass-2.0.0"), + }, + }; + const manifest = { + schemaVersion: 1, + releaseVersion: "2.0.0", + treeHashAlgorithm: spinUISkinUpdaterConstants.treeHashAlgorithm, + archive: { + name: spinUISkinUpdaterConstants.archiveAsset, + size: archive.length, + sha256: sha256(archive), + }, + themes: { + spinui_reloaded: themeManifest(themeFiles.spinui_reloaded), + spinui_glass: themeManifest(themeFiles.spinui_glass), + }, + }; + const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`); + const checksums = Buffer.from([ + `${overrides.archiveChecksum ?? sha256(archive)} ${spinUISkinUpdaterConstants.archiveAsset}`, + `${sha256(manifestBytes)} ${spinUISkinUpdaterConstants.manifestAsset}`, + "", + ].join("\n")); + const release = { + tag_name: "v2.0.0", + html_url: "https://github.com/itsspin/spinips/releases/tag/v2.0.0", + draft: false, + prerelease: false, + assets: [ + { + name: spinUISkinUpdaterConstants.archiveAsset, + browser_download_url: `https://github.com/itsspin/spinips/releases/download/v2.0.0/${spinUISkinUpdaterConstants.archiveAsset}`, + size: archive.length, + digest: `sha256:${sha256(archive)}`, + }, + { + name: spinUISkinUpdaterConstants.manifestAsset, + browser_download_url: `https://github.com/itsspin/spinips/releases/download/v2.0.0/${spinUISkinUpdaterConstants.manifestAsset}`, + size: manifestBytes.length, + digest: `sha256:${sha256(manifestBytes)}`, + }, + { + name: spinUISkinUpdaterConstants.checksumAsset, + browser_download_url: `https://github.com/itsspin/spinips/releases/download/v2.0.0/${spinUISkinUpdaterConstants.checksumAsset}`, + size: checksums.length, + digest: `sha256:${sha256(checksums)}`, + }, + ], + }; + return { archive, manifest, manifestBytes, checksums, release, themeFiles }; +} + +function fixtureFetch(fixture) { + return async (input) => { + const url = String(input); + let body; + if (url === spinUISkinUpdaterConstants.releaseApi) body = Buffer.from(JSON.stringify(fixture.release)); + else if (url.endsWith(`/${spinUISkinUpdaterConstants.archiveAsset}`)) body = fixture.archive; + else if (url.endsWith(`/${spinUISkinUpdaterConstants.manifestAsset}`)) body = fixture.manifestBytes; + else if (url.endsWith(`/${spinUISkinUpdaterConstants.checksumAsset}`)) body = fixture.checksums; + else throw new Error(`Unexpected updater request: ${url}`); + return new Response(body, { status: 200, headers: { "content-length": String(body.length) } }); + }; +} + +async function writeTree(root, files, extraFile = null) { + for (const [relative, content] of Object.entries(files)) { + const destination = path.join(root, ...relative.split("/")); + await mkdir(path.dirname(destination), { recursive: true }); + await writeFile(destination, content); + } + if (extraFile) { + const destination = path.join(root, extraFile); + await mkdir(path.dirname(destination), { recursive: true }); + await writeFile(destination, "unexpected"); + } +} + +function extractorFor(fixture, options = {}) { + return async (archivePath, destination) => { + assert.deepEqual(await readFile(archivePath), fixture.archive, "only the authenticated archive may be extracted"); + for (const theme of ["spinui_reloaded", "spinui_glass"]) { + await writeTree( + path.join(destination, theme), + fixture.themeFiles[theme], + options.extraTheme === theme ? "unexpected.txt" : null, + ); + } + await writeFile(path.join(destination, "README.md"), "package documentation is never installed"); + }; +} + +async function main() { + const extractorPackage = await import("@electron-internal/extract-zip"); + assert.equal(typeof extractorPackage.default, "function", "the hardened native ZIP extractor must be loadable"); + + const malicious = makeFixtureRelease().manifest; + malicious.themes.spinui_glass.files[0].path = "../escaped.xml"; + assert.throws(() => parseSpinUIManifest(malicious), /unsafe theme path/); + + const workspace = await mkdtemp(path.join(os.tmpdir(), "spinui-updater-test-")); + try { + const eqRoot = path.join(workspace, "EverQuest Legends"); + const uiFiles = path.join(eqRoot, "uifiles"); + const userData = path.join(workspace, "user-data"); + const logFile = path.join(eqRoot, "Logs", "eqlog_Spin_qeynos.txt"); + await mkdir(uiFiles, { recursive: true }); + await mkdir(path.dirname(logFile), { recursive: true }); + await writeFile(path.join(eqRoot, "eqgame.exe"), "MZfixture"); + await writeFile(logFile, "log fixture"); + assert.equal(await deriveEverQuestRoot(logFile), eqRoot); + assert.equal(await deriveEverQuestRoot(path.join(uiFiles, "spinui_reloaded")), eqRoot); + + const fixture = makeFixtureRelease(); + const oldReloaded = path.join(uiFiles, "spinui_reloaded"); + const otherSkin = path.join(uiFiles, "some_users_custom_skin"); + const externalLayout = path.join(eqRoot, "UI_Spin_qeynos.ini"); + await mkdir(oldReloaded, { recursive: true }); + await writeFile(path.join(oldReloaded, "old-only.xml"), "preserve in rollback"); + await mkdir(otherSkin, { recursive: true }); + await writeFile(path.join(otherSkin, "keep.txt"), "do not touch"); + await writeFile(externalLayout, "[layout]\nkeep=true\n"); + + let eqRunning = false; + const progress = []; + const service = new SpinUISkinUpdateService({ + userDataDir: userData, + eqRoot, + fetchImpl: fixtureFetch(fixture), + extractImpl: extractorFor(fixture), + eqProcessCheck: async () => eqRunning, + archiveMaximumBytes: 8192, + }); + service.subscribe((state) => progress.push(state)); + const before = await service.check(); + assert.equal(before.latestVersion, "2.0.0"); + assert.equal(before.themes.spinui_reloaded.phase, "modified"); + assert.equal(before.themes.spinui_glass.phase, "missing"); + + const installed = await service.install("spinui_reloaded"); + assert.equal(installed.version, "2.0.0"); + assert.equal(readFileSync(path.join(oldReloaded, "EQUI.xml"), "utf8"), "reloaded-2.0.0\n"); + assert.equal(readFileSync(path.join(installed.backupPath, "old-only.xml"), "utf8"), "preserve in rollback"); + assert.equal(readFileSync(path.join(otherSkin, "keep.txt"), "utf8"), "do not touch"); + assert.equal(readFileSync(externalLayout, "utf8"), "[layout]\nkeep=true\n"); + assert.equal(existsSync(path.join(uiFiles, "README.md")), false, "package docs must never escape staging"); + assert.equal(existsSync(path.join(userData, "spinui-update-receipts.json")), true); + + const current = await service.check(); + assert.equal(current.themes.spinui_reloaded.phase, "current"); + assert.equal(current.themes.spinui_reloaded.modified, false); + + eqRunning = true; + await assert.rejects(() => service.install("spinui_glass"), EverQuestRunningError); + assert.equal(service.getState().themes.spinui_glass.phase, "waiting-for-eq"); + assert.equal(existsSync(path.join(uiFiles, "spinui_glass")), false); + eqRunning = false; + await service.install("spinui_glass"); + assert.equal(readFileSync(path.join(uiFiles, "spinui_glass", "EQUI.xml"), "utf8"), "glass-2.0.0\n"); + + await writeFile(path.join(oldReloaded, "user-modification.txt"), "modified"); + const modified = await service.check(); + assert.equal(modified.themes.spinui_reloaded.phase, "modified"); + assert.equal(modified.themes.spinui_reloaded.modified, true); + + const targetSnapshot = readFileSync(path.join(oldReloaded, "EQUI.xml")); + const badExtractService = new SpinUISkinUpdateService({ + userDataDir: path.join(workspace, "bad-extract-data"), + eqRoot, + fetchImpl: fixtureFetch(fixture), + extractImpl: extractorFor(fixture, { extraTheme: "spinui_reloaded" }), + eqProcessCheck: async () => false, + archiveMaximumBytes: 8192, + }); + await badExtractService.check(); + await assert.rejects(() => badExtractService.install("spinui_reloaded"), /does not match the authenticated release manifest/); + assert.deepEqual(readFileSync(path.join(oldReloaded, "EQUI.xml")), targetSnapshot, "verification failure must leave the installed theme intact"); + assert.equal(readFileSync(path.join(otherSkin, "keep.txt"), "utf8"), "do not touch"); + + const dishonest = makeFixtureRelease({ archiveChecksum: "0".repeat(64) }); + const dishonestService = new SpinUISkinUpdateService({ + userDataDir: path.join(workspace, "dishonest-data"), + eqRoot, + fetchImpl: fixtureFetch(dishonest), + extractImpl: extractorFor(dishonest), + eqProcessCheck: async () => false, + archiveMaximumBytes: 8192, + }); + const rejected = await dishonestService.check(); + assert.equal(rejected.themes.spinui_reloaded.phase, "error"); + assert.match(rejected.themes.spinui_reloaded.detail, /digest and SHA256SUMS/); + assert.equal(progress.some((state) => state.themes.spinui_reloaded.phase === "downloading"), true); + } finally { + await rm(workspace, { recursive: true, force: true }); + } + process.stdout.write("SpinUI skin updater tests: PASS\n"); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/loremaster-desktop/src/App.tsx b/loremaster-desktop/src/App.tsx index 6131512..8b091bf 100644 --- a/loremaster-desktop/src/App.tsx +++ b/loremaster-desktop/src/App.tsx @@ -13,10 +13,17 @@ import { type EngineSnapshotEvent, type GearPlanView, type LoremasterTheme, + type RaidContextView, type AlertSoundKind, type AlertSoundPreset, + type UpdateCenterState, + type UpdateComponentId, + type UpdateComponentPhase, + type UpdateComponentState, } from "./protocol"; import { CombatArchive } from "./CombatArchive"; +import { LootChronicle } from "./LootChronicle"; +import { abilityCategoryLabel, abilityIdentityStyle, actorIdentityStyle, normalizeAbilityCategory } from "./visualIdentity"; const roman = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"]; const raidDifficulties = [0, 1, 2, 3, 4] as const; @@ -61,10 +68,11 @@ function applyTheme(value: unknown): LoremasterTheme { } const defaultDesktopSettings: DesktopSettings = { - logPath: "", raidDifficulty: null, bisBuildPath: "", inventoryPath: "", + logPath: "", eqRoot: "", autoCheckUpdates: true, + raidDifficulty: null, bisBuildPath: "", inventoryPath: "", uiTheme: "vellum", alwaysOnTop: true, fontScale: 1.15, composition: "", splitCharmedPetDps: false, - stanceAdvisorEnabled: false, seedPosition: null, + stanceAdvisorEnabled: false, itemNetworkLookups: true, seedPosition: null, alerts: { alertsEnabled: true, alertSound: true, alertSeconds: 5, alertAnchor: "auto", alertCharmBreak: true, alertTells: true, alertSummon: true, alertDeath: true, @@ -75,6 +83,25 @@ const defaultDesktopSettings: DesktopSettings = { }, }; +const updateComponentIds: readonly UpdateComponentId[] = ["loremaster", "spinui_reloaded", "spinui_glass"]; +const updateComponentLabels: Record = { + loremaster: { name: "Loremaster", eyebrow: "DESKTOP OVERLAY" }, + spinui_reloaded: { name: "SpinUI Reloaded", eyebrow: "VELLUM SKIN" }, + spinui_glass: { name: "SpinUI Glass", eyebrow: "FROST SKIN" }, +}; +const emptyUpdateState: UpdateCenterState = { + currentVersion: "", + latestVersion: "", + lastCheckedAt: "", + eqRoot: "", + busy: false, + components: { + loremaster: { id: "loremaster", phase: "idle", currentVersion: "", latestVersion: "", progress: null, detail: "Ready to check" }, + spinui_reloaded: { id: "spinui_reloaded", phase: "idle", currentVersion: "", latestVersion: "", progress: null, detail: "Select your EverQuest folder to check this skin" }, + spinui_glass: { id: "spinui_glass", phase: "idle", currentVersion: "", latestVersion: "", progress: null, detail: "Select your EverQuest folder to check this skin" }, + }, +}; + const emptyGearPlan: GearPlanView = { status: "empty", detail: "Import an EQ Legends Tools character build to begin.", buildName: "", classes: [], exportedAt: "", buildPath: "", inventoryPath: "", @@ -266,8 +293,8 @@ function SeedControlSurface() { return
{group.length > 0 &&
GROUP DPS{latest?.active ? "LIVE" : "LAST FIGHT"} · {group.length} VERIFIED
-
{group.map((actor) =>
- {actor.name}{groupTotal > 0 ? Math.round(actor.encounterDamage / groupTotal * 100) : 0}% GROUP SHARE +
{group.map((actor) =>
+