3 Commits

Author SHA1 Message Date
OctoWoW fbad749f0c Sync launcher: stop phantom update prompt, forum News panel, hardware-aware render distance
Squashed sync from upstream. Highlights:
- Updater no longer reports already-applied deletes as a pending update on
  every launch (guard the del branch on the target still existing)
- Derive the packaged CSP image origin from the configured server URL
- Forum Announcements panel + News tab; hardware-aware farClip recommendation;
  parchment UI; localization and tweak updates
- Addon source refresh; schema and mod-state fixes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:02:46 -07:00
octoadmin 16e442ea0f ColoredText: case-insensitive color-code regex so uppercase |c/|r titles render
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 09:59:13 -07:00
OctoWoW 1047a90704 Fixed tweaks and mods, added localization, added antivirus walkthrough 2026-06-28 19:34:40 +00:00
64 changed files with 4164 additions and 981 deletions
+6
View File
@@ -2,3 +2,9 @@
hooks/* text eol=lf hooks/* text eol=lf
*.sh text eol=lf *.sh text eol=lf
*.py text eol=lf *.py text eol=lf
.gitea/** export-ignore
.env.ptr export-ignore
electron-builder.ptr.yml export-ignore
server/Dockerfile export-ignore
scripts/publish-oss.sh export-ignore
+1 -1
View File
@@ -1,6 +1,6 @@
node_modules/ node_modules/
dist/ dist*/
out/ out/
release/ release/
+9 -6
View File
@@ -1,18 +1,23 @@
productName: OctoLauncher productName: OctoLauncher
appId: st.octowow.launcher
directories: directories:
buildResources: build buildResources: build
output: dist output: distprod
files: files:
- '!**/.vscode/*' - '!**/.vscode/*'
- '!src/*' - '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}' - '!electron.vite.config.{js,ts,mjs,cjs}'
# Strip every project-root .md file from the asar bundle
- '!*.md' - '!*.md'
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,.prettierrc.cjs,dev-app-update.yml}' - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,.prettierrc.cjs,dev-app-update.yml}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
- '!{tailwind.config.ts,postcss.config.cjs}' - '!{tailwind.config.ts,postcss.config.cjs}'
- '!{dist,dist-new,dist-test,out/main/chunks}/**' - '!dist*/**'
- '!out/main/chunks/**'
- '!{.gitea,.github}/**'
- '!Tools/**'
- '!**/builder-debug.yml'
- '!**/electron-builder.*'
- '!.launcher/**' - '!.launcher/**'
- '!WTF/**' - '!WTF/**'
- '!server/**' - '!server/**'
@@ -20,10 +25,8 @@ files:
- '!**/node_modules/**/{__tests__,test,tests,docs,example,examples,demo,demos,benchmark,benchmarks}/**' - '!**/node_modules/**/{__tests__,test,tests,docs,example,examples,demo,demos,benchmark,benchmarks}/**'
- '!**/node_modules/**/*.{tsx,map,markdown}' - '!**/node_modules/**/*.{tsx,map,markdown}'
- '!**/node_modules/**/build/Release/obj/**' - '!**/node_modules/**/build/Release/obj/**'
- '!**/node_modules/**/build/Release/{*.iobj,*.ipdb,*.recipe}' - '!**/node_modules/**/build/Release/{*.iobj,*.ipdb,*.recipe,*.exp,*.lib,*.pdb,*.obj}'
- '!**/node_modules/**/*.{vcxproj,vcxproj.filters}' - '!**/node_modules/**/*.{vcxproj,vcxproj.filters}'
asarUnpack:
- resources/**
npmRebuild: false npmRebuild: false
electronLanguages: en electronLanguages: en
win: win:
+19 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "octo-launcher", "name": "octo-launcher",
"version": "1.0.18", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "octo-launcher", "name": "octo-launcher",
"version": "1.0.18", "version": "1.2.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^1.0.3", "@electron-toolkit/preload": "^1.0.3",
@@ -20,6 +20,7 @@
"adm-zip": "^0.5.17", "adm-zip": "^0.5.17",
"classnames": "^2.3.2", "classnames": "^2.3.2",
"dll-inject": "^0.0.3", "dll-inject": "^0.0.3",
"dompurify": "^3.4.11",
"electron-log": "^5.1.5", "electron-log": "^5.1.5",
"electron-trpc": "^0.5.2", "electron-trpc": "^0.5.2",
"electron-updater": "^5.3.0", "electron-updater": "^5.3.0",
@@ -1785,6 +1786,13 @@
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.5.tgz", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.5.tgz",
"integrity": "sha512-+d+WYC1BxJ6yVOgUgzK8gWvp5qF8ssV5r4nsDcZWKRWcDQLQ619tvWAxJQYGgBrO1MnLJC7a5GtiYsAoQ47dJg==" "integrity": "sha512-+d+WYC1BxJ6yVOgUgzK8gWvp5qF8ssV5r4nsDcZWKRWcDQLQ619tvWAxJQYGgBrO1MnLJC7a5GtiYsAoQ47dJg=="
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/verror": { "node_modules/@types/verror": {
"version": "1.10.9", "version": "1.10.9",
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.9.tgz", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.9.tgz",
@@ -3425,6 +3433,15 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/dompurify": {
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "9.0.2", "version": "9.0.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz",
+6 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "octo-launcher", "name": "octo-launcher",
"version": "1.0.27", "version": "1.2.0",
"description": "An Electron application for launching and updating the OctoWoW client", "description": "An Electron application for launching and updating the OctoWoW client",
"author": "OctoWoW", "author": "OctoWoW",
"copyright": "Copyright © 2026 OctoWoW", "copyright": "Copyright © 2026 OctoWoW",
@@ -12,8 +12,11 @@
"postinstall": "electron-builder install-app-deps && node scripts/scrub-native-paths.cjs", "postinstall": "electron-builder install-app-deps && node scripts/scrub-native-paths.cjs",
"build": "electron-vite build", "build": "electron-vite build",
"build:test": "electron-vite build --mode test", "build:test": "electron-vite build --mode test",
"build:ptr": "electron-vite build --mode ptr",
"pack": "electron-builder --config", "pack": "electron-builder --config",
"dist": "tsc && npm run build && npm run pack" "pack:ptr": "electron-builder --config electron-builder.ptr.yml",
"dist": "tsc && npm run build && npm run pack",
"dist:ptr": "tsc && npm run build:ptr && npm run pack:ptr"
}, },
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^1.0.3", "@electron-toolkit/preload": "^1.0.3",
@@ -27,6 +30,7 @@
"adm-zip": "^0.5.17", "adm-zip": "^0.5.17",
"classnames": "^2.3.2", "classnames": "^2.3.2",
"dll-inject": "^0.0.3", "dll-inject": "^0.0.3",
"dompurify": "^3.4.11",
"electron-log": "^5.1.5", "electron-log": "^5.1.5",
"electron-trpc": "^0.5.2", "electron-trpc": "^0.5.2",
"electron-updater": "^5.3.0", "electron-updater": "^5.3.0",
+27 -8
View File
@@ -107,7 +107,9 @@ const giteaProvider: Provider = {
stars: j.stars_count ?? undefined stars: j.stars_count ?? undefined
}), }),
tocUrl: (o, r, ref, name) => tocUrl: (o, r, ref, name) =>
`${GITEA_API}/repos/${o}/${r}/raw/${name}.toc?ref=${encodeURIComponent(ref)}` `${GITEA_API}/repos/${o}/${r}/raw/${name}.toc?ref=${encodeURIComponent(
ref
)}`
}; };
const parseGitUrl = ( const parseGitUrl = (
@@ -158,8 +160,14 @@ const resolveOne = async (src: AddonSource): Promise<ResolvedAddon | null> => {
const candidates = src.ref const candidates = src.ref
? [src.ref] ? [src.ref]
: src.branch : src.branch
? [src.branch] ? [src.branch]
: [...new Set([meta?.defaultBranch, 'master', 'main'].filter((b): b is string => !!b))]; : [
...new Set(
[meta?.defaultBranch, 'main', 'master'].filter(
(b): b is string => !!b
)
)
];
let toc: TocData | undefined; let toc: TocData | undefined;
let resolvedRef: string | undefined; let resolvedRef: string | undefined;
@@ -222,26 +230,37 @@ const loadSources = async (): Promise<AddonSource[]> => {
if (!SOURCES_OVERRIDE_PATH) return defaultSources; if (!SOURCES_OVERRIDE_PATH) return defaultSources;
try { try {
if (await fs.pathExists(SOURCES_OVERRIDE_PATH)) { if (await fs.pathExists(SOURCES_OVERRIDE_PATH)) {
const override = (await fs.readJSON(SOURCES_OVERRIDE_PATH)) as AddonSource[]; const override = (await fs.readJSON(
SOURCES_OVERRIDE_PATH
)) as AddonSource[];
if (Array.isArray(override) && override.length > 0) { if (Array.isArray(override) && override.length > 0) {
console.log(`Using addon sources override from ${SOURCES_OVERRIDE_PATH}`); console.log(
`Using addon sources override from ${SOURCES_OVERRIDE_PATH}`
);
return override; return override;
} }
} }
} catch (e) { } catch (e) {
console.error(`Failed to read override at ${SOURCES_OVERRIDE_PATH}, using defaults:`, e); console.error(
`Failed to read override at ${SOURCES_OVERRIDE_PATH}, using defaults:`,
e
);
} }
return defaultSources; return defaultSources;
}; };
const buildList = async (): Promise<ResolvedAddon[]> => { const buildList = async (): Promise<ResolvedAddon[]> => {
const sources = await loadSources(); const sources = await loadSources();
console.log(`Resolving metadata for ${sources.length} addons (concurrency=${FETCH_CONCURRENCY})...`); console.log(
`Resolving metadata for ${sources.length} addons (concurrency=${FETCH_CONCURRENCY})...`
);
const t0 = Date.now(); const t0 = Date.now();
const results = await poolMap(sources, FETCH_CONCURRENCY, resolveOne); const results = await poolMap(sources, FETCH_CONCURRENCY, resolveOne);
const ok = results.filter((r): r is ResolvedAddon => r !== null); const ok = results.filter((r): r is ResolvedAddon => r !== null);
ok.sort((a, b) => a.name.localeCompare(b.name)); ok.sort((a, b) => a.name.localeCompare(b.name));
console.log(`Resolved ${ok.length}/${sources.length} addons in ${Date.now() - t0}ms`); console.log(
`Resolved ${ok.length}/${sources.length} addons in ${Date.now() - t0}ms`
);
return ok; return ok;
}; };
+16 -27
View File
@@ -29,7 +29,7 @@ export const defaultSources: AddonSource[] = [
description: 'Automated "Looking For More" broadcaster for Turtle WoW dungeons and raids' description: 'Automated "Looking For More" broadcaster for Turtle WoW dungeons and raids'
}, },
{ {
git: 'https://github.com/OldManAlpha/aux-addon.git', git: 'https://github.com/shirsig/aux-addon-vanilla.git',
name: 'aux-addon', name: 'aux-addon',
description: 'Auction House replacement with advanced filtering and search' description: 'Auction House replacement with advanced filtering and search'
}, },
@@ -48,7 +48,7 @@ export const defaultSources: AddonSource[] = [
git: 'https://github.com/MDGitHubRepo/CallOfElements.git', git: 'https://github.com/MDGitHubRepo/CallOfElements.git',
description: 'All-in-one Shaman totem bar and totem/healing manager' description: 'All-in-one Shaman totem bar and totem/healing manager'
}, },
{ git: 'https://github.com/cutiepoka/CleveRoidMacros.git' }, { git: 'https://github.com/bhhandley/CleveRoidMacros.git' },
{ {
git: 'https://github.com/Cinecom/ConsumesManager.git', git: 'https://github.com/Cinecom/ConsumesManager.git',
description: 'Tracks consumables and food buffs across alts, bank, and mail' description: 'Tracks consumables and food buffs across alts, bank, and mail'
@@ -66,7 +66,7 @@ export const defaultSources: AddonSource[] = [
git: 'https://github.com/DeterminedPanda/DifficultBulletinBoard.git', git: 'https://github.com/DeterminedPanda/DifficultBulletinBoard.git',
description: 'Organizes LFG, profession, and hardcore chat announcements into a bulletin board' description: 'Organizes LFG, profession, and hardcore chat announcements into a bulletin board'
}, },
{ git: 'https://github.com/pepopo978/DoiteAuras.git' }, { git: 'https://github.com/Player-Doite/DoiteAuras.git' },
{ git: 'https://github.com/Stormhand-dev/DragonflightUI-Reforged.git' }, { git: 'https://github.com/Stormhand-dev/DragonflightUI-Reforged.git' },
{ {
git: 'https://github.com/Fiurs-Hearth/ExtraResourceBars.git', git: 'https://github.com/Fiurs-Hearth/ExtraResourceBars.git',
@@ -98,14 +98,14 @@ export const defaultSources: AddonSource[] = [
description: 'Item set manager with quick-swap menus for inventory' description: 'Item set manager with quick-swap menus for inventory'
}, },
{ {
git: 'https://github.com/Otari98/_LazyPig.git', git: 'https://github.com/CosminPOP/_LazyPig.git',
name: '_LazyPig', name: '_LazyPig',
description: 'Auto-dismount, auto-accept, auto-roll, and chat spam filter. /lp to configure' description: 'Auto-dismount, auto-accept, auto-roll, and chat spam filter. /lp to configure'
}, },
{ git: 'https://github.com/Dusk-92/LevelRange-Octo.git' }, { git: 'https://github.com/Spartelfant/LevelRange-Turtle.git' },
{ git: 'https://github.com/tilare/MessageBox.git' }, { git: 'https://github.com/tilare/MessageBox.git' },
{ {
git: 'https://github.com/MarcelineVQ/ModifiedPowerAuras.git', git: 'https://github.com/tdymel/ModifiedPowerAuras.git',
description: "Advanced version of Sinesther's Power Auras" description: "Advanced version of Sinesther's Power Auras"
}, },
{ {
@@ -118,7 +118,7 @@ export const defaultSources: AddonSource[] = [
}, },
{ git: 'https://github.com/tilare/MovementTracker.git' }, { git: 'https://github.com/tilare/MovementTracker.git' },
{ {
git: 'https://github.com/Emyrk/NampowerSettings.git', git: 'https://github.com/Dusk-92/NampowerSettings.git',
description: 'Settings panel for the Nampower spellqueue addon' description: 'Settings panel for the Nampower spellqueue addon'
}, },
{ {
@@ -140,7 +140,7 @@ export const defaultSources: AddonSource[] = [
description: 'Equipment set manager to save and quickly swap gear outfits, with Turtle mount fixes' description: 'Equipment set manager to save and quickly swap gear outfits, with Turtle mount fixes'
}, },
{ {
git: 'https://github.com/ShikawaLePaladin/PallyPowerTW.git', git: 'https://github.com/CosminPOP/PallyPower.git',
description: 'Paladin buff and assignment manager for raids and parties' description: 'Paladin buff and assignment manager for raids and parties'
}, },
{ {
@@ -148,7 +148,7 @@ export const defaultSources: AddonSource[] = [
description: 'pfQuest extension showing all monster drops and quest chains. /pfex' description: 'pfQuest extension showing all monster drops and quest chains. /pfex'
}, },
{ git: 'https://github.com/shagu/pfQuest.git' }, { git: 'https://github.com/shagu/pfQuest.git' },
{ git: 'https://github.com/KameleonUK/pfQuest-turtle.git' }, { git: 'https://github.com/shagu/pfQuest-turtle.git' },
{ git: 'https://github.com/shagu/pfUI.git' }, { git: 'https://github.com/shagu/pfUI.git' },
{ {
git: 'https://github.com/jrc13245/pfUI-addonskinner.git', git: 'https://github.com/jrc13245/pfUI-addonskinner.git',
@@ -170,7 +170,7 @@ export const defaultSources: AddonSource[] = [
}, },
{ git: 'https://github.com/SabineWren/Quiver.git' }, { git: 'https://github.com/SabineWren/Quiver.git' },
{ {
git: 'https://github.com/thezephyrsong/Rested.git', git: 'https://github.com/hazlema/Rested.git',
description: 'Progress bar showing your rested XP while resting' description: 'Progress bar showing your rested XP while resting'
}, },
{ git: 'https://github.com/Otari98/Rinse.git' }, { git: 'https://github.com/Otari98/Rinse.git' },
@@ -183,9 +183,9 @@ export const defaultSources: AddonSource[] = [
git: 'https://github.com/shagu/ShaguPlates.git', git: 'https://github.com/shagu/ShaguPlates.git',
description: 'Nameplates with castbars and class colors. /splates' description: 'Nameplates with castbars and class colors. /splates'
}, },
{ git: 'https://github.com/paokkerkir/ShaguTweaks.git' }, { git: 'https://github.com/shagu/ShaguTweaks.git' },
{ {
git: 'https://github.com/paokkerkir/ShaguTweaks-extras.git', git: 'https://github.com/shagu/ShaguTweaks-extras.git',
description: 'Extras module for ShaguTweaks (additional UI tweaks)' description: 'Extras module for ShaguTweaks (additional UI tweaks)'
}, },
{ git: 'https://github.com/pepopo978/SimpleActionSets.git' }, { git: 'https://github.com/pepopo978/SimpleActionSets.git' },
@@ -231,8 +231,9 @@ export const defaultSources: AddonSource[] = [
description: 'Smart consolidated buff frames with extensive customization' description: 'Smart consolidated buff frames with extensive customization'
}, },
{ {
git: 'https://github.com/acolis/VitalWatch.git', git: 'https://octowow.st/git/shaga/LifeSafer_LowHealthWarning.git',
description: 'Customizable health, mana, and aggro alert system for solo, group, and raid play' branch: 'main',
description: 'Low health and mana fullscreen flash warnings with heartbeat sound; re-enables the hidden Blizzard alert effect'
}, },
{ {
git: 'https://github.com/Fiurs-Hearth/WIIIUI.git', git: 'https://github.com/Fiurs-Hearth/WIIIUI.git',
@@ -242,17 +243,5 @@ export const defaultSources: AddonSource[] = [
{ {
git: 'https://github.com/Arthur-Helias/ZonesLevel.git', git: 'https://github.com/Arthur-Helias/ZonesLevel.git',
description: "Shows zone level range under the title on the world map" description: "Shows zone level range under the title on the world map"
}, }
{
git: 'https://github.com/devteabct78/QuestTranslator-Vanilla-Turkish.git',
description: 'Translates quests into Turkish for the OctoWow Vanilla WoW private server'
},
{
git: 'https://github.com/devteabct78/ZoneTranslator-Vanilla-Turkish.git',
description: 'Translates zone and location names into Turkish for the OctoWow Vanilla WoW private server'
},
{
git: 'https://github.com/devteabct78/MobNpcTranslator-Vanilla-Turkish.git',
description: 'Translates Mob and NPC names into Turkish for the OctoWow Vanilla WoW private server'
}
]; ];
+19 -14
View File
@@ -24,9 +24,13 @@ const skipFiles = new Set([
'.manifest-overrides.json' '.manifest-overrides.json'
]); ]);
const skipPatterns: RegExp[] = [/\.bak(\.|$)/, /\.crashing(\.|$)/]; const skipPatterns: RegExp[] = [
const isSkipPattern = (file: string) => /\.bak([.\-]|$)/,
skipPatterns.some(p => p.test(file)); /\.crashing(\.|$)/,
/\.torrent$/,
/^manifest\.json\./
];
const isSkipPattern = (file: string) => skipPatterns.some(p => p.test(file));
const skipDirsPosix = new Set([ const skipDirsPosix = new Set([
'Interface/GlueXML', 'Interface/GlueXML',
@@ -66,7 +70,9 @@ export type BuildProgress = {
error: string | null; error: string | null;
}; };
export type ProgressCallback = (p: Pick<BuildProgress, 'done' | 'total' | 'currentFile'>) => void; export type ProgressCallback = (
p: Pick<BuildProgress, 'done' | 'total' | 'currentFile'>
) => void;
const getHash = (...filePath: string[]): Promise<string> => const getHash = (...filePath: string[]): Promise<string> =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
@@ -77,7 +83,10 @@ const getHash = (...filePath: string[]): Promise<string> =>
stream.on('end', () => resolve(hash.digest('hex').toLocaleUpperCase())); stream.on('end', () => resolve(hash.digest('hex').toLocaleUpperCase()));
}); });
const countFiles = async (clientPath: string, ...filePath: string[]): Promise<number> => { const countFiles = async (
clientPath: string,
...filePath: string[]
): Promise<number> => {
let total = 0; let total = 0;
const dir = path.join(clientPath, ...filePath); const dir = path.join(clientPath, ...filePath);
const files = await fs.readdir(dir); const files = await fs.readdir(dir);
@@ -118,8 +127,9 @@ export const buildCache = async (
if (node.type === 'dir' || node.type === 'mpq') { if (node.type === 'dir' || node.type === 'mpq') {
const newPrefix = node.name ? [...prefix, node.name] : prefix; const newPrefix = node.name ? [...prefix, node.name] : prefix;
if (node.type === 'mpq') { if (node.type === 'mpq') {
const mpqKey = [...newPrefix.slice(0, -1), node.name + '.mpq'] const mpqKey = [...newPrefix.slice(0, -1), node.name + '.mpq'].join(
.join('/'); '/'
);
prevHashByPath.set(mpqKey, node.hash); prevHashByPath.set(mpqKey, node.hash);
prevSizeByPath.set(mpqKey, node.size); prevSizeByPath.set(mpqKey, node.size);
} }
@@ -237,12 +247,7 @@ export const buildCache = async (
tree.push({ tree.push({
type: 'file', type: 'file',
name: file, name: file,
hash: await getHashCached( hash: await getHashCached(fullPath, stats.mtimeMs, ...filePath, file),
fullPath,
stats.mtimeMs,
...filePath,
file
),
version: allowModified ? stats.mtimeMs : undefined, version: allowModified ? stats.mtimeMs : undefined,
size: stats.size, size: stats.size,
tags: tags.length ? tags : undefined tags: tags.length ? tags : undefined
@@ -307,7 +312,7 @@ export const buildCache = async (
} }
} catch (e) { } catch (e) {
console.warn( console.warn(
`manifest-overrides: failed to apply ${overridesPath} -- continuing ` + `manifest-overrides: failed to apply ${overridesPath}, continuing` +
`without overrides (${(e as Error).message})` `without overrides (${(e as Error).message})`
); );
} }
+16 -8
View File
@@ -5,7 +5,7 @@ import fs from 'fs-extra';
import { buildCache, type BuildProgress } from './cache.js'; import { buildCache, type BuildProgress } from './cache.js';
import { getAddons, warmUp as warmUpAddons } from './addons-resolver.js'; import { getAddons, warmUp as warmUpAddons } from './addons-resolver.js';
const SourceDir = process.env.SOURCE_DIR || 'C:\\WoW\\TurtleFresh'; const SourceDir = process.env.SOURCE_DIR || './client';
const app = express(); const app = express();
const port = 5000; const port = 5000;
@@ -62,8 +62,7 @@ app.get('/api/file/:version/manifest.json', async (_req, res) => {
return; return;
} }
void ensureManifestBuilt().catch(() => { void ensureManifestBuilt().catch(() => {});
});
res.setHeader('Retry-After', '5'); res.setHeader('Retry-After', '5');
res.status(503).json({ res.status(503).json({
error: 'manifest_building', error: 'manifest_building',
@@ -80,7 +79,14 @@ app.get(
const filePath = req.params[0]; const filePath = req.params[0];
console.log(`Fetching file: ${filePath}`); console.log(`Fetching file: ${filePath}`);
res.sendFile(path.join(SourceDir, filePath)); const root = path.resolve(SourceDir);
const target = path.resolve(SourceDir, filePath);
if (target !== root && !target.startsWith(root + path.sep)) {
res.status(403).end();
return;
}
res.sendFile(target);
} }
); );
@@ -140,13 +146,15 @@ app.listen(port, () => {
const newest = await newestSourceMtime(SourceDir); const newest = await newestSourceMtime(SourceDir);
if (newest > manifestStat.mtimeMs) { if (newest > manifestStat.mtimeMs) {
console.log( console.log(
`Manifest is stale (newest source mtime ${new Date(newest).toISOString()} > manifest ${new Date(manifestStat.mtimeMs).toISOString()}); rebuilding in background.` `Manifest is stale (newest source mtime ${new Date(
newest
).toISOString()} > manifest ${new Date(
manifestStat.mtimeMs
).toISOString()}); rebuilding in background.`
); );
ensureManifestBuilt() ensureManifestBuilt()
.then(() => console.log('Background manifest rebuild complete.')) .then(() => console.log('Background manifest rebuild complete.'))
.catch(e => .catch(e => console.error('Background manifest rebuild failed:', e));
console.error('Background manifest rebuild failed:', e)
);
} else { } else {
console.log( console.log(
`Manifest cache already on disk at ${manifestPath} and up to date; no rebuild needed.` `Manifest cache already on disk at ${manifestPath} and up to date; no rebuild needed.`
+4 -5
View File
@@ -15,8 +15,6 @@ export type ModSource =
| { | {
kind: 'directFile'; kind: 'directFile';
url: string; url: string;
versionUrl?: string;
latestVersionUrl?: string;
parseLatest?: 'githubRelease' | 'gitlabRelease' | 'codebergRelease'; parseLatest?: 'githubRelease' | 'gitlabRelease' | 'codebergRelease';
apiUrl?: string; apiUrl?: string;
pinnedTag?: string; pinnedTag?: string;
@@ -25,7 +23,6 @@ export type ModSource =
| { | {
kind: 'archive'; kind: 'archive';
url: string; url: string;
latestVersionUrl?: string;
apiUrl?: string; apiUrl?: string;
parseLatest?: 'githubRelease' | 'gitlabRelease' | 'codebergRelease'; parseLatest?: 'githubRelease' | 'gitlabRelease' | 'codebergRelease';
pinnedTag?: string; pinnedTag?: string;
@@ -139,7 +136,8 @@ export const MODS: ModEntry[] = [
id: 'vanillaFixes', id: 'vanillaFixes',
name: 'vanillaFixes', name: 'vanillaFixes',
version: 'v1.5.3', version: 'v1.5.3',
description: 'A client modification that eliminates stutter and animation lag.', description:
'A client modification that eliminates stutter and animation lag.',
recommended: true, recommended: true,
repoUrl: 'https://github.com/hannesmann/vanillafixes', repoUrl: 'https://github.com/hannesmann/vanillafixes',
source: { source: {
@@ -160,7 +158,8 @@ export const MODS: ModEntry[] = [
id: 'vanillaHelpers', id: 'vanillaHelpers',
name: 'vanillaHelpers', name: 'vanillaHelpers',
version: 'v1.1.2', version: 'v1.1.2',
description: 'Utility library that might be required by other patches and addons.', description:
'Utility library that might be required by other patches and addons.',
repoUrl: 'https://github.com/isfir/VanillaHelpers', repoUrl: 'https://github.com/isfir/VanillaHelpers',
requires: ['vanillaFixes'], requires: ['vanillaFixes'],
source: { source: {
+32 -3
View File
@@ -36,6 +36,18 @@ export const ModStateSchema = z.object({
}); });
export type ModState = z.infer<typeof ModStateSchema>; export type ModState = z.infer<typeof ModStateSchema>;
export const HardwareInfoSchema = z.object({
totalRamMb: z.number(),
cpuCores: z.number(),
cpuModel: z.string(),
gpuModel: z.string(),
vramMb: z.number().nullable(),
vramSource: z.enum(['registry', 'wmi', 'none']),
detectedAt: z.string(),
schemaVersion: z.number()
});
export type HardwareInfo = z.infer<typeof HardwareInfoSchema>;
export const PreferencesSchema = z.object({ export const PreferencesSchema = z.object({
isPortable: z.boolean().optional(), isPortable: z.boolean().optional(),
server: z.enum(['live', 'ptr']).default('live'), server: z.enum(['live', 'ptr']).default('live'),
@@ -44,7 +56,12 @@ export const PreferencesSchema = z.object({
lastPatchedLauncherVersion: z.string().optional(), lastPatchedLauncherVersion: z.string().optional(),
expectedPatchedWowHash: z.string().optional(), expectedPatchedWowHash: z.string().optional(),
minimizeToTrayOnPlay: f.boolean(true), minimizeToTrayOnPlay: f.boolean(true),
cleanWdb: f.boolean(), cleanWdb: f.boolean(true),
locale: z
.enum(['enUS', 'deDE', 'zhCN', 'esES', 'ptBR', 'ruRU'])
.default('enUS'),
localePatchLetter: z.string().optional(),
localePatchLocale: z.string().optional(),
rememberPosition: f.boolean(), rememberPosition: f.boolean(),
windowPosition: z windowPosition: z
.object({ .object({
@@ -55,7 +72,9 @@ export const PreferencesSchema = z.object({
}) })
.nullish(), .nullish(),
config: ConfigWtfSchema.default({}), config: ConfigWtfSchema.default({}),
mods: z.record(ModStateSchema).default({}) mods: z.record(ModStateSchema).default({}),
hardware: HardwareInfoSchema.optional(),
farClipUserSet: z.boolean().optional()
}); });
export type PreferencesSchema = z.infer<typeof PreferencesSchema>; export type PreferencesSchema = z.infer<typeof PreferencesSchema>;
@@ -100,7 +119,7 @@ export const NewsItemSchema = z.object({
date: z.string(), date: z.string(),
body: z.string(), body: z.string(),
url: z.string().url().optional(), url: z.string().url().optional(),
author: z.string().optional() author: z.string().nullish()
}); });
export type NewsItem = z.infer<typeof NewsItemSchema>; export type NewsItem = z.infer<typeof NewsItemSchema>;
@@ -108,3 +127,13 @@ export const NewsFeedSchema = z.object({
items: z.array(NewsItemSchema) items: z.array(NewsItemSchema)
}); });
export type NewsFeed = z.infer<typeof NewsFeedSchema>; export type NewsFeed = z.infer<typeof NewsFeedSchema>;
export const ForumAnnouncementSchema = z.object({
id: z.string(),
title: z.string(),
author: z.string().nullish(),
date: z.string(),
url: z.string().url(),
html: z.string()
});
export type ForumAnnouncement = z.infer<typeof ForumAnnouncementSchema>;
+9 -1
View File
@@ -1,10 +1,18 @@
type Path = readonly (string | number)[]; type Path = readonly (string | number)[];
const isUnsafeKey = (key: string | number) =>
key === '__proto__' || key === 'constructor' || key === 'prototype';
export const nestedGet = <T>(object: unknown, path: Path) => export const nestedGet = <T>(object: unknown, path: Path) =>
path.reduce((obj, key) => obj?.[key], object) as T; path.reduce(
(obj, key) => (isUnsafeKey(key) ? undefined : obj?.[key]),
object
) as T;
export const nestedSet = (obj: any, path: Path, value: unknown) => { export const nestedSet = (obj: any, path: Path, value: unknown) => {
const [key, ...rest] = path; const [key, ...rest] = path;
if (isUnsafeKey(key)) return;
if (path.length === 1) { if (path.length === 1) {
obj[key] = value; obj[key] = value;
return; return;
+2
View File
@@ -6,6 +6,7 @@ import { patcherRouter } from './routers/patcher';
import { generalRouter } from './routers/general'; import { generalRouter } from './routers/general';
import { preferencesRouter } from './routers/preferences'; import { preferencesRouter } from './routers/preferences';
import { newsRouter } from './routers/news'; import { newsRouter } from './routers/news';
import { forumRouter } from './routers/forum';
import { modsRouter } from './routers/mods'; import { modsRouter } from './routers/mods';
import { selfUpdaterRouter } from './routers/selfUpdater'; import { selfUpdaterRouter } from './routers/selfUpdater';
@@ -17,6 +18,7 @@ export const appRouter = createTRPCRouter({
patcher: patcherRouter, patcher: patcherRouter,
updater: updaterRouter, updater: updaterRouter,
news: newsRouter, news: newsRouter,
forum: forumRouter,
mods: modsRouter, mods: modsRouter,
selfUpdater: selfUpdaterRouter selfUpdater: selfUpdaterRouter
}); });
+44
View File
@@ -0,0 +1,44 @@
import fetch from 'node-fetch';
import Logger from 'electron-log/main';
import { ForumAnnouncementSchema, type ForumAnnouncement } from '~common/schemas';
import { createTRPCRouter, publicProcedure } from '../trpc';
const FETCH_TIMEOUT_MS = 8_000;
const fetchLatestAnnouncement = async (): Promise<ForumAnnouncement | null> => {
const url = `${
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
}/forum/octonews.php?forum=35&mode=full`;
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw Error(`HTTP ${res.status}`);
const json = (await res.json()) as unknown;
if (!json || typeof json !== 'object' || !('id' in json)) return null;
const parsed = ForumAnnouncementSchema.safeParse(json);
if (!parsed.success) {
Logger.error(
'Forum announcement failed schema validation',
parsed.error.flatten()
);
throw Error('Malformed forum announcement');
}
return parsed.data;
} finally {
clearTimeout(t);
}
};
export const forumRouter = createTRPCRouter({
latestAnnouncement: publicProcedure.query(async () => {
try {
return await fetchLatestAnnouncement();
} catch (e) {
Logger.error('Failed to fetch forum announcement', e);
throw e;
}
})
});
+12
View File
@@ -4,11 +4,22 @@ import { z } from 'zod';
import { mainWindow } from '~main/index'; import { mainWindow } from '~main/index';
import Preferences from '~main/modules/preferences'; import Preferences from '~main/modules/preferences';
import { addDefenderExclusions } from '~main/modules/defender';
import { detectHardware, recommendFarClip } from '~main/modules/hardware';
import { createTRPCRouter, publicProcedure } from '../trpc'; import { createTRPCRouter, publicProcedure } from '../trpc';
export const generalRouter = createTRPCRouter({ export const generalRouter = createTRPCRouter({
appVersion: publicProcedure.query(() => app.getVersion()), appVersion: publicProcedure.query(() => app.getVersion()),
hardware: publicProcedure.query(() => {
const hardware = Preferences.data.hardware ?? null;
return { hardware, recommendedFarClip: recommendFarClip(hardware) };
}),
redetectHardware: publicProcedure.mutation(async () => {
const hardware = await detectHardware();
Preferences.data = { hardware };
return { hardware, recommendedFarClip: recommendFarClip(hardware) };
}),
quit: publicProcedure.mutation(() => app.quit()), quit: publicProcedure.mutation(() => app.quit()),
minimize: publicProcedure.mutation(() => mainWindow?.minimize()), minimize: publicProcedure.mutation(() => mainWindow?.minimize()),
openLink: publicProcedure openLink: publicProcedure
@@ -22,6 +33,7 @@ export const generalRouter = createTRPCRouter({
const file = Logger.transports.file.getFile().path; const file = Logger.transports.file.getFile().path;
shell.openPath(file); shell.openPath(file);
}), }),
addDefenderExclusion: publicProcedure.mutation(() => addDefenderExclusions()),
filePicker: publicProcedure filePicker: publicProcedure
.input( .input(
z.object({ z.object({
+63 -58
View File
@@ -2,7 +2,6 @@ import path from 'path';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import fs from 'fs-extra'; import fs from 'fs-extra';
import { inject } from 'dll-inject';
import Logger from 'electron-log/main'; import Logger from 'electron-log/main';
import Preferences from '~main/modules/preferences'; import Preferences from '~main/modules/preferences';
@@ -10,95 +9,101 @@ import Mods from '~main/modules/mods';
import { mainWindow } from '~main/index'; import { mainWindow } from '~main/index';
import { isGameRunning } from '~main/modules/updater'; import { isGameRunning } from '~main/modules/updater';
import { patchConfig } from '~main/modules/patcher'; import { patchConfig } from '~main/modules/patcher';
import { applyLocalePatch } from '~main/modules/localePatch';
import { minimizeToTray, restoreFromTray } from '~main/modules/tray'; import { minimizeToTray, restoreFromTray } from '~main/modules/tray';
import { getMod } from '~common/mods'; import { getMod } from '~common/mods';
import { createTRPCRouter, publicProcedure } from '../trpc'; import { createTRPCRouter, publicProcedure } from '../trpc';
const ensureChainloaderTweak = async (clientDir: string): Promise<boolean> => { const chainloaderNeeded = async (clientDir: string): Promise<boolean> => {
if (Preferences.data.config.vanillaFixes) return true; const installed = Mods.status.mods.filter(r => r.installedVersion);
if (installed.some(r => r.id === 'vanillaFixes')) return true;
if (installed.some(r => getMod(r.id)?.requires?.includes('vanillaFixes')))
return true;
const installedMods = Mods.status.mods.filter(r => r.installedVersion);
const anyDependsOnVf = installedMods.some(r =>
getMod(r.id)?.requires?.includes('vanillaFixes')
);
let dllsTxtHasEntries = false;
const dllsPath = path.join(clientDir, 'dlls.txt'); const dllsPath = path.join(clientDir, 'dlls.txt');
if (await fs.pathExists(dllsPath)) { if (await fs.pathExists(dllsPath)) {
const raw = await fs.readFile(dllsPath, 'utf8'); const raw = await fs.readFile(dllsPath, 'utf8');
dllsTxtHasEntries = raw return raw.split(/\r?\n/).some(l => l.trim() && !l.trim().startsWith('#'));
.split(/\r?\n/)
.some(l => l.trim() && !l.trim().startsWith('#'));
} }
return false;
if (!anyDependsOnVf && !dllsTxtHasEntries) return false;
Logger.info(
`Auto-enabling vanillaFixes Tweak (chainloader required): ${
anyDependsOnVf ? 'a dependent mod is installed' : ''
}${anyDependsOnVf && dllsTxtHasEntries ? ' + ' : ''}${
dllsTxtHasEntries ? 'dlls.txt has user entries' : ''
}.`
);
Preferences.data = {
config: { ...Preferences.data.config, vanillaFixes: true }
};
return true;
}; };
export const launcherRouter = createTRPCRouter({ type StartResult = { ok: boolean; error?: string };
start: publicProcedure.mutation(async () => {
const { cleanWdb, minimizeToTrayOnPlay, config, clientDir } =
Preferences.data;
if (!clientDir) return false;
const clientPath = path.join(clientDir, 'WoW.exe'); export const launcherRouter = createTRPCRouter({
Logger.log(`Launching ${clientPath}...`); start: publicProcedure.mutation(async (): Promise<StartResult> => {
if (await isGameRunning(clientPath)) return false; const { cleanWdb, minimizeToTrayOnPlay, clientDir } = Preferences.data;
if (!clientDir) return { ok: false, error: 'No game folder is set.' };
const exePath = path.join(clientDir, 'WoW.exe');
if (!(await fs.pathExists(exePath)))
return { ok: false, error: 'WoW.exe was not found in the game folder.' };
if (await isGameRunning(exePath))
return { ok: false, error: 'WoW is already running.' };
if (cleanWdb) { if (cleanWdb) {
Logger.log('Cleaning up WDB...'); Logger.log('Cleaning up WDB...');
await fs.remove(path.join(clientPath, 'WDB')); await fs.remove(path.join(clientDir, 'WDB'));
} }
Logger.log('Checking Config.wtf...'); Logger.log('Checking Config.wtf...');
await patchConfig(); await patchConfig();
Logger.log('Launching WoW...'); Logger.log('Applying UI language...');
const process = spawn(clientPath, { detached: !minimizeToTrayOnPlay }); await applyLocalePatch(clientDir, Preferences.data.locale);
const wantChainloader = await ensureChainloaderTweak(clientDir); const loaderPath = path.join(clientDir, 'VanillaFixes.exe');
if (wantChainloader) { const needsLoader = await chainloaderNeeded(clientDir);
Logger.log('Injecting VanillaFixes...'); const useLoader = needsLoader && (await fs.pathExists(loaderPath));
const vfPath = path.join(clientDir, 'VfPatcher.dll'); if (needsLoader && !useLoader)
Logger.warn(
'VanillaFixes.exe is missing but mods/dlls.txt expect a chainloader; ' +
'launching WoW.exe directly (mods will not load).'
);
if (!(await fs.pathExists(vfPath))) { const octoLocale = Preferences.data.locale || 'enUS';
Logger.warn( const gameEnv = { ...process.env, OCTO_LOCALE: octoLocale };
`VfPatcher.dll missing at ${vfPath} — chainloader needed but ` + Logger.log(
'the vanillaFixes mod is not installed. Skipping inject; ' + useLoader
'dlls.txt entries and dependent mods will not load. Install ' + ? `Launching via VanillaFixes (OCTO_LOCALE=${octoLocale})...`
"vanillaFixes from the Mods tab to fix." : `Launching ${exePath} (OCTO_LOCALE=${octoLocale})...`
); );
} else { const child = useLoader
const status = inject('WoW.exe', vfPath); ? spawn(loaderPath, ['WoW.exe'], {
if (status) { env: gameEnv,
Logger.error(`Injecting failed with error code ${status}...`); cwd: clientDir,
return true; detached: !minimizeToTrayOnPlay
} })
} : spawn(exePath, {
env: gameEnv,
cwd: clientDir,
detached: !minimizeToTrayOnPlay
});
try {
await new Promise<void>((resolve, reject) => {
child.once('spawn', resolve);
child.once('error', reject);
});
} catch (e) {
Logger.error('Failed to launch the game', e);
const message = e instanceof Error ? e.message : String(e);
return { ok: false, error: `Failed to launch the game: ${message}` };
} }
child.on('error', e => Logger.error('Game process error', e));
if (!minimizeToTrayOnPlay) { if (!minimizeToTrayOnPlay) {
mainWindow?.close(); mainWindow?.close();
return true; return { ok: true };
} }
minimizeToTray(); minimizeToTray();
process.on('exit', () => { child.on('exit', () => {
Logger.log('WoW stopped'); Logger.log('WoW stopped');
restoreFromTray(); restoreFromTray();
}); });
return true; return { ok: true };
}) })
}); });
+13
View File
@@ -1,6 +1,10 @@
import path from 'path';
import { z } from 'zod'; import { z } from 'zod';
import Mods from '~main/modules/mods'; import Mods from '~main/modules/mods';
import Preferences from '~main/modules/preferences';
import { isGameRunning } from '~main/modules/updater';
import { ModIdSchema } from '~common/mods'; import { ModIdSchema } from '~common/mods';
import { createTRPCRouter, publicProcedure } from '../trpc'; import { createTRPCRouter, publicProcedure } from '../trpc';
@@ -15,5 +19,14 @@ export const modsRouter = createTRPCRouter({
.input(z.object({ id: ModIdSchema, ignore: z.boolean() })) .input(z.object({ id: ModIdSchema, ignore: z.boolean() }))
.mutation(({ input }) => Mods.setIgnoreUpdates(input.id, input.ignore)), .mutation(({ input }) => Mods.setIgnoreUpdates(input.id, input.ignore)),
applyAll: publicProcedure.mutation(() => Mods.applyAll()), applyAll: publicProcedure.mutation(() => Mods.applyAll()),
repair: publicProcedure.mutation(async () => {
const clientDir = Preferences.data?.clientDir;
if (clientDir) {
const exePath = path.join(clientDir, 'WoW.exe');
if (await isGameRunning(exePath))
throw new Error('Please close WoW first before verifying files.');
}
return Mods.applyAll({ repairOnly: true });
}),
observe: publicProcedure.subscription(() => Mods.observe()) observe: publicProcedure.subscription(() => Mods.observe())
}); });
+3 -1
View File
@@ -8,7 +8,9 @@ import { createTRPCRouter, publicProcedure } from '../trpc';
const FETCH_TIMEOUT_MS = 8_000; const FETCH_TIMEOUT_MS = 8_000;
const fetchNews = async (): Promise<NewsItem[]> => { const fetchNews = async (): Promise<NewsItem[]> => {
const url = `${import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'}/news.json`; const url = `${
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
}/forum/octonews.php?mode=list&forum=2&limit=3`;
const controller = new AbortController(); const controller = new AbortController();
const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try { try {
+3 -1
View File
@@ -1,5 +1,6 @@
import { patchConfig, patchExecutable } from '~main/modules/patcher'; import { patchConfig, patchExecutable } from '~main/modules/patcher';
import Preferences from '~main/modules/preferences'; import Preferences from '~main/modules/preferences';
import Updater from '~main/modules/updater';
import { getClientVersion } from '~main/utils'; import { getClientVersion } from '~main/utils';
import { createTRPCRouter, publicProcedure } from '../trpc'; import { createTRPCRouter, publicProcedure } from '../trpc';
@@ -7,7 +8,8 @@ import { createTRPCRouter, publicProcedure } from '../trpc';
export const patcherRouter = createTRPCRouter({ export const patcherRouter = createTRPCRouter({
apply: publicProcedure.mutation(async () => { apply: publicProcedure.mutation(async () => {
await patchExecutable(); await patchExecutable();
await patchConfig(); await patchConfig(true);
await Updater.recordPatchedWow();
Preferences.data = { version: await getClientVersion() }; Preferences.data = { version: await getClientVersion() };
}) })
}); });
+3
View File
@@ -2,6 +2,7 @@ import { z } from 'zod';
import { PreferencesSchema } from '~common/schemas'; import { PreferencesSchema } from '~common/schemas';
import Preferences from '~main/modules/preferences'; import Preferences from '~main/modules/preferences';
import { applyLocalePatch } from '~main/modules/localePatch';
import { createTRPCRouter, publicProcedure } from '../trpc'; import { createTRPCRouter, publicProcedure } from '../trpc';
@@ -11,6 +12,8 @@ export const preferencesRouter = createTRPCRouter({
.input(PreferencesSchema.partial()) .input(PreferencesSchema.partial())
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
Preferences.data = input; Preferences.data = input;
if (input.locale !== undefined)
await applyLocalePatch(Preferences.data.clientDir, input.locale);
return Preferences.data; return Preferences.data;
}), }),
isValidClientDir: publicProcedure isValidClientDir: publicProcedure
+114 -24
View File
@@ -1,6 +1,6 @@
import { join } from 'path'; import { join } from 'path';
import { app, shell, BrowserWindow } from 'electron'; import { app, shell, session, BrowserWindow, screen } from 'electron';
import { electronApp, optimizer, is } from '@electron-toolkit/utils'; import { electronApp, optimizer, is } from '@electron-toolkit/utils';
import { createIPCHandler } from 'electron-trpc/main'; import { createIPCHandler } from 'electron-trpc/main';
import Logger from 'electron-log/main'; import Logger from 'electron-log/main';
@@ -13,6 +13,11 @@ import Updater from './modules/updater';
import Addons from './modules/addons'; import Addons from './modules/addons';
import Mods from './modules/mods'; import Mods from './modules/mods';
import { initSelfUpdater } from './modules/selfUpdater'; import { initSelfUpdater } from './modules/selfUpdater';
import {
detectHardware,
recommendFarClip,
HARDWARE_SCHEMA_VERSION
} from './modules/hardware';
Logger.initialize(); Logger.initialize();
Logger.errorHandler.startCatching(); Logger.errorHandler.startCatching();
@@ -22,10 +27,33 @@ app.disableHardwareAcceleration();
export let mainWindow: BrowserWindow | null = null; export let mainWindow: BrowserWindow | null = null;
const isOnScreen = (
pos?: {
x: number;
y: number;
width: number;
height: number;
} | null
) => {
if (!pos) return false;
return screen.getAllDisplays().some(d => {
const a = d.workArea;
return (
pos.x < a.x + a.width &&
pos.x + pos.width > a.x &&
pos.y < a.y + a.height &&
pos.y + pos.height > a.y
);
});
};
const createWindow = async () => { const createWindow = async () => {
const position = Preferences.data.rememberPosition const saved =
? Preferences.data.windowPosition Preferences.data.rememberPosition &&
: { width: 1000, height: 700 }; isOnScreen(Preferences.data.windowPosition)
? Preferences.data.windowPosition
: undefined;
const position = saved ?? { width: 1000, height: 700 };
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
...position, ...position,
@@ -50,16 +78,22 @@ const createWindow = async () => {
Logger.error('Renderer unresponsive'); Logger.error('Renderer unresponsive');
}); });
mainWindow.webContents.on('console-message', (_e, level, message, line, sourceId) => { mainWindow.webContents.on(
const lvl = level === 3 ? 'error' : level === 2 ? 'warn' : 'info'; 'console-message',
Logger[lvl](`[renderer:${lvl}] ${message} (${sourceId}:${line})`); (_e, level, message, line, sourceId) => {
}); const lvl = level === 3 ? 'error' : level === 2 ? 'warn' : 'info';
Logger[lvl](`[renderer:${lvl}] ${message} (${sourceId}:${line})`);
}
);
mainWindow.webContents.on('before-input-event', (_e, input) => { mainWindow.webContents.on('before-input-event', (_e, input) => {
if (input.type !== 'keyDown') return; if (input.type !== 'keyDown') return;
if (input.key === 'F12') { if (input.key === 'F12') {
mainWindow?.webContents.toggleDevTools(); mainWindow?.webContents.toggleDevTools();
return;
} }
if ((input.control || input.meta) && input.key.toLowerCase() === 'c')
mainWindow?.webContents.copy();
}); });
createIPCHandler({ router: appRouter, windows: [mainWindow] }); createIPCHandler({ router: appRouter, windows: [mainWindow] });
@@ -85,23 +119,79 @@ const createWindow = async () => {
} }
}; };
app.whenReady().then(async () => { const gotSingleInstanceLock = is.dev || app.requestSingleInstanceLock();
Preferences.data = await Preferences.load();
Addons.verify(); if (!gotSingleInstanceLock) {
Updater.verify(); app.quit();
Mods.verify(); } else {
initSelfUpdater(); app.on('second-instance', () => {
if (!mainWindow) return;
electronApp.setAppUserModelId('com.electron'); if (mainWindow.isMinimized()) mainWindow.restore();
if (!mainWindow.isVisible()) mainWindow.show();
app.on('browser-window-created', (_, window) => { mainWindow.focus();
optimizer.watchWindowShortcuts(window);
}); });
await createWindow(); app.whenReady().then(async () => {
}); Preferences.data = await Preferences.load();
app.on('window-all-closed', async () => { Addons.verify();
app.quit(); Updater.verify();
}); Mods.verify();
initSelfUpdater();
void (async () => {
try {
let hardware = Preferences.data.hardware;
if (!hardware || hardware.schemaVersion < HARDWARE_SCHEMA_VERSION) {
hardware = await detectHardware();
Preferences.data = { hardware };
}
const rec = recommendFarClip(hardware ?? null);
if (
Preferences.data.farClipUserSet !== true &&
Preferences.data.config.farClip !== rec
)
Preferences.data = {
config: { ...Preferences.data.config, farClip: rec }
};
} catch (e) {
Logger.error('Hardware detection / farClip recommendation failed', e);
}
})();
electronApp.setAppUserModelId('st.octowow.launcher');
if (app.isPackaged) {
const serverOrigin = new URL(
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
).origin;
session.defaultSession.webRequest.onHeadersReceived((details, cb) => {
cb({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
[
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
`img-src 'self' data: https://octowow.st https://forum.octowow.st ${serverOrigin}`,
"font-src 'self' data:",
"connect-src 'self'"
].join('; ')
]
}
});
});
}
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window);
});
await createWindow();
});
app.on('window-all-closed', () => {
app.quit();
});
}
+74 -24
View File
@@ -35,22 +35,50 @@ type AddonsList = {
}[]; }[];
const readTocData = (content: string) => const readTocData = (content: string) =>
content (content.charCodeAt(0) === 0xfeff ? content.slice(1) : content)
.split('\n') .split('\n')
.filter(l => l.startsWith('## ')) .filter(l => l.startsWith('## '))
.map(l => l.slice(3)) .map(l => l.slice(3))
.map(l => { .map(l => {
const [key, value] = l.split(':'); const idx = l.indexOf(':');
return [key.trim(), value.trim()]; if (idx === -1) return null;
return [l.slice(0, idx).trim(), l.slice(idx + 1).trim()] as const;
}) })
.filter((e): e is readonly [string, string] => !!e)
.reduce((acc, [key, value]) => { .reduce((acc, [key, value]) => {
acc[key] = value; acc[key] = value;
return acc; return acc;
}, {} as TocData); }, {} as TocData);
const isUnsafeFolder = (name?: string) =>
!name || name === '.' || name === '..' || /[/\\]/.test(name);
const ALLOWED_GIT_HOSTS = [
'github.com',
'gitlab.com',
'gitea.com',
'codeberg.org',
'octowow.st'
];
const isAllowedGitUrl = (url: string) => {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:') return false;
const host = parsed.hostname.toLowerCase();
return ALLOWED_GIT_HOSTS.some(h => host === h || host.endsWith('.' + h));
} catch {
return false;
}
};
const fetchAddons = async () => { const fetchAddons = async () => {
try { try {
const response = await fetch(`${import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'}/api/addons.json`); const response = await fetch(
`${
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
}/api/addons.json`
);
return (await response.json()) as AddonsList; return (await response.json()) as AddonsList;
} catch (e) { } catch (e) {
Logger.error('Failed to reach update server', e); Logger.error('Failed to reach update server', e);
@@ -103,35 +131,35 @@ class AddonsClass extends Observable<AddonsStatus> {
}; };
async checkGitUrl(url: string) { async checkGitUrl(url: string) {
const gitUrl = url.endsWith('.git') ? url : `${url}.git`; const clean = url.trim().replace(/\/+$/, '');
const gitUrl = clean.endsWith('.git') ? clean : `${clean}.git`;
if (!isAllowedGitUrl(gitUrl)) return undefined;
try { try {
await git.getRemoteInfo({ await git.getRemoteInfo({
http, http,
url: gitUrl url: gitUrl
}); });
// Only fetch preview from known public git hosts to prevent SSRF.
const allowed = ['github.com', 'gitlab.com', 'gitea.com', 'codeberg.org'];
let preview: string | undefined; let preview: string | undefined;
try { try {
const host = new URL(url).hostname.toLowerCase(); if (isAllowedGitUrl(url)) {
if (allowed.some(h => host === h || host.endsWith('.' + h))) {
const response = await fetch(url).then(r => r.text()); const response = await fetch(url).then(r => r.text());
preview = response.match( preview = response.match(
/property="og:image" content="([^"]*)"/ /property="og:image" content="([^"]*)"/
)?.[1]; )?.[1];
} }
} catch { } catch {
// preview stays undefined
} }
const folder = gitUrl.slice(0, -4).split('/').at(-1);
if (isUnsafeFolder(folder)) return undefined;
return { return {
status: 'available', status: 'available',
folder: gitUrl.slice(0, -4).split('/').at(-1), folder,
git: gitUrl, git: gitUrl,
preview preview
} as AddonData; } as AddonData;
} catch (e) { } catch {
return undefined; return undefined;
} }
} }
@@ -162,7 +190,7 @@ class AddonsClass extends Observable<AddonsStatus> {
} }
const addonsPath = path.join(clientPath, 'Interface', 'Addons'); const addonsPath = path.join(clientPath, 'Interface', 'Addons');
const dirs = await fs.pathExists(addonsPath) const dirs = (await fs.pathExists(addonsPath))
? await fs.readdir(addonsPath) ? await fs.readdir(addonsPath)
: []; : [];
const addons: AddonsStatus['addons'] = Object.fromEntries( const addons: AddonsStatus['addons'] = Object.fromEntries(
@@ -223,9 +251,7 @@ class AddonsClass extends Observable<AddonsStatus> {
.catch(() => null); .catch(() => null);
const remoteCommit = avail?.ref const remoteCommit = avail?.ref
? await git ? await git.resolveRef({ fs, dir, ref: avail.ref }).catch(() => null)
.resolveRef({ fs, dir, ref: avail.ref })
.catch(() => null)
: await git : await git
.log({ fs, dir, ref: `${remote.remote}/${branch}`, depth: 1 }) .log({ fs, dir, ref: `${remote.remote}/${branch}`, depth: 1 })
.then(r => r[0].oid) .then(r => r[0].oid)
@@ -249,7 +275,9 @@ class AddonsClass extends Observable<AddonsStatus> {
Logger.log( Logger.log(
isUpToDate isUpToDate
? `Addon "${folder}" is up to date${avail?.ref ? ` (pinned ${avail.ref})` : ''}` ? `Addon "${folder}" is up to date${
avail?.ref ? ` (pinned ${avail.ref})` : ''
}`
: `Addon "${folder}" has an update available` : `Addon "${folder}" has an update available`
); );
} catch (e) { } catch (e) {
@@ -268,13 +296,16 @@ class AddonsClass extends Observable<AddonsStatus> {
const VERIFY_CONCURRENCY = 6; const VERIFY_CONCURRENCY = 6;
let idx = 0; let idx = 0;
await Promise.all( await Promise.all(
Array.from({ length: Math.min(VERIFY_CONCURRENCY, folders.length) }, async () => { Array.from(
while (true) { { length: Math.min(VERIFY_CONCURRENCY, folders.length) },
const i = idx++; async () => {
if (i >= folders.length) return; while (true) {
await verifyOne(folders[i]); const i = idx++;
if (i >= folders.length) return;
await verifyOne(folders[i]);
}
} }
}) )
); );
this.status = { ...this.status, state: 'done' }; this.status = { ...this.status, state: 'done' };
@@ -330,7 +361,7 @@ class AddonsClass extends Observable<AddonsStatus> {
{ onProgress: this.#onProgress(folder, data) } { onProgress: this.#onProgress(folder, data) }
); );
} }
const toc = await readTocData( const toc = readTocData(
await fs.readFile(path.join(dir, `${folder}.toc`), 'utf-8') await fs.readFile(path.join(dir, `${folder}.toc`), 'utf-8')
); );
@@ -363,6 +394,25 @@ class AddonsClass extends Observable<AddonsStatus> {
async install(data: AddonData) { async install(data: AddonData) {
const clientPath = Preferences.data.clientDir; const clientPath = Preferences.data.clientDir;
if (!clientPath) return; if (!clientPath) return;
if (isUnsafeFolder(data.folder)) {
Logger.error(`Refusing addon with unsafe folder name: "${data.folder}"`);
this.#setAddon(data.folder, {
...data,
status: 'invalid',
error: 'Invalid addon name'
});
return;
}
if (!data.git || !isAllowedGitUrl(data.git)) {
Logger.error(`Refusing addon from disallowed git host: "${data.git}"`);
this.#setAddon(data.folder, {
...data,
status: 'invalid',
error: 'Addon URL is not from an allowed git host'
});
return;
}
const addonsPath = path.join(clientPath, 'Interface', 'Addons'); const addonsPath = path.join(clientPath, 'Interface', 'Addons');
const dir = path.join(addonsPath, data.folder); const dir = path.join(addonsPath, data.folder);
+80
View File
@@ -0,0 +1,80 @@
import { spawn } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
import { app } from 'electron';
import Logger from 'electron-log/main';
import Preferences from './preferences';
export type ExclusionResult = { ok: boolean; error?: string; paths?: string[] };
const psSingleQuote = (s: string) => `'${s.replace(/'/g, "''")}'`;
export const addDefenderExclusions = async (): Promise<ExclusionResult> => {
if (os.platform() !== 'win32')
return {
ok: false,
error: 'Antivirus exclusions are only needed on Windows.'
};
const clientDir = Preferences.data.clientDir;
if (!clientDir)
return {
ok: false,
error: 'Set your game folder first, then add the exclusion.'
};
const launcherDir =
process.env.PORTABLE_EXECUTABLE_DIR ?? path.dirname(app.getPath('exe'));
const paths = [...new Set([clientDir, launcherDir])];
const inner = [
'$ErrorActionPreference = "Stop"',
'try {',
...paths.map(p => `Add-MpPreference -ExclusionPath ${psSingleQuote(p)}`),
'Add-MpPreference -ExclusionProcess "WoW.exe"',
'Add-MpPreference -ExclusionProcess "VanillaFixes.exe"',
'exit 0',
'} catch { exit 2 }'
].join('\n');
const encoded = Buffer.from(inner, 'utf16le').toString('base64');
const outer =
'try { $p = Start-Process powershell -Verb RunAs -WindowStyle Hidden ' +
"-Wait -PassThru -ArgumentList '-NoProfile','-NonInteractive'," +
`'-EncodedCommand','${encoded}'; exit $p.ExitCode } catch { exit 1 }`;
return new Promise<ExclusionResult>(resolve => {
const child = spawn(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-Command', outer],
{ windowsHide: true }
);
let stderr = '';
child.stderr.on('data', d => (stderr += String(d)));
child.on('error', e => {
Logger.error('Failed to launch PowerShell for Defender exclusion', e);
resolve({ ok: false, error: 'Could not run Windows PowerShell.' });
});
child.on('exit', code => {
if (code === 0) {
Logger.info(`Added Defender exclusions: ${paths.join(', ')}`);
resolve({ ok: true, paths });
} else if (code === 1) {
resolve({
ok: false,
error:
'No permission granted. Click Yes on the Windows prompt to add the exclusion.'
});
} else {
Logger.error(`Defender exclusion failed (code ${code}): ${stderr}`);
resolve({
ok: false,
error:
'Could not add the exclusion automatically. You may need to add it in Windows Security manually.'
});
}
});
});
};
+132
View File
@@ -0,0 +1,132 @@
import os from 'node:os';
import { spawn } from 'node:child_process';
import { app } from 'electron';
import Logger from 'electron-log/main';
import type { HardwareInfo } from '~common/schemas';
export const HARDWARE_SCHEMA_VERSION = 1;
export const FARCLIP_FLOOR = 777;
export const FARCLIP_CEILING = 3000;
const VIDEO_CLASS_GUID = '{4d36e968-e325-11ce-bfc1-08002be10318}';
const getVramMb = (): Promise<{
mb: number | null;
source: HardwareInfo['vramSource'];
}> => {
if (os.platform() !== 'win32')
return Promise.resolve({ mb: null, source: 'none' });
const script = [
'$ErrorActionPreference = "Stop"',
`$base = 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Class\\${VIDEO_CLASS_GUID}'`,
'$max = [int64]0',
'Get-ChildItem $base -ErrorAction SilentlyContinue | ForEach-Object {',
' $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue',
' $v = [int64]0',
" if ($p.'HardwareInformation.qwMemorySize') { $v = [int64]$p.'HardwareInformation.qwMemorySize' }",
" elseif ($p.'HardwareInformation.MemorySize') {",
" $m = $p.'HardwareInformation.MemorySize'",
' if ($m -is [byte[]]) { $v = [int64][System.BitConverter]::ToUInt32($m, 0) } else { $v = [int64]$m }',
' }',
' if ($v -gt $max) { $max = $v }',
'}',
'Write-Output $max'
].join('\n');
const encoded = Buffer.from(script, 'utf16le').toString('base64');
return new Promise(resolve => {
let settled = false;
const finish = (mb: number | null, source: HardwareInfo['vramSource']) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ mb, source });
};
const child = spawn(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded],
{ windowsHide: true }
);
const timer = setTimeout(() => {
child.kill();
Logger.warn('VRAM detection timed out');
finish(null, 'none');
}, 8000);
let stdout = '';
child.stdout.on('data', d => (stdout += String(d)));
child.on('error', e => {
Logger.warn('VRAM detection failed to launch PowerShell', e);
finish(null, 'none');
});
child.on('exit', code => {
const bytes = Number(stdout.trim());
if (code === 0 && Number.isFinite(bytes) && bytes > 0)
finish(Math.round(bytes / 1024 / 1024), 'registry');
else finish(null, 'none');
});
});
};
const getGpuModel = async (): Promise<string> => {
try {
const info = (await app.getGPUInfo('complete')) as {
auxAttributes?: { glRenderer?: string };
gpuDevice?: { active?: boolean; vendorId?: number; deviceId?: number }[];
};
const renderer = info?.auxAttributes?.glRenderer?.trim();
if (renderer) return renderer;
const active = info?.gpuDevice?.find(d => d.active) ?? info?.gpuDevice?.[0];
if (active) return `vendor ${active.vendorId} device ${active.deviceId}`;
} catch (e) {
Logger.warn('GPU info detection failed', e);
}
return 'unknown';
};
export const detectHardware = async (): Promise<HardwareInfo> => {
const cpus = os.cpus();
const [vram, gpuModel] = await Promise.all([getVramMb(), getGpuModel()]);
const info: HardwareInfo = {
totalRamMb: Math.round(os.totalmem() / 1024 / 1024),
cpuCores: cpus.length,
cpuModel: cpus[0]?.model?.trim() || 'unknown',
gpuModel,
vramMb: vram.mb,
vramSource: vram.source,
detectedAt: new Date().toISOString(),
schemaVersion: HARDWARE_SCHEMA_VERSION
};
Logger.info('Detected hardware', info);
return info;
};
const clampFarClip = (n: number) =>
Math.min(FARCLIP_CEILING, Math.max(FARCLIP_FLOOR, Math.round(n)));
export const recommendFarClip = (hw: HardwareInfo | null): number => {
if (!hw) return clampFarClip(1000);
const ramGb = hw.totalRamMb / 1024;
const cores = hw.cpuCores;
const vramTrusted = hw.vramSource !== 'none' && hw.vramMb != null;
const vramGb = vramTrusted ? (hw.vramMb as number) / 1024 : null;
if (ramGb < 6 || cores <= 2) return clampFarClip(FARCLIP_FLOOR);
if (vramGb === null)
return clampFarClip(ramGb >= 8 && cores >= 4 ? 1500 : 1000);
if (ramGb < 8 || vramGb < 2) return clampFarClip(1000);
if (ramGb >= 32 && vramGb >= 8 && cores >= 8) return clampFarClip(3000);
if (ramGb >= 16 && vramGb >= 4 && cores >= 6) return clampFarClip(2200);
if (ramGb >= 8 && vramGb >= 2 && cores >= 4) return clampFarClip(1500);
return clampFarClip(1000);
};
+116
View File
@@ -0,0 +1,116 @@
import path from 'node:path';
import fs from 'fs-extra';
import {
SFileOpenArchive,
SFileCloseArchive,
SFileHasFile
} from 'stormlib-node';
import { STREAM_FLAG } from 'stormlib-node/dist/enums';
import Logger from 'electron-log/main';
import Preferences from './preferences';
const PREFERRED = 'L';
const LETTERS = 'BCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const MARKER = 'octolocale.marker';
const patchFile = (dataDir: string, letter: string) =>
path.join(dataDir, `patch-${letter}.mpq`);
const prebuiltFor = (dataDir: string, locale: string) =>
path.join(dataDir, locale, 'patch-L.mpq');
const isOurPatch = (mpqPath: string): boolean => {
if (!fs.existsSync(mpqPath)) return false;
try {
const h = SFileOpenArchive(mpqPath, STREAM_FLAG.READ_ONLY);
try {
return SFileHasFile(h, MARKER);
} finally {
SFileCloseArchive(h);
}
} catch {
return false;
}
};
const usableSlot = (dataDir: string, letter: string): boolean => {
if (fs.existsSync(path.join(dataDir, `patch-${letter}.MPQ`))) return false;
const f = patchFile(dataDir, letter);
return !fs.existsSync(f) || isOurPatch(f);
};
const removeOurPatch = async (dataDir: string) => {
for (const l of LETTERS) {
const f = patchFile(dataDir, l);
if (isOurPatch(f)) await fs.remove(f).catch(() => {});
}
if (Preferences.data.localePatchLetter || Preferences.data.localePatchLocale)
Preferences.data = {
localePatchLetter: undefined,
localePatchLocale: undefined
};
};
export const applyLocalePatch = async (
clientDir: string | undefined,
locale: string | undefined
): Promise<void> => {
if (!clientDir) return;
const dataDir = path.join(clientDir, 'Data');
const nextLocale = !locale || locale === 'enUS' ? undefined : locale;
if (Preferences.data.localePatchLocale !== nextLocale)
await fs.remove(path.join(clientDir, 'WDB')).catch(() => {});
if (!locale || locale === 'enUS') {
await removeOurPatch(dataDir);
return;
}
const source = prebuiltFor(dataDir, locale);
if (!(await fs.pathExists(source))) {
Logger.warn(
`Locale patch: no prebuilt patch-L for ${locale}; leaving UI as-is`
);
return;
}
const tracked = Preferences.data.localePatchLetter;
const letter =
(tracked && usableSlot(dataDir, tracked) ? tracked : undefined) ??
(usableSlot(dataDir, PREFERRED)
? PREFERRED
: LETTERS.find(l => usableSlot(dataDir, l)));
if (!letter) {
Logger.warn('Locale patch: no usable patch slot');
return;
}
const target = patchFile(dataDir, letter);
try {
if (
Preferences.data.localePatchLocale === locale &&
isOurPatch(target) &&
fs.statSync(target).mtimeMs >= fs.statSync(source).mtimeMs
)
return;
} catch {
}
try {
for (const l of LETTERS) {
if (l === letter) continue;
const f = patchFile(dataDir, l);
if (isOurPatch(f)) await fs.remove(f).catch(() => {});
}
await fs.copy(source, target, { overwrite: true });
Preferences.data = { localePatchLetter: letter, localePatchLocale: locale };
Logger.log(
`Locale patch: swapped prebuilt ${locale} -> patch-${letter}.mpq`
);
} catch (e) {
Logger.error('Locale patch: failed to swap in prebuilt patch', e);
}
};
+65 -71
View File
@@ -1,5 +1,4 @@
import path from 'path'; import path from 'path';
import os from 'os';
import fs from 'fs-extra'; import fs from 'fs-extra';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
@@ -12,8 +11,17 @@ import { type ModState } from '~common/schemas';
import Preferences from './preferences'; import Preferences from './preferences';
import Observable from './observable'; import Observable from './observable';
import Updater from './updater';
import { addDll, removeDll } from './dllsTxt'; import { addDll, removeDll } from './dllsTxt';
const MOD_DOWNLOAD_TIMEOUT_MS = 60_000;
const AV_ERROR =
'Windows Defender blocked this download. Use "Allow through antivirus" and apply again.';
const looksLikeAvBlock = (msg: string) =>
/windows defender|virus|potentially unwanted/i.test(msg);
export type ModRowStatus = { export type ModRowStatus = {
id: ModId; id: ModId;
name: string; name: string;
@@ -36,8 +44,6 @@ export type ModsStatus = {
mods: ModRowStatus[]; mods: ModRowStatus[];
}; };
const VERSION_CACHE_MS = 10 * 60 * 1000;
class ModsClass extends Observable<ModsStatus> { class ModsClass extends Observable<ModsStatus> {
protected _value: ModsStatus = { protected _value: ModsStatus = {
state: 'verifying', state: 'verifying',
@@ -45,21 +51,6 @@ class ModsClass extends Observable<ModsStatus> {
mods: [] mods: []
}; };
#latestCache = new Map<ModId, { v: string; ts: number }>();
installedFilePaths(): Set<string> {
const set = new Set<string>();
const mods = Preferences.data?.mods ?? {};
for (const id of Object.keys(mods) as ModId[]) {
const state = mods[id];
if (!state?.installedFiles?.length) continue;
for (const rel of state.installedFiles) {
set.add(rel.replace(/\\/g, '/').toLowerCase());
}
}
return set;
}
get status(): ModsStatus { get status(): ModsStatus {
return this._value; return this._value;
} }
@@ -119,6 +110,13 @@ class ModsClass extends Observable<ModsStatus> {
const clientDir = Preferences.data?.clientDir; const clientDir = Preferences.data?.clientDir;
if (clientDir) {
const vmmfDll = path.join(clientDir, 'VanillaMultiMonitorFix.dll');
const vmmfCfg = path.join(clientDir, 'VMMFix_preferred_monitor.txt');
if ((await fs.pathExists(vmmfDll)) && !(await fs.pathExists(vmmfCfg)))
await fs.writeFile(vmmfCfg, '0\n', 'utf8').catch(() => {});
}
for (const m of MODS) { for (const m of MODS) {
const state = Preferences.data?.mods?.[m.id]; const state = Preferences.data?.mods?.[m.id];
let installedVersion = state?.installedVersion; let installedVersion = state?.installedVersion;
@@ -140,54 +138,28 @@ class ModsClass extends Observable<ModsStatus> {
} }
} }
const latest = await this.#fetchLatestVersion(m).catch(() => m.version); if (clientDir && m.registerInDllsTxt)
await (installedVersion
? addDll(clientDir, m.registerInDllsTxt)
: removeDll(clientDir, m.registerInDllsTxt)
).catch(() => {});
this.#patchRow(m.id, { this.#patchRow(m.id, {
installedVersion, installedVersion,
latestVersion: latest, latestVersion: m.version,
enabled: !!state?.enabled, enabled: !!state?.enabled,
ignoreUpdates: !!state?.ignoreUpdates ignoreUpdates: !!state?.ignoreUpdates
}); });
} }
this._value = { ...this._value, state: 'idle', dirty: this.#computeDirty() }; this._value = {
...this._value,
state: 'idle',
dirty: this.#computeDirty()
};
this._notifyObservers(); this._notifyObservers();
} }
async #fetchLatestVersion(m: ModEntry): Promise<string> {
if (m.source.kind === 'managed') return m.version;
const cached = this.#latestCache.get(m.id);
if (cached && Date.now() - cached.ts < VERSION_CACHE_MS) return cached.v;
const apiUrl =
'apiUrl' in m.source && m.source.apiUrl ? m.source.apiUrl : undefined;
const parser =
'parseLatest' in m.source && m.source.parseLatest
? m.source.parseLatest
: undefined;
if (!apiUrl || !parser) {
const v = ('pinnedTag' in m.source && m.source.pinnedTag) || m.version;
this.#latestCache.set(m.id, { v, ts: Date.now() });
return v;
}
try {
const res = await fetch(apiUrl, {
headers: { 'User-Agent': 'OctoLauncher' }
});
if (!res.ok) throw new Error(`${apiUrl}${res.status}`);
const json = (await res.json()) as { tag_name?: string };
const tag = json.tag_name ?? m.version;
this.#latestCache.set(m.id, { v: tag, ts: Date.now() });
return tag;
} catch (e) {
Logger.warn(`Could not check latest version for ${m.id}:`, e);
const v = ('pinnedTag' in m.source && m.source.pinnedTag) || m.version;
return v;
}
}
async toggle(id: ModId, enabled: boolean) { async toggle(id: ModId, enabled: boolean) {
const cur = Preferences.data?.mods?.[id]; const cur = Preferences.data?.mods?.[id];
await this.#savePref(id, { await this.#savePref(id, {
@@ -210,12 +182,17 @@ class ModsClass extends Observable<ModsStatus> {
this.#patchRow(id, { ignoreUpdates: ignore }); this.#patchRow(id, { ignoreUpdates: ignore });
} }
async applyAll() { async applyAll(opts: { repairOnly?: boolean } = {}) {
const clientDir = Preferences.data?.clientDir; const clientDir = Preferences.data?.clientDir;
if (!clientDir) { if (!clientDir) {
Logger.warn('No clientDir set; cannot apply mods.'); Logger.warn('No clientDir set; cannot apply mods.');
return; return;
} }
if (this._value.state === 'busy') {
Logger.warn('applyAll already running; ignoring re-entrant call.');
return;
}
await this.verify();
this._value = { ...this._value, state: 'busy' }; this._value = { ...this._value, state: 'busy' };
this._notifyObservers(); this._notifyObservers();
@@ -226,6 +203,7 @@ class ModsClass extends Observable<ModsStatus> {
return 0; return 0;
}); });
const failures = new Map<ModId, string>();
for (const row of queue) { for (const row of queue) {
const m = getMod(row.id); const m = getMod(row.id);
if (!m) continue; if (!m) continue;
@@ -242,21 +220,22 @@ class ModsClass extends Observable<ModsStatus> {
await this.#install(m); await this.#install(m);
} else if (!wantInstalled && isInstalled) { } else if (!wantInstalled && isInstalled) {
await this.#uninstall(m); await this.#uninstall(m);
} else if (wantInstalled && updateAvailable) { } else if (wantInstalled && updateAvailable && !opts.repairOnly) {
await this.#uninstall(m); await this.#uninstall(m);
await this.#install(m); await this.#install(m);
} }
} catch (e) { } catch (e) {
Logger.error(`Failed to apply ${m.id}:`, e); Logger.error(`Failed to apply ${m.id}:`, e);
this.#patchRow(m.id, { const msg = e instanceof Error ? e.message : String(e);
state: 'error', failures.set(m.id, looksLikeAvBlock(msg) ? AV_ERROR : msg);
error: e instanceof Error ? e.message : String(e)
});
} }
} }
this._value = { ...this._value, state: 'idle' }; this._value = { ...this._value, state: 'idle' };
await this.verify(); await this.verify();
for (const [id, error] of failures)
this.#patchRow(id, { state: 'error', error });
await Updater.verify();
} }
async #install(m: ModEntry) { async #install(m: ModEntry) {
@@ -265,18 +244,25 @@ class ModsClass extends Observable<ModsStatus> {
if (m.source.kind === 'managed') return; if (m.source.kind === 'managed') return;
Logger.info(`Installing mod ${m.id}...`); Logger.info(`Installing mod ${m.id}...`);
this.#patchRow(m.id, { state: 'downloading', progress: 0, error: undefined }); this.#patchRow(m.id, {
state: 'downloading',
progress: 0,
error: undefined
});
const written: string[] = []; const written: string[] = [];
const missing: string[] = [];
if (m.source.kind === 'directFile') { if (m.source.kind === 'directFile') {
const dest = path.join(clientDir, m.source.assetName); const dest = path.join(clientDir, m.source.assetName);
await this.#downloadTo(m.source.url, dest); await this.#downloadTo(m.source.url, dest);
written.push(m.source.assetName); written.push(m.source.assetName);
} else if (m.source.kind === 'archive') { } else if (m.source.kind === 'archive') {
const scratch = path.join(clientDir, '.octolauncher-tmp');
await fs.ensureDir(scratch);
const tmp = path.join( const tmp = path.join(
os.tmpdir(), scratch,
`octolauncher-${m.id}-${Date.now()}.${m.source.format}` `${m.id}-${Date.now()}.${m.source.format}`
); );
await this.#downloadTo(m.source.url, tmp); await this.#downloadTo(m.source.url, tmp);
this.#patchRow(m.id, { state: 'installing' }); this.#patchRow(m.id, { state: 'installing' });
@@ -288,7 +274,7 @@ class ModsClass extends Observable<ModsStatus> {
for (const [src, dst] of Object.entries(map)) { for (const [src, dst] of Object.entries(map)) {
const entry = entries.find(e => e.entryName === src); const entry = entries.find(e => e.entryName === src);
if (!entry) { if (!entry) {
Logger.warn(`Mod ${m.id}: zip entry ${src} not found.`); missing.push(src);
continue; continue;
} }
const target = path.join(clientDir, dst); const target = path.join(clientDir, dst);
@@ -297,16 +283,13 @@ class ModsClass extends Observable<ModsStatus> {
written.push(dst); written.push(dst);
} }
} else { } else {
const stagingDir = path.join( const stagingDir = path.join(scratch, `${m.id}-${Date.now()}-extract`);
os.tmpdir(),
`octolauncher-${m.id}-${Date.now()}-extract`
);
await fs.ensureDir(stagingDir); await fs.ensureDir(stagingDir);
await tar.x({ file: tmp, cwd: stagingDir }); await tar.x({ file: tmp, cwd: stagingDir });
for (const [src, dst] of Object.entries(map)) { for (const [src, dst] of Object.entries(map)) {
const srcPath = path.join(stagingDir, src); const srcPath = path.join(stagingDir, src);
if (!(await fs.pathExists(srcPath))) { if (!(await fs.pathExists(srcPath))) {
Logger.warn(`Mod ${m.id}: tar entry ${src} not found.`); missing.push(src);
continue; continue;
} }
const target = path.join(clientDir, dst); const target = path.join(clientDir, dst);
@@ -319,6 +302,11 @@ class ModsClass extends Observable<ModsStatus> {
await fs.remove(tmp).catch(() => {}); await fs.remove(tmp).catch(() => {});
} }
if (missing.length)
throw new Error(
`${m.name}: download is missing expected file(s): ${missing.join(', ')}`
);
if (m.registerInDllsTxt) { if (m.registerInDllsTxt) {
await addDll(clientDir, m.registerInDllsTxt); await addDll(clientDir, m.registerInDllsTxt);
} }
@@ -371,12 +359,18 @@ class ModsClass extends Observable<ModsStatus> {
async #downloadTo(url: string, dest: string) { async #downloadTo(url: string, dest: string) {
const res = await fetch(url, { const res = await fetch(url, {
headers: { 'User-Agent': 'OctoLauncher' } headers: { 'User-Agent': 'OctoLauncher' },
timeout: MOD_DOWNLOAD_TIMEOUT_MS
}); });
if (!res.ok) throw new Error(`Download failed ${res.status}: ${url}`); if (!res.ok) throw new Error(`Download failed ${res.status}: ${url}`);
await fs.ensureDir(path.dirname(dest)); await fs.ensureDir(path.dirname(dest));
const buf = await res.arrayBuffer(); const buf = await res.arrayBuffer();
await fs.writeFile(dest, Buffer.from(buf)); await fs.writeFile(dest, Buffer.from(buf));
if (!(await fs.pathExists(dest)))
throw new Error(
`Downloaded file disappeared after writing: ${path.basename(dest)}. ` +
'This is often Windows Defender quarantine; if so, use "Allow through antivirus" and apply again.'
);
} }
async #savePref(id: ModId, state: ModState) { async #savePref(id: ModId, state: ModState) {
+2 -1
View File
@@ -12,7 +12,8 @@ abstract class Observable<T> {
try { try {
l(v); l(v);
return true; return true;
} catch { } catch (err) {
console.error('Observer threw, removing listener', err);
return false; return false;
} }
}); });
+83 -58
View File
@@ -22,17 +22,24 @@ const Servers = {
} }
} as const; } as const;
type Tweak = { key: keyof PreferencesSchema['config']; default?: unknown; forced?: boolean } & ( type TweakKey =
| { | { synthetic?: false; key: keyof PreferencesSchema['config'] }
type: 'bytes'; | { synthetic: true; key: string };
tweaks: [number, number[]][];
} type Tweak = TweakKey & {
| { default?: unknown;
type: 'int8' | 'uint16' | 'float'; forced?: boolean;
offset: number; } & (
value?: number; | {
} type: 'bytes';
); tweaks: [number, number[]][];
}
| {
type: 'int8' | 'uint16' | 'float';
offset: number;
value?: number;
}
);
export const patchExecutable = async () => { export const patchExecutable = async () => {
Logger.log('Patching WoW.exe...'); Logger.log('Patching WoW.exe...');
@@ -81,7 +88,8 @@ export const patchExecutable = async () => {
{ key: 'nameplateRange', type: 'float', offset: 0x40c448 }, { key: 'nameplateRange', type: 'float', offset: 0x40c448 },
{ key: 'cameraDistance', type: 'float', offset: 0x4089a4 }, { key: 'cameraDistance', type: 'float', offset: 0x4089a4 },
{ {
key: 'crossFactionResurrect' as never, synthetic: true,
key: 'crossFactionResurrect',
type: 'bytes', type: 'bytes',
default: true, default: true,
tweaks: [ tweaks: [
@@ -90,7 +98,8 @@ export const patchExecutable = async () => {
] ]
}, },
{ {
key: 'skillUiGateHijack' as never, synthetic: true,
key: 'skillUiGateHijack',
type: 'bytes', type: 'bytes',
default: true, default: true,
forced: true, forced: true,
@@ -98,47 +107,57 @@ export const patchExecutable = async () => {
[ [
0x002ddf90, 0x002ddf90,
[ [
0x55, 0x8b, 0xec, 0x83, 0xec, 0x08, 0x53, 0x56, 0x55, 0x8b, 0xec, 0x83, 0xec, 0x08, 0x53, 0x56, 0x57, 0x8b, 0x3d,
0x57, 0x8b, 0x3d, 0x60, 0xab, 0xce, 0x00, 0x83, 0x60, 0xab, 0xce, 0x00, 0x83, 0xff, 0xff, 0x89, 0x55, 0xfc, 0x89,
0xff, 0xff, 0x89, 0x55, 0xfc, 0x89, 0x4d, 0xf8, 0x4d, 0xf8, 0x74, 0x79, 0x8b, 0x75, 0x08, 0x8b, 0x15, 0x58, 0xab,
0x74, 0x79, 0x8b, 0x75, 0x08, 0x8b, 0x15, 0x58, 0xce, 0x00, 0x8b, 0xc7, 0x23, 0xc6, 0x8d, 0x04, 0x40, 0x8b, 0x4c,
0xab, 0xce, 0x00, 0x8b, 0xc7, 0x23, 0xc6, 0x8d, 0x82, 0x08, 0xf6, 0xc1, 0x01, 0x8d, 0x44, 0x82, 0x04, 0x75, 0x04,
0x04, 0x40, 0x8b, 0x4c, 0x82, 0x08, 0xf6, 0xc1, 0x85, 0xc9, 0x75, 0x05, 0x33, 0xc9, 0x8d, 0x49, 0x00, 0xf6, 0xc1,
0x01, 0x8d, 0x44, 0x82, 0x04, 0x75, 0x04, 0x85, 0x01, 0x75, 0x4e, 0x85, 0xc9, 0x74, 0x4a, 0x39, 0x31, 0x74, 0x13,
0xc9, 0x75, 0x05, 0x33, 0xc9, 0x8d, 0x49, 0x00, 0x8b, 0xc7, 0x23, 0xc6, 0x8d, 0x04, 0x40, 0x8d, 0x04, 0x82, 0x8b,
0xf6, 0xc1, 0x01, 0x75, 0x4e, 0x85, 0xc9, 0x74, 0x00, 0x03, 0xc1, 0x8b, 0x48, 0x04, 0xeb, 0xe0, 0x8b, 0x59, 0x1c,
0x4a, 0x39, 0x31, 0x74, 0x13, 0x8b, 0xc7, 0x23, 0x8b, 0x71, 0x18, 0x33, 0xff, 0x85, 0xdb, 0x7e, 0x27, 0x8d, 0x64,
0xc6, 0x8d, 0x04, 0x40, 0x8d, 0x04, 0x82, 0x8b, 0x24, 0x00, 0x8b, 0x4e, 0x0c, 0x8b, 0x56, 0x08, 0x6a, 0x00, 0x6a,
0x00, 0x03, 0xc1, 0x8b, 0x48, 0x04, 0xeb, 0xe0, 0x00, 0x51, 0x8b, 0x4d, 0xf8, 0x52, 0x8b, 0x55, 0xfc, 0xe8, 0xb9,
0x8b, 0x59, 0x1c, 0x8b, 0x71, 0x18, 0x33, 0xff, 0xfd, 0xff, 0xff, 0x84, 0xc0, 0x75, 0x13, 0x47, 0x83, 0xc6, 0x20,
0x85, 0xdb, 0x7e, 0x27, 0x8d, 0x64, 0x24, 0x00, 0x3b, 0xfb, 0x7c, 0xdd, 0x5f, 0x5e, 0x33, 0xc0, 0x5b, 0x8b, 0xe5,
0x8b, 0x4e, 0x0c, 0x8b, 0x56, 0x08, 0x6a, 0x00, 0x5d, 0xc2, 0x04, 0x00, 0x5f, 0x8b, 0xc6, 0x5e, 0x5b, 0x8b, 0xe5,
0x6a, 0x00, 0x51, 0x8b, 0x4d, 0xf8, 0x52, 0x8b, 0x5d, 0xc2, 0x04, 0x00, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90
0x55, 0xfc, 0xe8, 0xb9, 0xfd, 0xff, 0xff, 0x84, ]
0xc0, 0x75, 0x13, 0x47, 0x83, 0xc6, 0x20, 0x3b, ]
0xfb, 0x7c, 0xdd, 0x5f, 0x5e, 0x33, 0xc0, 0x5b, ]
0x8b, 0xe5, 0x5d, 0xc2, 0x04, 0x00, 0x5f, 0x8b, },
0xc6, 0x5e, 0x5b, 0x8b, 0xe5, 0x5d, 0xc2, 0x04, {
0x00, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90 synthetic: true,
key: 'octowowUrlAllowlist',
type: 'bytes',
default: true,
forced: true,
tweaks: [
[
0x45ccd8,
[
0x6f, 0x63, 0x74, 0x6f, 0x77, 0x6f, 0x77, 0x2e, 0x73, 0x74,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00
] ]
] ]
] ]
} }
] satisfies Tweak[]; ] satisfies Tweak[];
// Apply patches
Tweaks.forEach(t => { Tweaks.forEach(t => {
const val = const val = t.synthetic
config[t.key] ?? t.default ?? ConfigWtfSchema.parse({})[t.key]; ? t.default
: config[t.key] ?? t.default ?? ConfigWtfSchema.parse({})[t.key];
Logger.log(`Applying "${t.key}" patch with value: ${val}`); Logger.log(`Applying "${t.key}" patch with value: ${val}`);
if (t.type === 'float') { if (t.type === 'float') {
buffer.writeFloatLE(t.value ?? (val as never), t.offset); buffer.writeFloatLE(t.value ?? (val as number), t.offset);
} else if (t.type === 'int8') { } else if (t.type === 'int8') {
buffer.writeInt8(t.value ?? (val as never), t.offset); buffer.writeInt8(t.value ?? (val as number), t.offset);
} else if (t.type === 'uint16') { } else if (t.type === 'uint16') {
if (!t.forced && !val) return; if (!t.forced && !val) return;
buffer.writeUInt16LE(t.value ?? (val as never), t.offset); buffer.writeUInt16LE(t.value ?? (val as number), t.offset);
} else if (t.type === 'bytes') { } else if (t.type === 'bytes') {
if (!t.forced && !val) return; if (!t.forced && !val) return;
t.tweaks.forEach(([offset, bytes]) => t.tweaks.forEach(([offset, bytes]) =>
@@ -151,11 +170,12 @@ export const patchExecutable = async () => {
Logger.log('WoW.exe successfully patched'); Logger.log('WoW.exe successfully patched');
} catch (e) { } catch (e) {
Logger.error('Failed to patch WoW.exe', e); Logger.error('Failed to patch WoW.exe', e);
throw e instanceof Error ? e : new Error('Failed to patch WoW.exe');
} }
}; };
export const patchConfig = async () => { export const patchConfig = async (forceTweaks = false) => {
const { clientDir, server, config } = Preferences.data; const { clientDir, server, config, locale } = Preferences.data;
if (!clientDir) return; if (!clientDir) return;
const configPath = path.join(clientDir, 'WTF', 'Config.wtf'); const configPath = path.join(clientDir, 'WTF', 'Config.wtf');
@@ -163,20 +183,21 @@ export const patchConfig = async () => {
const raw = (await fs.pathExists(configPath)) const raw = (await fs.pathExists(configPath))
? await fs.readFile(configPath, { encoding: 'utf-8' }) ? await fs.readFile(configPath, { encoding: 'utf-8' })
: ''; : '';
if (raw) await fs.remove(configPath);
const configWtf = Object.fromEntries( const configWtf = Object.fromEntries(
raw raw
.split('\n') .split(/\r?\n/)
.map(l => { .map(l => {
const [_, k, v] = l.match(/SET (\w+) "(.+)"/) ?? []; const [, k, v] = l.match(/SET (\w+) "(.*)"/) ?? [];
return !k || !v ? undefined : [k, v]; return !k || v === undefined ? undefined : [k, v];
}) })
.filter(isNotUndef) .filter(isNotUndef)
); );
const primaryDisplay = screen.getPrimaryDisplay(); const primaryDisplay = screen.getPrimaryDisplay();
const { width, height } = primaryDisplay.bounds; const scale = primaryDisplay.scaleFactor || 1;
const width = Math.round(primaryDisplay.bounds.width * scale);
const height = Math.round(primaryDisplay.bounds.height * scale);
const parsed = { const parsed = {
scriptMemory: 512000, scriptMemory: 512000,
@@ -210,20 +231,24 @@ export const patchConfig = async () => {
gxMaximize: configWtf['gxMaximize'] ?? 1, gxMaximize: configWtf['gxMaximize'] ?? 1,
gxCursor: configWtf['gxCursor'] ?? 1, gxCursor: configWtf['gxCursor'] ?? 1,
checkAddonVersion: configWtf['checkAddonVersion'] ?? 0, checkAddonVersion: configWtf['checkAddonVersion'] ?? 0,
farClip: configWtf['farClip'] ?? config.farClip,
CameraDistanceMax: configWtf['CameraDistanceMax'] ?? config.cameraDistance,
...configWtf, ...configWtf,
CameraDistanceMax: config.cameraDistance, locale,
farClip: config.farClip,
realmList: Servers[server].realmList, realmList: Servers[server].realmList,
hwDetect: 0, hwDetect: 0,
M2UseShaders: 1 M2UseShaders: 1,
...(forceTweaks
? { farClip: config.farClip, CameraDistanceMax: config.cameraDistance }
: {})
}; };
await fs.writeFile( const body = Object.entries(parsed)
configPath, .filter(v => v[1] !== undefined && v[1] !== null)
Object.entries(parsed) .map(l => `SET ${l[0]} "${l[1]}"`)
.filter(v => v[1] !== undefined && v[1] !== null) .join('\n');
.map(l => `SET ${l[0]} "${l[1]}"`) const tmpPath = `${configPath}.tmp`;
.join('\n') await fs.writeFile(tmpPath, body);
); await fs.move(tmpPath, configPath, { overwrite: true });
Logger.log('Config.wtf successfully patched'); Logger.log('Config.wtf successfully patched');
}; };
+64 -15
View File
@@ -3,6 +3,7 @@ import path from 'path';
import fs from 'fs-extra'; import fs from 'fs-extra';
import { type z } from 'zod'; import { type z } from 'zod';
import { app } from 'electron'; import { app } from 'electron';
import Logger from 'electron-log/main';
import { PreferencesSchema } from '~common/schemas'; import { PreferencesSchema } from '~common/schemas';
import { omit } from '~common/utils'; import { omit } from '~common/utils';
@@ -11,6 +12,7 @@ const portableDir = process.env.PORTABLE_EXECUTABLE_DIR;
abstract class Preferences { abstract class Preferences {
static #data: z.infer<typeof PreferencesSchema>; static #data: z.infer<typeof PreferencesSchema>;
static #writeChain: Promise<void> = Promise.resolve();
static readonly userDataDir = process.env.PORTABLE_EXECUTABLE_DIR static readonly userDataDir = process.env.PORTABLE_EXECUTABLE_DIR
? path.join(process.env.PORTABLE_EXECUTABLE_DIR, '.launcher') ? path.join(process.env.PORTABLE_EXECUTABLE_DIR, '.launcher')
@@ -18,21 +20,44 @@ abstract class Preferences {
static async load() { static async load() {
await fs.ensureDir(this.userDataDir); await fs.ensureDir(this.userDataDir);
const settingsPath = path.join(this.userDataDir, 'settings.json');
const userDataPath = path.join(this.userDataDir, 'settings.json'); let json: Record<string, unknown>;
try { try {
const json = await fs.readJSON(userDataPath); json = await fs.readJSON(settingsPath);
return PreferencesSchema.parse({ } catch {
...json,
isPortable: !!portableDir,
clientDir: portableDir ?? json.clientDir
});
} catch (e) {
return PreferencesSchema.parse({ return PreferencesSchema.parse({
isPortable: !!portableDir, isPortable: !!portableDir,
clientDir: portableDir clientDir: portableDir
}); });
} }
const merged = {
...json,
isPortable: !!portableDir,
clientDir: portableDir ?? json.clientDir
};
const parsed = PreferencesSchema.safeParse(merged);
if (parsed.success) return parsed.data;
Logger.warn(
'settings.json failed validation; salvaging valid fields',
parsed.error
);
await fs.copy(settingsPath, `${settingsPath}.corrupt`).catch(() => {});
const salvaged: Record<string, unknown> = {
isPortable: !!portableDir,
clientDir: portableDir ?? json.clientDir
};
const shape = PreferencesSchema.shape;
for (const key of Object.keys(shape) as (keyof typeof shape)[]) {
if (!(key in merged)) continue;
const value = (merged as Record<string, unknown>)[key];
if (shape[key].safeParse(value).success) salvaged[key] = value;
}
return PreferencesSchema.parse(salvaged);
} }
static get data(): PreferencesSchema { static get data(): PreferencesSchema {
@@ -41,14 +66,38 @@ abstract class Preferences {
static set data(newData: Partial<Omit<PreferencesSchema, 'portableDir'>>) { static set data(newData: Partial<Omit<PreferencesSchema, 'portableDir'>>) {
this.#data = { ...this.#data, ...newData }; this.#data = { ...this.#data, ...newData };
fs.writeJSON(
path.join(this.userDataDir, 'settings.json'), const settingsPath = path.join(this.userDataDir, 'settings.json');
omit( const delta = omit(
this.#data, newData,
portableDir ? ['isPortable', 'clientDir'] : ['isPortable'] portableDir ? ['isPortable', 'clientDir'] : ['isPortable']
),
{ spaces: 2 }
); );
const snapshot = omit(
this.#data,
portableDir ? ['isPortable', 'clientDir'] : ['isPortable']
);
this.#writeChain = this.#writeChain
.then(async () => {
let onDisk: unknown = null;
try {
onDisk = await fs.readJSON(settingsPath);
} catch {
onDisk = null;
}
const base =
!!onDisk && typeof onDisk === 'object' && !Array.isArray(onDisk)
? (onDisk as Record<string, unknown>)
: null;
const merged = base ? { ...base, ...delta } : snapshot;
const tmp = `${settingsPath}.tmp`;
await fs.writeJSON(tmp, merged, { spaces: 2 });
await fs.move(tmp, settingsPath, { overwrite: true });
})
.catch(e => Logger.error('Failed to persist settings.json', e));
}
static save() {
return this.#writeChain;
} }
static async isValidClientDir(clientDir?: string) { static async isValidClientDir(clientDir?: string) {
+12 -5
View File
@@ -10,7 +10,12 @@ export type SelfUpdaterStatus =
| { state: 'checking'; currentVersion: string } | { state: 'checking'; currentVersion: string }
| { state: 'unavailable'; currentVersion: string } | { state: 'unavailable'; currentVersion: string }
| { state: 'available'; currentVersion: string; nextVersion: string } | { state: 'available'; currentVersion: string; nextVersion: string }
| { state: 'downloading'; currentVersion: string; nextVersion: string; progress: number } | {
state: 'downloading';
currentVersion: string;
nextVersion: string;
progress: number;
}
| { state: 'ready'; currentVersion: string; nextVersion: string } | { state: 'ready'; currentVersion: string; nextVersion: string }
| { state: 'error'; currentVersion: string; message: string }; | { state: 'error'; currentVersion: string; message: string };
@@ -37,7 +42,7 @@ class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
this.#initialized = true; this.#initialized = true;
if (is.dev) { if (is.dev) {
Logger.info('[selfUpdater] dev mode skipping'); Logger.info('[selfUpdater] dev mode, skipping');
return; return;
} }
@@ -83,7 +88,7 @@ class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
}); });
autoUpdater.on('update-downloaded', info => { autoUpdater.on('update-downloaded', info => {
Logger.info( Logger.info(
`[selfUpdater] downloaded ${info.version} awaiting user click` `[selfUpdater] downloaded ${info.version}, awaiting user click`
); );
this.status = { this.status = {
state: 'ready', state: 'ready',
@@ -100,11 +105,13 @@ class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
triggerInstall() { triggerInstall() {
if (this._value.state !== 'ready') { if (this._value.state !== 'ready') {
Logger.warn( Logger.warn(
`[selfUpdater] triggerInstall called in state ${this._value.state} ignoring` `[selfUpdater] triggerInstall called in state ${this._value.state}, ignoring`
); );
return; return;
} }
Logger.info('[selfUpdater] user clicked install — quitting + running installer'); Logger.info(
'[selfUpdater] user clicked install, quitting + running installer'
);
autoUpdater.quitAndInstall(false, true); autoUpdater.quitAndInstall(false, true);
} }
} }
+173 -51
View File
@@ -50,6 +50,7 @@ const getAvailableDiskSpace = async (probePath?: string): Promise<number> => {
os.homedir() || os.homedir() ||
(os.platform() === 'win32' ? 'C:\\' : '/'); (os.platform() === 'win32' ? 'C:\\' : '/');
try { try {
// @ts-expect-error statfs exists at runtime (Node 18) but not @types/node 16
const s = await fs.promises.statfs(target); const s = await fs.promises.statfs(target);
return Number(s.bsize) * Number(s.bavail); return Number(s.bsize) * Number(s.bavail);
} catch (e) { } catch (e) {
@@ -65,11 +66,23 @@ const isReadOnly = async (filePath: string) => {
try { try {
const { mode } = await fs.stat(filePath); const { mode } = await fs.stat(filePath);
return !(mode & fs.constants.S_IWUSR); return !(mode & fs.constants.S_IWUSR);
} catch (e) { } catch {
return false; return false;
} }
}; };
const friendlyError = (e: unknown): string => {
const msg = e instanceof Error ? e.message : String(e);
if (/EPERM|EACCES|EROFS|read-only/i.test(msg))
return (
'Cannot write to the game folder. Move your OctoWoW install out of ' +
'Program Files (or any protected folder), or run the launcher as ' +
'administrator, then try again.'
);
if (/ENOSPC/i.test(msg)) return 'Not enough disk space to finish the update.';
return msg;
};
type FolderTags = 'allowExtra'; type FolderTags = 'allowExtra';
type FileTags = 'vanillaFixes'; type FileTags = 'vanillaFixes';
type FileManifest = { name: string } & ( type FileManifest = { name: string } & (
@@ -141,18 +154,54 @@ export const isGameRunning = (executablePath: string) =>
}) })
: false; : false;
const toUrlPath = (p: string) => p.split(path.sep).map(encodeURIComponent).join('/'); const toUrlPath = (p: string) =>
p.split(path.sep).map(encodeURIComponent).join('/');
const CDN_VERSION = import.meta.env.MAIN_VITE_CLIENT_VERSION || 'latest'; const CDN_VERSION = import.meta.env.MAIN_VITE_CLIENT_VERSION || 'latest';
const CONNECT_TIMEOUT_MS = 30_000;
const STALL_TIMEOUT_MS = 60_000;
const isUnsafeName = (name: string) =>
!name ||
name === '.' ||
name === '..' ||
/[/\\]/.test(name) ||
path.isAbsolute(name);
const manifestPathsSafe = (m: FileManifest, isRoot = false): boolean => {
if (!isRoot && isUnsafeName(m.name)) return false;
if (m.type === 'dir' || m.type === 'mpq')
return m.files.every(f => manifestPathsSafe(f));
return true;
};
const fetchManifest = async () => { const fetchManifest = async () => {
try { try {
const r = await fetch( const r = await fetch(
`${import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'}/api/file/${CDN_VERSION}/manifest.json` `${
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
}/api/file/${CDN_VERSION}/manifest.json`,
{ timeout: CONNECT_TIMEOUT_MS }
); );
if (!r.ok) {
Logger.error(`Update server returned HTTP ${r.status}`);
return null;
}
const j = await r.json(); const j = await r.json();
if (!j || typeof j !== 'object' || !('root' in j) || !j.root) {
Logger.error('Update server returned a malformed manifest');
return null;
}
const root = j.root as FileManifest;
if (!manifestPathsSafe(root, true)) {
Logger.error(
'Update server manifest has unsafe path names; refusing it.'
);
return null;
}
await fs.writeJSON(path.join(Preferences.userDataDir, 'manifest.json'), j); await fs.writeJSON(path.join(Preferences.userDataDir, 'manifest.json'), j);
return j.root as FileManifest; return root;
} catch (e) { } catch (e) {
Logger.error('Failed to reach update server', e); Logger.error('Failed to reach update server', e);
return null; return null;
@@ -160,22 +209,26 @@ const fetchManifest = async () => {
}; };
const buildClientUrl = (filePath: string) => const buildClientUrl = (filePath: string) =>
`${import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'}/client/${CDN_VERSION}/${toUrlPath( `${
path.normalize(filePath) import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
)}`; }/client/${CDN_VERSION}/${toUrlPath(path.normalize(filePath))}`;
export const fetchFile = async ( export const fetchFile = async (
filePath: string, filePath: string,
onChunk?: (deltaBytes: number) => void onChunk?: (deltaBytes: number) => void
) => { ) => {
try { try {
const response = await fetch(buildClientUrl(filePath)); const response = await fetch(buildClientUrl(filePath), {
timeout: CONNECT_TIMEOUT_MS
});
if (!response.ok) throw Error(`HTTP ${response.status}`); if (!response.ok) throw Error(`HTTP ${response.status}`);
if (!onChunk || !response.body) return await response.arrayBuffer(); if (!onChunk || !response.body) return await response.arrayBuffer();
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
for await (const chunk of response.body as NodeJS.ReadableStream) { for await (const chunk of response.body as NodeJS.ReadableStream) {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array); const buf = Buffer.isBuffer(chunk)
? chunk
: Buffer.from(chunk);
chunks.push(buf); chunks.push(buf);
onChunk(buf.byteLength); onChunk(buf.byteLength);
} }
@@ -203,7 +256,8 @@ export const downloadFileToDisk = async (
else if (stats.size >= expectedSize) { else if (stats.size >= expectedSize) {
await fs.truncate(partPath, 0); await fs.truncate(partPath, 0);
} }
} catch { } catch (e) {
Logger.warn(`Failed to resume from "${partPath}".`, e);
} }
if (resumeFrom > 0) onChunk(resumeFrom); if (resumeFrom > 0) onChunk(resumeFrom);
@@ -212,20 +266,25 @@ export const downloadFileToDisk = async (
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (resumeFrom > 0) headers.Range = `bytes=${resumeFrom}-`; if (resumeFrom > 0) headers.Range = `bytes=${resumeFrom}-`;
const controller = new AbortController();
let response; let response;
try { try {
response = await fetch(url, { headers }); response = await fetch(url, {
headers,
signal: controller.signal,
timeout: CONNECT_TIMEOUT_MS
});
} catch (e) { } catch (e) {
Logger.error(`Network error downloading ${filePath}`, e); Logger.error(`Network error downloading ${filePath}`, e);
throw Error(`Failed to download ${path.normalize(filePath)}`); throw Error(`Failed to download ${path.normalize(filePath)}`);
} }
if (!response.ok && response.status !== 206) { if (!response.ok && response.status !== 206) {
throw Error(`Failed to download ${path.normalize(filePath)}: HTTP ${response.status}`); throw Error(
`Failed to download ${path.normalize(filePath)}: HTTP ${response.status}`
);
} }
// If we got 200, the server gave us the whole file
// roll back and truncate
if (resumeFrom > 0 && response.status === 200) { if (resumeFrom > 0 && response.status === 200) {
onChunk(-resumeFrom); onChunk(-resumeFrom);
await fs.truncate(partPath, 0); await fs.truncate(partPath, 0);
@@ -236,6 +295,7 @@ export const downloadFileToDisk = async (
flags: resumeFrom > 0 ? 'a' : 'w' flags: resumeFrom > 0 ? 'a' : 'w'
}); });
let stalled = false;
try { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
if (!response.body) { if (!response.body) {
@@ -243,26 +303,53 @@ export const downloadFileToDisk = async (
return; return;
} }
const body = response.body as NodeJS.ReadableStream; const body = response.body as NodeJS.ReadableStream;
let stallTimer: NodeJS.Timeout;
const armStall = () => {
clearTimeout(stallTimer);
stallTimer = setTimeout(() => {
stalled = true;
controller.abort();
}, STALL_TIMEOUT_MS);
};
armStall();
body.on('data', (chunk: Buffer | Uint8Array) => { body.on('data', (chunk: Buffer | Uint8Array) => {
armStall();
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
if (!writeStream.write(buf)) body.pause(); if (!writeStream.write(buf)) body.pause();
onChunk(buf.byteLength); onChunk(buf.byteLength);
}); });
writeStream.on('drain', () => body.resume()); writeStream.on('drain', () => body.resume());
body.on('end', () => writeStream.end(resolve)); body.on('end', () => {
body.on('error', reject); clearTimeout(stallTimer);
writeStream.on('error', reject); writeStream.end(resolve);
});
body.on('error', err => {
clearTimeout(stallTimer);
reject(err);
});
writeStream.on('error', err => {
clearTimeout(stallTimer);
reject(err);
});
}); });
} catch (e) { } catch (e) {
writeStream.destroy(); writeStream.destroy();
Logger.error(`Download interrupted for ${filePath}`, e); Logger.error(`Download interrupted for ${filePath}`, e);
throw Error(`Failed to download ${path.normalize(filePath)}`); throw Error(
stalled
? `Download stalled for ${path.normalize(
filePath
)}; no data received in ${STALL_TIMEOUT_MS / 1000}s.`
: `Failed to download ${path.normalize(filePath)}`
);
} }
const finalStats = await fs.stat(partPath); const finalStats = await fs.stat(partPath);
if (finalStats.size !== expectedSize) { if (finalStats.size !== expectedSize) {
throw Error( throw Error(
`Size mismatch for ${path.normalize(filePath)}: got ${finalStats.size}, expected ${expectedSize}. Will retry on next run.` `Size mismatch for ${path.normalize(filePath)}: got ${
finalStats.size
}, expected ${expectedSize}. Will retry on next run.`
); );
} }
@@ -336,9 +423,15 @@ class UpdaterClass extends Observable<UpdaterStatus> {
#clientTotalBytes = 0; #clientTotalBytes = 0;
#bytesAlreadyOnDisk = 0; #bytesAlreadyOnDisk = 0;
#cachePath = path.join(Preferences.userDataDir, 'cache.json'); #cachePath = path.join(Preferences.userDataDir, 'cache.json');
#cache: CacheTree = fs.existsSync(this.#cachePath) #cache: CacheTree = this.#readCache();
? fs.readJSONSync(this.#cachePath)
: {}; #readCache(): CacheTree {
try {
return fs.existsSync(this.#cachePath) ? fs.readJSONSync(this.#cachePath) : {};
} catch {
return {};
}
}
async #saveCache() { async #saveCache() {
await fs.writeJSON(this.#cachePath, this.#cache); await fs.writeJSON(this.#cachePath, this.#cache);
@@ -465,6 +558,10 @@ class UpdaterClass extends Observable<UpdaterStatus> {
try { try {
const vanillaFixes = Preferences.data.config.vanillaFixes; const vanillaFixes = Preferences.data.config.vanillaFixes;
const modOwnedFiles = new Set<string>();
for (const state of Object.values(Preferences.data.mods ?? {}))
for (const rel of state?.installedFiles ?? [])
modOwnedFiles.add(rel.replace(/\\/g, '/').toLowerCase());
const hashTree = await fetchManifest(); const hashTree = await fetchManifest();
if (!hashTree) { if (!hashTree) {
@@ -527,7 +624,11 @@ class UpdaterClass extends Observable<UpdaterStatus> {
const item = getManifestItem(hashTree, filePath); const item = getManifestItem(hashTree, filePath);
if (!item) return undefined; if (!item) return undefined;
if (item.type === 'del') return item; if (item.type === 'del') {
if (await fs.exists(path.join(clientPath, ...filePath)))
return item;
return undefined;
}
if (item.type === 'dir') { if (item.type === 'dir') {
const files = ( const files = (
@@ -582,11 +683,10 @@ class UpdaterClass extends Observable<UpdaterStatus> {
SFileCloseArchive(hMpq); SFileCloseArchive(hMpq);
} }
} catch (e) { } catch (e) {
Logger.log( Logger.warn(
`Failed to verify ${path.join( `Failed to verify ${path.join(
...patchPath ...patchPath
)}, will be downloaded fresh`, )}, will be downloaded fresh`,
'warning',
e e
); );
return { return {
@@ -598,13 +698,13 @@ class UpdaterClass extends Observable<UpdaterStatus> {
} }
} }
if (item.tags?.includes('vanillaFixes') && !vanillaFixes) { if (item.tags?.includes('vanillaFixes')) {
if (await fs.exists(path.join(clientPath, ...filePath))) { if (modOwnedFiles.has(filePath.join('/').toLowerCase()))
return { return undefined;
type: 'del',
name: item.name if (!vanillaFixes) {
}; if (await fs.exists(path.join(clientPath, ...filePath)))
} else { return { type: 'del', name: item.name };
return undefined; return undefined;
} }
} }
@@ -678,8 +778,7 @@ class UpdaterClass extends Observable<UpdaterStatus> {
const currentLauncherVersion = app.getVersion(); const currentLauncherVersion = app.getVersion();
if ( if (
this.status.state === 'upToDate' && this.status.state === 'upToDate' &&
Preferences.data.lastPatchedLauncherVersion !== Preferences.data.lastPatchedLauncherVersion !== currentLauncherVersion
currentLauncherVersion
) { ) {
Logger.log( Logger.log(
`Launcher version changed (${ `Launcher version changed (${
@@ -710,8 +809,7 @@ class UpdaterClass extends Observable<UpdaterStatus> {
})(); })();
} }
} catch (e) { } catch (e) {
const message = const message = friendlyError(e);
e instanceof Error ? e.message : 'Unexpected error occurred';
Logger.error(`Verification failed: ${message}`, e); Logger.error(`Verification failed: ${message}`, e);
this.status = { state: 'failed', message }; this.status = { state: 'failed', message };
} }
@@ -744,15 +842,31 @@ class UpdaterClass extends Observable<UpdaterStatus> {
try { try {
if (clean) { if (clean) {
const resolvedClientPath = path.resolve(clientPath);
const isFilesystemRoot =
path.parse(resolvedClientPath).root === resolvedClientPath;
if (
isFilesystemRoot ||
!fs.existsSync(path.join(resolvedClientPath, 'WoW.exe'))
) {
this.status = {
state: 'failed',
message:
'Refusing to clean: the client folder does not look like a valid WoW install.'
};
return;
}
this.status = { this.status = {
state: 'updating', state: 'updating',
progress: -1, progress: -1,
message: 'Cleaning up old files...' message: 'Cleaning up old files...'
}; };
const preserve = new Set(['octolauncher.exe', 'wtf', 'interface', 'screenshots']);
const files = await fs.readdir(clientPath); const files = await fs.readdir(clientPath);
for (const file of files) { for (const file of files) {
if (file === 'OctoLauncher.exe') continue; if (preserve.has(file.toLowerCase())) continue;
await fs.rm(path.join(clientPath, file), { await fs.rm(path.join(clientPath, file), {
recursive: true, recursive: true,
force: true force: true
@@ -804,12 +918,10 @@ class UpdaterClass extends Observable<UpdaterStatus> {
if (!item) return undefined; if (!item) return undefined;
if (item.type === 'del') { if (item.type === 'del') {
throw Error( if (SFileHasFile(hMpq, path.join(...filePath)))
`TODO: Deleting of files from MPQ not implemented at path ${path.join( SFileRemoveFile(hMpq, path.join(...filePath));
...mpqPath, nestedSet(this.#cache, [...mpqPath, ...filePath], undefined);
...filePath return;
)}`
);
} }
if (item.type === 'dir') { if (item.type === 'dir') {
@@ -826,7 +938,9 @@ class UpdaterClass extends Observable<UpdaterStatus> {
)}` )}`
); );
const label = `Patching: [${mpqPath.at(-1)}] "${path.join(...filePath)}"`; const label = `Patching: [${mpqPath.at(-1)}] "${path.join(
...filePath
)}"`;
emitProgress(label, true); emitProgress(label, true);
const data = await fetchFile( const data = await fetchFile(
@@ -853,6 +967,7 @@ class UpdaterClass extends Observable<UpdaterStatus> {
} finally { } finally {
SFileFinishFile(hFile); SFileFinishFile(hFile);
} }
nestedSet(this.#cache, [...mpqPath, ...filePath], undefined);
}; };
const iterateTree = async (...filePath: string[]) => { const iterateTree = async (...filePath: string[]) => {
@@ -922,7 +1037,7 @@ class UpdaterClass extends Observable<UpdaterStatus> {
if (item.name === 'WoW.exe') executableUpdate = true; if (item.name === 'WoW.exe') executableUpdate = true;
const fullPath = path.join(clientPath, ...filePath); const fullPath = path.join(clientPath, ...filePath);
if (await fs.exists(fullPath) && (await isReadOnly(fullPath))) if ((await fs.exists(fullPath)) && (await isReadOnly(fullPath)))
throw Error(`Failed to update "${fullPath}" because it's read-only.`); throw Error(`Failed to update "${fullPath}" because it's read-only.`);
await downloadFileToDisk( await downloadFileToDisk(
@@ -947,7 +1062,6 @@ class UpdaterClass extends Observable<UpdaterStatus> {
if (executableUpdate || launcherVersionChanged) { if (executableUpdate || launcherVersionChanged) {
await patchExecutable(); await patchExecutable();
await this.#getHash({ clientPath }, 'WoW.exe');
const patchedWowHash = await this.#getHash({ clientPath }, 'WoW.exe'); const patchedWowHash = await this.#getHash({ clientPath }, 'WoW.exe');
await this.#saveCache(); await this.#saveCache();
Preferences.data = { Preferences.data = {
@@ -960,13 +1074,21 @@ class UpdaterClass extends Observable<UpdaterStatus> {
this.#bytesAlreadyOnDisk = fullClientTotal; this.#bytesAlreadyOnDisk = fullClientTotal;
this.status = { state: 'upToDate', progress: 1 }; this.status = { state: 'upToDate', progress: 1 };
} catch (e) { } catch (e) {
console.error(e); Logger.error('Update failed', e);
this.status = { this.status = { state: 'failed', message: friendlyError(e) };
state: 'failed',
message: e instanceof Error ? e.message : 'Unexpected error occurred'
};
} }
} }
async recordPatchedWow() {
const clientPath = Preferences.data.clientDir;
if (!clientPath) return;
const patchedWowHash = await this.#getHash({ clientPath }, 'WoW.exe');
await this.#saveCache();
Preferences.data = {
lastPatchedLauncherVersion: app.getVersion(),
expectedPatchedWowHash: patchedWowHash
};
}
} }
const Updater = new UpdaterClass(); const Updater = new UpdaterClass();
+5 -1
View File
@@ -5,4 +5,8 @@ export {
type ModsStatus, type ModsStatus,
type ModRowStatus type ModRowStatus
} from './modules/mods'; } from './modules/mods';
export { type NewsItem, type NewsFeed } from '../common/schemas'; export {
type NewsItem,
type NewsFeed,
type ForumAnnouncement
} from '../common/schemas';
+33 -7
View File
@@ -6,8 +6,15 @@ import fs from 'fs-extra';
import Preferences from './modules/preferences'; import Preferences from './modules/preferences';
const isCallbackResponse = (data: any): data is { cb: string; args: any[] } => const isCallbackResponse = (
data && typeof data === 'object' && 'cb' in data && 'args' in data; data: unknown
): data is { cb: string; args: unknown[] } =>
typeof data === 'object' &&
data !== null &&
'cb' in data &&
typeof (data as { cb: unknown }).cb === 'string' &&
'args' in data &&
Array.isArray((data as { args: unknown }).args);
export const runWorker = <T>( export const runWorker = <T>(
worker: (o: WorkerOptions) => Worker, worker: (o: WorkerOptions) => Worker,
@@ -16,10 +23,16 @@ export const runWorker = <T>(
) => ) =>
new Promise<T>((resolve, reject) => new Promise<T>((resolve, reject) =>
worker({ workerData }) worker({ workerData })
.on('message', m => .on('message', (m: unknown) => {
isCallbackResponse(m) ? callbacks?.[m.cb](...m.args) : resolve(m) if (!isCallbackResponse(m)) return resolve(m as T);
) const callback = callbacks?.[m.cb];
if (callback) callback(...m.args);
else Logger.warn('Unknown worker callback', m.cb);
})
.on('error', reject) .on('error', reject)
.on('exit', code =>
reject(new Error(`Worker exited (code ${code}) without finishing`))
)
); );
export const getClientVersion = async () => { export const getClientVersion = async () => {
@@ -35,8 +48,21 @@ export const getClientVersion = async () => {
const file = await fs.readFile(exePath); const file = await fs.readFile(exePath);
const buffer = Buffer.from(file); const buffer = Buffer.from(file);
const version = buffer.toString('utf-8', 0x00437c04, 0x00437c04 + 6); const VERSION_OFFSET = 0x00437c04;
const build = buffer.toString('utf-8', 0x00437bfc, 0x00437bfc + 4); const VERSION_LEN = 6;
const BUILD_OFFSET = 0x00437bfc;
const BUILD_LEN = 4;
const version = buffer.toString(
'utf-8',
VERSION_OFFSET,
VERSION_OFFSET + VERSION_LEN
);
const build = buffer.toString(
'utf-8',
BUILD_OFFSET,
BUILD_OFFSET + BUILD_LEN
);
Logger.log(`Client version is: ${version} (${build})`); Logger.log(`Client version is: ${version} (${build})`);
return `${version} (${build})`; return `${version} (${build})`;
+18 -6
View File
@@ -9,15 +9,27 @@ if (!port) throw new Error('IllegalState');
const { dir, url, ref } = workerData; const { dir, url, ref } = workerData;
fs.removeSync(dir); const tmpDir = `${dir}.tmp`;
git
.clone({ const run = async () => {
dir, await fs.remove(tmpDir);
await git.clone({
dir: tmpDir,
fs, fs,
http, http,
url, url,
ref, ref,
singleBranch: !ref || ref === 'master' || ref === 'main', singleBranch: !ref || ref === 'master' || ref === 'main',
onProgress: (...args) => port.postMessage({ cb: 'onProgress', args }) onProgress: (...args) => port.postMessage({ cb: 'onProgress', args })
}) });
.then(() => port.postMessage(true));
await fs.remove(dir);
await fs.move(tmpDir, dir);
};
run()
.then(() => port.postMessage(true))
.catch(async err => {
await fs.remove(tmpDir).catch(() => undefined);
throw err;
});
+14 -14
View File
@@ -5,7 +5,7 @@ import http from 'isomorphic-git/http/node';
import fs from 'fs-extra'; import fs from 'fs-extra';
const port = parentPort; const port = parentPort;
if (!port) throw new Error('IllegalState'); if (!port) throw new Error('gitPull worker has no parentPort');
const { dir, remote, branch, ref } = workerData as { const { dir, remote, branch, ref } = workerData as {
dir: string; dir: string;
@@ -17,20 +17,17 @@ const { dir, remote, branch, ref } = workerData as {
const onProgress = (...args: unknown[]) => const onProgress = (...args: unknown[]) =>
port.postMessage({ cb: 'onProgress', args }); port.postMessage({ cb: 'onProgress', args });
const removeUntrackedFiles = async () => {
const status = await git.statusMatrix({ fs, dir });
await Promise.all(
status
.filter(([, HEAD]) => HEAD === 0)
.map(([filepath]) => fs.remove(`${dir}/${filepath}`))
);
};
const run = async () => { const run = async () => {
if (ref) { if (ref) {
await git.fetch({ fs, http, dir, tags: true, singleBranch: false, onProgress }); await git.fetch({
fs,
http,
dir,
tags: true,
singleBranch: false,
onProgress
});
await git.checkout({ fs, dir, force: true, ref, onProgress }); await git.checkout({ fs, dir, force: true, ref, onProgress });
await removeUntrackedFiles();
return; return;
} }
@@ -41,7 +38,6 @@ const run = async () => {
ref: `${remote}/${branch}`, ref: `${remote}/${branch}`,
onProgress onProgress
}); });
await removeUntrackedFiles();
await git.pull({ await git.pull({
fs, fs,
http, http,
@@ -53,4 +49,8 @@ const run = async () => {
}); });
}; };
run().then(() => port.postMessage(true)); run()
.then(() => port.postMessage(true))
.catch(err => {
throw err;
});
+4 -2
View File
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { api } from './utils/api'; import { api } from './utils/api';
import PageBackground from './assets/background.png'; import PageBackground from './assets/background.png';
import AntivirusModal from './components/AntivirusModal';
import Header from './components/Header'; import Header from './components/Header';
import LaunchPanel from './components/LaunchPanel'; import LaunchPanel from './components/LaunchPanel';
import SelfUpdateBanner from './components/SelfUpdateBanner'; import SelfUpdateBanner from './components/SelfUpdateBanner';
@@ -38,12 +39,13 @@ const App = () => {
</> </>
)} )}
{/* Launcher build label, anchored bottom-right.*/}
{appVersion && ( {appVersion && (
<span className="pointer-events-none absolute bottom-2 right-3 text-[10px] font-mono uppercase tracking-wider text-white/40 select-none"> <span className="pointer-events-none absolute bottom-2 right-3 select-none font-mono text-[10px] uppercase tracking-wider text-white/40">
v{appVersion} v{appVersion}
</span> </span>
)} )}
<AntivirusModal />
</div> </div>
); );
}; };
+56 -42
View File
@@ -2,6 +2,8 @@ import { Clipboard, RefreshCw, ServerCrash } from 'lucide-react';
import { Component, type ErrorInfo, type ReactNode } from 'react'; import { Component, type ErrorInfo, type ReactNode } from 'react';
import log from 'electron-log/renderer'; import log from 'electron-log/renderer';
import { useT } from '~renderer/i18n';
import PageBackground from './assets/background.png'; import PageBackground from './assets/background.png';
import TextButton from './components/styled/TextButton'; import TextButton from './components/styled/TextButton';
@@ -15,6 +17,59 @@ type Props = {
children: ReactNode; children: ReactNode;
}; };
const ErrorFallback = ({
error,
errorInfo
}: {
error?: Error;
errorInfo?: ErrorInfo;
}) => {
const t = useT();
const title = t('misc.uncaughtError', {
name: error?.name ?? '',
message: error?.message ?? t('misc.unknownError')
});
const detail = errorInfo?.componentStack?.slice(1);
return (
<div
className="relative flex h-screen w-screen grow flex-col overflow-hidden border border-blueGray/10 bg-cover bg-top bg-no-repeat p-3"
style={{ backgroundImage: `url(${PageBackground})` }}
>
<div className="tw-surface flex grow flex-col gap-3">
<div className="flex items-center gap-2">
<ServerCrash size={26} className="text-red" />
<h3 className="text-red">{t('misc.somethingWentWrong')}</h3>
</div>
<hr />
<div className="text-white">{title}</div>
<pre className="s1 -mt-2 grow overflow-auto text-blueGray">
{detail}
</pre>
<hr />
<div className="-mx-3 -mb-3 flex justify-end gap-2">
<TextButton
icon={Clipboard}
onClick={() =>
navigator.clipboard.writeText(
`\`\`\`\n${title}\n${detail}\n\`\`\``
)
}
>
{t('misc.copyError')}
</TextButton>
<TextButton
icon={RefreshCw}
onClick={() => window.location.reload()}
className="text-warmGreen"
>
{t('misc.reload')}
</TextButton>
</div>
</div>
</div>
);
};
class ErrorBoundary extends Component<Props, State> { class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
@@ -29,48 +84,7 @@ class ErrorBoundary extends Component<Props, State> {
render() { render() {
if (!this.state.didCatch) return this.props.children; if (!this.state.didCatch) return this.props.children;
const { error, errorInfo } = this.state; const { error, errorInfo } = this.state;
const title = `Uncaught ${error?.name}: ${ return <ErrorFallback error={error} errorInfo={errorInfo} />;
error?.message ?? 'Unknown error'
}`;
const detail = errorInfo?.componentStack.slice(1);
return (
<div
className="relative flex h-screen w-screen grow flex-col overflow-hidden border border-blueGray/10 bg-cover bg-top bg-no-repeat p-3"
style={{ backgroundImage: `url(${PageBackground})` }}
>
<div className="tw-surface flex grow flex-col gap-3">
<div className="flex items-center gap-2">
<ServerCrash size={26} className="text-red" />
<h3 className="text-red">Something went wrong!</h3>
</div>
<hr />
<div className="text-white">{title}</div>
<pre className="s1 -mt-2 grow overflow-auto text-blueGray">
{detail}
</pre>
<hr />
<div className="-mx-3 -mb-3 flex justify-end gap-2">
<TextButton
icon={Clipboard}
onClick={() =>
navigator.clipboard.writeText(
`\`\`\`\n${title}\n${detail}\n\`\`\``
)
}
>
Copy error
</TextButton>
<TextButton
icon={RefreshCw}
onClick={() => window.location.reload()}
className="text-warmGreen"
>
Reload
</TextButton>
</div>
</div>
</div>
);
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+194
View File
@@ -0,0 +1,194 @@
import { useEffect, useRef, useState } from 'react';
import { ShieldAlert, HelpCircle } from 'lucide-react';
import { createPortal } from 'react-dom';
import { api } from '~renderer/utils/api';
import { type ModsStatus } from '~main/types';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton';
const DETECTION = 'Trojan:Win32/Vigorf.A';
const AntivirusModal = () => {
const t = useT();
const [status, setStatus] = useState<ModsStatus>();
api.mods.observe.useSubscription(undefined, { onData: setStatus });
const addExclusion = api.general.addDefenderExclusion.useMutation();
const blocked = (status?.mods ?? [])
.filter(m => m.state === 'error' && m.error?.includes('Defender'))
.map(m => m.name);
const blockedKey = blocked.join(',');
const dialogRef = useRef<HTMLDialogElement>(null);
const [view, setView] = useState<'av' | 'why' | null>(null);
useEffect(() => {
if (view) {
dialogRef.current?.showModal();
(document.activeElement as HTMLElement | null)?.blur();
} else dialogRef.current?.close();
}, [view]);
useEffect(() => {
if (blockedKey) setView('av');
}, [blockedKey]);
const names = blockedKey ? blockedKey.split(',') : [];
return createPortal(
<dialog
ref={dialogRef}
onClose={() => setView(null)}
className="h-full w-full items-center justify-center bg-[transparent] backdrop:backdrop-blur-sm [&[open]]:flex"
>
{view === 'av' && (
<div className="tw-dialog !w-fit min-w-[380px] max-w-[480px] !gap-3">
<h3 className="tw-color">{t('av.blockedTitle')}</h3>
<p className="s1">
{names.length === 1 ? t('av.blockedOne') : t('av.blockedMany')}
</p>
<ul className="gap-0.5 flex flex-col pl-1">
{names.map(n => (
<li key={n} className="s1 text-orange">
{n}
</li>
))}
</ul>
<p className="s1 text-blueGray">{t('av.blockedExplain')}</p>
<TextButton
icon={HelpCircle}
onClick={() => setView('why')}
className="self-start text-yellow hocus:!text-yellow"
>
{t('av.whyHappening')}
</TextButton>
<TextButton
icon={ShieldAlert}
loading={addExclusion.isLoading}
onClick={() => addExclusion.mutate()}
className="self-start text-orange"
>
{t('av.allowThrough')}
</TextButton>
{addExclusion.data?.ok === true && (
<span className="s1 text-warmGreen">{t('av.addedRetry')}</span>
)}
{addExclusion.data?.ok === false && (
<span className="s1 text-orange">{addExclusion.data.error}</span>
)}
<div className="mt-1 flex justify-end">
<TextButton onClick={() => setView(null)} className="text-green">
{t('av.close')}
</TextButton>
</div>
</div>
)}
{view === 'why' && (
<div className="tw-dialog !w-fit min-w-[420px] max-w-[560px] !gap-3">
<h3 className="tw-color">{t('av.whyTitle')}</h3>
<div className="flex max-h-[60vh] transform-gpu flex-col gap-3 overflow-y-auto overscroll-contain pr-1 [will-change:transform]">
<p className="s1 text-blueGray">
{t('av.whyIntro', { detection: DETECTION })}{' '}
<span className="text-warmGreen">{t('av.falsePositive')}</span>.
</p>
<div>
<p className="tw-color text-[21px] leading-tight">
{t('av.whatSetsItOff')}
</p>
<p className="s1 text-blueGray">{t('av.whatSetsItOffIntro')}</p>
<ul className="mt-1 flex flex-col gap-1 pl-1">
<li className="s1 text-blueGray">
<span className="text-orange">{t('av.dllInjection')}</span>:{' '}
{t('av.dllInjectionText')}
</li>
<li className="s1 text-blueGray">
<span className="text-orange">{t('av.exePatching')}</span>:{' '}
{t('av.exePatchingText')}
</li>
</ul>
<p className="s1 mt-1 text-blueGray">{t('av.heuristicNote')}</p>
</div>
<div>
<p className="tw-color text-[21px] leading-tight">
{t('av.whatModsDo')}
</p>
<ul className="mt-1 flex flex-col gap-1 pl-1">
<li className="s1 text-blueGray">
<span className="text-warmGreen">VanillaFixes</span>:{' '}
{t('av.modVanillaFixes')}
</li>
<li className="s1 text-blueGray">
<span className="text-warmGreen">nampower</span>:{' '}
{t('av.modNampower')}
</li>
<li className="s1 text-blueGray">
<span className="text-warmGreen">SuperWoW and UnitXP</span>:{' '}
{t('av.modSuperWowUnitXp')}
</li>
<li className="s1 text-blueGray">
<span className="text-warmGreen">DXVK</span>:{' '}
{t('av.modDxvk')}
</li>
</ul>
</div>
<div>
<p className="tw-color text-[21px] leading-tight">
{t('av.howToVerify')}
</p>
<p className="s1 mt-1 text-blueGray">
{t('av.howToVerifyText', {
detection: DETECTION
})}
</p>
</div>
<div>
<p className="tw-color text-[21px] leading-tight">
{t('av.whatAllowDoesTitle')}
</p>
<p className="s1 mt-1 text-blueGray">
{t('av.whatAllowDoesIntro')}{' '}
<span className="text-warmGreen">Add-MpPreference</span>{' '}
{t('av.whatAllowDoesIntroAfter')}
</p>
<ul className="mt-1 flex flex-col gap-1 pl-1">
<li className="s1 text-blueGray">
{t('av.exclusionFoldersBefore')}{' '}
<span className="text-warmGreen">{t('av.gameFolder')}</span>{' '}
{t('av.exclusionFoldersAnd')}{' '}
<span className="text-warmGreen">
{t('av.launcherFolder')}
</span>
{t('av.exclusionFoldersAfter')}
</li>
<li className="s1 text-blueGray">
<span className="text-warmGreen">WoW.exe</span>{' '}
{t('av.exclusionExesAnd')}{' '}
<span className="text-warmGreen">VanillaFixes.exe</span>{' '}
{t('av.exclusionExesAfter')}
</li>
</ul>
<p className="s1 mt-1 text-blueGray">
{t('av.whatAllowDoesOutro')}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-3">
<TextButton onClick={() => setView('av')} className="text-blueGray">
{t('av.back')}
</TextButton>
<TextButton onClick={() => setView(null)} className="text-green">
{t('av.close')}
</TextButton>
</div>
</div>
)}
</dialog>,
document.body
);
};
export default AntivirusModal;
+14 -19
View File
@@ -4,6 +4,7 @@ import { useEffect } from 'react';
import { PreferencesSchema } from '~common/schemas'; import { PreferencesSchema } from '~common/schemas';
import zodResolver from '~renderer/utils/zodResolver'; import zodResolver from '~renderer/utils/zodResolver';
import { api } from '~renderer/utils/api'; import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton'; import TextButton from './styled/TextButton';
import FilePickerInput from './form/FilePickerInput'; import FilePickerInput from './form/FilePickerInput';
@@ -12,6 +13,7 @@ import CloseButton from './styled/CloseButton';
type Props = { close: () => void }; type Props = { close: () => void };
const ClientDirDialog = ({ close }: Props) => { const ClientDirDialog = ({ close }: Props) => {
const t = useT();
const { data: pref } = api.preferences.get.useQuery(); const { data: pref } = api.preferences.get.useQuery();
const setPref = api.preferences.set.useMutation(); const setPref = api.preferences.set.useMutation();
const isValidClientDir = api.preferences.isValidClientDir.useQuery( const isValidClientDir = api.preferences.isValidClientDir.useQuery(
@@ -42,16 +44,14 @@ const ClientDirDialog = ({ close }: Props) => {
return ( return (
<form className="tw-dialog"> <form className="tw-dialog">
<CloseButton close={close} /> <CloseButton close={close} />
<h2 className="color mb-2 text-xl">Install location</h2> <h2 className="color mb-2 text-xl">
<p> {t('prefs.installLocationTitle')}
You are using the portable version of the launcher. Install location </h2>
is determined by the location of the launcher executable. <p>{t('prefs.portableInfo')}</p>
</p>
{!isValidClientDir.isLoading && !isValidClientDir.data && ( {!isValidClientDir.isLoading && !isValidClientDir.data && (
<p> <p>
<span className="text-secondary">Error: </span> <span className="text-secondary">{t('prefs.errorLabel')}</span>
WoW.exe not found in current folder. Please close the launcher and {t('prefs.wowExeNotFound', { exe: 'WoW.exe' })}
move it to your WoW 1.12 client directory.
</p> </p>
)} )}
</form> </form>
@@ -64,7 +64,7 @@ const ClientDirDialog = ({ close }: Props) => {
onSubmit={handleSubmit(async ({ clientDir }) => { onSubmit={handleSubmit(async ({ clientDir }) => {
try { try {
await setPref.mutateAsync({ clientDir }); await setPref.mutateAsync({ clientDir });
verify.mutateAsync(); verify.mutate();
close(); close();
} catch (e) { } catch (e) {
setError('clientDir', { setError('clientDir', {
@@ -79,18 +79,13 @@ const ClientDirDialog = ({ close }: Props) => {
close(); close();
}} }}
/> />
<h3 className="tw-color">Install location</h3> <h3 className="tw-color">{t('prefs.installLocationTitle')}</h3>
<hr /> <hr />
<p className="text-blueGray"> <p className="text-blueGray">{t('prefs.selectDirectory')}</p>
Select a directory for the game client installation. <p className="text-blueGray">{t('prefs.upgradeExisting')}</p>
</p>
<p className="text-blueGray">
You may also choose a directory with an existing Turtle WoW or Vanilla
WoW installation, and it will be automatically upgraded.
</p>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<label htmlFor="clientDir">Install directory:</label> <label htmlFor="clientDir">{t('prefs.installDirectory')}</label>
<FilePickerInput <FilePickerInput
{...register('clientDir')} {...register('clientDir')}
title={watch('clientDir') ?? undefined} title={watch('clientDir') ?? undefined}
@@ -115,7 +110,7 @@ const ClientDirDialog = ({ close }: Props) => {
loading={formState.isSubmitting} loading={formState.isSubmitting}
className="self-end text-green" className="self-end text-green"
> >
Confirm {t('prefs.confirm')}
</TextButton> </TextButton>
</form> </form>
); );
@@ -0,0 +1,218 @@
import DOMPurify from 'dompurify';
import { AlertTriangle, ExternalLink, RefreshCw } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import useScrollHint from '~renderer/utils/useScrollHint';
import Parchment from '~renderer/assets/parchment.jpg';
import IconSpinner from './styled/IconSpinner';
import TextButton from './styled/TextButton';
const SANITIZE_CONFIG = {
ALLOWED_TAGS: [
'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'a', 'ul', 'ol', 'li',
'blockquote', 'cite', 'span', 'div', 'img', 'br', 'p', 'code', 'pre',
'dl', 'dt', 'dd', 'hr', 'sub', 'sup', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'table', 'thead', 'tbody', 'tr', 'td', 'th'
],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'style'],
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:)/i,
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'style', 'link', 'meta']
};
const LIGHT_NAMED = new Set([
'white', 'snow', 'ivory', 'floralwhite', 'ghostwhite', 'seashell', 'beige',
'linen', 'cornsilk', 'lightyellow', 'lightgoldenrodyellow', 'lemonchiffon',
'yellow', 'aqua', 'cyan', 'lime', 'aquamarine', 'azure', 'mintcream',
'honeydew', 'lavender', 'lavenderblush', 'aliceblue', 'whitesmoke',
'gainsboro', 'silver', 'lightgray', 'lightgrey', 'antiquewhite', 'papayawhip',
'blanchedalmond', 'bisque', 'moccasin', 'navajowhite', 'peachpuff', 'khaki',
'wheat', 'greenyellow', 'chartreuse', 'springgreen', 'palegoldenrod', 'gold'
]);
const LIGHT_THRESHOLD = 165;
const SAFE_STYLE_PROPS = new Set([
'color',
'background-color',
'font-weight',
'font-style',
'text-decoration',
'text-align',
'font-size'
]);
const colorIsTooLight = (raw: string): boolean => {
const v = raw.trim().toLowerCase();
const lum = (r: number, g: number, b: number) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
const hex = v.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
if (hex) {
const h =
hex[1].length === 3
? hex[1]
.split('')
.map(c => c + c)
.join('')
: hex[1];
return (
lum(
parseInt(h.slice(0, 2), 16),
parseInt(h.slice(2, 4), 16),
parseInt(h.slice(4, 6), 16)
) > LIGHT_THRESHOLD
);
}
const rgb = v.match(/^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/);
if (rgb) return lum(+rgb[1], +rgb[2], +rgb[3]) > LIGHT_THRESHOLD;
return LIGHT_NAMED.has(v);
};
const clampFontSize = (val: string): string => {
const m = val.match(/^(\d+(?:\.\d+)?)(px|pt|%|em|rem)$/i);
if (!m) return val;
const n = parseFloat(m[1]);
const unit = m[2].toLowerCase();
const max = unit === 'px' ? 28 : unit === 'pt' ? 21 : unit === '%' ? 200 : 2;
return `${Math.min(n, max)}${unit}`;
};
DOMPurify.addHook('afterSanitizeAttributes', node => {
if (node.tagName === 'IMG') node.setAttribute('loading', 'lazy');
const style = node.getAttribute('style');
if (!style) return;
const kept: string[] = [];
for (const decl of style.split(';')) {
const idx = decl.indexOf(':');
if (idx < 0) continue;
const prop = decl.slice(0, idx).trim().toLowerCase();
let val = decl.slice(idx + 1).trim();
if (!val || !SAFE_STYLE_PROPS.has(prop)) continue;
if (prop === 'color' && colorIsTooLight(val)) continue;
if (prop === 'font-size') val = clampFontSize(val);
kept.push(`${prop}:${val}`);
}
if (kept.length) node.setAttribute('style', kept.join(';'));
else node.removeAttribute('style');
});
const formatDate = (raw: string) => {
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return raw;
return d.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const ForumAnnouncementPanel = () => {
const t = useT();
const openLink = api.general.openLink.useMutation();
const query = api.forum.latestAnnouncement.useQuery(undefined, {
staleTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1
});
const scrollRef = useScrollHint<HTMLDivElement>();
const data = query.data;
const safeHtml = useMemo(
() => (data ? DOMPurify.sanitize(data.html, SANITIZE_CONFIG) : ''),
[data]
);
const onBodyClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const anchor = (e.target as HTMLElement).closest('a[href]');
if (!anchor) return;
e.preventDefault();
const href = anchor.getAttribute('href');
if (href) openLink.mutateAsync(href);
},
[openLink]
);
return (
<aside
className="parchment-post flex min-h-0 flex-grow flex-col gap-3"
style={{ backgroundImage: `url(${Parchment})` }}
>
<div className="flex items-start justify-between gap-2">
<h4 className="parchment-post-title min-w-0 flex-1 break-words">
{data?.title ?? t('forum.title')}
</h4>
<TextButton
icon={RefreshCw}
size={18}
className="-mr-2 -mt-1 shrink-0"
loading={query.isFetching}
onClick={() => query.refetch()}
title={t('misc.refresh')}
/>
</div>
{data?.author && (
<span className="parchment-post-meta">
{t('misc.newsByAuthor', { author: data.author })} · {formatDate(data.date)}
</span>
)}
<hr />
<div
ref={scrollRef}
className="relative -mx-4 flex min-h-0 flex-grow flex-col overflow-y-auto overflow-x-hidden px-4"
>
{query.isLoading ? (
<div className="flex flex-grow flex-col items-center justify-center gap-2">
<IconSpinner className="parchment-post-muted" />
<p className="parchment-post-muted italic">{t('forum.loading')}</p>
</div>
) : query.isError ? (
<div className="flex flex-grow flex-col items-center justify-center gap-3">
<AlertTriangle size={32} className="text-red" />
<p className="parchment-post-muted italic">{t('forum.error')}</p>
<TextButton
icon={RefreshCw}
size={18}
onClick={() => query.refetch()}
>
{t('misc.tryAgain')}
</TextButton>
</div>
) : !data ? (
<div className="flex flex-grow flex-col items-center justify-center">
<p className="parchment-post-muted italic">{t('forum.empty')}</p>
</div>
) : (
<div
className="parchment-post-body"
onClick={onBodyClick}
dangerouslySetInnerHTML={{ __html: safeHtml }}
/>
)}
</div>
{data && (
<TextButton
icon={ExternalLink}
size={14}
className="-ml-2 self-start"
onClick={() => openLink.mutateAsync(data.url)}
>
{t('forum.readFullPost')}
</TextButton>
)}
</aside>
);
};
export default ForumAnnouncementPanel;
+24 -19
View File
@@ -1,5 +1,7 @@
import OctoLogo from '~renderer/assets/logo.png'; import OctoLogo from '~renderer/assets/logo.png';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton'; import TextButton from './styled/TextButton';
import { TabNames, type TabType } from './TabsPanel'; import { TabNames, type TabType } from './TabsPanel';
@@ -8,25 +10,28 @@ type Props = {
setActiveTab: (tab?: TabType) => void; setActiveTab: (tab?: TabType) => void;
}; };
const Header = ({ activeTab, setActiveTab }: Props) => ( const Header = ({ activeTab, setActiveTab }: Props) => {
<div className="-mb-3 flex select-none items-center gap-1"> const t = useT();
<button return (
onClick={() => setActiveTab(undefined)} <div className="-mb-3 flex select-none items-center gap-1">
className="z-10 -my-3 mx-3 w-[180px] cursor-pointer" <button
> onClick={() => setActiveTab(undefined)}
<img src={OctoLogo} alt="OctoWoW" className="pointer-events-none" /> className="z-10 -my-3 mx-3 w-[180px] cursor-pointer"
</button>
{TabNames.map(t => (
<TextButton
key={t}
onClick={() => setActiveTab(t)}
active={activeTab === t}
className="uppercase"
> >
{t} <img src={OctoLogo} alt="OctoWoW" className="pointer-events-none" />
</TextButton> </button>
))} {TabNames.map(tab => (
</div> <TextButton
); key={tab}
onClick={() => setActiveTab(tab)}
active={activeTab === tab}
className="uppercase"
>
{t(`tab.${tab}`)}
</TextButton>
))}
</div>
);
};
export default Header; export default Header;
@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from 'react';
import { Globe } from 'lucide-react';
import { createPortal } from 'react-dom';
import cls from 'classnames';
import { api } from '~renderer/utils/api';
import { useLocale } from '~renderer/i18n';
import { type Lang } from '~renderer/i18n/translations';
const LOCALES: { value: Lang; code: string; label: string }[] = [
{ value: 'enUS', code: 'En', label: 'English' },
{ value: 'deDE', code: 'De', label: 'Deutsch' },
{ value: 'zhCN', code: 'Zh', label: '中文' },
{ value: 'esES', code: 'Es', label: 'Español' },
{ value: 'ptBR', code: 'Pt', label: 'Português' },
{ value: 'ruRU', code: 'Ru', label: 'Русский' }
];
const LanguageDropdown = () => {
const { lang, setLang, t } = useLocale();
const setPref = api.preferences.set.useMutation();
const code = LOCALES.find(l => l.value === lang)?.code ?? 'En';
const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ top: number; right: number }>();
const btnRef = useRef<HTMLButtonElement>(null);
const toggle = () => {
const r = btnRef.current?.getBoundingClientRect();
if (r) setPos({ top: r.bottom + 4, right: window.innerWidth - r.right });
setOpen(o => !o);
};
useEffect(() => {
if (!open) return;
const close = () => setOpen(false);
window.addEventListener('click', close);
return () => window.removeEventListener('click', close);
}, [open]);
const pick = (v: Lang) => {
setLang(v);
setPref.mutate({ locale: v });
setOpen(false);
};
return (
<>
<button
ref={btnRef}
type="button"
title={t('topbar.language')}
onClick={e => {
e.stopPropagation();
toggle();
}}
className="bg-transparent flex cursor-pointer items-center border-0 px-1 text-[12px] tracking-wide hocus:text-orange"
>
<Globe size={14} />
{code}
</button>
{open &&
pos &&
createPortal(
<div
onClick={e => e.stopPropagation()}
style={{ top: pos.top, right: pos.right }}
className="fixed z-50 flex flex-col border border-blueGray/30 bg-darkGray py-1 shadow-[0_8px_20px_rgba(0,0,0,0.5)]"
>
{LOCALES.map(l => (
<button
key={l.value}
type="button"
title={l.label}
onClick={() => pick(l.value)}
className={cls(
'bg-transparent cursor-pointer border-0 px-3 py-1 text-center text-[12px] hocus:bg-orange/20',
l.value === lang ? 'text-warmGreen' : 'text-white'
)}
>
{l.code}
</button>
))}
</div>,
document.body
)}
</>
);
};
export default LanguageDropdown;
+58 -37
View File
@@ -1,9 +1,11 @@
import { useState, type ReactElement } from 'react'; import { useState, type ReactElement } from 'react';
import cls from 'classnames'; import cls from 'classnames';
import log from 'electron-log/renderer';
import { type UpdaterStatus, type ModsStatus } from '~main/types'; import { type UpdaterStatus, type ModsStatus } from '~main/types';
import { formatFileSize } from '~common/utils'; import { formatFileSize } from '~common/utils';
import { api } from '~renderer/utils/api'; import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import Button from './styled/Button'; import Button from './styled/Button';
import DialogButton from './styled/DialogButton'; import DialogButton from './styled/DialogButton';
@@ -20,40 +22,43 @@ const formatDuration = (seconds: number) => {
return minRem ? `${h}h ${minRem}m` : `${h}h`; return minRem ? `${h}h ${minRem}m` : `${h}h`;
}; };
const formatPercent = (progress: number) => `${(progress * 100).toFixed(1)}%`;
const ProgressDetails = ({ status }: { status: UpdaterStatus }) => { const ProgressDetails = ({ status }: { status: UpdaterStatus }) => {
const { bytesDone, bytesTotal, bytesPerSecond, etaSeconds, progress } = status; const t = useT();
const { bytesDone, bytesTotal, bytesPerSecond, etaSeconds, progress } =
status;
if (bytesTotal === undefined || bytesDone === undefined) return null; if (bytesTotal === undefined || bytesDone === undefined) return null;
const pct = progress !== undefined && progress >= 0 const pct =
? `${(progress * 100).toFixed(1)}%` progress !== undefined && progress >= 0 ? formatPercent(progress) : '—';
: '—';
return ( return (
<p className="s1 text-blueGray"> <p className="s1 text-blueGray">
<span className="tw-color">{pct}</span> <span className="tw-color">{pct}</span>
<span> · {formatFileSize(bytesDone)} / {formatFileSize(bytesTotal)}</span> <span>
{' '}
· {formatFileSize(bytesDone)} / {formatFileSize(bytesTotal)}
</span>
{bytesPerSecond !== undefined && bytesPerSecond > 0 && ( {bytesPerSecond !== undefined && bytesPerSecond > 0 && (
<span> · {formatFileSize(bytesPerSecond)}/s</span> <span> · {formatFileSize(bytesPerSecond)}/s</span>
)} )}
<span> <span>
{' · '} {' · '}
{etaSeconds !== undefined {etaSeconds !== undefined
? `~${formatDuration(etaSeconds)} remaining` ? `~${formatDuration(etaSeconds)} ${t('launch.remaining')}`
: 'calculating'} : t('launch.calculating')}
</span> </span>
</p> </p>
); );
}; };
const LaunchPanel = () => { const LaunchPanel = () => {
const t = useT();
const [status, setStatus] = useState<UpdaterStatus>({ state: 'verifying' }); const [status, setStatus] = useState<UpdaterStatus>({ state: 'verifying' });
api.updater.observe.useSubscription(undefined, { api.updater.observe.useSubscription(undefined, {
onData: data => { onData: setStatus,
console.log({ data }); onError: err => log.error('Updater subscription error:', err)
setStatus(data);
},
onError: err => console.log({ err }),
onStarted: () => console.log('Started')
}); });
const { data: pref } = api.preferences.get.useQuery(); const { data: pref } = api.preferences.get.useQuery();
@@ -72,23 +77,25 @@ const LaunchPanel = () => {
UpdaterStatus['state'], UpdaterStatus['state'],
{ button: ReactElement; helperText?: ReactElement } { button: ReactElement; helperText?: ReactElement }
> = { > = {
verifying: { button: <Button disabled>Verifying</Button> }, verifying: { button: <Button disabled>{t('launch.verifying')}</Button> },
serverUnreachable: { serverUnreachable: {
button: pref?.version ? ( button: pref?.version ? (
<Button onClick={() => start.mutateAsync()}>Play</Button> <Button onClick={() => start.mutateAsync()}>{t('launch.play')}</Button>
) : ( ) : (
<Button onClick={() => verify.mutateAsync()}>Retry</Button> <Button onClick={() => verify.mutateAsync()}>
{t('launch.retry')}
</Button>
), ),
helperText: ( helperText: (
<div className="-mb-2"> <div className="-mb-2">
<p> <p>
<span className="text-orange">Error: </span> Failed to reach update <span className="text-orange">{t('launch.errorLabel')}</span>{' '}
server {t('launch.serverFail')}
</p> </p>
<p className="s1 text-blueGray"> <p className="s1 text-blueGray">
{pref?.version {pref?.version
? `You can launch local version ${pref?.version}` ? t('launch.localVersion', { version: pref.version })
: 'Please try again later'} : t('launch.tryLater')}
</p> </p>
</div> </div>
) )
@@ -101,39 +108,44 @@ const LaunchPanel = () => {
> >
{open => ( {open => (
<Button primary onClick={open}> <Button primary onClick={open}>
Install {t('launch.install')}
</Button> </Button>
)} )}
</DialogButton> </DialogButton>
) )
}, },
updateAvailable: { updateAvailable: {
button: <Button onClick={() => update.mutateAsync()}>Update</Button>, button: (
<Button onClick={() => update.mutateAsync()}>
{t('launch.update')}
</Button>
),
helperText: ( helperText: (
<div className="-mb-2 flex flex-col gap-1"> <div className="-mb-2 flex flex-col gap-1">
<p>Update available!</p> <p>{t('launch.updateAvailable')}</p>
<p className="s1 text-blueGray"> <p className="s1 text-blueGray">
{status.progress !== undefined && {status.progress !== undefined &&
status.bytesDone !== undefined && status.bytesDone !== undefined &&
status.bytesTotal !== undefined && ( status.bytesTotal !== undefined && (
<> <>
<span className="tw-color"> <span className="tw-color">
{(status.progress * 100).toFixed(1)}% {formatPercent(status.progress)}
</span> </span>
<span> <span>
{' '} {' '}
· {formatFileSize(status.bytesDone)} /{' '} · {formatFileSize(status.bytesDone)} /{' '}
{formatFileSize(status.bytesTotal)} on disk ·{' '} {formatFileSize(status.bytesTotal)} {t('launch.onDisk')} ·{' '}
</span> </span>
</> </>
)} )}
<span className="break-all">{status.message}</span> remaining <span className="break-all">{status.message}</span>{' '}
{t('launch.remaining')}
</p> </p>
</div> </div>
) )
}, },
updating: { updating: {
button: <Button disabled>Updating</Button>, button: <Button disabled>{t('launch.updating')}</Button>,
helperText: ( helperText: (
<div className="-mb-2 flex flex-col gap-1"> <div className="-mb-2 flex flex-col gap-1">
{status.message && ( {status.message && (
@@ -150,35 +162,41 @@ const LaunchPanel = () => {
onClick={() => applyMods.mutateAsync()} onClick={() => applyMods.mutateAsync()}
disabled={applyMods.isLoading || modsStatus?.state === 'busy'} disabled={applyMods.isLoading || modsStatus?.state === 'busy'}
> >
{modsStatus?.state === 'busy' ? 'Applying' : 'Update'} {modsStatus?.state === 'busy'
? t('launch.applying')
: t('launch.update')}
</Button> </Button>
) : ( ) : (
<Button primary onClick={() => start.mutateAsync()}> <Button primary onClick={() => start.mutateAsync()}>
Play {t('launch.play')}
</Button> </Button>
), ),
helperText: ( helperText: (
<div className="-mb-2"> <div className="-mb-2">
{modsStatus?.dirty ? ( {modsStatus?.dirty ? (
<p>Mods changed apply before playing</p> <p>{t('launch.modsChanged')}</p>
) : ( ) : (
<p>Everything up to date!</p> <p>{t('launch.upToDate')}</p>
)} )}
<p className="s1 text-blueGray">Version: {pref?.version}</p> <p className="s1 text-blueGray">
{t('launch.version', { version: pref?.version ?? '' })}
</p>
</div> </div>
) )
}, },
failed: { failed: {
button: <Button onClick={() => verify.mutateAsync()}>Retry</Button>, button: (
<Button onClick={() => verify.mutateAsync()}>
{t('launch.retry')}
</Button>
),
helperText: ( helperText: (
<div className="-mb-2"> <div className="-mb-2">
<p> <p>
<span className="text-orange">Error: </span> <span className="text-orange">{t('launch.errorLabel')}</span>{' '}
{status.message} {status.message}
</p> </p>
<p className="s1 text-blueGray"> <p className="s1 text-blueGray">{t('launch.verifyHint')}</p>
Verify your game data by clicking Retry.
</p>
</div> </div>
) )
} }
@@ -191,6 +209,9 @@ const LaunchPanel = () => {
(status.message && ( (status.message && (
<p className="s1 -mb-2 text-blueGray">{status.message}</p> <p className="s1 -mb-2 text-blueGray">{status.message}</p>
))} ))}
{start.data && !start.data.ok && start.data.error && (
<p className="s1 -mb-2 text-orange">{start.data.error}</p>
)}
<div className="tw-loading-wrapper"> <div className="tw-loading-wrapper">
{status.progress !== undefined && ( {status.progress !== undefined && (
<div <div
+58 -26
View File
@@ -5,12 +5,14 @@ import {
FolderOpen, FolderOpen,
RefreshCw, RefreshCw,
ScrollText, ScrollText,
ShieldAlert,
ShieldCheck ShieldCheck
} from 'lucide-react'; } from 'lucide-react';
import { PreferencesSchema } from '~common/schemas'; import { PreferencesSchema } from '~common/schemas';
import { api } from '~renderer/utils/api'; import { api } from '~renderer/utils/api';
import zodResolver from '~renderer/utils/zodResolver'; import zodResolver from '~renderer/utils/zodResolver';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton'; import TextButton from './styled/TextButton';
import CheckboxInput from './form/CheckboxInput'; import CheckboxInput from './form/CheckboxInput';
@@ -19,27 +21,33 @@ import ClientDirDialog from './ClientDirDialog';
import CloseButton from './styled/CloseButton'; import CloseButton from './styled/CloseButton';
const MirrorStatus = () => { const MirrorStatus = () => {
const t = useT();
const [state, setState] = useState<string>('verifying'); const [state, setState] = useState<string>('verifying');
api.updater.observe.useSubscription(undefined, { api.updater.observe.useSubscription(undefined, {
onData: ({ state }) => setState(state) onData: ({ state }) => setState(state)
}); });
if (state === 'serverUnreachable') if (state === 'serverUnreachable')
return <span className="s1 text-red">offline</span>; return <span className="s1 text-red">{t('prefs.mirrorOffline')}</span>;
if (state === 'verifying' || state === 'updating') if (state === 'verifying' || state === 'updating')
return <span className="s1 text-blueGray">checking</span>; return (
return <span className="s1 text-warmGreen">online</span>; <span className="s1 text-blueGray">{t('prefs.mirrorChecking')}</span>
);
return <span className="s1 text-warmGreen">{t('prefs.mirrorOnline')}</span>;
}; };
type Props = { close: () => void }; type Props = { close: () => void };
const PreferencesDialog = ({ close }: Props) => { const PreferencesDialog = ({ close }: Props) => {
const t = useT();
const { data: pref } = api.preferences.get.useQuery(); const { data: pref } = api.preferences.get.useQuery();
const setPref = api.preferences.set.useMutation(); const setPref = api.preferences.set.useMutation();
const verify = api.updater.verify.useMutation(); const verify = api.updater.verify.useMutation();
const repair = api.mods.repair.useMutation();
const openInstallFolder = api.general.openInstallFolder.useMutation(); const openInstallFolder = api.general.openInstallFolder.useMutation();
const openLogFile = api.general.openLogFile.useMutation(); const openLogFile = api.general.openLogFile.useMutation();
const addExclusion = api.general.addDefenderExclusion.useMutation();
const { handleSubmit, watch, setValue, reset } = useForm({ const { handleSubmit, watch, setValue, reset } = useForm({
defaultValues: pref ?? {}, defaultValues: pref ?? {},
@@ -59,9 +67,12 @@ const PreferencesDialog = ({ close }: Props) => {
return ( return (
<form <form
className="tw-dialog !w-fit min-w-[480px] max-w-[640px] !gap-1 whitespace-nowrap" className="tw-dialog !w-fit min-w-[480px] max-w-[640px] !gap-1"
onSubmit={handleSubmit(async v => { onSubmit={handleSubmit(async v => {
await setPref.mutateAsync(v); await setPref.mutateAsync({
cleanWdb: v.cleanWdb,
minimizeToTrayOnPlay: v.minimizeToTrayOnPlay
});
close(); close();
})} })}
> >
@@ -71,26 +82,26 @@ const PreferencesDialog = ({ close }: Props) => {
close(); close();
}} }}
/> />
<h3 className="tw-color">SETTINGS</h3> <h3 className="tw-color">{t('prefs.title')}</h3>
<hr className="mb-1" /> <hr className="mb-1" />
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h4 className="tw-color">INSTALL LOCATION:</h4> <h4 className="tw-color">{t('prefs.installLocation')}</h4>
<TextButton <TextButton
icon={FolderOpen} icon={FolderOpen}
size={14} size={14}
onClick={() => openInstallFolder.mutateAsync()} onClick={() => openInstallFolder.mutateAsync()}
className="!p-1 text-blueGray" className="!p-1 text-blueGray"
> >
Open folder {t('prefs.openFolder')}
</TextButton> </TextButton>
</div> </div>
<div className="flex items-center gap-2 border border-blueGray/20 bg-darkGray/40 px-3 py-1"> <div className="flex items-center gap-2 border border-blueGray/20 bg-darkGray/40 px-3 py-1">
<span <span
title={pref?.clientDir} title={pref?.clientDir}
className="min-w-0 shrink grow overflow-hidden text-ellipsis" className="min-w-0 shrink grow overflow-hidden text-ellipsis whitespace-nowrap"
> >
{pref?.clientDir ?? 'Not selected'} {pref?.clientDir ?? t('prefs.notSelected')}
</span> </span>
<DialogButton <DialogButton
dialog={closeInner => ( dialog={closeInner => (
@@ -104,15 +115,20 @@ const PreferencesDialog = ({ close }: Props) => {
clickAway={pref?.isPortable} clickAway={pref?.isPortable}
> >
{open => ( {open => (
<TextButton icon={FilePen} size={14} onClick={open} className="!p-1"> <TextButton
Change icon={FilePen}
size={14}
onClick={open}
className="!p-1"
>
{t('prefs.change')}
</TextButton> </TextButton>
)} )}
</DialogButton> </DialogButton>
</div> </div>
<div className="mt-1 flex items-center gap-3"> <div className="mt-1 flex items-center gap-3">
<h4 className="tw-color">DOWNLOAD MIRROR:</h4> <h4 className="tw-color">{t('prefs.downloadMirror')}</h4>
</div> </div>
<div className="flex items-center gap-2 pl-2"> <div className="flex items-center gap-2 pl-2">
<input type="radio" checked readOnly className="accent-warmGreen" /> <input type="radio" checked readOnly className="accent-warmGreen" />
@@ -122,47 +138,63 @@ const PreferencesDialog = ({ close }: Props) => {
icon={RefreshCw} icon={RefreshCw}
size={12} size={12}
onClick={() => verify.mutateAsync()} onClick={() => verify.mutateAsync()}
title="Re-check" title={t('prefs.recheck')}
className="!p-0 text-blueGray" className="!p-0 text-blueGray"
/> />
</div> </div>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="flex flex-col"> <div className="flex min-w-0 flex-col">
<h4 className="tw-color">TROUBLESHOOTING:</h4> <h4 className="tw-color">{t('prefs.troubleshooting')}</h4>
<TextButton <TextButton
icon={ShieldCheck} icon={ShieldCheck}
onClick={() => verify.mutateAsync().then(close)} onClick={() => repair.mutateAsync().then(close)}
className="text-warmGreen" className="!items-start text-left text-warmGreen"
> >
Verify game files {t('prefs.verifyGameFiles')}
</TextButton> </TextButton>
<TextButton <TextButton
icon={ScrollText} icon={ScrollText}
onClick={() => openLogFile.mutateAsync()} onClick={() => openLogFile.mutateAsync()}
className="text-pink" className="!items-start text-left text-pink"
> >
Open log file {t('prefs.openLogFile')}
</TextButton> </TextButton>
<TextButton
icon={ShieldAlert}
onClick={() => addExclusion.mutateAsync()}
loading={addExclusion.isLoading}
className="!items-start text-left text-orange"
>
{t('prefs.allowThroughAntivirus')}
</TextButton>
{addExclusion.data?.ok === true && (
<span className="s1 text-warmGreen">
{t('prefs.exclusionAdded')}
</span>
)}
{addExclusion.data?.ok === false && (
<span className="s1 text-orange">{addExclusion.data.error}</span>
)}
</div> </div>
<div className="flex flex-col"> <div className="flex min-w-0 flex-col">
<h4 className="tw-color">GENERAL SETTINGS:</h4> <h4 className="tw-color">{t('prefs.generalSettings')}</h4>
<CheckboxInput <CheckboxInput
value={!!watch('cleanWdb')} value={!!watch('cleanWdb')}
setValue={setBool('cleanWdb')} setValue={setBool('cleanWdb')}
label="Clean WDB on each launch" label={t('prefs.cleanWdb')}
/> />
<CheckboxInput <CheckboxInput
value={!!watch('minimizeToTrayOnPlay')} value={!!watch('minimizeToTrayOnPlay')}
setValue={setBool('minimizeToTrayOnPlay')} setValue={setBool('minimizeToTrayOnPlay')}
label="Minimize to tray while playing" label={t('prefs.minimizeToTray')}
/> />
</div> </div>
</div> </div>
<TextButton type="submit" className="mt-1 self-end text-green"> <TextButton type="submit" className="mt-1 self-end text-green">
Save {t('prefs.save')}
</TextButton> </TextButton>
</form> </form>
); );
+15 -10
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { api } from '~renderer/utils/api'; import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import Button from './styled/Button'; import Button from './styled/Button';
@@ -19,6 +20,7 @@ type Status =
| { state: 'error'; currentVersion: string; message: string }; | { state: 'error'; currentVersion: string; message: string };
const SelfUpdateBanner = () => { const SelfUpdateBanner = () => {
const t = useT();
const [status, setStatus] = useState<Status>({ const [status, setStatus] = useState<Status>({
state: 'idle', state: 'idle',
currentVersion: '' currentVersion: ''
@@ -39,16 +41,19 @@ const SelfUpdateBanner = () => {
const tone = status.state === 'error' ? 'border-red/40' : 'border-tw/40'; const tone = status.state === 'error' ? 'border-red/40' : 'border-tw/40';
const label = const label =
status.state === 'error' status.state === 'error'
? `Update check failed: ${status.message}` ? t('misc.selfUpdateCheckFailed', { message: status.message })
: status.state === 'available' : status.state === 'available'
? `Launcher update ${'nextVersion' in status ? status.nextVersion : ''} available — preparing download…` ? t('misc.selfUpdateAvailable', {
: status.state === 'downloading' version: status.nextVersion
? `Downloading update ${status.nextVersion} · ${Math.round( })
status.progress * 100 : status.state === 'downloading'
)}%` ? t('misc.selfUpdateDownloading', {
: status.state === 'ready' version: status.nextVersion,
? `Launcher update ${status.nextVersion} ready to install` percent: Math.round(status.progress * 100)
: ''; })
: status.state === 'ready'
? t('misc.selfUpdateReady', { version: status.nextVersion })
: '';
return ( return (
<div <div
@@ -61,7 +66,7 @@ const SelfUpdateBanner = () => {
onClick={() => install.mutateAsync()} onClick={() => install.mutateAsync()}
disabled={install.isLoading} disabled={install.isLoading}
> >
Install now {t('misc.selfUpdateInstallNow')}
</Button> </Button>
)} )}
</div> </div>
+49 -23
View File
@@ -2,6 +2,8 @@ import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Component, type ErrorInfo, type ReactNode } from 'react'; import { Component, type ErrorInfo, type ReactNode } from 'react';
import log from 'electron-log/renderer'; import log from 'electron-log/renderer';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton'; import TextButton from './styled/TextButton';
type Props = { type Props = {
@@ -14,6 +16,47 @@ type State = {
componentStack?: string; componentStack?: string;
}; };
type FallbackProps = {
tabName: string;
error: Error;
componentStack?: string;
onReset: () => void;
};
const TabErrorFallback = ({
tabName,
error,
componentStack,
onReset
}: FallbackProps) => {
const t = useT();
return (
<div className="tw-surface flex min-h-0 flex-grow flex-col gap-3">
<div className="flex items-center gap-2">
<AlertTriangle size={22} className="text-red" />
<h4 className="text-red">{t('misc.tabCrashed', { tab: tabName })}</h4>
</div>
<hr />
<p className="text-white">
{error.name}: {error.message}
</p>
{componentStack && (
<pre className="s1 max-h-[200px] overflow-auto whitespace-pre-wrap text-blueGray">
{componentStack.trim()}
</pre>
)}
<hr />
<TextButton
icon={RefreshCw}
onClick={onReset}
className="self-end text-warmGreen"
>
{t('misc.tryAgain')}
</TextButton>
</div>
);
};
class TabErrorBoundary extends Component<Props, State> { class TabErrorBoundary extends Component<Props, State> {
state: State = {}; state: State = {};
@@ -38,29 +81,12 @@ class TabErrorBoundary extends Component<Props, State> {
if (!this.state.error) return this.props.children; if (!this.state.error) return this.props.children;
const { error, componentStack } = this.state; const { error, componentStack } = this.state;
return ( return (
<div className="tw-surface flex min-h-0 flex-grow flex-col gap-3"> <TabErrorFallback
<div className="flex items-center gap-2"> tabName={this.props.tabName}
<AlertTriangle size={22} className="text-red" /> error={error}
<h4 className="text-red">{this.props.tabName} crashed</h4> componentStack={componentStack}
</div> onReset={this.#reset}
<hr /> />
<p className="text-white">
{error.name}: {error.message}
</p>
{componentStack && (
<pre className="s1 max-h-[200px] overflow-auto whitespace-pre-wrap text-blueGray">
{componentStack.trim()}
</pre>
)}
<hr />
<TextButton
icon={RefreshCw}
onClick={this.#reset}
className="self-end text-warmGreen"
>
Try again
</TextButton>
</div>
); );
} }
} }
+15 -11
View File
@@ -2,12 +2,15 @@ import { Settings, Minus, X } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { api } from '~renderer/utils/api'; import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import DialogButton from './styled/DialogButton'; import DialogButton from './styled/DialogButton';
import PreferencesDialog from './PreferencesDialog'; import PreferencesDialog from './PreferencesDialog';
import TextButton from './styled/TextButton'; import TextButton from './styled/TextButton';
import LanguageDropdown from './LanguageDropdown';
const TopBar = () => { const TopBar = () => {
const t = useT();
const [safeToQuit, setSafeToQuit] = useState(true); const [safeToQuit, setSafeToQuit] = useState(true);
api.updater.observe.useSubscription(undefined, { api.updater.observe.useSubscription(undefined, {
onData: ({ state }) => onData: ({ state }) =>
@@ -21,12 +24,16 @@ const TopBar = () => {
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties} style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
className="absolute left-0 right-0 top-0 flex justify-end pr-2 pt-2 opacity-50" className="absolute left-0 right-0 top-0 flex justify-end pr-2 pt-2 opacity-50"
> >
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties} className="flex"> <div
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
className="flex items-center"
>
<LanguageDropdown />
<DialogButton dialog={close => <PreferencesDialog close={close} />}> <DialogButton dialog={close => <PreferencesDialog close={close} />}>
{open => ( {open => (
<TextButton <TextButton
icon={Settings} icon={Settings}
title="Settings" title={t('topbar.settings')}
onClick={open} onClick={open}
size={16} size={16}
className="!p-1" className="!p-1"
@@ -35,7 +42,7 @@ const TopBar = () => {
</DialogButton> </DialogButton>
<TextButton <TextButton
icon={Minus} icon={Minus}
title="Minimize" title={t('topbar.minimize')}
onClick={() => minimize.mutateAsync()} onClick={() => minimize.mutateAsync()}
size={16} size={16}
className="!p-1" className="!p-1"
@@ -43,19 +50,16 @@ const TopBar = () => {
<DialogButton <DialogButton
dialog={close => ( dialog={close => (
<div className="tw-dialog"> <div className="tw-dialog">
<h3 className="tw-color">Quit?</h3> <h3 className="tw-color">{t('quit.title')}</h3>
<hr /> <hr />
<p className="text-blueGray"> <p className="text-blueGray">{t('quit.warn')}</p>
Your game is currently being updated. Quitting now may cause
problems.
</p>
<div className="flex gap-2 self-end"> <div className="flex gap-2 self-end">
<TextButton onClick={close}>Return</TextButton> <TextButton onClick={close}>{t('quit.return')}</TextButton>
<TextButton <TextButton
onClick={() => quit.mutateAsync()} onClick={() => quit.mutateAsync()}
className="text-red" className="text-red"
> >
Quit {t('topbar.quit')}
</TextButton> </TextButton>
</div> </div>
</div> </div>
@@ -64,7 +68,7 @@ const TopBar = () => {
{open => ( {open => (
<TextButton <TextButton
icon={X} icon={X}
title="Quit" title={t('topbar.quit')}
onClick={() => (!safeToQuit ? open() : quit.mutateAsync())} onClick={() => (!safeToQuit ? open() : quit.mutateAsync())}
size={16} size={16}
className="!p-1 hocus:text-red" className="!p-1 hocus:text-red"
@@ -33,12 +33,18 @@ type Props = {
className?: cls.Value; className?: cls.Value;
}; };
const CheckboxInput = ({ label, value, setValue, disabled, className }: Props) => ( const CheckboxInput = ({
label,
value,
setValue,
disabled,
className
}: Props) => (
<TextButton <TextButton
onClick={() => !disabled && setValue(!value)} onClick={() => !disabled && setValue(!value)}
icon={Checkbox} icon={Checkbox}
className={cls( className={cls(
'text-blueGray', '!items-start text-left text-blueGray',
{ '[&_*]:fill-none': !value, 'pointer-events-none opacity-40': disabled }, { '[&_*]:fill-none': !value, 'pointer-events-none opacity-40': disabled },
className className
)} )}
@@ -39,7 +39,6 @@ const DialogButton = ({
ref.current?.close(); ref.current?.close();
}, []); }, []);
// Click away
useEffect(() => { useEffect(() => {
if (!clickAway) return; if (!clickAway) return;
const callback = (e: MouseEvent) => e.target === ref.current && close(); const callback = (e: MouseEvent) => e.target === ref.current && close();
@@ -53,7 +53,7 @@ const TextButton = ({
{loading ? ( {loading ? (
<IconSpinner size={size ?? 24} strokeWidth={1.5} /> <IconSpinner size={size ?? 24} strokeWidth={1.5} />
) : ( ) : (
Icon && <Icon size={size} /> Icon && <Icon size={size} className="shrink-0" />
)} )}
{children && ( {children && (
<span className="cursor-pointer select-none tracking-wide text-inherit [font-size:_inherit]"> <span className="cursor-pointer select-none tracking-wide text-inherit [font-size:_inherit]">
+34 -10
View File
@@ -5,6 +5,7 @@ import { type AddonData, type AddonsStatus } from '~main/types';
import { api } from '~renderer/utils/api'; import { api } from '~renderer/utils/api';
import TextButton from '~renderer/components/styled/TextButton'; import TextButton from '~renderer/components/styled/TextButton';
import useScrollHint from '~renderer/utils/useScrollHint'; import useScrollHint from '~renderer/utils/useScrollHint';
import { useT } from '~renderer/i18n';
import DialogButton from '../styled/DialogButton'; import DialogButton from '../styled/DialogButton';
import IconSpinner from '../styled/IconSpinner'; import IconSpinner from '../styled/IconSpinner';
@@ -13,6 +14,17 @@ import AddonList from './addons/AddonList';
import { type Dependencies } from './addons/AddonListItem'; import { type Dependencies } from './addons/AddonListItem';
import CustomAddonDialog from './addons/CustomAddonDialog'; import CustomAddonDialog from './addons/CustomAddonDialog';
const RECOMMENDED = new Set([
'AtlasLoot',
'pfExtend',
'pfQuest',
'pfQuest-turtle',
'SellValue',
'ShaguTweaks',
'ShaguTweaks-extras',
'TurtleMail'
]);
const localeFilter = (l: AddonData[], filter: string) => { const localeFilter = (l: AddonData[], filter: string) => {
const seen = new Set<string>(); const seen = new Set<string>();
const deduped = l.filter(a => { const deduped = l.filter(a => {
@@ -21,14 +33,14 @@ const localeFilter = (l: AddonData[], filter: string) => {
return true; return true;
}); });
return deduped return deduped
.filter( .filter(a =>
a => a.folder.toLocaleLowerCase().includes(filter.toLocaleLowerCase())
a.folder.toLocaleLowerCase().indexOf(filter.toLocaleLowerCase()) !== -1
) )
.sort((a, b) => a.folder.localeCompare(b.folder)); .sort((a, b) => a.folder.localeCompare(b.folder));
}; };
const AddonsTab = () => { const AddonsTab = () => {
const t = useT();
const [data, setData] = useState<AddonsStatus>({ const [data, setData] = useState<AddonsStatus>({
state: 'verifying', state: 'verifying',
addons: {}, addons: {},
@@ -61,14 +73,26 @@ const AddonsTab = () => {
className="relative -m-4 -mb-3 flex flex-grow flex-col gap-3 overflow-y-auto overflow-x-hidden p-4 pb-3" className="relative -m-4 -mb-3 flex flex-grow flex-col gap-3 overflow-y-auto overflow-x-hidden p-4 pb-3"
> >
<AddonList <AddonList
title="Installed" title={t('addons.sectionInstalled')}
addons={localeFilter(Object.values(data.addons), filter)} addons={localeFilter(Object.values(data.addons), filter)}
dependencies={dependencies} dependencies={dependencies}
/> />
<AddonList <AddonList
title="Available" title={t('addons.sectionRecommended')}
addons={localeFilter( addons={localeFilter(
data.available.filter(a => !(a.folder in data.addons)), data.available.filter(
a => !(a.folder in data.addons) && RECOMMENDED.has(a.folder)
),
filter
)}
dependencies={dependencies}
/>
<AddonList
title={t('addons.sectionAvailable')}
addons={localeFilter(
data.available.filter(
a => !(a.folder in data.addons) && !RECOMMENDED.has(a.folder)
),
filter filter
)} )}
dependencies={dependencies} dependencies={dependencies}
@@ -83,7 +107,7 @@ const AddonsTab = () => {
size={18} size={18}
loading={data.state !== 'done'} loading={data.state !== 'done'}
> >
Check for updates {t('addons.checkForUpdates')}
</TextButton> </TextButton>
<DialogButton <DialogButton
clickAway clickAway
@@ -96,7 +120,7 @@ const AddonsTab = () => {
onClick={open} onClick={open}
className="s1 text-pink" className="s1 text-pink"
> >
Add custom git addon {t('addons.addCustomGitAddon')}
</TextButton> </TextButton>
)} )}
</DialogButton> </DialogButton>
@@ -107,11 +131,11 @@ const AddonsTab = () => {
onClick={() => update.mutateAsync({})} onClick={() => update.mutateAsync({})}
className="justify-self-end text-warmGreen" className="justify-self-end text-warmGreen"
> >
Update all {t('addons.updateAll')}
</TextButton> </TextButton>
) : ( ) : (
<p className="s1 justify-self-end text-blueGray"> <p className="s1 justify-self-end text-blueGray">
Everything is up to date. {t('addons.everythingUpToDate')}
</p> </p>
)} )}
</div> </div>
+10 -5
View File
@@ -1,7 +1,12 @@
const ComingSoonTab = () => ( import { useT } from '~renderer/i18n';
<div className="tw-surface flex flex-grow flex-col items-center justify-center gap-2">
<p className="italic text-blueGray">Coming soon...</p> const ComingSoonTab = () => {
</div> const t = useT();
); return (
<div className="tw-surface flex flex-grow flex-col items-center justify-center gap-2">
<p className="italic text-blueGray">{t('misc.comingSoon')}</p>
</div>
);
};
export default ComingSoonTab; export default ComingSoonTab;
+98 -18
View File
@@ -1,9 +1,11 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { ExternalLink, AlertTriangle, Sparkles } from 'lucide-react'; import { ExternalLink, AlertTriangle, Sparkles } from 'lucide-react';
import { createPortal } from 'react-dom';
import cls from 'classnames'; import cls from 'classnames';
import { api } from '~renderer/utils/api'; import { api } from '~renderer/utils/api';
import useScrollHint from '~renderer/utils/useScrollHint'; import useScrollHint from '~renderer/utils/useScrollHint';
import { useT } from '~renderer/i18n';
import { type ModRowStatus, type ModsStatus } from '~main/types'; import { type ModRowStatus, type ModsStatus } from '~main/types';
import TextButton from '../styled/TextButton'; import TextButton from '../styled/TextButton';
@@ -11,9 +13,8 @@ import CheckboxInput from '../form/CheckboxInput';
import IconSpinner from '../styled/IconSpinner'; import IconSpinner from '../styled/IconSpinner';
const RowState = ({ row }: { row: ModRowStatus }) => { const RowState = ({ row }: { row: ModRowStatus }) => {
if (row.state === 'downloading' || row.state === 'installing') const t = useT();
return <IconSpinner className="text-blueGray" />; if (['downloading', 'installing', 'uninstalling'].includes(row.state))
if (row.state === 'uninstalling')
return <IconSpinner className="text-blueGray" />; return <IconSpinner className="text-blueGray" />;
if (row.state === 'error') if (row.state === 'error')
return ( return (
@@ -21,12 +22,17 @@ const RowState = ({ row }: { row: ModRowStatus }) => {
<AlertTriangle size={14} className="text-red" /> <AlertTriangle size={14} className="text-red" />
</span> </span>
); );
if (row.installedVersion && row.installedVersion !== row.latestVersion && !row.ignoreUpdates) if (
return <span className="s1 text-pink">update</span>; row.installedVersion &&
row.installedVersion !== row.latestVersion &&
!row.ignoreUpdates
)
return <span className="s1 text-pink">{t('mods.update')}</span>;
return null; return null;
}; };
const ModRow = ({ row }: { row: ModRowStatus }) => { const ModRow = ({ row }: { row: ModRowStatus }) => {
const t = useT();
const toggle = api.mods.toggle.useMutation(); const toggle = api.mods.toggle.useMutation();
const setIgnore = api.mods.setIgnoreUpdates.useMutation(); const setIgnore = api.mods.setIgnoreUpdates.useMutation();
const openLink = api.general.openLink.useMutation(); const openLink = api.general.openLink.useMutation();
@@ -37,7 +43,9 @@ const ModRow = ({ row }: { row: ModRowStatus }) => {
{row.recommended && ( {row.recommended && (
<Sparkles size={12} className="shrink-0 text-warmGreen" /> <Sparkles size={12} className="shrink-0 text-warmGreen" />
)} )}
<span className={cls(row.recommended && 'text-warmGreen')}>{row.name}</span> <span className={cls(row.recommended && 'text-warmGreen')}>
{row.name}
</span>
<span className="s1 text-warmGreen">{row.latestVersion}</span> <span className="s1 text-warmGreen">{row.latestVersion}</span>
</div> </div>
<CheckboxInput <CheckboxInput
@@ -59,13 +67,14 @@ const ModRow = ({ row }: { row: ModRowStatus }) => {
<CheckboxInput <CheckboxInput
value={row.ignoreUpdates} value={row.ignoreUpdates}
setValue={v => setIgnore.mutate({ id: row.id, ignore: v })} setValue={v => setIgnore.mutate({ id: row.id, ignore: v })}
label={<span className="s1">Ignore updates</span>} label={<span className="s1">{t('mods.ignoreUpdates')}</span>}
/> />
</> </>
); );
}; };
const ModsTab = () => { const ModsTab = () => {
const t = useT();
const [status, setStatus] = useState<ModsStatus>(); const [status, setStatus] = useState<ModsStatus>();
api.mods.observe.useSubscription(undefined, { api.mods.observe.useSubscription(undefined, {
onData: setStatus onData: setStatus
@@ -82,40 +91,111 @@ const ModsTab = () => {
const scrollRef = useScrollHint<HTMLDivElement>(); const scrollRef = useScrollHint<HTMLDivElement>();
const mods = status?.mods ?? [];
const enabledIds = new Set(mods.filter(m => m.enabled).map(m => m.id));
const modName = (id: string) => mods.find(m => m.id === id)?.name ?? id;
const missingDeps = [
...new Set(
mods
.filter(m => m.enabled)
.flatMap(m => m.requires.filter(d => !enabledIds.has(d)))
)
];
const pendingDepMessage = missingDeps
.map(dep => {
const requiredBy = mods
.filter(m => m.enabled && m.requires.includes(dep))
.map(m => m.name)
.join(', ');
return t('mods.depRequired', { mod: modName(dep), requiredBy });
})
.join('\n');
const dialogRef = useRef<HTMLDialogElement>(null);
const [shownDepMessage, setShownDepMessage] = useState<string | null>(null);
useEffect(() => {
if (shownDepMessage) {
dialogRef.current?.showModal();
(document.activeElement as HTMLElement | null)?.blur();
} else dialogRef.current?.close();
}, [shownDepMessage]);
const onApply = () => {
if (missingDeps.length) {
setShownDepMessage(pendingDepMessage);
return;
}
apply.mutateAsync();
};
const showApply =
!!status?.dirty || apply.isLoading || status?.state === 'busy';
return ( return (
<div className="tw-surface flex min-h-0 flex-grow flex-col gap-3"> <div className="tw-surface flex min-h-0 flex-grow flex-col gap-3">
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<h4 className="tw-color">CUSTOM MODS</h4> <h4 className="tw-color">{t('mods.title')}</h4>
{status?.dirty && ( {status?.dirty && (
<span className="s1 text-pink">unsaved changes</span> <span className="s1 text-pink">{t('mods.unsavedChanges')}</span>
)} )}
</div> </div>
<p className="s1 text-blueGray"> <p className="s1 text-blueGray">
<span className="text-orange"></span> Enabling custom mods may not provide <span className="text-orange"></span> {t('mods.warning')}
any performance benefits or may even cause game crashes depending on your
system. Please try disabling them if you experience any issues.
</p> </p>
{missingDeps.length > 0 && (
<p className="s1 text-orange">
{' '}
{t('mods.enableRequired', {
mods: missingDeps.map(modName).join(', ')
})}
</p>
)}
<hr /> <hr />
<div <div
ref={scrollRef} ref={scrollRef}
className="relative -m-4 -mt-0 grid flex-grow grid-cols-[auto_auto_1fr_auto] content-start items-center gap-x-4 gap-y-2 overflow-y-auto p-4 pt-0" className="relative -m-4 -mt-0 grid flex-grow grid-cols-[auto_auto_1fr_auto] content-start items-center gap-x-4 gap-y-2 overflow-y-auto p-4 pt-0"
> >
{status?.mods.map(row => <ModRow key={row.id} row={row} />)} {status?.mods.map(row => (
<ModRow key={row.id} row={row} />
))}
</div> </div>
<hr /> <hr />
<div className="-mb-4 -mt-3 flex items-center gap-2 py-2"> <div className="-mb-4 -mt-3 flex items-center gap-2 py-2">
<p className="s1 flex-grow text-blueGray"> <p className="s1 flex-grow text-blueGray">
<span className="text-warmGreen">Highlighted</span> mods are recommended. <span className="text-warmGreen">{t('mods.highlighted')}</span>{' '}
{t('mods.highlightedRecommended')}
</p> </p>
<TextButton <TextButton
type="button" type="button"
loading={apply.isLoading || status?.state === 'busy'} loading={apply.isLoading || status?.state === 'busy'}
onClick={() => apply.mutateAsync()} onClick={onApply}
className={cls(status?.dirty && 'text-green')} className={cls('text-green', !showApply && 'invisible')}
> >
Apply {t('mods.apply')}
</TextButton> </TextButton>
</div> </div>
{createPortal(
<dialog
ref={dialogRef}
onClose={() => setShownDepMessage(null)}
className="h-full w-full items-center justify-center bg-[transparent] backdrop:backdrop-blur-sm [&[open]]:flex"
>
{shownDepMessage && (
<div className="tw-dialog !w-fit min-w-[360px] max-w-[460px] !gap-3">
<h3 className="tw-color">{t('mods.cantApplyYet')}</h3>
<p className="s1 whitespace-pre-line">{shownDepMessage}</p>
<TextButton
onClick={() => setShownDepMessage(null)}
className="self-end text-green"
>
{t('mods.close')}
</TextButton>
</div>
)}
</dialog>,
document.body
)}
</div> </div>
); );
}; };
+30 -11
View File
@@ -2,8 +2,10 @@ import { AlertTriangle, ExternalLink, RefreshCw } from 'lucide-react';
import { type NewsItem } from '~main/types'; import { type NewsItem } from '~main/types';
import { api } from '~renderer/utils/api'; import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import useScrollHint from '~renderer/utils/useScrollHint'; import useScrollHint from '~renderer/utils/useScrollHint';
import ForumAnnouncementPanel from '../ForumAnnouncementPanel';
import IconSpinner from '../styled/IconSpinner'; import IconSpinner from '../styled/IconSpinner';
import TextButton from '../styled/TextButton'; import TextButton from '../styled/TextButton';
@@ -18,15 +20,20 @@ const formatDate = (raw: string) => {
}; };
const NewsEntry = ({ item }: { item: NewsItem }) => { const NewsEntry = ({ item }: { item: NewsItem }) => {
const t = useT();
const openLink = api.general.openLink.useMutation(); const openLink = api.general.openLink.useMutation();
return ( return (
<article className="flex flex-col gap-1 border-b border-blueGray/30 pb-3 last:border-0"> <article className="flex flex-col gap-1 border-b border-blueGray/30 pb-3 last:border-0">
<div className="flex items-baseline justify-between gap-3"> <div className="flex items-baseline justify-between gap-3">
<h5 className="tw-color">{item.title}</h5> <h5 className="tw-color">{item.title}</h5>
<span className="s1 shrink-0 text-blueGray">{formatDate(item.date)}</span> <span className="s1 shrink-0 text-blueGray">
{formatDate(item.date)}
</span>
</div> </div>
{item.author && ( {item.author && (
<span className="s1 italic text-blueGray">by {item.author}</span> <span className="s1 italic text-blueGray">
{t('misc.newsByAuthor', { author: item.author })}
</span>
)} )}
<p className="whitespace-pre-wrap text-sm leading-relaxed">{item.body}</p> <p className="whitespace-pre-wrap text-sm leading-relaxed">{item.body}</p>
{item.url && ( {item.url && (
@@ -36,14 +43,16 @@ const NewsEntry = ({ item }: { item: NewsItem }) => {
className="-ml-2 self-start text-pink" className="-ml-2 self-start text-pink"
onClick={() => openLink.mutateAsync(item.url!)} onClick={() => openLink.mutateAsync(item.url!)}
> >
Read more {t('misc.newsReadMore')}
</TextButton> </TextButton>
)} )}
</article> </article>
); );
}; };
const NewsTab = () => { // The "Announcements" list — most-recent forum topics as short previews + links.
const AnnouncementsBox = () => {
const t = useT();
const query = api.news.list.useQuery(undefined, { const query = api.news.list.useQuery(undefined, {
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
@@ -52,16 +61,16 @@ const NewsTab = () => {
const scrollRef = useScrollHint<HTMLDivElement>(); const scrollRef = useScrollHint<HTMLDivElement>();
return ( return (
<div className="tw-surface flex min-h-0 flex-grow flex-col gap-3"> <div className="tw-surface flex min-h-0 w-[360px] shrink-0 flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="tw-color">News</h4> <h4 className="tw-color">{t('misc.announcementsTitle')}</h4>
<TextButton <TextButton
icon={RefreshCw} icon={RefreshCw}
size={18} size={18}
className="-mr-2 text-blueGray" className="-mr-2 text-blueGray"
loading={query.isFetching} loading={query.isFetching}
onClick={() => query.refetch()} onClick={() => query.refetch()}
title="Refresh" title={t('misc.refresh')}
/> />
</div> </div>
<hr /> <hr />
@@ -72,24 +81,24 @@ const NewsTab = () => {
{query.isLoading ? ( {query.isLoading ? (
<div className="flex flex-grow flex-col items-center justify-center gap-2"> <div className="flex flex-grow flex-col items-center justify-center gap-2">
<IconSpinner className="text-blueGray" /> <IconSpinner className="text-blueGray" />
<p className="italic text-blueGray">Loading news...</p> <p className="italic text-blueGray">{t('misc.newsLoading')}</p>
</div> </div>
) : query.isError ? ( ) : query.isError ? (
<div className="flex flex-grow flex-col items-center justify-center gap-3"> <div className="flex flex-grow flex-col items-center justify-center gap-3">
<AlertTriangle size={32} className="text-red" /> <AlertTriangle size={32} className="text-red" />
<p className="italic text-blueGray">Couldn't reach the news feed.</p> <p className="italic text-blueGray">{t('misc.newsError')}</p>
<TextButton <TextButton
icon={RefreshCw} icon={RefreshCw}
size={18} size={18}
className="text-pink" className="text-pink"
onClick={() => query.refetch()} onClick={() => query.refetch()}
> >
Try again {t('misc.tryAgain')}
</TextButton> </TextButton>
</div> </div>
) : !query.data?.length ? ( ) : !query.data?.length ? (
<div className="flex flex-grow flex-col items-center justify-center"> <div className="flex flex-grow flex-col items-center justify-center">
<p className="italic text-blueGray">No news yet check back later.</p> <p className="italic text-blueGray">{t('misc.newsEmpty')}</p>
</div> </div>
) : ( ) : (
query.data.map(item => <NewsEntry key={item.id} item={item} />) query.data.map(item => <NewsEntry key={item.id} item={item} />)
@@ -99,4 +108,14 @@ const NewsTab = () => {
); );
}; };
// The News tab holds both boxes side by side: the parchment "newsletter" (the
// featured Nautilus News Network post, biggest) and the "Announcements" list.
// Living inside the tab means they only show on News — not on Tweaks/Addons/Mods.
const NewsTab = () => (
<div className="flex min-h-0 flex-grow gap-3">
<ForumAnnouncementPanel />
<AnnouncementsBox />
</div>
);
export default NewsTab; export default NewsTab;
+67 -28
View File
@@ -6,6 +6,7 @@ import { api } from '~renderer/utils/api';
import { ConfigWtfSchema } from '~common/schemas'; import { ConfigWtfSchema } from '~common/schemas';
import zodResolver from '~renderer/utils/zodResolver'; import zodResolver from '~renderer/utils/zodResolver';
import useScrollHint from '~renderer/utils/useScrollHint'; import useScrollHint from '~renderer/utils/useScrollHint';
import { useT } from '~renderer/i18n';
import TextButton from '../styled/TextButton'; import TextButton from '../styled/TextButton';
import CheckboxInput from '../form/CheckboxInput'; import CheckboxInput from '../form/CheckboxInput';
@@ -64,6 +65,7 @@ const Item = ({
}; };
const TweaksTab = () => { const TweaksTab = () => {
const t = useT();
const { data: pref } = api.preferences.get.useQuery(); const { data: pref } = api.preferences.get.useQuery();
const setPref = api.preferences.set.useMutation(); const setPref = api.preferences.set.useMutation();
@@ -74,7 +76,19 @@ const TweaksTab = () => {
defaultValues: pref?.config ?? {}, defaultValues: pref?.config ?? {},
resolver: zodResolver(ConfigWtfSchema) resolver: zodResolver(ConfigWtfSchema)
}); });
const { handleSubmit, reset } = form; const { handleSubmit, reset, formState } = form;
const { data: hw } = api.general.hardware.useQuery();
const recommendedFarClip = hw?.recommendedFarClip;
const farClipValue = form.watch('farClip');
const farClipText =
t('tweaks.farClip.text') +
(recommendedFarClip != null
? ' ' + t('tweaks.farClip.recommendedHint', { value: recommendedFarClip })
: '');
const isApplying =
setPref.isLoading || applyPatch.isLoading || verify.isLoading;
useEffect(() => { useEffect(() => {
pref && reset(pref.config); pref && reset(pref.config);
@@ -85,7 +99,7 @@ const TweaksTab = () => {
return ( return (
<form <form
onSubmit={handleSubmit(async config => { onSubmit={handleSubmit(async config => {
await setPref.mutateAsync({ config }); await setPref.mutateAsync({ config, farClipUserSet: true });
await applyPatch.mutateAsync(); await applyPatch.mutateAsync();
await verify.mutateAsync(); await verify.mutateAsync();
@@ -100,33 +114,35 @@ const TweaksTab = () => {
<Item <Item
form={form} form={form}
id="alwaysAutoLoot" id="alwaysAutoLoot"
label="Always auto-loot" label={t('tweaks.alwaysAutoLoot.label')}
text="Reverses auto-loot behavior to always auto-loot and disable auto-with bound key." text={t('tweaks.alwaysAutoLoot.text')}
/> />
<Item <Item
form={form} form={form}
id="largeAddress" id="largeAddress"
label="Large Address Aware" label={t('tweaks.largeAddress.label')}
text="Allows the game to use more than 2GB of memory." text={t('tweaks.largeAddress.text')}
recommended recommended
/> />
<Item <Item
form={form} form={form}
type="number" type="number"
id="nameplateRange" id="nameplateRange"
label="Nameplate range" label={t('tweaks.nameplateRange.label')}
text="Increases distance at which nameplates are visible. [Vanilla: 20] [Classic: 41]" text={t('tweaks.nameplateRange.text')}
min={0} min={0}
max={41} max={41}
/> />
<h4 className="tw-color col-span-3 mt-3">Camera</h4> <h4 className="tw-color col-span-3 mt-3">
{t('tweaks.cameraHeading')}
</h4>
<Item <Item
form={form} form={form}
id="fieldOfView" id="fieldOfView"
label="Field of View" label={t('tweaks.fieldOfView.label')}
type="number" type="number"
text="Recommended for widescreen window resolutions. [Vanilla: 90] [Tweaks: 110]" text={t('tweaks.fieldOfView.text')}
min={90} min={90}
max={180} max={180}
step={5} step={5}
@@ -134,9 +150,13 @@ const TweaksTab = () => {
<Item <Item
form={form} form={form}
id="farClip" id="farClip"
label="Render distance" label={t('tweaks.farClip.label')}
type="number" type="number"
text="Increases maximum render distance. [Vanilla: 777] [Tweaks: 10000]" text={farClipText}
recommended={
recommendedFarClip != null &&
Number(farClipValue) === recommendedFarClip
}
min={100} min={100}
max={10000} max={10000}
sensitivity={3} sensitivity={3}
@@ -144,9 +164,9 @@ const TweaksTab = () => {
<Item <Item
form={form} form={form}
id="frillDistance" id="frillDistance"
label="Ground clutter distance" label={t('tweaks.frillDistance.label')}
type="number" type="number"
text="Changes ground clutter render distance. [Vanilla: 70] [Tweaks: 300]" text={t('tweaks.frillDistance.text')}
min={0} min={0}
max={300} max={300}
sensitivity={0.3} sensitivity={0.3}
@@ -154,40 +174,59 @@ const TweaksTab = () => {
<Item <Item
form={form} form={form}
id="cameraDistance" id="cameraDistance"
label="Camera distance" label={t('tweaks.cameraDistance.label')}
type="number" type="number"
text="Increases maximum camera (zoom out) distance. [Vanilla: 50] [Max:100]" text={t('tweaks.cameraDistance.text')}
min={50} min={50}
max={100} max={100}
/> />
<h4 className="tw-color col-span-3 mt-3">Sounds</h4> <h4 className="tw-color col-span-3 mt-3">
{t('tweaks.soundsHeading')}
</h4>
<Item <Item
form={form} form={form}
id="soundInBackground" id="soundInBackground"
label="Background sounds" label={t('tweaks.soundInBackground.label')}
text="Allows game sounds to play while the game is minimized." text={t('tweaks.soundInBackground.text')}
recommended recommended
/> />
</div> </div>
<hr /> <hr />
<div className="-mb-4 -mt-3 flex items-center gap-2 py-2"> <div className="-mb-4 -mt-3 flex items-center gap-2 py-2">
<p className="s1 flex-grow text-blueGray"> <p className="s1 flex-grow text-blueGray">
<span className="s1 text-warmGreen">Highlighted</span> options are {applyPatch.isError ? (
recommended and enabled by default <span className="text-orange">
{t('tweaks.applyFailed', {
message: applyPatch.error?.message ?? ''
})}
</span>
) : (
<>
<span className="s1 text-warmGreen">
{t('tweaks.highlighted')}
</span>{' '}
{t('tweaks.recommendedNote')}
</>
)}
</p> </p>
<TextButton <TextButton
onClick={async () => { onClick={async () => {
const config = ConfigWtfSchema.parse({}); const config =
await setPref.mutateAsync({ config }); recommendedFarClip != null
? { ...ConfigWtfSchema.parse({}), farClip: recommendedFarClip }
: ConfigWtfSchema.parse({});
await setPref.mutateAsync({ config, farClipUserSet: false });
reset(config); reset(config);
}} }}
> >
Reset {t('tweaks.reset')}
</TextButton>
<TextButton type="submit" className="text-green">
Apply
</TextButton> </TextButton>
{(formState.isDirty || isApplying) && (
<TextButton type="submit" loading={isApplying} className="text-green">
{t('tweaks.apply')}
</TextButton>
)}
</div> </div>
</form> </form>
); );
@@ -16,6 +16,7 @@ import { ColoredText } from '~renderer/components/styled/ColoredText';
import useScrollHint from '~renderer/utils/useScrollHint'; import useScrollHint from '~renderer/utils/useScrollHint';
import IconSpinner from '~renderer/components/styled/IconSpinner'; import IconSpinner from '~renderer/components/styled/IconSpinner';
import CloseButton from '~renderer/components/styled/CloseButton'; import CloseButton from '~renderer/components/styled/CloseButton';
import { useT } from '~renderer/i18n';
import { type LocalDependencies } from './AddonListItem'; import { type LocalDependencies } from './AddonListItem';
@@ -41,6 +42,7 @@ type Props = AddonData & {
}; };
const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => { const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => {
const t = useT();
const openLink = api.general.openLink.useMutation(); const openLink = api.general.openLink.useMutation();
const update = api.addons.update.useMutation(); const update = api.addons.update.useMutation();
@@ -71,26 +73,26 @@ const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => {
<ColoredText>{addon.toc?.Notes ?? addon.description ?? ''}</ColoredText> <ColoredText>{addon.toc?.Notes ?? addon.description ?? ''}</ColoredText>
)} )}
<div> <div>
<AddonDetailItem name="Source"> <AddonDetailItem name={t('addons.detailSource')}>
{addon.git && ( {addon.git && (
<TextButton <TextButton
onClick={() => openLink.mutateAsync(addon.git)} onClick={() => openLink.mutateAsync(addon.git)}
className="s1 -m-2 !inline" className="s1 -m-2 !inline"
> >
Open on GitHub {t('addons.openOnGithubShort')}
<ExternalLink size={12} className="ml-1 inline" /> <ExternalLink size={12} className="ml-1 inline" />
</TextButton> </TextButton>
)} )}
</AddonDetailItem> </AddonDetailItem>
{addon.toc && ( {addon.toc && (
<> <>
<AddonDetailItem name="Contributions"> <AddonDetailItem name={t('addons.detailContributions')}>
{addon.toc.Author} {addon.toc.Author}
</AddonDetailItem> </AddonDetailItem>
<AddonDetailItem name="Addon version"> <AddonDetailItem name={t('addons.detailAddonVersion')}>
{addon.toc.Version} {addon.toc.Version}
</AddonDetailItem> </AddonDetailItem>
<AddonDetailItem name="Dependencies"> <AddonDetailItem name={t('addons.detailDependencies')}>
{!!dependencies.length && ( {!!dependencies.length && (
<ul className="pl-2"> <ul className="pl-2">
{dependencies.map(({ name, optional, status }) => ( {dependencies.map(({ name, optional, status }) => (
@@ -99,7 +101,7 @@ const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => {
<Check size={16} className="inline text-darkGreen" /> <Check size={16} className="inline text-darkGreen" />
) : status === 'available' ? ( ) : status === 'available' ? (
<TextButton <TextButton
title="Download" title={t('addons.download')}
icon={DownloadCloud} icon={DownloadCloud}
size={16} size={16}
onClick={() => onClick={() =>
@@ -122,7 +124,9 @@ const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => {
) ? ( ) ? (
<p className="s1 inline text-blueGray">{status}</p> <p className="s1 inline text-blueGray">{status}</p>
) : optional ? ( ) : optional ? (
<p className="s1 inline text-blueGray">(optional)</p> <p className="s1 inline text-blueGray">
{t('addons.optional')}
</p>
) : null} ) : null}
</li> </li>
))} ))}
@@ -16,6 +16,7 @@ import IconSpinner from '~renderer/components/styled/IconSpinner';
import DialogButton from '~renderer/components/styled/DialogButton'; import DialogButton from '~renderer/components/styled/DialogButton';
import { isNotUndef } from '~common/utils'; import { isNotUndef } from '~common/utils';
import CloseButton from '~renderer/components/styled/CloseButton'; import CloseButton from '~renderer/components/styled/CloseButton';
import { useT } from '~renderer/i18n';
import AddonDetail from './AddonDetail'; import AddonDetail from './AddonDetail';
@@ -39,6 +40,7 @@ const toRepoUrl = (git?: string) =>
git ? git.replace(/\.git$/, '') : undefined; git ? git.replace(/\.git$/, '') : undefined;
const AddonListItem = ({ row, dependencies, ...addon }: Props) => { const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
const t = useT();
const update = api.addons.update.useMutation(); const update = api.addons.update.useMutation();
const remove = api.addons.remove.useMutation(); const remove = api.addons.remove.useMutation();
const openLink = api.general.openLink.useMutation(); const openLink = api.general.openLink.useMutation();
@@ -58,17 +60,21 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
const warnings = [ const warnings = [
addon.toc && addon.toc?.Interface !== '11200' addon.toc && addon.toc?.Interface !== '11200'
? { ? {
full: `This addon seems to be made for different game version (${addon.toc?.Interface}) and it may not function correctly`, full: t('addons.warnIncorrectVersionFull', {
short: 'Incorrect version' version: addon.toc?.Interface ?? ''
}),
short: t('addons.warnIncorrectVersionShort')
} }
: undefined, : undefined,
localDependencies.some(d => d.status !== 'installed' && !d.optional) localDependencies.some(d => d.status !== 'installed' && !d.optional)
? { ? {
full: `This addon has missing dependencies: ${localDependencies full: t('addons.warnMissingDependenciesFull', {
.filter(d => d.status !== 'installed' && !d.optional) deps: localDependencies
.map(d => d.name) .filter(d => d.status !== 'installed' && !d.optional)
.join(', ')}`, .map(d => d.name)
short: 'Missing dependencies' .join(', ')
}),
short: t('addons.warnMissingDependenciesShort')
} }
: undefined : undefined
].filter(isNotUndef); ].filter(isNotUndef);
@@ -107,7 +113,7 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
: HelpCircle : HelpCircle
} }
onClick={open} onClick={open}
title="Details" title={t('addons.details')}
size={18} size={18}
className={cls( className={cls(
'-mx-2', '-mx-2',
@@ -132,7 +138,7 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
<TextButton <TextButton
icon={Github} icon={Github}
size={14} size={14}
title={`Open ${repoUrl} on GitHub`} title={t('addons.openOnGithub', { url: repoUrl })}
onClick={() => openLink.mutateAsync(repoUrl)} onClick={() => openLink.mutateAsync(repoUrl)}
className="!p-1 text-blueGray/60 hocus:text-pink" className="!p-1 text-blueGray/60 hocus:text-pink"
/> />
@@ -162,9 +168,9 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
) : ( ) : (
<p className="s1 text-blueGray/50"> <p className="s1 text-blueGray/50">
{addon.status === 'upToDate' {addon.status === 'upToDate'
? 'Up to date' ? t('addons.upToDate')
: !addon.git : !addon.git
? 'Not versioned' ? t('addons.notVersioned')
: ''} : ''}
</p> </p>
)} )}
@@ -173,17 +179,16 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
onClick={() => update.mutateAsync({ toUpdate: [addon.folder] })} onClick={() => update.mutateAsync({ toUpdate: [addon.folder] })}
className="s1 -mx-2 justify-self-end" className="s1 -mx-2 justify-self-end"
> >
Update {t('addons.update')}
</TextButton> </TextButton>
)} )}
{addon.status === 'available' ? ( {addon.status === 'available' ? (
<TextButton <TextButton
// TODO: With dependencies checkbox
onClick={() => update.mutateAsync({ toUpdate: [addon.folder] })} onClick={() => update.mutateAsync({ toUpdate: [addon.folder] })}
className="text-warmGreen" className="text-warmGreen"
icon={DownloadCloud} icon={DownloadCloud}
size={18} size={18}
title="Download" title={t('addons.download')}
/> />
) : ( ) : (
<DialogButton <DialogButton
@@ -191,14 +196,13 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
dialog={close => ( dialog={close => (
<div className="tw-dialog"> <div className="tw-dialog">
<CloseButton close={close} /> <CloseButton close={close} />
<h4 className="tw-color">Are you sure?</h4> <h4 className="tw-color">{t('addons.deleteConfirmTitle')}</h4>
<hr /> <hr />
<p className="text-blueGray"> <p className="text-blueGray">
Are you sure you want to delete <span>{addon.folder}</span>{' '} {t('addons.deleteConfirmBody', { folder: addon.folder })}
addon?
</p> </p>
<p className="text-blueGray"> <p className="text-blueGray">
This will delete all files in the addon folder. {t('addons.deleteConfirmFiles')}
</p> </p>
<TextButton <TextButton
icon={Trash2} icon={Trash2}
@@ -209,7 +213,7 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
disabled={remove.isLoading} disabled={remove.isLoading}
className="self-end text-red" className="self-end text-red"
> >
Delete {t('addons.delete')}
</TextButton> </TextButton>
</div> </div>
)} )}
@@ -220,7 +224,7 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
className="text-red/50" className="text-red/50"
icon={Trash2} icon={Trash2}
size={18} size={18}
title="Remove" title={t('addons.remove')}
/> />
)} )}
</DialogButton> </DialogButton>
@@ -5,6 +5,7 @@ import CloseButton from '~renderer/components/styled/CloseButton';
import IconSpinner from '~renderer/components/styled/IconSpinner'; import IconSpinner from '~renderer/components/styled/IconSpinner';
import TextButton from '~renderer/components/styled/TextButton'; import TextButton from '~renderer/components/styled/TextButton';
import { api } from '~renderer/utils/api'; import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
const useDebounced = (value: string, delay: number) => { const useDebounced = (value: string, delay: number) => {
const [debouncedValue, setDebouncedValue] = useState(value); const [debouncedValue, setDebouncedValue] = useState(value);
@@ -17,6 +18,7 @@ const useDebounced = (value: string, delay: number) => {
}; };
const CustomAddonDialog = ({ close }: { close: () => void }) => { const CustomAddonDialog = ({ close }: { close: () => void }) => {
const t = useT();
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
const debouncedUrl = useDebounced(url, 500); const debouncedUrl = useDebounced(url, 500);
const response = api.addons.checkGitUrl.useQuery(debouncedUrl, { const response = api.addons.checkGitUrl.useQuery(debouncedUrl, {
@@ -27,10 +29,14 @@ const CustomAddonDialog = ({ close }: { close: () => void }) => {
return ( return (
<div className="tw-dialog"> <div className="tw-dialog">
<CloseButton close={close} /> <CloseButton close={close} />
<h3 className="tw-color">Install addon</h3> <h3 className="tw-color">{t('addons.installAddon')}</h3>
<hr /> <hr />
{response.data ? ( {response.data ? (
<img src={response.data?.preview} alt="Preview" className="w-full" /> <img
src={response.data?.preview}
alt={t('addons.previewAlt')}
className="w-full"
/>
) : ( ) : (
<div className="flex h-[191px] w-full items-center justify-center bg-darkPurple"> <div className="flex h-[191px] w-full items-center justify-center bg-darkPurple">
{response.isFetching && <IconSpinner />} {response.isFetching && <IconSpinner />}
@@ -53,8 +59,8 @@ const CustomAddonDialog = ({ close }: { close: () => void }) => {
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<p className="s1 text-blueGray"> <p className="s1 text-blueGray">
{response.data {response.data
? 'Ready to install' ? t('addons.readyToInstall')
: 'Not a valid git repository URL'} : t('addons.invalidGitUrl')}
</p> </p>
<TextButton <TextButton
onClick={() => { onClick={() => {
@@ -66,7 +72,7 @@ const CustomAddonDialog = ({ close }: { close: () => void }) => {
className={response.data ? 'text-warmGreen' : 'text-blueGray'} className={response.data ? 'text-warmGreen' : 'text-blueGray'}
disabled={!response.data || response.isLoading} disabled={!response.data || response.isLoading}
> >
Install {t('addons.install')}
</TextButton> </TextButton>
</div> </div>
</div> </div>
+52
View File
@@ -0,0 +1,52 @@
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode
} from 'react';
import { api } from '~renderer/utils/api';
import { translations, type Lang } from './translations';
type Params = Record<string, string | number>;
type Translate = (key: string, params?: Params) => string;
type LocaleCtx = {
lang: Lang;
setLang: (lang: Lang) => void;
t: Translate;
};
const LocaleContext = createContext<LocaleCtx>({
lang: 'enUS',
setLang: () => {},
t: key => key
});
export const LocaleProvider = ({ children }: { children: ReactNode }) => {
const { data: pref } = api.preferences.get.useQuery();
const [lang, setLang] = useState<Lang>('enUS');
useEffect(() => {
if (pref?.locale) setLang(pref.locale);
}, [pref?.locale]);
const t: Translate = (key, params) => {
let s = translations[lang]?.[key] ?? translations.enUS[key] ?? key;
if (params)
for (const [k, v] of Object.entries(params))
s = s.replace(`{${k}}`, String(v));
return s;
};
return (
<LocaleContext.Provider value={{ lang, setLang, t }}>
{children}
</LocaleContext.Provider>
);
};
export const useLocale = () => useContext(LocaleContext);
export const useT = () => useContext(LocaleContext).t;
File diff suppressed because it is too large Load Diff
+123
View File
@@ -25,6 +25,16 @@ body {
height: 100vh; height: 100vh;
} }
body {
-webkit-user-select: text;
user-select: text;
}
::selection {
background: rgb(248 156 66 / 0.45);
color: #fff;
}
#root { #root {
position: relative; position: relative;
width: 100%; width: 100%;
@@ -192,6 +202,119 @@ input[type='number'] {
} }
} }
.parchment-post {
position: relative;
color: #2b1a0c;
background-color: #e9dab5;
background-size: cover;
background-position: center;
@apply border p-4;
border-color: rgba(60, 40, 15, 0.4);
box-shadow: rgb(0 0 0 / 45%) 0px 25px 20px -20px;
& .parchment-post-title {
@apply font-fontin uppercase;
font-size: 18px;
line-height: 24px;
letter-spacing: 0.03em;
color: #3a230f;
}
& .parchment-post-meta {
font-size: 14px;
line-height: 20px;
font-style: italic;
color: #6b5836;
}
& .parchment-post-muted {
color: #7a6a47;
}
& hr {
@apply -mx-4;
border: 0;
border-top: 1px solid rgba(60, 40, 15, 0.25);
}
& button {
color: #6b3e15;
}
& button:hover,
& button:focus {
color: #9c4a12;
}
& .parchment-post-body,
& .parchment-post-body * {
color: #2b1a0c;
}
& .parchment-post-body {
font-size: 15px;
line-height: 24px;
word-break: break-word;
overflow-wrap: anywhere;
}
& .parchment-post-body a {
color: #6b3e15;
text-decoration: underline;
cursor: pointer;
}
& .parchment-post-body a:hover {
color: #9c4a12;
}
& .parchment-post-body p {
margin: 6px 0;
}
& .parchment-post-body strong,
& .parchment-post-body b {
font-weight: 700;
}
& .parchment-post-body em,
& .parchment-post-body i {
font-style: italic;
}
& .parchment-post-body ul {
list-style: disc;
padding-left: 20px;
margin: 6px 0;
}
& .parchment-post-body ol {
list-style: decimal;
padding-left: 20px;
margin: 6px 0;
}
& .parchment-post-body blockquote {
border-left: 3px solid #b08a57;
background: rgba(120, 80, 30, 0.08);
padding: 8px 12px;
margin: 8px 0;
font-style: italic;
}
& .parchment-post-body code,
& .parchment-post-body pre,
& .parchment-post-body .codebox {
font-family: monospace;
background: rgba(60, 40, 15, 0.1);
border: 1px solid rgba(60, 40, 15, 0.2);
border-radius: 2px;
padding: 8px;
display: block;
white-space: pre-wrap;
word-break: break-word;
}
& .parchment-post-body img {
max-width: 100%;
height: auto;
}
& .parchment-post-body h1,
& .parchment-post-body h2,
& .parchment-post-body h3,
& .parchment-post-body h4 {
color: #3a230f;
}
}
.tw-hocus { .tw-hocus {
@apply hocus:text-orange hocus:drop-shadow-[0px_0px_15px_white]; @apply hocus:text-orange hocus:drop-shadow-[0px_0px_15px_white];
} }
+4 -1
View File
@@ -11,6 +11,7 @@ import log from 'electron-log/renderer';
import { api } from './utils/api'; import { api } from './utils/api';
import App from './App'; import App from './App';
import ErrorBoundary from './ErrorBoundary'; import ErrorBoundary from './ErrorBoundary';
import { LocaleProvider } from './i18n';
import './index.css'; import './index.css';
@@ -56,7 +57,9 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<ErrorBoundary> <ErrorBoundary>
<api.Provider client={trpcClient} queryClient={queryClient}> <api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<App /> <LocaleProvider>
<App />
</LocaleProvider>
{import.meta.env.DEV && <ReactQueryDevtools />} {import.meta.env.DEV && <ReactQueryDevtools />}
</QueryClientProvider> </QueryClientProvider>
</api.Provider> </api.Provider>
+5 -10
View File
@@ -2,29 +2,24 @@ import { useEffect } from 'react';
const allowedElements = ['INPUT', 'TEXTAREA']; const allowedElements = ['INPUT', 'TEXTAREA'];
const isClipboardShortcut = (e: KeyboardEvent) =>
(e.ctrlKey || e.metaKey) &&
['a', 'c', 'v', 'x'].includes(e.key.toLowerCase());
const usePreventDefaultEvents = () => { const usePreventDefaultEvents = () => {
useEffect(() => { useEffect(() => {
const disableKeyboardEvents = (e: KeyboardEvent) => { const disableKeyboardEvents = (e: KeyboardEvent) => {
if (allowedElements.includes((e.target as HTMLElement).tagName)) return; if (allowedElements.includes((e.target as HTMLElement).tagName)) return;
e.preventDefault(); if (isClipboardShortcut(e)) return;
};
const disableFocus = (e: FocusEvent) => {
if (allowedElements.includes((e.target as HTMLElement).tagName)) return;
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
e.preventDefault(); e.preventDefault();
}; };
window.addEventListener('keydown', disableKeyboardEvents, true); window.addEventListener('keydown', disableKeyboardEvents, true);
window.addEventListener('keyup', disableKeyboardEvents, true); window.addEventListener('keyup', disableKeyboardEvents, true);
window.addEventListener('focusin', disableFocus, true);
return () => { return () => {
window.removeEventListener('keydown', disableKeyboardEvents, true); window.removeEventListener('keydown', disableKeyboardEvents, true);
window.removeEventListener('keyup', disableKeyboardEvents, true); window.removeEventListener('keyup', disableKeyboardEvents, true);
window.removeEventListener('focusin', disableFocus, true);
}; };
}, []); }, []);
}; };