forked from OctoWoW/OctoLauncher
Fixed tweaks and mods, added localization, added antivirus walkthrough
This commit is contained in:
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "octo-launcher",
|
"name": "octo-launcher",
|
||||||
"version": "1.0.27",
|
"version": "1.1.1",
|
||||||
"description": "An Electron application for launching and updating the OctoWoW client",
|
"description": "An Electron application for launching and updating the OctoWoW client",
|
||||||
"author": "OctoWoW",
|
"author": "OctoWoW",
|
||||||
"copyright": "Copyright © 2026 OctoWoW",
|
"copyright": "Copyright © 2026 OctoWoW",
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"dev": "electron-vite dev",
|
"dev": "electron-vite dev",
|
||||||
"server": "cd server && npm run 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": "electron-vite build",
|
||||||
"build:test": "electron-vite build --mode test",
|
"build:test": "electron-vite build --mode test",
|
||||||
"pack": "electron-builder --config",
|
"pack": "electron-builder --config",
|
||||||
|
|||||||
@@ -107,9 +107,12 @@ const giteaProvider: Provider = {
|
|||||||
stars: j.stars_count ?? undefined
|
stars: j.stars_count ?? undefined
|
||||||
}),
|
}),
|
||||||
tocUrl: (o, r, ref, name) =>
|
tocUrl: (o, r, ref, name) =>
|
||||||
`${GITEA_API}/repos/${o}/${r}/raw/${name}.toc?ref=${encodeURIComponent(ref)}`
|
`${GITEA_API}/repos/${o}/${r}/raw/${name}.toc?ref=${encodeURIComponent(
|
||||||
|
ref
|
||||||
|
)}`
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// only allow known git hosts over https
|
||||||
const parseGitUrl = (
|
const parseGitUrl = (
|
||||||
git: string
|
git: string
|
||||||
): { owner: string; repo: string; provider: Provider } => {
|
): { owner: string; repo: string; provider: Provider } => {
|
||||||
@@ -158,8 +161,14 @@ const resolveOne = async (src: AddonSource): Promise<ResolvedAddon | null> => {
|
|||||||
const candidates = src.ref
|
const candidates = src.ref
|
||||||
? [src.ref]
|
? [src.ref]
|
||||||
: src.branch
|
: src.branch
|
||||||
? [src.branch]
|
? [src.branch]
|
||||||
: [...new Set([meta?.defaultBranch, 'master', 'main'].filter((b): b is string => !!b))];
|
: [
|
||||||
|
...new Set(
|
||||||
|
[meta?.defaultBranch, 'main', 'master'].filter(
|
||||||
|
(b): b is string => !!b
|
||||||
|
)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
let toc: TocData | undefined;
|
let toc: TocData | undefined;
|
||||||
let resolvedRef: string | undefined;
|
let resolvedRef: string | undefined;
|
||||||
@@ -222,26 +231,37 @@ const loadSources = async (): Promise<AddonSource[]> => {
|
|||||||
if (!SOURCES_OVERRIDE_PATH) return defaultSources;
|
if (!SOURCES_OVERRIDE_PATH) return defaultSources;
|
||||||
try {
|
try {
|
||||||
if (await fs.pathExists(SOURCES_OVERRIDE_PATH)) {
|
if (await fs.pathExists(SOURCES_OVERRIDE_PATH)) {
|
||||||
const override = (await fs.readJSON(SOURCES_OVERRIDE_PATH)) as AddonSource[];
|
const override = (await fs.readJSON(
|
||||||
|
SOURCES_OVERRIDE_PATH
|
||||||
|
)) as AddonSource[];
|
||||||
if (Array.isArray(override) && override.length > 0) {
|
if (Array.isArray(override) && override.length > 0) {
|
||||||
console.log(`Using addon sources override from ${SOURCES_OVERRIDE_PATH}`);
|
console.log(
|
||||||
|
`Using addon sources override from ${SOURCES_OVERRIDE_PATH}`
|
||||||
|
);
|
||||||
return override;
|
return override;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Failed to read override at ${SOURCES_OVERRIDE_PATH}, using defaults:`, e);
|
console.error(
|
||||||
|
`Failed to read override at ${SOURCES_OVERRIDE_PATH}, using defaults:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return defaultSources;
|
return defaultSources;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildList = async (): Promise<ResolvedAddon[]> => {
|
const buildList = async (): Promise<ResolvedAddon[]> => {
|
||||||
const sources = await loadSources();
|
const sources = await loadSources();
|
||||||
console.log(`Resolving metadata for ${sources.length} addons (concurrency=${FETCH_CONCURRENCY})...`);
|
console.log(
|
||||||
|
`Resolving metadata for ${sources.length} addons (concurrency=${FETCH_CONCURRENCY})...`
|
||||||
|
);
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const results = await poolMap(sources, FETCH_CONCURRENCY, resolveOne);
|
const results = await poolMap(sources, FETCH_CONCURRENCY, resolveOne);
|
||||||
const ok = results.filter((r): r is ResolvedAddon => r !== null);
|
const ok = results.filter((r): r is ResolvedAddon => r !== null);
|
||||||
ok.sort((a, b) => a.name.localeCompare(b.name));
|
ok.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
console.log(`Resolved ${ok.length}/${sources.length} addons in ${Date.now() - t0}ms`);
|
console.log(
|
||||||
|
`Resolved ${ok.length}/${sources.length} addons in ${Date.now() - t0}ms`
|
||||||
|
);
|
||||||
return ok;
|
return ok;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const defaultSources: AddonSource[] = [
|
|||||||
description: 'Automated "Looking For More" broadcaster for Turtle WoW dungeons and raids'
|
description: 'Automated "Looking For More" broadcaster for Turtle WoW dungeons and raids'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
git: 'https://github.com/OldManAlpha/aux-addon.git',
|
git: 'https://github.com/shirsig/aux-addon-vanilla.git',
|
||||||
name: 'aux-addon',
|
name: 'aux-addon',
|
||||||
description: 'Auction House replacement with advanced filtering and search'
|
description: 'Auction House replacement with advanced filtering and search'
|
||||||
},
|
},
|
||||||
@@ -48,7 +48,7 @@ export const defaultSources: AddonSource[] = [
|
|||||||
git: 'https://github.com/MDGitHubRepo/CallOfElements.git',
|
git: 'https://github.com/MDGitHubRepo/CallOfElements.git',
|
||||||
description: 'All-in-one Shaman totem bar and totem/healing manager'
|
description: 'All-in-one Shaman totem bar and totem/healing manager'
|
||||||
},
|
},
|
||||||
{ git: 'https://github.com/cutiepoka/CleveRoidMacros.git' },
|
{ git: 'https://github.com/bhhandley/CleveRoidMacros.git' },
|
||||||
{
|
{
|
||||||
git: 'https://github.com/Cinecom/ConsumesManager.git',
|
git: 'https://github.com/Cinecom/ConsumesManager.git',
|
||||||
description: 'Tracks consumables and food buffs across alts, bank, and mail'
|
description: 'Tracks consumables and food buffs across alts, bank, and mail'
|
||||||
@@ -66,7 +66,7 @@ export const defaultSources: AddonSource[] = [
|
|||||||
git: 'https://github.com/DeterminedPanda/DifficultBulletinBoard.git',
|
git: 'https://github.com/DeterminedPanda/DifficultBulletinBoard.git',
|
||||||
description: 'Organizes LFG, profession, and hardcore chat announcements into a bulletin board'
|
description: 'Organizes LFG, profession, and hardcore chat announcements into a bulletin board'
|
||||||
},
|
},
|
||||||
{ git: 'https://github.com/pepopo978/DoiteAuras.git' },
|
{ git: 'https://github.com/Player-Doite/DoiteAuras.git' },
|
||||||
{ git: 'https://github.com/Stormhand-dev/DragonflightUI-Reforged.git' },
|
{ git: 'https://github.com/Stormhand-dev/DragonflightUI-Reforged.git' },
|
||||||
{
|
{
|
||||||
git: 'https://github.com/Fiurs-Hearth/ExtraResourceBars.git',
|
git: 'https://github.com/Fiurs-Hearth/ExtraResourceBars.git',
|
||||||
@@ -98,14 +98,14 @@ export const defaultSources: AddonSource[] = [
|
|||||||
description: 'Item set manager with quick-swap menus for inventory'
|
description: 'Item set manager with quick-swap menus for inventory'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
git: 'https://github.com/Otari98/_LazyPig.git',
|
git: 'https://github.com/CosminPOP/_LazyPig.git',
|
||||||
name: '_LazyPig',
|
name: '_LazyPig',
|
||||||
description: 'Auto-dismount, auto-accept, auto-roll, and chat spam filter. /lp to configure'
|
description: 'Auto-dismount, auto-accept, auto-roll, and chat spam filter. /lp to configure'
|
||||||
},
|
},
|
||||||
{ git: 'https://github.com/Dusk-92/LevelRange-Octo.git' },
|
{ git: 'https://github.com/Spartelfant/LevelRange-Turtle.git' },
|
||||||
{ git: 'https://github.com/tilare/MessageBox.git' },
|
{ git: 'https://github.com/tilare/MessageBox.git' },
|
||||||
{
|
{
|
||||||
git: 'https://github.com/MarcelineVQ/ModifiedPowerAuras.git',
|
git: 'https://github.com/tdymel/ModifiedPowerAuras.git',
|
||||||
description: "Advanced version of Sinesther's Power Auras"
|
description: "Advanced version of Sinesther's Power Auras"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -118,7 +118,7 @@ export const defaultSources: AddonSource[] = [
|
|||||||
},
|
},
|
||||||
{ git: 'https://github.com/tilare/MovementTracker.git' },
|
{ git: 'https://github.com/tilare/MovementTracker.git' },
|
||||||
{
|
{
|
||||||
git: 'https://github.com/Emyrk/NampowerSettings.git',
|
git: 'https://github.com/Dusk-92/NampowerSettings.git',
|
||||||
description: 'Settings panel for the Nampower spellqueue addon'
|
description: 'Settings panel for the Nampower spellqueue addon'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -140,7 +140,7 @@ export const defaultSources: AddonSource[] = [
|
|||||||
description: 'Equipment set manager to save and quickly swap gear outfits, with Turtle mount fixes'
|
description: 'Equipment set manager to save and quickly swap gear outfits, with Turtle mount fixes'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
git: 'https://github.com/ShikawaLePaladin/PallyPowerTW.git',
|
git: 'https://github.com/CosminPOP/PallyPower.git',
|
||||||
description: 'Paladin buff and assignment manager for raids and parties'
|
description: 'Paladin buff and assignment manager for raids and parties'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -148,7 +148,7 @@ export const defaultSources: AddonSource[] = [
|
|||||||
description: 'pfQuest extension showing all monster drops and quest chains. /pfex'
|
description: 'pfQuest extension showing all monster drops and quest chains. /pfex'
|
||||||
},
|
},
|
||||||
{ git: 'https://github.com/shagu/pfQuest.git' },
|
{ git: 'https://github.com/shagu/pfQuest.git' },
|
||||||
{ git: 'https://github.com/KameleonUK/pfQuest-turtle.git' },
|
{ git: 'https://github.com/shagu/pfQuest-turtle.git' },
|
||||||
{ git: 'https://github.com/shagu/pfUI.git' },
|
{ git: 'https://github.com/shagu/pfUI.git' },
|
||||||
{
|
{
|
||||||
git: 'https://github.com/jrc13245/pfUI-addonskinner.git',
|
git: 'https://github.com/jrc13245/pfUI-addonskinner.git',
|
||||||
@@ -170,7 +170,7 @@ export const defaultSources: AddonSource[] = [
|
|||||||
},
|
},
|
||||||
{ git: 'https://github.com/SabineWren/Quiver.git' },
|
{ git: 'https://github.com/SabineWren/Quiver.git' },
|
||||||
{
|
{
|
||||||
git: 'https://github.com/thezephyrsong/Rested.git',
|
git: 'https://github.com/hazlema/Rested.git',
|
||||||
description: 'Progress bar showing your rested XP while resting'
|
description: 'Progress bar showing your rested XP while resting'
|
||||||
},
|
},
|
||||||
{ git: 'https://github.com/Otari98/Rinse.git' },
|
{ git: 'https://github.com/Otari98/Rinse.git' },
|
||||||
@@ -183,9 +183,9 @@ export const defaultSources: AddonSource[] = [
|
|||||||
git: 'https://github.com/shagu/ShaguPlates.git',
|
git: 'https://github.com/shagu/ShaguPlates.git',
|
||||||
description: 'Nameplates with castbars and class colors. /splates'
|
description: 'Nameplates with castbars and class colors. /splates'
|
||||||
},
|
},
|
||||||
{ git: 'https://github.com/paokkerkir/ShaguTweaks.git' },
|
{ git: 'https://github.com/shagu/ShaguTweaks.git' },
|
||||||
{
|
{
|
||||||
git: 'https://github.com/paokkerkir/ShaguTweaks-extras.git',
|
git: 'https://github.com/shagu/ShaguTweaks-extras.git',
|
||||||
description: 'Extras module for ShaguTweaks (additional UI tweaks)'
|
description: 'Extras module for ShaguTweaks (additional UI tweaks)'
|
||||||
},
|
},
|
||||||
{ git: 'https://github.com/pepopo978/SimpleActionSets.git' },
|
{ git: 'https://github.com/pepopo978/SimpleActionSets.git' },
|
||||||
|
|||||||
+13
-13
@@ -25,8 +25,7 @@ const skipFiles = new Set([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const skipPatterns: RegExp[] = [/\.bak(\.|$)/, /\.crashing(\.|$)/];
|
const skipPatterns: RegExp[] = [/\.bak(\.|$)/, /\.crashing(\.|$)/];
|
||||||
const isSkipPattern = (file: string) =>
|
const isSkipPattern = (file: string) => skipPatterns.some(p => p.test(file));
|
||||||
skipPatterns.some(p => p.test(file));
|
|
||||||
|
|
||||||
const skipDirsPosix = new Set([
|
const skipDirsPosix = new Set([
|
||||||
'Interface/GlueXML',
|
'Interface/GlueXML',
|
||||||
@@ -66,7 +65,9 @@ export type BuildProgress = {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProgressCallback = (p: Pick<BuildProgress, 'done' | 'total' | 'currentFile'>) => void;
|
export type ProgressCallback = (
|
||||||
|
p: Pick<BuildProgress, 'done' | 'total' | 'currentFile'>
|
||||||
|
) => void;
|
||||||
|
|
||||||
const getHash = (...filePath: string[]): Promise<string> =>
|
const getHash = (...filePath: string[]): Promise<string> =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
@@ -77,7 +78,10 @@ const getHash = (...filePath: string[]): Promise<string> =>
|
|||||||
stream.on('end', () => resolve(hash.digest('hex').toLocaleUpperCase()));
|
stream.on('end', () => resolve(hash.digest('hex').toLocaleUpperCase()));
|
||||||
});
|
});
|
||||||
|
|
||||||
const countFiles = async (clientPath: string, ...filePath: string[]): Promise<number> => {
|
const countFiles = async (
|
||||||
|
clientPath: string,
|
||||||
|
...filePath: string[]
|
||||||
|
): Promise<number> => {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
const dir = path.join(clientPath, ...filePath);
|
const dir = path.join(clientPath, ...filePath);
|
||||||
const files = await fs.readdir(dir);
|
const files = await fs.readdir(dir);
|
||||||
@@ -118,8 +122,9 @@ export const buildCache = async (
|
|||||||
if (node.type === 'dir' || node.type === 'mpq') {
|
if (node.type === 'dir' || node.type === 'mpq') {
|
||||||
const newPrefix = node.name ? [...prefix, node.name] : prefix;
|
const newPrefix = node.name ? [...prefix, node.name] : prefix;
|
||||||
if (node.type === 'mpq') {
|
if (node.type === 'mpq') {
|
||||||
const mpqKey = [...newPrefix.slice(0, -1), node.name + '.mpq']
|
const mpqKey = [...newPrefix.slice(0, -1), node.name + '.mpq'].join(
|
||||||
.join('/');
|
'/'
|
||||||
|
);
|
||||||
prevHashByPath.set(mpqKey, node.hash);
|
prevHashByPath.set(mpqKey, node.hash);
|
||||||
prevSizeByPath.set(mpqKey, node.size);
|
prevSizeByPath.set(mpqKey, node.size);
|
||||||
}
|
}
|
||||||
@@ -237,12 +242,7 @@ export const buildCache = async (
|
|||||||
tree.push({
|
tree.push({
|
||||||
type: 'file',
|
type: 'file',
|
||||||
name: file,
|
name: file,
|
||||||
hash: await getHashCached(
|
hash: await getHashCached(fullPath, stats.mtimeMs, ...filePath, file),
|
||||||
fullPath,
|
|
||||||
stats.mtimeMs,
|
|
||||||
...filePath,
|
|
||||||
file
|
|
||||||
),
|
|
||||||
version: allowModified ? stats.mtimeMs : undefined,
|
version: allowModified ? stats.mtimeMs : undefined,
|
||||||
size: stats.size,
|
size: stats.size,
|
||||||
tags: tags.length ? tags : undefined
|
tags: tags.length ? tags : undefined
|
||||||
@@ -307,7 +307,7 @@ export const buildCache = async (
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`manifest-overrides: failed to apply ${overridesPath} -- continuing ` +
|
`manifest-overrides: failed to apply ${overridesPath}, continuing` +
|
||||||
`without overrides (${(e as Error).message})`
|
`without overrides (${(e as Error).message})`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-8
@@ -5,7 +5,7 @@ import fs from 'fs-extra';
|
|||||||
import { buildCache, type BuildProgress } from './cache.js';
|
import { buildCache, type BuildProgress } from './cache.js';
|
||||||
import { getAddons, warmUp as warmUpAddons } from './addons-resolver.js';
|
import { getAddons, warmUp as warmUpAddons } from './addons-resolver.js';
|
||||||
|
|
||||||
const SourceDir = process.env.SOURCE_DIR || 'C:\\WoW\\TurtleFresh';
|
const SourceDir = process.env.SOURCE_DIR || './client';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = 5000;
|
const port = 5000;
|
||||||
@@ -62,8 +62,7 @@ app.get('/api/file/:version/manifest.json', async (_req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ensureManifestBuilt().catch(() => {
|
void ensureManifestBuilt().catch(() => {});
|
||||||
});
|
|
||||||
res.setHeader('Retry-After', '5');
|
res.setHeader('Retry-After', '5');
|
||||||
res.status(503).json({
|
res.status(503).json({
|
||||||
error: 'manifest_building',
|
error: 'manifest_building',
|
||||||
@@ -80,7 +79,15 @@ app.get(
|
|||||||
const filePath = req.params[0];
|
const filePath = req.params[0];
|
||||||
console.log(`Fetching file: ${filePath}`);
|
console.log(`Fetching file: ${filePath}`);
|
||||||
|
|
||||||
res.sendFile(path.join(SourceDir, filePath));
|
// 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);
|
const newest = await newestSourceMtime(SourceDir);
|
||||||
if (newest > manifestStat.mtimeMs) {
|
if (newest > manifestStat.mtimeMs) {
|
||||||
console.log(
|
console.log(
|
||||||
`Manifest is stale (newest source mtime ${new Date(newest).toISOString()} > manifest ${new Date(manifestStat.mtimeMs).toISOString()}); rebuilding in background.`
|
`Manifest is stale (newest source mtime ${new Date(
|
||||||
|
newest
|
||||||
|
).toISOString()} > manifest ${new Date(
|
||||||
|
manifestStat.mtimeMs
|
||||||
|
).toISOString()}); rebuilding in background.`
|
||||||
);
|
);
|
||||||
ensureManifestBuilt()
|
ensureManifestBuilt()
|
||||||
.then(() => console.log('Background manifest rebuild complete.'))
|
.then(() => console.log('Background manifest rebuild complete.'))
|
||||||
.catch(e =>
|
.catch(e => console.error('Background manifest rebuild failed:', e));
|
||||||
console.error('Background manifest rebuild failed:', e)
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
console.log(
|
console.log(
|
||||||
`Manifest cache already on disk at ${manifestPath} and up to date; no rebuild needed.`
|
`Manifest cache already on disk at ${manifestPath} and up to date; no rebuild needed.`
|
||||||
|
|||||||
+6
-6
@@ -15,8 +15,6 @@ export type ModSource =
|
|||||||
| {
|
| {
|
||||||
kind: 'directFile';
|
kind: 'directFile';
|
||||||
url: string;
|
url: string;
|
||||||
versionUrl?: string;
|
|
||||||
latestVersionUrl?: string;
|
|
||||||
parseLatest?: 'githubRelease' | 'gitlabRelease' | 'codebergRelease';
|
parseLatest?: 'githubRelease' | 'gitlabRelease' | 'codebergRelease';
|
||||||
apiUrl?: string;
|
apiUrl?: string;
|
||||||
pinnedTag?: string;
|
pinnedTag?: string;
|
||||||
@@ -25,7 +23,6 @@ export type ModSource =
|
|||||||
| {
|
| {
|
||||||
kind: 'archive';
|
kind: 'archive';
|
||||||
url: string;
|
url: string;
|
||||||
latestVersionUrl?: string;
|
|
||||||
apiUrl?: string;
|
apiUrl?: string;
|
||||||
parseLatest?: 'githubRelease' | 'gitlabRelease' | 'codebergRelease';
|
parseLatest?: 'githubRelease' | 'gitlabRelease' | 'codebergRelease';
|
||||||
pinnedTag?: string;
|
pinnedTag?: string;
|
||||||
@@ -96,7 +93,8 @@ export const MODS: ModEntry[] = [
|
|||||||
pinnedTag: '0.2',
|
pinnedTag: '0.2',
|
||||||
format: 'zip',
|
format: 'zip',
|
||||||
extractMap: {
|
extractMap: {
|
||||||
'VanillaMultiMonitorFix.dll': 'VanillaMultiMonitorFix.dll'
|
'VanillaMultiMonitorFix.dll': 'VanillaMultiMonitorFix.dll',
|
||||||
|
'VMMFix_preferred_monitor.txt': 'VMMFix_preferred_monitor.txt'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
registerInDllsTxt: 'VanillaMultiMonitorFix.dll'
|
registerInDllsTxt: 'VanillaMultiMonitorFix.dll'
|
||||||
@@ -139,7 +137,8 @@ export const MODS: ModEntry[] = [
|
|||||||
id: 'vanillaFixes',
|
id: 'vanillaFixes',
|
||||||
name: 'vanillaFixes',
|
name: 'vanillaFixes',
|
||||||
version: 'v1.5.3',
|
version: 'v1.5.3',
|
||||||
description: 'A client modification that eliminates stutter and animation lag.',
|
description:
|
||||||
|
'A client modification that eliminates stutter and animation lag.',
|
||||||
recommended: true,
|
recommended: true,
|
||||||
repoUrl: 'https://github.com/hannesmann/vanillafixes',
|
repoUrl: 'https://github.com/hannesmann/vanillafixes',
|
||||||
source: {
|
source: {
|
||||||
@@ -160,7 +159,8 @@ export const MODS: ModEntry[] = [
|
|||||||
id: 'vanillaHelpers',
|
id: 'vanillaHelpers',
|
||||||
name: 'vanillaHelpers',
|
name: 'vanillaHelpers',
|
||||||
version: 'v1.1.2',
|
version: 'v1.1.2',
|
||||||
description: 'Utility library that might be required by other patches and addons.',
|
description:
|
||||||
|
'Utility library that might be required by other patches and addons.',
|
||||||
repoUrl: 'https://github.com/isfir/VanillaHelpers',
|
repoUrl: 'https://github.com/isfir/VanillaHelpers',
|
||||||
requires: ['vanillaFixes'],
|
requires: ['vanillaFixes'],
|
||||||
source: {
|
source: {
|
||||||
|
|||||||
@@ -44,7 +44,12 @@ export const PreferencesSchema = z.object({
|
|||||||
lastPatchedLauncherVersion: z.string().optional(),
|
lastPatchedLauncherVersion: z.string().optional(),
|
||||||
expectedPatchedWowHash: z.string().optional(),
|
expectedPatchedWowHash: z.string().optional(),
|
||||||
minimizeToTrayOnPlay: f.boolean(true),
|
minimizeToTrayOnPlay: f.boolean(true),
|
||||||
cleanWdb: f.boolean(),
|
cleanWdb: f.boolean(true),
|
||||||
|
locale: z
|
||||||
|
.enum(['enUS', 'deDE', 'zhCN', 'esES', 'ptBR', 'ruRU'])
|
||||||
|
.default('enUS'),
|
||||||
|
localePatchLetter: z.string().optional(),
|
||||||
|
localePatchLocale: z.string().optional(),
|
||||||
rememberPosition: f.boolean(),
|
rememberPosition: f.boolean(),
|
||||||
windowPosition: z
|
windowPosition: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
+10
-1
@@ -1,10 +1,19 @@
|
|||||||
type Path = readonly (string | number)[];
|
type Path = readonly (string | number)[];
|
||||||
|
|
||||||
|
// reject prototype-polluting keys
|
||||||
|
const isUnsafeKey = (key: string | number) =>
|
||||||
|
key === '__proto__' || key === 'constructor' || key === 'prototype';
|
||||||
|
|
||||||
export const nestedGet = <T>(object: unknown, path: Path) =>
|
export const nestedGet = <T>(object: unknown, path: Path) =>
|
||||||
path.reduce((obj, key) => obj?.[key], object) as T;
|
path.reduce(
|
||||||
|
(obj, key) => (isUnsafeKey(key) ? undefined : obj?.[key]),
|
||||||
|
object
|
||||||
|
) as T;
|
||||||
|
|
||||||
export const nestedSet = (obj: any, path: Path, value: unknown) => {
|
export const nestedSet = (obj: any, path: Path, value: unknown) => {
|
||||||
const [key, ...rest] = path;
|
const [key, ...rest] = path;
|
||||||
|
if (isUnsafeKey(key)) return;
|
||||||
|
|
||||||
if (path.length === 1) {
|
if (path.length === 1) {
|
||||||
obj[key] = value;
|
obj[key] = value;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { mainWindow } from '~main/index';
|
import { mainWindow } from '~main/index';
|
||||||
import Preferences from '~main/modules/preferences';
|
import Preferences from '~main/modules/preferences';
|
||||||
|
import { addDefenderExclusions } from '~main/modules/defender';
|
||||||
|
|
||||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export const generalRouter = createTRPCRouter({
|
|||||||
const file = Logger.transports.file.getFile().path;
|
const file = Logger.transports.file.getFile().path;
|
||||||
shell.openPath(file);
|
shell.openPath(file);
|
||||||
}),
|
}),
|
||||||
|
addDefenderExclusion: publicProcedure.mutation(() => addDefenderExclusions()),
|
||||||
filePicker: publicProcedure
|
filePicker: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import path from 'path';
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
|
|
||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import { inject } from 'dll-inject';
|
|
||||||
import Logger from 'electron-log/main';
|
import Logger from 'electron-log/main';
|
||||||
|
|
||||||
import Preferences from '~main/modules/preferences';
|
import Preferences from '~main/modules/preferences';
|
||||||
@@ -10,95 +9,101 @@ import Mods from '~main/modules/mods';
|
|||||||
import { mainWindow } from '~main/index';
|
import { mainWindow } from '~main/index';
|
||||||
import { isGameRunning } from '~main/modules/updater';
|
import { isGameRunning } from '~main/modules/updater';
|
||||||
import { patchConfig } from '~main/modules/patcher';
|
import { patchConfig } from '~main/modules/patcher';
|
||||||
|
import { applyLocalePatch } from '~main/modules/localePatch';
|
||||||
import { minimizeToTray, restoreFromTray } from '~main/modules/tray';
|
import { minimizeToTray, restoreFromTray } from '~main/modules/tray';
|
||||||
import { getMod } from '~common/mods';
|
import { getMod } from '~common/mods';
|
||||||
|
|
||||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||||
|
|
||||||
const ensureChainloaderTweak = async (clientDir: string): Promise<boolean> => {
|
const chainloaderNeeded = async (clientDir: string): Promise<boolean> => {
|
||||||
if (Preferences.data.config.vanillaFixes) return true;
|
const installed = Mods.status.mods.filter(r => r.installedVersion);
|
||||||
|
if (installed.some(r => r.id === 'vanillaFixes')) return true;
|
||||||
|
if (installed.some(r => getMod(r.id)?.requires?.includes('vanillaFixes')))
|
||||||
|
return true;
|
||||||
|
|
||||||
const installedMods = Mods.status.mods.filter(r => r.installedVersion);
|
|
||||||
const anyDependsOnVf = installedMods.some(r =>
|
|
||||||
getMod(r.id)?.requires?.includes('vanillaFixes')
|
|
||||||
);
|
|
||||||
|
|
||||||
let dllsTxtHasEntries = false;
|
|
||||||
const dllsPath = path.join(clientDir, 'dlls.txt');
|
const dllsPath = path.join(clientDir, 'dlls.txt');
|
||||||
if (await fs.pathExists(dllsPath)) {
|
if (await fs.pathExists(dllsPath)) {
|
||||||
const raw = await fs.readFile(dllsPath, 'utf8');
|
const raw = await fs.readFile(dllsPath, 'utf8');
|
||||||
dllsTxtHasEntries = raw
|
return raw.split(/\r?\n/).some(l => l.trim() && !l.trim().startsWith('#'));
|
||||||
.split(/\r?\n/)
|
|
||||||
.some(l => l.trim() && !l.trim().startsWith('#'));
|
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
if (!anyDependsOnVf && !dllsTxtHasEntries) return false;
|
|
||||||
|
|
||||||
Logger.info(
|
|
||||||
`Auto-enabling vanillaFixes Tweak (chainloader required): ${
|
|
||||||
anyDependsOnVf ? 'a dependent mod is installed' : ''
|
|
||||||
}${anyDependsOnVf && dllsTxtHasEntries ? ' + ' : ''}${
|
|
||||||
dllsTxtHasEntries ? 'dlls.txt has user entries' : ''
|
|
||||||
}.`
|
|
||||||
);
|
|
||||||
Preferences.data = {
|
|
||||||
config: { ...Preferences.data.config, vanillaFixes: true }
|
|
||||||
};
|
|
||||||
return true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const launcherRouter = createTRPCRouter({
|
type StartResult = { ok: boolean; error?: string };
|
||||||
start: publicProcedure.mutation(async () => {
|
|
||||||
const { cleanWdb, minimizeToTrayOnPlay, config, clientDir } =
|
|
||||||
Preferences.data;
|
|
||||||
if (!clientDir) return false;
|
|
||||||
|
|
||||||
const clientPath = path.join(clientDir, 'WoW.exe');
|
export const launcherRouter = createTRPCRouter({
|
||||||
Logger.log(`Launching ${clientPath}...`);
|
start: publicProcedure.mutation(async (): Promise<StartResult> => {
|
||||||
if (await isGameRunning(clientPath)) return false;
|
const { cleanWdb, minimizeToTrayOnPlay, clientDir } = Preferences.data;
|
||||||
|
if (!clientDir) return { ok: false, error: 'No game folder is set.' };
|
||||||
|
|
||||||
|
const exePath = path.join(clientDir, 'WoW.exe');
|
||||||
|
if (!(await fs.pathExists(exePath)))
|
||||||
|
return { ok: false, error: 'WoW.exe was not found in the game folder.' };
|
||||||
|
if (await isGameRunning(exePath))
|
||||||
|
return { ok: false, error: 'WoW is already running.' };
|
||||||
|
|
||||||
if (cleanWdb) {
|
if (cleanWdb) {
|
||||||
Logger.log('Cleaning up WDB...');
|
Logger.log('Cleaning up WDB...');
|
||||||
await fs.remove(path.join(clientPath, 'WDB'));
|
await fs.remove(path.join(clientDir, 'WDB'));
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.log('Checking Config.wtf...');
|
Logger.log('Checking Config.wtf...');
|
||||||
await patchConfig();
|
await patchConfig();
|
||||||
|
|
||||||
Logger.log('Launching WoW...');
|
Logger.log('Applying UI language...');
|
||||||
const process = spawn(clientPath, { detached: !minimizeToTrayOnPlay });
|
await applyLocalePatch(clientDir, Preferences.data.locale);
|
||||||
|
|
||||||
const wantChainloader = await ensureChainloaderTweak(clientDir);
|
const loaderPath = path.join(clientDir, 'VanillaFixes.exe');
|
||||||
if (wantChainloader) {
|
const needsLoader = await chainloaderNeeded(clientDir);
|
||||||
Logger.log('Injecting VanillaFixes...');
|
const useLoader = needsLoader && (await fs.pathExists(loaderPath));
|
||||||
const vfPath = path.join(clientDir, 'VfPatcher.dll');
|
if (needsLoader && !useLoader)
|
||||||
|
Logger.warn(
|
||||||
|
'VanillaFixes.exe is missing but mods/dlls.txt expect a chainloader; ' +
|
||||||
|
'launching WoW.exe directly (mods will not load).'
|
||||||
|
);
|
||||||
|
|
||||||
if (!(await fs.pathExists(vfPath))) {
|
const octoLocale = Preferences.data.locale || 'enUS';
|
||||||
Logger.warn(
|
const gameEnv = { ...process.env, OCTO_LOCALE: octoLocale };
|
||||||
`VfPatcher.dll missing at ${vfPath} — chainloader needed but ` +
|
Logger.log(
|
||||||
'the vanillaFixes mod is not installed. Skipping inject; ' +
|
useLoader
|
||||||
'dlls.txt entries and dependent mods will not load. Install ' +
|
? `Launching via VanillaFixes (OCTO_LOCALE=${octoLocale})...`
|
||||||
"vanillaFixes from the Mods tab to fix."
|
: `Launching ${exePath} (OCTO_LOCALE=${octoLocale})...`
|
||||||
);
|
);
|
||||||
} else {
|
const child = useLoader
|
||||||
const status = inject('WoW.exe', vfPath);
|
? spawn(loaderPath, ['WoW.exe'], {
|
||||||
if (status) {
|
env: gameEnv,
|
||||||
Logger.error(`Injecting failed with error code ${status}...`);
|
cwd: clientDir,
|
||||||
return true;
|
detached: !minimizeToTrayOnPlay
|
||||||
}
|
})
|
||||||
}
|
: spawn(exePath, {
|
||||||
|
env: gameEnv,
|
||||||
|
cwd: clientDir,
|
||||||
|
detached: !minimizeToTrayOnPlay
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
child.once('spawn', resolve);
|
||||||
|
child.once('error', reject);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
Logger.error('Failed to launch the game', e);
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
return { ok: false, error: `Failed to launch the game: ${message}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
child.on('error', e => Logger.error('Game process error', e));
|
||||||
|
|
||||||
if (!minimizeToTrayOnPlay) {
|
if (!minimizeToTrayOnPlay) {
|
||||||
mainWindow?.close();
|
mainWindow?.close();
|
||||||
return true;
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
minimizeToTray();
|
minimizeToTray();
|
||||||
process.on('exit', () => {
|
child.on('exit', () => {
|
||||||
Logger.log('WoW stopped');
|
Logger.log('WoW stopped');
|
||||||
restoreFromTray();
|
restoreFromTray();
|
||||||
});
|
});
|
||||||
return true;
|
return { ok: true };
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { patchConfig, patchExecutable } from '~main/modules/patcher';
|
import { patchConfig, patchExecutable } from '~main/modules/patcher';
|
||||||
import Preferences from '~main/modules/preferences';
|
import Preferences from '~main/modules/preferences';
|
||||||
|
import Updater from '~main/modules/updater';
|
||||||
import { getClientVersion } from '~main/utils';
|
import { getClientVersion } from '~main/utils';
|
||||||
|
|
||||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||||
@@ -7,7 +8,8 @@ import { createTRPCRouter, publicProcedure } from '../trpc';
|
|||||||
export const patcherRouter = createTRPCRouter({
|
export const patcherRouter = createTRPCRouter({
|
||||||
apply: publicProcedure.mutation(async () => {
|
apply: publicProcedure.mutation(async () => {
|
||||||
await patchExecutable();
|
await patchExecutable();
|
||||||
await patchConfig();
|
await patchConfig(true);
|
||||||
|
await Updater.recordPatchedWow();
|
||||||
Preferences.data = { version: await getClientVersion() };
|
Preferences.data = { version: await getClientVersion() };
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { PreferencesSchema } from '~common/schemas';
|
import { PreferencesSchema } from '~common/schemas';
|
||||||
import Preferences from '~main/modules/preferences';
|
import Preferences from '~main/modules/preferences';
|
||||||
|
import { applyLocalePatch } from '~main/modules/localePatch';
|
||||||
|
|
||||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||||
|
|
||||||
@@ -11,6 +12,8 @@ export const preferencesRouter = createTRPCRouter({
|
|||||||
.input(PreferencesSchema.partial())
|
.input(PreferencesSchema.partial())
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
Preferences.data = input;
|
Preferences.data = input;
|
||||||
|
if (input.locale !== undefined)
|
||||||
|
await applyLocalePatch(Preferences.data.clientDir, input.locale);
|
||||||
return Preferences.data;
|
return Preferences.data;
|
||||||
}),
|
}),
|
||||||
isValidClientDir: publicProcedure
|
isValidClientDir: publicProcedure
|
||||||
|
|||||||
+66
-24
@@ -1,6 +1,6 @@
|
|||||||
import { join } from 'path';
|
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 { electronApp, optimizer, is } from '@electron-toolkit/utils';
|
||||||
import { createIPCHandler } from 'electron-trpc/main';
|
import { createIPCHandler } from 'electron-trpc/main';
|
||||||
import Logger from 'electron-log/main';
|
import Logger from 'electron-log/main';
|
||||||
@@ -22,10 +22,33 @@ app.disableHardwareAcceleration();
|
|||||||
|
|
||||||
export let mainWindow: BrowserWindow | null = null;
|
export let mainWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
|
const isOnScreen = (
|
||||||
|
pos?: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null
|
||||||
|
) => {
|
||||||
|
if (!pos) return false;
|
||||||
|
return screen.getAllDisplays().some(d => {
|
||||||
|
const a = d.workArea;
|
||||||
|
return (
|
||||||
|
pos.x < a.x + a.width &&
|
||||||
|
pos.x + pos.width > a.x &&
|
||||||
|
pos.y < a.y + a.height &&
|
||||||
|
pos.y + pos.height > a.y
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const createWindow = async () => {
|
const createWindow = async () => {
|
||||||
const position = Preferences.data.rememberPosition
|
const saved =
|
||||||
? Preferences.data.windowPosition
|
Preferences.data.rememberPosition &&
|
||||||
: { width: 1000, height: 700 };
|
isOnScreen(Preferences.data.windowPosition)
|
||||||
|
? Preferences.data.windowPosition
|
||||||
|
: undefined;
|
||||||
|
const position = saved ?? { width: 1000, height: 700 };
|
||||||
|
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
...position,
|
...position,
|
||||||
@@ -50,16 +73,22 @@ const createWindow = async () => {
|
|||||||
Logger.error('Renderer unresponsive');
|
Logger.error('Renderer unresponsive');
|
||||||
});
|
});
|
||||||
|
|
||||||
mainWindow.webContents.on('console-message', (_e, level, message, line, sourceId) => {
|
mainWindow.webContents.on(
|
||||||
const lvl = level === 3 ? 'error' : level === 2 ? 'warn' : 'info';
|
'console-message',
|
||||||
Logger[lvl](`[renderer:${lvl}] ${message} (${sourceId}:${line})`);
|
(_e, level, message, line, sourceId) => {
|
||||||
});
|
const lvl = level === 3 ? 'error' : level === 2 ? 'warn' : 'info';
|
||||||
|
Logger[lvl](`[renderer:${lvl}] ${message} (${sourceId}:${line})`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
mainWindow.webContents.on('before-input-event', (_e, input) => {
|
mainWindow.webContents.on('before-input-event', (_e, input) => {
|
||||||
if (input.type !== 'keyDown') return;
|
if (input.type !== 'keyDown') return;
|
||||||
if (input.key === 'F12') {
|
if (input.key === 'F12') {
|
||||||
mainWindow?.webContents.toggleDevTools();
|
mainWindow?.webContents.toggleDevTools();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if ((input.control || input.meta) && input.key.toLowerCase() === 'c')
|
||||||
|
mainWindow?.webContents.copy();
|
||||||
});
|
});
|
||||||
|
|
||||||
createIPCHandler({ router: appRouter, windows: [mainWindow] });
|
createIPCHandler({ router: appRouter, windows: [mainWindow] });
|
||||||
@@ -85,23 +114,36 @@ const createWindow = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
const gotSingleInstanceLock = is.dev || app.requestSingleInstanceLock();
|
||||||
Preferences.data = await Preferences.load();
|
|
||||||
|
|
||||||
Addons.verify();
|
if (!gotSingleInstanceLock) {
|
||||||
Updater.verify();
|
app.quit();
|
||||||
Mods.verify();
|
} else {
|
||||||
initSelfUpdater();
|
app.on('second-instance', () => {
|
||||||
|
if (!mainWindow) return;
|
||||||
electronApp.setAppUserModelId('com.electron');
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||||
|
if (!mainWindow.isVisible()) mainWindow.show();
|
||||||
app.on('browser-window-created', (_, window) => {
|
mainWindow.focus();
|
||||||
optimizer.watchWindowShortcuts(window);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await createWindow();
|
app.whenReady().then(async () => {
|
||||||
});
|
Preferences.data = await Preferences.load();
|
||||||
|
|
||||||
app.on('window-all-closed', async () => {
|
Addons.verify();
|
||||||
app.quit();
|
Updater.verify();
|
||||||
});
|
Mods.verify();
|
||||||
|
initSelfUpdater();
|
||||||
|
|
||||||
|
electronApp.setAppUserModelId('st.octowow.launcher');
|
||||||
|
|
||||||
|
app.on('browser-window-created', (_, window) => {
|
||||||
|
optimizer.watchWindowShortcuts(window);
|
||||||
|
});
|
||||||
|
|
||||||
|
await createWindow();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
app.quit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+76
-24
@@ -35,22 +35,51 @@ type AddonsList = {
|
|||||||
}[];
|
}[];
|
||||||
|
|
||||||
const readTocData = (content: string) =>
|
const readTocData = (content: string) =>
|
||||||
content
|
(content.charCodeAt(0) === 0xfeff ? content.slice(1) : content)
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.filter(l => l.startsWith('## '))
|
.filter(l => l.startsWith('## '))
|
||||||
.map(l => l.slice(3))
|
.map(l => l.slice(3))
|
||||||
.map(l => {
|
.map(l => {
|
||||||
const [key, value] = l.split(':');
|
const idx = l.indexOf(':');
|
||||||
return [key.trim(), value.trim()];
|
if (idx === -1) return null;
|
||||||
|
return [l.slice(0, idx).trim(), l.slice(idx + 1).trim()] as const;
|
||||||
})
|
})
|
||||||
|
.filter((e): e is readonly [string, string] => !!e)
|
||||||
.reduce((acc, [key, value]) => {
|
.reduce((acc, [key, value]) => {
|
||||||
acc[key] = value;
|
acc[key] = value;
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as TocData);
|
}, {} as TocData);
|
||||||
|
|
||||||
|
const isUnsafeFolder = (name?: string) =>
|
||||||
|
!name || name === '.' || name === '..' || /[/\\]/.test(name);
|
||||||
|
|
||||||
|
// 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 () => {
|
const fetchAddons = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'}/api/addons.json`);
|
const response = await fetch(
|
||||||
|
`${
|
||||||
|
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
|
||||||
|
}/api/addons.json`
|
||||||
|
);
|
||||||
return (await response.json()) as AddonsList;
|
return (await response.json()) as AddonsList;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Logger.error('Failed to reach update server', e);
|
Logger.error('Failed to reach update server', e);
|
||||||
@@ -103,35 +132,36 @@ class AddonsClass extends Observable<AddonsStatus> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async checkGitUrl(url: string) {
|
async checkGitUrl(url: string) {
|
||||||
const gitUrl = url.endsWith('.git') ? url : `${url}.git`;
|
const clean = url.trim().replace(/\/+$/, '');
|
||||||
|
const gitUrl = clean.endsWith('.git') ? clean : `${clean}.git`;
|
||||||
|
if (!isAllowedGitUrl(gitUrl)) return undefined;
|
||||||
try {
|
try {
|
||||||
await git.getRemoteInfo({
|
await git.getRemoteInfo({
|
||||||
http,
|
http,
|
||||||
url: gitUrl
|
url: gitUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only fetch preview from known public git hosts to prevent SSRF.
|
|
||||||
const allowed = ['github.com', 'gitlab.com', 'gitea.com', 'codeberg.org'];
|
|
||||||
let preview: string | undefined;
|
let preview: string | undefined;
|
||||||
try {
|
try {
|
||||||
const host = new URL(url).hostname.toLowerCase();
|
if (isAllowedGitUrl(url)) {
|
||||||
if (allowed.some(h => host === h || host.endsWith('.' + h))) {
|
|
||||||
const response = await fetch(url).then(r => r.text());
|
const response = await fetch(url).then(r => r.text());
|
||||||
preview = response.match(
|
preview = response.match(
|
||||||
/property="og:image" content="([^"]*)"/
|
/property="og:image" content="([^"]*)"/
|
||||||
)?.[1];
|
)?.[1];
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// preview stays undefined
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const folder = gitUrl.slice(0, -4).split('/').at(-1);
|
||||||
|
if (isUnsafeFolder(folder)) return undefined;
|
||||||
return {
|
return {
|
||||||
status: 'available',
|
status: 'available',
|
||||||
folder: gitUrl.slice(0, -4).split('/').at(-1),
|
folder,
|
||||||
git: gitUrl,
|
git: gitUrl,
|
||||||
preview
|
preview
|
||||||
} as AddonData;
|
} as AddonData;
|
||||||
} catch (e) {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,7 +192,7 @@ class AddonsClass extends Observable<AddonsStatus> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addonsPath = path.join(clientPath, 'Interface', 'Addons');
|
const addonsPath = path.join(clientPath, 'Interface', 'Addons');
|
||||||
const dirs = await fs.pathExists(addonsPath)
|
const dirs = (await fs.pathExists(addonsPath))
|
||||||
? await fs.readdir(addonsPath)
|
? await fs.readdir(addonsPath)
|
||||||
: [];
|
: [];
|
||||||
const addons: AddonsStatus['addons'] = Object.fromEntries(
|
const addons: AddonsStatus['addons'] = Object.fromEntries(
|
||||||
@@ -223,9 +253,7 @@ class AddonsClass extends Observable<AddonsStatus> {
|
|||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
|
|
||||||
const remoteCommit = avail?.ref
|
const remoteCommit = avail?.ref
|
||||||
? await git
|
? await git.resolveRef({ fs, dir, ref: avail.ref }).catch(() => null)
|
||||||
.resolveRef({ fs, dir, ref: avail.ref })
|
|
||||||
.catch(() => null)
|
|
||||||
: await git
|
: await git
|
||||||
.log({ fs, dir, ref: `${remote.remote}/${branch}`, depth: 1 })
|
.log({ fs, dir, ref: `${remote.remote}/${branch}`, depth: 1 })
|
||||||
.then(r => r[0].oid)
|
.then(r => r[0].oid)
|
||||||
@@ -249,7 +277,9 @@ class AddonsClass extends Observable<AddonsStatus> {
|
|||||||
|
|
||||||
Logger.log(
|
Logger.log(
|
||||||
isUpToDate
|
isUpToDate
|
||||||
? `Addon "${folder}" is up to date${avail?.ref ? ` (pinned ${avail.ref})` : ''}`
|
? `Addon "${folder}" is up to date${
|
||||||
|
avail?.ref ? ` (pinned ${avail.ref})` : ''
|
||||||
|
}`
|
||||||
: `Addon "${folder}" has an update available`
|
: `Addon "${folder}" has an update available`
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -268,13 +298,16 @@ class AddonsClass extends Observable<AddonsStatus> {
|
|||||||
const VERIFY_CONCURRENCY = 6;
|
const VERIFY_CONCURRENCY = 6;
|
||||||
let idx = 0;
|
let idx = 0;
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Array.from({ length: Math.min(VERIFY_CONCURRENCY, folders.length) }, async () => {
|
Array.from(
|
||||||
while (true) {
|
{ length: Math.min(VERIFY_CONCURRENCY, folders.length) },
|
||||||
const i = idx++;
|
async () => {
|
||||||
if (i >= folders.length) return;
|
while (true) {
|
||||||
await verifyOne(folders[i]);
|
const i = idx++;
|
||||||
|
if (i >= folders.length) return;
|
||||||
|
await verifyOne(folders[i]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
this.status = { ...this.status, state: 'done' };
|
this.status = { ...this.status, state: 'done' };
|
||||||
@@ -330,7 +363,7 @@ class AddonsClass extends Observable<AddonsStatus> {
|
|||||||
{ onProgress: this.#onProgress(folder, data) }
|
{ onProgress: this.#onProgress(folder, data) }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const toc = await readTocData(
|
const toc = readTocData(
|
||||||
await fs.readFile(path.join(dir, `${folder}.toc`), 'utf-8')
|
await fs.readFile(path.join(dir, `${folder}.toc`), 'utf-8')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -363,6 +396,25 @@ class AddonsClass extends Observable<AddonsStatus> {
|
|||||||
async install(data: AddonData) {
|
async install(data: AddonData) {
|
||||||
const clientPath = Preferences.data.clientDir;
|
const clientPath = Preferences.data.clientDir;
|
||||||
if (!clientPath) return;
|
if (!clientPath) return;
|
||||||
|
if (isUnsafeFolder(data.folder)) {
|
||||||
|
Logger.error(`Refusing addon with unsafe folder name: "${data.folder}"`);
|
||||||
|
this.#setAddon(data.folder, {
|
||||||
|
...data,
|
||||||
|
status: 'invalid',
|
||||||
|
error: 'Invalid addon name'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.git || !isAllowedGitUrl(data.git)) {
|
||||||
|
Logger.error(`Refusing addon from disallowed git host: "${data.git}"`);
|
||||||
|
this.#setAddon(data.folder, {
|
||||||
|
...data,
|
||||||
|
status: 'invalid',
|
||||||
|
error: 'Addon URL is not from an allowed git host'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const addonsPath = path.join(clientPath, 'Interface', 'Addons');
|
const addonsPath = path.join(clientPath, 'Interface', 'Addons');
|
||||||
const dir = path.join(addonsPath, data.folder);
|
const dir = path.join(addonsPath, data.folder);
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { app } from 'electron';
|
||||||
|
import Logger from 'electron-log/main';
|
||||||
|
|
||||||
|
import Preferences from './preferences';
|
||||||
|
|
||||||
|
export type ExclusionResult = { ok: boolean; error?: string; paths?: string[] };
|
||||||
|
|
||||||
|
const psSingleQuote = (s: string) => `'${s.replace(/'/g, "''")}'`;
|
||||||
|
|
||||||
|
export const addDefenderExclusions = async (): Promise<ExclusionResult> => {
|
||||||
|
if (os.platform() !== 'win32')
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'Antivirus exclusions are only needed on Windows.'
|
||||||
|
};
|
||||||
|
|
||||||
|
const clientDir = Preferences.data.clientDir;
|
||||||
|
if (!clientDir)
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'Set your game folder first, then add the exclusion.'
|
||||||
|
};
|
||||||
|
|
||||||
|
const launcherDir =
|
||||||
|
process.env.PORTABLE_EXECUTABLE_DIR ?? path.dirname(app.getPath('exe'));
|
||||||
|
const paths = [...new Set([clientDir, launcherDir])];
|
||||||
|
|
||||||
|
const inner = [
|
||||||
|
'$ErrorActionPreference = "Stop"',
|
||||||
|
'try {',
|
||||||
|
...paths.map(p => `Add-MpPreference -ExclusionPath ${psSingleQuote(p)}`),
|
||||||
|
'Add-MpPreference -ExclusionProcess "WoW.exe"',
|
||||||
|
'Add-MpPreference -ExclusionProcess "VanillaFixes.exe"',
|
||||||
|
'exit 0',
|
||||||
|
'} catch { exit 2 }'
|
||||||
|
].join('\n');
|
||||||
|
const encoded = Buffer.from(inner, 'utf16le').toString('base64');
|
||||||
|
|
||||||
|
const outer =
|
||||||
|
'try { $p = Start-Process powershell -Verb RunAs -WindowStyle Hidden ' +
|
||||||
|
"-Wait -PassThru -ArgumentList '-NoProfile','-NonInteractive'," +
|
||||||
|
`'-EncodedCommand','${encoded}'; exit $p.ExitCode } catch { exit 1 }`;
|
||||||
|
|
||||||
|
return new Promise<ExclusionResult>(resolve => {
|
||||||
|
const child = spawn(
|
||||||
|
'powershell.exe',
|
||||||
|
['-NoProfile', '-NonInteractive', '-Command', outer],
|
||||||
|
{ windowsHide: true }
|
||||||
|
);
|
||||||
|
let stderr = '';
|
||||||
|
child.stderr.on('data', d => (stderr += String(d)));
|
||||||
|
child.on('error', e => {
|
||||||
|
Logger.error('Failed to launch PowerShell for Defender exclusion', e);
|
||||||
|
resolve({ ok: false, error: 'Could not run Windows PowerShell.' });
|
||||||
|
});
|
||||||
|
child.on('exit', code => {
|
||||||
|
if (code === 0) {
|
||||||
|
Logger.info(`Added Defender exclusions: ${paths.join(', ')}`);
|
||||||
|
resolve({ ok: true, paths });
|
||||||
|
} else if (code === 1) {
|
||||||
|
resolve({
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
'No permission granted. Click Yes on the Windows prompt to add the exclusion.'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Logger.error(`Defender exclusion failed (code ${code}): ${stderr}`);
|
||||||
|
resolve({
|
||||||
|
ok: false,
|
||||||
|
error:
|
||||||
|
'Could not add the exclusion automatically. You may need to add it in Windows Security manually.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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<void> => {
|
||||||
|
if (!clientDir) return;
|
||||||
|
const dataDir = path.join(clientDir, 'Data');
|
||||||
|
|
||||||
|
const nextLocale = !locale || locale === 'enUS' ? undefined : locale;
|
||||||
|
if (Preferences.data.localePatchLocale !== nextLocale)
|
||||||
|
await fs.remove(path.join(clientDir, 'WDB')).catch(() => {});
|
||||||
|
|
||||||
|
if (!locale || locale === 'enUS') {
|
||||||
|
await removeOurPatch(dataDir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = prebuiltFor(dataDir, locale);
|
||||||
|
if (!(await fs.pathExists(source))) {
|
||||||
|
Logger.warn(
|
||||||
|
`Locale patch: no prebuilt patch-L for ${locale}; leaving UI as-is`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracked = Preferences.data.localePatchLetter;
|
||||||
|
const letter =
|
||||||
|
(tracked && usableSlot(dataDir, tracked) ? tracked : undefined) ??
|
||||||
|
(usableSlot(dataDir, PREFERRED)
|
||||||
|
? PREFERRED
|
||||||
|
: LETTERS.find(l => usableSlot(dataDir, l)));
|
||||||
|
if (!letter) {
|
||||||
|
Logger.warn('Locale patch: no usable patch slot');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = patchFile(dataDir, letter);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
Preferences.data.localePatchLocale === locale &&
|
||||||
|
isOurPatch(target) &&
|
||||||
|
fs.statSync(target).mtimeMs >= fs.statSync(source).mtimeMs
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
+62
-69
@@ -1,5 +1,4 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
|
||||||
|
|
||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
@@ -12,8 +11,17 @@ import { type ModState } from '~common/schemas';
|
|||||||
|
|
||||||
import Preferences from './preferences';
|
import Preferences from './preferences';
|
||||||
import Observable from './observable';
|
import Observable from './observable';
|
||||||
|
import Updater from './updater';
|
||||||
import { addDll, removeDll } from './dllsTxt';
|
import { addDll, removeDll } from './dllsTxt';
|
||||||
|
|
||||||
|
const MOD_DOWNLOAD_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
const AV_ERROR =
|
||||||
|
'Windows Defender blocked this download. Use "Allow through antivirus" and apply again.';
|
||||||
|
|
||||||
|
const looksLikeAvBlock = (msg: string) =>
|
||||||
|
/windows defender|virus|potentially unwanted/i.test(msg);
|
||||||
|
|
||||||
export type ModRowStatus = {
|
export type ModRowStatus = {
|
||||||
id: ModId;
|
id: ModId;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -36,8 +44,6 @@ export type ModsStatus = {
|
|||||||
mods: ModRowStatus[];
|
mods: ModRowStatus[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const VERSION_CACHE_MS = 10 * 60 * 1000;
|
|
||||||
|
|
||||||
class ModsClass extends Observable<ModsStatus> {
|
class ModsClass extends Observable<ModsStatus> {
|
||||||
protected _value: ModsStatus = {
|
protected _value: ModsStatus = {
|
||||||
state: 'verifying',
|
state: 'verifying',
|
||||||
@@ -45,21 +51,6 @@ class ModsClass extends Observable<ModsStatus> {
|
|||||||
mods: []
|
mods: []
|
||||||
};
|
};
|
||||||
|
|
||||||
#latestCache = new Map<ModId, { v: string; ts: number }>();
|
|
||||||
|
|
||||||
installedFilePaths(): Set<string> {
|
|
||||||
const set = new Set<string>();
|
|
||||||
const mods = Preferences.data?.mods ?? {};
|
|
||||||
for (const id of Object.keys(mods) as ModId[]) {
|
|
||||||
const state = mods[id];
|
|
||||||
if (!state?.installedFiles?.length) continue;
|
|
||||||
for (const rel of state.installedFiles) {
|
|
||||||
set.add(rel.replace(/\\/g, '/').toLowerCase());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return set;
|
|
||||||
}
|
|
||||||
|
|
||||||
get status(): ModsStatus {
|
get status(): ModsStatus {
|
||||||
return this._value;
|
return this._value;
|
||||||
}
|
}
|
||||||
@@ -119,6 +110,13 @@ class ModsClass extends Observable<ModsStatus> {
|
|||||||
|
|
||||||
const clientDir = Preferences.data?.clientDir;
|
const clientDir = Preferences.data?.clientDir;
|
||||||
|
|
||||||
|
if (clientDir) {
|
||||||
|
const vmmfDll = path.join(clientDir, 'VanillaMultiMonitorFix.dll');
|
||||||
|
const vmmfCfg = path.join(clientDir, 'VMMFix_preferred_monitor.txt');
|
||||||
|
if ((await fs.pathExists(vmmfDll)) && !(await fs.pathExists(vmmfCfg)))
|
||||||
|
await fs.writeFile(vmmfCfg, '1\n', 'utf8').catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
for (const m of MODS) {
|
for (const m of MODS) {
|
||||||
const state = Preferences.data?.mods?.[m.id];
|
const state = Preferences.data?.mods?.[m.id];
|
||||||
let installedVersion = state?.installedVersion;
|
let installedVersion = state?.installedVersion;
|
||||||
@@ -140,54 +138,28 @@ class ModsClass extends Observable<ModsStatus> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const latest = await this.#fetchLatestVersion(m).catch(() => m.version);
|
if (clientDir && m.registerInDllsTxt)
|
||||||
|
await (installedVersion
|
||||||
|
? addDll(clientDir, m.registerInDllsTxt)
|
||||||
|
: removeDll(clientDir, m.registerInDllsTxt)
|
||||||
|
).catch(() => {});
|
||||||
|
|
||||||
this.#patchRow(m.id, {
|
this.#patchRow(m.id, {
|
||||||
installedVersion,
|
installedVersion,
|
||||||
latestVersion: latest,
|
latestVersion: m.version,
|
||||||
enabled: !!state?.enabled,
|
enabled: !!state?.enabled,
|
||||||
ignoreUpdates: !!state?.ignoreUpdates
|
ignoreUpdates: !!state?.ignoreUpdates
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this._value = { ...this._value, state: 'idle', dirty: this.#computeDirty() };
|
this._value = {
|
||||||
|
...this._value,
|
||||||
|
state: 'idle',
|
||||||
|
dirty: this.#computeDirty()
|
||||||
|
};
|
||||||
this._notifyObservers();
|
this._notifyObservers();
|
||||||
}
|
}
|
||||||
|
|
||||||
async #fetchLatestVersion(m: ModEntry): Promise<string> {
|
|
||||||
if (m.source.kind === 'managed') return m.version;
|
|
||||||
const cached = this.#latestCache.get(m.id);
|
|
||||||
if (cached && Date.now() - cached.ts < VERSION_CACHE_MS) return cached.v;
|
|
||||||
|
|
||||||
const apiUrl =
|
|
||||||
'apiUrl' in m.source && m.source.apiUrl ? m.source.apiUrl : undefined;
|
|
||||||
const parser =
|
|
||||||
'parseLatest' in m.source && m.source.parseLatest
|
|
||||||
? m.source.parseLatest
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (!apiUrl || !parser) {
|
|
||||||
const v = ('pinnedTag' in m.source && m.source.pinnedTag) || m.version;
|
|
||||||
this.#latestCache.set(m.id, { v, ts: Date.now() });
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(apiUrl, {
|
|
||||||
headers: { 'User-Agent': 'OctoLauncher' }
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`${apiUrl} → ${res.status}`);
|
|
||||||
const json = (await res.json()) as { tag_name?: string };
|
|
||||||
const tag = json.tag_name ?? m.version;
|
|
||||||
this.#latestCache.set(m.id, { v: tag, ts: Date.now() });
|
|
||||||
return tag;
|
|
||||||
} catch (e) {
|
|
||||||
Logger.warn(`Could not check latest version for ${m.id}:`, e);
|
|
||||||
const v = ('pinnedTag' in m.source && m.source.pinnedTag) || m.version;
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async toggle(id: ModId, enabled: boolean) {
|
async toggle(id: ModId, enabled: boolean) {
|
||||||
const cur = Preferences.data?.mods?.[id];
|
const cur = Preferences.data?.mods?.[id];
|
||||||
await this.#savePref(id, {
|
await this.#savePref(id, {
|
||||||
@@ -216,6 +188,10 @@ class ModsClass extends Observable<ModsStatus> {
|
|||||||
Logger.warn('No clientDir set; cannot apply mods.');
|
Logger.warn('No clientDir set; cannot apply mods.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this._value.state === 'busy') {
|
||||||
|
Logger.warn('applyAll already running; ignoring re-entrant call.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
this._value = { ...this._value, state: 'busy' };
|
this._value = { ...this._value, state: 'busy' };
|
||||||
this._notifyObservers();
|
this._notifyObservers();
|
||||||
|
|
||||||
@@ -226,6 +202,7 @@ class ModsClass extends Observable<ModsStatus> {
|
|||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const failures = new Map<ModId, string>();
|
||||||
for (const row of queue) {
|
for (const row of queue) {
|
||||||
const m = getMod(row.id);
|
const m = getMod(row.id);
|
||||||
if (!m) continue;
|
if (!m) continue;
|
||||||
@@ -248,15 +225,16 @@ class ModsClass extends Observable<ModsStatus> {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Logger.error(`Failed to apply ${m.id}:`, e);
|
Logger.error(`Failed to apply ${m.id}:`, e);
|
||||||
this.#patchRow(m.id, {
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
state: 'error',
|
failures.set(m.id, looksLikeAvBlock(msg) ? AV_ERROR : msg);
|
||||||
error: e instanceof Error ? e.message : String(e)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this._value = { ...this._value, state: 'idle' };
|
this._value = { ...this._value, state: 'idle' };
|
||||||
await this.verify();
|
await this.verify();
|
||||||
|
for (const [id, error] of failures)
|
||||||
|
this.#patchRow(id, { state: 'error', error });
|
||||||
|
await Updater.verify();
|
||||||
}
|
}
|
||||||
|
|
||||||
async #install(m: ModEntry) {
|
async #install(m: ModEntry) {
|
||||||
@@ -265,18 +243,25 @@ class ModsClass extends Observable<ModsStatus> {
|
|||||||
if (m.source.kind === 'managed') return;
|
if (m.source.kind === 'managed') return;
|
||||||
|
|
||||||
Logger.info(`Installing mod ${m.id}...`);
|
Logger.info(`Installing mod ${m.id}...`);
|
||||||
this.#patchRow(m.id, { state: 'downloading', progress: 0, error: undefined });
|
this.#patchRow(m.id, {
|
||||||
|
state: 'downloading',
|
||||||
|
progress: 0,
|
||||||
|
error: undefined
|
||||||
|
});
|
||||||
|
|
||||||
const written: string[] = [];
|
const written: string[] = [];
|
||||||
|
const missing: string[] = [];
|
||||||
|
|
||||||
if (m.source.kind === 'directFile') {
|
if (m.source.kind === 'directFile') {
|
||||||
const dest = path.join(clientDir, m.source.assetName);
|
const dest = path.join(clientDir, m.source.assetName);
|
||||||
await this.#downloadTo(m.source.url, dest);
|
await this.#downloadTo(m.source.url, dest);
|
||||||
written.push(m.source.assetName);
|
written.push(m.source.assetName);
|
||||||
} else if (m.source.kind === 'archive') {
|
} else if (m.source.kind === 'archive') {
|
||||||
|
const scratch = path.join(clientDir, '.octolauncher-tmp');
|
||||||
|
await fs.ensureDir(scratch);
|
||||||
const tmp = path.join(
|
const tmp = path.join(
|
||||||
os.tmpdir(),
|
scratch,
|
||||||
`octolauncher-${m.id}-${Date.now()}.${m.source.format}`
|
`${m.id}-${Date.now()}.${m.source.format}`
|
||||||
);
|
);
|
||||||
await this.#downloadTo(m.source.url, tmp);
|
await this.#downloadTo(m.source.url, tmp);
|
||||||
this.#patchRow(m.id, { state: 'installing' });
|
this.#patchRow(m.id, { state: 'installing' });
|
||||||
@@ -288,7 +273,7 @@ class ModsClass extends Observable<ModsStatus> {
|
|||||||
for (const [src, dst] of Object.entries(map)) {
|
for (const [src, dst] of Object.entries(map)) {
|
||||||
const entry = entries.find(e => e.entryName === src);
|
const entry = entries.find(e => e.entryName === src);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
Logger.warn(`Mod ${m.id}: zip entry ${src} not found.`);
|
missing.push(src);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const target = path.join(clientDir, dst);
|
const target = path.join(clientDir, dst);
|
||||||
@@ -297,16 +282,13 @@ class ModsClass extends Observable<ModsStatus> {
|
|||||||
written.push(dst);
|
written.push(dst);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const stagingDir = path.join(
|
const stagingDir = path.join(scratch, `${m.id}-${Date.now()}-extract`);
|
||||||
os.tmpdir(),
|
|
||||||
`octolauncher-${m.id}-${Date.now()}-extract`
|
|
||||||
);
|
|
||||||
await fs.ensureDir(stagingDir);
|
await fs.ensureDir(stagingDir);
|
||||||
await tar.x({ file: tmp, cwd: stagingDir });
|
await tar.x({ file: tmp, cwd: stagingDir });
|
||||||
for (const [src, dst] of Object.entries(map)) {
|
for (const [src, dst] of Object.entries(map)) {
|
||||||
const srcPath = path.join(stagingDir, src);
|
const srcPath = path.join(stagingDir, src);
|
||||||
if (!(await fs.pathExists(srcPath))) {
|
if (!(await fs.pathExists(srcPath))) {
|
||||||
Logger.warn(`Mod ${m.id}: tar entry ${src} not found.`);
|
missing.push(src);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const target = path.join(clientDir, dst);
|
const target = path.join(clientDir, dst);
|
||||||
@@ -319,6 +301,11 @@ class ModsClass extends Observable<ModsStatus> {
|
|||||||
await fs.remove(tmp).catch(() => {});
|
await fs.remove(tmp).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (missing.length)
|
||||||
|
throw new Error(
|
||||||
|
`${m.name}: download is missing expected file(s): ${missing.join(', ')}`
|
||||||
|
);
|
||||||
|
|
||||||
if (m.registerInDllsTxt) {
|
if (m.registerInDllsTxt) {
|
||||||
await addDll(clientDir, m.registerInDllsTxt);
|
await addDll(clientDir, m.registerInDllsTxt);
|
||||||
}
|
}
|
||||||
@@ -371,12 +358,18 @@ class ModsClass extends Observable<ModsStatus> {
|
|||||||
|
|
||||||
async #downloadTo(url: string, dest: string) {
|
async #downloadTo(url: string, dest: string) {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: { 'User-Agent': 'OctoLauncher' }
|
headers: { 'User-Agent': 'OctoLauncher' },
|
||||||
|
timeout: MOD_DOWNLOAD_TIMEOUT_MS
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Download failed ${res.status}: ${url}`);
|
if (!res.ok) throw new Error(`Download failed ${res.status}: ${url}`);
|
||||||
await fs.ensureDir(path.dirname(dest));
|
await fs.ensureDir(path.dirname(dest));
|
||||||
const buf = await res.arrayBuffer();
|
const buf = await res.arrayBuffer();
|
||||||
await fs.writeFile(dest, Buffer.from(buf));
|
await fs.writeFile(dest, Buffer.from(buf));
|
||||||
|
if (!(await fs.pathExists(dest)))
|
||||||
|
throw new Error(
|
||||||
|
`Downloaded file disappeared after writing: ${path.basename(dest)}. ` +
|
||||||
|
'This is often Windows Defender quarantine; if so, use "Allow through antivirus" and apply again.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async #savePref(id: ModId, state: ModState) {
|
async #savePref(id: ModId, state: ModState) {
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ abstract class Observable<T> {
|
|||||||
try {
|
try {
|
||||||
l(v);
|
l(v);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Observer threw, removing listener', err);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+65
-57
@@ -22,17 +22,24 @@ const Servers = {
|
|||||||
}
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type Tweak = { key: keyof PreferencesSchema['config']; default?: unknown; forced?: boolean } & (
|
type TweakKey =
|
||||||
| {
|
| { synthetic?: false; key: keyof PreferencesSchema['config'] }
|
||||||
type: 'bytes';
|
| { synthetic: true; key: string };
|
||||||
tweaks: [number, number[]][];
|
|
||||||
}
|
type Tweak = TweakKey & {
|
||||||
| {
|
default?: unknown;
|
||||||
type: 'int8' | 'uint16' | 'float';
|
forced?: boolean;
|
||||||
offset: number;
|
} & (
|
||||||
value?: number;
|
| {
|
||||||
}
|
type: 'bytes';
|
||||||
);
|
tweaks: [number, number[]][];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'int8' | 'uint16' | 'float';
|
||||||
|
offset: number;
|
||||||
|
value?: number;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const patchExecutable = async () => {
|
export const patchExecutable = async () => {
|
||||||
Logger.log('Patching WoW.exe...');
|
Logger.log('Patching WoW.exe...');
|
||||||
@@ -81,7 +88,8 @@ export const patchExecutable = async () => {
|
|||||||
{ key: 'nameplateRange', type: 'float', offset: 0x40c448 },
|
{ key: 'nameplateRange', type: 'float', offset: 0x40c448 },
|
||||||
{ key: 'cameraDistance', type: 'float', offset: 0x4089a4 },
|
{ key: 'cameraDistance', type: 'float', offset: 0x4089a4 },
|
||||||
{
|
{
|
||||||
key: 'crossFactionResurrect' as never,
|
synthetic: true,
|
||||||
|
key: 'crossFactionResurrect',
|
||||||
type: 'bytes',
|
type: 'bytes',
|
||||||
default: true,
|
default: true,
|
||||||
tweaks: [
|
tweaks: [
|
||||||
@@ -89,8 +97,10 @@ export const patchExecutable = async () => {
|
|||||||
[0x006e62a8, [0x006e62a9]]
|
[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',
|
type: 'bytes',
|
||||||
default: true,
|
default: true,
|
||||||
forced: true,
|
forced: true,
|
||||||
@@ -98,47 +108,41 @@ export const patchExecutable = async () => {
|
|||||||
[
|
[
|
||||||
0x002ddf90,
|
0x002ddf90,
|
||||||
[
|
[
|
||||||
0x55, 0x8b, 0xec, 0x83, 0xec, 0x08, 0x53, 0x56,
|
0x55, 0x8b, 0xec, 0x83, 0xec, 0x08, 0x53, 0x56, 0x57, 0x8b, 0x3d,
|
||||||
0x57, 0x8b, 0x3d, 0x60, 0xab, 0xce, 0x00, 0x83,
|
0x60, 0xab, 0xce, 0x00, 0x83, 0xff, 0xff, 0x89, 0x55, 0xfc, 0x89,
|
||||||
0xff, 0xff, 0x89, 0x55, 0xfc, 0x89, 0x4d, 0xf8,
|
0x4d, 0xf8, 0x74, 0x79, 0x8b, 0x75, 0x08, 0x8b, 0x15, 0x58, 0xab,
|
||||||
0x74, 0x79, 0x8b, 0x75, 0x08, 0x8b, 0x15, 0x58,
|
0xce, 0x00, 0x8b, 0xc7, 0x23, 0xc6, 0x8d, 0x04, 0x40, 0x8b, 0x4c,
|
||||||
0xab, 0xce, 0x00, 0x8b, 0xc7, 0x23, 0xc6, 0x8d,
|
0x82, 0x08, 0xf6, 0xc1, 0x01, 0x8d, 0x44, 0x82, 0x04, 0x75, 0x04,
|
||||||
0x04, 0x40, 0x8b, 0x4c, 0x82, 0x08, 0xf6, 0xc1,
|
0x85, 0xc9, 0x75, 0x05, 0x33, 0xc9, 0x8d, 0x49, 0x00, 0xf6, 0xc1,
|
||||||
0x01, 0x8d, 0x44, 0x82, 0x04, 0x75, 0x04, 0x85,
|
0x01, 0x75, 0x4e, 0x85, 0xc9, 0x74, 0x4a, 0x39, 0x31, 0x74, 0x13,
|
||||||
0xc9, 0x75, 0x05, 0x33, 0xc9, 0x8d, 0x49, 0x00,
|
0x8b, 0xc7, 0x23, 0xc6, 0x8d, 0x04, 0x40, 0x8d, 0x04, 0x82, 0x8b,
|
||||||
0xf6, 0xc1, 0x01, 0x75, 0x4e, 0x85, 0xc9, 0x74,
|
0x00, 0x03, 0xc1, 0x8b, 0x48, 0x04, 0xeb, 0xe0, 0x8b, 0x59, 0x1c,
|
||||||
0x4a, 0x39, 0x31, 0x74, 0x13, 0x8b, 0xc7, 0x23,
|
0x8b, 0x71, 0x18, 0x33, 0xff, 0x85, 0xdb, 0x7e, 0x27, 0x8d, 0x64,
|
||||||
0xc6, 0x8d, 0x04, 0x40, 0x8d, 0x04, 0x82, 0x8b,
|
0x24, 0x00, 0x8b, 0x4e, 0x0c, 0x8b, 0x56, 0x08, 0x6a, 0x00, 0x6a,
|
||||||
0x00, 0x03, 0xc1, 0x8b, 0x48, 0x04, 0xeb, 0xe0,
|
0x00, 0x51, 0x8b, 0x4d, 0xf8, 0x52, 0x8b, 0x55, 0xfc, 0xe8, 0xb9,
|
||||||
0x8b, 0x59, 0x1c, 0x8b, 0x71, 0x18, 0x33, 0xff,
|
0xfd, 0xff, 0xff, 0x84, 0xc0, 0x75, 0x13, 0x47, 0x83, 0xc6, 0x20,
|
||||||
0x85, 0xdb, 0x7e, 0x27, 0x8d, 0x64, 0x24, 0x00,
|
0x3b, 0xfb, 0x7c, 0xdd, 0x5f, 0x5e, 0x33, 0xc0, 0x5b, 0x8b, 0xe5,
|
||||||
0x8b, 0x4e, 0x0c, 0x8b, 0x56, 0x08, 0x6a, 0x00,
|
0x5d, 0xc2, 0x04, 0x00, 0x5f, 0x8b, 0xc6, 0x5e, 0x5b, 0x8b, 0xe5,
|
||||||
0x6a, 0x00, 0x51, 0x8b, 0x4d, 0xf8, 0x52, 0x8b,
|
0x5d, 0xc2, 0x04, 0x00, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90
|
||||||
0x55, 0xfc, 0xe8, 0xb9, 0xfd, 0xff, 0xff, 0x84,
|
|
||||||
0xc0, 0x75, 0x13, 0x47, 0x83, 0xc6, 0x20, 0x3b,
|
|
||||||
0xfb, 0x7c, 0xdd, 0x5f, 0x5e, 0x33, 0xc0, 0x5b,
|
|
||||||
0x8b, 0xe5, 0x5d, 0xc2, 0x04, 0x00, 0x5f, 0x8b,
|
|
||||||
0xc6, 0x5e, 0x5b, 0x8b, 0xe5, 0x5d, 0xc2, 0x04,
|
|
||||||
0x00, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90
|
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
] satisfies Tweak[];
|
] satisfies Tweak[];
|
||||||
|
|
||||||
// Apply patches
|
|
||||||
Tweaks.forEach(t => {
|
Tweaks.forEach(t => {
|
||||||
const val =
|
const val = t.synthetic
|
||||||
config[t.key] ?? t.default ?? ConfigWtfSchema.parse({})[t.key];
|
? t.default
|
||||||
|
: config[t.key] ?? t.default ?? ConfigWtfSchema.parse({})[t.key];
|
||||||
|
|
||||||
Logger.log(`Applying "${t.key}" patch with value: ${val}`);
|
Logger.log(`Applying "${t.key}" patch with value: ${val}`);
|
||||||
if (t.type === 'float') {
|
if (t.type === 'float') {
|
||||||
buffer.writeFloatLE(t.value ?? (val as never), t.offset);
|
buffer.writeFloatLE(t.value ?? (val as number), t.offset);
|
||||||
} else if (t.type === 'int8') {
|
} else if (t.type === 'int8') {
|
||||||
buffer.writeInt8(t.value ?? (val as never), t.offset);
|
buffer.writeInt8(t.value ?? (val as number), t.offset);
|
||||||
} else if (t.type === 'uint16') {
|
} else if (t.type === 'uint16') {
|
||||||
if (!t.forced && !val) return;
|
if (!t.forced && !val) return;
|
||||||
buffer.writeUInt16LE(t.value ?? (val as never), t.offset);
|
buffer.writeUInt16LE(t.value ?? (val as number), t.offset);
|
||||||
} else if (t.type === 'bytes') {
|
} else if (t.type === 'bytes') {
|
||||||
if (!t.forced && !val) return;
|
if (!t.forced && !val) return;
|
||||||
t.tweaks.forEach(([offset, bytes]) =>
|
t.tweaks.forEach(([offset, bytes]) =>
|
||||||
@@ -151,11 +155,12 @@ export const patchExecutable = async () => {
|
|||||||
Logger.log('WoW.exe successfully patched');
|
Logger.log('WoW.exe successfully patched');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Logger.error('Failed to patch WoW.exe', e);
|
Logger.error('Failed to patch WoW.exe', e);
|
||||||
|
throw e instanceof Error ? e : new Error('Failed to patch WoW.exe');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const patchConfig = async () => {
|
export const patchConfig = async (forceTweaks = false) => {
|
||||||
const { clientDir, server, config } = Preferences.data;
|
const { clientDir, server, config, locale } = Preferences.data;
|
||||||
if (!clientDir) return;
|
if (!clientDir) return;
|
||||||
|
|
||||||
const configPath = path.join(clientDir, 'WTF', 'Config.wtf');
|
const configPath = path.join(clientDir, 'WTF', 'Config.wtf');
|
||||||
@@ -163,14 +168,13 @@ export const patchConfig = async () => {
|
|||||||
const raw = (await fs.pathExists(configPath))
|
const raw = (await fs.pathExists(configPath))
|
||||||
? await fs.readFile(configPath, { encoding: 'utf-8' })
|
? await fs.readFile(configPath, { encoding: 'utf-8' })
|
||||||
: '';
|
: '';
|
||||||
if (raw) await fs.remove(configPath);
|
|
||||||
|
|
||||||
const configWtf = Object.fromEntries(
|
const configWtf = Object.fromEntries(
|
||||||
raw
|
raw
|
||||||
.split('\n')
|
.split(/\r?\n/)
|
||||||
.map(l => {
|
.map(l => {
|
||||||
const [_, k, v] = l.match(/SET (\w+) "(.+)"/) ?? [];
|
const [, k, v] = l.match(/SET (\w+) "(.*)"/) ?? [];
|
||||||
return !k || !v ? undefined : [k, v];
|
return !k || v === undefined ? undefined : [k, v];
|
||||||
})
|
})
|
||||||
.filter(isNotUndef)
|
.filter(isNotUndef)
|
||||||
);
|
);
|
||||||
@@ -210,20 +214,24 @@ export const patchConfig = async () => {
|
|||||||
gxMaximize: configWtf['gxMaximize'] ?? 1,
|
gxMaximize: configWtf['gxMaximize'] ?? 1,
|
||||||
gxCursor: configWtf['gxCursor'] ?? 1,
|
gxCursor: configWtf['gxCursor'] ?? 1,
|
||||||
checkAddonVersion: configWtf['checkAddonVersion'] ?? 0,
|
checkAddonVersion: configWtf['checkAddonVersion'] ?? 0,
|
||||||
|
farClip: configWtf['farClip'] ?? config.farClip,
|
||||||
|
CameraDistanceMax: configWtf['CameraDistanceMax'] ?? config.cameraDistance,
|
||||||
...configWtf,
|
...configWtf,
|
||||||
CameraDistanceMax: config.cameraDistance,
|
locale,
|
||||||
farClip: config.farClip,
|
|
||||||
realmList: Servers[server].realmList,
|
realmList: Servers[server].realmList,
|
||||||
hwDetect: 0,
|
hwDetect: 0,
|
||||||
M2UseShaders: 1
|
M2UseShaders: 1,
|
||||||
|
...(forceTweaks
|
||||||
|
? { farClip: config.farClip, CameraDistanceMax: config.cameraDistance }
|
||||||
|
: {})
|
||||||
};
|
};
|
||||||
|
|
||||||
await fs.writeFile(
|
const body = Object.entries(parsed)
|
||||||
configPath,
|
.filter(v => v[1] !== undefined && v[1] !== null)
|
||||||
Object.entries(parsed)
|
.map(l => `SET ${l[0]} "${l[1]}"`)
|
||||||
.filter(v => v[1] !== undefined && v[1] !== null)
|
.join('\n');
|
||||||
.map(l => `SET ${l[0]} "${l[1]}"`)
|
const tmpPath = `${configPath}.tmp`;
|
||||||
.join('\n')
|
await fs.writeFile(tmpPath, body);
|
||||||
);
|
await fs.move(tmpPath, configPath, { overwrite: true });
|
||||||
Logger.log('Config.wtf successfully patched');
|
Logger.log('Config.wtf successfully patched');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import path from 'path';
|
|||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import { type z } from 'zod';
|
import { type z } from 'zod';
|
||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
|
import Logger from 'electron-log/main';
|
||||||
|
|
||||||
import { PreferencesSchema } from '~common/schemas';
|
import { PreferencesSchema } from '~common/schemas';
|
||||||
import { omit } from '~common/utils';
|
import { omit } from '~common/utils';
|
||||||
@@ -11,6 +12,7 @@ const portableDir = process.env.PORTABLE_EXECUTABLE_DIR;
|
|||||||
|
|
||||||
abstract class Preferences {
|
abstract class Preferences {
|
||||||
static #data: z.infer<typeof PreferencesSchema>;
|
static #data: z.infer<typeof PreferencesSchema>;
|
||||||
|
static #writeChain: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
static readonly userDataDir = process.env.PORTABLE_EXECUTABLE_DIR
|
static readonly userDataDir = process.env.PORTABLE_EXECUTABLE_DIR
|
||||||
? path.join(process.env.PORTABLE_EXECUTABLE_DIR, '.launcher')
|
? path.join(process.env.PORTABLE_EXECUTABLE_DIR, '.launcher')
|
||||||
@@ -18,21 +20,44 @@ abstract class Preferences {
|
|||||||
|
|
||||||
static async load() {
|
static async load() {
|
||||||
await fs.ensureDir(this.userDataDir);
|
await fs.ensureDir(this.userDataDir);
|
||||||
|
const settingsPath = path.join(this.userDataDir, 'settings.json');
|
||||||
|
|
||||||
const userDataPath = path.join(this.userDataDir, 'settings.json');
|
let json: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
const json = await fs.readJSON(userDataPath);
|
json = await fs.readJSON(settingsPath);
|
||||||
return PreferencesSchema.parse({
|
} catch {
|
||||||
...json,
|
|
||||||
isPortable: !!portableDir,
|
|
||||||
clientDir: portableDir ?? json.clientDir
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
return PreferencesSchema.parse({
|
return PreferencesSchema.parse({
|
||||||
isPortable: !!portableDir,
|
isPortable: !!portableDir,
|
||||||
clientDir: portableDir
|
clientDir: portableDir
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const merged = {
|
||||||
|
...json,
|
||||||
|
isPortable: !!portableDir,
|
||||||
|
clientDir: portableDir ?? json.clientDir
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsed = PreferencesSchema.safeParse(merged);
|
||||||
|
if (parsed.success) return parsed.data;
|
||||||
|
|
||||||
|
Logger.warn(
|
||||||
|
'settings.json failed validation; salvaging valid fields',
|
||||||
|
parsed.error
|
||||||
|
);
|
||||||
|
await fs.copy(settingsPath, `${settingsPath}.corrupt`).catch(() => {});
|
||||||
|
|
||||||
|
const salvaged: Record<string, unknown> = {
|
||||||
|
isPortable: !!portableDir,
|
||||||
|
clientDir: portableDir ?? json.clientDir
|
||||||
|
};
|
||||||
|
const shape = PreferencesSchema.shape;
|
||||||
|
for (const key of Object.keys(shape) as (keyof typeof shape)[]) {
|
||||||
|
if (!(key in merged)) continue;
|
||||||
|
const value = (merged as Record<string, unknown>)[key];
|
||||||
|
if (shape[key].safeParse(value).success) salvaged[key] = value;
|
||||||
|
}
|
||||||
|
return PreferencesSchema.parse(salvaged);
|
||||||
}
|
}
|
||||||
|
|
||||||
static get data(): PreferencesSchema {
|
static get data(): PreferencesSchema {
|
||||||
@@ -41,14 +66,38 @@ abstract class Preferences {
|
|||||||
|
|
||||||
static set data(newData: Partial<Omit<PreferencesSchema, 'portableDir'>>) {
|
static set data(newData: Partial<Omit<PreferencesSchema, 'portableDir'>>) {
|
||||||
this.#data = { ...this.#data, ...newData };
|
this.#data = { ...this.#data, ...newData };
|
||||||
fs.writeJSON(
|
|
||||||
path.join(this.userDataDir, 'settings.json'),
|
const settingsPath = path.join(this.userDataDir, 'settings.json');
|
||||||
omit(
|
const delta = omit(
|
||||||
this.#data,
|
newData,
|
||||||
portableDir ? ['isPortable', 'clientDir'] : ['isPortable']
|
portableDir ? ['isPortable', 'clientDir'] : ['isPortable']
|
||||||
),
|
|
||||||
{ spaces: 2 }
|
|
||||||
);
|
);
|
||||||
|
const snapshot = omit(
|
||||||
|
this.#data,
|
||||||
|
portableDir ? ['isPortable', 'clientDir'] : ['isPortable']
|
||||||
|
);
|
||||||
|
this.#writeChain = this.#writeChain
|
||||||
|
.then(async () => {
|
||||||
|
let onDisk: unknown = null;
|
||||||
|
try {
|
||||||
|
onDisk = await fs.readJSON(settingsPath);
|
||||||
|
} catch {
|
||||||
|
onDisk = null;
|
||||||
|
}
|
||||||
|
const base =
|
||||||
|
!!onDisk && typeof onDisk === 'object' && !Array.isArray(onDisk)
|
||||||
|
? (onDisk as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const merged = base ? { ...base, ...delta } : snapshot;
|
||||||
|
const tmp = `${settingsPath}.tmp`;
|
||||||
|
await fs.writeJSON(tmp, merged, { spaces: 2 });
|
||||||
|
await fs.move(tmp, settingsPath, { overwrite: true });
|
||||||
|
})
|
||||||
|
.catch(e => Logger.error('Failed to persist settings.json', e));
|
||||||
|
}
|
||||||
|
|
||||||
|
static save() {
|
||||||
|
return this.#writeChain;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async isValidClientDir(clientDir?: string) {
|
static async isValidClientDir(clientDir?: string) {
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ export type SelfUpdaterStatus =
|
|||||||
| { state: 'checking'; currentVersion: string }
|
| { state: 'checking'; currentVersion: string }
|
||||||
| { state: 'unavailable'; currentVersion: string }
|
| { state: 'unavailable'; currentVersion: string }
|
||||||
| { state: 'available'; currentVersion: string; nextVersion: string }
|
| { state: 'available'; currentVersion: string; nextVersion: string }
|
||||||
| { state: 'downloading'; currentVersion: string; nextVersion: string; progress: number }
|
| {
|
||||||
|
state: 'downloading';
|
||||||
|
currentVersion: string;
|
||||||
|
nextVersion: string;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
| { state: 'ready'; currentVersion: string; nextVersion: string }
|
| { state: 'ready'; currentVersion: string; nextVersion: string }
|
||||||
| { state: 'error'; currentVersion: string; message: string };
|
| { state: 'error'; currentVersion: string; message: string };
|
||||||
|
|
||||||
@@ -37,7 +42,7 @@ class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
|
|||||||
this.#initialized = true;
|
this.#initialized = true;
|
||||||
|
|
||||||
if (is.dev) {
|
if (is.dev) {
|
||||||
Logger.info('[selfUpdater] dev mode — skipping');
|
Logger.info('[selfUpdater] dev mode, skipping');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +88,7 @@ class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
|
|||||||
});
|
});
|
||||||
autoUpdater.on('update-downloaded', info => {
|
autoUpdater.on('update-downloaded', info => {
|
||||||
Logger.info(
|
Logger.info(
|
||||||
`[selfUpdater] downloaded ${info.version} — awaiting user click`
|
`[selfUpdater] downloaded ${info.version}, awaiting user click`
|
||||||
);
|
);
|
||||||
this.status = {
|
this.status = {
|
||||||
state: 'ready',
|
state: 'ready',
|
||||||
@@ -100,11 +105,13 @@ class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
|
|||||||
triggerInstall() {
|
triggerInstall() {
|
||||||
if (this._value.state !== 'ready') {
|
if (this._value.state !== 'ready') {
|
||||||
Logger.warn(
|
Logger.warn(
|
||||||
`[selfUpdater] triggerInstall called in state ${this._value.state} — ignoring`
|
`[selfUpdater] triggerInstall called in state ${this._value.state}, ignoring`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Logger.info('[selfUpdater] user clicked install — quitting + running installer');
|
Logger.info(
|
||||||
|
'[selfUpdater] user clicked install, quitting + running installer'
|
||||||
|
);
|
||||||
autoUpdater.quitAndInstall(false, true);
|
autoUpdater.quitAndInstall(false, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+158
-46
@@ -50,6 +50,7 @@ const getAvailableDiskSpace = async (probePath?: string): Promise<number> => {
|
|||||||
os.homedir() ||
|
os.homedir() ||
|
||||||
(os.platform() === 'win32' ? 'C:\\' : '/');
|
(os.platform() === 'win32' ? 'C:\\' : '/');
|
||||||
try {
|
try {
|
||||||
|
// @ts-expect-error statfs exists at runtime (Node 18) but not @types/node 16
|
||||||
const s = await fs.promises.statfs(target);
|
const s = await fs.promises.statfs(target);
|
||||||
return Number(s.bsize) * Number(s.bavail);
|
return Number(s.bsize) * Number(s.bavail);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -65,11 +66,23 @@ const isReadOnly = async (filePath: string) => {
|
|||||||
try {
|
try {
|
||||||
const { mode } = await fs.stat(filePath);
|
const { mode } = await fs.stat(filePath);
|
||||||
return !(mode & fs.constants.S_IWUSR);
|
return !(mode & fs.constants.S_IWUSR);
|
||||||
} catch (e) {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const friendlyError = (e: unknown): string => {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
if (/EPERM|EACCES|EROFS|read-only/i.test(msg))
|
||||||
|
return (
|
||||||
|
'Cannot write to the game folder. Move your OctoWoW install out of ' +
|
||||||
|
'Program Files (or any protected folder), or run the launcher as ' +
|
||||||
|
'administrator, then try again.'
|
||||||
|
);
|
||||||
|
if (/ENOSPC/i.test(msg)) return 'Not enough disk space to finish the update.';
|
||||||
|
return msg;
|
||||||
|
};
|
||||||
|
|
||||||
type FolderTags = 'allowExtra';
|
type FolderTags = 'allowExtra';
|
||||||
type FileTags = 'vanillaFixes';
|
type FileTags = 'vanillaFixes';
|
||||||
type FileManifest = { name: string } & (
|
type FileManifest = { name: string } & (
|
||||||
@@ -141,18 +154,54 @@ export const isGameRunning = (executablePath: string) =>
|
|||||||
})
|
})
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const toUrlPath = (p: string) => p.split(path.sep).map(encodeURIComponent).join('/');
|
const toUrlPath = (p: string) =>
|
||||||
|
p.split(path.sep).map(encodeURIComponent).join('/');
|
||||||
|
|
||||||
const CDN_VERSION = import.meta.env.MAIN_VITE_CLIENT_VERSION || 'latest';
|
const CDN_VERSION = import.meta.env.MAIN_VITE_CLIENT_VERSION || 'latest';
|
||||||
|
|
||||||
|
const CONNECT_TIMEOUT_MS = 30_000;
|
||||||
|
const STALL_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
const isUnsafeName = (name: string) =>
|
||||||
|
!name ||
|
||||||
|
name === '.' ||
|
||||||
|
name === '..' ||
|
||||||
|
/[/\\]/.test(name) ||
|
||||||
|
path.isAbsolute(name);
|
||||||
|
|
||||||
|
const manifestPathsSafe = (m: FileManifest, isRoot = false): boolean => {
|
||||||
|
if (!isRoot && isUnsafeName(m.name)) return false;
|
||||||
|
if (m.type === 'dir' || m.type === 'mpq')
|
||||||
|
return m.files.every(f => manifestPathsSafe(f));
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const fetchManifest = async () => {
|
const fetchManifest = async () => {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(
|
const r = await fetch(
|
||||||
`${import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'}/api/file/${CDN_VERSION}/manifest.json`
|
`${
|
||||||
|
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
|
||||||
|
}/api/file/${CDN_VERSION}/manifest.json`,
|
||||||
|
{ timeout: CONNECT_TIMEOUT_MS }
|
||||||
);
|
);
|
||||||
|
if (!r.ok) {
|
||||||
|
Logger.error(`Update server returned HTTP ${r.status}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
|
if (!j || typeof j !== 'object' || !('root' in j) || !j.root) {
|
||||||
|
Logger.error('Update server returned a malformed manifest');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const root = j.root as FileManifest;
|
||||||
|
if (!manifestPathsSafe(root, true)) {
|
||||||
|
Logger.error(
|
||||||
|
'Update server manifest has unsafe path names; refusing it.'
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
await fs.writeJSON(path.join(Preferences.userDataDir, 'manifest.json'), j);
|
await fs.writeJSON(path.join(Preferences.userDataDir, 'manifest.json'), j);
|
||||||
return j.root as FileManifest;
|
return root;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Logger.error('Failed to reach update server', e);
|
Logger.error('Failed to reach update server', e);
|
||||||
return null;
|
return null;
|
||||||
@@ -160,22 +209,26 @@ const fetchManifest = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buildClientUrl = (filePath: string) =>
|
const buildClientUrl = (filePath: string) =>
|
||||||
`${import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'}/client/${CDN_VERSION}/${toUrlPath(
|
`${
|
||||||
path.normalize(filePath)
|
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
|
||||||
)}`;
|
}/client/${CDN_VERSION}/${toUrlPath(path.normalize(filePath))}`;
|
||||||
|
|
||||||
export const fetchFile = async (
|
export const fetchFile = async (
|
||||||
filePath: string,
|
filePath: string,
|
||||||
onChunk?: (deltaBytes: number) => void
|
onChunk?: (deltaBytes: number) => void
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(buildClientUrl(filePath));
|
const response = await fetch(buildClientUrl(filePath), {
|
||||||
|
timeout: CONNECT_TIMEOUT_MS
|
||||||
|
});
|
||||||
if (!response.ok) throw Error(`HTTP ${response.status}`);
|
if (!response.ok) throw Error(`HTTP ${response.status}`);
|
||||||
if (!onChunk || !response.body) return await response.arrayBuffer();
|
if (!onChunk || !response.body) return await response.arrayBuffer();
|
||||||
|
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
for await (const chunk of response.body as NodeJS.ReadableStream) {
|
for await (const chunk of response.body as NodeJS.ReadableStream) {
|
||||||
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
|
const buf = Buffer.isBuffer(chunk)
|
||||||
|
? chunk
|
||||||
|
: Buffer.from(chunk);
|
||||||
chunks.push(buf);
|
chunks.push(buf);
|
||||||
onChunk(buf.byteLength);
|
onChunk(buf.byteLength);
|
||||||
}
|
}
|
||||||
@@ -203,7 +256,8 @@ export const downloadFileToDisk = async (
|
|||||||
else if (stats.size >= expectedSize) {
|
else if (stats.size >= expectedSize) {
|
||||||
await fs.truncate(partPath, 0);
|
await fs.truncate(partPath, 0);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
Logger.warn(`Failed to resume from "${partPath}".`, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resumeFrom > 0) onChunk(resumeFrom);
|
if (resumeFrom > 0) onChunk(resumeFrom);
|
||||||
@@ -212,20 +266,25 @@ export const downloadFileToDisk = async (
|
|||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (resumeFrom > 0) headers.Range = `bytes=${resumeFrom}-`;
|
if (resumeFrom > 0) headers.Range = `bytes=${resumeFrom}-`;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(url, { headers });
|
response = await fetch(url, {
|
||||||
|
headers,
|
||||||
|
signal: controller.signal,
|
||||||
|
timeout: CONNECT_TIMEOUT_MS
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Logger.error(`Network error downloading ${filePath}`, e);
|
Logger.error(`Network error downloading ${filePath}`, e);
|
||||||
throw Error(`Failed to download ${path.normalize(filePath)}`);
|
throw Error(`Failed to download ${path.normalize(filePath)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok && response.status !== 206) {
|
if (!response.ok && response.status !== 206) {
|
||||||
throw Error(`Failed to download ${path.normalize(filePath)}: HTTP ${response.status}`);
|
throw Error(
|
||||||
|
`Failed to download ${path.normalize(filePath)}: HTTP ${response.status}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we got 200, the server gave us the whole file
|
|
||||||
// roll back and truncate
|
|
||||||
if (resumeFrom > 0 && response.status === 200) {
|
if (resumeFrom > 0 && response.status === 200) {
|
||||||
onChunk(-resumeFrom);
|
onChunk(-resumeFrom);
|
||||||
await fs.truncate(partPath, 0);
|
await fs.truncate(partPath, 0);
|
||||||
@@ -236,6 +295,7 @@ export const downloadFileToDisk = async (
|
|||||||
flags: resumeFrom > 0 ? 'a' : 'w'
|
flags: resumeFrom > 0 ? 'a' : 'w'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let stalled = false;
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
if (!response.body) {
|
if (!response.body) {
|
||||||
@@ -243,26 +303,53 @@ export const downloadFileToDisk = async (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const body = response.body as NodeJS.ReadableStream;
|
const body = response.body as NodeJS.ReadableStream;
|
||||||
|
let stallTimer: NodeJS.Timeout;
|
||||||
|
const armStall = () => {
|
||||||
|
clearTimeout(stallTimer);
|
||||||
|
stallTimer = setTimeout(() => {
|
||||||
|
stalled = true;
|
||||||
|
controller.abort();
|
||||||
|
}, STALL_TIMEOUT_MS);
|
||||||
|
};
|
||||||
|
armStall();
|
||||||
body.on('data', (chunk: Buffer | Uint8Array) => {
|
body.on('data', (chunk: Buffer | Uint8Array) => {
|
||||||
|
armStall();
|
||||||
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
if (!writeStream.write(buf)) body.pause();
|
if (!writeStream.write(buf)) body.pause();
|
||||||
onChunk(buf.byteLength);
|
onChunk(buf.byteLength);
|
||||||
});
|
});
|
||||||
writeStream.on('drain', () => body.resume());
|
writeStream.on('drain', () => body.resume());
|
||||||
body.on('end', () => writeStream.end(resolve));
|
body.on('end', () => {
|
||||||
body.on('error', reject);
|
clearTimeout(stallTimer);
|
||||||
writeStream.on('error', reject);
|
writeStream.end(resolve);
|
||||||
|
});
|
||||||
|
body.on('error', err => {
|
||||||
|
clearTimeout(stallTimer);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
writeStream.on('error', err => {
|
||||||
|
clearTimeout(stallTimer);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
writeStream.destroy();
|
writeStream.destroy();
|
||||||
Logger.error(`Download interrupted for ${filePath}`, e);
|
Logger.error(`Download interrupted for ${filePath}`, e);
|
||||||
throw Error(`Failed to download ${path.normalize(filePath)}`);
|
throw Error(
|
||||||
|
stalled
|
||||||
|
? `Download stalled for ${path.normalize(
|
||||||
|
filePath
|
||||||
|
)}; no data received in ${STALL_TIMEOUT_MS / 1000}s.`
|
||||||
|
: `Failed to download ${path.normalize(filePath)}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalStats = await fs.stat(partPath);
|
const finalStats = await fs.stat(partPath);
|
||||||
if (finalStats.size !== expectedSize) {
|
if (finalStats.size !== expectedSize) {
|
||||||
throw Error(
|
throw Error(
|
||||||
`Size mismatch for ${path.normalize(filePath)}: got ${finalStats.size}, expected ${expectedSize}. Will retry on next run.`
|
`Size mismatch for ${path.normalize(filePath)}: got ${
|
||||||
|
finalStats.size
|
||||||
|
}, expected ${expectedSize}. Will retry on next run.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,6 +552,10 @@ class UpdaterClass extends Observable<UpdaterStatus> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const vanillaFixes = Preferences.data.config.vanillaFixes;
|
const vanillaFixes = Preferences.data.config.vanillaFixes;
|
||||||
|
const modOwnedFiles = new Set<string>();
|
||||||
|
for (const state of Object.values(Preferences.data.mods ?? {}))
|
||||||
|
for (const rel of state?.installedFiles ?? [])
|
||||||
|
modOwnedFiles.add(rel.replace(/\\/g, '/').toLowerCase());
|
||||||
|
|
||||||
const hashTree = await fetchManifest();
|
const hashTree = await fetchManifest();
|
||||||
if (!hashTree) {
|
if (!hashTree) {
|
||||||
@@ -582,11 +673,10 @@ class UpdaterClass extends Observable<UpdaterStatus> {
|
|||||||
SFileCloseArchive(hMpq);
|
SFileCloseArchive(hMpq);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Logger.log(
|
Logger.warn(
|
||||||
`Failed to verify ${path.join(
|
`Failed to verify ${path.join(
|
||||||
...patchPath
|
...patchPath
|
||||||
)}, will be downloaded fresh`,
|
)}, will be downloaded fresh`,
|
||||||
'warning',
|
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -598,13 +688,13 @@ class UpdaterClass extends Observable<UpdaterStatus> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.tags?.includes('vanillaFixes') && !vanillaFixes) {
|
if (item.tags?.includes('vanillaFixes')) {
|
||||||
if (await fs.exists(path.join(clientPath, ...filePath))) {
|
if (modOwnedFiles.has(filePath.join('/').toLowerCase()))
|
||||||
return {
|
return undefined;
|
||||||
type: 'del',
|
|
||||||
name: item.name
|
if (!vanillaFixes) {
|
||||||
};
|
if (await fs.exists(path.join(clientPath, ...filePath)))
|
||||||
} else {
|
return { type: 'del', name: item.name };
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -678,8 +768,7 @@ class UpdaterClass extends Observable<UpdaterStatus> {
|
|||||||
const currentLauncherVersion = app.getVersion();
|
const currentLauncherVersion = app.getVersion();
|
||||||
if (
|
if (
|
||||||
this.status.state === 'upToDate' &&
|
this.status.state === 'upToDate' &&
|
||||||
Preferences.data.lastPatchedLauncherVersion !==
|
Preferences.data.lastPatchedLauncherVersion !== currentLauncherVersion
|
||||||
currentLauncherVersion
|
|
||||||
) {
|
) {
|
||||||
Logger.log(
|
Logger.log(
|
||||||
`Launcher version changed (${
|
`Launcher version changed (${
|
||||||
@@ -710,8 +799,7 @@ class UpdaterClass extends Observable<UpdaterStatus> {
|
|||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message =
|
const message = friendlyError(e);
|
||||||
e instanceof Error ? e.message : 'Unexpected error occurred';
|
|
||||||
Logger.error(`Verification failed: ${message}`, e);
|
Logger.error(`Verification failed: ${message}`, e);
|
||||||
this.status = { state: 'failed', message };
|
this.status = { state: 'failed', message };
|
||||||
}
|
}
|
||||||
@@ -744,6 +832,22 @@ class UpdaterClass extends Observable<UpdaterStatus> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (clean) {
|
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 = {
|
this.status = {
|
||||||
state: 'updating',
|
state: 'updating',
|
||||||
progress: -1,
|
progress: -1,
|
||||||
@@ -804,12 +908,10 @@ class UpdaterClass extends Observable<UpdaterStatus> {
|
|||||||
if (!item) return undefined;
|
if (!item) return undefined;
|
||||||
|
|
||||||
if (item.type === 'del') {
|
if (item.type === 'del') {
|
||||||
throw Error(
|
if (SFileHasFile(hMpq, path.join(...filePath)))
|
||||||
`TODO: Deleting of files from MPQ not implemented at path ${path.join(
|
SFileRemoveFile(hMpq, path.join(...filePath));
|
||||||
...mpqPath,
|
nestedSet(this.#cache, [...mpqPath, ...filePath], undefined);
|
||||||
...filePath
|
return;
|
||||||
)}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.type === 'dir') {
|
if (item.type === 'dir') {
|
||||||
@@ -826,7 +928,9 @@ class UpdaterClass extends Observable<UpdaterStatus> {
|
|||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const label = `Patching: [${mpqPath.at(-1)}] "${path.join(...filePath)}"`;
|
const label = `Patching: [${mpqPath.at(-1)}] "${path.join(
|
||||||
|
...filePath
|
||||||
|
)}"`;
|
||||||
emitProgress(label, true);
|
emitProgress(label, true);
|
||||||
|
|
||||||
const data = await fetchFile(
|
const data = await fetchFile(
|
||||||
@@ -853,6 +957,7 @@ class UpdaterClass extends Observable<UpdaterStatus> {
|
|||||||
} finally {
|
} finally {
|
||||||
SFileFinishFile(hFile);
|
SFileFinishFile(hFile);
|
||||||
}
|
}
|
||||||
|
nestedSet(this.#cache, [...mpqPath, ...filePath], undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const iterateTree = async (...filePath: string[]) => {
|
const iterateTree = async (...filePath: string[]) => {
|
||||||
@@ -922,7 +1027,7 @@ class UpdaterClass extends Observable<UpdaterStatus> {
|
|||||||
if (item.name === 'WoW.exe') executableUpdate = true;
|
if (item.name === 'WoW.exe') executableUpdate = true;
|
||||||
|
|
||||||
const fullPath = path.join(clientPath, ...filePath);
|
const fullPath = path.join(clientPath, ...filePath);
|
||||||
if (await fs.exists(fullPath) && (await isReadOnly(fullPath)))
|
if ((await fs.exists(fullPath)) && (await isReadOnly(fullPath)))
|
||||||
throw Error(`Failed to update "${fullPath}" because it's read-only.`);
|
throw Error(`Failed to update "${fullPath}" because it's read-only.`);
|
||||||
|
|
||||||
await downloadFileToDisk(
|
await downloadFileToDisk(
|
||||||
@@ -947,7 +1052,6 @@ class UpdaterClass extends Observable<UpdaterStatus> {
|
|||||||
|
|
||||||
if (executableUpdate || launcherVersionChanged) {
|
if (executableUpdate || launcherVersionChanged) {
|
||||||
await patchExecutable();
|
await patchExecutable();
|
||||||
await this.#getHash({ clientPath }, 'WoW.exe');
|
|
||||||
const patchedWowHash = await this.#getHash({ clientPath }, 'WoW.exe');
|
const patchedWowHash = await this.#getHash({ clientPath }, 'WoW.exe');
|
||||||
await this.#saveCache();
|
await this.#saveCache();
|
||||||
Preferences.data = {
|
Preferences.data = {
|
||||||
@@ -960,13 +1064,21 @@ class UpdaterClass extends Observable<UpdaterStatus> {
|
|||||||
this.#bytesAlreadyOnDisk = fullClientTotal;
|
this.#bytesAlreadyOnDisk = fullClientTotal;
|
||||||
this.status = { state: 'upToDate', progress: 1 };
|
this.status = { state: 'upToDate', progress: 1 };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
Logger.error('Update failed', e);
|
||||||
this.status = {
|
this.status = { state: 'failed', message: friendlyError(e) };
|
||||||
state: 'failed',
|
|
||||||
message: e instanceof Error ? e.message : 'Unexpected error occurred'
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async recordPatchedWow() {
|
||||||
|
const clientPath = Preferences.data.clientDir;
|
||||||
|
if (!clientPath) return;
|
||||||
|
const patchedWowHash = await this.#getHash({ clientPath }, 'WoW.exe');
|
||||||
|
await this.#saveCache();
|
||||||
|
Preferences.data = {
|
||||||
|
lastPatchedLauncherVersion: app.getVersion(),
|
||||||
|
expectedPatchedWowHash: patchedWowHash
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const Updater = new UpdaterClass();
|
const Updater = new UpdaterClass();
|
||||||
|
|||||||
+34
-7
@@ -6,8 +6,15 @@ import fs from 'fs-extra';
|
|||||||
|
|
||||||
import Preferences from './modules/preferences';
|
import Preferences from './modules/preferences';
|
||||||
|
|
||||||
const isCallbackResponse = (data: any): data is { cb: string; args: any[] } =>
|
const isCallbackResponse = (
|
||||||
data && typeof data === 'object' && 'cb' in data && 'args' in data;
|
data: unknown
|
||||||
|
): data is { cb: string; args: unknown[] } =>
|
||||||
|
typeof data === 'object' &&
|
||||||
|
data !== null &&
|
||||||
|
'cb' in data &&
|
||||||
|
typeof (data as { cb: unknown }).cb === 'string' &&
|
||||||
|
'args' in data &&
|
||||||
|
Array.isArray((data as { args: unknown }).args);
|
||||||
|
|
||||||
export const runWorker = <T>(
|
export const runWorker = <T>(
|
||||||
worker: (o: WorkerOptions) => Worker,
|
worker: (o: WorkerOptions) => Worker,
|
||||||
@@ -16,10 +23,16 @@ export const runWorker = <T>(
|
|||||||
) =>
|
) =>
|
||||||
new Promise<T>((resolve, reject) =>
|
new Promise<T>((resolve, reject) =>
|
||||||
worker({ workerData })
|
worker({ workerData })
|
||||||
.on('message', m =>
|
.on('message', (m: unknown) => {
|
||||||
isCallbackResponse(m) ? callbacks?.[m.cb](...m.args) : resolve(m)
|
if (!isCallbackResponse(m)) return resolve(m as T);
|
||||||
)
|
const callback = callbacks?.[m.cb];
|
||||||
|
if (callback) callback(...m.args);
|
||||||
|
else Logger.warn('Unknown worker callback', m.cb);
|
||||||
|
})
|
||||||
.on('error', reject)
|
.on('error', reject)
|
||||||
|
.on('exit', code =>
|
||||||
|
reject(new Error(`Worker exited (code ${code}) without finishing`))
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getClientVersion = async () => {
|
export const getClientVersion = async () => {
|
||||||
@@ -35,8 +48,22 @@ export const getClientVersion = async () => {
|
|||||||
const file = await fs.readFile(exePath);
|
const file = await fs.readFile(exePath);
|
||||||
const buffer = Buffer.from(file);
|
const buffer = Buffer.from(file);
|
||||||
|
|
||||||
const version = buffer.toString('utf-8', 0x00437c04, 0x00437c04 + 6);
|
// Fixed addresses in the 1.12.1 client binary.
|
||||||
const build = buffer.toString('utf-8', 0x00437bfc, 0x00437bfc + 4);
|
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})`);
|
Logger.log(`Client version is: ${version} (${build})`);
|
||||||
return `${version} (${build})`;
|
return `${version} (${build})`;
|
||||||
|
|||||||
@@ -9,15 +9,27 @@ if (!port) throw new Error('IllegalState');
|
|||||||
|
|
||||||
const { dir, url, ref } = workerData;
|
const { dir, url, ref } = workerData;
|
||||||
|
|
||||||
fs.removeSync(dir);
|
const tmpDir = `${dir}.tmp`;
|
||||||
git
|
|
||||||
.clone({
|
const run = async () => {
|
||||||
dir,
|
await fs.remove(tmpDir);
|
||||||
|
await git.clone({
|
||||||
|
dir: tmpDir,
|
||||||
fs,
|
fs,
|
||||||
http,
|
http,
|
||||||
url,
|
url,
|
||||||
ref,
|
ref,
|
||||||
singleBranch: !ref || ref === 'master' || ref === 'main',
|
singleBranch: !ref || ref === 'master' || ref === 'main',
|
||||||
onProgress: (...args) => port.postMessage({ cb: 'onProgress', args })
|
onProgress: (...args) => port.postMessage({ cb: 'onProgress', args })
|
||||||
})
|
});
|
||||||
.then(() => port.postMessage(true));
|
|
||||||
|
await fs.remove(dir);
|
||||||
|
await fs.move(tmpDir, dir);
|
||||||
|
};
|
||||||
|
|
||||||
|
run()
|
||||||
|
.then(() => port.postMessage(true))
|
||||||
|
.catch(async err => {
|
||||||
|
await fs.remove(tmpDir).catch(() => undefined);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|||||||
+14
-14
@@ -5,7 +5,7 @@ import http from 'isomorphic-git/http/node';
|
|||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
|
|
||||||
const port = parentPort;
|
const port = parentPort;
|
||||||
if (!port) throw new Error('IllegalState');
|
if (!port) throw new Error('gitPull worker has no parentPort');
|
||||||
|
|
||||||
const { dir, remote, branch, ref } = workerData as {
|
const { dir, remote, branch, ref } = workerData as {
|
||||||
dir: string;
|
dir: string;
|
||||||
@@ -17,20 +17,17 @@ const { dir, remote, branch, ref } = workerData as {
|
|||||||
const onProgress = (...args: unknown[]) =>
|
const onProgress = (...args: unknown[]) =>
|
||||||
port.postMessage({ cb: 'onProgress', args });
|
port.postMessage({ cb: 'onProgress', args });
|
||||||
|
|
||||||
const removeUntrackedFiles = async () => {
|
|
||||||
const status = await git.statusMatrix({ fs, dir });
|
|
||||||
await Promise.all(
|
|
||||||
status
|
|
||||||
.filter(([, HEAD]) => HEAD === 0)
|
|
||||||
.map(([filepath]) => fs.remove(`${dir}/${filepath}`))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
if (ref) {
|
if (ref) {
|
||||||
await git.fetch({ fs, http, dir, tags: true, singleBranch: false, onProgress });
|
await git.fetch({
|
||||||
|
fs,
|
||||||
|
http,
|
||||||
|
dir,
|
||||||
|
tags: true,
|
||||||
|
singleBranch: false,
|
||||||
|
onProgress
|
||||||
|
});
|
||||||
await git.checkout({ fs, dir, force: true, ref, onProgress });
|
await git.checkout({ fs, dir, force: true, ref, onProgress });
|
||||||
await removeUntrackedFiles();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +38,6 @@ const run = async () => {
|
|||||||
ref: `${remote}/${branch}`,
|
ref: `${remote}/${branch}`,
|
||||||
onProgress
|
onProgress
|
||||||
});
|
});
|
||||||
await removeUntrackedFiles();
|
|
||||||
await git.pull({
|
await git.pull({
|
||||||
fs,
|
fs,
|
||||||
http,
|
http,
|
||||||
@@ -53,4 +49,8 @@ const run = async () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
run().then(() => port.postMessage(true));
|
run()
|
||||||
|
.then(() => port.postMessage(true))
|
||||||
|
.catch(err => {
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { api } from './utils/api';
|
import { api } from './utils/api';
|
||||||
import PageBackground from './assets/background.png';
|
import PageBackground from './assets/background.png';
|
||||||
|
import AntivirusModal from './components/AntivirusModal';
|
||||||
import Header from './components/Header';
|
import Header from './components/Header';
|
||||||
import LaunchPanel from './components/LaunchPanel';
|
import LaunchPanel from './components/LaunchPanel';
|
||||||
import SelfUpdateBanner from './components/SelfUpdateBanner';
|
import SelfUpdateBanner from './components/SelfUpdateBanner';
|
||||||
@@ -38,12 +39,13 @@ const App = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Launcher build label, anchored bottom-right.*/}
|
|
||||||
{appVersion && (
|
{appVersion && (
|
||||||
<span className="pointer-events-none absolute bottom-2 right-3 text-[10px] font-mono uppercase tracking-wider text-white/40 select-none">
|
<span className="pointer-events-none absolute bottom-2 right-3 select-none font-mono text-[10px] uppercase tracking-wider text-white/40">
|
||||||
v{appVersion}
|
v{appVersion}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AntivirusModal />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Clipboard, RefreshCw, ServerCrash } from 'lucide-react';
|
|||||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||||
import log from 'electron-log/renderer';
|
import log from 'electron-log/renderer';
|
||||||
|
|
||||||
|
import { useT } from '~renderer/i18n';
|
||||||
|
|
||||||
import PageBackground from './assets/background.png';
|
import PageBackground from './assets/background.png';
|
||||||
import TextButton from './components/styled/TextButton';
|
import TextButton from './components/styled/TextButton';
|
||||||
|
|
||||||
@@ -15,6 +17,59 @@ type Props = {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ErrorFallback = ({
|
||||||
|
error,
|
||||||
|
errorInfo
|
||||||
|
}: {
|
||||||
|
error?: Error;
|
||||||
|
errorInfo?: ErrorInfo;
|
||||||
|
}) => {
|
||||||
|
const t = useT();
|
||||||
|
const title = t('misc.uncaughtError', {
|
||||||
|
name: error?.name ?? '',
|
||||||
|
message: error?.message ?? t('misc.unknownError')
|
||||||
|
});
|
||||||
|
const detail = errorInfo?.componentStack?.slice(1);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative flex h-screen w-screen grow flex-col overflow-hidden border border-blueGray/10 bg-cover bg-top bg-no-repeat p-3"
|
||||||
|
style={{ backgroundImage: `url(${PageBackground})` }}
|
||||||
|
>
|
||||||
|
<div className="tw-surface flex grow flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ServerCrash size={26} className="text-red" />
|
||||||
|
<h3 className="text-red">{t('misc.somethingWentWrong')}</h3>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<div className="text-white">{title}</div>
|
||||||
|
<pre className="s1 -mt-2 grow overflow-auto text-blueGray">
|
||||||
|
{detail}
|
||||||
|
</pre>
|
||||||
|
<hr />
|
||||||
|
<div className="-mx-3 -mb-3 flex justify-end gap-2">
|
||||||
|
<TextButton
|
||||||
|
icon={Clipboard}
|
||||||
|
onClick={() =>
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
`\`\`\`\n${title}\n${detail}\n\`\`\``
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('misc.copyError')}
|
||||||
|
</TextButton>
|
||||||
|
<TextButton
|
||||||
|
icon={RefreshCw}
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="text-warmGreen"
|
||||||
|
>
|
||||||
|
{t('misc.reload')}
|
||||||
|
</TextButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
class ErrorBoundary extends Component<Props, State> {
|
class ErrorBoundary extends Component<Props, State> {
|
||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
super(props);
|
super(props);
|
||||||
@@ -29,48 +84,7 @@ class ErrorBoundary extends Component<Props, State> {
|
|||||||
render() {
|
render() {
|
||||||
if (!this.state.didCatch) return this.props.children;
|
if (!this.state.didCatch) return this.props.children;
|
||||||
const { error, errorInfo } = this.state;
|
const { error, errorInfo } = this.state;
|
||||||
const title = `Uncaught ${error?.name}: ${
|
return <ErrorFallback error={error} errorInfo={errorInfo} />;
|
||||||
error?.message ?? 'Unknown error'
|
|
||||||
}`;
|
|
||||||
const detail = errorInfo?.componentStack.slice(1);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="relative flex h-screen w-screen grow flex-col overflow-hidden border border-blueGray/10 bg-cover bg-top bg-no-repeat p-3"
|
|
||||||
style={{ backgroundImage: `url(${PageBackground})` }}
|
|
||||||
>
|
|
||||||
<div className="tw-surface flex grow flex-col gap-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ServerCrash size={26} className="text-red" />
|
|
||||||
<h3 className="text-red">Something went wrong!</h3>
|
|
||||||
</div>
|
|
||||||
<hr />
|
|
||||||
<div className="text-white">{title}</div>
|
|
||||||
<pre className="s1 -mt-2 grow overflow-auto text-blueGray">
|
|
||||||
{detail}
|
|
||||||
</pre>
|
|
||||||
<hr />
|
|
||||||
<div className="-mx-3 -mb-3 flex justify-end gap-2">
|
|
||||||
<TextButton
|
|
||||||
icon={Clipboard}
|
|
||||||
onClick={() =>
|
|
||||||
navigator.clipboard.writeText(
|
|
||||||
`\`\`\`\n${title}\n${detail}\n\`\`\``
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Copy error
|
|
||||||
</TextButton>
|
|
||||||
<TextButton
|
|
||||||
icon={RefreshCw}
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
className="text-warmGreen"
|
|
||||||
>
|
|
||||||
Reload
|
|
||||||
</TextButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { ShieldAlert, HelpCircle } from 'lucide-react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import { api } from '~renderer/utils/api';
|
||||||
|
import { type ModsStatus } from '~main/types';
|
||||||
|
import { useT } from '~renderer/i18n';
|
||||||
|
|
||||||
|
import TextButton from './styled/TextButton';
|
||||||
|
|
||||||
|
const DETECTION = 'Trojan:Win32/Vigorf.A';
|
||||||
|
|
||||||
|
const AntivirusModal = () => {
|
||||||
|
const t = useT();
|
||||||
|
const [status, setStatus] = useState<ModsStatus>();
|
||||||
|
api.mods.observe.useSubscription(undefined, { onData: setStatus });
|
||||||
|
|
||||||
|
const addExclusion = api.general.addDefenderExclusion.useMutation();
|
||||||
|
|
||||||
|
const blocked = (status?.mods ?? [])
|
||||||
|
.filter(m => m.state === 'error' && m.error?.includes('Defender'))
|
||||||
|
.map(m => m.name);
|
||||||
|
const blockedKey = blocked.join(',');
|
||||||
|
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
const [view, setView] = useState<'av' | 'why' | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (view) {
|
||||||
|
dialogRef.current?.showModal();
|
||||||
|
(document.activeElement as HTMLElement | null)?.blur();
|
||||||
|
} else dialogRef.current?.close();
|
||||||
|
}, [view]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (blockedKey) setView('av');
|
||||||
|
}, [blockedKey]);
|
||||||
|
|
||||||
|
const names = blockedKey ? blockedKey.split(',') : [];
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
onClose={() => setView(null)}
|
||||||
|
className="h-full w-full items-center justify-center bg-[transparent] backdrop:backdrop-blur-sm [&[open]]:flex"
|
||||||
|
>
|
||||||
|
{view === 'av' && (
|
||||||
|
<div className="tw-dialog !w-fit min-w-[380px] max-w-[480px] !gap-3">
|
||||||
|
<h3 className="tw-color">{t('av.blockedTitle')}</h3>
|
||||||
|
<p className="s1">
|
||||||
|
{names.length === 1 ? t('av.blockedOne') : t('av.blockedMany')}
|
||||||
|
</p>
|
||||||
|
<ul className="gap-0.5 flex flex-col pl-1">
|
||||||
|
{names.map(n => (
|
||||||
|
<li key={n} className="s1 text-orange">
|
||||||
|
• {n}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="s1 text-blueGray">{t('av.blockedExplain')}</p>
|
||||||
|
<TextButton
|
||||||
|
icon={HelpCircle}
|
||||||
|
onClick={() => setView('why')}
|
||||||
|
className="self-start text-yellow hocus:!text-yellow"
|
||||||
|
>
|
||||||
|
{t('av.whyHappening')}
|
||||||
|
</TextButton>
|
||||||
|
<TextButton
|
||||||
|
icon={ShieldAlert}
|
||||||
|
loading={addExclusion.isLoading}
|
||||||
|
onClick={() => addExclusion.mutate()}
|
||||||
|
className="self-start text-orange"
|
||||||
|
>
|
||||||
|
{t('av.allowThrough')}
|
||||||
|
</TextButton>
|
||||||
|
{addExclusion.data?.ok === true && (
|
||||||
|
<span className="s1 text-warmGreen">{t('av.addedRetry')}</span>
|
||||||
|
)}
|
||||||
|
{addExclusion.data?.ok === false && (
|
||||||
|
<span className="s1 text-orange">{addExclusion.data.error}</span>
|
||||||
|
)}
|
||||||
|
<div className="mt-1 flex justify-end">
|
||||||
|
<TextButton onClick={() => setView(null)} className="text-green">
|
||||||
|
{t('av.close')}
|
||||||
|
</TextButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'why' && (
|
||||||
|
<div className="tw-dialog !w-fit min-w-[420px] max-w-[560px] !gap-3">
|
||||||
|
<h3 className="tw-color">{t('av.whyTitle')}</h3>
|
||||||
|
<div className="flex max-h-[60vh] transform-gpu flex-col gap-3 overflow-y-auto overscroll-contain pr-1 [will-change:transform]">
|
||||||
|
<p className="s1 text-blueGray">
|
||||||
|
{t('av.whyIntro', { detection: DETECTION })}{' '}
|
||||||
|
<span className="text-warmGreen">{t('av.falsePositive')}</span>.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<p className="tw-color text-[21px] leading-tight">
|
||||||
|
{t('av.whatSetsItOff')}
|
||||||
|
</p>
|
||||||
|
<p className="s1 text-blueGray">{t('av.whatSetsItOffIntro')}</p>
|
||||||
|
<ul className="mt-1 flex flex-col gap-1 pl-1">
|
||||||
|
<li className="s1 text-blueGray">
|
||||||
|
<span className="text-orange">{t('av.dllInjection')}</span>:{' '}
|
||||||
|
{t('av.dllInjectionText')}
|
||||||
|
</li>
|
||||||
|
<li className="s1 text-blueGray">
|
||||||
|
<span className="text-orange">{t('av.exePatching')}</span>:{' '}
|
||||||
|
{t('av.exePatchingText')}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="s1 mt-1 text-blueGray">{t('av.heuristicNote')}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="tw-color text-[21px] leading-tight">
|
||||||
|
{t('av.whatModsDo')}
|
||||||
|
</p>
|
||||||
|
<ul className="mt-1 flex flex-col gap-1 pl-1">
|
||||||
|
<li className="s1 text-blueGray">
|
||||||
|
<span className="text-warmGreen">VanillaFixes</span>:{' '}
|
||||||
|
{t('av.modVanillaFixes')}
|
||||||
|
</li>
|
||||||
|
<li className="s1 text-blueGray">
|
||||||
|
<span className="text-warmGreen">nampower</span>:{' '}
|
||||||
|
{t('av.modNampower')}
|
||||||
|
</li>
|
||||||
|
<li className="s1 text-blueGray">
|
||||||
|
<span className="text-warmGreen">SuperWoW and UnitXP</span>:{' '}
|
||||||
|
{t('av.modSuperWowUnitXp')}
|
||||||
|
</li>
|
||||||
|
<li className="s1 text-blueGray">
|
||||||
|
<span className="text-warmGreen">DXVK</span>:{' '}
|
||||||
|
{t('av.modDxvk')}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="tw-color text-[21px] leading-tight">
|
||||||
|
{t('av.howToVerify')}
|
||||||
|
</p>
|
||||||
|
<p className="s1 mt-1 text-blueGray">
|
||||||
|
{t('av.howToVerifyText', {
|
||||||
|
detection: DETECTION
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="tw-color text-[21px] leading-tight">
|
||||||
|
{t('av.whatAllowDoesTitle')}
|
||||||
|
</p>
|
||||||
|
<p className="s1 mt-1 text-blueGray">
|
||||||
|
{t('av.whatAllowDoesIntro')}{' '}
|
||||||
|
<span className="text-warmGreen">Add-MpPreference</span>{' '}
|
||||||
|
{t('av.whatAllowDoesIntroAfter')}
|
||||||
|
</p>
|
||||||
|
<ul className="mt-1 flex flex-col gap-1 pl-1">
|
||||||
|
<li className="s1 text-blueGray">
|
||||||
|
{t('av.exclusionFoldersBefore')}{' '}
|
||||||
|
<span className="text-warmGreen">{t('av.gameFolder')}</span>{' '}
|
||||||
|
{t('av.exclusionFoldersAnd')}{' '}
|
||||||
|
<span className="text-warmGreen">
|
||||||
|
{t('av.launcherFolder')}
|
||||||
|
</span>
|
||||||
|
{t('av.exclusionFoldersAfter')}
|
||||||
|
</li>
|
||||||
|
<li className="s1 text-blueGray">
|
||||||
|
<span className="text-warmGreen">WoW.exe</span>{' '}
|
||||||
|
{t('av.exclusionExesAnd')}{' '}
|
||||||
|
<span className="text-warmGreen">VanillaFixes.exe</span>{' '}
|
||||||
|
{t('av.exclusionExesAfter')}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="s1 mt-1 text-blueGray">
|
||||||
|
{t('av.whatAllowDoesOutro')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-3">
|
||||||
|
<TextButton onClick={() => setView('av')} className="text-blueGray">
|
||||||
|
{t('av.back')}
|
||||||
|
</TextButton>
|
||||||
|
<TextButton onClick={() => setView(null)} className="text-green">
|
||||||
|
{t('av.close')}
|
||||||
|
</TextButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dialog>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AntivirusModal;
|
||||||
@@ -4,6 +4,7 @@ import { useEffect } from 'react';
|
|||||||
import { PreferencesSchema } from '~common/schemas';
|
import { PreferencesSchema } from '~common/schemas';
|
||||||
import zodResolver from '~renderer/utils/zodResolver';
|
import zodResolver from '~renderer/utils/zodResolver';
|
||||||
import { api } from '~renderer/utils/api';
|
import { api } from '~renderer/utils/api';
|
||||||
|
import { useT } from '~renderer/i18n';
|
||||||
|
|
||||||
import TextButton from './styled/TextButton';
|
import TextButton from './styled/TextButton';
|
||||||
import FilePickerInput from './form/FilePickerInput';
|
import FilePickerInput from './form/FilePickerInput';
|
||||||
@@ -12,6 +13,7 @@ import CloseButton from './styled/CloseButton';
|
|||||||
type Props = { close: () => void };
|
type Props = { close: () => void };
|
||||||
|
|
||||||
const ClientDirDialog = ({ close }: Props) => {
|
const ClientDirDialog = ({ close }: Props) => {
|
||||||
|
const t = useT();
|
||||||
const { data: pref } = api.preferences.get.useQuery();
|
const { data: pref } = api.preferences.get.useQuery();
|
||||||
const setPref = api.preferences.set.useMutation();
|
const setPref = api.preferences.set.useMutation();
|
||||||
const isValidClientDir = api.preferences.isValidClientDir.useQuery(
|
const isValidClientDir = api.preferences.isValidClientDir.useQuery(
|
||||||
@@ -42,16 +44,14 @@ const ClientDirDialog = ({ close }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<form className="tw-dialog">
|
<form className="tw-dialog">
|
||||||
<CloseButton close={close} />
|
<CloseButton close={close} />
|
||||||
<h2 className="color mb-2 text-xl">Install location</h2>
|
<h2 className="color mb-2 text-xl">
|
||||||
<p>
|
{t('prefs.installLocationTitle')}
|
||||||
You are using the portable version of the launcher. Install location
|
</h2>
|
||||||
is determined by the location of the launcher executable.
|
<p>{t('prefs.portableInfo')}</p>
|
||||||
</p>
|
|
||||||
{!isValidClientDir.isLoading && !isValidClientDir.data && (
|
{!isValidClientDir.isLoading && !isValidClientDir.data && (
|
||||||
<p>
|
<p>
|
||||||
<span className="text-secondary">Error: </span>
|
<span className="text-secondary">{t('prefs.errorLabel')}</span>
|
||||||
WoW.exe not found in current folder. Please close the launcher and
|
{t('prefs.wowExeNotFound', { exe: 'WoW.exe' })}
|
||||||
move it to your WoW 1.12 client directory.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
@@ -64,7 +64,7 @@ const ClientDirDialog = ({ close }: Props) => {
|
|||||||
onSubmit={handleSubmit(async ({ clientDir }) => {
|
onSubmit={handleSubmit(async ({ clientDir }) => {
|
||||||
try {
|
try {
|
||||||
await setPref.mutateAsync({ clientDir });
|
await setPref.mutateAsync({ clientDir });
|
||||||
verify.mutateAsync();
|
verify.mutate();
|
||||||
close();
|
close();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError('clientDir', {
|
setError('clientDir', {
|
||||||
@@ -79,18 +79,13 @@ const ClientDirDialog = ({ close }: Props) => {
|
|||||||
close();
|
close();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<h3 className="tw-color">Install location</h3>
|
<h3 className="tw-color">{t('prefs.installLocationTitle')}</h3>
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<p className="text-blueGray">
|
<p className="text-blueGray">{t('prefs.selectDirectory')}</p>
|
||||||
Select a directory for the game client installation.
|
<p className="text-blueGray">{t('prefs.upgradeExisting')}</p>
|
||||||
</p>
|
|
||||||
<p className="text-blueGray">
|
|
||||||
You may also choose a directory with an existing Turtle WoW or Vanilla
|
|
||||||
WoW installation, and it will be automatically upgraded.
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<label htmlFor="clientDir">Install directory:</label>
|
<label htmlFor="clientDir">{t('prefs.installDirectory')}</label>
|
||||||
<FilePickerInput
|
<FilePickerInput
|
||||||
{...register('clientDir')}
|
{...register('clientDir')}
|
||||||
title={watch('clientDir') ?? undefined}
|
title={watch('clientDir') ?? undefined}
|
||||||
@@ -115,7 +110,7 @@ const ClientDirDialog = ({ close }: Props) => {
|
|||||||
loading={formState.isSubmitting}
|
loading={formState.isSubmitting}
|
||||||
className="self-end text-green"
|
className="self-end text-green"
|
||||||
>
|
>
|
||||||
Confirm
|
{t('prefs.confirm')}
|
||||||
</TextButton>
|
</TextButton>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import OctoLogo from '~renderer/assets/logo.png';
|
import OctoLogo from '~renderer/assets/logo.png';
|
||||||
|
|
||||||
|
import { useT } from '~renderer/i18n';
|
||||||
|
|
||||||
import TextButton from './styled/TextButton';
|
import TextButton from './styled/TextButton';
|
||||||
import { TabNames, type TabType } from './TabsPanel';
|
import { TabNames, type TabType } from './TabsPanel';
|
||||||
|
|
||||||
@@ -8,25 +10,28 @@ type Props = {
|
|||||||
setActiveTab: (tab?: TabType) => void;
|
setActiveTab: (tab?: TabType) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Header = ({ activeTab, setActiveTab }: Props) => (
|
const Header = ({ activeTab, setActiveTab }: Props) => {
|
||||||
<div className="-mb-3 flex select-none items-center gap-1">
|
const t = useT();
|
||||||
<button
|
return (
|
||||||
onClick={() => setActiveTab(undefined)}
|
<div className="-mb-3 flex select-none items-center gap-1">
|
||||||
className="z-10 -my-3 mx-3 w-[180px] cursor-pointer"
|
<button
|
||||||
>
|
onClick={() => setActiveTab(undefined)}
|
||||||
<img src={OctoLogo} alt="OctoWoW" className="pointer-events-none" />
|
className="z-10 -my-3 mx-3 w-[180px] cursor-pointer"
|
||||||
</button>
|
|
||||||
{TabNames.map(t => (
|
|
||||||
<TextButton
|
|
||||||
key={t}
|
|
||||||
onClick={() => setActiveTab(t)}
|
|
||||||
active={activeTab === t}
|
|
||||||
className="uppercase"
|
|
||||||
>
|
>
|
||||||
{t}
|
<img src={OctoLogo} alt="OctoWoW" className="pointer-events-none" />
|
||||||
</TextButton>
|
</button>
|
||||||
))}
|
{TabNames.map(tab => (
|
||||||
</div>
|
<TextButton
|
||||||
);
|
key={tab}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
active={activeTab === tab}
|
||||||
|
className="uppercase"
|
||||||
|
>
|
||||||
|
{t(`tab.${tab}`)}
|
||||||
|
</TextButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default Header;
|
export default Header;
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Globe } from 'lucide-react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import cls from 'classnames';
|
||||||
|
|
||||||
|
import { api } from '~renderer/utils/api';
|
||||||
|
import { useLocale } from '~renderer/i18n';
|
||||||
|
import { type Lang } from '~renderer/i18n/translations';
|
||||||
|
|
||||||
|
const LOCALES: { value: Lang; code: string; label: string }[] = [
|
||||||
|
{ value: 'enUS', code: 'En', label: 'English' },
|
||||||
|
{ value: 'deDE', code: 'De', label: 'Deutsch' },
|
||||||
|
{ value: 'zhCN', code: 'Zh', label: '中文' },
|
||||||
|
{ value: 'esES', code: 'Es', label: 'Español' },
|
||||||
|
{ value: 'ptBR', code: 'Pt', label: 'Português' },
|
||||||
|
{ value: 'ruRU', code: 'Ru', label: 'Русский' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const LanguageDropdown = () => {
|
||||||
|
const { lang, setLang, t } = useLocale();
|
||||||
|
const setPref = api.preferences.set.useMutation();
|
||||||
|
const code = LOCALES.find(l => l.value === lang)?.code ?? 'En';
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pos, setPos] = useState<{ top: number; right: number }>();
|
||||||
|
const btnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
const r = btnRef.current?.getBoundingClientRect();
|
||||||
|
if (r) setPos({ top: r.bottom + 4, right: window.innerWidth - r.right });
|
||||||
|
setOpen(o => !o);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const close = () => setOpen(false);
|
||||||
|
window.addEventListener('click', close);
|
||||||
|
return () => window.removeEventListener('click', close);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const pick = (v: Lang) => {
|
||||||
|
setLang(v);
|
||||||
|
setPref.mutate({ locale: v });
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={btnRef}
|
||||||
|
type="button"
|
||||||
|
title={t('topbar.language')}
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggle();
|
||||||
|
}}
|
||||||
|
className="bg-transparent flex cursor-pointer items-center border-0 px-1 text-[12px] tracking-wide hocus:text-orange"
|
||||||
|
>
|
||||||
|
<Globe size={14} />
|
||||||
|
{code}
|
||||||
|
</button>
|
||||||
|
{open &&
|
||||||
|
pos &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{ top: pos.top, right: pos.right }}
|
||||||
|
className="fixed z-50 flex flex-col border border-blueGray/30 bg-darkGray py-1 shadow-[0_8px_20px_rgba(0,0,0,0.5)]"
|
||||||
|
>
|
||||||
|
{LOCALES.map(l => (
|
||||||
|
<button
|
||||||
|
key={l.value}
|
||||||
|
type="button"
|
||||||
|
title={l.label}
|
||||||
|
onClick={() => pick(l.value)}
|
||||||
|
className={cls(
|
||||||
|
'bg-transparent cursor-pointer border-0 px-3 py-1 text-center text-[12px] hocus:bg-orange/20',
|
||||||
|
l.value === lang ? 'text-warmGreen' : 'text-white'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{l.code}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LanguageDropdown;
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState, type ReactElement } from 'react';
|
import { useState, type ReactElement } from 'react';
|
||||||
import cls from 'classnames';
|
import cls from 'classnames';
|
||||||
|
import log from 'electron-log/renderer';
|
||||||
|
|
||||||
import { type UpdaterStatus, type ModsStatus } from '~main/types';
|
import { type UpdaterStatus, type ModsStatus } from '~main/types';
|
||||||
import { formatFileSize } from '~common/utils';
|
import { formatFileSize } from '~common/utils';
|
||||||
import { api } from '~renderer/utils/api';
|
import { api } from '~renderer/utils/api';
|
||||||
|
import { useT } from '~renderer/i18n';
|
||||||
|
|
||||||
import Button from './styled/Button';
|
import Button from './styled/Button';
|
||||||
import DialogButton from './styled/DialogButton';
|
import DialogButton from './styled/DialogButton';
|
||||||
@@ -20,40 +22,43 @@ const formatDuration = (seconds: number) => {
|
|||||||
return minRem ? `${h}h ${minRem}m` : `${h}h`;
|
return minRem ? `${h}h ${minRem}m` : `${h}h`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatPercent = (progress: number) => `${(progress * 100).toFixed(1)}%`;
|
||||||
|
|
||||||
const ProgressDetails = ({ status }: { status: UpdaterStatus }) => {
|
const ProgressDetails = ({ status }: { status: UpdaterStatus }) => {
|
||||||
const { bytesDone, bytesTotal, bytesPerSecond, etaSeconds, progress } = status;
|
const t = useT();
|
||||||
|
const { bytesDone, bytesTotal, bytesPerSecond, etaSeconds, progress } =
|
||||||
|
status;
|
||||||
if (bytesTotal === undefined || bytesDone === undefined) return null;
|
if (bytesTotal === undefined || bytesDone === undefined) return null;
|
||||||
|
|
||||||
const pct = progress !== undefined && progress >= 0
|
const pct =
|
||||||
? `${(progress * 100).toFixed(1)}%`
|
progress !== undefined && progress >= 0 ? formatPercent(progress) : '—';
|
||||||
: '—';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p className="s1 text-blueGray">
|
<p className="s1 text-blueGray">
|
||||||
<span className="tw-color">{pct}</span>
|
<span className="tw-color">{pct}</span>
|
||||||
<span> · {formatFileSize(bytesDone)} / {formatFileSize(bytesTotal)}</span>
|
<span>
|
||||||
|
{' '}
|
||||||
|
· {formatFileSize(bytesDone)} / {formatFileSize(bytesTotal)}
|
||||||
|
</span>
|
||||||
{bytesPerSecond !== undefined && bytesPerSecond > 0 && (
|
{bytesPerSecond !== undefined && bytesPerSecond > 0 && (
|
||||||
<span> · {formatFileSize(bytesPerSecond)}/s</span>
|
<span> · {formatFileSize(bytesPerSecond)}/s</span>
|
||||||
)}
|
)}
|
||||||
<span>
|
<span>
|
||||||
{' · '}
|
{' · '}
|
||||||
{etaSeconds !== undefined
|
{etaSeconds !== undefined
|
||||||
? `~${formatDuration(etaSeconds)} remaining`
|
? `~${formatDuration(etaSeconds)} ${t('launch.remaining')}`
|
||||||
: 'calculating…'}
|
: t('launch.calculating')}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const LaunchPanel = () => {
|
const LaunchPanel = () => {
|
||||||
|
const t = useT();
|
||||||
const [status, setStatus] = useState<UpdaterStatus>({ state: 'verifying' });
|
const [status, setStatus] = useState<UpdaterStatus>({ state: 'verifying' });
|
||||||
api.updater.observe.useSubscription(undefined, {
|
api.updater.observe.useSubscription(undefined, {
|
||||||
onData: data => {
|
onData: setStatus,
|
||||||
console.log({ data });
|
onError: err => log.error('Updater subscription error:', err)
|
||||||
setStatus(data);
|
|
||||||
},
|
|
||||||
onError: err => console.log({ err }),
|
|
||||||
onStarted: () => console.log('Started')
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: pref } = api.preferences.get.useQuery();
|
const { data: pref } = api.preferences.get.useQuery();
|
||||||
@@ -72,23 +77,25 @@ const LaunchPanel = () => {
|
|||||||
UpdaterStatus['state'],
|
UpdaterStatus['state'],
|
||||||
{ button: ReactElement; helperText?: ReactElement }
|
{ button: ReactElement; helperText?: ReactElement }
|
||||||
> = {
|
> = {
|
||||||
verifying: { button: <Button disabled>Verifying</Button> },
|
verifying: { button: <Button disabled>{t('launch.verifying')}</Button> },
|
||||||
serverUnreachable: {
|
serverUnreachable: {
|
||||||
button: pref?.version ? (
|
button: pref?.version ? (
|
||||||
<Button onClick={() => start.mutateAsync()}>Play</Button>
|
<Button onClick={() => start.mutateAsync()}>{t('launch.play')}</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button onClick={() => verify.mutateAsync()}>Retry</Button>
|
<Button onClick={() => verify.mutateAsync()}>
|
||||||
|
{t('launch.retry')}
|
||||||
|
</Button>
|
||||||
),
|
),
|
||||||
helperText: (
|
helperText: (
|
||||||
<div className="-mb-2">
|
<div className="-mb-2">
|
||||||
<p>
|
<p>
|
||||||
<span className="text-orange">Error: </span> Failed to reach update
|
<span className="text-orange">{t('launch.errorLabel')}</span>{' '}
|
||||||
server
|
{t('launch.serverFail')}
|
||||||
</p>
|
</p>
|
||||||
<p className="s1 text-blueGray">
|
<p className="s1 text-blueGray">
|
||||||
{pref?.version
|
{pref?.version
|
||||||
? `You can launch local version ${pref?.version}`
|
? t('launch.localVersion', { version: pref.version })
|
||||||
: 'Please try again later'}
|
: t('launch.tryLater')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -101,39 +108,44 @@ const LaunchPanel = () => {
|
|||||||
>
|
>
|
||||||
{open => (
|
{open => (
|
||||||
<Button primary onClick={open}>
|
<Button primary onClick={open}>
|
||||||
Install
|
{t('launch.install')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
updateAvailable: {
|
updateAvailable: {
|
||||||
button: <Button onClick={() => update.mutateAsync()}>Update</Button>,
|
button: (
|
||||||
|
<Button onClick={() => update.mutateAsync()}>
|
||||||
|
{t('launch.update')}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
helperText: (
|
helperText: (
|
||||||
<div className="-mb-2 flex flex-col gap-1">
|
<div className="-mb-2 flex flex-col gap-1">
|
||||||
<p>Update available!</p>
|
<p>{t('launch.updateAvailable')}</p>
|
||||||
<p className="s1 text-blueGray">
|
<p className="s1 text-blueGray">
|
||||||
{status.progress !== undefined &&
|
{status.progress !== undefined &&
|
||||||
status.bytesDone !== undefined &&
|
status.bytesDone !== undefined &&
|
||||||
status.bytesTotal !== undefined && (
|
status.bytesTotal !== undefined && (
|
||||||
<>
|
<>
|
||||||
<span className="tw-color">
|
<span className="tw-color">
|
||||||
{(status.progress * 100).toFixed(1)}%
|
{formatPercent(status.progress)}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{' '}
|
{' '}
|
||||||
· {formatFileSize(status.bytesDone)} /{' '}
|
· {formatFileSize(status.bytesDone)} /{' '}
|
||||||
{formatFileSize(status.bytesTotal)} on disk ·{' '}
|
{formatFileSize(status.bytesTotal)} {t('launch.onDisk')} ·{' '}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span className="break-all">{status.message}</span> remaining
|
<span className="break-all">{status.message}</span>{' '}
|
||||||
|
{t('launch.remaining')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
updating: {
|
updating: {
|
||||||
button: <Button disabled>Updating</Button>,
|
button: <Button disabled>{t('launch.updating')}</Button>,
|
||||||
helperText: (
|
helperText: (
|
||||||
<div className="-mb-2 flex flex-col gap-1">
|
<div className="-mb-2 flex flex-col gap-1">
|
||||||
{status.message && (
|
{status.message && (
|
||||||
@@ -150,35 +162,41 @@ const LaunchPanel = () => {
|
|||||||
onClick={() => applyMods.mutateAsync()}
|
onClick={() => applyMods.mutateAsync()}
|
||||||
disabled={applyMods.isLoading || modsStatus?.state === 'busy'}
|
disabled={applyMods.isLoading || modsStatus?.state === 'busy'}
|
||||||
>
|
>
|
||||||
{modsStatus?.state === 'busy' ? 'Applying' : 'Update'}
|
{modsStatus?.state === 'busy'
|
||||||
|
? t('launch.applying')
|
||||||
|
: t('launch.update')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button primary onClick={() => start.mutateAsync()}>
|
<Button primary onClick={() => start.mutateAsync()}>
|
||||||
Play
|
{t('launch.play')}
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
helperText: (
|
helperText: (
|
||||||
<div className="-mb-2">
|
<div className="-mb-2">
|
||||||
{modsStatus?.dirty ? (
|
{modsStatus?.dirty ? (
|
||||||
<p>Mods changed — apply before playing</p>
|
<p>{t('launch.modsChanged')}</p>
|
||||||
) : (
|
) : (
|
||||||
<p>Everything up to date!</p>
|
<p>{t('launch.upToDate')}</p>
|
||||||
)}
|
)}
|
||||||
<p className="s1 text-blueGray">Version: {pref?.version}</p>
|
<p className="s1 text-blueGray">
|
||||||
|
{t('launch.version', { version: pref?.version ?? '' })}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
failed: {
|
failed: {
|
||||||
button: <Button onClick={() => verify.mutateAsync()}>Retry</Button>,
|
button: (
|
||||||
|
<Button onClick={() => verify.mutateAsync()}>
|
||||||
|
{t('launch.retry')}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
helperText: (
|
helperText: (
|
||||||
<div className="-mb-2">
|
<div className="-mb-2">
|
||||||
<p>
|
<p>
|
||||||
<span className="text-orange">Error: </span>
|
<span className="text-orange">{t('launch.errorLabel')}</span>{' '}
|
||||||
{status.message}
|
{status.message}
|
||||||
</p>
|
</p>
|
||||||
<p className="s1 text-blueGray">
|
<p className="s1 text-blueGray">{t('launch.verifyHint')}</p>
|
||||||
Verify your game data by clicking Retry.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -191,6 +209,9 @@ const LaunchPanel = () => {
|
|||||||
(status.message && (
|
(status.message && (
|
||||||
<p className="s1 -mb-2 text-blueGray">{status.message}</p>
|
<p className="s1 -mb-2 text-blueGray">{status.message}</p>
|
||||||
))}
|
))}
|
||||||
|
{start.data && !start.data.ok && start.data.error && (
|
||||||
|
<p className="s1 -mb-2 text-orange">{start.data.error}</p>
|
||||||
|
)}
|
||||||
<div className="tw-loading-wrapper">
|
<div className="tw-loading-wrapper">
|
||||||
{status.progress !== undefined && (
|
{status.progress !== undefined && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
|
ShieldAlert,
|
||||||
ShieldCheck
|
ShieldCheck
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { PreferencesSchema } from '~common/schemas';
|
import { PreferencesSchema } from '~common/schemas';
|
||||||
import { api } from '~renderer/utils/api';
|
import { api } from '~renderer/utils/api';
|
||||||
import zodResolver from '~renderer/utils/zodResolver';
|
import zodResolver from '~renderer/utils/zodResolver';
|
||||||
|
import { useT } from '~renderer/i18n';
|
||||||
|
|
||||||
import TextButton from './styled/TextButton';
|
import TextButton from './styled/TextButton';
|
||||||
import CheckboxInput from './form/CheckboxInput';
|
import CheckboxInput from './form/CheckboxInput';
|
||||||
@@ -19,27 +21,32 @@ import ClientDirDialog from './ClientDirDialog';
|
|||||||
import CloseButton from './styled/CloseButton';
|
import CloseButton from './styled/CloseButton';
|
||||||
|
|
||||||
const MirrorStatus = () => {
|
const MirrorStatus = () => {
|
||||||
|
const t = useT();
|
||||||
const [state, setState] = useState<string>('verifying');
|
const [state, setState] = useState<string>('verifying');
|
||||||
api.updater.observe.useSubscription(undefined, {
|
api.updater.observe.useSubscription(undefined, {
|
||||||
onData: ({ state }) => setState(state)
|
onData: ({ state }) => setState(state)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (state === 'serverUnreachable')
|
if (state === 'serverUnreachable')
|
||||||
return <span className="s1 text-red">offline</span>;
|
return <span className="s1 text-red">{t('prefs.mirrorOffline')}</span>;
|
||||||
if (state === 'verifying' || state === 'updating')
|
if (state === 'verifying' || state === 'updating')
|
||||||
return <span className="s1 text-blueGray">checking…</span>;
|
return (
|
||||||
return <span className="s1 text-warmGreen">online</span>;
|
<span className="s1 text-blueGray">{t('prefs.mirrorChecking')}</span>
|
||||||
|
);
|
||||||
|
return <span className="s1 text-warmGreen">{t('prefs.mirrorOnline')}</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = { close: () => void };
|
type Props = { close: () => void };
|
||||||
|
|
||||||
const PreferencesDialog = ({ close }: Props) => {
|
const PreferencesDialog = ({ close }: Props) => {
|
||||||
|
const t = useT();
|
||||||
const { data: pref } = api.preferences.get.useQuery();
|
const { data: pref } = api.preferences.get.useQuery();
|
||||||
const setPref = api.preferences.set.useMutation();
|
const setPref = api.preferences.set.useMutation();
|
||||||
|
|
||||||
const verify = api.updater.verify.useMutation();
|
const verify = api.updater.verify.useMutation();
|
||||||
const openInstallFolder = api.general.openInstallFolder.useMutation();
|
const openInstallFolder = api.general.openInstallFolder.useMutation();
|
||||||
const openLogFile = api.general.openLogFile.useMutation();
|
const openLogFile = api.general.openLogFile.useMutation();
|
||||||
|
const addExclusion = api.general.addDefenderExclusion.useMutation();
|
||||||
|
|
||||||
const { handleSubmit, watch, setValue, reset } = useForm({
|
const { handleSubmit, watch, setValue, reset } = useForm({
|
||||||
defaultValues: pref ?? {},
|
defaultValues: pref ?? {},
|
||||||
@@ -59,9 +66,12 @@ const PreferencesDialog = ({ close }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className="tw-dialog !w-fit min-w-[480px] max-w-[640px] !gap-1 whitespace-nowrap"
|
className="tw-dialog !w-fit min-w-[480px] max-w-[640px] !gap-1"
|
||||||
onSubmit={handleSubmit(async v => {
|
onSubmit={handleSubmit(async v => {
|
||||||
await setPref.mutateAsync(v);
|
await setPref.mutateAsync({
|
||||||
|
cleanWdb: v.cleanWdb,
|
||||||
|
minimizeToTrayOnPlay: v.minimizeToTrayOnPlay
|
||||||
|
});
|
||||||
close();
|
close();
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
@@ -71,26 +81,26 @@ const PreferencesDialog = ({ close }: Props) => {
|
|||||||
close();
|
close();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<h3 className="tw-color">SETTINGS</h3>
|
<h3 className="tw-color">{t('prefs.title')}</h3>
|
||||||
<hr className="mb-1" />
|
<hr className="mb-1" />
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h4 className="tw-color">INSTALL LOCATION:</h4>
|
<h4 className="tw-color">{t('prefs.installLocation')}</h4>
|
||||||
<TextButton
|
<TextButton
|
||||||
icon={FolderOpen}
|
icon={FolderOpen}
|
||||||
size={14}
|
size={14}
|
||||||
onClick={() => openInstallFolder.mutateAsync()}
|
onClick={() => openInstallFolder.mutateAsync()}
|
||||||
className="!p-1 text-blueGray"
|
className="!p-1 text-blueGray"
|
||||||
>
|
>
|
||||||
Open folder
|
{t('prefs.openFolder')}
|
||||||
</TextButton>
|
</TextButton>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 border border-blueGray/20 bg-darkGray/40 px-3 py-1">
|
<div className="flex items-center gap-2 border border-blueGray/20 bg-darkGray/40 px-3 py-1">
|
||||||
<span
|
<span
|
||||||
title={pref?.clientDir}
|
title={pref?.clientDir}
|
||||||
className="min-w-0 shrink grow overflow-hidden text-ellipsis"
|
className="min-w-0 shrink grow overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{pref?.clientDir ?? 'Not selected'}
|
{pref?.clientDir ?? t('prefs.notSelected')}
|
||||||
</span>
|
</span>
|
||||||
<DialogButton
|
<DialogButton
|
||||||
dialog={closeInner => (
|
dialog={closeInner => (
|
||||||
@@ -104,15 +114,20 @@ const PreferencesDialog = ({ close }: Props) => {
|
|||||||
clickAway={pref?.isPortable}
|
clickAway={pref?.isPortable}
|
||||||
>
|
>
|
||||||
{open => (
|
{open => (
|
||||||
<TextButton icon={FilePen} size={14} onClick={open} className="!p-1">
|
<TextButton
|
||||||
Change
|
icon={FilePen}
|
||||||
|
size={14}
|
||||||
|
onClick={open}
|
||||||
|
className="!p-1"
|
||||||
|
>
|
||||||
|
{t('prefs.change')}
|
||||||
</TextButton>
|
</TextButton>
|
||||||
)}
|
)}
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-1 flex items-center gap-3">
|
<div className="mt-1 flex items-center gap-3">
|
||||||
<h4 className="tw-color">DOWNLOAD MIRROR:</h4>
|
<h4 className="tw-color">{t('prefs.downloadMirror')}</h4>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 pl-2">
|
<div className="flex items-center gap-2 pl-2">
|
||||||
<input type="radio" checked readOnly className="accent-warmGreen" />
|
<input type="radio" checked readOnly className="accent-warmGreen" />
|
||||||
@@ -122,47 +137,63 @@ const PreferencesDialog = ({ close }: Props) => {
|
|||||||
icon={RefreshCw}
|
icon={RefreshCw}
|
||||||
size={12}
|
size={12}
|
||||||
onClick={() => verify.mutateAsync()}
|
onClick={() => verify.mutateAsync()}
|
||||||
title="Re-check"
|
title={t('prefs.recheck')}
|
||||||
className="!p-0 text-blueGray"
|
className="!p-0 text-blueGray"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="flex flex-col">
|
<div className="flex min-w-0 flex-col">
|
||||||
<h4 className="tw-color">TROUBLESHOOTING:</h4>
|
<h4 className="tw-color">{t('prefs.troubleshooting')}</h4>
|
||||||
<TextButton
|
<TextButton
|
||||||
icon={ShieldCheck}
|
icon={ShieldCheck}
|
||||||
onClick={() => verify.mutateAsync().then(close)}
|
onClick={() => verify.mutateAsync().then(close)}
|
||||||
className="text-warmGreen"
|
className="!items-start text-left text-warmGreen"
|
||||||
>
|
>
|
||||||
Verify game files
|
{t('prefs.verifyGameFiles')}
|
||||||
</TextButton>
|
</TextButton>
|
||||||
<TextButton
|
<TextButton
|
||||||
icon={ScrollText}
|
icon={ScrollText}
|
||||||
onClick={() => openLogFile.mutateAsync()}
|
onClick={() => openLogFile.mutateAsync()}
|
||||||
className="text-pink"
|
className="!items-start text-left text-pink"
|
||||||
>
|
>
|
||||||
Open log file
|
{t('prefs.openLogFile')}
|
||||||
</TextButton>
|
</TextButton>
|
||||||
|
<TextButton
|
||||||
|
icon={ShieldAlert}
|
||||||
|
onClick={() => addExclusion.mutateAsync()}
|
||||||
|
loading={addExclusion.isLoading}
|
||||||
|
className="!items-start text-left text-orange"
|
||||||
|
>
|
||||||
|
{t('prefs.allowThroughAntivirus')}
|
||||||
|
</TextButton>
|
||||||
|
{addExclusion.data?.ok === true && (
|
||||||
|
<span className="s1 text-warmGreen">
|
||||||
|
{t('prefs.exclusionAdded')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{addExclusion.data?.ok === false && (
|
||||||
|
<span className="s1 text-orange">{addExclusion.data.error}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col">
|
<div className="flex min-w-0 flex-col">
|
||||||
<h4 className="tw-color">GENERAL SETTINGS:</h4>
|
<h4 className="tw-color">{t('prefs.generalSettings')}</h4>
|
||||||
<CheckboxInput
|
<CheckboxInput
|
||||||
value={!!watch('cleanWdb')}
|
value={!!watch('cleanWdb')}
|
||||||
setValue={setBool('cleanWdb')}
|
setValue={setBool('cleanWdb')}
|
||||||
label="Clean WDB on each launch"
|
label={t('prefs.cleanWdb')}
|
||||||
/>
|
/>
|
||||||
<CheckboxInput
|
<CheckboxInput
|
||||||
value={!!watch('minimizeToTrayOnPlay')}
|
value={!!watch('minimizeToTrayOnPlay')}
|
||||||
setValue={setBool('minimizeToTrayOnPlay')}
|
setValue={setBool('minimizeToTrayOnPlay')}
|
||||||
label="Minimize to tray while playing"
|
label={t('prefs.minimizeToTray')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TextButton type="submit" className="mt-1 self-end text-green">
|
<TextButton type="submit" className="mt-1 self-end text-green">
|
||||||
Save
|
{t('prefs.save')}
|
||||||
</TextButton>
|
</TextButton>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { api } from '~renderer/utils/api';
|
import { api } from '~renderer/utils/api';
|
||||||
|
import { useT } from '~renderer/i18n';
|
||||||
|
|
||||||
import Button from './styled/Button';
|
import Button from './styled/Button';
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ type Status =
|
|||||||
| { state: 'error'; currentVersion: string; message: string };
|
| { state: 'error'; currentVersion: string; message: string };
|
||||||
|
|
||||||
const SelfUpdateBanner = () => {
|
const SelfUpdateBanner = () => {
|
||||||
|
const t = useT();
|
||||||
const [status, setStatus] = useState<Status>({
|
const [status, setStatus] = useState<Status>({
|
||||||
state: 'idle',
|
state: 'idle',
|
||||||
currentVersion: ''
|
currentVersion: ''
|
||||||
@@ -39,16 +41,19 @@ const SelfUpdateBanner = () => {
|
|||||||
const tone = status.state === 'error' ? 'border-red/40' : 'border-tw/40';
|
const tone = status.state === 'error' ? 'border-red/40' : 'border-tw/40';
|
||||||
const label =
|
const label =
|
||||||
status.state === 'error'
|
status.state === 'error'
|
||||||
? `Update check failed: ${status.message}`
|
? t('misc.selfUpdateCheckFailed', { message: status.message })
|
||||||
: status.state === 'available'
|
: status.state === 'available'
|
||||||
? `Launcher update ${'nextVersion' in status ? status.nextVersion : ''} available — preparing download…`
|
? t('misc.selfUpdateAvailable', {
|
||||||
: status.state === 'downloading'
|
version: status.nextVersion
|
||||||
? `Downloading update ${status.nextVersion} · ${Math.round(
|
})
|
||||||
status.progress * 100
|
: status.state === 'downloading'
|
||||||
)}%`
|
? t('misc.selfUpdateDownloading', {
|
||||||
: status.state === 'ready'
|
version: status.nextVersion,
|
||||||
? `Launcher update ${status.nextVersion} ready to install`
|
percent: Math.round(status.progress * 100)
|
||||||
: '';
|
})
|
||||||
|
: status.state === 'ready'
|
||||||
|
? t('misc.selfUpdateReady', { version: status.nextVersion })
|
||||||
|
: '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -61,7 +66,7 @@ const SelfUpdateBanner = () => {
|
|||||||
onClick={() => install.mutateAsync()}
|
onClick={() => install.mutateAsync()}
|
||||||
disabled={install.isLoading}
|
disabled={install.isLoading}
|
||||||
>
|
>
|
||||||
Install now
|
{t('misc.selfUpdateInstallNow')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { AlertTriangle, RefreshCw } from 'lucide-react';
|
|||||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||||
import log from 'electron-log/renderer';
|
import log from 'electron-log/renderer';
|
||||||
|
|
||||||
|
import { useT } from '~renderer/i18n';
|
||||||
|
|
||||||
import TextButton from './styled/TextButton';
|
import TextButton from './styled/TextButton';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -14,6 +16,47 @@ type State = {
|
|||||||
componentStack?: string;
|
componentStack?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FallbackProps = {
|
||||||
|
tabName: string;
|
||||||
|
error: Error;
|
||||||
|
componentStack?: string;
|
||||||
|
onReset: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TabErrorFallback = ({
|
||||||
|
tabName,
|
||||||
|
error,
|
||||||
|
componentStack,
|
||||||
|
onReset
|
||||||
|
}: FallbackProps) => {
|
||||||
|
const t = useT();
|
||||||
|
return (
|
||||||
|
<div className="tw-surface flex min-h-0 flex-grow flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle size={22} className="text-red" />
|
||||||
|
<h4 className="text-red">{t('misc.tabCrashed', { tab: tabName })}</h4>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<p className="text-white">
|
||||||
|
{error.name}: {error.message}
|
||||||
|
</p>
|
||||||
|
{componentStack && (
|
||||||
|
<pre className="s1 max-h-[200px] overflow-auto whitespace-pre-wrap text-blueGray">
|
||||||
|
{componentStack.trim()}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
<hr />
|
||||||
|
<TextButton
|
||||||
|
icon={RefreshCw}
|
||||||
|
onClick={onReset}
|
||||||
|
className="self-end text-warmGreen"
|
||||||
|
>
|
||||||
|
{t('misc.tryAgain')}
|
||||||
|
</TextButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
class TabErrorBoundary extends Component<Props, State> {
|
class TabErrorBoundary extends Component<Props, State> {
|
||||||
state: State = {};
|
state: State = {};
|
||||||
|
|
||||||
@@ -38,29 +81,12 @@ class TabErrorBoundary extends Component<Props, State> {
|
|||||||
if (!this.state.error) return this.props.children;
|
if (!this.state.error) return this.props.children;
|
||||||
const { error, componentStack } = this.state;
|
const { error, componentStack } = this.state;
|
||||||
return (
|
return (
|
||||||
<div className="tw-surface flex min-h-0 flex-grow flex-col gap-3">
|
<TabErrorFallback
|
||||||
<div className="flex items-center gap-2">
|
tabName={this.props.tabName}
|
||||||
<AlertTriangle size={22} className="text-red" />
|
error={error}
|
||||||
<h4 className="text-red">{this.props.tabName} crashed</h4>
|
componentStack={componentStack}
|
||||||
</div>
|
onReset={this.#reset}
|
||||||
<hr />
|
/>
|
||||||
<p className="text-white">
|
|
||||||
{error.name}: {error.message}
|
|
||||||
</p>
|
|
||||||
{componentStack && (
|
|
||||||
<pre className="s1 max-h-[200px] overflow-auto whitespace-pre-wrap text-blueGray">
|
|
||||||
{componentStack.trim()}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
<hr />
|
|
||||||
<TextButton
|
|
||||||
icon={RefreshCw}
|
|
||||||
onClick={this.#reset}
|
|
||||||
className="self-end text-warmGreen"
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</TextButton>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { Settings, Minus, X } from 'lucide-react';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { api } from '~renderer/utils/api';
|
import { api } from '~renderer/utils/api';
|
||||||
|
import { useT } from '~renderer/i18n';
|
||||||
|
|
||||||
import DialogButton from './styled/DialogButton';
|
import DialogButton from './styled/DialogButton';
|
||||||
import PreferencesDialog from './PreferencesDialog';
|
import PreferencesDialog from './PreferencesDialog';
|
||||||
import TextButton from './styled/TextButton';
|
import TextButton from './styled/TextButton';
|
||||||
|
import LanguageDropdown from './LanguageDropdown';
|
||||||
|
|
||||||
const TopBar = () => {
|
const TopBar = () => {
|
||||||
|
const t = useT();
|
||||||
const [safeToQuit, setSafeToQuit] = useState(true);
|
const [safeToQuit, setSafeToQuit] = useState(true);
|
||||||
api.updater.observe.useSubscription(undefined, {
|
api.updater.observe.useSubscription(undefined, {
|
||||||
onData: ({ state }) =>
|
onData: ({ state }) =>
|
||||||
@@ -21,12 +24,16 @@ const TopBar = () => {
|
|||||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||||
className="absolute left-0 right-0 top-0 flex justify-end pr-2 pt-2 opacity-50"
|
className="absolute left-0 right-0 top-0 flex justify-end pr-2 pt-2 opacity-50"
|
||||||
>
|
>
|
||||||
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties} className="flex">
|
<div
|
||||||
|
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||||
|
className="flex items-center"
|
||||||
|
>
|
||||||
|
<LanguageDropdown />
|
||||||
<DialogButton dialog={close => <PreferencesDialog close={close} />}>
|
<DialogButton dialog={close => <PreferencesDialog close={close} />}>
|
||||||
{open => (
|
{open => (
|
||||||
<TextButton
|
<TextButton
|
||||||
icon={Settings}
|
icon={Settings}
|
||||||
title="Settings"
|
title={t('topbar.settings')}
|
||||||
onClick={open}
|
onClick={open}
|
||||||
size={16}
|
size={16}
|
||||||
className="!p-1"
|
className="!p-1"
|
||||||
@@ -35,7 +42,7 @@ const TopBar = () => {
|
|||||||
</DialogButton>
|
</DialogButton>
|
||||||
<TextButton
|
<TextButton
|
||||||
icon={Minus}
|
icon={Minus}
|
||||||
title="Minimize"
|
title={t('topbar.minimize')}
|
||||||
onClick={() => minimize.mutateAsync()}
|
onClick={() => minimize.mutateAsync()}
|
||||||
size={16}
|
size={16}
|
||||||
className="!p-1"
|
className="!p-1"
|
||||||
@@ -43,19 +50,16 @@ const TopBar = () => {
|
|||||||
<DialogButton
|
<DialogButton
|
||||||
dialog={close => (
|
dialog={close => (
|
||||||
<div className="tw-dialog">
|
<div className="tw-dialog">
|
||||||
<h3 className="tw-color">Quit?</h3>
|
<h3 className="tw-color">{t('quit.title')}</h3>
|
||||||
<hr />
|
<hr />
|
||||||
<p className="text-blueGray">
|
<p className="text-blueGray">{t('quit.warn')}</p>
|
||||||
Your game is currently being updated. Quitting now may cause
|
|
||||||
problems.
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2 self-end">
|
<div className="flex gap-2 self-end">
|
||||||
<TextButton onClick={close}>Return</TextButton>
|
<TextButton onClick={close}>{t('quit.return')}</TextButton>
|
||||||
<TextButton
|
<TextButton
|
||||||
onClick={() => quit.mutateAsync()}
|
onClick={() => quit.mutateAsync()}
|
||||||
className="text-red"
|
className="text-red"
|
||||||
>
|
>
|
||||||
Quit
|
{t('topbar.quit')}
|
||||||
</TextButton>
|
</TextButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,7 +68,7 @@ const TopBar = () => {
|
|||||||
{open => (
|
{open => (
|
||||||
<TextButton
|
<TextButton
|
||||||
icon={X}
|
icon={X}
|
||||||
title="Quit"
|
title={t('topbar.quit')}
|
||||||
onClick={() => (!safeToQuit ? open() : quit.mutateAsync())}
|
onClick={() => (!safeToQuit ? open() : quit.mutateAsync())}
|
||||||
size={16}
|
size={16}
|
||||||
className="!p-1 hocus:text-red"
|
className="!p-1 hocus:text-red"
|
||||||
|
|||||||
@@ -33,12 +33,18 @@ type Props = {
|
|||||||
className?: cls.Value;
|
className?: cls.Value;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CheckboxInput = ({ label, value, setValue, disabled, className }: Props) => (
|
const CheckboxInput = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
setValue,
|
||||||
|
disabled,
|
||||||
|
className
|
||||||
|
}: Props) => (
|
||||||
<TextButton
|
<TextButton
|
||||||
onClick={() => !disabled && setValue(!value)}
|
onClick={() => !disabled && setValue(!value)}
|
||||||
icon={Checkbox}
|
icon={Checkbox}
|
||||||
className={cls(
|
className={cls(
|
||||||
'text-blueGray',
|
'!items-start text-left text-blueGray',
|
||||||
{ '[&_*]:fill-none': !value, 'pointer-events-none opacity-40': disabled },
|
{ '[&_*]:fill-none': !value, 'pointer-events-none opacity-40': disabled },
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const TextButton = ({
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<IconSpinner size={size ?? 24} strokeWidth={1.5} />
|
<IconSpinner size={size ?? 24} strokeWidth={1.5} />
|
||||||
) : (
|
) : (
|
||||||
Icon && <Icon size={size} />
|
Icon && <Icon size={size} className="shrink-0" />
|
||||||
)}
|
)}
|
||||||
{children && (
|
{children && (
|
||||||
<span className="cursor-pointer select-none tracking-wide text-inherit [font-size:_inherit]">
|
<span className="cursor-pointer select-none tracking-wide text-inherit [font-size:_inherit]">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { type AddonData, type AddonsStatus } from '~main/types';
|
|||||||
import { api } from '~renderer/utils/api';
|
import { api } from '~renderer/utils/api';
|
||||||
import TextButton from '~renderer/components/styled/TextButton';
|
import TextButton from '~renderer/components/styled/TextButton';
|
||||||
import useScrollHint from '~renderer/utils/useScrollHint';
|
import useScrollHint from '~renderer/utils/useScrollHint';
|
||||||
|
import { useT } from '~renderer/i18n';
|
||||||
|
|
||||||
import DialogButton from '../styled/DialogButton';
|
import DialogButton from '../styled/DialogButton';
|
||||||
import IconSpinner from '../styled/IconSpinner';
|
import IconSpinner from '../styled/IconSpinner';
|
||||||
@@ -13,6 +14,17 @@ import AddonList from './addons/AddonList';
|
|||||||
import { type Dependencies } from './addons/AddonListItem';
|
import { type Dependencies } from './addons/AddonListItem';
|
||||||
import CustomAddonDialog from './addons/CustomAddonDialog';
|
import CustomAddonDialog from './addons/CustomAddonDialog';
|
||||||
|
|
||||||
|
const RECOMMENDED = new Set([
|
||||||
|
'AtlasLoot',
|
||||||
|
'pfExtend',
|
||||||
|
'pfQuest',
|
||||||
|
'pfQuest-turtle',
|
||||||
|
'SellValue',
|
||||||
|
'ShaguTweaks',
|
||||||
|
'ShaguTweaks-extras',
|
||||||
|
'TurtleMail'
|
||||||
|
]);
|
||||||
|
|
||||||
const localeFilter = (l: AddonData[], filter: string) => {
|
const localeFilter = (l: AddonData[], filter: string) => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const deduped = l.filter(a => {
|
const deduped = l.filter(a => {
|
||||||
@@ -21,14 +33,14 @@ const localeFilter = (l: AddonData[], filter: string) => {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
return deduped
|
return deduped
|
||||||
.filter(
|
.filter(a =>
|
||||||
a =>
|
a.folder.toLocaleLowerCase().includes(filter.toLocaleLowerCase())
|
||||||
a.folder.toLocaleLowerCase().indexOf(filter.toLocaleLowerCase()) !== -1
|
|
||||||
)
|
)
|
||||||
.sort((a, b) => a.folder.localeCompare(b.folder));
|
.sort((a, b) => a.folder.localeCompare(b.folder));
|
||||||
};
|
};
|
||||||
|
|
||||||
const AddonsTab = () => {
|
const AddonsTab = () => {
|
||||||
|
const t = useT();
|
||||||
const [data, setData] = useState<AddonsStatus>({
|
const [data, setData] = useState<AddonsStatus>({
|
||||||
state: 'verifying',
|
state: 'verifying',
|
||||||
addons: {},
|
addons: {},
|
||||||
@@ -61,14 +73,26 @@ const AddonsTab = () => {
|
|||||||
className="relative -m-4 -mb-3 flex flex-grow flex-col gap-3 overflow-y-auto overflow-x-hidden p-4 pb-3"
|
className="relative -m-4 -mb-3 flex flex-grow flex-col gap-3 overflow-y-auto overflow-x-hidden p-4 pb-3"
|
||||||
>
|
>
|
||||||
<AddonList
|
<AddonList
|
||||||
title="Installed"
|
title={t('addons.sectionInstalled')}
|
||||||
addons={localeFilter(Object.values(data.addons), filter)}
|
addons={localeFilter(Object.values(data.addons), filter)}
|
||||||
dependencies={dependencies}
|
dependencies={dependencies}
|
||||||
/>
|
/>
|
||||||
<AddonList
|
<AddonList
|
||||||
title="Available"
|
title={t('addons.sectionRecommended')}
|
||||||
addons={localeFilter(
|
addons={localeFilter(
|
||||||
data.available.filter(a => !(a.folder in data.addons)),
|
data.available.filter(
|
||||||
|
a => !(a.folder in data.addons) && RECOMMENDED.has(a.folder)
|
||||||
|
),
|
||||||
|
filter
|
||||||
|
)}
|
||||||
|
dependencies={dependencies}
|
||||||
|
/>
|
||||||
|
<AddonList
|
||||||
|
title={t('addons.sectionAvailable')}
|
||||||
|
addons={localeFilter(
|
||||||
|
data.available.filter(
|
||||||
|
a => !(a.folder in data.addons) && !RECOMMENDED.has(a.folder)
|
||||||
|
),
|
||||||
filter
|
filter
|
||||||
)}
|
)}
|
||||||
dependencies={dependencies}
|
dependencies={dependencies}
|
||||||
@@ -83,7 +107,7 @@ const AddonsTab = () => {
|
|||||||
size={18}
|
size={18}
|
||||||
loading={data.state !== 'done'}
|
loading={data.state !== 'done'}
|
||||||
>
|
>
|
||||||
Check for updates
|
{t('addons.checkForUpdates')}
|
||||||
</TextButton>
|
</TextButton>
|
||||||
<DialogButton
|
<DialogButton
|
||||||
clickAway
|
clickAway
|
||||||
@@ -96,7 +120,7 @@ const AddonsTab = () => {
|
|||||||
onClick={open}
|
onClick={open}
|
||||||
className="s1 text-pink"
|
className="s1 text-pink"
|
||||||
>
|
>
|
||||||
Add custom git addon
|
{t('addons.addCustomGitAddon')}
|
||||||
</TextButton>
|
</TextButton>
|
||||||
)}
|
)}
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
@@ -107,11 +131,11 @@ const AddonsTab = () => {
|
|||||||
onClick={() => update.mutateAsync({})}
|
onClick={() => update.mutateAsync({})}
|
||||||
className="justify-self-end text-warmGreen"
|
className="justify-self-end text-warmGreen"
|
||||||
>
|
>
|
||||||
Update all
|
{t('addons.updateAll')}
|
||||||
</TextButton>
|
</TextButton>
|
||||||
) : (
|
) : (
|
||||||
<p className="s1 justify-self-end text-blueGray">
|
<p className="s1 justify-self-end text-blueGray">
|
||||||
Everything is up to date.
|
{t('addons.everythingUpToDate')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
const ComingSoonTab = () => (
|
import { useT } from '~renderer/i18n';
|
||||||
<div className="tw-surface flex flex-grow flex-col items-center justify-center gap-2">
|
|
||||||
<p className="italic text-blueGray">Coming soon...</p>
|
const ComingSoonTab = () => {
|
||||||
</div>
|
const t = useT();
|
||||||
);
|
return (
|
||||||
|
<div className="tw-surface flex flex-grow flex-col items-center justify-center gap-2">
|
||||||
|
<p className="italic text-blueGray">{t('misc.comingSoon')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default ComingSoonTab;
|
export default ComingSoonTab;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { ExternalLink, AlertTriangle, Sparkles } from 'lucide-react';
|
import { ExternalLink, AlertTriangle, Sparkles } from 'lucide-react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import cls from 'classnames';
|
import cls from 'classnames';
|
||||||
|
|
||||||
import { api } from '~renderer/utils/api';
|
import { api } from '~renderer/utils/api';
|
||||||
import useScrollHint from '~renderer/utils/useScrollHint';
|
import useScrollHint from '~renderer/utils/useScrollHint';
|
||||||
|
import { useT } from '~renderer/i18n';
|
||||||
import { type ModRowStatus, type ModsStatus } from '~main/types';
|
import { type ModRowStatus, type ModsStatus } from '~main/types';
|
||||||
|
|
||||||
import TextButton from '../styled/TextButton';
|
import TextButton from '../styled/TextButton';
|
||||||
@@ -11,9 +13,8 @@ import CheckboxInput from '../form/CheckboxInput';
|
|||||||
import IconSpinner from '../styled/IconSpinner';
|
import IconSpinner from '../styled/IconSpinner';
|
||||||
|
|
||||||
const RowState = ({ row }: { row: ModRowStatus }) => {
|
const RowState = ({ row }: { row: ModRowStatus }) => {
|
||||||
if (row.state === 'downloading' || row.state === 'installing')
|
const t = useT();
|
||||||
return <IconSpinner className="text-blueGray" />;
|
if (['downloading', 'installing', 'uninstalling'].includes(row.state))
|
||||||
if (row.state === 'uninstalling')
|
|
||||||
return <IconSpinner className="text-blueGray" />;
|
return <IconSpinner className="text-blueGray" />;
|
||||||
if (row.state === 'error')
|
if (row.state === 'error')
|
||||||
return (
|
return (
|
||||||
@@ -21,12 +22,17 @@ const RowState = ({ row }: { row: ModRowStatus }) => {
|
|||||||
<AlertTriangle size={14} className="text-red" />
|
<AlertTriangle size={14} className="text-red" />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
if (row.installedVersion && row.installedVersion !== row.latestVersion && !row.ignoreUpdates)
|
if (
|
||||||
return <span className="s1 text-pink">update</span>;
|
row.installedVersion &&
|
||||||
|
row.installedVersion !== row.latestVersion &&
|
||||||
|
!row.ignoreUpdates
|
||||||
|
)
|
||||||
|
return <span className="s1 text-pink">{t('mods.update')}</span>;
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ModRow = ({ row }: { row: ModRowStatus }) => {
|
const ModRow = ({ row }: { row: ModRowStatus }) => {
|
||||||
|
const t = useT();
|
||||||
const toggle = api.mods.toggle.useMutation();
|
const toggle = api.mods.toggle.useMutation();
|
||||||
const setIgnore = api.mods.setIgnoreUpdates.useMutation();
|
const setIgnore = api.mods.setIgnoreUpdates.useMutation();
|
||||||
const openLink = api.general.openLink.useMutation();
|
const openLink = api.general.openLink.useMutation();
|
||||||
@@ -37,7 +43,9 @@ const ModRow = ({ row }: { row: ModRowStatus }) => {
|
|||||||
{row.recommended && (
|
{row.recommended && (
|
||||||
<Sparkles size={12} className="shrink-0 text-warmGreen" />
|
<Sparkles size={12} className="shrink-0 text-warmGreen" />
|
||||||
)}
|
)}
|
||||||
<span className={cls(row.recommended && 'text-warmGreen')}>{row.name}</span>
|
<span className={cls(row.recommended && 'text-warmGreen')}>
|
||||||
|
{row.name}
|
||||||
|
</span>
|
||||||
<span className="s1 text-warmGreen">{row.latestVersion}</span>
|
<span className="s1 text-warmGreen">{row.latestVersion}</span>
|
||||||
</div>
|
</div>
|
||||||
<CheckboxInput
|
<CheckboxInput
|
||||||
@@ -59,13 +67,14 @@ const ModRow = ({ row }: { row: ModRowStatus }) => {
|
|||||||
<CheckboxInput
|
<CheckboxInput
|
||||||
value={row.ignoreUpdates}
|
value={row.ignoreUpdates}
|
||||||
setValue={v => setIgnore.mutate({ id: row.id, ignore: v })}
|
setValue={v => setIgnore.mutate({ id: row.id, ignore: v })}
|
||||||
label={<span className="s1">Ignore updates</span>}
|
label={<span className="s1">{t('mods.ignoreUpdates')}</span>}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ModsTab = () => {
|
const ModsTab = () => {
|
||||||
|
const t = useT();
|
||||||
const [status, setStatus] = useState<ModsStatus>();
|
const [status, setStatus] = useState<ModsStatus>();
|
||||||
api.mods.observe.useSubscription(undefined, {
|
api.mods.observe.useSubscription(undefined, {
|
||||||
onData: setStatus
|
onData: setStatus
|
||||||
@@ -82,40 +91,111 @@ const ModsTab = () => {
|
|||||||
|
|
||||||
const scrollRef = useScrollHint<HTMLDivElement>();
|
const scrollRef = useScrollHint<HTMLDivElement>();
|
||||||
|
|
||||||
|
const mods = status?.mods ?? [];
|
||||||
|
const enabledIds = new Set(mods.filter(m => m.enabled).map(m => m.id));
|
||||||
|
const modName = (id: string) => mods.find(m => m.id === id)?.name ?? id;
|
||||||
|
const missingDeps = [
|
||||||
|
...new Set(
|
||||||
|
mods
|
||||||
|
.filter(m => m.enabled)
|
||||||
|
.flatMap(m => m.requires.filter(d => !enabledIds.has(d)))
|
||||||
|
)
|
||||||
|
];
|
||||||
|
const pendingDepMessage = missingDeps
|
||||||
|
.map(dep => {
|
||||||
|
const requiredBy = mods
|
||||||
|
.filter(m => m.enabled && m.requires.includes(dep))
|
||||||
|
.map(m => m.name)
|
||||||
|
.join(', ');
|
||||||
|
return t('mods.depRequired', { mod: modName(dep), requiredBy });
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
const [shownDepMessage, setShownDepMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shownDepMessage) {
|
||||||
|
dialogRef.current?.showModal();
|
||||||
|
(document.activeElement as HTMLElement | null)?.blur();
|
||||||
|
} else dialogRef.current?.close();
|
||||||
|
}, [shownDepMessage]);
|
||||||
|
|
||||||
|
const onApply = () => {
|
||||||
|
if (missingDeps.length) {
|
||||||
|
setShownDepMessage(pendingDepMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apply.mutateAsync();
|
||||||
|
};
|
||||||
|
|
||||||
|
const showApply =
|
||||||
|
!!status?.dirty || apply.isLoading || status?.state === 'busy';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tw-surface flex min-h-0 flex-grow flex-col gap-3">
|
<div className="tw-surface flex min-h-0 flex-grow flex-col gap-3">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<h4 className="tw-color">CUSTOM MODS</h4>
|
<h4 className="tw-color">{t('mods.title')}</h4>
|
||||||
{status?.dirty && (
|
{status?.dirty && (
|
||||||
<span className="s1 text-pink">unsaved changes</span>
|
<span className="s1 text-pink">{t('mods.unsavedChanges')}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="s1 text-blueGray">
|
<p className="s1 text-blueGray">
|
||||||
<span className="text-orange">⚠</span> Enabling custom mods may not provide
|
<span className="text-orange">⚠</span> {t('mods.warning')}
|
||||||
any performance benefits or may even cause game crashes depending on your
|
|
||||||
system. Please try disabling them if you experience any issues.
|
|
||||||
</p>
|
</p>
|
||||||
|
{missingDeps.length > 0 && (
|
||||||
|
<p className="s1 text-orange">
|
||||||
|
⚠{' '}
|
||||||
|
{t('mods.enableRequired', {
|
||||||
|
mods: missingDeps.map(modName).join(', ')
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<hr />
|
<hr />
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="relative -m-4 -mt-0 grid flex-grow grid-cols-[auto_auto_1fr_auto] content-start items-center gap-x-4 gap-y-2 overflow-y-auto p-4 pt-0"
|
className="relative -m-4 -mt-0 grid flex-grow grid-cols-[auto_auto_1fr_auto] content-start items-center gap-x-4 gap-y-2 overflow-y-auto p-4 pt-0"
|
||||||
>
|
>
|
||||||
{status?.mods.map(row => <ModRow key={row.id} row={row} />)}
|
{status?.mods.map(row => (
|
||||||
|
<ModRow key={row.id} row={row} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
<div className="-mb-4 -mt-3 flex items-center gap-2 py-2">
|
<div className="-mb-4 -mt-3 flex items-center gap-2 py-2">
|
||||||
<p className="s1 flex-grow text-blueGray">
|
<p className="s1 flex-grow text-blueGray">
|
||||||
<span className="text-warmGreen">Highlighted</span> mods are recommended.
|
<span className="text-warmGreen">{t('mods.highlighted')}</span>{' '}
|
||||||
|
{t('mods.highlightedRecommended')}
|
||||||
</p>
|
</p>
|
||||||
<TextButton
|
<TextButton
|
||||||
type="button"
|
type="button"
|
||||||
loading={apply.isLoading || status?.state === 'busy'}
|
loading={apply.isLoading || status?.state === 'busy'}
|
||||||
onClick={() => apply.mutateAsync()}
|
onClick={onApply}
|
||||||
className={cls(status?.dirty && 'text-green')}
|
className={cls('text-green', !showApply && 'invisible')}
|
||||||
>
|
>
|
||||||
Apply
|
{t('mods.apply')}
|
||||||
</TextButton>
|
</TextButton>
|
||||||
</div>
|
</div>
|
||||||
|
{createPortal(
|
||||||
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
onClose={() => setShownDepMessage(null)}
|
||||||
|
className="h-full w-full items-center justify-center bg-[transparent] backdrop:backdrop-blur-sm [&[open]]:flex"
|
||||||
|
>
|
||||||
|
{shownDepMessage && (
|
||||||
|
<div className="tw-dialog !w-fit min-w-[360px] max-w-[460px] !gap-3">
|
||||||
|
<h3 className="tw-color">{t('mods.cantApplyYet')}</h3>
|
||||||
|
<p className="s1 whitespace-pre-line">{shownDepMessage}</p>
|
||||||
|
<TextButton
|
||||||
|
onClick={() => setShownDepMessage(null)}
|
||||||
|
className="self-end text-green"
|
||||||
|
>
|
||||||
|
{t('mods.close')}
|
||||||
|
</TextButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dialog>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { AlertTriangle, ExternalLink, RefreshCw } from 'lucide-react';
|
|||||||
|
|
||||||
import { type NewsItem } from '~main/types';
|
import { type NewsItem } from '~main/types';
|
||||||
import { api } from '~renderer/utils/api';
|
import { api } from '~renderer/utils/api';
|
||||||
|
import { useT } from '~renderer/i18n';
|
||||||
import useScrollHint from '~renderer/utils/useScrollHint';
|
import useScrollHint from '~renderer/utils/useScrollHint';
|
||||||
|
|
||||||
import IconSpinner from '../styled/IconSpinner';
|
import IconSpinner from '../styled/IconSpinner';
|
||||||
@@ -18,15 +19,20 @@ const formatDate = (raw: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const NewsEntry = ({ item }: { item: NewsItem }) => {
|
const NewsEntry = ({ item }: { item: NewsItem }) => {
|
||||||
|
const t = useT();
|
||||||
const openLink = api.general.openLink.useMutation();
|
const openLink = api.general.openLink.useMutation();
|
||||||
return (
|
return (
|
||||||
<article className="flex flex-col gap-1 border-b border-blueGray/30 pb-3 last:border-0">
|
<article className="flex flex-col gap-1 border-b border-blueGray/30 pb-3 last:border-0">
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
<h5 className="tw-color">{item.title}</h5>
|
<h5 className="tw-color">{item.title}</h5>
|
||||||
<span className="s1 shrink-0 text-blueGray">{formatDate(item.date)}</span>
|
<span className="s1 shrink-0 text-blueGray">
|
||||||
|
{formatDate(item.date)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{item.author && (
|
{item.author && (
|
||||||
<span className="s1 italic text-blueGray">by {item.author}</span>
|
<span className="s1 italic text-blueGray">
|
||||||
|
{t('misc.newsByAuthor', { author: item.author })}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">{item.body}</p>
|
<p className="whitespace-pre-wrap text-sm leading-relaxed">{item.body}</p>
|
||||||
{item.url && (
|
{item.url && (
|
||||||
@@ -36,7 +42,7 @@ const NewsEntry = ({ item }: { item: NewsItem }) => {
|
|||||||
className="-ml-2 self-start text-pink"
|
className="-ml-2 self-start text-pink"
|
||||||
onClick={() => openLink.mutateAsync(item.url!)}
|
onClick={() => openLink.mutateAsync(item.url!)}
|
||||||
>
|
>
|
||||||
Read more
|
{t('misc.newsReadMore')}
|
||||||
</TextButton>
|
</TextButton>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
@@ -44,6 +50,7 @@ const NewsEntry = ({ item }: { item: NewsItem }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const NewsTab = () => {
|
const NewsTab = () => {
|
||||||
|
const t = useT();
|
||||||
const query = api.news.list.useQuery(undefined, {
|
const query = api.news.list.useQuery(undefined, {
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
@@ -54,14 +61,14 @@ const NewsTab = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="tw-surface flex min-h-0 flex-grow flex-col gap-3">
|
<div className="tw-surface flex min-h-0 flex-grow flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="tw-color">News</h4>
|
<h4 className="tw-color">{t('misc.newsTitle')}</h4>
|
||||||
<TextButton
|
<TextButton
|
||||||
icon={RefreshCw}
|
icon={RefreshCw}
|
||||||
size={18}
|
size={18}
|
||||||
className="-mr-2 text-blueGray"
|
className="-mr-2 text-blueGray"
|
||||||
loading={query.isFetching}
|
loading={query.isFetching}
|
||||||
onClick={() => query.refetch()}
|
onClick={() => query.refetch()}
|
||||||
title="Refresh"
|
title={t('misc.refresh')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
@@ -72,24 +79,24 @@ const NewsTab = () => {
|
|||||||
{query.isLoading ? (
|
{query.isLoading ? (
|
||||||
<div className="flex flex-grow flex-col items-center justify-center gap-2">
|
<div className="flex flex-grow flex-col items-center justify-center gap-2">
|
||||||
<IconSpinner className="text-blueGray" />
|
<IconSpinner className="text-blueGray" />
|
||||||
<p className="italic text-blueGray">Loading news...</p>
|
<p className="italic text-blueGray">{t('misc.newsLoading')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : query.isError ? (
|
) : query.isError ? (
|
||||||
<div className="flex flex-grow flex-col items-center justify-center gap-3">
|
<div className="flex flex-grow flex-col items-center justify-center gap-3">
|
||||||
<AlertTriangle size={32} className="text-red" />
|
<AlertTriangle size={32} className="text-red" />
|
||||||
<p className="italic text-blueGray">Couldn't reach the news feed.</p>
|
<p className="italic text-blueGray">{t('misc.newsError')}</p>
|
||||||
<TextButton
|
<TextButton
|
||||||
icon={RefreshCw}
|
icon={RefreshCw}
|
||||||
size={18}
|
size={18}
|
||||||
className="text-pink"
|
className="text-pink"
|
||||||
onClick={() => query.refetch()}
|
onClick={() => query.refetch()}
|
||||||
>
|
>
|
||||||
Try again
|
{t('misc.tryAgain')}
|
||||||
</TextButton>
|
</TextButton>
|
||||||
</div>
|
</div>
|
||||||
) : !query.data?.length ? (
|
) : !query.data?.length ? (
|
||||||
<div className="flex flex-grow flex-col items-center justify-center">
|
<div className="flex flex-grow flex-col items-center justify-center">
|
||||||
<p className="italic text-blueGray">No news yet — check back later.</p>
|
<p className="italic text-blueGray">{t('misc.newsEmpty')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
query.data.map(item => <NewsEntry key={item.id} item={item} />)
|
query.data.map(item => <NewsEntry key={item.id} item={item} />)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { api } from '~renderer/utils/api';
|
|||||||
import { ConfigWtfSchema } from '~common/schemas';
|
import { ConfigWtfSchema } from '~common/schemas';
|
||||||
import zodResolver from '~renderer/utils/zodResolver';
|
import zodResolver from '~renderer/utils/zodResolver';
|
||||||
import useScrollHint from '~renderer/utils/useScrollHint';
|
import useScrollHint from '~renderer/utils/useScrollHint';
|
||||||
|
import { useT } from '~renderer/i18n';
|
||||||
|
|
||||||
import TextButton from '../styled/TextButton';
|
import TextButton from '../styled/TextButton';
|
||||||
import CheckboxInput from '../form/CheckboxInput';
|
import CheckboxInput from '../form/CheckboxInput';
|
||||||
@@ -64,6 +65,7 @@ const Item = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TweaksTab = () => {
|
const TweaksTab = () => {
|
||||||
|
const t = useT();
|
||||||
const { data: pref } = api.preferences.get.useQuery();
|
const { data: pref } = api.preferences.get.useQuery();
|
||||||
const setPref = api.preferences.set.useMutation();
|
const setPref = api.preferences.set.useMutation();
|
||||||
|
|
||||||
@@ -74,7 +76,10 @@ const TweaksTab = () => {
|
|||||||
defaultValues: pref?.config ?? {},
|
defaultValues: pref?.config ?? {},
|
||||||
resolver: zodResolver(ConfigWtfSchema)
|
resolver: zodResolver(ConfigWtfSchema)
|
||||||
});
|
});
|
||||||
const { handleSubmit, reset } = form;
|
const { handleSubmit, reset, formState } = form;
|
||||||
|
|
||||||
|
const isApplying =
|
||||||
|
setPref.isLoading || applyPatch.isLoading || verify.isLoading;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pref && reset(pref.config);
|
pref && reset(pref.config);
|
||||||
@@ -100,33 +105,35 @@ const TweaksTab = () => {
|
|||||||
<Item
|
<Item
|
||||||
form={form}
|
form={form}
|
||||||
id="alwaysAutoLoot"
|
id="alwaysAutoLoot"
|
||||||
label="Always auto-loot"
|
label={t('tweaks.alwaysAutoLoot.label')}
|
||||||
text="Reverses auto-loot behavior to always auto-loot and disable auto-with bound key."
|
text={t('tweaks.alwaysAutoLoot.text')}
|
||||||
/>
|
/>
|
||||||
<Item
|
<Item
|
||||||
form={form}
|
form={form}
|
||||||
id="largeAddress"
|
id="largeAddress"
|
||||||
label="Large Address Aware"
|
label={t('tweaks.largeAddress.label')}
|
||||||
text="Allows the game to use more than 2GB of memory."
|
text={t('tweaks.largeAddress.text')}
|
||||||
recommended
|
recommended
|
||||||
/>
|
/>
|
||||||
<Item
|
<Item
|
||||||
form={form}
|
form={form}
|
||||||
type="number"
|
type="number"
|
||||||
id="nameplateRange"
|
id="nameplateRange"
|
||||||
label="Nameplate range"
|
label={t('tweaks.nameplateRange.label')}
|
||||||
text="Increases distance at which nameplates are visible. [Vanilla: 20] [Classic: 41]"
|
text={t('tweaks.nameplateRange.text')}
|
||||||
min={0}
|
min={0}
|
||||||
max={41}
|
max={41}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<h4 className="tw-color col-span-3 mt-3">Camera</h4>
|
<h4 className="tw-color col-span-3 mt-3">
|
||||||
|
{t('tweaks.cameraHeading')}
|
||||||
|
</h4>
|
||||||
<Item
|
<Item
|
||||||
form={form}
|
form={form}
|
||||||
id="fieldOfView"
|
id="fieldOfView"
|
||||||
label="Field of View"
|
label={t('tweaks.fieldOfView.label')}
|
||||||
type="number"
|
type="number"
|
||||||
text="Recommended for widescreen window resolutions. [Vanilla: 90] [Tweaks: 110]"
|
text={t('tweaks.fieldOfView.text')}
|
||||||
min={90}
|
min={90}
|
||||||
max={180}
|
max={180}
|
||||||
step={5}
|
step={5}
|
||||||
@@ -134,9 +141,9 @@ const TweaksTab = () => {
|
|||||||
<Item
|
<Item
|
||||||
form={form}
|
form={form}
|
||||||
id="farClip"
|
id="farClip"
|
||||||
label="Render distance"
|
label={t('tweaks.farClip.label')}
|
||||||
type="number"
|
type="number"
|
||||||
text="Increases maximum render distance. [Vanilla: 777] [Tweaks: 10000]"
|
text={t('tweaks.farClip.text')}
|
||||||
min={100}
|
min={100}
|
||||||
max={10000}
|
max={10000}
|
||||||
sensitivity={3}
|
sensitivity={3}
|
||||||
@@ -144,9 +151,9 @@ const TweaksTab = () => {
|
|||||||
<Item
|
<Item
|
||||||
form={form}
|
form={form}
|
||||||
id="frillDistance"
|
id="frillDistance"
|
||||||
label="Ground clutter distance"
|
label={t('tweaks.frillDistance.label')}
|
||||||
type="number"
|
type="number"
|
||||||
text="Changes ground clutter render distance. [Vanilla: 70] [Tweaks: 300]"
|
text={t('tweaks.frillDistance.text')}
|
||||||
min={0}
|
min={0}
|
||||||
max={300}
|
max={300}
|
||||||
sensitivity={0.3}
|
sensitivity={0.3}
|
||||||
@@ -154,27 +161,41 @@ const TweaksTab = () => {
|
|||||||
<Item
|
<Item
|
||||||
form={form}
|
form={form}
|
||||||
id="cameraDistance"
|
id="cameraDistance"
|
||||||
label="Camera distance"
|
label={t('tweaks.cameraDistance.label')}
|
||||||
type="number"
|
type="number"
|
||||||
text="Increases maximum camera (zoom out) distance. [Vanilla: 50] [Max:100]"
|
text={t('tweaks.cameraDistance.text')}
|
||||||
min={50}
|
min={50}
|
||||||
max={100}
|
max={100}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<h4 className="tw-color col-span-3 mt-3">Sounds</h4>
|
<h4 className="tw-color col-span-3 mt-3">
|
||||||
|
{t('tweaks.soundsHeading')}
|
||||||
|
</h4>
|
||||||
<Item
|
<Item
|
||||||
form={form}
|
form={form}
|
||||||
id="soundInBackground"
|
id="soundInBackground"
|
||||||
label="Background sounds"
|
label={t('tweaks.soundInBackground.label')}
|
||||||
text="Allows game sounds to play while the game is minimized."
|
text={t('tweaks.soundInBackground.text')}
|
||||||
recommended
|
recommended
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
<div className="-mb-4 -mt-3 flex items-center gap-2 py-2">
|
<div className="-mb-4 -mt-3 flex items-center gap-2 py-2">
|
||||||
<p className="s1 flex-grow text-blueGray">
|
<p className="s1 flex-grow text-blueGray">
|
||||||
<span className="s1 text-warmGreen">Highlighted</span> options are
|
{applyPatch.isError ? (
|
||||||
recommended and enabled by default
|
<span className="text-orange">
|
||||||
|
{t('tweaks.applyFailed', {
|
||||||
|
message: applyPatch.error?.message ?? ''
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="s1 text-warmGreen">
|
||||||
|
{t('tweaks.highlighted')}
|
||||||
|
</span>{' '}
|
||||||
|
{t('tweaks.recommendedNote')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<TextButton
|
<TextButton
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -183,11 +204,13 @@ const TweaksTab = () => {
|
|||||||
reset(config);
|
reset(config);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Reset
|
{t('tweaks.reset')}
|
||||||
</TextButton>
|
|
||||||
<TextButton type="submit" className="text-green">
|
|
||||||
Apply
|
|
||||||
</TextButton>
|
</TextButton>
|
||||||
|
{(formState.isDirty || isApplying) && (
|
||||||
|
<TextButton type="submit" loading={isApplying} className="text-green">
|
||||||
|
{t('tweaks.apply')}
|
||||||
|
</TextButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { ColoredText } from '~renderer/components/styled/ColoredText';
|
|||||||
import useScrollHint from '~renderer/utils/useScrollHint';
|
import useScrollHint from '~renderer/utils/useScrollHint';
|
||||||
import IconSpinner from '~renderer/components/styled/IconSpinner';
|
import IconSpinner from '~renderer/components/styled/IconSpinner';
|
||||||
import CloseButton from '~renderer/components/styled/CloseButton';
|
import CloseButton from '~renderer/components/styled/CloseButton';
|
||||||
|
import { useT } from '~renderer/i18n';
|
||||||
|
|
||||||
import { type LocalDependencies } from './AddonListItem';
|
import { type LocalDependencies } from './AddonListItem';
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ type Props = AddonData & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => {
|
const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => {
|
||||||
|
const t = useT();
|
||||||
const openLink = api.general.openLink.useMutation();
|
const openLink = api.general.openLink.useMutation();
|
||||||
const update = api.addons.update.useMutation();
|
const update = api.addons.update.useMutation();
|
||||||
|
|
||||||
@@ -71,26 +73,26 @@ const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => {
|
|||||||
<ColoredText>{addon.toc?.Notes ?? addon.description ?? ''}</ColoredText>
|
<ColoredText>{addon.toc?.Notes ?? addon.description ?? ''}</ColoredText>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<AddonDetailItem name="Source">
|
<AddonDetailItem name={t('addons.detailSource')}>
|
||||||
{addon.git && (
|
{addon.git && (
|
||||||
<TextButton
|
<TextButton
|
||||||
onClick={() => openLink.mutateAsync(addon.git)}
|
onClick={() => openLink.mutateAsync(addon.git)}
|
||||||
className="s1 -m-2 !inline"
|
className="s1 -m-2 !inline"
|
||||||
>
|
>
|
||||||
Open on GitHub
|
{t('addons.openOnGithubShort')}
|
||||||
<ExternalLink size={12} className="ml-1 inline" />
|
<ExternalLink size={12} className="ml-1 inline" />
|
||||||
</TextButton>
|
</TextButton>
|
||||||
)}
|
)}
|
||||||
</AddonDetailItem>
|
</AddonDetailItem>
|
||||||
{addon.toc && (
|
{addon.toc && (
|
||||||
<>
|
<>
|
||||||
<AddonDetailItem name="Contributions">
|
<AddonDetailItem name={t('addons.detailContributions')}>
|
||||||
{addon.toc.Author}
|
{addon.toc.Author}
|
||||||
</AddonDetailItem>
|
</AddonDetailItem>
|
||||||
<AddonDetailItem name="Addon version">
|
<AddonDetailItem name={t('addons.detailAddonVersion')}>
|
||||||
{addon.toc.Version}
|
{addon.toc.Version}
|
||||||
</AddonDetailItem>
|
</AddonDetailItem>
|
||||||
<AddonDetailItem name="Dependencies">
|
<AddonDetailItem name={t('addons.detailDependencies')}>
|
||||||
{!!dependencies.length && (
|
{!!dependencies.length && (
|
||||||
<ul className="pl-2">
|
<ul className="pl-2">
|
||||||
{dependencies.map(({ name, optional, status }) => (
|
{dependencies.map(({ name, optional, status }) => (
|
||||||
@@ -99,7 +101,7 @@ const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => {
|
|||||||
<Check size={16} className="inline text-darkGreen" />
|
<Check size={16} className="inline text-darkGreen" />
|
||||||
) : status === 'available' ? (
|
) : status === 'available' ? (
|
||||||
<TextButton
|
<TextButton
|
||||||
title="Download"
|
title={t('addons.download')}
|
||||||
icon={DownloadCloud}
|
icon={DownloadCloud}
|
||||||
size={16}
|
size={16}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -122,7 +124,9 @@ const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => {
|
|||||||
) ? (
|
) ? (
|
||||||
<p className="s1 inline text-blueGray">{status}</p>
|
<p className="s1 inline text-blueGray">{status}</p>
|
||||||
) : optional ? (
|
) : optional ? (
|
||||||
<p className="s1 inline text-blueGray">(optional)</p>
|
<p className="s1 inline text-blueGray">
|
||||||
|
{t('addons.optional')}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import IconSpinner from '~renderer/components/styled/IconSpinner';
|
|||||||
import DialogButton from '~renderer/components/styled/DialogButton';
|
import DialogButton from '~renderer/components/styled/DialogButton';
|
||||||
import { isNotUndef } from '~common/utils';
|
import { isNotUndef } from '~common/utils';
|
||||||
import CloseButton from '~renderer/components/styled/CloseButton';
|
import CloseButton from '~renderer/components/styled/CloseButton';
|
||||||
|
import { useT } from '~renderer/i18n';
|
||||||
|
|
||||||
import AddonDetail from './AddonDetail';
|
import AddonDetail from './AddonDetail';
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ const toRepoUrl = (git?: string) =>
|
|||||||
git ? git.replace(/\.git$/, '') : undefined;
|
git ? git.replace(/\.git$/, '') : undefined;
|
||||||
|
|
||||||
const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
|
const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
|
||||||
|
const t = useT();
|
||||||
const update = api.addons.update.useMutation();
|
const update = api.addons.update.useMutation();
|
||||||
const remove = api.addons.remove.useMutation();
|
const remove = api.addons.remove.useMutation();
|
||||||
const openLink = api.general.openLink.useMutation();
|
const openLink = api.general.openLink.useMutation();
|
||||||
@@ -58,17 +60,21 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
|
|||||||
const warnings = [
|
const warnings = [
|
||||||
addon.toc && addon.toc?.Interface !== '11200'
|
addon.toc && addon.toc?.Interface !== '11200'
|
||||||
? {
|
? {
|
||||||
full: `This addon seems to be made for different game version (${addon.toc?.Interface}) and it may not function correctly`,
|
full: t('addons.warnIncorrectVersionFull', {
|
||||||
short: 'Incorrect version'
|
version: addon.toc?.Interface ?? ''
|
||||||
|
}),
|
||||||
|
short: t('addons.warnIncorrectVersionShort')
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
localDependencies.some(d => d.status !== 'installed' && !d.optional)
|
localDependencies.some(d => d.status !== 'installed' && !d.optional)
|
||||||
? {
|
? {
|
||||||
full: `This addon has missing dependencies: ${localDependencies
|
full: t('addons.warnMissingDependenciesFull', {
|
||||||
.filter(d => d.status !== 'installed' && !d.optional)
|
deps: localDependencies
|
||||||
.map(d => d.name)
|
.filter(d => d.status !== 'installed' && !d.optional)
|
||||||
.join(', ')}`,
|
.map(d => d.name)
|
||||||
short: 'Missing dependencies'
|
.join(', ')
|
||||||
|
}),
|
||||||
|
short: t('addons.warnMissingDependenciesShort')
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
].filter(isNotUndef);
|
].filter(isNotUndef);
|
||||||
@@ -107,7 +113,7 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
|
|||||||
: HelpCircle
|
: HelpCircle
|
||||||
}
|
}
|
||||||
onClick={open}
|
onClick={open}
|
||||||
title="Details"
|
title={t('addons.details')}
|
||||||
size={18}
|
size={18}
|
||||||
className={cls(
|
className={cls(
|
||||||
'-mx-2',
|
'-mx-2',
|
||||||
@@ -132,7 +138,7 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
|
|||||||
<TextButton
|
<TextButton
|
||||||
icon={Github}
|
icon={Github}
|
||||||
size={14}
|
size={14}
|
||||||
title={`Open ${repoUrl} on GitHub`}
|
title={t('addons.openOnGithub', { url: repoUrl })}
|
||||||
onClick={() => openLink.mutateAsync(repoUrl)}
|
onClick={() => openLink.mutateAsync(repoUrl)}
|
||||||
className="!p-1 text-blueGray/60 hocus:text-pink"
|
className="!p-1 text-blueGray/60 hocus:text-pink"
|
||||||
/>
|
/>
|
||||||
@@ -162,9 +168,9 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
|
|||||||
) : (
|
) : (
|
||||||
<p className="s1 text-blueGray/50">
|
<p className="s1 text-blueGray/50">
|
||||||
{addon.status === 'upToDate'
|
{addon.status === 'upToDate'
|
||||||
? 'Up to date'
|
? t('addons.upToDate')
|
||||||
: !addon.git
|
: !addon.git
|
||||||
? 'Not versioned'
|
? t('addons.notVersioned')
|
||||||
: ''}
|
: ''}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -173,17 +179,16 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
|
|||||||
onClick={() => update.mutateAsync({ toUpdate: [addon.folder] })}
|
onClick={() => update.mutateAsync({ toUpdate: [addon.folder] })}
|
||||||
className="s1 -mx-2 justify-self-end"
|
className="s1 -mx-2 justify-self-end"
|
||||||
>
|
>
|
||||||
Update
|
{t('addons.update')}
|
||||||
</TextButton>
|
</TextButton>
|
||||||
)}
|
)}
|
||||||
{addon.status === 'available' ? (
|
{addon.status === 'available' ? (
|
||||||
<TextButton
|
<TextButton
|
||||||
// TODO: With dependencies checkbox
|
|
||||||
onClick={() => update.mutateAsync({ toUpdate: [addon.folder] })}
|
onClick={() => update.mutateAsync({ toUpdate: [addon.folder] })}
|
||||||
className="text-warmGreen"
|
className="text-warmGreen"
|
||||||
icon={DownloadCloud}
|
icon={DownloadCloud}
|
||||||
size={18}
|
size={18}
|
||||||
title="Download"
|
title={t('addons.download')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DialogButton
|
<DialogButton
|
||||||
@@ -191,14 +196,13 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
|
|||||||
dialog={close => (
|
dialog={close => (
|
||||||
<div className="tw-dialog">
|
<div className="tw-dialog">
|
||||||
<CloseButton close={close} />
|
<CloseButton close={close} />
|
||||||
<h4 className="tw-color">Are you sure?</h4>
|
<h4 className="tw-color">{t('addons.deleteConfirmTitle')}</h4>
|
||||||
<hr />
|
<hr />
|
||||||
<p className="text-blueGray">
|
<p className="text-blueGray">
|
||||||
Are you sure you want to delete <span>{addon.folder}</span>{' '}
|
{t('addons.deleteConfirmBody', { folder: addon.folder })}
|
||||||
addon?
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-blueGray">
|
<p className="text-blueGray">
|
||||||
This will delete all files in the addon folder.
|
{t('addons.deleteConfirmFiles')}
|
||||||
</p>
|
</p>
|
||||||
<TextButton
|
<TextButton
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
@@ -209,7 +213,7 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
|
|||||||
disabled={remove.isLoading}
|
disabled={remove.isLoading}
|
||||||
className="self-end text-red"
|
className="self-end text-red"
|
||||||
>
|
>
|
||||||
Delete
|
{t('addons.delete')}
|
||||||
</TextButton>
|
</TextButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -220,7 +224,7 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
|
|||||||
className="text-red/50"
|
className="text-red/50"
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
size={18}
|
size={18}
|
||||||
title="Remove"
|
title={t('addons.remove')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import CloseButton from '~renderer/components/styled/CloseButton';
|
|||||||
import IconSpinner from '~renderer/components/styled/IconSpinner';
|
import IconSpinner from '~renderer/components/styled/IconSpinner';
|
||||||
import TextButton from '~renderer/components/styled/TextButton';
|
import TextButton from '~renderer/components/styled/TextButton';
|
||||||
import { api } from '~renderer/utils/api';
|
import { api } from '~renderer/utils/api';
|
||||||
|
import { useT } from '~renderer/i18n';
|
||||||
|
|
||||||
const useDebounced = (value: string, delay: number) => {
|
const useDebounced = (value: string, delay: number) => {
|
||||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||||
@@ -17,6 +18,7 @@ const useDebounced = (value: string, delay: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CustomAddonDialog = ({ close }: { close: () => void }) => {
|
const CustomAddonDialog = ({ close }: { close: () => void }) => {
|
||||||
|
const t = useT();
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
const debouncedUrl = useDebounced(url, 500);
|
const debouncedUrl = useDebounced(url, 500);
|
||||||
const response = api.addons.checkGitUrl.useQuery(debouncedUrl, {
|
const response = api.addons.checkGitUrl.useQuery(debouncedUrl, {
|
||||||
@@ -27,10 +29,14 @@ const CustomAddonDialog = ({ close }: { close: () => void }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="tw-dialog">
|
<div className="tw-dialog">
|
||||||
<CloseButton close={close} />
|
<CloseButton close={close} />
|
||||||
<h3 className="tw-color">Install addon</h3>
|
<h3 className="tw-color">{t('addons.installAddon')}</h3>
|
||||||
<hr />
|
<hr />
|
||||||
{response.data ? (
|
{response.data ? (
|
||||||
<img src={response.data?.preview} alt="Preview" className="w-full" />
|
<img
|
||||||
|
src={response.data?.preview}
|
||||||
|
alt={t('addons.previewAlt')}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-[191px] w-full items-center justify-center bg-darkPurple">
|
<div className="flex h-[191px] w-full items-center justify-center bg-darkPurple">
|
||||||
{response.isFetching && <IconSpinner />}
|
{response.isFetching && <IconSpinner />}
|
||||||
@@ -53,8 +59,8 @@ const CustomAddonDialog = ({ close }: { close: () => void }) => {
|
|||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<p className="s1 text-blueGray">
|
<p className="s1 text-blueGray">
|
||||||
{response.data
|
{response.data
|
||||||
? 'Ready to install'
|
? t('addons.readyToInstall')
|
||||||
: 'Not a valid git repository URL'}
|
: t('addons.invalidGitUrl')}
|
||||||
</p>
|
</p>
|
||||||
<TextButton
|
<TextButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -66,7 +72,7 @@ const CustomAddonDialog = ({ close }: { close: () => void }) => {
|
|||||||
className={response.data ? 'text-warmGreen' : 'text-blueGray'}
|
className={response.data ? 'text-warmGreen' : 'text-blueGray'}
|
||||||
disabled={!response.data || response.isLoading}
|
disabled={!response.data || response.isLoading}
|
||||||
>
|
>
|
||||||
Install
|
{t('addons.install')}
|
||||||
</TextButton>
|
</TextButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type ReactNode
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { api } from '~renderer/utils/api';
|
||||||
|
|
||||||
|
import { translations, type Lang } from './translations';
|
||||||
|
|
||||||
|
type Params = Record<string, string | number>;
|
||||||
|
type Translate = (key: string, params?: Params) => string;
|
||||||
|
|
||||||
|
type LocaleCtx = {
|
||||||
|
lang: Lang;
|
||||||
|
setLang: (lang: Lang) => void;
|
||||||
|
t: Translate;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LocaleContext = createContext<LocaleCtx>({
|
||||||
|
lang: 'enUS',
|
||||||
|
setLang: () => {},
|
||||||
|
t: key => key
|
||||||
|
});
|
||||||
|
|
||||||
|
export const LocaleProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const { data: pref } = api.preferences.get.useQuery();
|
||||||
|
const [lang, setLang] = useState<Lang>('enUS');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pref?.locale) setLang(pref.locale);
|
||||||
|
}, [pref?.locale]);
|
||||||
|
|
||||||
|
const t: Translate = (key, params) => {
|
||||||
|
let s = translations[lang]?.[key] ?? translations.enUS[key] ?? key;
|
||||||
|
if (params)
|
||||||
|
for (const [k, v] of Object.entries(params))
|
||||||
|
s = s.replace(`{${k}}`, String(v));
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LocaleContext.Provider value={{ lang, setLang, t }}>
|
||||||
|
{children}
|
||||||
|
</LocaleContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLocale = () => useContext(LocaleContext);
|
||||||
|
export const useT = () => useContext(LocaleContext).t;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,16 @@ body {
|
|||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
-webkit-user-select: text;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: rgb(248 156 66 / 0.45);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import log from 'electron-log/renderer';
|
|||||||
import { api } from './utils/api';
|
import { api } from './utils/api';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import ErrorBoundary from './ErrorBoundary';
|
import ErrorBoundary from './ErrorBoundary';
|
||||||
|
import { LocaleProvider } from './i18n';
|
||||||
|
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
@@ -56,7 +57,9 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<api.Provider client={trpcClient} queryClient={queryClient}>
|
<api.Provider client={trpcClient} queryClient={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<LocaleProvider>
|
||||||
|
<App />
|
||||||
|
</LocaleProvider>
|
||||||
{import.meta.env.DEV && <ReactQueryDevtools />}
|
{import.meta.env.DEV && <ReactQueryDevtools />}
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</api.Provider>
|
</api.Provider>
|
||||||
|
|||||||
@@ -2,29 +2,24 @@ import { useEffect } from 'react';
|
|||||||
|
|
||||||
const allowedElements = ['INPUT', 'TEXTAREA'];
|
const allowedElements = ['INPUT', 'TEXTAREA'];
|
||||||
|
|
||||||
|
const isClipboardShortcut = (e: KeyboardEvent) =>
|
||||||
|
(e.ctrlKey || e.metaKey) &&
|
||||||
|
['a', 'c', 'v', 'x'].includes(e.key.toLowerCase());
|
||||||
|
|
||||||
const usePreventDefaultEvents = () => {
|
const usePreventDefaultEvents = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const disableKeyboardEvents = (e: KeyboardEvent) => {
|
const disableKeyboardEvents = (e: KeyboardEvent) => {
|
||||||
if (allowedElements.includes((e.target as HTMLElement).tagName)) return;
|
if (allowedElements.includes((e.target as HTMLElement).tagName)) return;
|
||||||
e.preventDefault();
|
if (isClipboardShortcut(e)) return;
|
||||||
};
|
|
||||||
|
|
||||||
const disableFocus = (e: FocusEvent) => {
|
|
||||||
if (allowedElements.includes((e.target as HTMLElement).tagName)) return;
|
|
||||||
if (document.activeElement instanceof HTMLElement) {
|
|
||||||
document.activeElement.blur();
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', disableKeyboardEvents, true);
|
window.addEventListener('keydown', disableKeyboardEvents, true);
|
||||||
window.addEventListener('keyup', disableKeyboardEvents, true);
|
window.addEventListener('keyup', disableKeyboardEvents, true);
|
||||||
window.addEventListener('focusin', disableFocus, true);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', disableKeyboardEvents, true);
|
window.removeEventListener('keydown', disableKeyboardEvents, true);
|
||||||
window.removeEventListener('keyup', disableKeyboardEvents, true);
|
window.removeEventListener('keyup', disableKeyboardEvents, true);
|
||||||
window.removeEventListener('focusin', disableFocus, true);
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user