diff --git a/package.json b/package.json index d750642..7eaa889 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "octo-launcher", - "version": "1.0.27", + "version": "1.1.1", "description": "An Electron application for launching and updating the OctoWoW client", "author": "OctoWoW", "copyright": "Copyright © 2026 OctoWoW", @@ -9,7 +9,7 @@ "start": "electron-vite preview", "dev": "electron-vite dev", "server": "cd server && npm run dev", - "postinstall": "electron-builder install-app-deps && node scripts/scrub-native-paths.cjs", + "postinstall": "electron-builder install-app-deps", "build": "electron-vite build", "build:test": "electron-vite build --mode test", "pack": "electron-builder --config", diff --git a/server/src/addons-resolver.ts b/server/src/addons-resolver.ts index fb860a0..4138bd9 100644 --- a/server/src/addons-resolver.ts +++ b/server/src/addons-resolver.ts @@ -1,266 +1,286 @@ -import fs from 'fs-extra'; -import path from 'path'; - -import { defaultSources, type AddonSource } from './addons-sources.js'; - -const CACHE_TTL_MS = 60 * 60 * 1000; -const FETCH_CONCURRENCY = 8; -const FETCH_TIMEOUT_MS = 10_000; -const SOURCES_OVERRIDE_PATH = process.env.ADDONS_SOURCES_PATH ?? ''; - -export type TocData = Record; - -export type ResolvedAddon = { - name: string; - owner: string; - git: string; - branch?: string; - ref?: string; - toc?: TocData; - description?: string; - lastUpdated?: string; - stars?: number; -}; - -type CacheEntry = { at: number; data: ResolvedAddon[] }; -let cache: CacheEntry | undefined; -let inFlight: Promise | undefined; - -const normalizeColorCodes = (s: string): string => - s.replace(/\|C(?=[0-9a-fA-F]{8})/g, '|c').replace(/\|R/g, '|r'); - -const parseToc = (content: string): TocData => - content - .split('\n') - .filter(l => l.startsWith('## ')) - .map(l => l.slice(3)) - .map(l => { - const idx = l.indexOf(':'); - 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, [k, v]) => { - acc[k] = normalizeColorCodes(v); - return acc; - }, {}); - -const fetchWithTimeout = async (url: string, init?: RequestInit) => { - const controller = new AbortController(); - const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - try { - return await fetch(url, { ...init, signal: controller.signal }); - } finally { - clearTimeout(t); - } -}; - -type RepoMeta = { - description?: string; - defaultBranch?: string; - lastUpdated?: string; - stars?: number; -}; - -type RawMeta = { - description?: string | null; - default_branch?: string; - pushed_at?: string | null; - updated_at?: string | null; - stargazers_count?: number | null; - stars_count?: number | null; -}; - -type Provider = { - apiUrl: (owner: string, repo: string) => string; - apiHeaders: () => Record; - mapMeta: (json: RawMeta) => RepoMeta; - tocUrl: (owner: string, repo: string, ref: string, name: string) => string; -}; - -const githubProvider: Provider = { - apiUrl: (o, r) => `https://api.github.com/repos/${o}/${r}`, - apiHeaders: () => ({ - Accept: 'application/vnd.github+json', - ...(process.env.GITHUB_TOKEN && { - Authorization: `Bearer ${process.env.GITHUB_TOKEN}` - }) - }), - mapMeta: j => ({ - description: j.description ?? undefined, - defaultBranch: j.default_branch, - lastUpdated: j.pushed_at ?? undefined, - stars: j.stargazers_count ?? undefined - }), - tocUrl: (o, r, ref, name) => - `https://raw.githubusercontent.com/${o}/${r}/${ref}/${name}.toc` -}; - -const GITEA_API = 'https://octowow.st/git/api/v1'; -const giteaProvider: Provider = { - apiUrl: (o, r) => `${GITEA_API}/repos/${o}/${r}`, - apiHeaders: () => ({ Accept: 'application/json' }), - mapMeta: j => ({ - description: j.description ?? undefined, - defaultBranch: j.default_branch, - lastUpdated: j.updated_at ?? undefined, - stars: j.stars_count ?? undefined - }), - tocUrl: (o, r, ref, name) => - `${GITEA_API}/repos/${o}/${r}/raw/${name}.toc?ref=${encodeURIComponent(ref)}` -}; - -const parseGitUrl = ( - git: string -): { owner: string; repo: string; provider: Provider } => { - const gh = git.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/); - if (gh && gh[1] && gh[2]) { - return { owner: gh[1], repo: gh[2], provider: githubProvider }; - } - const gitea = git.match(/octowow\.st\/git\/([^/]+)\/([^/]+?)(?:\.git)?$/); - if (gitea && gitea[1] && gitea[2]) { - return { owner: gitea[1], repo: gitea[2], provider: giteaProvider }; - } - throw Error(`Unsupported git URL: ${git}`); -}; - -const REQUIRED_TOC_KEYS = ['Interface']; - -const tryFetchToc = async ( - provider: Provider, - owner: string, - repo: string, - name: string, - ref: string -): Promise => { - const res = await fetchWithTimeout( - provider.tocUrl(owner, repo, ref, name) - ).catch(() => null); - if (!res?.ok) return undefined; - const parsed = parseToc(await res.text()); - return REQUIRED_TOC_KEYS.every(k => typeof parsed[k] === 'string') - ? parsed - : undefined; -}; - -const resolveOne = async (src: AddonSource): Promise => { - try { - const { owner, repo, provider } = parseGitUrl(src.git); - const name = src.name ?? repo; - - const apiRes = await fetchWithTimeout(provider.apiUrl(owner, repo), { - headers: provider.apiHeaders() - }).catch(() => null); - - let meta: RepoMeta | undefined; - if (apiRes?.ok) meta = provider.mapMeta((await apiRes.json()) as RawMeta); - - const candidates = src.ref - ? [src.ref] - : src.branch - ? [src.branch] - : [...new Set([meta?.defaultBranch, 'master', 'main'].filter((b): b is string => !!b))]; - - let toc: TocData | undefined; - let resolvedRef: string | undefined; - for (const ref of candidates) { - toc = await tryFetchToc(provider, owner, repo, name, ref); - if (toc) { - resolvedRef = ref; - break; - } - } - - const effectiveBranch = src.ref - ? undefined - : src.branch ?? resolvedRef ?? meta?.defaultBranch; - - let description = meta?.description ?? undefined; - const lastUpdated = meta?.lastUpdated; - const stars = meta?.stars; - - if (src.description) { - description = src.description; - if (toc) toc = { ...toc, Notes: src.description }; - } - - const result: ResolvedAddon = { name, owner, git: src.git }; - if (effectiveBranch !== undefined) result.branch = effectiveBranch; - if (src.ref !== undefined) result.ref = src.ref; - if (toc !== undefined) result.toc = toc; - if (description !== undefined) result.description = description; - if (lastUpdated !== undefined) result.lastUpdated = lastUpdated; - if (stars !== undefined) result.stars = stars; - return result; - } catch (e) { - console.error(`Failed to resolve ${src.git}:`, e); - return null; - } -}; - -const poolMap = async ( - items: T[], - concurrency: number, - fn: (item: T) => Promise -): Promise => { - const results: R[] = new Array(items.length); - let idx = 0; - const worker = async () => { - while (true) { - const i = idx++; - if (i >= items.length) return; - const item = items[i]; - if (item === undefined) return; - results[i] = await fn(item); - } - }; - await Promise.all(Array.from({ length: concurrency }, worker)); - return results; -}; - -const loadSources = async (): Promise => { - if (!SOURCES_OVERRIDE_PATH) return defaultSources; - try { - if (await fs.pathExists(SOURCES_OVERRIDE_PATH)) { - const override = (await fs.readJSON(SOURCES_OVERRIDE_PATH)) as AddonSource[]; - if (Array.isArray(override) && override.length > 0) { - console.log(`Using addon sources override from ${SOURCES_OVERRIDE_PATH}`); - return override; - } - } - } catch (e) { - console.error(`Failed to read override at ${SOURCES_OVERRIDE_PATH}, using defaults:`, e); - } - return defaultSources; -}; - -const buildList = async (): Promise => { - const sources = await loadSources(); - console.log(`Resolving metadata for ${sources.length} addons (concurrency=${FETCH_CONCURRENCY})...`); - const t0 = Date.now(); - const results = await poolMap(sources, FETCH_CONCURRENCY, resolveOne); - const ok = results.filter((r): r is ResolvedAddon => r !== null); - ok.sort((a, b) => a.name.localeCompare(b.name)); - console.log(`Resolved ${ok.length}/${sources.length} addons in ${Date.now() - t0}ms`); - return ok; -}; - -export const getAddons = async (force = false): Promise => { - if (!force && cache && Date.now() - cache.at < CACHE_TTL_MS) { - return cache.data; - } - if (inFlight) return inFlight; - inFlight = buildList() - .then(data => { - cache = { at: Date.now(), data }; - return data; - }) - .finally(() => { - inFlight = undefined; - }); - return inFlight; -}; - -export const warmUp = () => { - getAddons().catch(e => console.error('Addon resolver warm-up failed:', e)); -}; +import fs from 'fs-extra'; +import path from 'path'; + +import { defaultSources, type AddonSource } from './addons-sources.js'; + +const CACHE_TTL_MS = 60 * 60 * 1000; +const FETCH_CONCURRENCY = 8; +const FETCH_TIMEOUT_MS = 10_000; +const SOURCES_OVERRIDE_PATH = process.env.ADDONS_SOURCES_PATH ?? ''; + +export type TocData = Record; + +export type ResolvedAddon = { + name: string; + owner: string; + git: string; + branch?: string; + ref?: string; + toc?: TocData; + description?: string; + lastUpdated?: string; + stars?: number; +}; + +type CacheEntry = { at: number; data: ResolvedAddon[] }; +let cache: CacheEntry | undefined; +let inFlight: Promise | undefined; + +const normalizeColorCodes = (s: string): string => + s.replace(/\|C(?=[0-9a-fA-F]{8})/g, '|c').replace(/\|R/g, '|r'); + +const parseToc = (content: string): TocData => + content + .split('\n') + .filter(l => l.startsWith('## ')) + .map(l => l.slice(3)) + .map(l => { + const idx = l.indexOf(':'); + 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, [k, v]) => { + acc[k] = normalizeColorCodes(v); + return acc; + }, {}); + +const fetchWithTimeout = async (url: string, init?: RequestInit) => { + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(t); + } +}; + +type RepoMeta = { + description?: string; + defaultBranch?: string; + lastUpdated?: string; + stars?: number; +}; + +type RawMeta = { + description?: string | null; + default_branch?: string; + pushed_at?: string | null; + updated_at?: string | null; + stargazers_count?: number | null; + stars_count?: number | null; +}; + +type Provider = { + apiUrl: (owner: string, repo: string) => string; + apiHeaders: () => Record; + mapMeta: (json: RawMeta) => RepoMeta; + tocUrl: (owner: string, repo: string, ref: string, name: string) => string; +}; + +const githubProvider: Provider = { + apiUrl: (o, r) => `https://api.github.com/repos/${o}/${r}`, + apiHeaders: () => ({ + Accept: 'application/vnd.github+json', + ...(process.env.GITHUB_TOKEN && { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}` + }) + }), + mapMeta: j => ({ + description: j.description ?? undefined, + defaultBranch: j.default_branch, + lastUpdated: j.pushed_at ?? undefined, + stars: j.stargazers_count ?? undefined + }), + tocUrl: (o, r, ref, name) => + `https://raw.githubusercontent.com/${o}/${r}/${ref}/${name}.toc` +}; + +const GITEA_API = 'https://octowow.st/git/api/v1'; +const giteaProvider: Provider = { + apiUrl: (o, r) => `${GITEA_API}/repos/${o}/${r}`, + apiHeaders: () => ({ Accept: 'application/json' }), + mapMeta: j => ({ + description: j.description ?? undefined, + defaultBranch: j.default_branch, + lastUpdated: j.updated_at ?? undefined, + stars: j.stars_count ?? undefined + }), + tocUrl: (o, r, ref, name) => + `${GITEA_API}/repos/${o}/${r}/raw/${name}.toc?ref=${encodeURIComponent( + ref + )}` +}; + +// only allow known git hosts over https +const parseGitUrl = ( + git: string +): { owner: string; repo: string; provider: Provider } => { + const gh = git.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/); + if (gh && gh[1] && gh[2]) { + return { owner: gh[1], repo: gh[2], provider: githubProvider }; + } + const gitea = git.match(/octowow\.st\/git\/([^/]+)\/([^/]+?)(?:\.git)?$/); + if (gitea && gitea[1] && gitea[2]) { + return { owner: gitea[1], repo: gitea[2], provider: giteaProvider }; + } + throw Error(`Unsupported git URL: ${git}`); +}; + +const REQUIRED_TOC_KEYS = ['Interface']; + +const tryFetchToc = async ( + provider: Provider, + owner: string, + repo: string, + name: string, + ref: string +): Promise => { + const res = await fetchWithTimeout( + provider.tocUrl(owner, repo, ref, name) + ).catch(() => null); + if (!res?.ok) return undefined; + const parsed = parseToc(await res.text()); + return REQUIRED_TOC_KEYS.every(k => typeof parsed[k] === 'string') + ? parsed + : undefined; +}; + +const resolveOne = async (src: AddonSource): Promise => { + try { + const { owner, repo, provider } = parseGitUrl(src.git); + const name = src.name ?? repo; + + const apiRes = await fetchWithTimeout(provider.apiUrl(owner, repo), { + headers: provider.apiHeaders() + }).catch(() => null); + + let meta: RepoMeta | undefined; + if (apiRes?.ok) meta = provider.mapMeta((await apiRes.json()) as RawMeta); + + const candidates = src.ref + ? [src.ref] + : src.branch + ? [src.branch] + : [ + ...new Set( + [meta?.defaultBranch, 'main', 'master'].filter( + (b): b is string => !!b + ) + ) + ]; + + let toc: TocData | undefined; + let resolvedRef: string | undefined; + for (const ref of candidates) { + toc = await tryFetchToc(provider, owner, repo, name, ref); + if (toc) { + resolvedRef = ref; + break; + } + } + + const effectiveBranch = src.ref + ? undefined + : src.branch ?? resolvedRef ?? meta?.defaultBranch; + + let description = meta?.description ?? undefined; + const lastUpdated = meta?.lastUpdated; + const stars = meta?.stars; + + if (src.description) { + description = src.description; + if (toc) toc = { ...toc, Notes: src.description }; + } + + const result: ResolvedAddon = { name, owner, git: src.git }; + if (effectiveBranch !== undefined) result.branch = effectiveBranch; + if (src.ref !== undefined) result.ref = src.ref; + if (toc !== undefined) result.toc = toc; + if (description !== undefined) result.description = description; + if (lastUpdated !== undefined) result.lastUpdated = lastUpdated; + if (stars !== undefined) result.stars = stars; + return result; + } catch (e) { + console.error(`Failed to resolve ${src.git}:`, e); + return null; + } +}; + +const poolMap = async ( + items: T[], + concurrency: number, + fn: (item: T) => Promise +): Promise => { + const results: R[] = new Array(items.length); + let idx = 0; + const worker = async () => { + while (true) { + const i = idx++; + if (i >= items.length) return; + const item = items[i]; + if (item === undefined) return; + results[i] = await fn(item); + } + }; + await Promise.all(Array.from({ length: concurrency }, worker)); + return results; +}; + +const loadSources = async (): Promise => { + if (!SOURCES_OVERRIDE_PATH) return defaultSources; + try { + if (await fs.pathExists(SOURCES_OVERRIDE_PATH)) { + const override = (await fs.readJSON( + SOURCES_OVERRIDE_PATH + )) as AddonSource[]; + if (Array.isArray(override) && override.length > 0) { + console.log( + `Using addon sources override from ${SOURCES_OVERRIDE_PATH}` + ); + return override; + } + } + } catch (e) { + console.error( + `Failed to read override at ${SOURCES_OVERRIDE_PATH}, using defaults:`, + e + ); + } + return defaultSources; +}; + +const buildList = async (): Promise => { + const sources = await loadSources(); + console.log( + `Resolving metadata for ${sources.length} addons (concurrency=${FETCH_CONCURRENCY})...` + ); + const t0 = Date.now(); + const results = await poolMap(sources, FETCH_CONCURRENCY, resolveOne); + const ok = results.filter((r): r is ResolvedAddon => r !== null); + ok.sort((a, b) => a.name.localeCompare(b.name)); + console.log( + `Resolved ${ok.length}/${sources.length} addons in ${Date.now() - t0}ms` + ); + return ok; +}; + +export const getAddons = async (force = false): Promise => { + if (!force && cache && Date.now() - cache.at < CACHE_TTL_MS) { + return cache.data; + } + if (inFlight) return inFlight; + inFlight = buildList() + .then(data => { + cache = { at: Date.now(), data }; + return data; + }) + .finally(() => { + inFlight = undefined; + }); + return inFlight; +}; + +export const warmUp = () => { + getAddons().catch(e => console.error('Addon resolver warm-up failed:', e)); +}; diff --git a/server/src/addons-sources.ts b/server/src/addons-sources.ts index 8a71e77..8a8736e 100644 --- a/server/src/addons-sources.ts +++ b/server/src/addons-sources.ts @@ -29,7 +29,7 @@ export const defaultSources: AddonSource[] = [ 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', description: 'Auction House replacement with advanced filtering and search' }, @@ -48,7 +48,7 @@ export const defaultSources: AddonSource[] = [ git: 'https://github.com/MDGitHubRepo/CallOfElements.git', 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', 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', 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/Fiurs-Hearth/ExtraResourceBars.git', @@ -98,14 +98,14 @@ export const defaultSources: AddonSource[] = [ 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', 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/MarcelineVQ/ModifiedPowerAuras.git', + git: 'https://github.com/tdymel/ModifiedPowerAuras.git', 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/Emyrk/NampowerSettings.git', + git: 'https://github.com/Dusk-92/NampowerSettings.git', 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' }, { - git: 'https://github.com/ShikawaLePaladin/PallyPowerTW.git', + git: 'https://github.com/CosminPOP/PallyPower.git', 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' }, { 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/jrc13245/pfUI-addonskinner.git', @@ -170,7 +170,7 @@ export const defaultSources: AddonSource[] = [ }, { 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' }, { git: 'https://github.com/Otari98/Rinse.git' }, @@ -183,9 +183,9 @@ export const defaultSources: AddonSource[] = [ git: 'https://github.com/shagu/ShaguPlates.git', 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)' }, { git: 'https://github.com/pepopo978/SimpleActionSets.git' }, diff --git a/server/src/cache.ts b/server/src/cache.ts index 1603777..d16ebad 100644 --- a/server/src/cache.ts +++ b/server/src/cache.ts @@ -25,8 +25,7 @@ const skipFiles = new Set([ ]); const skipPatterns: RegExp[] = [/\.bak(\.|$)/, /\.crashing(\.|$)/]; -const isSkipPattern = (file: string) => - skipPatterns.some(p => p.test(file)); +const isSkipPattern = (file: string) => skipPatterns.some(p => p.test(file)); const skipDirsPosix = new Set([ 'Interface/GlueXML', @@ -66,7 +65,9 @@ export type BuildProgress = { error: string | null; }; -export type ProgressCallback = (p: Pick) => void; +export type ProgressCallback = ( + p: Pick +) => void; const getHash = (...filePath: string[]): Promise => new Promise((resolve, reject) => { @@ -77,7 +78,10 @@ const getHash = (...filePath: string[]): Promise => stream.on('end', () => resolve(hash.digest('hex').toLocaleUpperCase())); }); -const countFiles = async (clientPath: string, ...filePath: string[]): Promise => { +const countFiles = async ( + clientPath: string, + ...filePath: string[] +): Promise => { let total = 0; const dir = path.join(clientPath, ...filePath); const files = await fs.readdir(dir); @@ -118,8 +122,9 @@ export const buildCache = async ( if (node.type === 'dir' || node.type === 'mpq') { const newPrefix = node.name ? [...prefix, node.name] : prefix; if (node.type === 'mpq') { - const mpqKey = [...newPrefix.slice(0, -1), node.name + '.mpq'] - .join('/'); + const mpqKey = [...newPrefix.slice(0, -1), node.name + '.mpq'].join( + '/' + ); prevHashByPath.set(mpqKey, node.hash); prevSizeByPath.set(mpqKey, node.size); } @@ -237,12 +242,7 @@ export const buildCache = async ( tree.push({ type: 'file', name: file, - hash: await getHashCached( - fullPath, - stats.mtimeMs, - ...filePath, - file - ), + hash: await getHashCached(fullPath, stats.mtimeMs, ...filePath, file), version: allowModified ? stats.mtimeMs : undefined, size: stats.size, tags: tags.length ? tags : undefined @@ -307,7 +307,7 @@ export const buildCache = async ( } } catch (e) { console.warn( - `manifest-overrides: failed to apply ${overridesPath} -- continuing ` + + `manifest-overrides: failed to apply ${overridesPath}, continuing` + `without overrides (${(e as Error).message})` ); } diff --git a/server/src/index.ts b/server/src/index.ts index 363dbd8..fbbd65b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -5,7 +5,7 @@ import fs from 'fs-extra'; import { buildCache, type BuildProgress } from './cache.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 port = 5000; @@ -62,8 +62,7 @@ app.get('/api/file/:version/manifest.json', async (_req, res) => { return; } - void ensureManifestBuilt().catch(() => { - }); + void ensureManifestBuilt().catch(() => {}); res.setHeader('Retry-After', '5'); res.status(503).json({ error: 'manifest_building', @@ -80,7 +79,15 @@ app.get( const filePath = req.params[0]; console.log(`Fetching file: ${filePath}`); - res.sendFile(path.join(SourceDir, filePath)); + // keep the resolved path inside SourceDir + 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 +147,15 @@ app.listen(port, () => { const newest = await newestSourceMtime(SourceDir); if (newest > manifestStat.mtimeMs) { 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() .then(() => console.log('Background manifest rebuild complete.')) - .catch(e => - console.error('Background manifest rebuild failed:', e) - ); + .catch(e => console.error('Background manifest rebuild failed:', e)); } else { console.log( `Manifest cache already on disk at ${manifestPath} and up to date; no rebuild needed.` diff --git a/src/common/mods.ts b/src/common/mods.ts index 944ac18..58ee7c8 100644 --- a/src/common/mods.ts +++ b/src/common/mods.ts @@ -15,8 +15,6 @@ export type ModSource = | { kind: 'directFile'; url: string; - versionUrl?: string; - latestVersionUrl?: string; parseLatest?: 'githubRelease' | 'gitlabRelease' | 'codebergRelease'; apiUrl?: string; pinnedTag?: string; @@ -25,7 +23,6 @@ export type ModSource = | { kind: 'archive'; url: string; - latestVersionUrl?: string; apiUrl?: string; parseLatest?: 'githubRelease' | 'gitlabRelease' | 'codebergRelease'; pinnedTag?: string; @@ -96,7 +93,8 @@ export const MODS: ModEntry[] = [ pinnedTag: '0.2', format: 'zip', extractMap: { - 'VanillaMultiMonitorFix.dll': 'VanillaMultiMonitorFix.dll' + 'VanillaMultiMonitorFix.dll': 'VanillaMultiMonitorFix.dll', + 'VMMFix_preferred_monitor.txt': 'VMMFix_preferred_monitor.txt' } }, registerInDllsTxt: 'VanillaMultiMonitorFix.dll' @@ -139,7 +137,8 @@ export const MODS: ModEntry[] = [ id: 'vanillaFixes', name: 'vanillaFixes', 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, repoUrl: 'https://github.com/hannesmann/vanillafixes', source: { @@ -160,7 +159,8 @@ export const MODS: ModEntry[] = [ id: 'vanillaHelpers', name: 'vanillaHelpers', 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', requires: ['vanillaFixes'], source: { diff --git a/src/common/schemas.ts b/src/common/schemas.ts index 385ca6d..0d360c5 100644 --- a/src/common/schemas.ts +++ b/src/common/schemas.ts @@ -44,7 +44,12 @@ export const PreferencesSchema = z.object({ lastPatchedLauncherVersion: z.string().optional(), expectedPatchedWowHash: z.string().optional(), 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(), windowPosition: z .object({ diff --git a/src/common/utils.ts b/src/common/utils.ts index 4e16787..46e4481 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -1,10 +1,19 @@ type Path = readonly (string | number)[]; +// reject prototype-polluting keys +const isUnsafeKey = (key: string | number) => + key === '__proto__' || key === 'constructor' || key === 'prototype'; + export const nestedGet = (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) => { const [key, ...rest] = path; + if (isUnsafeKey(key)) return; + if (path.length === 1) { obj[key] = value; return; diff --git a/src/main/api/routers/general.ts b/src/main/api/routers/general.ts index 20ea814..d374e63 100644 --- a/src/main/api/routers/general.ts +++ b/src/main/api/routers/general.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { mainWindow } from '~main/index'; import Preferences from '~main/modules/preferences'; +import { addDefenderExclusions } from '~main/modules/defender'; import { createTRPCRouter, publicProcedure } from '../trpc'; @@ -22,6 +23,7 @@ export const generalRouter = createTRPCRouter({ const file = Logger.transports.file.getFile().path; shell.openPath(file); }), + addDefenderExclusion: publicProcedure.mutation(() => addDefenderExclusions()), filePicker: publicProcedure .input( z.object({ diff --git a/src/main/api/routers/launcher.ts b/src/main/api/routers/launcher.ts index 70558bf..e1c875d 100644 --- a/src/main/api/routers/launcher.ts +++ b/src/main/api/routers/launcher.ts @@ -2,7 +2,6 @@ import path from 'path'; import { spawn } from 'child_process'; import fs from 'fs-extra'; -import { inject } from 'dll-inject'; import Logger from 'electron-log/main'; import Preferences from '~main/modules/preferences'; @@ -10,95 +9,101 @@ import Mods from '~main/modules/mods'; import { mainWindow } from '~main/index'; import { isGameRunning } from '~main/modules/updater'; import { patchConfig } from '~main/modules/patcher'; +import { applyLocalePatch } from '~main/modules/localePatch'; import { minimizeToTray, restoreFromTray } from '~main/modules/tray'; import { getMod } from '~common/mods'; import { createTRPCRouter, publicProcedure } from '../trpc'; -const ensureChainloaderTweak = async (clientDir: string): Promise => { - if (Preferences.data.config.vanillaFixes) return true; +const chainloaderNeeded = async (clientDir: string): Promise => { + 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'); if (await fs.pathExists(dllsPath)) { const raw = await fs.readFile(dllsPath, 'utf8'); - dllsTxtHasEntries = raw - .split(/\r?\n/) - .some(l => l.trim() && !l.trim().startsWith('#')); + return raw.split(/\r?\n/).some(l => l.trim() && !l.trim().startsWith('#')); } - - 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; + return false; }; -export const launcherRouter = createTRPCRouter({ - start: publicProcedure.mutation(async () => { - const { cleanWdb, minimizeToTrayOnPlay, config, clientDir } = - Preferences.data; - if (!clientDir) return false; +type StartResult = { ok: boolean; error?: string }; - const clientPath = path.join(clientDir, 'WoW.exe'); - Logger.log(`Launching ${clientPath}...`); - if (await isGameRunning(clientPath)) return false; +export const launcherRouter = createTRPCRouter({ + start: publicProcedure.mutation(async (): Promise => { + 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) { Logger.log('Cleaning up WDB...'); - await fs.remove(path.join(clientPath, 'WDB')); + await fs.remove(path.join(clientDir, 'WDB')); } Logger.log('Checking Config.wtf...'); await patchConfig(); - Logger.log('Launching WoW...'); - const process = spawn(clientPath, { detached: !minimizeToTrayOnPlay }); + Logger.log('Applying UI language...'); + await applyLocalePatch(clientDir, Preferences.data.locale); - const wantChainloader = await ensureChainloaderTweak(clientDir); - if (wantChainloader) { - Logger.log('Injecting VanillaFixes...'); - const vfPath = path.join(clientDir, 'VfPatcher.dll'); + const loaderPath = path.join(clientDir, 'VanillaFixes.exe'); + const needsLoader = await chainloaderNeeded(clientDir); + const useLoader = needsLoader && (await fs.pathExists(loaderPath)); + 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))) { - Logger.warn( - `VfPatcher.dll missing at ${vfPath} — chainloader needed but ` + - 'the vanillaFixes mod is not installed. Skipping inject; ' + - 'dlls.txt entries and dependent mods will not load. Install ' + - "vanillaFixes from the Mods tab to fix." - ); - } else { - const status = inject('WoW.exe', vfPath); - if (status) { - Logger.error(`Injecting failed with error code ${status}...`); - return true; - } - } + const octoLocale = Preferences.data.locale || 'enUS'; + const gameEnv = { ...process.env, OCTO_LOCALE: octoLocale }; + Logger.log( + useLoader + ? `Launching via VanillaFixes (OCTO_LOCALE=${octoLocale})...` + : `Launching ${exePath} (OCTO_LOCALE=${octoLocale})...` + ); + const child = useLoader + ? spawn(loaderPath, ['WoW.exe'], { + env: gameEnv, + cwd: clientDir, + detached: !minimizeToTrayOnPlay + }) + : spawn(exePath, { + env: gameEnv, + cwd: clientDir, + detached: !minimizeToTrayOnPlay + }); + + try { + await new Promise((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) { mainWindow?.close(); - return true; + return { ok: true }; } minimizeToTray(); - process.on('exit', () => { + child.on('exit', () => { Logger.log('WoW stopped'); restoreFromTray(); }); - return true; + return { ok: true }; }) }); diff --git a/src/main/api/routers/patcher.ts b/src/main/api/routers/patcher.ts index a8e1b3d..8a34ad1 100644 --- a/src/main/api/routers/patcher.ts +++ b/src/main/api/routers/patcher.ts @@ -1,5 +1,6 @@ import { patchConfig, patchExecutable } from '~main/modules/patcher'; import Preferences from '~main/modules/preferences'; +import Updater from '~main/modules/updater'; import { getClientVersion } from '~main/utils'; import { createTRPCRouter, publicProcedure } from '../trpc'; @@ -7,7 +8,8 @@ import { createTRPCRouter, publicProcedure } from '../trpc'; export const patcherRouter = createTRPCRouter({ apply: publicProcedure.mutation(async () => { await patchExecutable(); - await patchConfig(); + await patchConfig(true); + await Updater.recordPatchedWow(); Preferences.data = { version: await getClientVersion() }; }) }); diff --git a/src/main/api/routers/preferences.ts b/src/main/api/routers/preferences.ts index 926327d..6967be8 100644 --- a/src/main/api/routers/preferences.ts +++ b/src/main/api/routers/preferences.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { PreferencesSchema } from '~common/schemas'; import Preferences from '~main/modules/preferences'; +import { applyLocalePatch } from '~main/modules/localePatch'; import { createTRPCRouter, publicProcedure } from '../trpc'; @@ -11,6 +12,8 @@ export const preferencesRouter = createTRPCRouter({ .input(PreferencesSchema.partial()) .mutation(async ({ input }) => { Preferences.data = input; + if (input.locale !== undefined) + await applyLocalePatch(Preferences.data.clientDir, input.locale); return Preferences.data; }), isValidClientDir: publicProcedure diff --git a/src/main/index.ts b/src/main/index.ts index 466d2b9..946dc97 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,6 @@ import { join } from 'path'; -import { app, shell, BrowserWindow } from 'electron'; +import { app, shell, BrowserWindow, screen } from 'electron'; import { electronApp, optimizer, is } from '@electron-toolkit/utils'; import { createIPCHandler } from 'electron-trpc/main'; import Logger from 'electron-log/main'; @@ -22,10 +22,33 @@ app.disableHardwareAcceleration(); 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 position = Preferences.data.rememberPosition - ? Preferences.data.windowPosition - : { width: 1000, height: 700 }; + const saved = + Preferences.data.rememberPosition && + isOnScreen(Preferences.data.windowPosition) + ? Preferences.data.windowPosition + : undefined; + const position = saved ?? { width: 1000, height: 700 }; mainWindow = new BrowserWindow({ ...position, @@ -50,16 +73,22 @@ const createWindow = async () => { Logger.error('Renderer unresponsive'); }); - mainWindow.webContents.on('console-message', (_e, level, message, line, sourceId) => { - const lvl = level === 3 ? 'error' : level === 2 ? 'warn' : 'info'; - Logger[lvl](`[renderer:${lvl}] ${message} (${sourceId}:${line})`); - }); + mainWindow.webContents.on( + 'console-message', + (_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) => { if (input.type !== 'keyDown') return; if (input.key === 'F12') { mainWindow?.webContents.toggleDevTools(); + return; } + if ((input.control || input.meta) && input.key.toLowerCase() === 'c') + mainWindow?.webContents.copy(); }); createIPCHandler({ router: appRouter, windows: [mainWindow] }); @@ -77,7 +106,7 @@ const createWindow = async () => { const [width = 0, height = 0] = mainWindow.getSize(); Preferences.data = { windowPosition: { x, y, width, height } }; }); - + if (is.dev && process.env.ELECTRON_RENDERER_URL) { mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL); } else { @@ -85,23 +114,36 @@ const createWindow = async () => { } }; -app.whenReady().then(async () => { - Preferences.data = await Preferences.load(); +const gotSingleInstanceLock = is.dev || app.requestSingleInstanceLock(); - Addons.verify(); - Updater.verify(); - Mods.verify(); - initSelfUpdater(); - - electronApp.setAppUserModelId('com.electron'); - - app.on('browser-window-created', (_, window) => { - optimizer.watchWindowShortcuts(window); +if (!gotSingleInstanceLock) { + app.quit(); +} else { + app.on('second-instance', () => { + if (!mainWindow) return; + if (mainWindow.isMinimized()) mainWindow.restore(); + if (!mainWindow.isVisible()) mainWindow.show(); + mainWindow.focus(); }); - await createWindow(); -}); + app.whenReady().then(async () => { + Preferences.data = await Preferences.load(); -app.on('window-all-closed', async () => { - app.quit(); -}); + Addons.verify(); + Updater.verify(); + Mods.verify(); + initSelfUpdater(); + + electronApp.setAppUserModelId('st.octowow.launcher'); + + app.on('browser-window-created', (_, window) => { + optimizer.watchWindowShortcuts(window); + }); + + await createWindow(); + }); + + app.on('window-all-closed', () => { + app.quit(); + }); +} diff --git a/src/main/modules/addons.ts b/src/main/modules/addons.ts index 1efe96b..06a0fac 100644 --- a/src/main/modules/addons.ts +++ b/src/main/modules/addons.ts @@ -35,22 +35,51 @@ type AddonsList = { }[]; const readTocData = (content: string) => - content + (content.charCodeAt(0) === 0xfeff ? content.slice(1) : content) .split('\n') .filter(l => l.startsWith('## ')) .map(l => l.slice(3)) .map(l => { - const [key, value] = l.split(':'); - return [key.trim(), value.trim()]; + const idx = l.indexOf(':'); + 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]) => { acc[key] = value; return acc; }, {} as TocData); +const isUnsafeFolder = (name?: string) => + !name || name === '.' || name === '..' || /[/\\]/.test(name); + +// only allow known git hosts over https +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 () => { 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; } catch (e) { Logger.error('Failed to reach update server', e); @@ -103,35 +132,36 @@ class AddonsClass extends Observable { }; 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 { await git.getRemoteInfo({ http, 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; try { - const host = new URL(url).hostname.toLowerCase(); - if (allowed.some(h => host === h || host.endsWith('.' + h))) { + if (isAllowedGitUrl(url)) { const response = await fetch(url).then(r => r.text()); preview = response.match( /property="og:image" content="([^"]*)"/ )?.[1]; } } catch { - // preview stays undefined + /* ignore */ } + const folder = gitUrl.slice(0, -4).split('/').at(-1); + if (isUnsafeFolder(folder)) return undefined; return { status: 'available', - folder: gitUrl.slice(0, -4).split('/').at(-1), + folder, git: gitUrl, preview } as AddonData; - } catch (e) { + } catch { return undefined; } } @@ -162,7 +192,7 @@ class AddonsClass extends Observable { } const addonsPath = path.join(clientPath, 'Interface', 'Addons'); - const dirs = await fs.pathExists(addonsPath) + const dirs = (await fs.pathExists(addonsPath)) ? await fs.readdir(addonsPath) : []; const addons: AddonsStatus['addons'] = Object.fromEntries( @@ -223,9 +253,7 @@ class AddonsClass extends Observable { .catch(() => null); const remoteCommit = avail?.ref - ? await git - .resolveRef({ fs, dir, ref: avail.ref }) - .catch(() => null) + ? await git.resolveRef({ fs, dir, ref: avail.ref }).catch(() => null) : await git .log({ fs, dir, ref: `${remote.remote}/${branch}`, depth: 1 }) .then(r => r[0].oid) @@ -249,7 +277,9 @@ class AddonsClass extends Observable { Logger.log( 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` ); } catch (e) { @@ -268,13 +298,16 @@ class AddonsClass extends Observable { const VERIFY_CONCURRENCY = 6; let idx = 0; await Promise.all( - Array.from({ length: Math.min(VERIFY_CONCURRENCY, folders.length) }, async () => { - while (true) { - const i = idx++; - if (i >= folders.length) return; - await verifyOne(folders[i]); + Array.from( + { length: Math.min(VERIFY_CONCURRENCY, folders.length) }, + async () => { + while (true) { + const i = idx++; + if (i >= folders.length) return; + await verifyOne(folders[i]); + } } - }) + ) ); this.status = { ...this.status, state: 'done' }; @@ -330,7 +363,7 @@ class AddonsClass extends Observable { { onProgress: this.#onProgress(folder, data) } ); } - const toc = await readTocData( + const toc = readTocData( await fs.readFile(path.join(dir, `${folder}.toc`), 'utf-8') ); @@ -363,6 +396,25 @@ class AddonsClass extends Observable { async install(data: AddonData) { const clientPath = Preferences.data.clientDir; 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 dir = path.join(addonsPath, data.folder); diff --git a/src/main/modules/defender.ts b/src/main/modules/defender.ts new file mode 100644 index 0000000..0800bda --- /dev/null +++ b/src/main/modules/defender.ts @@ -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 => { + 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(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.' + }); + } + }); + }); +}; diff --git a/src/main/modules/localePatch.ts b/src/main/modules/localePatch.ts new file mode 100644 index 0000000..4b6c7de --- /dev/null +++ b/src/main/modules/localePatch.ts @@ -0,0 +1,117 @@ +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 => { + 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 { + /* ignore */ + } + + 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); + } +}; diff --git a/src/main/modules/mods.ts b/src/main/modules/mods.ts index f89041b..21396a3 100644 --- a/src/main/modules/mods.ts +++ b/src/main/modules/mods.ts @@ -1,5 +1,4 @@ import path from 'path'; -import os from 'os'; import fs from 'fs-extra'; import fetch from 'node-fetch'; @@ -12,8 +11,17 @@ import { type ModState } from '~common/schemas'; import Preferences from './preferences'; import Observable from './observable'; +import Updater from './updater'; 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 = { id: ModId; name: string; @@ -36,8 +44,6 @@ export type ModsStatus = { mods: ModRowStatus[]; }; -const VERSION_CACHE_MS = 10 * 60 * 1000; - class ModsClass extends Observable { protected _value: ModsStatus = { state: 'verifying', @@ -45,21 +51,6 @@ class ModsClass extends Observable { mods: [] }; - #latestCache = new Map(); - - installedFilePaths(): Set { - const set = new Set(); - 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 { return this._value; } @@ -119,6 +110,13 @@ class ModsClass extends Observable { 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, '1\n', 'utf8').catch(() => {}); + } + for (const m of MODS) { const state = Preferences.data?.mods?.[m.id]; let installedVersion = state?.installedVersion; @@ -140,54 +138,28 @@ class ModsClass extends Observable { } } - 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, { installedVersion, - latestVersion: latest, + latestVersion: m.version, enabled: !!state?.enabled, ignoreUpdates: !!state?.ignoreUpdates }); } - this._value = { ...this._value, state: 'idle', dirty: this.#computeDirty() }; + this._value = { + ...this._value, + state: 'idle', + dirty: this.#computeDirty() + }; this._notifyObservers(); } - async #fetchLatestVersion(m: ModEntry): Promise { - 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) { const cur = Preferences.data?.mods?.[id]; await this.#savePref(id, { @@ -216,6 +188,10 @@ class ModsClass extends Observable { Logger.warn('No clientDir set; cannot apply mods.'); return; } + if (this._value.state === 'busy') { + Logger.warn('applyAll already running; ignoring re-entrant call.'); + return; + } this._value = { ...this._value, state: 'busy' }; this._notifyObservers(); @@ -226,6 +202,7 @@ class ModsClass extends Observable { return 0; }); + const failures = new Map(); for (const row of queue) { const m = getMod(row.id); if (!m) continue; @@ -248,15 +225,16 @@ class ModsClass extends Observable { } } catch (e) { Logger.error(`Failed to apply ${m.id}:`, e); - this.#patchRow(m.id, { - state: 'error', - error: e instanceof Error ? e.message : String(e) - }); + const msg = e instanceof Error ? e.message : String(e); + failures.set(m.id, looksLikeAvBlock(msg) ? AV_ERROR : msg); } } this._value = { ...this._value, state: 'idle' }; await this.verify(); + for (const [id, error] of failures) + this.#patchRow(id, { state: 'error', error }); + await Updater.verify(); } async #install(m: ModEntry) { @@ -265,18 +243,25 @@ class ModsClass extends Observable { if (m.source.kind === 'managed') return; 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 missing: string[] = []; if (m.source.kind === 'directFile') { const dest = path.join(clientDir, m.source.assetName); await this.#downloadTo(m.source.url, dest); written.push(m.source.assetName); } else if (m.source.kind === 'archive') { + const scratch = path.join(clientDir, '.octolauncher-tmp'); + await fs.ensureDir(scratch); const tmp = path.join( - os.tmpdir(), - `octolauncher-${m.id}-${Date.now()}.${m.source.format}` + scratch, + `${m.id}-${Date.now()}.${m.source.format}` ); await this.#downloadTo(m.source.url, tmp); this.#patchRow(m.id, { state: 'installing' }); @@ -288,7 +273,7 @@ class ModsClass extends Observable { for (const [src, dst] of Object.entries(map)) { const entry = entries.find(e => e.entryName === src); if (!entry) { - Logger.warn(`Mod ${m.id}: zip entry ${src} not found.`); + missing.push(src); continue; } const target = path.join(clientDir, dst); @@ -297,16 +282,13 @@ class ModsClass extends Observable { written.push(dst); } } else { - const stagingDir = path.join( - os.tmpdir(), - `octolauncher-${m.id}-${Date.now()}-extract` - ); + const stagingDir = path.join(scratch, `${m.id}-${Date.now()}-extract`); await fs.ensureDir(stagingDir); await tar.x({ file: tmp, cwd: stagingDir }); for (const [src, dst] of Object.entries(map)) { const srcPath = path.join(stagingDir, src); if (!(await fs.pathExists(srcPath))) { - Logger.warn(`Mod ${m.id}: tar entry ${src} not found.`); + missing.push(src); continue; } const target = path.join(clientDir, dst); @@ -319,6 +301,11 @@ class ModsClass extends Observable { await fs.remove(tmp).catch(() => {}); } + if (missing.length) + throw new Error( + `${m.name}: download is missing expected file(s): ${missing.join(', ')}` + ); + if (m.registerInDllsTxt) { await addDll(clientDir, m.registerInDllsTxt); } @@ -371,12 +358,18 @@ class ModsClass extends Observable { async #downloadTo(url: string, dest: string) { 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}`); await fs.ensureDir(path.dirname(dest)); const buf = await res.arrayBuffer(); 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) { diff --git a/src/main/modules/observable.ts b/src/main/modules/observable.ts index 13660ad..6b11dde 100644 --- a/src/main/modules/observable.ts +++ b/src/main/modules/observable.ts @@ -12,7 +12,8 @@ abstract class Observable { try { l(v); return true; - } catch { + } catch (err) { + console.error('Observer threw, removing listener', err); return false; } }); diff --git a/src/main/modules/patcher.ts b/src/main/modules/patcher.ts index f2deda0..da69efd 100644 --- a/src/main/modules/patcher.ts +++ b/src/main/modules/patcher.ts @@ -22,17 +22,24 @@ const Servers = { } } as const; -type Tweak = { key: keyof PreferencesSchema['config']; default?: unknown; forced?: boolean } & ( - | { - type: 'bytes'; - tweaks: [number, number[]][]; - } - | { - type: 'int8' | 'uint16' | 'float'; - offset: number; - value?: number; - } -); +type TweakKey = + | { synthetic?: false; key: keyof PreferencesSchema['config'] } + | { synthetic: true; key: string }; + +type Tweak = TweakKey & { + default?: unknown; + forced?: boolean; +} & ( + | { + type: 'bytes'; + tweaks: [number, number[]][]; + } + | { + type: 'int8' | 'uint16' | 'float'; + offset: number; + value?: number; + } + ); export const patchExecutable = async () => { Logger.log('Patching WoW.exe...'); @@ -81,7 +88,8 @@ export const patchExecutable = async () => { { key: 'nameplateRange', type: 'float', offset: 0x40c448 }, { key: 'cameraDistance', type: 'float', offset: 0x4089a4 }, { - key: 'crossFactionResurrect' as never, + synthetic: true, + key: 'crossFactionResurrect', type: 'bytes', default: true, tweaks: [ @@ -89,8 +97,10 @@ export const patchExecutable = async () => { [0x006e62a8, [0x006e62a9]] ] }, + // version-pinned in-place patch of the WoW.exe routine at this offset { - key: 'skillUiGateHijack' as never, + synthetic: true, + key: 'skillUiGateHijack', type: 'bytes', default: true, forced: true, @@ -98,47 +108,41 @@ export const patchExecutable = async () => { [ 0x002ddf90, [ - 0x55, 0x8b, 0xec, 0x83, 0xec, 0x08, 0x53, 0x56, - 0x57, 0x8b, 0x3d, 0x60, 0xab, 0xce, 0x00, 0x83, - 0xff, 0xff, 0x89, 0x55, 0xfc, 0x89, 0x4d, 0xf8, - 0x74, 0x79, 0x8b, 0x75, 0x08, 0x8b, 0x15, 0x58, - 0xab, 0xce, 0x00, 0x8b, 0xc7, 0x23, 0xc6, 0x8d, - 0x04, 0x40, 0x8b, 0x4c, 0x82, 0x08, 0xf6, 0xc1, - 0x01, 0x8d, 0x44, 0x82, 0x04, 0x75, 0x04, 0x85, - 0xc9, 0x75, 0x05, 0x33, 0xc9, 0x8d, 0x49, 0x00, - 0xf6, 0xc1, 0x01, 0x75, 0x4e, 0x85, 0xc9, 0x74, - 0x4a, 0x39, 0x31, 0x74, 0x13, 0x8b, 0xc7, 0x23, - 0xc6, 0x8d, 0x04, 0x40, 0x8d, 0x04, 0x82, 0x8b, - 0x00, 0x03, 0xc1, 0x8b, 0x48, 0x04, 0xeb, 0xe0, - 0x8b, 0x59, 0x1c, 0x8b, 0x71, 0x18, 0x33, 0xff, - 0x85, 0xdb, 0x7e, 0x27, 0x8d, 0x64, 0x24, 0x00, - 0x8b, 0x4e, 0x0c, 0x8b, 0x56, 0x08, 0x6a, 0x00, - 0x6a, 0x00, 0x51, 0x8b, 0x4d, 0xf8, 0x52, 0x8b, - 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 + 0x55, 0x8b, 0xec, 0x83, 0xec, 0x08, 0x53, 0x56, 0x57, 0x8b, 0x3d, + 0x60, 0xab, 0xce, 0x00, 0x83, 0xff, 0xff, 0x89, 0x55, 0xfc, 0x89, + 0x4d, 0xf8, 0x74, 0x79, 0x8b, 0x75, 0x08, 0x8b, 0x15, 0x58, 0xab, + 0xce, 0x00, 0x8b, 0xc7, 0x23, 0xc6, 0x8d, 0x04, 0x40, 0x8b, 0x4c, + 0x82, 0x08, 0xf6, 0xc1, 0x01, 0x8d, 0x44, 0x82, 0x04, 0x75, 0x04, + 0x85, 0xc9, 0x75, 0x05, 0x33, 0xc9, 0x8d, 0x49, 0x00, 0xf6, 0xc1, + 0x01, 0x75, 0x4e, 0x85, 0xc9, 0x74, 0x4a, 0x39, 0x31, 0x74, 0x13, + 0x8b, 0xc7, 0x23, 0xc6, 0x8d, 0x04, 0x40, 0x8d, 0x04, 0x82, 0x8b, + 0x00, 0x03, 0xc1, 0x8b, 0x48, 0x04, 0xeb, 0xe0, 0x8b, 0x59, 0x1c, + 0x8b, 0x71, 0x18, 0x33, 0xff, 0x85, 0xdb, 0x7e, 0x27, 0x8d, 0x64, + 0x24, 0x00, 0x8b, 0x4e, 0x0c, 0x8b, 0x56, 0x08, 0x6a, 0x00, 0x6a, + 0x00, 0x51, 0x8b, 0x4d, 0xf8, 0x52, 0x8b, 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 ] ] ] } ] satisfies Tweak[]; - // Apply patches Tweaks.forEach(t => { - const val = - config[t.key] ?? t.default ?? ConfigWtfSchema.parse({})[t.key]; + const val = t.synthetic + ? t.default + : config[t.key] ?? t.default ?? ConfigWtfSchema.parse({})[t.key]; Logger.log(`Applying "${t.key}" patch with value: ${val}`); 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') { - buffer.writeInt8(t.value ?? (val as never), t.offset); + buffer.writeInt8(t.value ?? (val as number), t.offset); } else if (t.type === 'uint16') { 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') { if (!t.forced && !val) return; t.tweaks.forEach(([offset, bytes]) => @@ -151,11 +155,12 @@ export const patchExecutable = async () => { Logger.log('WoW.exe successfully patched'); } catch (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 () => { - const { clientDir, server, config } = Preferences.data; +export const patchConfig = async (forceTweaks = false) => { + const { clientDir, server, config, locale } = Preferences.data; if (!clientDir) return; const configPath = path.join(clientDir, 'WTF', 'Config.wtf'); @@ -163,14 +168,13 @@ export const patchConfig = async () => { const raw = (await fs.pathExists(configPath)) ? await fs.readFile(configPath, { encoding: 'utf-8' }) : ''; - if (raw) await fs.remove(configPath); const configWtf = Object.fromEntries( raw - .split('\n') + .split(/\r?\n/) .map(l => { - const [_, k, v] = l.match(/SET (\w+) "(.+)"/) ?? []; - return !k || !v ? undefined : [k, v]; + const [, k, v] = l.match(/SET (\w+) "(.*)"/) ?? []; + return !k || v === undefined ? undefined : [k, v]; }) .filter(isNotUndef) ); @@ -210,20 +214,24 @@ export const patchConfig = async () => { gxMaximize: configWtf['gxMaximize'] ?? 1, gxCursor: configWtf['gxCursor'] ?? 1, checkAddonVersion: configWtf['checkAddonVersion'] ?? 0, + farClip: configWtf['farClip'] ?? config.farClip, + CameraDistanceMax: configWtf['CameraDistanceMax'] ?? config.cameraDistance, ...configWtf, - CameraDistanceMax: config.cameraDistance, - farClip: config.farClip, + locale, realmList: Servers[server].realmList, hwDetect: 0, - M2UseShaders: 1 + M2UseShaders: 1, + ...(forceTweaks + ? { farClip: config.farClip, CameraDistanceMax: config.cameraDistance } + : {}) }; - await fs.writeFile( - configPath, - Object.entries(parsed) - .filter(v => v[1] !== undefined && v[1] !== null) - .map(l => `SET ${l[0]} "${l[1]}"`) - .join('\n') - ); + const body = Object.entries(parsed) + .filter(v => v[1] !== undefined && v[1] !== null) + .map(l => `SET ${l[0]} "${l[1]}"`) + .join('\n'); + const tmpPath = `${configPath}.tmp`; + await fs.writeFile(tmpPath, body); + await fs.move(tmpPath, configPath, { overwrite: true }); Logger.log('Config.wtf successfully patched'); }; diff --git a/src/main/modules/preferences.ts b/src/main/modules/preferences.ts index a7c1fbb..52dd534 100644 --- a/src/main/modules/preferences.ts +++ b/src/main/modules/preferences.ts @@ -3,6 +3,7 @@ import path from 'path'; import fs from 'fs-extra'; import { type z } from 'zod'; import { app } from 'electron'; +import Logger from 'electron-log/main'; import { PreferencesSchema } from '~common/schemas'; import { omit } from '~common/utils'; @@ -11,6 +12,7 @@ const portableDir = process.env.PORTABLE_EXECUTABLE_DIR; abstract class Preferences { static #data: z.infer; + static #writeChain: Promise = Promise.resolve(); static readonly userDataDir = process.env.PORTABLE_EXECUTABLE_DIR ? path.join(process.env.PORTABLE_EXECUTABLE_DIR, '.launcher') @@ -18,21 +20,44 @@ abstract class Preferences { static async load() { await fs.ensureDir(this.userDataDir); + const settingsPath = path.join(this.userDataDir, 'settings.json'); - const userDataPath = path.join(this.userDataDir, 'settings.json'); + let json: Record; try { - const json = await fs.readJSON(userDataPath); - return PreferencesSchema.parse({ - ...json, - isPortable: !!portableDir, - clientDir: portableDir ?? json.clientDir - }); - } catch (e) { + json = await fs.readJSON(settingsPath); + } catch { return PreferencesSchema.parse({ isPortable: !!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 = { + 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)[key]; + if (shape[key].safeParse(value).success) salvaged[key] = value; + } + return PreferencesSchema.parse(salvaged); } static get data(): PreferencesSchema { @@ -41,14 +66,38 @@ abstract class Preferences { static set data(newData: Partial>) { this.#data = { ...this.#data, ...newData }; - fs.writeJSON( - path.join(this.userDataDir, 'settings.json'), - omit( - this.#data, - portableDir ? ['isPortable', 'clientDir'] : ['isPortable'] - ), - { spaces: 2 } + + const settingsPath = path.join(this.userDataDir, 'settings.json'); + const delta = omit( + newData, + portableDir ? ['isPortable', 'clientDir'] : ['isPortable'] ); + 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) + : 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) { diff --git a/src/main/modules/selfUpdater.ts b/src/main/modules/selfUpdater.ts index df267ca..d715a2c 100644 --- a/src/main/modules/selfUpdater.ts +++ b/src/main/modules/selfUpdater.ts @@ -10,7 +10,12 @@ export type SelfUpdaterStatus = | { state: 'checking'; currentVersion: string } | { state: 'unavailable'; currentVersion: 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: 'error'; currentVersion: string; message: string }; @@ -37,7 +42,7 @@ class SelfUpdaterClass extends Observable { this.#initialized = true; if (is.dev) { - Logger.info('[selfUpdater] dev mode — skipping'); + Logger.info('[selfUpdater] dev mode, skipping'); return; } @@ -83,7 +88,7 @@ class SelfUpdaterClass extends Observable { }); autoUpdater.on('update-downloaded', info => { Logger.info( - `[selfUpdater] downloaded ${info.version} — awaiting user click` + `[selfUpdater] downloaded ${info.version}, awaiting user click` ); this.status = { state: 'ready', @@ -100,11 +105,13 @@ class SelfUpdaterClass extends Observable { triggerInstall() { if (this._value.state !== 'ready') { Logger.warn( - `[selfUpdater] triggerInstall called in state ${this._value.state} — ignoring` + `[selfUpdater] triggerInstall called in state ${this._value.state}, ignoring` ); return; } - Logger.info('[selfUpdater] user clicked install — quitting + running installer'); + Logger.info( + '[selfUpdater] user clicked install, quitting + running installer' + ); autoUpdater.quitAndInstall(false, true); } } diff --git a/src/main/modules/updater.ts b/src/main/modules/updater.ts index b650fcd..80145ac 100644 --- a/src/main/modules/updater.ts +++ b/src/main/modules/updater.ts @@ -50,6 +50,7 @@ const getAvailableDiskSpace = async (probePath?: string): Promise => { os.homedir() || (os.platform() === 'win32' ? 'C:\\' : '/'); try { + // @ts-expect-error statfs exists at runtime (Node 18) but not @types/node 16 const s = await fs.promises.statfs(target); return Number(s.bsize) * Number(s.bavail); } catch (e) { @@ -65,11 +66,23 @@ const isReadOnly = async (filePath: string) => { try { const { mode } = await fs.stat(filePath); return !(mode & fs.constants.S_IWUSR); - } catch (e) { + } catch { 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 FileTags = 'vanillaFixes'; type FileManifest = { name: string } & ( @@ -141,18 +154,54 @@ export const isGameRunning = (executablePath: string) => }) : 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 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 () => { try { 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(); + 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); - return j.root as FileManifest; + return root; } catch (e) { Logger.error('Failed to reach update server', e); return null; @@ -160,22 +209,26 @@ const fetchManifest = async () => { }; 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 ( filePath: string, onChunk?: (deltaBytes: number) => void ) => { 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 (!onChunk || !response.body) return await response.arrayBuffer(); const chunks: Buffer[] = []; 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); onChunk(buf.byteLength); } @@ -203,7 +256,8 @@ export const downloadFileToDisk = async ( else if (stats.size >= expectedSize) { await fs.truncate(partPath, 0); } - } catch { + } catch (e) { + Logger.warn(`Failed to resume from "${partPath}".`, e); } if (resumeFrom > 0) onChunk(resumeFrom); @@ -212,20 +266,25 @@ export const downloadFileToDisk = async ( const headers: Record = {}; if (resumeFrom > 0) headers.Range = `bytes=${resumeFrom}-`; + const controller = new AbortController(); let response; try { - response = await fetch(url, { headers }); + response = await fetch(url, { + headers, + signal: controller.signal, + timeout: CONNECT_TIMEOUT_MS + }); } catch (e) { Logger.error(`Network error downloading ${filePath}`, e); throw Error(`Failed to download ${path.normalize(filePath)}`); } 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) { onChunk(-resumeFrom); await fs.truncate(partPath, 0); @@ -236,6 +295,7 @@ export const downloadFileToDisk = async ( flags: resumeFrom > 0 ? 'a' : 'w' }); + let stalled = false; try { await new Promise((resolve, reject) => { if (!response.body) { @@ -243,26 +303,53 @@ export const downloadFileToDisk = async ( return; } 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) => { + armStall(); const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); if (!writeStream.write(buf)) body.pause(); onChunk(buf.byteLength); }); writeStream.on('drain', () => body.resume()); - body.on('end', () => writeStream.end(resolve)); - body.on('error', reject); - writeStream.on('error', reject); + body.on('end', () => { + clearTimeout(stallTimer); + writeStream.end(resolve); + }); + body.on('error', err => { + clearTimeout(stallTimer); + reject(err); + }); + writeStream.on('error', err => { + clearTimeout(stallTimer); + reject(err); + }); }); } catch (e) { writeStream.destroy(); 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); if (finalStats.size !== expectedSize) { 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.` ); } @@ -465,6 +552,10 @@ class UpdaterClass extends Observable { try { const vanillaFixes = Preferences.data.config.vanillaFixes; + const modOwnedFiles = new Set(); + 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(); if (!hashTree) { @@ -582,11 +673,10 @@ class UpdaterClass extends Observable { SFileCloseArchive(hMpq); } } catch (e) { - Logger.log( + Logger.warn( `Failed to verify ${path.join( ...patchPath )}, will be downloaded fresh`, - 'warning', e ); return { @@ -598,13 +688,13 @@ class UpdaterClass extends Observable { } } - if (item.tags?.includes('vanillaFixes') && !vanillaFixes) { - if (await fs.exists(path.join(clientPath, ...filePath))) { - return { - type: 'del', - name: item.name - }; - } else { + if (item.tags?.includes('vanillaFixes')) { + if (modOwnedFiles.has(filePath.join('/').toLowerCase())) + return undefined; + + if (!vanillaFixes) { + if (await fs.exists(path.join(clientPath, ...filePath))) + return { type: 'del', name: item.name }; return undefined; } } @@ -678,8 +768,7 @@ class UpdaterClass extends Observable { const currentLauncherVersion = app.getVersion(); if ( this.status.state === 'upToDate' && - Preferences.data.lastPatchedLauncherVersion !== - currentLauncherVersion + Preferences.data.lastPatchedLauncherVersion !== currentLauncherVersion ) { Logger.log( `Launcher version changed (${ @@ -710,8 +799,7 @@ class UpdaterClass extends Observable { })(); } } catch (e) { - const message = - e instanceof Error ? e.message : 'Unexpected error occurred'; + const message = friendlyError(e); Logger.error(`Verification failed: ${message}`, e); this.status = { state: 'failed', message }; } @@ -744,6 +832,22 @@ class UpdaterClass extends Observable { try { if (clean) { + // never wipe a drive root or a non-client dir + 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 = { state: 'updating', progress: -1, @@ -804,12 +908,10 @@ class UpdaterClass extends Observable { if (!item) return undefined; if (item.type === 'del') { - throw Error( - `TODO: Deleting of files from MPQ not implemented at path ${path.join( - ...mpqPath, - ...filePath - )}` - ); + if (SFileHasFile(hMpq, path.join(...filePath))) + SFileRemoveFile(hMpq, path.join(...filePath)); + nestedSet(this.#cache, [...mpqPath, ...filePath], undefined); + return; } if (item.type === 'dir') { @@ -826,7 +928,9 @@ class UpdaterClass extends Observable { )}` ); - const label = `Patching: [${mpqPath.at(-1)}] "${path.join(...filePath)}"`; + const label = `Patching: [${mpqPath.at(-1)}] "${path.join( + ...filePath + )}"`; emitProgress(label, true); const data = await fetchFile( @@ -853,6 +957,7 @@ class UpdaterClass extends Observable { } finally { SFileFinishFile(hFile); } + nestedSet(this.#cache, [...mpqPath, ...filePath], undefined); }; const iterateTree = async (...filePath: string[]) => { @@ -922,7 +1027,7 @@ class UpdaterClass extends Observable { if (item.name === 'WoW.exe') executableUpdate = true; 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.`); await downloadFileToDisk( @@ -947,7 +1052,6 @@ class UpdaterClass extends Observable { if (executableUpdate || launcherVersionChanged) { await patchExecutable(); - await this.#getHash({ clientPath }, 'WoW.exe'); const patchedWowHash = await this.#getHash({ clientPath }, 'WoW.exe'); await this.#saveCache(); Preferences.data = { @@ -960,13 +1064,21 @@ class UpdaterClass extends Observable { this.#bytesAlreadyOnDisk = fullClientTotal; this.status = { state: 'upToDate', progress: 1 }; } catch (e) { - console.error(e); - this.status = { - state: 'failed', - message: e instanceof Error ? e.message : 'Unexpected error occurred' - }; + Logger.error('Update failed', e); + this.status = { state: 'failed', message: friendlyError(e) }; } } + + 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(); diff --git a/src/main/utils.ts b/src/main/utils.ts index 096e538..ccd46fa 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -6,8 +6,15 @@ import fs from 'fs-extra'; import Preferences from './modules/preferences'; -const isCallbackResponse = (data: any): data is { cb: string; args: any[] } => - data && typeof data === 'object' && 'cb' in data && 'args' in data; +const isCallbackResponse = ( + 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 = ( worker: (o: WorkerOptions) => Worker, @@ -16,10 +23,16 @@ export const runWorker = ( ) => new Promise((resolve, reject) => worker({ workerData }) - .on('message', m => - isCallbackResponse(m) ? callbacks?.[m.cb](...m.args) : resolve(m) - ) + .on('message', (m: unknown) => { + 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('exit', code => + reject(new Error(`Worker exited (code ${code}) without finishing`)) + ) ); export const getClientVersion = async () => { @@ -35,8 +48,22 @@ export const getClientVersion = async () => { const file = await fs.readFile(exePath); const buffer = Buffer.from(file); - const version = buffer.toString('utf-8', 0x00437c04, 0x00437c04 + 6); - const build = buffer.toString('utf-8', 0x00437bfc, 0x00437bfc + 4); + // Fixed addresses in the 1.12.1 client binary. + const VERSION_OFFSET = 0x00437c04; + 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})`); return `${version} (${build})`; diff --git a/src/main/workers/gitClone.ts b/src/main/workers/gitClone.ts index 1b5fe19..31593e8 100644 --- a/src/main/workers/gitClone.ts +++ b/src/main/workers/gitClone.ts @@ -9,15 +9,27 @@ if (!port) throw new Error('IllegalState'); const { dir, url, ref } = workerData; -fs.removeSync(dir); -git - .clone({ - dir, +const tmpDir = `${dir}.tmp`; + +const run = async () => { + await fs.remove(tmpDir); + await git.clone({ + dir: tmpDir, fs, http, url, ref, singleBranch: !ref || ref === 'master' || ref === 'main', 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; + }); diff --git a/src/main/workers/gitPull.ts b/src/main/workers/gitPull.ts index 488dd7e..431f1fa 100644 --- a/src/main/workers/gitPull.ts +++ b/src/main/workers/gitPull.ts @@ -5,7 +5,7 @@ import http from 'isomorphic-git/http/node'; import fs from 'fs-extra'; 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 { dir: string; @@ -17,20 +17,17 @@ const { dir, remote, branch, ref } = workerData as { const onProgress = (...args: unknown[]) => 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 () => { 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 removeUntrackedFiles(); return; } @@ -41,7 +38,6 @@ const run = async () => { ref: `${remote}/${branch}`, onProgress }); - await removeUntrackedFiles(); await git.pull({ fs, http, @@ -53,4 +49,8 @@ const run = async () => { }); }; -run().then(() => port.postMessage(true)); +run() + .then(() => port.postMessage(true)) + .catch(err => { + throw err; + }); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0c6df0b..6244300 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { api } from './utils/api'; import PageBackground from './assets/background.png'; +import AntivirusModal from './components/AntivirusModal'; import Header from './components/Header'; import LaunchPanel from './components/LaunchPanel'; import SelfUpdateBanner from './components/SelfUpdateBanner'; @@ -38,12 +39,13 @@ const App = () => { )} - {/* Launcher build label, anchored bottom-right.*/} {appVersion && ( - + v{appVersion} )} + + ); }; diff --git a/src/renderer/ErrorBoundary.tsx b/src/renderer/ErrorBoundary.tsx index d1af42e..b10bec8 100644 --- a/src/renderer/ErrorBoundary.tsx +++ b/src/renderer/ErrorBoundary.tsx @@ -2,6 +2,8 @@ import { Clipboard, RefreshCw, ServerCrash } from 'lucide-react'; import { Component, type ErrorInfo, type ReactNode } from 'react'; import log from 'electron-log/renderer'; +import { useT } from '~renderer/i18n'; + import PageBackground from './assets/background.png'; import TextButton from './components/styled/TextButton'; @@ -15,6 +17,59 @@ type Props = { 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 ( +
+
+
+ +

{t('misc.somethingWentWrong')}

+
+
+
{title}
+
+					{detail}
+				
+
+
+ + navigator.clipboard.writeText( + `\`\`\`\n${title}\n${detail}\n\`\`\`` + ) + } + > + {t('misc.copyError')} + + window.location.reload()} + className="text-warmGreen" + > + {t('misc.reload')} + +
+
+
+ ); +}; + class ErrorBoundary extends Component { constructor(props: Props) { super(props); @@ -29,48 +84,7 @@ class ErrorBoundary extends Component { render() { if (!this.state.didCatch) return this.props.children; const { error, errorInfo } = this.state; - const title = `Uncaught ${error?.name}: ${ - error?.message ?? 'Unknown error' - }`; - const detail = errorInfo?.componentStack.slice(1); - return ( -
-
-
- -

Something went wrong!

-
-
-
{title}
-
-						{detail}
-					
-
-
- - navigator.clipboard.writeText( - `\`\`\`\n${title}\n${detail}\n\`\`\`` - ) - } - > - Copy error - - window.location.reload()} - className="text-warmGreen" - > - Reload - -
-
-
- ); + return ; } } diff --git a/src/renderer/components/AntivirusModal.tsx b/src/renderer/components/AntivirusModal.tsx new file mode 100644 index 0000000..36f2da2 --- /dev/null +++ b/src/renderer/components/AntivirusModal.tsx @@ -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(); + 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(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( + setView(null)} + className="h-full w-full items-center justify-center bg-[transparent] backdrop:backdrop-blur-sm [&[open]]:flex" + > + {view === 'av' && ( +
+

{t('av.blockedTitle')}

+

+ {names.length === 1 ? t('av.blockedOne') : t('av.blockedMany')} +

+
    + {names.map(n => ( +
  • + • {n} +
  • + ))} +
+

{t('av.blockedExplain')}

+ setView('why')} + className="self-start text-yellow hocus:!text-yellow" + > + {t('av.whyHappening')} + + addExclusion.mutate()} + className="self-start text-orange" + > + {t('av.allowThrough')} + + {addExclusion.data?.ok === true && ( + {t('av.addedRetry')} + )} + {addExclusion.data?.ok === false && ( + {addExclusion.data.error} + )} +
+ setView(null)} className="text-green"> + {t('av.close')} + +
+
+ )} + + {view === 'why' && ( +
+

{t('av.whyTitle')}

+
+

+ {t('av.whyIntro', { detection: DETECTION })}{' '} + {t('av.falsePositive')}. +

+
+

+ {t('av.whatSetsItOff')} +

+

{t('av.whatSetsItOffIntro')}

+
    +
  • + {t('av.dllInjection')}:{' '} + {t('av.dllInjectionText')} +
  • +
  • + {t('av.exePatching')}:{' '} + {t('av.exePatchingText')} +
  • +
+

{t('av.heuristicNote')}

+
+
+

+ {t('av.whatModsDo')} +

+
    +
  • + VanillaFixes:{' '} + {t('av.modVanillaFixes')} +
  • +
  • + nampower:{' '} + {t('av.modNampower')} +
  • +
  • + SuperWoW and UnitXP:{' '} + {t('av.modSuperWowUnitXp')} +
  • +
  • + DXVK:{' '} + {t('av.modDxvk')} +
  • +
+
+
+

+ {t('av.howToVerify')} +

+

+ {t('av.howToVerifyText', { + detection: DETECTION + })} +

+
+
+

+ {t('av.whatAllowDoesTitle')} +

+

+ {t('av.whatAllowDoesIntro')}{' '} + Add-MpPreference{' '} + {t('av.whatAllowDoesIntroAfter')} +

+
    +
  • + {t('av.exclusionFoldersBefore')}{' '} + {t('av.gameFolder')}{' '} + {t('av.exclusionFoldersAnd')}{' '} + + {t('av.launcherFolder')} + + {t('av.exclusionFoldersAfter')} +
  • +
  • + WoW.exe{' '} + {t('av.exclusionExesAnd')}{' '} + VanillaFixes.exe{' '} + {t('av.exclusionExesAfter')} +
  • +
+

+ {t('av.whatAllowDoesOutro')} +

+
+
+
+ setView('av')} className="text-blueGray"> + {t('av.back')} + + setView(null)} className="text-green"> + {t('av.close')} + +
+
+ )} +
, + document.body + ); +}; + +export default AntivirusModal; diff --git a/src/renderer/components/ClientDirDialog.tsx b/src/renderer/components/ClientDirDialog.tsx index 4b3ebf7..b0173d4 100644 --- a/src/renderer/components/ClientDirDialog.tsx +++ b/src/renderer/components/ClientDirDialog.tsx @@ -4,6 +4,7 @@ import { useEffect } from 'react'; import { PreferencesSchema } from '~common/schemas'; import zodResolver from '~renderer/utils/zodResolver'; import { api } from '~renderer/utils/api'; +import { useT } from '~renderer/i18n'; import TextButton from './styled/TextButton'; import FilePickerInput from './form/FilePickerInput'; @@ -12,6 +13,7 @@ import CloseButton from './styled/CloseButton'; type Props = { close: () => void }; const ClientDirDialog = ({ close }: Props) => { + const t = useT(); const { data: pref } = api.preferences.get.useQuery(); const setPref = api.preferences.set.useMutation(); const isValidClientDir = api.preferences.isValidClientDir.useQuery( @@ -42,16 +44,14 @@ const ClientDirDialog = ({ close }: Props) => { return (
-

Install location

-

- You are using the portable version of the launcher. Install location - is determined by the location of the launcher executable. -

+

+ {t('prefs.installLocationTitle')} +

+

{t('prefs.portableInfo')}

{!isValidClientDir.isLoading && !isValidClientDir.data && (

- Error: - WoW.exe not found in current folder. Please close the launcher and - move it to your WoW 1.12 client directory. + {t('prefs.errorLabel')} + {t('prefs.wowExeNotFound', { exe: 'WoW.exe' })}

)} @@ -64,7 +64,7 @@ const ClientDirDialog = ({ close }: Props) => { onSubmit={handleSubmit(async ({ clientDir }) => { try { await setPref.mutateAsync({ clientDir }); - verify.mutateAsync(); + verify.mutate(); close(); } catch (e) { setError('clientDir', { @@ -79,18 +79,13 @@ const ClientDirDialog = ({ close }: Props) => { close(); }} /> -

Install location

+

{t('prefs.installLocationTitle')}


-

- Select a directory for the game client installation. -

-

- You may also choose a directory with an existing Turtle WoW or Vanilla - WoW installation, and it will be automatically upgraded. -

+

{t('prefs.selectDirectory')}

+

{t('prefs.upgradeExisting')}

- + { loading={formState.isSubmitting} className="self-end text-green" > - Confirm + {t('prefs.confirm')} ); diff --git a/src/renderer/components/Header.tsx b/src/renderer/components/Header.tsx index d077551..32438a2 100644 --- a/src/renderer/components/Header.tsx +++ b/src/renderer/components/Header.tsx @@ -1,5 +1,7 @@ import OctoLogo from '~renderer/assets/logo.png'; +import { useT } from '~renderer/i18n'; + import TextButton from './styled/TextButton'; import { TabNames, type TabType } from './TabsPanel'; @@ -8,25 +10,28 @@ type Props = { setActiveTab: (tab?: TabType) => void; }; -const Header = ({ activeTab, setActiveTab }: Props) => ( -
- - {TabNames.map(t => ( - setActiveTab(t)} - active={activeTab === t} - className="uppercase" +const Header = ({ activeTab, setActiveTab }: Props) => { + const t = useT(); + return ( +
+
-); + OctoWoW + + {TabNames.map(tab => ( + setActiveTab(tab)} + active={activeTab === tab} + className="uppercase" + > + {t(`tab.${tab}`)} + + ))} +
+ ); +}; export default Header; diff --git a/src/renderer/components/LanguageDropdown.tsx b/src/renderer/components/LanguageDropdown.tsx new file mode 100644 index 0000000..08bb294 --- /dev/null +++ b/src/renderer/components/LanguageDropdown.tsx @@ -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(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 ( + <> + + {open && + pos && + createPortal( +
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 => ( + + ))} +
, + document.body + )} + + ); +}; + +export default LanguageDropdown; diff --git a/src/renderer/components/LaunchPanel.tsx b/src/renderer/components/LaunchPanel.tsx index 260afdd..c137abd 100644 --- a/src/renderer/components/LaunchPanel.tsx +++ b/src/renderer/components/LaunchPanel.tsx @@ -1,9 +1,11 @@ import { useState, type ReactElement } from 'react'; import cls from 'classnames'; +import log from 'electron-log/renderer'; import { type UpdaterStatus, type ModsStatus } from '~main/types'; import { formatFileSize } from '~common/utils'; import { api } from '~renderer/utils/api'; +import { useT } from '~renderer/i18n'; import Button from './styled/Button'; import DialogButton from './styled/DialogButton'; @@ -20,40 +22,43 @@ const formatDuration = (seconds: number) => { return minRem ? `${h}h ${minRem}m` : `${h}h`; }; +const formatPercent = (progress: number) => `${(progress * 100).toFixed(1)}%`; + 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; - const pct = progress !== undefined && progress >= 0 - ? `${(progress * 100).toFixed(1)}%` - : '—'; + const pct = + progress !== undefined && progress >= 0 ? formatPercent(progress) : '—'; return (

{pct} - · {formatFileSize(bytesDone)} / {formatFileSize(bytesTotal)} + + {' '} + · {formatFileSize(bytesDone)} / {formatFileSize(bytesTotal)} + {bytesPerSecond !== undefined && bytesPerSecond > 0 && ( · {formatFileSize(bytesPerSecond)}/s )} {' · '} {etaSeconds !== undefined - ? `~${formatDuration(etaSeconds)} remaining` - : 'calculating…'} + ? `~${formatDuration(etaSeconds)} ${t('launch.remaining')}` + : t('launch.calculating')}

); }; const LaunchPanel = () => { + const t = useT(); const [status, setStatus] = useState({ state: 'verifying' }); api.updater.observe.useSubscription(undefined, { - onData: data => { - console.log({ data }); - setStatus(data); - }, - onError: err => console.log({ err }), - onStarted: () => console.log('Started') + onData: setStatus, + onError: err => log.error('Updater subscription error:', err) }); const { data: pref } = api.preferences.get.useQuery(); @@ -72,23 +77,25 @@ const LaunchPanel = () => { UpdaterStatus['state'], { button: ReactElement; helperText?: ReactElement } > = { - verifying: { button: }, + verifying: { button: }, serverUnreachable: { button: pref?.version ? ( - + ) : ( - + ), helperText: (

- Error: Failed to reach update - server + {t('launch.errorLabel')}{' '} + {t('launch.serverFail')}

{pref?.version - ? `You can launch local version ${pref?.version}` - : 'Please try again later'} + ? t('launch.localVersion', { version: pref.version }) + : t('launch.tryLater')}

) @@ -101,39 +108,44 @@ const LaunchPanel = () => { > {open => ( )} ) }, updateAvailable: { - button: , + button: ( + + ), helperText: (
-

Update available!

+

{t('launch.updateAvailable')}

{status.progress !== undefined && status.bytesDone !== undefined && status.bytesTotal !== undefined && ( <> - {(status.progress * 100).toFixed(1)}% + {formatPercent(status.progress)} {' '} · {formatFileSize(status.bytesDone)} /{' '} - {formatFileSize(status.bytesTotal)} on disk ·{' '} + {formatFileSize(status.bytesTotal)} {t('launch.onDisk')} ·{' '} )} - {status.message} remaining + {status.message}{' '} + {t('launch.remaining')}

) }, updating: { - button: , + button: , helperText: (
{status.message && ( @@ -150,35 +162,41 @@ const LaunchPanel = () => { onClick={() => applyMods.mutateAsync()} disabled={applyMods.isLoading || modsStatus?.state === 'busy'} > - {modsStatus?.state === 'busy' ? 'Applying' : 'Update'} + {modsStatus?.state === 'busy' + ? t('launch.applying') + : t('launch.update')} ) : ( ), helperText: (
{modsStatus?.dirty ? ( -

Mods changed — apply before playing

+

{t('launch.modsChanged')}

) : ( -

Everything up to date!

+

{t('launch.upToDate')}

)} -

Version: {pref?.version}

+

+ {t('launch.version', { version: pref?.version ?? '' })} +

) }, failed: { - button: , + button: ( + + ), helperText: (

- Error: + {t('launch.errorLabel')}{' '} {status.message}

-

- Verify your game data by clicking Retry. -

+

{t('launch.verifyHint')}

) } @@ -191,6 +209,9 @@ const LaunchPanel = () => { (status.message && (

{status.message}

))} + {start.data && !start.data.ok && start.data.error && ( +

{start.data.error}

+ )}
{status.progress !== undefined && (
{ + const t = useT(); const [state, setState] = useState('verifying'); api.updater.observe.useSubscription(undefined, { onData: ({ state }) => setState(state) }); if (state === 'serverUnreachable') - return offline; + return {t('prefs.mirrorOffline')}; if (state === 'verifying' || state === 'updating') - return checking…; - return online; + return ( + {t('prefs.mirrorChecking')} + ); + return {t('prefs.mirrorOnline')}; }; type Props = { close: () => void }; const PreferencesDialog = ({ close }: Props) => { + const t = useT(); const { data: pref } = api.preferences.get.useQuery(); const setPref = api.preferences.set.useMutation(); const verify = api.updater.verify.useMutation(); const openInstallFolder = api.general.openInstallFolder.useMutation(); const openLogFile = api.general.openLogFile.useMutation(); + const addExclusion = api.general.addDefenderExclusion.useMutation(); const { handleSubmit, watch, setValue, reset } = useForm({ defaultValues: pref ?? {}, @@ -59,9 +66,12 @@ const PreferencesDialog = ({ close }: Props) => { return (
{ - await setPref.mutateAsync(v); + await setPref.mutateAsync({ + cleanWdb: v.cleanWdb, + minimizeToTrayOnPlay: v.minimizeToTrayOnPlay + }); close(); })} > @@ -71,26 +81,26 @@ const PreferencesDialog = ({ close }: Props) => { close(); }} /> -

SETTINGS

+

{t('prefs.title')}


-

INSTALL LOCATION:

+

{t('prefs.installLocation')}

openInstallFolder.mutateAsync()} className="!p-1 text-blueGray" > - Open folder + {t('prefs.openFolder')}
- {pref?.clientDir ?? 'Not selected'} + {pref?.clientDir ?? t('prefs.notSelected')} ( @@ -104,15 +114,20 @@ const PreferencesDialog = ({ close }: Props) => { clickAway={pref?.isPortable} > {open => ( - - Change + + {t('prefs.change')} )}
-

DOWNLOAD MIRROR:

+

{t('prefs.downloadMirror')}

@@ -122,47 +137,63 @@ const PreferencesDialog = ({ close }: Props) => { icon={RefreshCw} size={12} onClick={() => verify.mutateAsync()} - title="Re-check" + title={t('prefs.recheck')} className="!p-0 text-blueGray" />
-
-

TROUBLESHOOTING:

+
+

{t('prefs.troubleshooting')}

verify.mutateAsync().then(close)} - className="text-warmGreen" + className="!items-start text-left text-warmGreen" > - Verify game files + {t('prefs.verifyGameFiles')} openLogFile.mutateAsync()} - className="text-pink" + className="!items-start text-left text-pink" > - Open log file + {t('prefs.openLogFile')} + addExclusion.mutateAsync()} + loading={addExclusion.isLoading} + className="!items-start text-left text-orange" + > + {t('prefs.allowThroughAntivirus')} + + {addExclusion.data?.ok === true && ( + + {t('prefs.exclusionAdded')} + + )} + {addExclusion.data?.ok === false && ( + {addExclusion.data.error} + )}
-
-

GENERAL SETTINGS:

+
+

{t('prefs.generalSettings')}

- Save + {t('prefs.save')} ); diff --git a/src/renderer/components/SelfUpdateBanner.tsx b/src/renderer/components/SelfUpdateBanner.tsx index e9b8f07..768376f 100644 --- a/src/renderer/components/SelfUpdateBanner.tsx +++ b/src/renderer/components/SelfUpdateBanner.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { api } from '~renderer/utils/api'; +import { useT } from '~renderer/i18n'; import Button from './styled/Button'; @@ -19,6 +20,7 @@ type Status = | { state: 'error'; currentVersion: string; message: string }; const SelfUpdateBanner = () => { + const t = useT(); const [status, setStatus] = useState({ state: 'idle', currentVersion: '' @@ -39,16 +41,19 @@ const SelfUpdateBanner = () => { const tone = status.state === 'error' ? 'border-red/40' : 'border-tw/40'; const label = status.state === 'error' - ? `Update check failed: ${status.message}` + ? t('misc.selfUpdateCheckFailed', { message: status.message }) : status.state === 'available' - ? `Launcher update ${'nextVersion' in status ? status.nextVersion : ''} available — preparing download…` - : status.state === 'downloading' - ? `Downloading update ${status.nextVersion} · ${Math.round( - status.progress * 100 - )}%` - : status.state === 'ready' - ? `Launcher update ${status.nextVersion} ready to install` - : ''; + ? t('misc.selfUpdateAvailable', { + version: status.nextVersion + }) + : status.state === 'downloading' + ? t('misc.selfUpdateDownloading', { + version: status.nextVersion, + percent: Math.round(status.progress * 100) + }) + : status.state === 'ready' + ? t('misc.selfUpdateReady', { version: status.nextVersion }) + : ''; return (
{ onClick={() => install.mutateAsync()} disabled={install.isLoading} > - Install now + {t('misc.selfUpdateInstallNow')} )}
diff --git a/src/renderer/components/TabErrorBoundary.tsx b/src/renderer/components/TabErrorBoundary.tsx index 6ef9a20..cc2aebe 100644 --- a/src/renderer/components/TabErrorBoundary.tsx +++ b/src/renderer/components/TabErrorBoundary.tsx @@ -2,6 +2,8 @@ import { AlertTriangle, RefreshCw } from 'lucide-react'; import { Component, type ErrorInfo, type ReactNode } from 'react'; import log from 'electron-log/renderer'; +import { useT } from '~renderer/i18n'; + import TextButton from './styled/TextButton'; type Props = { @@ -14,6 +16,47 @@ type State = { componentStack?: string; }; +type FallbackProps = { + tabName: string; + error: Error; + componentStack?: string; + onReset: () => void; +}; + +const TabErrorFallback = ({ + tabName, + error, + componentStack, + onReset +}: FallbackProps) => { + const t = useT(); + return ( +
+
+ +

{t('misc.tabCrashed', { tab: tabName })}

+
+
+

+ {error.name}: {error.message} +

+ {componentStack && ( +
+					{componentStack.trim()}
+				
+ )} +
+ + {t('misc.tryAgain')} + +
+ ); +}; + class TabErrorBoundary extends Component { state: State = {}; @@ -38,29 +81,12 @@ class TabErrorBoundary extends Component { if (!this.state.error) return this.props.children; const { error, componentStack } = this.state; return ( -
-
- -

{this.props.tabName} crashed

-
-
-

- {error.name}: {error.message} -

- {componentStack && ( -
-						{componentStack.trim()}
-					
- )} -
- - Try again - -
+ ); } } diff --git a/src/renderer/components/TopBar.tsx b/src/renderer/components/TopBar.tsx index f2a658c..f1c39e5 100644 --- a/src/renderer/components/TopBar.tsx +++ b/src/renderer/components/TopBar.tsx @@ -2,12 +2,15 @@ import { Settings, Minus, X } from 'lucide-react'; import { useState } from 'react'; import { api } from '~renderer/utils/api'; +import { useT } from '~renderer/i18n'; import DialogButton from './styled/DialogButton'; import PreferencesDialog from './PreferencesDialog'; import TextButton from './styled/TextButton'; +import LanguageDropdown from './LanguageDropdown'; const TopBar = () => { + const t = useT(); const [safeToQuit, setSafeToQuit] = useState(true); api.updater.observe.useSubscription(undefined, { onData: ({ state }) => @@ -21,12 +24,16 @@ const TopBar = () => { style={{ WebkitAppRegion: 'drag' } as React.CSSProperties} className="absolute left-0 right-0 top-0 flex justify-end pr-2 pt-2 opacity-50" > -
+
+ }> {open => ( { minimize.mutateAsync()} size={16} className="!p-1" @@ -43,19 +50,16 @@ const TopBar = () => { (
-

Quit?

+

{t('quit.title')}


-

- Your game is currently being updated. Quitting now may cause - problems. -

+

{t('quit.warn')}

- Return + {t('quit.return')} quit.mutateAsync()} className="text-red" > - Quit + {t('topbar.quit')}
@@ -64,7 +68,7 @@ const TopBar = () => { {open => ( (!safeToQuit ? open() : quit.mutateAsync())} size={16} className="!p-1 hocus:text-red" diff --git a/src/renderer/components/form/CheckboxInput.tsx b/src/renderer/components/form/CheckboxInput.tsx index 5195b9e..d602bc5 100644 --- a/src/renderer/components/form/CheckboxInput.tsx +++ b/src/renderer/components/form/CheckboxInput.tsx @@ -33,12 +33,18 @@ type Props = { className?: cls.Value; }; -const CheckboxInput = ({ label, value, setValue, disabled, className }: Props) => ( +const CheckboxInput = ({ + label, + value, + setValue, + disabled, + className +}: Props) => ( !disabled && setValue(!value)} icon={Checkbox} className={cls( - 'text-blueGray', + '!items-start text-left text-blueGray', { '[&_*]:fill-none': !value, 'pointer-events-none opacity-40': disabled }, className )} diff --git a/src/renderer/components/styled/TextButton.tsx b/src/renderer/components/styled/TextButton.tsx index f25cc2f..890deed 100644 --- a/src/renderer/components/styled/TextButton.tsx +++ b/src/renderer/components/styled/TextButton.tsx @@ -53,7 +53,7 @@ const TextButton = ({ {loading ? ( ) : ( - Icon && + Icon && )} {children && ( diff --git a/src/renderer/components/tabs/AddonsTab.tsx b/src/renderer/components/tabs/AddonsTab.tsx index dc03f8d..7041afe 100644 --- a/src/renderer/components/tabs/AddonsTab.tsx +++ b/src/renderer/components/tabs/AddonsTab.tsx @@ -5,6 +5,7 @@ import { type AddonData, type AddonsStatus } from '~main/types'; import { api } from '~renderer/utils/api'; import TextButton from '~renderer/components/styled/TextButton'; import useScrollHint from '~renderer/utils/useScrollHint'; +import { useT } from '~renderer/i18n'; import DialogButton from '../styled/DialogButton'; import IconSpinner from '../styled/IconSpinner'; @@ -13,6 +14,17 @@ import AddonList from './addons/AddonList'; import { type Dependencies } from './addons/AddonListItem'; 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 seen = new Set(); const deduped = l.filter(a => { @@ -21,14 +33,14 @@ const localeFilter = (l: AddonData[], filter: string) => { return true; }); return deduped - .filter( - a => - a.folder.toLocaleLowerCase().indexOf(filter.toLocaleLowerCase()) !== -1 + .filter(a => + a.folder.toLocaleLowerCase().includes(filter.toLocaleLowerCase()) ) .sort((a, b) => a.folder.localeCompare(b.folder)); }; const AddonsTab = () => { + const t = useT(); const [data, setData] = useState({ state: 'verifying', 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" > !(a.folder in data.addons)), + data.available.filter( + a => !(a.folder in data.addons) && RECOMMENDED.has(a.folder) + ), + filter + )} + dependencies={dependencies} + /> + !(a.folder in data.addons) && !RECOMMENDED.has(a.folder) + ), filter )} dependencies={dependencies} @@ -83,7 +107,7 @@ const AddonsTab = () => { size={18} loading={data.state !== 'done'} > - Check for updates + {t('addons.checkForUpdates')} { onClick={open} className="s1 text-pink" > - Add custom git addon + {t('addons.addCustomGitAddon')} )}
@@ -107,11 +131,11 @@ const AddonsTab = () => { onClick={() => update.mutateAsync({})} className="justify-self-end text-warmGreen" > - Update all + {t('addons.updateAll')}
) : (

- Everything is up to date. + {t('addons.everythingUpToDate')}

)}
diff --git a/src/renderer/components/tabs/ComingSoonTab.tsx b/src/renderer/components/tabs/ComingSoonTab.tsx index 8099b1b..32c1853 100644 --- a/src/renderer/components/tabs/ComingSoonTab.tsx +++ b/src/renderer/components/tabs/ComingSoonTab.tsx @@ -1,7 +1,12 @@ -const ComingSoonTab = () => ( -
-

Coming soon...

-
-); +import { useT } from '~renderer/i18n'; + +const ComingSoonTab = () => { + const t = useT(); + return ( +
+

{t('misc.comingSoon')}

+
+ ); +}; export default ComingSoonTab; diff --git a/src/renderer/components/tabs/ModsTab.tsx b/src/renderer/components/tabs/ModsTab.tsx index 2acbfee..30638aa 100644 --- a/src/renderer/components/tabs/ModsTab.tsx +++ b/src/renderer/components/tabs/ModsTab.tsx @@ -1,9 +1,11 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { ExternalLink, AlertTriangle, Sparkles } from 'lucide-react'; +import { createPortal } from 'react-dom'; import cls from 'classnames'; import { api } from '~renderer/utils/api'; import useScrollHint from '~renderer/utils/useScrollHint'; +import { useT } from '~renderer/i18n'; import { type ModRowStatus, type ModsStatus } from '~main/types'; import TextButton from '../styled/TextButton'; @@ -11,9 +13,8 @@ import CheckboxInput from '../form/CheckboxInput'; import IconSpinner from '../styled/IconSpinner'; const RowState = ({ row }: { row: ModRowStatus }) => { - if (row.state === 'downloading' || row.state === 'installing') - return ; - if (row.state === 'uninstalling') + const t = useT(); + if (['downloading', 'installing', 'uninstalling'].includes(row.state)) return ; if (row.state === 'error') return ( @@ -21,12 +22,17 @@ const RowState = ({ row }: { row: ModRowStatus }) => { ); - if (row.installedVersion && row.installedVersion !== row.latestVersion && !row.ignoreUpdates) - return update; + if ( + row.installedVersion && + row.installedVersion !== row.latestVersion && + !row.ignoreUpdates + ) + return {t('mods.update')}; return null; }; const ModRow = ({ row }: { row: ModRowStatus }) => { + const t = useT(); const toggle = api.mods.toggle.useMutation(); const setIgnore = api.mods.setIgnoreUpdates.useMutation(); const openLink = api.general.openLink.useMutation(); @@ -37,7 +43,9 @@ const ModRow = ({ row }: { row: ModRowStatus }) => { {row.recommended && ( )} - {row.name} + + {row.name} + {row.latestVersion}
{ setIgnore.mutate({ id: row.id, ignore: v })} - label={Ignore updates} + label={{t('mods.ignoreUpdates')}} /> ); }; const ModsTab = () => { + const t = useT(); const [status, setStatus] = useState(); api.mods.observe.useSubscription(undefined, { onData: setStatus @@ -82,40 +91,111 @@ const ModsTab = () => { const scrollRef = useScrollHint(); + 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(null); + const [shownDepMessage, setShownDepMessage] = useState(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 (
-

CUSTOM MODS

+

{t('mods.title')}

{status?.dirty && ( - unsaved changes + {t('mods.unsavedChanges')} )}

- Enabling custom mods may not provide - any performance benefits or may even cause game crashes depending on your - system. Please try disabling them if you experience any issues. + {t('mods.warning')}

+ {missingDeps.length > 0 && ( +

+ ⚠{' '} + {t('mods.enableRequired', { + mods: missingDeps.map(modName).join(', ') + })} +

+ )}
- {status?.mods.map(row => )} + {status?.mods.map(row => ( + + ))}

- Highlighted mods are recommended. + {t('mods.highlighted')}{' '} + {t('mods.highlightedRecommended')}

apply.mutateAsync()} - className={cls(status?.dirty && 'text-green')} + onClick={onApply} + className={cls('text-green', !showApply && 'invisible')} > - Apply + {t('mods.apply')}
+ {createPortal( + setShownDepMessage(null)} + className="h-full w-full items-center justify-center bg-[transparent] backdrop:backdrop-blur-sm [&[open]]:flex" + > + {shownDepMessage && ( +
+

{t('mods.cantApplyYet')}

+

{shownDepMessage}

+ setShownDepMessage(null)} + className="self-end text-green" + > + {t('mods.close')} + +
+ )} +
, + document.body + )}
); }; diff --git a/src/renderer/components/tabs/NewsTab.tsx b/src/renderer/components/tabs/NewsTab.tsx index 6f74e87..da2685b 100644 --- a/src/renderer/components/tabs/NewsTab.tsx +++ b/src/renderer/components/tabs/NewsTab.tsx @@ -2,6 +2,7 @@ import { AlertTriangle, ExternalLink, RefreshCw } from 'lucide-react'; import { type NewsItem } from '~main/types'; import { api } from '~renderer/utils/api'; +import { useT } from '~renderer/i18n'; import useScrollHint from '~renderer/utils/useScrollHint'; import IconSpinner from '../styled/IconSpinner'; @@ -18,15 +19,20 @@ const formatDate = (raw: string) => { }; const NewsEntry = ({ item }: { item: NewsItem }) => { + const t = useT(); const openLink = api.general.openLink.useMutation(); return (
{item.title}
- {formatDate(item.date)} + + {formatDate(item.date)} +
{item.author && ( - by {item.author} + + {t('misc.newsByAuthor', { author: item.author })} + )}

{item.body}

{item.url && ( @@ -36,7 +42,7 @@ const NewsEntry = ({ item }: { item: NewsItem }) => { className="-ml-2 self-start text-pink" onClick={() => openLink.mutateAsync(item.url!)} > - Read more + {t('misc.newsReadMore')} )}
@@ -44,6 +50,7 @@ const NewsEntry = ({ item }: { item: NewsItem }) => { }; const NewsTab = () => { + const t = useT(); const query = api.news.list.useQuery(undefined, { staleTime: 5 * 60 * 1000, refetchOnWindowFocus: false, @@ -54,14 +61,14 @@ const NewsTab = () => { return (
-

News

+

{t('misc.newsTitle')}

query.refetch()} - title="Refresh" + title={t('misc.refresh')} />

@@ -72,24 +79,24 @@ const NewsTab = () => { {query.isLoading ? (
-

Loading news...

+

{t('misc.newsLoading')}

) : query.isError ? (
-

Couldn't reach the news feed.

+

{t('misc.newsError')}

query.refetch()} > - Try again + {t('misc.tryAgain')}
) : !query.data?.length ? (
-

No news yet — check back later.

+

{t('misc.newsEmpty')}

) : ( query.data.map(item => ) diff --git a/src/renderer/components/tabs/TweaksTab.tsx b/src/renderer/components/tabs/TweaksTab.tsx index 1dc3266..9ce4446 100644 --- a/src/renderer/components/tabs/TweaksTab.tsx +++ b/src/renderer/components/tabs/TweaksTab.tsx @@ -6,6 +6,7 @@ import { api } from '~renderer/utils/api'; import { ConfigWtfSchema } from '~common/schemas'; import zodResolver from '~renderer/utils/zodResolver'; import useScrollHint from '~renderer/utils/useScrollHint'; +import { useT } from '~renderer/i18n'; import TextButton from '../styled/TextButton'; import CheckboxInput from '../form/CheckboxInput'; @@ -64,6 +65,7 @@ const Item = ({ }; const TweaksTab = () => { + const t = useT(); const { data: pref } = api.preferences.get.useQuery(); const setPref = api.preferences.set.useMutation(); @@ -74,7 +76,10 @@ const TweaksTab = () => { defaultValues: pref?.config ?? {}, resolver: zodResolver(ConfigWtfSchema) }); - const { handleSubmit, reset } = form; + const { handleSubmit, reset, formState } = form; + + const isApplying = + setPref.isLoading || applyPatch.isLoading || verify.isLoading; useEffect(() => { pref && reset(pref.config); @@ -100,33 +105,35 @@ const TweaksTab = () => { -

Camera

+

+ {t('tweaks.cameraHeading')} +

{ { { -

Sounds

+

+ {t('tweaks.soundsHeading')} +


- Highlighted options are - recommended and enabled by default + {applyPatch.isError ? ( + + {t('tweaks.applyFailed', { + message: applyPatch.error?.message ?? '' + })} + + ) : ( + <> + + {t('tweaks.highlighted')} + {' '} + {t('tweaks.recommendedNote')} + + )}

{ @@ -183,11 +204,13 @@ const TweaksTab = () => { reset(config); }} > - Reset - - - Apply + {t('tweaks.reset')} + {(formState.isDirty || isApplying) && ( + + {t('tweaks.apply')} + + )}
); diff --git a/src/renderer/components/tabs/addons/AddonDetail.tsx b/src/renderer/components/tabs/addons/AddonDetail.tsx index 14f2136..f964a1a 100644 --- a/src/renderer/components/tabs/addons/AddonDetail.tsx +++ b/src/renderer/components/tabs/addons/AddonDetail.tsx @@ -16,6 +16,7 @@ import { ColoredText } from '~renderer/components/styled/ColoredText'; import useScrollHint from '~renderer/utils/useScrollHint'; import IconSpinner from '~renderer/components/styled/IconSpinner'; import CloseButton from '~renderer/components/styled/CloseButton'; +import { useT } from '~renderer/i18n'; import { type LocalDependencies } from './AddonListItem'; @@ -41,6 +42,7 @@ type Props = AddonData & { }; const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => { + const t = useT(); const openLink = api.general.openLink.useMutation(); const update = api.addons.update.useMutation(); @@ -71,26 +73,26 @@ const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => { {addon.toc?.Notes ?? addon.description ?? ''} )}
- + {addon.git && ( openLink.mutateAsync(addon.git)} className="s1 -m-2 !inline" > - Open on GitHub + {t('addons.openOnGithubShort')} )} {addon.toc && ( <> - + {addon.toc.Author} - + {addon.toc.Version} - + {!!dependencies.length && (
    {dependencies.map(({ name, optional, status }) => ( @@ -99,7 +101,7 @@ const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => { ) : status === 'available' ? ( @@ -122,7 +124,9 @@ const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => { ) ? (

    {status}

    ) : optional ? ( -

    (optional)

    +

    + {t('addons.optional')} +

    ) : null} ))} diff --git a/src/renderer/components/tabs/addons/AddonListItem.tsx b/src/renderer/components/tabs/addons/AddonListItem.tsx index 5cd188a..0602761 100644 --- a/src/renderer/components/tabs/addons/AddonListItem.tsx +++ b/src/renderer/components/tabs/addons/AddonListItem.tsx @@ -16,6 +16,7 @@ import IconSpinner from '~renderer/components/styled/IconSpinner'; import DialogButton from '~renderer/components/styled/DialogButton'; import { isNotUndef } from '~common/utils'; import CloseButton from '~renderer/components/styled/CloseButton'; +import { useT } from '~renderer/i18n'; import AddonDetail from './AddonDetail'; @@ -39,6 +40,7 @@ const toRepoUrl = (git?: string) => git ? git.replace(/\.git$/, '') : undefined; const AddonListItem = ({ row, dependencies, ...addon }: Props) => { + const t = useT(); const update = api.addons.update.useMutation(); const remove = api.addons.remove.useMutation(); const openLink = api.general.openLink.useMutation(); @@ -58,17 +60,21 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => { const warnings = [ 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`, - short: 'Incorrect version' + full: t('addons.warnIncorrectVersionFull', { + version: addon.toc?.Interface ?? '' + }), + short: t('addons.warnIncorrectVersionShort') } : undefined, localDependencies.some(d => d.status !== 'installed' && !d.optional) ? { - full: `This addon has missing dependencies: ${localDependencies - .filter(d => d.status !== 'installed' && !d.optional) - .map(d => d.name) - .join(', ')}`, - short: 'Missing dependencies' + full: t('addons.warnMissingDependenciesFull', { + deps: localDependencies + .filter(d => d.status !== 'installed' && !d.optional) + .map(d => d.name) + .join(', ') + }), + short: t('addons.warnMissingDependenciesShort') } : undefined ].filter(isNotUndef); @@ -107,7 +113,7 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => { : HelpCircle } onClick={open} - title="Details" + title={t('addons.details')} size={18} className={cls( '-mx-2', @@ -132,7 +138,7 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => { openLink.mutateAsync(repoUrl)} className="!p-1 text-blueGray/60 hocus:text-pink" /> @@ -162,9 +168,9 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => { ) : (

    {addon.status === 'upToDate' - ? 'Up to date' + ? t('addons.upToDate') : !addon.git - ? 'Not versioned' + ? t('addons.notVersioned') : ''}

    )} @@ -173,17 +179,16 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => { onClick={() => update.mutateAsync({ toUpdate: [addon.folder] })} className="s1 -mx-2 justify-self-end" > - Update + {t('addons.update')}
    )} {addon.status === 'available' ? ( update.mutateAsync({ toUpdate: [addon.folder] })} className="text-warmGreen" icon={DownloadCloud} size={18} - title="Download" + title={t('addons.download')} /> ) : ( { dialog={close => (
    -

    Are you sure?

    +

    {t('addons.deleteConfirmTitle')}


    - Are you sure you want to delete {addon.folder}{' '} - addon? + {t('addons.deleteConfirmBody', { folder: addon.folder })}

    - This will delete all files in the addon folder. + {t('addons.deleteConfirmFiles')}

    { disabled={remove.isLoading} className="self-end text-red" > - Delete + {t('addons.delete')}
    )} @@ -220,7 +224,7 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => { className="text-red/50" icon={Trash2} size={18} - title="Remove" + title={t('addons.remove')} /> )}
    diff --git a/src/renderer/components/tabs/addons/CustomAddonDialog.tsx b/src/renderer/components/tabs/addons/CustomAddonDialog.tsx index 78afd8a..2c60d1e 100644 --- a/src/renderer/components/tabs/addons/CustomAddonDialog.tsx +++ b/src/renderer/components/tabs/addons/CustomAddonDialog.tsx @@ -5,6 +5,7 @@ import CloseButton from '~renderer/components/styled/CloseButton'; import IconSpinner from '~renderer/components/styled/IconSpinner'; import TextButton from '~renderer/components/styled/TextButton'; import { api } from '~renderer/utils/api'; +import { useT } from '~renderer/i18n'; const useDebounced = (value: string, delay: number) => { const [debouncedValue, setDebouncedValue] = useState(value); @@ -17,6 +18,7 @@ const useDebounced = (value: string, delay: number) => { }; const CustomAddonDialog = ({ close }: { close: () => void }) => { + const t = useT(); const [url, setUrl] = useState(''); const debouncedUrl = useDebounced(url, 500); const response = api.addons.checkGitUrl.useQuery(debouncedUrl, { @@ -27,10 +29,14 @@ const CustomAddonDialog = ({ close }: { close: () => void }) => { return (
    -

    Install addon

    +

    {t('addons.installAddon')}


    {response.data ? ( - Preview + {t('addons.previewAlt')} ) : (
    {response.isFetching && } @@ -53,8 +59,8 @@ const CustomAddonDialog = ({ close }: { close: () => void }) => {

    {response.data - ? 'Ready to install' - : 'Not a valid git repository URL'} + ? t('addons.readyToInstall') + : t('addons.invalidGitUrl')}

    { @@ -66,7 +72,7 @@ const CustomAddonDialog = ({ close }: { close: () => void }) => { className={response.data ? 'text-warmGreen' : 'text-blueGray'} disabled={!response.data || response.isLoading} > - Install + {t('addons.install')}
    diff --git a/src/renderer/i18n/index.tsx b/src/renderer/i18n/index.tsx new file mode 100644 index 0000000..3ee26f3 --- /dev/null +++ b/src/renderer/i18n/index.tsx @@ -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; +type Translate = (key: string, params?: Params) => string; + +type LocaleCtx = { + lang: Lang; + setLang: (lang: Lang) => void; + t: Translate; +}; + +const LocaleContext = createContext({ + lang: 'enUS', + setLang: () => {}, + t: key => key +}); + +export const LocaleProvider = ({ children }: { children: ReactNode }) => { + const { data: pref } = api.preferences.get.useQuery(); + const [lang, setLang] = useState('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 ( + + {children} + + ); +}; + +export const useLocale = () => useContext(LocaleContext); +export const useT = () => useContext(LocaleContext).t; diff --git a/src/renderer/i18n/translations.ts b/src/renderer/i18n/translations.ts new file mode 100644 index 0000000..94d797b --- /dev/null +++ b/src/renderer/i18n/translations.ts @@ -0,0 +1,1326 @@ +export type Lang = 'enUS' | 'deDE' | 'zhCN' | 'esES' | 'ptBR' | 'ruRU'; + +type Dict = Record; + +const enUS: Dict = { + 'tab.news': 'News', + 'tab.tweaks': 'Tweaks', + 'tab.addons': 'Addons', + 'tab.mods': 'Mods', + 'topbar.settings': 'Settings', + 'topbar.minimize': 'Minimize', + 'topbar.quit': 'Quit', + 'topbar.language': 'Language', + 'quit.title': 'Quit?', + 'quit.warn': + 'Your game is currently being updated. Quitting now may cause problems.', + 'quit.return': 'Return', + 'launch.verifying': 'Verifying', + 'launch.play': 'Play', + 'launch.retry': 'Retry', + 'launch.install': 'Install', + 'launch.update': 'Update', + 'launch.updating': 'Updating', + 'launch.applying': 'Applying', + 'launch.updateAvailable': 'Update available!', + 'launch.modsChanged': 'Mods changed, apply before playing', + 'launch.upToDate': 'Everything up to date!', + 'launch.version': 'Version: {version}', + 'launch.errorLabel': 'Error:', + 'launch.serverFail': 'Failed to reach update server', + 'launch.localVersion': 'You can launch local version {version}', + 'launch.tryLater': 'Please try again later', + 'launch.verifyHint': 'Verify your game data by clicking Retry.', + 'launch.remaining': 'remaining', + 'launch.calculating': 'calculating…', + 'launch.onDisk': 'on disk', + 'tweaks.alwaysAutoLoot.label': 'Always auto-loot', + 'tweaks.alwaysAutoLoot.text': + 'Reverses auto-loot behavior to always auto-loot and disable auto-with bound key.', + 'tweaks.largeAddress.label': 'Large Address Aware', + 'tweaks.largeAddress.text': 'Allows the game to use more than 2GB of memory.', + 'tweaks.nameplateRange.label': 'Nameplate range', + 'tweaks.nameplateRange.text': + 'Increases distance at which nameplates are visible. [Vanilla: 20] [Classic: 41]', + 'tweaks.cameraHeading': 'Camera', + 'tweaks.fieldOfView.label': 'Field of View', + 'tweaks.fieldOfView.text': + 'Recommended for widescreen window resolutions. [Vanilla: 90] [Tweaks: 110]', + 'tweaks.farClip.label': 'Render distance', + 'tweaks.farClip.text': + 'Increases maximum render distance. [Vanilla: 777] [Tweaks: 10000]', + 'tweaks.frillDistance.label': 'Ground clutter distance', + 'tweaks.frillDistance.text': + 'Changes ground clutter render distance. [Vanilla: 70] [Tweaks: 300]', + 'tweaks.cameraDistance.label': 'Camera distance', + 'tweaks.cameraDistance.text': + 'Increases maximum camera (zoom out) distance. [Vanilla: 50] [Max:100]', + 'tweaks.soundsHeading': 'Sounds', + 'tweaks.soundInBackground.label': 'Background sounds', + 'tweaks.soundInBackground.text': + 'Allows game sounds to play while the game is minimized.', + 'tweaks.applyFailed': 'Failed to apply tweaks: {message}', + 'tweaks.highlighted': 'Highlighted', + 'tweaks.recommendedNote': 'options are recommended and enabled by default', + 'tweaks.reset': 'Reset', + 'tweaks.apply': 'Apply', + 'misc.newsTitle': 'News', + 'misc.newsByAuthor': 'by {author}', + 'misc.newsReadMore': 'Read more', + 'misc.refresh': 'Refresh', + 'misc.newsLoading': 'Loading news...', + 'misc.newsError': "Couldn't reach the news feed.", + 'misc.newsEmpty': 'No news yet. Check back later.', + 'misc.tryAgain': 'Try again', + 'misc.comingSoon': 'Coming soon...', + 'misc.selfUpdateCheckFailed': 'Update check failed: {message}', + 'misc.selfUpdateAvailable': + 'Launcher update {version} available, preparing download…', + 'misc.selfUpdateDownloading': 'Downloading update {version} · {percent}%', + 'misc.selfUpdateReady': 'Launcher update {version} ready to install', + 'misc.selfUpdateInstallNow': 'Install now', + 'misc.tabCrashed': '{tab} crashed', + 'misc.somethingWentWrong': 'Something went wrong!', + 'misc.uncaughtError': 'Uncaught {name}: {message}', + 'misc.unknownError': 'Unknown error', + 'misc.copyError': 'Copy error', + 'misc.reload': 'Reload', + 'mods.update': 'update', + 'mods.ignoreUpdates': 'Ignore updates', + 'mods.title': 'CUSTOM MODS', + 'mods.unsavedChanges': 'unsaved changes', + 'mods.warning': + 'Enabling custom mods may not provide any performance benefits or may even cause game crashes depending on your system. Please try disabling them if you experience any issues.', + 'mods.enableRequired': 'Enable {mods}, required by your selected mods.', + 'mods.depRequired': '{mod} must be enabled. It is required by {requiredBy}.', + 'mods.highlighted': 'Highlighted', + 'mods.highlightedRecommended': 'mods are recommended.', + 'mods.apply': 'Apply', + 'mods.cantApplyYet': "CAN'T APPLY YET", + 'mods.close': 'Close', + 'av.blockedTitle': 'BLOCKED BY ANTIVIRUS', + 'av.blockedOne': 'Windows Defender blocked this mod:', + 'av.blockedMany': 'Windows Defender blocked these mods:', + 'av.blockedExplain': + 'These are safe community mods that Defender mis-flags. Allow them once, then try again.', + 'av.whyHappening': 'Why is this happening?', + 'av.allowThrough': 'Allow through antivirus', + 'av.addedRetry': 'Added. Close this and try again.', + 'av.close': 'Close', + 'av.whyTitle': 'WHY ANTIVIRUS FLAGS MODS', + 'av.whyIntro': + 'Some of these mods get flagged by Windows Defender (or other antivirus) as a threat such as "{detection}". This is a', + 'av.falsePositive': 'false positive', + 'av.whatSetsItOff': 'What sets it off', + 'av.whatSetsItOffIntro': + 'These mods modernize the 20-year-old 1.12 game client using two techniques that look suspicious to antivirus heuristics:', + 'av.dllInjection': 'DLL injection', + 'av.dllInjectionText': + 'the mods load extra code into the running game to add features (ping compensation, better nameplates, performance). Loading code into another program is also something malware does, so the behavior alone trips the scanner.', + 'av.exePatching': 'Executable patching', + 'av.exePatchingText': + 'some mods edit WoW.exe to lift old engine limits (for example letting the 32-bit client use 4GB of RAM instead of 2GB). Modifying an executable is another malware-like pattern.', + 'av.heuristicNote': + 'Because these are behavior-based (heuristic) detections, not matches against real malware, legitimate mods that use them get caught as generic "trojan" or "hacktool" false positives.', + 'av.whatModsDo': 'What the mods actually do', + 'av.modVanillaFixes': + 'the loader that fixes stutter and timer bugs and starts the other mods.', + 'av.modNampower': + 'ping compensation so casts feel responsive on higher latency.', + 'av.modSuperWowUnitXp': + 'advanced Lua, real nameplates, camera and targeting upgrades.', + 'av.modDxvk': + 'translates the old DirectX 9 to modern Vulkan for big FPS gains.', + 'av.howToVerify': 'How to verify these claims', + 'av.howToVerifyText': + 'These are open-source community projects. You can read every line of each mod\'s code from the source link next to it in the Mods list, and you can look up the detection name (for example "{detection}") to see it is a documented false positive on these tools.', + 'av.whatAllowDoesTitle': 'What does "Allow through antivirus" do', + 'av.whatAllowDoesIntro': + "It asks Windows for administrator permission (one prompt, click Yes), then runs Windows' own built-in", + 'av.whatAllowDoesIntroAfter': + 'command to add a few entries to the Windows Defender exclusion list:', + 'av.exclusionFoldersBefore': 'Your', + 'av.gameFolder': 'game folder', + 'av.exclusionFoldersAnd': 'and the', + 'av.launcherFolder': 'launcher folder', + 'av.exclusionFoldersAfter': + ', so the mod files in them are no longer scanned or quarantined.', + 'av.exclusionExesAnd': 'and', + 'av.exclusionExesAfter': + 'as allowed processes, so they are not blocked while running.', + 'av.whatAllowDoesOutro': + 'This is the same thing you would do by hand under Windows Security, Virus and threat protection, Exclusions. It installs nothing, runs no third-party software, and changes nothing outside those specific paths.', + 'av.back': 'Back', + 'addons.sectionInstalled': 'Installed', + 'addons.sectionRecommended': 'Recommended', + 'addons.sectionAvailable': 'Available', + 'addons.checkForUpdates': 'Check for updates', + 'addons.addCustomGitAddon': 'Add custom git addon', + 'addons.updateAll': 'Update all', + 'addons.everythingUpToDate': 'Everything is up to date.', + 'addons.installAddon': 'Install addon', + 'addons.previewAlt': 'Preview', + 'addons.readyToInstall': 'Ready to install', + 'addons.invalidGitUrl': 'Not a valid git repository URL', + 'addons.install': 'Install', + 'addons.warnIncorrectVersionFull': + 'This addon seems to be made for different game version ({version}) and it may not function correctly', + 'addons.warnIncorrectVersionShort': 'Incorrect version', + 'addons.warnMissingDependenciesFull': + 'This addon has missing dependencies: {deps}', + 'addons.warnMissingDependenciesShort': 'Missing dependencies', + 'addons.details': 'Details', + 'addons.openOnGithub': 'Open {url} on GitHub', + 'addons.upToDate': 'Up to date', + 'addons.notVersioned': 'Not versioned', + 'addons.update': 'Update', + 'addons.download': 'Download', + 'addons.deleteConfirmTitle': 'Are you sure?', + 'addons.deleteConfirmBody': 'Are you sure you want to delete {folder} addon?', + 'addons.deleteConfirmFiles': + 'This will delete all files in the addon folder.', + 'addons.delete': 'Delete', + 'addons.remove': 'Remove', + 'addons.detailSource': 'Source', + 'addons.openOnGithubShort': 'Open on GitHub', + 'addons.detailContributions': 'Contributions', + 'addons.detailAddonVersion': 'Addon version', + 'addons.detailDependencies': 'Dependencies', + 'addons.optional': '(optional)', + 'prefs.mirrorOffline': 'offline', + 'prefs.mirrorChecking': 'checking…', + 'prefs.mirrorOnline': 'online', + 'prefs.title': 'SETTINGS', + 'prefs.installLocation': 'INSTALL LOCATION:', + 'prefs.openFolder': 'Open folder', + 'prefs.notSelected': 'Not selected', + 'prefs.change': 'Change', + 'prefs.downloadMirror': 'DOWNLOAD MIRROR:', + 'prefs.recheck': 'Re-check', + 'prefs.troubleshooting': 'TROUBLESHOOTING:', + 'prefs.verifyGameFiles': 'Verify game files', + 'prefs.openLogFile': 'Open log file', + 'prefs.allowThroughAntivirus': 'Allow through antivirus', + 'prefs.exclusionAdded': 'Added. Re-verify game files if any were removed.', + 'prefs.generalSettings': 'GENERAL SETTINGS:', + 'prefs.cleanWdb': 'Clean WDB on each launch', + 'prefs.minimizeToTray': 'Minimize to tray while playing', + 'prefs.save': 'Save', + 'prefs.installLocationTitle': 'Install location', + 'prefs.portableInfo': + 'You are using the portable version of the launcher. Install location is determined by the location of the launcher executable.', + 'prefs.errorLabel': 'Error: ', + 'prefs.wowExeNotFound': + '{exe} not found in current folder. Please close the launcher and move it to your WoW 1.12 client directory.', + 'prefs.selectDirectory': + 'Select a directory for the game client installation.', + 'prefs.upgradeExisting': + 'You may also choose a directory with an existing Turtle WoW or Vanilla WoW installation, and it will be automatically upgraded.', + 'prefs.installDirectory': 'Install directory:', + 'prefs.confirm': 'Confirm' +}; + +const deDE: Dict = { + 'tab.news': 'Neuigkeiten', + 'tab.tweaks': 'Optimierungen', + 'tab.addons': 'Addons', + 'tab.mods': 'Mods', + 'topbar.settings': 'Einstellungen', + 'topbar.minimize': 'Minimieren', + 'topbar.quit': 'Beenden', + 'topbar.language': 'Sprache', + 'quit.title': 'Beenden?', + 'quit.warn': + 'Dein Spiel wird gerade aktualisiert. Ein Beenden kann Probleme verursachen.', + 'quit.return': 'Zurück', + 'launch.verifying': 'Überprüfung', + 'launch.play': 'Spielen', + 'launch.retry': 'Erneut', + 'launch.install': 'Installieren', + 'launch.update': 'Aktualisieren', + 'launch.updating': 'Aktualisierung', + 'launch.applying': 'Anwenden', + 'launch.updateAvailable': 'Update verfügbar!', + 'launch.modsChanged': 'Mods geändert, vor dem Spielen anwenden', + 'launch.upToDate': 'Alles aktuell!', + 'launch.version': 'Version: {version}', + 'launch.errorLabel': 'Fehler:', + 'launch.serverFail': 'Update-Server nicht erreichbar', + 'launch.localVersion': 'Du kannst die lokale Version {version} starten', + 'launch.tryLater': 'Bitte versuche es später erneut', + 'launch.verifyHint': + 'Überprüfe deine Spieldaten, indem du auf „Erneut“ klickst.', + 'launch.remaining': 'verbleibend', + 'launch.calculating': 'wird berechnet…', + 'launch.onDisk': 'auf der Festplatte', + 'tweaks.alwaysAutoLoot.label': 'Immer automatisch plündern', + 'tweaks.alwaysAutoLoot.text': + 'Kehrt das Auto-Plündern-Verhalten um, sodass immer automatisch geplündert wird und das Auto-Plündern per Tastenkombination deaktiviert ist.', + 'tweaks.largeAddress.label': 'Large Address Aware', + 'tweaks.largeAddress.text': + 'Erlaubt dem Spiel, mehr als 2 GB Arbeitsspeicher zu nutzen.', + 'tweaks.nameplateRange.label': 'Namensplaketten-Reichweite', + 'tweaks.nameplateRange.text': + 'Erhöht die Entfernung, in der Namensplaketten sichtbar sind. [Vanilla: 20] [Classic: 41]', + 'tweaks.cameraHeading': 'Kamera', + 'tweaks.fieldOfView.label': 'Sichtfeld', + 'tweaks.fieldOfView.text': + 'Empfohlen für Breitbild-Fensterauflösungen. [Vanilla: 90] [Tweaks: 110]', + 'tweaks.farClip.label': 'Sichtweite', + 'tweaks.farClip.text': + 'Erhöht die maximale Sichtweite. [Vanilla: 777] [Tweaks: 10000]', + 'tweaks.frillDistance.label': 'Bodendetail-Distanz', + 'tweaks.frillDistance.text': + 'Ändert die Renderdistanz für Bodendetails. [Vanilla: 70] [Tweaks: 300]', + 'tweaks.cameraDistance.label': 'Kameradistanz', + 'tweaks.cameraDistance.text': + 'Erhöht die maximale Kameradistanz (Herauszoomen). [Vanilla: 50] [Max:100]', + 'tweaks.soundsHeading': 'Töne', + 'tweaks.soundInBackground.label': 'Hintergrundton', + 'tweaks.soundInBackground.text': + 'Lässt die Spieltöne weiterlaufen, während das Spiel minimiert ist.', + 'tweaks.applyFailed': 'Tweaks konnten nicht angewendet werden: {message}', + 'tweaks.highlighted': 'Hervorgehoben', + 'tweaks.recommendedNote': + 'Optionen werden empfohlen und sind standardmäßig aktiviert', + 'tweaks.reset': 'Zurücksetzen', + 'tweaks.apply': 'Anwenden', + 'mods.update': 'Aktualisieren', + 'mods.ignoreUpdates': 'Updates ignorieren', + 'mods.title': 'BENUTZERDEFINIERTE MODS', + 'mods.unsavedChanges': 'nicht gespeicherte Änderungen', + 'mods.warning': + 'Das Aktivieren benutzerdefinierter Mods bringt je nach System möglicherweise keine Leistungsvorteile oder kann sogar zu Spielabstürzen führen. Bitte deaktiviere sie, falls Probleme auftreten.', + 'mods.enableRequired': + 'Aktiviere {mods}, die von deinen ausgewählten Mods benötigt werden.', + 'mods.depRequired': + '{mod} muss aktiviert sein. Es wird von {requiredBy} benötigt.', + 'mods.highlighted': 'Hervorgehoben', + 'mods.highlightedRecommended': 'Mods werden empfohlen.', + 'mods.apply': 'Anwenden', + 'mods.cantApplyYet': 'NOCH NICHT ANWENDBAR', + 'mods.close': 'Schließen', + 'av.blockedTitle': 'VOM ANTIVIRENPROGRAMM BLOCKIERT', + 'av.blockedOne': 'Windows Defender hat diesen Mod blockiert:', + 'av.blockedMany': 'Windows Defender hat diese Mods blockiert:', + 'av.blockedExplain': + 'Das sind sichere Community-Mods, die der Defender fälschlicherweise meldet. Erlaube sie einmal und versuche es dann erneut.', + 'av.whyHappening': 'Warum passiert das?', + 'av.allowThrough': 'Im Antivirenprogramm zulassen', + 'av.addedRetry': 'Hinzugefügt. Schließe dies und versuche es erneut.', + 'av.close': 'Schließen', + 'av.whyTitle': 'WARUM ANTIVIRENPROGRAMME MODS MELDEN', + 'av.whyIntro': + 'Einige dieser Mods werden von Windows Defender (oder anderen Antivirenprogrammen) als Bedrohung wie z. B. "{detection}" gemeldet. Dabei handelt es sich um einen', + 'av.falsePositive': 'Fehlalarm', + 'av.whatSetsItOff': 'Was den Alarm auslöst', + 'av.whatSetsItOffIntro': + 'Diese Mods modernisieren den 20 Jahre alten 1.12-Spielclient mit zwei Techniken, die für die Heuristik von Antivirenprogrammen verdächtig aussehen:', + 'av.dllInjection': 'DLL-Injektion', + 'av.dllInjectionText': + 'Die Mods laden zusätzlichen Code in das laufende Spiel, um Funktionen hinzuzufügen (Ping-Kompensation, bessere Namensplaketten, Leistung). Das Laden von Code in ein anderes Programm ist auch etwas, das Schadsoftware tut, daher löst allein dieses Verhalten den Scanner aus.', + 'av.exePatching': 'Patchen der ausführbaren Datei', + 'av.exePatchingText': + 'Einige Mods bearbeiten WoW.exe, um alte Engine-Limits aufzuheben (zum Beispiel, damit der 32-Bit-Client 4 GB RAM statt 2 GB nutzen kann). Das Verändern einer ausführbaren Datei ist ein weiteres schadsoftwareähnliches Muster.', + 'av.heuristicNote': + 'Da es sich um verhaltensbasierte (heuristische) Erkennungen handelt und nicht um Übereinstimmungen mit echter Schadsoftware, werden legitime Mods, die diese Techniken nutzen, fälschlicherweise als allgemeine "Trojaner"- oder "Hacktool"-Bedrohung eingestuft.', + 'av.whatModsDo': 'Was die Mods tatsächlich tun', + 'av.modVanillaFixes': + 'der Loader, der Ruckler und Timer-Fehler behebt und die anderen Mods startet.', + 'av.modNampower': + 'Ping-Kompensation, damit sich Zauber bei höherer Latenz reaktionsschnell anfühlen.', + 'av.modSuperWowUnitXp': + 'erweitertes Lua, echte Namensplaketten, Kamera- und Zielerfassungs-Verbesserungen.', + 'av.modDxvk': + 'übersetzt das alte DirectX 9 in das moderne Vulkan für große FPS-Gewinne.', + 'av.howToVerify': 'Wie du diese Angaben überprüfen kannst', + 'av.howToVerifyText': + 'Das sind quelloffene Community-Projekte. Du kannst jede Zeile des Codes jedes Mods über den Quell-Link daneben in der Mods-Liste einsehen, und du kannst den Erkennungsnamen (zum Beispiel "{detection}") nachschlagen, um zu sehen, dass es sich bei diesen Tools um einen dokumentierten Fehlalarm handelt.', + 'av.whatAllowDoesTitle': 'Was bewirkt "Im Antivirenprogramm zulassen"', + 'av.whatAllowDoesIntro': + 'Es fragt Windows nach Administratorrechten (eine Abfrage, auf Ja klicken) und führt dann den in Windows integrierten', + 'av.whatAllowDoesIntroAfter': + 'Befehl aus, um der Ausschlussliste von Windows Defender einige Einträge hinzuzufügen:', + 'av.exclusionFoldersBefore': 'Deinen', + 'av.gameFolder': 'Spielordner', + 'av.exclusionFoldersAnd': 'und den', + 'av.launcherFolder': 'Launcher-Ordner', + 'av.exclusionFoldersAfter': + ', damit die darin enthaltenen Mod-Dateien nicht mehr gescannt oder in Quarantäne verschoben werden.', + 'av.exclusionExesAnd': 'und', + 'av.exclusionExesAfter': + 'als erlaubte Prozesse, damit sie während der Ausführung nicht blockiert werden.', + 'av.whatAllowDoesOutro': + 'Das ist dasselbe, was du von Hand unter Windows-Sicherheit, Viren- und Bedrohungsschutz, Ausschlüsse tun würdest. Es installiert nichts, führt keine Drittanbietersoftware aus und ändert nichts außerhalb dieser bestimmten Pfade.', + 'av.back': 'Zurück', + 'addons.sectionInstalled': 'Installiert', + 'addons.sectionRecommended': 'Empfohlen', + 'addons.sectionAvailable': 'Verfügbar', + 'addons.checkForUpdates': 'Nach Updates suchen', + 'addons.addCustomGitAddon': 'Benutzerdefiniertes Git-Addon hinzufügen', + 'addons.updateAll': 'Alle aktualisieren', + 'addons.everythingUpToDate': 'Alles ist auf dem neuesten Stand.', + 'addons.installAddon': 'Addon installieren', + 'addons.previewAlt': 'Vorschau', + 'addons.readyToInstall': 'Bereit zur Installation', + 'addons.invalidGitUrl': 'Keine gültige Git-Repository-URL', + 'addons.install': 'Installieren', + 'addons.warnIncorrectVersionFull': + 'Dieses Addon scheint für eine andere Spielversion ({version}) erstellt worden zu sein und funktioniert möglicherweise nicht korrekt', + 'addons.warnIncorrectVersionShort': 'Falsche Version', + 'addons.warnMissingDependenciesFull': + 'Diesem Addon fehlen Abhängigkeiten: {deps}', + 'addons.warnMissingDependenciesShort': 'Fehlende Abhängigkeiten', + 'addons.details': 'Details', + 'addons.openOnGithub': '{url} auf GitHub öffnen', + 'addons.upToDate': 'Aktuell', + 'addons.notVersioned': 'Nicht versioniert', + 'addons.update': 'Aktualisieren', + 'addons.download': 'Herunterladen', + 'addons.deleteConfirmTitle': 'Bist du sicher?', + 'addons.deleteConfirmBody': + 'Möchtest du das Addon {folder} wirklich löschen?', + 'addons.deleteConfirmFiles': + 'Dadurch werden alle Dateien im Addon-Ordner gelöscht.', + 'addons.delete': 'Löschen', + 'addons.remove': 'Entfernen', + 'addons.detailSource': 'Quelle', + 'addons.openOnGithubShort': 'Auf GitHub öffnen', + 'addons.detailContributions': 'Beiträge', + 'addons.detailAddonVersion': 'Addon-Version', + 'addons.detailDependencies': 'Abhängigkeiten', + 'addons.optional': '(optional)', + 'prefs.mirrorOffline': 'offline', + 'prefs.mirrorChecking': 'wird geprüft…', + 'prefs.mirrorOnline': 'online', + 'prefs.title': 'EINSTELLUNGEN', + 'prefs.installLocation': 'INSTALLATIONSORT:', + 'prefs.openFolder': 'Ordner öffnen', + 'prefs.notSelected': 'Nicht ausgewählt', + 'prefs.change': 'Ändern', + 'prefs.downloadMirror': 'DOWNLOAD-MIRROR:', + 'prefs.recheck': 'Erneut prüfen', + 'prefs.troubleshooting': 'FEHLERBEHEBUNG:', + 'prefs.verifyGameFiles': 'Spieldateien überprüfen', + 'prefs.openLogFile': 'Protokolldatei öffnen', + 'prefs.allowThroughAntivirus': 'Im Antivirenprogramm zulassen', + 'prefs.exclusionAdded': + 'Hinzugefügt. Überprüfe die Spieldateien erneut, falls welche entfernt wurden.', + 'prefs.generalSettings': 'ALLGEMEINE EINSTELLUNGEN:', + 'prefs.cleanWdb': 'WDB bei jedem Start leeren', + 'prefs.minimizeToTray': 'Während des Spielens in den Infobereich minimieren', + 'prefs.save': 'Speichern', + 'prefs.installLocationTitle': 'Installationsort', + 'prefs.portableInfo': + 'Du verwendest die portable Version des Launchers. Der Installationsort wird durch den Speicherort der ausführbaren Launcher-Datei bestimmt.', + 'prefs.errorLabel': 'Fehler: ', + 'prefs.wowExeNotFound': + '{exe} wurde im aktuellen Ordner nicht gefunden. Bitte schließe den Launcher und verschiebe ihn in dein WoW-1.12-Client-Verzeichnis.', + 'prefs.selectDirectory': + 'Wähle ein Verzeichnis für die Installation des Spielclients aus.', + 'prefs.upgradeExisting': + 'Du kannst auch ein Verzeichnis mit einer vorhandenen Turtle-WoW- oder Vanilla-WoW-Installation wählen, und es wird automatisch aktualisiert.', + 'prefs.installDirectory': 'Installationsverzeichnis:', + 'prefs.confirm': 'Bestätigen', + 'misc.newsTitle': 'Neuigkeiten', + 'misc.newsByAuthor': 'von {author}', + 'misc.newsReadMore': 'Mehr lesen', + 'misc.refresh': 'Aktualisieren', + 'misc.newsLoading': 'Neuigkeiten werden geladen...', + 'misc.newsError': 'Der Neuigkeiten-Feed konnte nicht erreicht werden.', + 'misc.newsEmpty': 'Noch keine Neuigkeiten – schau später wieder vorbei.', + 'misc.tryAgain': 'Erneut versuchen', + 'misc.comingSoon': 'Demnächst verfügbar...', + 'misc.selfUpdateCheckFailed': 'Update-Prüfung fehlgeschlagen: {message}', + 'misc.selfUpdateAvailable': + 'Launcher-Update {version} verfügbar – Download wird vorbereitet…', + 'misc.selfUpdateDownloading': + 'Update {version} wird heruntergeladen · {percent}%', + 'misc.selfUpdateReady': 'Launcher-Update {version} bereit zur Installation', + 'misc.selfUpdateInstallNow': 'Jetzt installieren', + 'misc.tabCrashed': '{tab} ist abgestürzt', + 'misc.somethingWentWrong': 'Etwas ist schiefgelaufen!', + 'misc.uncaughtError': 'Nicht abgefangener {name}: {message}', + 'misc.unknownError': 'Unbekannter Fehler', + 'misc.copyError': 'Fehler kopieren', + 'misc.reload': 'Neu laden' +}; + +const zhCN: Dict = { + 'tab.news': '新闻', + 'tab.tweaks': '优化', + 'tab.addons': '插件', + 'tab.mods': '模组', + 'topbar.settings': '设置', + 'topbar.minimize': '最小化', + 'topbar.quit': '退出', + 'topbar.language': '语言', + 'quit.title': '退出?', + 'quit.warn': '游戏正在更新。现在退出可能会导致问题。', + 'quit.return': '返回', + 'launch.verifying': '正在验证', + 'launch.play': '开始游戏', + 'launch.retry': '重试', + 'launch.install': '安装', + 'launch.update': '更新', + 'launch.updating': '正在更新', + 'launch.applying': '正在应用', + 'launch.updateAvailable': '有可用更新!', + 'launch.modsChanged': '模组已更改,请在游戏前应用', + 'launch.upToDate': '一切都是最新的!', + 'launch.version': '版本:{version}', + 'launch.errorLabel': '错误:', + 'launch.serverFail': '无法连接到更新服务器', + 'launch.localVersion': '您可以启动本地版本 {version}', + 'launch.tryLater': '请稍后再试', + 'launch.verifyHint': '点击重试以验证游戏数据。', + 'launch.remaining': '剩余', + 'launch.calculating': '计算中…', + 'launch.onDisk': '在磁盘上', + 'tweaks.alwaysAutoLoot.label': '始终自动拾取', + 'tweaks.alwaysAutoLoot.text': + '反转自动拾取行为,改为始终自动拾取,并禁用按住绑定键的自动拾取。', + 'tweaks.largeAddress.label': '大地址感知', + 'tweaks.largeAddress.text': '允许游戏使用超过 2GB 的内存。', + 'tweaks.nameplateRange.label': '姓名板范围', + 'tweaks.nameplateRange.text': + '增加姓名板可见的距离。[Vanilla: 20] [Classic: 41]', + 'tweaks.cameraHeading': '镜头', + 'tweaks.fieldOfView.label': '视野范围', + 'tweaks.fieldOfView.text': + '推荐用于宽屏窗口分辨率。[Vanilla: 90] [Tweaks: 110]', + 'tweaks.farClip.label': '渲染距离', + 'tweaks.farClip.text': '增加最大渲染距离。[Vanilla: 777] [Tweaks: 10000]', + 'tweaks.frillDistance.label': '地表杂物距离', + 'tweaks.frillDistance.text': + '更改地表杂物的渲染距离。[Vanilla: 70] [Tweaks: 300]', + 'tweaks.cameraDistance.label': '镜头距离', + 'tweaks.cameraDistance.text': + '增加最大镜头(拉远)距离。[Vanilla: 50] [Max:100]', + 'tweaks.soundsHeading': '声音', + 'tweaks.soundInBackground.label': '后台声音', + 'tweaks.soundInBackground.text': '允许游戏在最小化时继续播放声音。', + 'tweaks.applyFailed': '应用优化失败:{message}', + 'tweaks.highlighted': '已高亮', + 'tweaks.recommendedNote': '选项为推荐项,默认已启用', + 'tweaks.reset': '重置', + 'tweaks.apply': '应用', + 'mods.update': '更新', + 'mods.ignoreUpdates': '忽略更新', + 'mods.title': '自定义 Mods', + 'mods.unsavedChanges': '未保存的更改', + 'mods.warning': + '启用自定义 Mods 不一定能带来性能提升,甚至可能因系统差异导致游戏崩溃。如遇到任何问题,请尝试将其禁用。', + 'mods.enableRequired': '启用 {mods},你所选的 Mods 需要它们。', + 'mods.depRequired': '必须启用 {mod},它是 {requiredBy} 所必需的。', + 'mods.highlighted': '已高亮', + 'mods.highlightedRecommended': 'Mods 为推荐项。', + 'mods.apply': '应用', + 'mods.cantApplyYet': '暂时无法应用', + 'mods.close': '关闭', + 'av.blockedTitle': '被杀毒软件拦截', + 'av.blockedOne': 'Windows Defender 拦截了此 Mod:', + 'av.blockedMany': 'Windows Defender 拦截了以下 Mods:', + 'av.blockedExplain': + '这些是安全的社区 Mods,被 Defender 误报了。允许它们一次,然后重试即可。', + 'av.whyHappening': '为什么会这样?', + 'av.allowThrough': '在杀毒软件中放行', + 'av.addedRetry': '已添加。关闭此窗口并重试。', + 'av.close': '关闭', + 'av.whyTitle': '为什么杀毒软件会标记 Mods', + 'av.whyIntro': + '其中一些 Mods 会被 Windows Defender(或其他杀毒软件)标记为威胁,例如“{detection}”。这是一个', + 'av.falsePositive': '误报', + 'av.whatSetsItOff': '触发原因', + 'av.whatSetsItOffIntro': + '这些 Mods 使用了两种会让杀毒软件启发式检测觉得可疑的技术,来对这款有 20 年历史的 1.12 游戏客户端进行现代化改造:', + 'av.dllInjection': 'DLL 注入', + 'av.dllInjectionText': + '这些 Mods 会向运行中的游戏加载额外代码以添加功能(延迟补偿、更好的姓名板、性能优化)。向其他程序注入代码也是恶意软件会做的事,因此仅凭这一行为就会触发扫描器。', + 'av.exePatching': '可执行文件修补', + 'av.exePatchingText': + '部分 Mods 会修改 WoW.exe 以解除旧引擎的限制(例如让 32 位客户端可使用 4GB 内存而非 2GB)。修改可执行文件是另一种类似恶意软件的特征。', + 'av.heuristicNote': + '由于这些是基于行为的(启发式)检测,而非与真实恶意软件的特征匹配,因此使用这些技术的正规 Mods 会被误报为通用的“木马”或“黑客工具”。', + 'av.whatModsDo': '这些 Mods 实际做了什么', + 'av.modVanillaFixes': '用于修复卡顿和计时器错误并启动其他 Mods 的加载器。', + 'av.modNampower': '延迟补偿,让你在较高延迟下施法依然跟手。', + 'av.modSuperWowUnitXp': '高级 Lua、真正的姓名板、镜头与目标选取的增强。', + 'av.modDxvk': '将旧的 DirectX 9 转换为现代的 Vulkan,大幅提升 FPS。', + 'av.howToVerify': '如何核实这些说法', + 'av.howToVerifyText': + '这些都是开源的社区项目。你可以通过 Mods 列表中每个 Mod 旁的源代码链接阅读其每一行代码,也可以查询检测名称(例如“{detection}”),即可确认它在这些工具上是已被记录的误报。', + 'av.whatAllowDoesTitle': '“在杀毒软件中放行”会做什么', + 'av.whatAllowDoesIntro': + '它会向 Windows 请求管理员权限(仅一次提示,点击“是”),然后运行 Windows 自带的', + 'av.whatAllowDoesIntroAfter': + '命令,向 Windows Defender 排除列表添加几个条目:', + 'av.exclusionFoldersBefore': '你的', + 'av.gameFolder': '游戏文件夹', + 'av.exclusionFoldersAnd': '以及', + 'av.launcherFolder': '启动器文件夹', + 'av.exclusionFoldersAfter': ',这样其中的 Mod 文件就不会再被扫描或隔离。', + 'av.exclusionExesAnd': '和', + 'av.exclusionExesAfter': '作为允许的进程,使它们在运行时不会被拦截。', + 'av.whatAllowDoesOutro': + '这与你在“Windows 安全中心 → 病毒和威胁防护 → 排除项”中手动操作的效果完全相同。它不安装任何东西,不运行任何第三方软件,也不会更改这些特定路径以外的任何内容。', + 'av.back': '返回', + 'addons.sectionInstalled': '已安装', + 'addons.sectionRecommended': '推荐', + 'addons.sectionAvailable': '可用', + 'addons.checkForUpdates': '检查更新', + 'addons.addCustomGitAddon': '添加自定义 git 插件', + 'addons.updateAll': '全部更新', + 'addons.everythingUpToDate': '一切均为最新。', + 'addons.installAddon': '安装插件', + 'addons.previewAlt': '预览', + 'addons.readyToInstall': '准备安装', + 'addons.invalidGitUrl': '不是有效的 git 仓库地址', + 'addons.install': '安装', + 'addons.warnIncorrectVersionFull': + '此插件似乎是为其他游戏版本({version})制作的,可能无法正常运行', + 'addons.warnIncorrectVersionShort': '版本不符', + 'addons.warnMissingDependenciesFull': '此插件缺少依赖项:{deps}', + 'addons.warnMissingDependenciesShort': '缺少依赖项', + 'addons.details': '详情', + 'addons.openOnGithub': '在 GitHub 上打开 {url}', + 'addons.upToDate': '已是最新', + 'addons.notVersioned': '无版本信息', + 'addons.update': '更新', + 'addons.download': '下载', + 'addons.deleteConfirmTitle': '确定吗?', + 'addons.deleteConfirmBody': '确定要删除 {folder} 插件吗?', + 'addons.deleteConfirmFiles': '这将删除该插件文件夹中的所有文件。', + 'addons.delete': '删除', + 'addons.remove': '移除', + 'addons.detailSource': '源代码', + 'addons.openOnGithubShort': '在 GitHub 上打开', + 'addons.detailContributions': '贡献者', + 'addons.detailAddonVersion': '插件版本', + 'addons.detailDependencies': '依赖项', + 'addons.optional': '(可选)', + 'prefs.mirrorOffline': '离线', + 'prefs.mirrorChecking': '检查中…', + 'prefs.mirrorOnline': '在线', + 'prefs.title': '设置', + 'prefs.installLocation': '安装位置:', + 'prefs.openFolder': '打开文件夹', + 'prefs.notSelected': '未选择', + 'prefs.change': '更改', + 'prefs.downloadMirror': '下载镜像:', + 'prefs.recheck': '重新检查', + 'prefs.troubleshooting': '故障排除:', + 'prefs.verifyGameFiles': '校验游戏文件', + 'prefs.openLogFile': '打开日志文件', + 'prefs.allowThroughAntivirus': '在杀毒软件中放行', + 'prefs.exclusionAdded': '已添加。如有文件被删除,请重新校验游戏文件。', + 'prefs.generalSettings': '常规设置:', + 'prefs.cleanWdb': '每次启动时清理 WDB', + 'prefs.minimizeToTray': '游戏时最小化到托盘', + 'prefs.save': '保存', + 'prefs.installLocationTitle': '安装位置', + 'prefs.portableInfo': + '你正在使用便携版启动器。安装位置由启动器可执行文件所在的位置决定。', + 'prefs.errorLabel': '错误:', + 'prefs.wowExeNotFound': + '在当前文件夹中未找到 {exe}。请关闭启动器并将其移动到你的 WoW 1.12 客户端目录。', + 'prefs.selectDirectory': '请选择游戏客户端的安装目录。', + 'prefs.upgradeExisting': + '你也可以选择一个已有 Turtle WoW 或 Vanilla WoW 安装的目录,它将被自动升级。', + 'prefs.installDirectory': '安装目录:', + 'prefs.confirm': '确认', + 'misc.newsTitle': '新闻', + 'misc.newsByAuthor': '作者:{author}', + 'misc.newsReadMore': '阅读更多', + 'misc.refresh': '刷新', + 'misc.newsLoading': '正在加载新闻...', + 'misc.newsError': '无法连接到新闻源。', + 'misc.newsEmpty': '暂无新闻,请稍后再来查看。', + 'misc.tryAgain': '重试', + 'misc.comingSoon': '敬请期待...', + 'misc.selfUpdateCheckFailed': '检查更新失败:{message}', + 'misc.selfUpdateAvailable': '启动器更新 {version} 可用,正在准备下载…', + 'misc.selfUpdateDownloading': '正在下载更新 {version} · {percent}%', + 'misc.selfUpdateReady': '启动器更新 {version} 已准备好安装', + 'misc.selfUpdateInstallNow': '立即安装', + 'misc.tabCrashed': '{tab} 已崩溃', + 'misc.somethingWentWrong': '出错了!', + 'misc.uncaughtError': '未捕获的 {name}:{message}', + 'misc.unknownError': '未知错误', + 'misc.copyError': '复制错误信息', + 'misc.reload': '重新加载' +}; + +const esES: Dict = { + 'tab.news': 'Noticias', + 'tab.tweaks': 'Ajustes', + 'tab.addons': 'Addons', + 'tab.mods': 'Mods', + 'topbar.settings': 'Configuración', + 'topbar.minimize': 'Minimizar', + 'topbar.quit': 'Salir', + 'topbar.language': 'Idioma', + 'quit.title': '¿Salir?', + 'quit.warn': + 'Tu juego se está actualizando. Salir ahora puede causar problemas.', + 'quit.return': 'Volver', + 'launch.verifying': 'Verificando', + 'launch.play': 'Jugar', + 'launch.retry': 'Reintentar', + 'launch.install': 'Instalar', + 'launch.update': 'Actualizar', + 'launch.updating': 'Actualizando', + 'launch.applying': 'Aplicando', + 'launch.updateAvailable': '¡Actualización disponible!', + 'launch.modsChanged': 'Mods cambiados, aplícalos antes de jugar', + 'launch.upToDate': '¡Todo está actualizado!', + 'launch.version': 'Versión: {version}', + 'launch.errorLabel': 'Error:', + 'launch.serverFail': 'No se pudo conectar al servidor de actualización', + 'launch.localVersion': 'Puedes iniciar la versión local {version}', + 'launch.tryLater': 'Inténtalo de nuevo más tarde', + 'launch.verifyHint': 'Verifica los datos del juego pulsando Reintentar.', + 'launch.remaining': 'restante', + 'launch.calculating': 'calculando…', + 'launch.onDisk': 'en disco', + 'tweaks.alwaysAutoLoot.label': 'Saqueo automático siempre', + 'tweaks.alwaysAutoLoot.text': + 'Invierte el comportamiento del saqueo automático para saquear siempre de forma automática y desactivar el saqueo automático con tecla asignada.', + 'tweaks.largeAddress.label': 'Large Address Aware', + 'tweaks.largeAddress.text': + 'Permite que el juego utilice más de 2 GB de memoria.', + 'tweaks.nameplateRange.label': 'Alcance de las placas de nombre', + 'tweaks.nameplateRange.text': + 'Aumenta la distancia a la que son visibles las placas de nombre. [Vanilla: 20] [Classic: 41]', + 'tweaks.cameraHeading': 'Cámara', + 'tweaks.fieldOfView.label': 'Campo de visión', + 'tweaks.fieldOfView.text': + 'Recomendado para resoluciones de ventana panorámicas. [Vanilla: 90] [Tweaks: 110]', + 'tweaks.farClip.label': 'Distancia de renderizado', + 'tweaks.farClip.text': + 'Aumenta la distancia máxima de renderizado. [Vanilla: 777] [Tweaks: 10000]', + 'tweaks.frillDistance.label': 'Distancia de la maleza', + 'tweaks.frillDistance.text': + 'Cambia la distancia de renderizado de la maleza del suelo. [Vanilla: 70] [Tweaks: 300]', + 'tweaks.cameraDistance.label': 'Distancia de la cámara', + 'tweaks.cameraDistance.text': + 'Aumenta la distancia máxima de la cámara (alejar el zoom). [Vanilla: 50] [Max:100]', + 'tweaks.soundsHeading': 'Sonidos', + 'tweaks.soundInBackground.label': 'Sonidos en segundo plano', + 'tweaks.soundInBackground.text': + 'Permite que el sonido del juego se reproduzca mientras está minimizado.', + 'tweaks.applyFailed': 'No se han podido aplicar los ajustes: {message}', + 'tweaks.highlighted': 'Destacado', + 'tweaks.recommendedNote': + 'opciones recomendadas y activadas de forma predeterminada', + 'tweaks.reset': 'Restablecer', + 'tweaks.apply': 'Aplicar', + 'mods.update': 'actualizar', + 'mods.ignoreUpdates': 'Ignorar actualizaciones', + 'mods.title': 'MODS PERSONALIZADOS', + 'mods.unsavedChanges': 'cambios sin guardar', + 'mods.warning': + 'Activar los mods personalizados puede no aportar ninguna mejora de rendimiento o incluso provocar cierres inesperados del juego según tu sistema. Prueba a desactivarlos si experimentas algún problema.', + 'mods.enableRequired': + 'Activa {mods}, necesarios para los mods seleccionados.', + 'mods.depRequired': + '{mod} debe estar activado. Es necesario para {requiredBy}.', + 'mods.highlighted': 'Destacado', + 'mods.highlightedRecommended': 'mods recomendados.', + 'mods.apply': 'Aplicar', + 'mods.cantApplyYet': 'AÚN NO SE PUEDE APLICAR', + 'mods.close': 'Cerrar', + 'av.blockedTitle': 'BLOQUEADO POR EL ANTIVIRUS', + 'av.blockedOne': 'Windows Defender ha bloqueado este mod:', + 'av.blockedMany': 'Windows Defender ha bloqueado estos mods:', + 'av.blockedExplain': + 'Son mods seguros de la comunidad que Defender marca por error. Permítelos una vez y vuelve a intentarlo.', + 'av.whyHappening': '¿Por qué ocurre esto?', + 'av.allowThrough': 'Permitir a través del antivirus', + 'av.addedRetry': 'Añadido. Cierra esta ventana y vuelve a intentarlo.', + 'av.close': 'Cerrar', + 'av.whyTitle': 'POR QUÉ EL ANTIVIRUS MARCA LOS MODS', + 'av.whyIntro': + 'Algunos de estos mods son marcados por Windows Defender (u otro antivirus) como una amenaza, por ejemplo «{detection}». Se trata de un', + 'av.falsePositive': 'falso positivo', + 'av.whatSetsItOff': 'Qué lo provoca', + 'av.whatSetsItOffIntro': + 'Estos mods modernizan el cliente del juego 1.12, de hace 20 años, mediante dos técnicas que resultan sospechosas para la heurística del antivirus:', + 'av.dllInjection': 'Inyección de DLL', + 'av.dllInjectionText': + 'los mods cargan código adicional en el juego en ejecución para añadir funciones (compensación de ping, mejores placas de nombre, rendimiento). Cargar código en otro programa es también algo que hace el malware, así que solo ese comportamiento ya activa el análisis.', + 'av.exePatching': 'Parcheo del ejecutable', + 'av.exePatchingText': + 'algunos mods editan WoW.exe para eliminar los límites del viejo motor (por ejemplo, permitir que el cliente de 32 bits use 4 GB de RAM en vez de 2 GB). Modificar un ejecutable es otro patrón propio del malware.', + 'av.heuristicNote': + 'Como son detecciones basadas en el comportamiento (heurísticas) y no coincidencias con malware real, los mods legítimos que las utilizan se detectan como falsos positivos genéricos de tipo «trojan» o «hacktool».', + 'av.whatModsDo': 'Qué hacen realmente los mods', + 'av.modVanillaFixes': + 'el cargador que corrige los tirones y los errores de temporización e inicia los demás mods.', + 'av.modNampower': + 'compensación de ping para que los lanzamientos respondan bien con latencias más altas.', + 'av.modSuperWowUnitXp': + 'Lua avanzado, placas de nombre reales y mejoras de cámara y selección de objetivos.', + 'av.modDxvk': + 'traduce el antiguo DirectX 9 al moderno Vulkan para obtener grandes mejoras de FPS.', + 'av.howToVerify': 'Cómo verificar estas afirmaciones', + 'av.howToVerifyText': + 'Son proyectos de código abierto de la comunidad. Puedes leer cada línea del código de cada mod desde el enlace al código fuente que aparece junto a él en la lista de Mods, y puedes buscar el nombre de la detección (por ejemplo «{detection}») para comprobar que es un falso positivo documentado en estas herramientas.', + 'av.whatAllowDoesTitle': 'Qué hace «Permitir a través del antivirus»', + 'av.whatAllowDoesIntro': + 'Pide permiso de administrador a Windows (un único aviso, haz clic en Sí) y luego ejecuta el propio comando integrado de Windows', + 'av.whatAllowDoesIntroAfter': + 'para añadir unas pocas entradas a la lista de exclusiones de Windows Defender:', + 'av.exclusionFoldersBefore': 'Tu', + 'av.gameFolder': 'carpeta del juego', + 'av.exclusionFoldersAnd': 'y la', + 'av.launcherFolder': 'carpeta del launcher', + 'av.exclusionFoldersAfter': + ', de modo que los archivos de los mods que contienen dejan de analizarse y de ponerse en cuarentena.', + 'av.exclusionExesAnd': 'y', + 'av.exclusionExesAfter': + 'como procesos permitidos, para que no se bloqueen durante su ejecución.', + 'av.whatAllowDoesOutro': + 'Es lo mismo que harías a mano en Seguridad de Windows, Protección antivirus y contra amenazas, Exclusiones. No instala nada, no ejecuta software de terceros y no cambia nada fuera de esas rutas concretas.', + 'av.back': 'Atrás', + 'addons.sectionInstalled': 'Instalados', + 'addons.sectionRecommended': 'Recomendados', + 'addons.sectionAvailable': 'Disponibles', + 'addons.checkForUpdates': 'Buscar actualizaciones', + 'addons.addCustomGitAddon': 'Añadir addon de git personalizado', + 'addons.updateAll': 'Actualizar todo', + 'addons.everythingUpToDate': 'Todo está actualizado.', + 'addons.installAddon': 'Instalar addon', + 'addons.previewAlt': 'Vista previa', + 'addons.readyToInstall': 'Listo para instalar', + 'addons.invalidGitUrl': 'No es una URL de repositorio git válida', + 'addons.install': 'Instalar', + 'addons.warnIncorrectVersionFull': + 'Este addon parece estar hecho para una versión distinta del juego ({version}) y puede que no funcione correctamente', + 'addons.warnIncorrectVersionShort': 'Versión incorrecta', + 'addons.warnMissingDependenciesFull': + 'A este addon le faltan dependencias: {deps}', + 'addons.warnMissingDependenciesShort': 'Faltan dependencias', + 'addons.details': 'Detalles', + 'addons.openOnGithub': 'Abrir {url} en GitHub', + 'addons.upToDate': 'Actualizado', + 'addons.notVersioned': 'Sin versión', + 'addons.update': 'Actualizar', + 'addons.download': 'Descargar', + 'addons.deleteConfirmTitle': '¿Estás seguro?', + 'addons.deleteConfirmBody': '¿Seguro que quieres eliminar el addon {folder}?', + 'addons.deleteConfirmFiles': + 'Esto eliminará todos los archivos de la carpeta del addon.', + 'addons.delete': 'Eliminar', + 'addons.remove': 'Quitar', + 'addons.detailSource': 'Código fuente', + 'addons.openOnGithubShort': 'Abrir en GitHub', + 'addons.detailContributions': 'Contribuciones', + 'addons.detailAddonVersion': 'Versión del addon', + 'addons.detailDependencies': 'Dependencias', + 'addons.optional': '(opcional)', + 'prefs.mirrorOffline': 'sin conexión', + 'prefs.mirrorChecking': 'comprobando…', + 'prefs.mirrorOnline': 'en línea', + 'prefs.title': 'AJUSTES', + 'prefs.installLocation': 'UBICACIÓN DE INSTALACIÓN:', + 'prefs.openFolder': 'Abrir carpeta', + 'prefs.notSelected': 'Sin seleccionar', + 'prefs.change': 'Cambiar', + 'prefs.downloadMirror': 'SERVIDOR DE DESCARGA:', + 'prefs.recheck': 'Volver a comprobar', + 'prefs.troubleshooting': 'SOLUCIÓN DE PROBLEMAS:', + 'prefs.verifyGameFiles': 'Verificar archivos del juego', + 'prefs.openLogFile': 'Abrir archivo de registro', + 'prefs.allowThroughAntivirus': 'Permitir a través del antivirus', + 'prefs.exclusionAdded': + 'Añadido. Vuelve a verificar los archivos del juego si se eliminó alguno.', + 'prefs.generalSettings': 'AJUSTES GENERALES:', + 'prefs.cleanWdb': 'Limpiar WDB en cada inicio', + 'prefs.minimizeToTray': 'Minimizar a la bandeja mientras juegas', + 'prefs.save': 'Guardar', + 'prefs.installLocationTitle': 'Ubicación de instalación', + 'prefs.portableInfo': + 'Estás usando la versión portátil del launcher. La ubicación de instalación viene determinada por la ubicación del ejecutable del launcher.', + 'prefs.errorLabel': 'Error: ', + 'prefs.wowExeNotFound': + 'No se ha encontrado {exe} en la carpeta actual. Cierra el launcher y muévelo al directorio de tu cliente de WoW 1.12.', + 'prefs.selectDirectory': + 'Selecciona un directorio para la instalación del cliente del juego.', + 'prefs.upgradeExisting': + 'También puedes elegir un directorio con una instalación existente de Turtle WoW o Vanilla WoW, y se actualizará automáticamente.', + 'prefs.installDirectory': 'Directorio de instalación:', + 'prefs.confirm': 'Confirmar', + 'misc.newsTitle': 'Noticias', + 'misc.newsByAuthor': 'por {author}', + 'misc.newsReadMore': 'Leer más', + 'misc.refresh': 'Actualizar', + 'misc.newsLoading': 'Cargando noticias...', + 'misc.newsError': 'No se ha podido acceder al feed de noticias.', + 'misc.newsEmpty': 'Aún no hay noticias. Vuelve más tarde.', + 'misc.tryAgain': 'Reintentar', + 'misc.comingSoon': 'Próximamente...', + 'misc.selfUpdateCheckFailed': 'Error al buscar actualizaciones: {message}', + 'misc.selfUpdateAvailable': + 'Actualización del launcher {version} disponible. Preparando la descarga…', + 'misc.selfUpdateDownloading': + 'Descargando la actualización {version} · {percent}%', + 'misc.selfUpdateReady': + 'Actualización del launcher {version} lista para instalar', + 'misc.selfUpdateInstallNow': 'Instalar ahora', + 'misc.tabCrashed': '{tab} ha fallado', + 'misc.somethingWentWrong': '¡Algo ha salido mal!', + 'misc.uncaughtError': '{name} no capturado: {message}', + 'misc.unknownError': 'Error desconocido', + 'misc.copyError': 'Copiar error', + 'misc.reload': 'Recargar' +}; + +const ptBR: Dict = { + 'tab.news': 'Notícias', + 'tab.tweaks': 'Ajustes', + 'tab.addons': 'Addons', + 'tab.mods': 'Mods', + 'topbar.settings': 'Configurações', + 'topbar.minimize': 'Minimizar', + 'topbar.quit': 'Sair', + 'topbar.language': 'Idioma', + 'quit.title': 'Sair?', + 'quit.warn': + 'Seu jogo está sendo atualizado. Sair agora pode causar problemas.', + 'quit.return': 'Voltar', + 'launch.verifying': 'Verificando', + 'launch.play': 'Jogar', + 'launch.retry': 'Tentar de novo', + 'launch.install': 'Instalar', + 'launch.update': 'Atualizar', + 'launch.updating': 'Atualizando', + 'launch.applying': 'Aplicando', + 'launch.updateAvailable': 'Atualização disponível!', + 'launch.modsChanged': 'Mods alterados, aplique antes de jogar', + 'launch.upToDate': 'Tudo atualizado!', + 'launch.version': 'Versão: {version}', + 'launch.errorLabel': 'Erro:', + 'launch.serverFail': 'Falha ao conectar ao servidor de atualização', + 'launch.localVersion': 'Você pode iniciar a versão local {version}', + 'launch.tryLater': 'Tente novamente mais tarde', + 'launch.verifyHint': 'Verifique os dados do jogo clicando em Tentar de novo.', + 'launch.remaining': 'restante', + 'launch.calculating': 'calculando…', + 'launch.onDisk': 'no disco', + 'tweaks.alwaysAutoLoot.label': 'Saque automático sempre ativo', + 'tweaks.alwaysAutoLoot.text': + 'Inverte o comportamento do saque automático para saquear sempre automaticamente e desativa o saque automático com a tecla atribuída.', + 'tweaks.largeAddress.label': 'Large Address Aware', + 'tweaks.largeAddress.text': 'Permite que o jogo use mais de 2GB de memória.', + 'tweaks.nameplateRange.label': 'Alcance das placas de nome', + 'tweaks.nameplateRange.text': + 'Aumenta a distância em que as placas de nome ficam visíveis. [Vanilla: 20] [Classic: 41]', + 'tweaks.cameraHeading': 'Câmera', + 'tweaks.fieldOfView.label': 'Campo de visão', + 'tweaks.fieldOfView.text': + 'Recomendado para resoluções de janela widescreen. [Vanilla: 90] [Tweaks: 110]', + 'tweaks.farClip.label': 'Distância de renderização', + 'tweaks.farClip.text': + 'Aumenta a distância máxima de renderização. [Vanilla: 777] [Tweaks: 10000]', + 'tweaks.frillDistance.label': 'Distância da vegetação do solo', + 'tweaks.frillDistance.text': + 'Altera a distância de renderização da vegetação do solo. [Vanilla: 70] [Tweaks: 300]', + 'tweaks.cameraDistance.label': 'Distância da câmera', + 'tweaks.cameraDistance.text': + 'Aumenta a distância máxima da câmera (zoom para fora). [Vanilla: 50] [Max:100]', + 'tweaks.soundsHeading': 'Sons', + 'tweaks.soundInBackground.label': 'Sons em segundo plano', + 'tweaks.soundInBackground.text': + 'Permite que os sons do jogo continuem tocando enquanto o jogo está minimizado.', + 'tweaks.applyFailed': 'Falha ao aplicar os ajustes: {message}', + 'tweaks.highlighted': 'Destacadas', + 'tweaks.recommendedNote': 'opções são recomendadas e ativadas por padrão', + 'tweaks.reset': 'Redefinir', + 'tweaks.apply': 'Aplicar', + 'mods.update': 'atualizar', + 'mods.ignoreUpdates': 'Ignorar atualizações', + 'mods.title': 'MODS PERSONALIZADOS', + 'mods.unsavedChanges': 'alterações não salvas', + 'mods.warning': + 'Ativar mods personalizados pode não trazer nenhum ganho de desempenho ou até causar travamentos no jogo, dependendo do seu sistema. Tente desativá-los caso enfrente algum problema.', + 'mods.enableRequired': + 'Ative {mods}, exigidos pelos mods que você selecionou.', + 'mods.depRequired': + '{mod} precisa estar ativado. Ele é exigido por {requiredBy}.', + 'mods.highlighted': 'Destacados', + 'mods.highlightedRecommended': 'mods são recomendados.', + 'mods.apply': 'Aplicar', + 'mods.cantApplyYet': 'AINDA NÃO É POSSÍVEL APLICAR', + 'mods.close': 'Fechar', + 'av.blockedTitle': 'BLOQUEADO PELO ANTIVÍRUS', + 'av.blockedOne': 'O Windows Defender bloqueou este mod:', + 'av.blockedMany': 'O Windows Defender bloqueou estes mods:', + 'av.blockedExplain': + 'Estes são mods seguros da comunidade que o Defender sinaliza por engano. Permita-os uma vez e tente novamente.', + 'av.whyHappening': 'Por que isso está acontecendo?', + 'av.allowThrough': 'Permitir no antivírus', + 'av.addedRetry': 'Adicionado. Feche isto e tente novamente.', + 'av.close': 'Fechar', + 'av.whyTitle': 'POR QUE O ANTIVÍRUS SINALIZA OS MODS', + 'av.whyIntro': + 'Alguns destes mods são sinalizados pelo Windows Defender (ou outro antivírus) como uma ameaça, por exemplo "{detection}". Isso é um', + 'av.falsePositive': 'falso positivo', + 'av.whatSetsItOff': 'O que dispara o alerta', + 'av.whatSetsItOffIntro': + 'Estes mods modernizam o cliente do jogo 1.12, que tem 20 anos, usando duas técnicas que parecem suspeitas para as heurísticas de antivírus:', + 'av.dllInjection': 'Injeção de DLL', + 'av.dllInjectionText': + 'os mods carregam código extra no jogo em execução para adicionar recursos (compensação de ping, melhores placas de nome, desempenho). Carregar código em outro programa também é algo que malwares fazem, então só esse comportamento já aciona o scanner.', + 'av.exePatching': 'Modificação de executável', + 'av.exePatchingText': + 'alguns mods editam o WoW.exe para remover limites antigos do motor (por exemplo, permitir que o cliente de 32 bits use 4GB de RAM em vez de 2GB). Modificar um executável é outro padrão típico de malware.', + 'av.heuristicNote': + 'Como são detecções baseadas em comportamento (heurísticas), e não correspondências com malware real, mods legítimos que usam essas técnicas acabam sendo pegos como falsos positivos genéricos de "trojan" ou "hacktool".', + 'av.whatModsDo': 'O que os mods realmente fazem', + 'av.modVanillaFixes': + 'o carregador que corrige travamentos e bugs de timer e inicia os outros mods.', + 'av.modNampower': + 'compensação de ping para que as conjurações fiquem responsivas em latências mais altas.', + 'av.modSuperWowUnitXp': + 'Lua avançado, placas de nome reais e melhorias de câmera e seleção de alvo.', + 'av.modDxvk': + 'traduz o antigo DirectX 9 para o moderno Vulkan, com grandes ganhos de FPS.', + 'av.howToVerify': 'Como verificar essas afirmações', + 'av.howToVerifyText': + 'Estes são projetos de código aberto da comunidade. Você pode ler cada linha do código de cada mod pelo link da fonte ao lado dele na lista de Mods, e pode pesquisar o nome da detecção (por exemplo "{detection}") para ver que é um falso positivo documentado nessas ferramentas.', + 'av.whatAllowDoesTitle': 'O que faz a opção "Permitir no antivírus"', + 'av.whatAllowDoesIntro': + 'Ela pede permissão de administrador ao Windows (um único aviso, clique em Sim) e então executa o próprio comando interno do Windows', + 'av.whatAllowDoesIntroAfter': + 'para adicionar algumas entradas à lista de exclusões do Windows Defender:', + 'av.exclusionFoldersBefore': 'Sua', + 'av.gameFolder': 'pasta do jogo', + 'av.exclusionFoldersAnd': 'e a', + 'av.launcherFolder': 'pasta do launcher', + 'av.exclusionFoldersAfter': + ', para que os arquivos de mod dentro delas não sejam mais escaneados nem colocados em quarentena.', + 'av.exclusionExesAnd': 'e', + 'av.exclusionExesAfter': + 'como processos permitidos, para que não sejam bloqueados durante a execução.', + 'av.whatAllowDoesOutro': + 'Isto é exatamente o que você faria manualmente em Segurança do Windows, Proteção contra vírus e ameaças, Exclusões. Não instala nada, não executa nenhum software de terceiros e não altera nada fora desses caminhos específicos.', + 'av.back': 'Voltar', + 'addons.sectionInstalled': 'Instalados', + 'addons.sectionRecommended': 'Recomendados', + 'addons.sectionAvailable': 'Disponíveis', + 'addons.checkForUpdates': 'Procurar atualizações', + 'addons.addCustomGitAddon': 'Adicionar addon git personalizado', + 'addons.updateAll': 'Atualizar todos', + 'addons.everythingUpToDate': 'Tudo está atualizado.', + 'addons.installAddon': 'Instalar addon', + 'addons.previewAlt': 'Prévia', + 'addons.readyToInstall': 'Pronto para instalar', + 'addons.invalidGitUrl': 'URL de repositório git inválida', + 'addons.install': 'Instalar', + 'addons.warnIncorrectVersionFull': + 'Este addon parece ter sido feito para outra versão do jogo ({version}) e pode não funcionar corretamente', + 'addons.warnIncorrectVersionShort': 'Versão incorreta', + 'addons.warnMissingDependenciesFull': + 'Este addon tem dependências ausentes: {deps}', + 'addons.warnMissingDependenciesShort': 'Dependências ausentes', + 'addons.details': 'Detalhes', + 'addons.openOnGithub': 'Abrir {url} no GitHub', + 'addons.upToDate': 'Atualizado', + 'addons.notVersioned': 'Sem versionamento', + 'addons.update': 'Atualizar', + 'addons.download': 'Baixar', + 'addons.deleteConfirmTitle': 'Tem certeza?', + 'addons.deleteConfirmBody': + 'Tem certeza de que deseja excluir o addon {folder}?', + 'addons.deleteConfirmFiles': + 'Isso excluirá todos os arquivos da pasta do addon.', + 'addons.delete': 'Excluir', + 'addons.remove': 'Remover', + 'addons.detailSource': 'Fonte', + 'addons.openOnGithubShort': 'Abrir no GitHub', + 'addons.detailContributions': 'Contribuições', + 'addons.detailAddonVersion': 'Versão do addon', + 'addons.detailDependencies': 'Dependências', + 'addons.optional': '(opcional)', + 'prefs.mirrorOffline': 'offline', + 'prefs.mirrorChecking': 'verificando…', + 'prefs.mirrorOnline': 'online', + 'prefs.title': 'CONFIGURAÇÕES', + 'prefs.installLocation': 'LOCAL DE INSTALAÇÃO:', + 'prefs.openFolder': 'Abrir pasta', + 'prefs.notSelected': 'Não selecionado', + 'prefs.change': 'Alterar', + 'prefs.downloadMirror': 'ESPELHO DE DOWNLOAD:', + 'prefs.recheck': 'Verificar novamente', + 'prefs.troubleshooting': 'SOLUÇÃO DE PROBLEMAS:', + 'prefs.verifyGameFiles': 'Verificar arquivos do jogo', + 'prefs.openLogFile': 'Abrir arquivo de log', + 'prefs.allowThroughAntivirus': 'Permitir no antivírus', + 'prefs.exclusionAdded': + 'Adicionado. Verifique os arquivos do jogo novamente se algum tiver sido removido.', + 'prefs.generalSettings': 'CONFIGURAÇÕES GERAIS:', + 'prefs.cleanWdb': 'Limpar WDB a cada inicialização', + 'prefs.minimizeToTray': 'Minimizar para a bandeja durante o jogo', + 'prefs.save': 'Salvar', + 'prefs.installLocationTitle': 'Local de instalação', + 'prefs.portableInfo': + 'Você está usando a versão portátil do launcher. O local de instalação é definido pela localização do executável do launcher.', + 'prefs.errorLabel': 'Erro: ', + 'prefs.wowExeNotFound': + '{exe} não encontrado na pasta atual. Feche o launcher e mova-o para o diretório do seu cliente do WoW 1.12.', + 'prefs.selectDirectory': + 'Selecione um diretório para a instalação do cliente do jogo.', + 'prefs.upgradeExisting': + 'Você também pode escolher um diretório com uma instalação existente do Turtle WoW ou Vanilla WoW, que será atualizada automaticamente.', + 'prefs.installDirectory': 'Diretório de instalação:', + 'prefs.confirm': 'Confirmar', + 'misc.newsTitle': 'Notícias', + 'misc.newsByAuthor': 'por {author}', + 'misc.newsReadMore': 'Ler mais', + 'misc.refresh': 'Atualizar', + 'misc.newsLoading': 'Carregando notícias...', + 'misc.newsError': 'Não foi possível acessar o feed de notícias.', + 'misc.newsEmpty': 'Ainda não há notícias — volte mais tarde.', + 'misc.tryAgain': 'Tentar novamente', + 'misc.comingSoon': 'Em breve...', + 'misc.selfUpdateCheckFailed': 'Falha ao verificar atualizações: {message}', + 'misc.selfUpdateAvailable': + 'Atualização {version} do launcher disponível — preparando o download…', + 'misc.selfUpdateDownloading': 'Baixando a atualização {version} · {percent}%', + 'misc.selfUpdateReady': + 'Atualização {version} do launcher pronta para instalar', + 'misc.selfUpdateInstallNow': 'Instalar agora', + 'misc.tabCrashed': '{tab} travou', + 'misc.somethingWentWrong': 'Algo deu errado!', + 'misc.uncaughtError': '{name} não tratado: {message}', + 'misc.unknownError': 'Erro desconhecido', + 'misc.copyError': 'Copiar erro', + 'misc.reload': 'Recarregar' +}; + +const ruRU: Dict = { + 'tab.news': 'Новости', + 'tab.tweaks': 'Твики', + 'tab.addons': 'Аддоны', + 'tab.mods': 'Моды', + 'topbar.settings': 'Настройки', + 'topbar.minimize': 'Свернуть', + 'topbar.quit': 'Выход', + 'topbar.language': 'Язык', + 'quit.title': 'Выйти?', + 'quit.warn': 'Игра обновляется. Выход сейчас может вызвать проблемы.', + 'quit.return': 'Назад', + 'launch.verifying': 'Проверка', + 'launch.play': 'Играть', + 'launch.retry': 'Повторить', + 'launch.install': 'Установить', + 'launch.update': 'Обновить', + 'launch.updating': 'Обновление', + 'launch.applying': 'Применение', + 'launch.updateAvailable': 'Доступно обновление!', + 'launch.modsChanged': 'Моды изменены, примените перед игрой', + 'launch.upToDate': 'Всё обновлено!', + 'launch.version': 'Версия: {version}', + 'launch.errorLabel': 'Ошибка:', + 'launch.serverFail': 'Не удалось подключиться к серверу обновлений', + 'launch.localVersion': 'Вы можете запустить локальную версию {version}', + 'launch.tryLater': 'Повторите попытку позже', + 'launch.verifyHint': 'Проверьте данные игры, нажав Повторить.', + 'launch.remaining': 'осталось', + 'launch.calculating': 'вычисление…', + 'launch.onDisk': 'на диске', + 'tweaks.alwaysAutoLoot.label': 'Всегда автосбор', + 'tweaks.alwaysAutoLoot.text': + 'Меняет поведение автосбора на противоположное: всегда автоматически собирать добычу, а ручной сбор включается зажатой клавишей.', + 'tweaks.largeAddress.label': 'Large Address Aware', + 'tweaks.largeAddress.text': 'Позволяет игре использовать более 2 ГБ памяти.', + 'tweaks.nameplateRange.label': 'Дальность индикаторов', + 'tweaks.nameplateRange.text': + 'Увеличивает расстояние, на котором видны индикаторы (nameplate). [Vanilla: 20] [Classic: 41]', + 'tweaks.cameraHeading': 'Камера', + 'tweaks.fieldOfView.label': 'Поле зрения', + 'tweaks.fieldOfView.text': + 'Рекомендуется для широкоэкранных разрешений. [Vanilla: 90] [Tweaks: 110]', + 'tweaks.farClip.label': 'Дальность прорисовки', + 'tweaks.farClip.text': + 'Увеличивает максимальную дальность прорисовки. [Vanilla: 777] [Tweaks: 10000]', + 'tweaks.frillDistance.label': 'Дальность травы и деталей', + 'tweaks.frillDistance.text': + 'Изменяет дальность прорисовки травы и мелких деталей земли. [Vanilla: 70] [Tweaks: 300]', + 'tweaks.cameraDistance.label': 'Дальность камеры', + 'tweaks.cameraDistance.text': + 'Увеличивает максимальное отдаление камеры. [Vanilla: 50] [Max:100]', + 'tweaks.soundsHeading': 'Звук', + 'tweaks.soundInBackground.label': 'Звук в фоне', + 'tweaks.soundInBackground.text': + 'Позволяет воспроизводить звуки игры, когда она свёрнута.', + 'tweaks.applyFailed': 'Не удалось применить твики: {message}', + 'tweaks.highlighted': 'Выделенные', + 'tweaks.recommendedNote': 'параметры рекомендуются и включены по умолчанию', + 'tweaks.reset': 'Сбросить', + 'tweaks.apply': 'Применить', + 'mods.update': 'обновление', + 'mods.ignoreUpdates': 'Игнорировать обновления', + 'mods.title': 'ПОЛЬЗОВАТЕЛЬСКИЕ МОДЫ', + 'mods.unsavedChanges': 'несохранённые изменения', + 'mods.warning': + 'Включение пользовательских модов может не дать прироста производительности или даже вызвать вылеты игры в зависимости от вашей системы. Если возникнут проблемы, попробуйте их отключить.', + 'mods.enableRequired': 'Включить {mods}, требуемые выбранными вами модами.', + 'mods.depRequired': + '{mod} должен быть включён. Он требуется для {requiredBy}.', + 'mods.highlighted': 'Выделенные', + 'mods.highlightedRecommended': 'моды рекомендуются.', + 'mods.apply': 'Применить', + 'mods.cantApplyYet': 'ПОКА НЕЛЬЗЯ ПРИМЕНИТЬ', + 'mods.close': 'Закрыть', + 'av.blockedTitle': 'ЗАБЛОКИРОВАНО АНТИВИРУСОМ', + 'av.blockedOne': 'Windows Defender заблокировал этот мод:', + 'av.blockedMany': 'Windows Defender заблокировал эти моды:', + 'av.blockedExplain': + 'Это безопасные моды от сообщества, которые Defender ошибочно помечает как угрозу. Разрешите их один раз и повторите попытку.', + 'av.whyHappening': 'Почему так происходит?', + 'av.allowThrough': 'Разрешить в антивирусе', + 'av.addedRetry': 'Добавлено. Закройте это окно и повторите попытку.', + 'av.close': 'Закрыть', + 'av.whyTitle': 'ПОЧЕМУ АНТИВИРУС ПОМЕЧАЕТ МОДЫ', + 'av.whyIntro': + 'Некоторые из этих модов помечаются Windows Defender (или другим антивирусом) как угроза, например «{detection}». Это', + 'av.falsePositive': 'ложное срабатывание', + 'av.whatSetsItOff': 'Что вызывает срабатывание', + 'av.whatSetsItOffIntro': + 'Эти моды модернизируют 20-летний клиент игры 1.12, используя два приёма, которые выглядят подозрительно для эвристик антивируса:', + 'av.dllInjection': 'Внедрение DLL', + 'av.dllInjectionText': + 'моды загружают дополнительный код в работающую игру, чтобы добавить функции (компенсацию пинга, улучшенные индикаторы, производительность). Внедрение кода в чужую программу — это то, что делают и вредоносные программы, поэтому одно лишь такое поведение вызывает срабатывание сканера.', + 'av.exePatching': 'Патчинг исполняемого файла', + 'av.exePatchingText': + 'некоторые моды изменяют WoW.exe, чтобы снять старые ограничения движка (например, позволить 32-битному клиенту использовать 4 ГБ ОЗУ вместо 2 ГБ). Изменение исполняемого файла — ещё один паттерн, характерный для вредоносных программ.', + 'av.heuristicNote': + 'Поскольку это срабатывания на основе поведения (эвристические), а не совпадения с реальными вредоносными программами, легитимные моды, использующие эти приёмы, попадают под ложные срабатывания как обобщённый «троян» или «hacktool».', + 'av.whatModsDo': 'Что моды делают на самом деле', + 'av.modVanillaFixes': + 'загрузчик, который исправляет подёргивания и ошибки таймеров и запускает остальные моды.', + 'av.modNampower': + 'компенсация пинга, чтобы заклинания ощущались отзывчиво при высокой задержке.', + 'av.modSuperWowUnitXp': + 'расширенный Lua, настоящие индикаторы, улучшения камеры и прицеливания.', + 'av.modDxvk': + 'переводит старый DirectX 9 в современный Vulkan для значительного прироста FPS.', + 'av.howToVerify': 'Как проверить эти утверждения', + 'av.howToVerifyText': + 'Это проекты сообщества с открытым исходным кодом. Вы можете прочитать каждую строку кода любого мода по ссылке на исходники рядом с ним в списке модов, а также найти название обнаружения (например, «{detection}») и убедиться, что это задокументированное ложное срабатывание на этих инструментах.', + 'av.whatAllowDoesTitle': 'Что делает «Разрешить в антивирусе»', + 'av.whatAllowDoesIntro': + 'Эта функция запрашивает у Windows права администратора (одно окно, нажмите «Да»), затем запускает встроенную в Windows', + 'av.whatAllowDoesIntroAfter': + 'команду, чтобы добавить несколько записей в список исключений Windows Defender:', + 'av.exclusionFoldersBefore': 'Вашу', + 'av.gameFolder': 'папку игры', + 'av.exclusionFoldersAnd': 'и', + 'av.launcherFolder': 'папку лаунчера', + 'av.exclusionFoldersAfter': + ', чтобы файлы модов в них больше не сканировались и не помещались в карантин.', + 'av.exclusionExesAnd': 'и', + 'av.exclusionExesAfter': + 'как разрешённые процессы, чтобы они не блокировались во время работы.', + 'av.whatAllowDoesOutro': + 'Это то же самое, что вы сделали бы вручную в разделе «Безопасность Windows» → «Защита от вирусов и угроз» → «Исключения». Она ничего не устанавливает, не запускает стороннее ПО и не меняет ничего за пределами этих конкретных путей.', + 'av.back': 'Назад', + 'addons.sectionInstalled': 'Установленные', + 'addons.sectionRecommended': 'Рекомендуемые', + 'addons.sectionAvailable': 'Доступные', + 'addons.checkForUpdates': 'Проверить обновления', + 'addons.addCustomGitAddon': 'Добавить аддон из git', + 'addons.updateAll': 'Обновить все', + 'addons.everythingUpToDate': 'Всё актуально.', + 'addons.installAddon': 'Установить аддон', + 'addons.previewAlt': 'Превью', + 'addons.readyToInstall': 'Готов к установке', + 'addons.invalidGitUrl': 'Недействительный URL git-репозитория', + 'addons.install': 'Установить', + 'addons.warnIncorrectVersionFull': + 'Похоже, этот аддон сделан для другой версии игры ({version}), и он может работать некорректно', + 'addons.warnIncorrectVersionShort': 'Неверная версия', + 'addons.warnMissingDependenciesFull': + 'У этого аддона отсутствуют зависимости: {deps}', + 'addons.warnMissingDependenciesShort': 'Отсутствуют зависимости', + 'addons.details': 'Подробнее', + 'addons.openOnGithub': 'Открыть {url} на GitHub', + 'addons.upToDate': 'Актуально', + 'addons.notVersioned': 'Без версии', + 'addons.update': 'Обновить', + 'addons.download': 'Скачать', + 'addons.deleteConfirmTitle': 'Вы уверены?', + 'addons.deleteConfirmBody': 'Вы уверены, что хотите удалить аддон {folder}?', + 'addons.deleteConfirmFiles': 'Это удалит все файлы в папке аддона.', + 'addons.delete': 'Удалить', + 'addons.remove': 'Убрать', + 'addons.detailSource': 'Исходник', + 'addons.openOnGithubShort': 'Открыть на GitHub', + 'addons.detailContributions': 'Вклад', + 'addons.detailAddonVersion': 'Версия аддона', + 'addons.detailDependencies': 'Зависимости', + 'addons.optional': '(необязательно)', + 'prefs.mirrorOffline': 'недоступно', + 'prefs.mirrorChecking': 'проверка…', + 'prefs.mirrorOnline': 'в сети', + 'prefs.title': 'НАСТРОЙКИ', + 'prefs.installLocation': 'ПАПКА УСТАНОВКИ:', + 'prefs.openFolder': 'Открыть папку', + 'prefs.notSelected': 'Не выбрано', + 'prefs.change': 'Изменить', + 'prefs.downloadMirror': 'ЗЕРКАЛО ЗАГРУЗКИ:', + 'prefs.recheck': 'Перепроверить', + 'prefs.troubleshooting': 'УСТРАНЕНИЕ НЕПОЛАДОК:', + 'prefs.verifyGameFiles': 'Проверить файлы игры', + 'prefs.openLogFile': 'Открыть файл журнала', + 'prefs.allowThroughAntivirus': 'Разрешить в антивирусе', + 'prefs.exclusionAdded': + 'Добавлено. Перепроверьте файлы игры, если какие-то из них были удалены.', + 'prefs.generalSettings': 'ОБЩИЕ НАСТРОЙКИ:', + 'prefs.cleanWdb': 'Очищать WDB при каждом запуске', + 'prefs.minimizeToTray': 'Сворачивать в трей во время игры', + 'prefs.save': 'Сохранить', + 'prefs.installLocationTitle': 'Папка установки', + 'prefs.portableInfo': + 'Вы используете портативную версию лаунчера. Папка установки определяется расположением исполняемого файла лаунчера.', + 'prefs.errorLabel': 'Ошибка: ', + 'prefs.wowExeNotFound': + '{exe} не найден в текущей папке. Закройте лаунчер и переместите его в папку с клиентом WoW 1.12.', + 'prefs.selectDirectory': 'Выберите папку для установки клиента игры.', + 'prefs.upgradeExisting': + 'Вы также можете выбрать папку с уже установленным Turtle WoW или Vanilla WoW, и она будет автоматически обновлена.', + 'prefs.installDirectory': 'Папка установки:', + 'prefs.confirm': 'Подтвердить', + 'misc.newsTitle': 'Новости', + 'misc.newsByAuthor': 'от {author}', + 'misc.newsReadMore': 'Читать далее', + 'misc.refresh': 'Обновить', + 'misc.newsLoading': 'Загрузка новостей...', + 'misc.newsError': 'Не удалось получить ленту новостей.', + 'misc.newsEmpty': 'Новостей пока нет — загляните позже.', + 'misc.tryAgain': 'Повторить', + 'misc.comingSoon': 'Скоро...', + 'misc.selfUpdateCheckFailed': 'Не удалось проверить обновления: {message}', + 'misc.selfUpdateAvailable': + 'Доступно обновление лаунчера {version} — подготовка загрузки…', + 'misc.selfUpdateDownloading': 'Загрузка обновления {version} · {percent}%', + 'misc.selfUpdateReady': 'Обновление лаунчера {version} готово к установке', + 'misc.selfUpdateInstallNow': 'Установить сейчас', + 'misc.tabCrashed': 'Вкладка {tab} аварийно завершилась', + 'misc.somethingWentWrong': 'Что-то пошло не так!', + 'misc.uncaughtError': 'Необработанная ошибка {name}: {message}', + 'misc.unknownError': 'Неизвестная ошибка', + 'misc.copyError': 'Скопировать ошибку', + 'misc.reload': 'Перезагрузить' +}; + +export const translations: Record = { + enUS, + deDE, + zhCN, + esES, + ptBR, + ruRU +}; diff --git a/src/renderer/index.css b/src/renderer/index.css index e75f7f1..084e476 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -25,6 +25,16 @@ body { height: 100vh; } +body { + -webkit-user-select: text; + user-select: text; +} + +::selection { + background: rgb(248 156 66 / 0.45); + color: #fff; +} + #root { position: relative; width: 100%; diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index d6197a2..e627ff9 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -11,6 +11,7 @@ import log from 'electron-log/renderer'; import { api } from './utils/api'; import App from './App'; import ErrorBoundary from './ErrorBoundary'; +import { LocaleProvider } from './i18n'; import './index.css'; @@ -56,7 +57,9 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - + + + {import.meta.env.DEV && } diff --git a/src/renderer/utils/usePreventDefaultEvents.ts b/src/renderer/utils/usePreventDefaultEvents.ts index 43e743f..cab6888 100644 --- a/src/renderer/utils/usePreventDefaultEvents.ts +++ b/src/renderer/utils/usePreventDefaultEvents.ts @@ -2,29 +2,24 @@ import { useEffect } from 'react'; const allowedElements = ['INPUT', 'TEXTAREA']; +const isClipboardShortcut = (e: KeyboardEvent) => + (e.ctrlKey || e.metaKey) && + ['a', 'c', 'v', 'x'].includes(e.key.toLowerCase()); + const usePreventDefaultEvents = () => { useEffect(() => { const disableKeyboardEvents = (e: KeyboardEvent) => { if (allowedElements.includes((e.target as HTMLElement).tagName)) return; - e.preventDefault(); - }; - - const disableFocus = (e: FocusEvent) => { - if (allowedElements.includes((e.target as HTMLElement).tagName)) return; - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } + if (isClipboardShortcut(e)) return; e.preventDefault(); }; window.addEventListener('keydown', disableKeyboardEvents, true); window.addEventListener('keyup', disableKeyboardEvents, true); - window.addEventListener('focusin', disableFocus, true); return () => { window.removeEventListener('keydown', disableKeyboardEvents, true); window.removeEventListener('keyup', disableKeyboardEvents, true); - window.removeEventListener('focusin', disableFocus, true); }; }, []); };