@@ -0,0 +1,180 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ModIdSchema = z.enum([
|
||||
'dxvk',
|
||||
'nampower',
|
||||
'multiMonitorFix',
|
||||
'transmogFix',
|
||||
'unitXp',
|
||||
'vanillaFixes',
|
||||
'vanillaHelpers'
|
||||
]);
|
||||
export type ModId = z.infer<typeof ModIdSchema>;
|
||||
|
||||
export type ModSource =
|
||||
| {
|
||||
kind: 'directFile';
|
||||
url: string;
|
||||
versionUrl?: string;
|
||||
latestVersionUrl?: string;
|
||||
parseLatest?: 'githubRelease' | 'gitlabRelease' | 'codebergRelease';
|
||||
apiUrl?: string;
|
||||
pinnedTag?: string;
|
||||
assetName: string;
|
||||
}
|
||||
| {
|
||||
kind: 'archive';
|
||||
url: string;
|
||||
latestVersionUrl?: string;
|
||||
apiUrl?: string;
|
||||
parseLatest?: 'githubRelease' | 'gitlabRelease' | 'codebergRelease';
|
||||
pinnedTag?: string;
|
||||
format: 'zip' | 'tar.gz';
|
||||
extractMap: Record<string, string>;
|
||||
}
|
||||
| { kind: 'managed' };
|
||||
|
||||
export type ModEntry = {
|
||||
id: ModId;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
recommended?: boolean;
|
||||
requires?: ModId[];
|
||||
repoUrl: string;
|
||||
source: ModSource;
|
||||
registerInDllsTxt?: string;
|
||||
};
|
||||
|
||||
export const MODS: ModEntry[] = [
|
||||
{
|
||||
id: 'dxvk',
|
||||
name: 'dxvk',
|
||||
version: 'v2.7.1-1',
|
||||
description: 'Enables Vulkan based rendering mode for better performance.',
|
||||
recommended: true,
|
||||
repoUrl: 'https://gitlab.com/Ph42oN/dxvk-gplasync',
|
||||
source: {
|
||||
kind: 'archive',
|
||||
url: 'https://gitlab.com/Ph42oN/dxvk-gplasync/-/raw/main/releases/dxvk-gplasync-v2.7.1-1.tar.gz?ref_type=heads',
|
||||
pinnedTag: 'v2.7.1-1',
|
||||
format: 'tar.gz',
|
||||
extractMap: {
|
||||
'dxvk-gplasync-v2.7.1-1/x32/d3d9.dll': 'd3d9.dll'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'nampower',
|
||||
name: 'nampower',
|
||||
version: 'v4.6.0',
|
||||
description:
|
||||
'A client modification that minimizes your input lag if you have higher latency.',
|
||||
repoUrl: 'https://gitea.com/avitasia/nampower',
|
||||
requires: ['vanillaFixes'],
|
||||
source: {
|
||||
kind: 'directFile',
|
||||
url: 'https://gitea.com/avitasia/nampower/releases/download/v4.6.0/nampower.dll',
|
||||
pinnedTag: 'v4.6.0',
|
||||
assetName: 'nampower.dll'
|
||||
},
|
||||
registerInDllsTxt: 'nampower.dll'
|
||||
},
|
||||
{
|
||||
id: 'multiMonitorFix',
|
||||
name: 'no1600x1200',
|
||||
version: '0.2',
|
||||
description: 'Fix for larger resolutions or multi monitor setups.',
|
||||
repoUrl: 'https://github.com/Mates1500/VanillaMultiMonitorFix',
|
||||
requires: ['vanillaFixes'],
|
||||
source: {
|
||||
kind: 'archive',
|
||||
url: 'https://github.com/Mates1500/VanillaMultiMonitorFix/releases/download/0.2/release.zip',
|
||||
apiUrl:
|
||||
'https://api.github.com/repos/Mates1500/VanillaMultiMonitorFix/releases/latest',
|
||||
parseLatest: 'githubRelease',
|
||||
pinnedTag: '0.2',
|
||||
format: 'zip',
|
||||
extractMap: {
|
||||
'VanillaMultiMonitorFix.dll': 'VanillaMultiMonitorFix.dll'
|
||||
}
|
||||
},
|
||||
registerInDllsTxt: 'VanillaMultiMonitorFix.dll'
|
||||
},
|
||||
{
|
||||
id: 'transmogFix',
|
||||
name: 'transmogFix',
|
||||
version: 'v0.7.0',
|
||||
description:
|
||||
"A client-side fix that eliminates frame drops caused by the server's transmogrification durability workaround.",
|
||||
repoUrl: 'https://codeberg.org/MarcelineVQ/WeirdUtils',
|
||||
requires: ['vanillaFixes'],
|
||||
source: {
|
||||
kind: 'directFile',
|
||||
url: 'https://codeberg.org/MarcelineVQ/WeirdUtils/releases/download/v0.7.0/transmogfix.dll',
|
||||
pinnedTag: 'v0.7.0',
|
||||
assetName: 'transmogfix.dll'
|
||||
},
|
||||
registerInDllsTxt: 'transmogfix.dll'
|
||||
},
|
||||
{
|
||||
id: 'unitXp',
|
||||
name: 'unitXp',
|
||||
version: 'v89',
|
||||
description: 'An attempt to make Vanilla 1.12 modern.',
|
||||
repoUrl: 'https://codeberg.org/konaka/UnitXP_SP3',
|
||||
requires: ['vanillaFixes'],
|
||||
source: {
|
||||
kind: 'archive',
|
||||
url: 'https://codeberg.org/konaka/UnitXP_SP3/releases/download/v89/UnitXP_SP3%20v89.zip',
|
||||
pinnedTag: 'v89',
|
||||
format: 'zip',
|
||||
extractMap: {
|
||||
'UnitXP_SP3.dll': 'UnitXP_SP3.dll'
|
||||
}
|
||||
},
|
||||
registerInDllsTxt: 'UnitXP_SP3.dll'
|
||||
},
|
||||
{
|
||||
id: 'vanillaFixes',
|
||||
name: 'vanillaFixes',
|
||||
version: 'v1.5.3',
|
||||
description: 'A client modification that eliminates stutter and animation lag.',
|
||||
recommended: true,
|
||||
repoUrl: 'https://github.com/hannesmann/vanillafixes',
|
||||
source: {
|
||||
kind: 'archive',
|
||||
url: 'https://github.com/hannesmann/vanillafixes/releases/download/v1.5.3/vanillafixes-1.5.3.zip',
|
||||
apiUrl:
|
||||
'https://api.github.com/repos/hannesmann/vanillafixes/releases/latest',
|
||||
parseLatest: 'githubRelease',
|
||||
pinnedTag: 'v1.5.3',
|
||||
format: 'zip',
|
||||
extractMap: {
|
||||
'VfPatcher.dll': 'VfPatcher.dll',
|
||||
'VanillaFixes.exe': 'VanillaFixes.exe'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'vanillaHelpers',
|
||||
name: 'vanillaHelpers',
|
||||
version: 'v1.1.2',
|
||||
description: 'Utility library that might be required by other patches and addons.',
|
||||
repoUrl: 'https://github.com/isfir/VanillaHelpers',
|
||||
requires: ['vanillaFixes'],
|
||||
source: {
|
||||
kind: 'directFile',
|
||||
url: 'https://github.com/isfir/VanillaHelpers/releases/download/v1.1.2/VanillaHelpers.dll',
|
||||
apiUrl:
|
||||
'https://api.github.com/repos/isfir/VanillaHelpers/releases/latest',
|
||||
parseLatest: 'githubRelease',
|
||||
pinnedTag: 'v1.1.2',
|
||||
assetName: 'VanillaHelpers.dll'
|
||||
},
|
||||
registerInDllsTxt: 'VanillaHelpers.dll'
|
||||
}
|
||||
];
|
||||
|
||||
export const getMod = (id: ModId): ModEntry | undefined =>
|
||||
MODS.find(m => m.id === id);
|
||||
@@ -0,0 +1,110 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const f = {
|
||||
boolean: (defaultValue?: boolean) =>
|
||||
z.boolean().nullish().default(!!defaultValue),
|
||||
number: (defaultValue?: number, val?: (v: z.ZodNumber) => z.ZodNumber) =>
|
||||
z.preprocess(
|
||||
v =>
|
||||
v === '' || v === undefined
|
||||
? defaultValue ?? null
|
||||
: typeof v === 'string'
|
||||
? Number(v)
|
||||
: v,
|
||||
(val?.(z.number()) ?? z.number()).nullish()
|
||||
)
|
||||
};
|
||||
|
||||
export const ConfigWtfSchema = z.object({
|
||||
vanillaFixes: f.boolean(),
|
||||
largeAddress: f.boolean(true),
|
||||
nameplateRange: f.number(41),
|
||||
alwaysAutoLoot: f.boolean(),
|
||||
fieldOfView: f.number(110),
|
||||
farClip: f.number(777),
|
||||
frillDistance: f.number(70),
|
||||
cameraDistance: f.number(50),
|
||||
soundInBackground: f.boolean(true)
|
||||
});
|
||||
export type ConfigWtfSchema = z.infer<typeof ConfigWtfSchema>;
|
||||
|
||||
export const ModStateSchema = z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
installedVersion: z.string().optional(),
|
||||
installedFiles: z.array(z.string()).default([]),
|
||||
ignoreUpdates: z.boolean().default(false)
|
||||
});
|
||||
export type ModState = z.infer<typeof ModStateSchema>;
|
||||
|
||||
export const PreferencesSchema = z.object({
|
||||
isPortable: z.boolean().optional(),
|
||||
server: z.enum(['live', 'ptr']).default('live'),
|
||||
clientDir: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
lastPatchedLauncherVersion: z.string().optional(),
|
||||
expectedPatchedWowHash: z.string().optional(),
|
||||
minimizeToTrayOnPlay: f.boolean(true),
|
||||
cleanWdb: f.boolean(),
|
||||
rememberPosition: f.boolean(),
|
||||
windowPosition: z
|
||||
.object({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
width: z.number(),
|
||||
height: z.number()
|
||||
})
|
||||
.nullish(),
|
||||
config: ConfigWtfSchema.default({}),
|
||||
mods: z.record(ModStateSchema).default({})
|
||||
});
|
||||
export type PreferencesSchema = z.infer<typeof PreferencesSchema>;
|
||||
|
||||
export const TocDataSchema = z.object({
|
||||
Interface: z.string(),
|
||||
Title: z.string(),
|
||||
Author: z.string(),
|
||||
Notes: z.string(),
|
||||
Version: z.string(),
|
||||
Dependencies: z.string().optional(),
|
||||
OptionalDeps: z.string().optional()
|
||||
});
|
||||
|
||||
export type TocData = z.infer<typeof TocDataSchema>;
|
||||
|
||||
export const AddonDataSchema = z.object({
|
||||
status: z.enum([
|
||||
'available',
|
||||
'fetching',
|
||||
'unknown',
|
||||
'upToDate',
|
||||
'outOfDate',
|
||||
'downloading',
|
||||
'invalid'
|
||||
]),
|
||||
git: z.string().optional(),
|
||||
toc: TocDataSchema.optional(),
|
||||
description: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
branch: z.string().optional(),
|
||||
ref: z.string().optional(),
|
||||
folder: z.string(),
|
||||
progress: z.string().optional(),
|
||||
preview: z.string().optional()
|
||||
});
|
||||
|
||||
export type AddonData = z.infer<typeof AddonDataSchema>;
|
||||
|
||||
export const NewsItemSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
date: z.string(),
|
||||
body: z.string(),
|
||||
url: z.string().url().optional(),
|
||||
author: z.string().optional()
|
||||
});
|
||||
export type NewsItem = z.infer<typeof NewsItemSchema>;
|
||||
|
||||
export const NewsFeedSchema = z.object({
|
||||
items: z.array(NewsItemSchema)
|
||||
});
|
||||
export type NewsFeed = z.infer<typeof NewsFeedSchema>;
|
||||
@@ -0,0 +1,74 @@
|
||||
type Path = readonly (string | number)[];
|
||||
|
||||
export const nestedGet = <T>(object: unknown, path: Path) =>
|
||||
path.reduce((obj, key) => obj?.[key], object) as T;
|
||||
|
||||
export const nestedSet = (obj: any, path: Path, value: unknown) => {
|
||||
const [key, ...rest] = path;
|
||||
if (path.length === 1) {
|
||||
obj[key] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (obj[key] === undefined) {
|
||||
obj[key] = typeof rest[0] === 'number' ? [] : {};
|
||||
}
|
||||
|
||||
nestedSet(obj[key], rest as never, value);
|
||||
};
|
||||
|
||||
export const asyncReduce = async <T, U>(
|
||||
arr: T[],
|
||||
reducer: (acc: U, cur: T) => Promise<U>,
|
||||
init: U
|
||||
): Promise<U> => {
|
||||
let acc: U = init;
|
||||
for (const i of arr) acc = await reducer(acc, i);
|
||||
return acc;
|
||||
};
|
||||
|
||||
export const asyncMap = async <T, U>(
|
||||
arr: T[],
|
||||
map: (cur: T) => Promise<U>
|
||||
): Promise<U[]> => {
|
||||
const acc: U[] = [];
|
||||
for (const i of arr) acc.push(await map(i));
|
||||
return acc;
|
||||
};
|
||||
|
||||
export const isNotUndef = <T>(obj: T): obj is Exclude<T, undefined> =>
|
||||
obj !== undefined;
|
||||
|
||||
export const formatFileSize = (bytes: number) => {
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
export const formatDuration = (remaining: number) => {
|
||||
const hours = Math.floor(remaining / 3600);
|
||||
const minutes = Math.floor((remaining % 3600) / 60);
|
||||
const seconds = Math.floor(remaining % 60);
|
||||
|
||||
return `${hours ? `${hours}h ` : ''}${
|
||||
minutes ? `${minutes}m ` : ''
|
||||
}${seconds}s`;
|
||||
};
|
||||
|
||||
export const omit = <T extends object, const K extends keyof T>(
|
||||
obj: T,
|
||||
keys: K[]
|
||||
): Omit<T, K> => {
|
||||
const result = { ...obj };
|
||||
keys.forEach(key => {
|
||||
delete result[key];
|
||||
});
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createTRPCRouter } from './trpc';
|
||||
import { addonsRouter } from './routers/addonts';
|
||||
import { launcherRouter } from './routers/launcher';
|
||||
import { updaterRouter } from './routers/updater';
|
||||
import { patcherRouter } from './routers/patcher';
|
||||
import { generalRouter } from './routers/general';
|
||||
import { preferencesRouter } from './routers/preferences';
|
||||
import { newsRouter } from './routers/news';
|
||||
import { modsRouter } from './routers/mods';
|
||||
import { selfUpdaterRouter } from './routers/selfUpdater';
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
addons: addonsRouter,
|
||||
general: generalRouter,
|
||||
preferences: preferencesRouter,
|
||||
launcher: launcherRouter,
|
||||
patcher: patcherRouter,
|
||||
updater: updaterRouter,
|
||||
news: newsRouter,
|
||||
mods: modsRouter,
|
||||
selfUpdater: selfUpdaterRouter
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import Addons from '~main/modules/addons';
|
||||
import { AddonDataSchema } from '~common/schemas';
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
export const addonsRouter = createTRPCRouter({
|
||||
verify: publicProcedure.mutation(() => {
|
||||
Addons.verify();
|
||||
}),
|
||||
update: publicProcedure
|
||||
.input(z.object({ toUpdate: z.array(z.string()).optional() }))
|
||||
.mutation(({ input }) => Addons.update(input.toUpdate)),
|
||||
install: publicProcedure
|
||||
.input(AddonDataSchema)
|
||||
.mutation(({ input }) => Addons.install(input)),
|
||||
remove: publicProcedure
|
||||
.input(z.object({ toDelete: z.array(z.string()) }))
|
||||
.mutation(({ input }) => Addons.remove(input.toDelete)),
|
||||
checkGitUrl: publicProcedure
|
||||
.input(z.string())
|
||||
.query(({ input }) => Addons.checkGitUrl(input)),
|
||||
observe: publicProcedure.subscription(() => Addons.observe())
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { app, dialog, shell } from 'electron';
|
||||
import Logger from 'electron-log/main';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { mainWindow } from '~main/index';
|
||||
import Preferences from '~main/modules/preferences';
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
export const generalRouter = createTRPCRouter({
|
||||
appVersion: publicProcedure.query(() => app.getVersion()),
|
||||
quit: publicProcedure.mutation(() => app.quit()),
|
||||
minimize: publicProcedure.mutation(() => mainWindow?.minimize()),
|
||||
openLink: publicProcedure
|
||||
.input(z.string().url())
|
||||
.mutation(({ input }) => shell.openExternal(input)),
|
||||
openInstallFolder: publicProcedure.mutation(() => {
|
||||
const dir = Preferences.data.clientDir;
|
||||
if (dir) shell.openPath(dir);
|
||||
}),
|
||||
openLogFile: publicProcedure.mutation(() => {
|
||||
const file = Logger.transports.file.getFile().path;
|
||||
shell.openPath(file);
|
||||
}),
|
||||
filePicker: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
title: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
filters: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
extensions: z.array(z.string())
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
properties: z
|
||||
.array(
|
||||
z.enum([
|
||||
'openDirectory',
|
||||
'openFile',
|
||||
'multiSelections',
|
||||
'showHiddenFiles',
|
||||
'createDirectory',
|
||||
'promptToCreate',
|
||||
'noResolveAliases',
|
||||
'treatPackageAsDirectory',
|
||||
'dontAddToRecent'
|
||||
])
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
if (!mainWindow) return { canceled: true } as const;
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog(
|
||||
mainWindow,
|
||||
input
|
||||
);
|
||||
|
||||
return canceled
|
||||
? ({ canceled: true } as const)
|
||||
: ({
|
||||
canceled: false,
|
||||
path: filePaths as [string, ...string[]]
|
||||
} as const);
|
||||
})
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { inject } from 'dll-inject';
|
||||
import Logger from 'electron-log/main';
|
||||
|
||||
import Preferences from '~main/modules/preferences';
|
||||
import Mods from '~main/modules/mods';
|
||||
import { mainWindow } from '~main/index';
|
||||
import { isGameRunning } from '~main/modules/updater';
|
||||
import { patchConfig } from '~main/modules/patcher';
|
||||
import { minimizeToTray, restoreFromTray } from '~main/modules/tray';
|
||||
import { getMod } from '~common/mods';
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
const ensureChainloaderTweak = async (clientDir: string): Promise<boolean> => {
|
||||
if (Preferences.data.config.vanillaFixes) return true;
|
||||
|
||||
const installedMods = Mods.status.mods.filter(r => r.installedVersion);
|
||||
const anyDependsOnVf = installedMods.some(r =>
|
||||
getMod(r.id)?.requires?.includes('vanillaFixes')
|
||||
);
|
||||
|
||||
let dllsTxtHasEntries = false;
|
||||
const dllsPath = path.join(clientDir, 'dlls.txt');
|
||||
if (await fs.pathExists(dllsPath)) {
|
||||
const raw = await fs.readFile(dllsPath, 'utf8');
|
||||
dllsTxtHasEntries = raw
|
||||
.split(/\r?\n/)
|
||||
.some(l => l.trim() && !l.trim().startsWith('#'));
|
||||
}
|
||||
|
||||
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({
|
||||
start: publicProcedure.mutation(async () => {
|
||||
const { cleanWdb, minimizeToTrayOnPlay, config, clientDir } =
|
||||
Preferences.data;
|
||||
if (!clientDir) return false;
|
||||
|
||||
const clientPath = path.join(clientDir, 'WoW.exe');
|
||||
Logger.log(`Launching ${clientPath}...`);
|
||||
if (await isGameRunning(clientPath)) return false;
|
||||
|
||||
if (cleanWdb) {
|
||||
Logger.log('Cleaning up WDB...');
|
||||
await fs.remove(path.join(clientPath, 'WDB'));
|
||||
}
|
||||
|
||||
Logger.log('Checking Config.wtf...');
|
||||
await patchConfig();
|
||||
|
||||
Logger.log('Launching WoW...');
|
||||
const process = spawn(clientPath, { detached: !minimizeToTrayOnPlay });
|
||||
|
||||
const wantChainloader = await ensureChainloaderTweak(clientDir);
|
||||
if (wantChainloader) {
|
||||
Logger.log('Injecting VanillaFixes...');
|
||||
const vfPath = path.join(clientDir, 'VfPatcher.dll');
|
||||
|
||||
if (!(await fs.pathExists(vfPath))) {
|
||||
Logger.warn(
|
||||
`VfPatcher.dll missing at ${vfPath} — chainloader needed but ` +
|
||||
'the vanillaFixes mod is not installed. Skipping inject; ' +
|
||||
'dlls.txt entries and dependent mods will not load. Install ' +
|
||||
"vanillaFixes from the Mods tab to fix."
|
||||
);
|
||||
} else {
|
||||
const status = inject('WoW.exe', vfPath);
|
||||
if (status) {
|
||||
Logger.error(`Injecting failed with error code ${status}...`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!minimizeToTrayOnPlay) {
|
||||
mainWindow?.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
minimizeToTray();
|
||||
process.on('exit', () => {
|
||||
Logger.log('WoW stopped');
|
||||
restoreFromTray();
|
||||
});
|
||||
return true;
|
||||
})
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import Mods from '~main/modules/mods';
|
||||
import { ModIdSchema } from '~common/mods';
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
export const modsRouter = createTRPCRouter({
|
||||
list: publicProcedure.query(() => Mods.status),
|
||||
verify: publicProcedure.mutation(() => Mods.verify()),
|
||||
toggle: publicProcedure
|
||||
.input(z.object({ id: ModIdSchema, enabled: z.boolean() }))
|
||||
.mutation(({ input }) => Mods.toggle(input.id, input.enabled)),
|
||||
setIgnoreUpdates: publicProcedure
|
||||
.input(z.object({ id: ModIdSchema, ignore: z.boolean() }))
|
||||
.mutation(({ input }) => Mods.setIgnoreUpdates(input.id, input.ignore)),
|
||||
applyAll: publicProcedure.mutation(() => Mods.applyAll()),
|
||||
observe: publicProcedure.subscription(() => Mods.observe())
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import fetch from 'node-fetch';
|
||||
import Logger from 'electron-log/main';
|
||||
|
||||
import { NewsFeedSchema, type NewsItem } from '~common/schemas';
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
const FETCH_TIMEOUT_MS = 8_000;
|
||||
|
||||
const fetchNews = async (): Promise<NewsItem[]> => {
|
||||
const url = `${import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'}/news.json`;
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
if (!res.ok) throw Error(`HTTP ${res.status}`);
|
||||
const parsed = NewsFeedSchema.safeParse(await res.json());
|
||||
if (!parsed.success) {
|
||||
Logger.error('News feed failed schema validation', parsed.error.flatten());
|
||||
throw Error('Malformed news feed');
|
||||
}
|
||||
return parsed.data.items;
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
};
|
||||
|
||||
export const newsRouter = createTRPCRouter({
|
||||
list: publicProcedure.query(async () => {
|
||||
try {
|
||||
return await fetchNews();
|
||||
} catch (e) {
|
||||
Logger.error('Failed to fetch news', e);
|
||||
throw e;
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { patchConfig, patchExecutable } from '~main/modules/patcher';
|
||||
import Preferences from '~main/modules/preferences';
|
||||
import { getClientVersion } from '~main/utils';
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
export const patcherRouter = createTRPCRouter({
|
||||
apply: publicProcedure.mutation(async () => {
|
||||
await patchExecutable();
|
||||
await patchConfig();
|
||||
Preferences.data = { version: await getClientVersion() };
|
||||
})
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { PreferencesSchema } from '~common/schemas';
|
||||
import Preferences from '~main/modules/preferences';
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
export const preferencesRouter = createTRPCRouter({
|
||||
get: publicProcedure.output(PreferencesSchema).query(() => Preferences.data),
|
||||
set: publicProcedure
|
||||
.input(PreferencesSchema.partial())
|
||||
.mutation(async ({ input }) => {
|
||||
Preferences.data = input;
|
||||
return Preferences.data;
|
||||
}),
|
||||
isValidClientDir: publicProcedure
|
||||
.input(z.string().optional())
|
||||
.query(({ input }) => Preferences.isValidClientDir(input))
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import SelfUpdater from '~main/modules/selfUpdater';
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
export const selfUpdaterRouter = createTRPCRouter({
|
||||
observe: publicProcedure.subscription(() => SelfUpdater.observe()),
|
||||
install: publicProcedure.mutation(() => SelfUpdater.triggerInstall())
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import Updater from '~main/modules/updater';
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
export const updaterRouter = createTRPCRouter({
|
||||
verify: publicProcedure.mutation(() => Updater.verify()),
|
||||
update: publicProcedure
|
||||
.input(z.boolean().optional())
|
||||
.mutation(async ({ input }) => Updater.update(input)),
|
||||
observe: publicProcedure.subscription(() => Updater.observe())
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import superjson from 'superjson';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
const t = initTRPC.create({
|
||||
transformer: superjson,
|
||||
errorFormatter: ({ shape, error }) => ({
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
export const createTRPCRouter = t.router;
|
||||
|
||||
export const publicProcedure = t.procedure;
|
||||
@@ -0,0 +1,107 @@
|
||||
import { join } from 'path';
|
||||
|
||||
import { app, shell, BrowserWindow } from 'electron';
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils';
|
||||
import { createIPCHandler } from 'electron-trpc/main';
|
||||
import Logger from 'electron-log/main';
|
||||
|
||||
import icon from '~build/icon.png?asset';
|
||||
|
||||
import { appRouter } from './api/root';
|
||||
import Preferences from './modules/preferences';
|
||||
import Updater from './modules/updater';
|
||||
import Addons from './modules/addons';
|
||||
import Mods from './modules/mods';
|
||||
import { initSelfUpdater } from './modules/selfUpdater';
|
||||
|
||||
Logger.initialize();
|
||||
Logger.errorHandler.startCatching();
|
||||
Logger.info('Launcher starting...');
|
||||
|
||||
app.disableHardwareAcceleration();
|
||||
|
||||
export let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
const createWindow = async () => {
|
||||
const position = Preferences.data.rememberPosition
|
||||
? Preferences.data.windowPosition
|
||||
: { width: 1000, height: 700 };
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
...position,
|
||||
minWidth: 1000,
|
||||
minHeight: 700,
|
||||
icon,
|
||||
frame: false,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
contextIsolation: true,
|
||||
sandbox: false,
|
||||
devTools: true
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.webContents.on('render-process-gone', (_e, details) => {
|
||||
Logger.error('Renderer process gone:', details);
|
||||
});
|
||||
mainWindow.webContents.on('unresponsive', () => {
|
||||
Logger.error('Renderer unresponsive');
|
||||
});
|
||||
|
||||
mainWindow.webContents.on('console-message', (_e, level, message, line, sourceId) => {
|
||||
const lvl = level === 3 ? 'error' : level === 2 ? 'warn' : 'info';
|
||||
Logger[lvl](`[renderer:${lvl}] ${message} (${sourceId}:${line})`);
|
||||
});
|
||||
|
||||
mainWindow.webContents.on('before-input-event', (_e, input) => {
|
||||
if (input.type !== 'keyDown') return;
|
||||
if (input.key === 'F12') {
|
||||
mainWindow?.webContents.toggleDevTools();
|
||||
}
|
||||
});
|
||||
|
||||
createIPCHandler({ router: appRouter, windows: [mainWindow] });
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow?.show();
|
||||
});
|
||||
mainWindow.webContents.setWindowOpenHandler(details => {
|
||||
shell.openExternal(details.url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
mainWindow.on('close', () => {
|
||||
if (!mainWindow) return;
|
||||
const [x = 0, y = 0] = mainWindow.getPosition();
|
||||
const [width = 0, height = 0] = mainWindow.getSize();
|
||||
Preferences.data = { windowPosition: { x, y, width, height } };
|
||||
});
|
||||
|
||||
if (is.dev && process.env.ELECTRON_RENDERER_URL) {
|
||||
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL);
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
|
||||
}
|
||||
};
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
Preferences.data = await Preferences.load();
|
||||
|
||||
Addons.verify();
|
||||
Updater.verify();
|
||||
Mods.verify();
|
||||
initSelfUpdater();
|
||||
|
||||
electronApp.setAppUserModelId('com.electron');
|
||||
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window);
|
||||
});
|
||||
|
||||
await createWindow();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', async () => {
|
||||
app.quit();
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import git, { type ProgressCallback } from 'isomorphic-git';
|
||||
import http from 'isomorphic-git/http/node';
|
||||
import fs from 'fs-extra';
|
||||
import fetch from 'node-fetch';
|
||||
import Logger from 'electron-log/main';
|
||||
|
||||
import { isNotUndef } from '~common/utils';
|
||||
import { type AddonData, type TocData } from '~common/schemas';
|
||||
import { runWorker } from '~main/utils';
|
||||
import gitPull from '~main/workers/gitPull?nodeWorker';
|
||||
import gitClone from '~main/workers/gitClone?nodeWorker';
|
||||
|
||||
import Preferences from './preferences';
|
||||
import Observable from './observable';
|
||||
|
||||
export type AddonsStatus = {
|
||||
state: 'verifying' | 'done';
|
||||
addons: { [name: string]: AddonData };
|
||||
available: AddonData[];
|
||||
};
|
||||
|
||||
type AddonsList = {
|
||||
name: string;
|
||||
owner: string;
|
||||
branch?: string;
|
||||
ref?: string;
|
||||
git: string;
|
||||
toc?: TocData;
|
||||
description?: string;
|
||||
lastUpdated?: string;
|
||||
stars?: number;
|
||||
dependencies?: string[];
|
||||
}[];
|
||||
|
||||
const readTocData = (content: string) =>
|
||||
content
|
||||
.split('\n')
|
||||
.filter(l => l.startsWith('## '))
|
||||
.map(l => l.slice(3))
|
||||
.map(l => {
|
||||
const [key, value] = l.split(':');
|
||||
return [key.trim(), value.trim()];
|
||||
})
|
||||
.reduce((acc, [key, value]) => {
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
}, {} as TocData);
|
||||
|
||||
const fetchAddons = async () => {
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'}/api/addons.json`);
|
||||
return (await response.json()) as AddonsList;
|
||||
} catch (e) {
|
||||
Logger.error('Failed to reach update server', e);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
class AddonsClass extends Observable<AddonsStatus> {
|
||||
protected _value: AddonsStatus = {
|
||||
state: 'done',
|
||||
addons: {},
|
||||
available: []
|
||||
};
|
||||
|
||||
get status() {
|
||||
return this._value;
|
||||
}
|
||||
private set status(v: AddonsStatus) {
|
||||
this._value = v;
|
||||
this._notifyObservers(v);
|
||||
}
|
||||
|
||||
#onProgress =
|
||||
(folder: string, data: AddonData): ProgressCallback =>
|
||||
progress => {
|
||||
const getPhase = (step: string) => {
|
||||
switch (step) {
|
||||
case 'Counting objects':
|
||||
return 1;
|
||||
case 'Compressing objects':
|
||||
return 2;
|
||||
case 'Receiving objects':
|
||||
return 3;
|
||||
case 'Resolving deltas':
|
||||
return 4;
|
||||
case 'Analyzing workdir':
|
||||
return 5;
|
||||
case 'Updating workdir':
|
||||
return 6;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
this.#setAddon(folder, {
|
||||
...data,
|
||||
progress: `${Math.round(
|
||||
(progress.loaded / (progress.total ?? progress.loaded)) * 100
|
||||
)}% (${getPhase(progress.phase)}/6)`
|
||||
});
|
||||
};
|
||||
|
||||
async checkGitUrl(url: string) {
|
||||
const gitUrl = url.endsWith('.git') ? url : `${url}.git`;
|
||||
try {
|
||||
await git.getRemoteInfo({
|
||||
http,
|
||||
url: gitUrl
|
||||
});
|
||||
|
||||
// Only fetch preview from known public git hosts to prevent SSRF.
|
||||
const allowed = ['github.com', 'gitlab.com', 'gitea.com', 'codeberg.org'];
|
||||
let preview: string | undefined;
|
||||
try {
|
||||
const host = new URL(url).hostname.toLowerCase();
|
||||
if (allowed.some(h => host === h || host.endsWith('.' + h))) {
|
||||
const response = await fetch(url).then(r => r.text());
|
||||
preview = response.match(
|
||||
/property="og:image" content="([^"]*)"/
|
||||
)?.[1];
|
||||
}
|
||||
} catch {
|
||||
// preview stays undefined
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'available',
|
||||
folder: gitUrl.slice(0, -4).split('/').at(-1),
|
||||
git: gitUrl,
|
||||
preview
|
||||
} as AddonData;
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async verify() {
|
||||
if (this.status.state !== 'done') return;
|
||||
|
||||
this.status = {
|
||||
...this.status,
|
||||
state: 'verifying'
|
||||
};
|
||||
|
||||
const remoteAddons = await fetchAddons();
|
||||
const available: AddonData[] = remoteAddons.map(a => ({
|
||||
status: 'available',
|
||||
git: a.git,
|
||||
toc: a.toc,
|
||||
description: a.description,
|
||||
folder: a.name,
|
||||
branch: a.branch,
|
||||
ref: a.ref
|
||||
}));
|
||||
|
||||
const clientPath = Preferences.data.clientDir;
|
||||
if (!clientPath) {
|
||||
this.status = { state: 'done', addons: {}, available };
|
||||
return;
|
||||
}
|
||||
|
||||
const addonsPath = path.join(clientPath, 'Interface', 'Addons');
|
||||
const dirs = await fs.pathExists(addonsPath)
|
||||
? await fs.readdir(addonsPath)
|
||||
: [];
|
||||
const addons: AddonsStatus['addons'] = Object.fromEntries(
|
||||
dirs
|
||||
.filter(d => !d.startsWith('Blizzard_'))
|
||||
.map(name => [name, { status: 'fetching' as const, folder: name }])
|
||||
);
|
||||
|
||||
this.status = { state: 'verifying', addons, available };
|
||||
|
||||
const verifyOne = async (folder: string) => {
|
||||
const dir = path.join(addonsPath, folder);
|
||||
|
||||
if (!fs.existsSync(path.join(dir, `${folder}.toc`))) {
|
||||
this.#setAddon(folder, {
|
||||
status: 'invalid',
|
||||
error: 'Missing .toc file',
|
||||
folder
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const toc = await readTocData(
|
||||
await fs.readFile(path.join(dir, `${folder}.toc`), 'utf-8')
|
||||
);
|
||||
|
||||
const remote = await git
|
||||
.listRemotes({ fs, dir })
|
||||
.then(r => r[0])
|
||||
.catch(() => null);
|
||||
|
||||
const avail = remoteAddons.find(a => a.name === folder);
|
||||
if (!remote) {
|
||||
Logger.log(`Addon "${folder}" is not a git repository`);
|
||||
this.#setAddon(
|
||||
folder,
|
||||
avail
|
||||
? {
|
||||
status: 'outOfDate',
|
||||
git: avail.git,
|
||||
toc,
|
||||
description: avail.description,
|
||||
folder
|
||||
}
|
||||
: { status: 'unknown', toc, folder }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await git.fetch({ fs, dir, http, tags: true });
|
||||
|
||||
const branch = await git.currentBranch({ fs, dir });
|
||||
|
||||
const localCommit = await git
|
||||
.log({ fs, dir, ref: 'HEAD', depth: 1 })
|
||||
.then(r => r[0].oid)
|
||||
.catch(() => null);
|
||||
|
||||
const remoteCommit = avail?.ref
|
||||
? await git
|
||||
.resolveRef({ fs, dir, ref: avail.ref })
|
||||
.catch(() => null)
|
||||
: await git
|
||||
.log({ fs, dir, ref: `${remote.remote}/${branch}`, depth: 1 })
|
||||
.then(r => r[0].oid)
|
||||
.catch(() => null);
|
||||
|
||||
const status = await git.statusMatrix({ fs, dir });
|
||||
const hasChanges = status.some(
|
||||
([_, HEAD, index, workdir]) => HEAD !== index || index !== workdir
|
||||
);
|
||||
|
||||
const isUpToDate =
|
||||
!hasChanges && remoteCommit && localCommit === remoteCommit;
|
||||
this.#setAddon(folder, {
|
||||
git: remote.url,
|
||||
status: isUpToDate ? 'upToDate' : 'outOfDate',
|
||||
toc,
|
||||
description: avail?.description,
|
||||
ref: avail?.ref,
|
||||
folder
|
||||
});
|
||||
|
||||
Logger.log(
|
||||
isUpToDate
|
||||
? `Addon "${folder}" is up to date${avail?.ref ? ` (pinned ${avail.ref})` : ''}`
|
||||
: `Addon "${folder}" has an update available`
|
||||
);
|
||||
} catch (e) {
|
||||
this.#setAddon(folder, {
|
||||
git: remote.url,
|
||||
status: 'invalid',
|
||||
error: 'Failed to verify',
|
||||
toc,
|
||||
folder
|
||||
});
|
||||
Logger.error(`Addon "${folder}" failed to verify`, e);
|
||||
}
|
||||
};
|
||||
|
||||
const folders = Object.keys(addons);
|
||||
const VERIFY_CONCURRENCY = 6;
|
||||
let idx = 0;
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(VERIFY_CONCURRENCY, folders.length) }, async () => {
|
||||
while (true) {
|
||||
const i = idx++;
|
||||
if (i >= folders.length) return;
|
||||
await verifyOne(folders[i]);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.status = { ...this.status, state: 'done' };
|
||||
}
|
||||
|
||||
async update(
|
||||
toUpdate = Object.values(this.status.addons)
|
||||
.filter(e => e.status === 'outOfDate')
|
||||
.map(e => e.folder)
|
||||
.filter(isNotUndef)
|
||||
) {
|
||||
const clientPath = Preferences.data.clientDir;
|
||||
if (!clientPath) return;
|
||||
if (this.status.state !== 'done') return;
|
||||
|
||||
const addonsPath = path.join(clientPath, 'Interface', 'Addons');
|
||||
|
||||
for (const folder of toUpdate) {
|
||||
if (this.status.addons[folder]?.status === 'downloading') continue;
|
||||
const dir = path.join(addonsPath, folder);
|
||||
|
||||
const avail = this.status.available.find(a => a.folder === folder);
|
||||
const data: AddonData = {
|
||||
...avail,
|
||||
...this.status.addons[folder],
|
||||
status: 'downloading'
|
||||
};
|
||||
this.#setAddon(folder, data);
|
||||
|
||||
const remote = await git
|
||||
.listRemotes({ fs, dir })
|
||||
.then(r => r?.[0])
|
||||
.catch(() => null);
|
||||
|
||||
try {
|
||||
if (!remote) {
|
||||
await runWorker(
|
||||
gitClone,
|
||||
{ dir, url: data.git, ref: data.ref ?? data.branch },
|
||||
{ onProgress: this.#onProgress(folder, data) }
|
||||
);
|
||||
} else {
|
||||
const branch =
|
||||
(await git.currentBranch({ fs, dir })) ?? avail?.branch ?? 'master';
|
||||
await runWorker(
|
||||
gitPull,
|
||||
{
|
||||
dir,
|
||||
remote: remote.remote,
|
||||
branch,
|
||||
ref: avail?.ref
|
||||
},
|
||||
{ onProgress: this.#onProgress(folder, data) }
|
||||
);
|
||||
}
|
||||
const toc = await readTocData(
|
||||
await fs.readFile(path.join(dir, `${folder}.toc`), 'utf-8')
|
||||
);
|
||||
|
||||
this.#setAddon(folder, { ...data, toc, status: 'upToDate' });
|
||||
Logger.log(`Updated addon "${folder}"`);
|
||||
} catch (e) {
|
||||
this.#setAddon(folder, {
|
||||
...data,
|
||||
status: 'invalid',
|
||||
error: 'Failed to update'
|
||||
});
|
||||
Logger.error(`Addon "${folder}" failed to update`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async remove(toRemove: string[]) {
|
||||
const clientPath = Preferences.data.clientDir;
|
||||
if (!clientPath) return;
|
||||
if (this.status.state !== 'done') return;
|
||||
|
||||
for (const folder of toRemove) {
|
||||
const dir = path.join(clientPath, 'Interface', 'Addons', folder);
|
||||
if (fs.existsSync(dir)) await fs.remove(dir);
|
||||
this.#setAddon(folder);
|
||||
Logger.log(`Removed addon "${folder}"`);
|
||||
}
|
||||
}
|
||||
|
||||
async install(data: AddonData) {
|
||||
const clientPath = Preferences.data.clientDir;
|
||||
if (!clientPath) return;
|
||||
|
||||
const addonsPath = path.join(clientPath, 'Interface', 'Addons');
|
||||
const dir = path.join(addonsPath, data.folder);
|
||||
try {
|
||||
await runWorker(
|
||||
gitClone,
|
||||
{ dir, url: data.git, ref: data.ref ?? data.branch },
|
||||
{ onProgress: this.#onProgress(data.folder, data) }
|
||||
);
|
||||
const toc = await readTocData(
|
||||
await fs.readFile(path.join(dir, `${data.folder}.toc`), 'utf-8')
|
||||
);
|
||||
this.#setAddon(data.folder, { ...data, toc, status: 'upToDate' });
|
||||
Logger.log(`Installed addon "${data.folder}"`);
|
||||
} catch (e) {
|
||||
this.#setAddon(data.folder, {
|
||||
...data,
|
||||
status: 'invalid',
|
||||
error: 'Failed to install'
|
||||
});
|
||||
Logger.error(`Addon "${data.folder}" failed to install`, e);
|
||||
}
|
||||
}
|
||||
|
||||
#setAddon(folder: string, data?: AddonData) {
|
||||
const { [folder]: _, ...addons } = this.status.addons;
|
||||
this.status = {
|
||||
...this.status,
|
||||
addons: data ? { ...addons, [folder]: data } : addons
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const Addons = new AddonsClass();
|
||||
export default Addons;
|
||||
@@ -0,0 +1,55 @@
|
||||
import path from 'path';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
|
||||
let queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
const serial = <T>(fn: () => Promise<T>): Promise<T> => {
|
||||
const next = queue.then(fn, fn);
|
||||
queue = next.catch(() => {});
|
||||
return next;
|
||||
};
|
||||
|
||||
const dllsPath = (clientDir: string) => path.join(clientDir, 'dlls.txt');
|
||||
|
||||
const readLines = async (clientDir: string): Promise<string[]> => {
|
||||
const file = dllsPath(clientDir);
|
||||
if (!(await fs.pathExists(file))) return [];
|
||||
const text = await fs.readFile(file, 'utf8');
|
||||
return text.split(/\r?\n/);
|
||||
};
|
||||
|
||||
const writeLines = async (clientDir: string, lines: string[]) => {
|
||||
const file = dllsPath(clientDir);
|
||||
const trimmed = lines.join('\n').replace(/\n+$/, '');
|
||||
if (!trimmed.trim()) {
|
||||
if (await fs.pathExists(file)) await fs.remove(file);
|
||||
return;
|
||||
}
|
||||
await fs.writeFile(file, trimmed + '\n', 'utf8');
|
||||
};
|
||||
|
||||
const matches = (line: string, name: string) =>
|
||||
line.trim().toLowerCase() === name.toLowerCase();
|
||||
|
||||
export const addDll = (clientDir: string, name: string) =>
|
||||
serial(async () => {
|
||||
const lines = await readLines(clientDir);
|
||||
if (lines.some(l => matches(l, name))) return;
|
||||
lines.push(name);
|
||||
await writeLines(clientDir, lines);
|
||||
});
|
||||
|
||||
export const removeDll = (clientDir: string, name: string) =>
|
||||
serial(async () => {
|
||||
const lines = await readLines(clientDir);
|
||||
const next = lines.filter(l => !matches(l, name));
|
||||
if (next.length === lines.length) return;
|
||||
await writeLines(clientDir, next);
|
||||
});
|
||||
|
||||
export const hasDll = (clientDir: string, name: string) =>
|
||||
serial(async () => {
|
||||
const lines = await readLines(clientDir);
|
||||
return lines.some(l => matches(l, name));
|
||||
});
|
||||
@@ -0,0 +1,389 @@
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import fetch from 'node-fetch';
|
||||
import AdmZip from 'adm-zip';
|
||||
import * as tar from 'tar';
|
||||
import Logger from 'electron-log/main';
|
||||
|
||||
import { MODS, type ModEntry, type ModId, getMod } from '~common/mods';
|
||||
import { type ModState } from '~common/schemas';
|
||||
|
||||
import Preferences from './preferences';
|
||||
import Observable from './observable';
|
||||
import { addDll, removeDll } from './dllsTxt';
|
||||
|
||||
export type ModRowStatus = {
|
||||
id: ModId;
|
||||
name: string;
|
||||
description: string;
|
||||
repoUrl: string;
|
||||
recommended: boolean;
|
||||
requires: ModId[];
|
||||
enabled: boolean;
|
||||
ignoreUpdates: boolean;
|
||||
installedVersion?: string;
|
||||
latestVersion: string;
|
||||
state: 'idle' | 'downloading' | 'installing' | 'uninstalling' | 'error';
|
||||
progress?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ModsStatus = {
|
||||
state: 'verifying' | 'idle' | 'busy';
|
||||
dirty: boolean;
|
||||
mods: ModRowStatus[];
|
||||
};
|
||||
|
||||
const VERSION_CACHE_MS = 10 * 60 * 1000;
|
||||
|
||||
class ModsClass extends Observable<ModsStatus> {
|
||||
protected _value: ModsStatus = {
|
||||
state: 'verifying',
|
||||
dirty: false,
|
||||
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 {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
#initialRow(m: ModEntry): ModRowStatus {
|
||||
const state = Preferences.data?.mods?.[m.id];
|
||||
return {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
repoUrl: m.repoUrl,
|
||||
recommended: !!m.recommended,
|
||||
requires: m.requires ?? [],
|
||||
enabled: !!state?.enabled,
|
||||
ignoreUpdates: !!state?.ignoreUpdates,
|
||||
installedVersion: state?.installedVersion,
|
||||
latestVersion: m.version,
|
||||
state: 'idle'
|
||||
};
|
||||
}
|
||||
|
||||
#patchRow(id: ModId, patch: Partial<ModRowStatus>) {
|
||||
this._value = {
|
||||
...this._value,
|
||||
mods: this._value.mods.map(r => (r.id === id ? { ...r, ...patch } : r))
|
||||
};
|
||||
this._value = { ...this._value, dirty: this.#computeDirty() };
|
||||
this._notifyObservers();
|
||||
}
|
||||
|
||||
#computeDirty(): boolean {
|
||||
return this._value.mods.some(r => {
|
||||
const wantInstalled = r.enabled;
|
||||
const isInstalled = !!r.installedVersion;
|
||||
if (wantInstalled !== isInstalled) return true;
|
||||
if (
|
||||
r.installedVersion &&
|
||||
r.installedVersion !== r.latestVersion &&
|
||||
!r.ignoreUpdates
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
load() {
|
||||
this._value = {
|
||||
state: 'verifying',
|
||||
dirty: false,
|
||||
mods: MODS.map(m => this.#initialRow(m))
|
||||
};
|
||||
}
|
||||
|
||||
async verify() {
|
||||
this.load();
|
||||
this._notifyObservers();
|
||||
|
||||
const clientDir = Preferences.data?.clientDir;
|
||||
|
||||
for (const m of MODS) {
|
||||
const state = Preferences.data?.mods?.[m.id];
|
||||
let installedVersion = state?.installedVersion;
|
||||
|
||||
if (clientDir && installedVersion) {
|
||||
const filesPresent = await Promise.all(
|
||||
(state?.installedFiles ?? []).map(rel =>
|
||||
fs.pathExists(path.join(clientDir, rel))
|
||||
)
|
||||
);
|
||||
if (state?.installedFiles?.length && !filesPresent.every(Boolean)) {
|
||||
installedVersion = undefined;
|
||||
await this.#savePref(m.id, {
|
||||
enabled: state?.enabled ?? false,
|
||||
installedVersion: undefined,
|
||||
installedFiles: [],
|
||||
ignoreUpdates: state?.ignoreUpdates ?? false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const latest = await this.#fetchLatestVersion(m).catch(() => m.version);
|
||||
|
||||
this.#patchRow(m.id, {
|
||||
installedVersion,
|
||||
latestVersion: latest,
|
||||
enabled: !!state?.enabled,
|
||||
ignoreUpdates: !!state?.ignoreUpdates
|
||||
});
|
||||
}
|
||||
|
||||
this._value = { ...this._value, state: 'idle', dirty: this.#computeDirty() };
|
||||
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) {
|
||||
const cur = Preferences.data?.mods?.[id];
|
||||
await this.#savePref(id, {
|
||||
enabled,
|
||||
installedVersion: cur?.installedVersion,
|
||||
installedFiles: cur?.installedFiles ?? [],
|
||||
ignoreUpdates: cur?.ignoreUpdates ?? false
|
||||
});
|
||||
this.#patchRow(id, { enabled });
|
||||
}
|
||||
|
||||
async setIgnoreUpdates(id: ModId, ignore: boolean) {
|
||||
const cur = Preferences.data?.mods?.[id];
|
||||
await this.#savePref(id, {
|
||||
enabled: cur?.enabled ?? false,
|
||||
installedVersion: cur?.installedVersion,
|
||||
installedFiles: cur?.installedFiles ?? [],
|
||||
ignoreUpdates: ignore
|
||||
});
|
||||
this.#patchRow(id, { ignoreUpdates: ignore });
|
||||
}
|
||||
|
||||
async applyAll() {
|
||||
const clientDir = Preferences.data?.clientDir;
|
||||
if (!clientDir) {
|
||||
Logger.warn('No clientDir set; cannot apply mods.');
|
||||
return;
|
||||
}
|
||||
this._value = { ...this._value, state: 'busy' };
|
||||
this._notifyObservers();
|
||||
|
||||
const queue = [...this._value.mods];
|
||||
queue.sort((a, b) => {
|
||||
if (a.id === 'vanillaFixes') return -1;
|
||||
if (b.id === 'vanillaFixes') return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
for (const row of queue) {
|
||||
const m = getMod(row.id);
|
||||
if (!m) continue;
|
||||
|
||||
const wantInstalled = row.enabled;
|
||||
const isInstalled = !!row.installedVersion;
|
||||
const updateAvailable =
|
||||
isInstalled &&
|
||||
row.installedVersion !== row.latestVersion &&
|
||||
!row.ignoreUpdates;
|
||||
|
||||
try {
|
||||
if (wantInstalled && !isInstalled) {
|
||||
await this.#install(m);
|
||||
} else if (!wantInstalled && isInstalled) {
|
||||
await this.#uninstall(m);
|
||||
} else if (wantInstalled && updateAvailable) {
|
||||
await this.#uninstall(m);
|
||||
await this.#install(m);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.error(`Failed to apply ${m.id}:`, e);
|
||||
this.#patchRow(m.id, {
|
||||
state: 'error',
|
||||
error: e instanceof Error ? e.message : String(e)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this._value = { ...this._value, state: 'idle' };
|
||||
await this.verify();
|
||||
}
|
||||
|
||||
async #install(m: ModEntry) {
|
||||
const clientDir = Preferences.data?.clientDir;
|
||||
if (!clientDir) throw new Error('No client dir');
|
||||
if (m.source.kind === 'managed') return;
|
||||
|
||||
Logger.info(`Installing mod ${m.id}...`);
|
||||
this.#patchRow(m.id, { state: 'downloading', progress: 0, error: undefined });
|
||||
|
||||
const written: string[] = [];
|
||||
|
||||
if (m.source.kind === 'directFile') {
|
||||
const dest = path.join(clientDir, m.source.assetName);
|
||||
await this.#downloadTo(m.source.url, dest);
|
||||
written.push(m.source.assetName);
|
||||
} else if (m.source.kind === 'archive') {
|
||||
const tmp = path.join(
|
||||
os.tmpdir(),
|
||||
`octolauncher-${m.id}-${Date.now()}.${m.source.format}`
|
||||
);
|
||||
await this.#downloadTo(m.source.url, tmp);
|
||||
this.#patchRow(m.id, { state: 'installing' });
|
||||
|
||||
const map = m.source.extractMap;
|
||||
if (m.source.format === 'zip') {
|
||||
const zip = new AdmZip(tmp);
|
||||
const entries = zip.getEntries();
|
||||
for (const [src, dst] of Object.entries(map)) {
|
||||
const entry = entries.find(e => e.entryName === src);
|
||||
if (!entry) {
|
||||
Logger.warn(`Mod ${m.id}: zip entry ${src} not found.`);
|
||||
continue;
|
||||
}
|
||||
const target = path.join(clientDir, dst);
|
||||
await fs.ensureDir(path.dirname(target));
|
||||
await fs.writeFile(target, entry.getData());
|
||||
written.push(dst);
|
||||
}
|
||||
} else {
|
||||
const stagingDir = path.join(
|
||||
os.tmpdir(),
|
||||
`octolauncher-${m.id}-${Date.now()}-extract`
|
||||
);
|
||||
await fs.ensureDir(stagingDir);
|
||||
await tar.x({ file: tmp, cwd: stagingDir });
|
||||
for (const [src, dst] of Object.entries(map)) {
|
||||
const srcPath = path.join(stagingDir, src);
|
||||
if (!(await fs.pathExists(srcPath))) {
|
||||
Logger.warn(`Mod ${m.id}: tar entry ${src} not found.`);
|
||||
continue;
|
||||
}
|
||||
const target = path.join(clientDir, dst);
|
||||
await fs.ensureDir(path.dirname(target));
|
||||
await fs.copy(srcPath, target);
|
||||
written.push(dst);
|
||||
}
|
||||
await fs.remove(stagingDir).catch(() => {});
|
||||
}
|
||||
await fs.remove(tmp).catch(() => {});
|
||||
}
|
||||
|
||||
if (m.registerInDllsTxt) {
|
||||
await addDll(clientDir, m.registerInDllsTxt);
|
||||
}
|
||||
|
||||
await this.#savePref(m.id, {
|
||||
enabled: true,
|
||||
installedVersion: m.version,
|
||||
installedFiles: written,
|
||||
ignoreUpdates: Preferences.data?.mods?.[m.id]?.ignoreUpdates ?? false
|
||||
});
|
||||
|
||||
this.#patchRow(m.id, {
|
||||
state: 'idle',
|
||||
installedVersion: m.version,
|
||||
progress: 1
|
||||
});
|
||||
}
|
||||
|
||||
async #uninstall(m: ModEntry) {
|
||||
const clientDir = Preferences.data?.clientDir;
|
||||
if (!clientDir) throw new Error('No client dir');
|
||||
if (m.source.kind === 'managed') return;
|
||||
|
||||
Logger.info(`Uninstalling mod ${m.id}...`);
|
||||
this.#patchRow(m.id, { state: 'uninstalling', error: undefined });
|
||||
|
||||
const cur = Preferences.data?.mods?.[m.id];
|
||||
const files = cur?.installedFiles ?? [];
|
||||
|
||||
for (const rel of files) {
|
||||
const fullPath = path.join(clientDir, rel);
|
||||
await fs
|
||||
.remove(fullPath)
|
||||
.catch(err => Logger.warn(`Couldn't remove ${fullPath}:`, err));
|
||||
}
|
||||
|
||||
if (m.registerInDllsTxt) {
|
||||
await removeDll(clientDir, m.registerInDllsTxt);
|
||||
}
|
||||
|
||||
await this.#savePref(m.id, {
|
||||
enabled: cur?.enabled ?? false,
|
||||
installedVersion: undefined,
|
||||
installedFiles: [],
|
||||
ignoreUpdates: cur?.ignoreUpdates ?? false
|
||||
});
|
||||
|
||||
this.#patchRow(m.id, { state: 'idle', installedVersion: undefined });
|
||||
}
|
||||
|
||||
async #downloadTo(url: string, dest: string) {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': 'OctoLauncher' }
|
||||
});
|
||||
if (!res.ok) throw new Error(`Download failed ${res.status}: ${url}`);
|
||||
await fs.ensureDir(path.dirname(dest));
|
||||
const buf = await res.arrayBuffer();
|
||||
await fs.writeFile(dest, Buffer.from(buf));
|
||||
}
|
||||
|
||||
async #savePref(id: ModId, state: ModState) {
|
||||
const allMods = { ...(Preferences.data?.mods ?? {}), [id]: state };
|
||||
Preferences.data = { mods: allMods };
|
||||
}
|
||||
}
|
||||
|
||||
const Mods = new ModsClass();
|
||||
export default Mods;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { observable } from '@trpc/server/observable';
|
||||
|
||||
type Func<T> = (arg: T) => void;
|
||||
|
||||
abstract class Observable<T> {
|
||||
private _listeners: Func<T>[] = [];
|
||||
|
||||
protected abstract _value: T;
|
||||
|
||||
protected _notifyObservers(v = this._value) {
|
||||
this._listeners = this._listeners.filter(l => {
|
||||
try {
|
||||
l(v);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
observe() {
|
||||
return observable<T>(e => {
|
||||
e.next(this._value);
|
||||
this._listeners.push(e.next);
|
||||
return () => {
|
||||
this._listeners = this._listeners.filter(v => v !== e.next);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
clearObservers() {
|
||||
this._listeners = [];
|
||||
}
|
||||
}
|
||||
|
||||
export default Observable;
|
||||
@@ -0,0 +1,229 @@
|
||||
import path from 'path';
|
||||
|
||||
import { screen } from 'electron';
|
||||
import fs from 'fs-extra';
|
||||
import Logger from 'electron-log/main';
|
||||
|
||||
import Preferences from '~main/modules/preferences';
|
||||
import { ConfigWtfSchema, type PreferencesSchema } from '~common/schemas';
|
||||
import { isNotUndef } from '~common/utils';
|
||||
import { fetchFile } from '~main/modules/updater';
|
||||
|
||||
const Servers = {
|
||||
live: {
|
||||
realmList: 'octowow.st',
|
||||
patchList: 'octowow.st',
|
||||
realmName: 'OctoWoW'
|
||||
},
|
||||
ptr: {
|
||||
realmList: 'octowow.st',
|
||||
patchList: 'octowow.st',
|
||||
realmName: 'OctoWoW PTR'
|
||||
}
|
||||
} as const;
|
||||
|
||||
type Tweak = { key: keyof PreferencesSchema['config']; default?: unknown; forced?: boolean } & (
|
||||
| {
|
||||
type: 'bytes';
|
||||
tweaks: [number, number[]][];
|
||||
}
|
||||
| {
|
||||
type: 'int8' | 'uint16' | 'float';
|
||||
offset: number;
|
||||
value?: number;
|
||||
}
|
||||
);
|
||||
|
||||
export const patchExecutable = async () => {
|
||||
Logger.log('Patching WoW.exe...');
|
||||
|
||||
const { clientDir, config } = Preferences.data;
|
||||
if (!clientDir) return;
|
||||
const exePath = path.join(clientDir, 'WoW.exe');
|
||||
|
||||
try {
|
||||
Logger.log('Fetching clean WoW.exe...');
|
||||
const file = await fetchFile('WoW.exe');
|
||||
const buffer = Buffer.from(file);
|
||||
|
||||
const Tweaks = [
|
||||
{
|
||||
key: 'largeAddress',
|
||||
type: 'uint16',
|
||||
offset: 0x126,
|
||||
value: buffer.readUint16LE(0x126) | 0x20,
|
||||
default: false
|
||||
},
|
||||
{ key: 'farClip', type: 'float', offset: 0x40fed8 },
|
||||
{
|
||||
key: 'fieldOfView',
|
||||
type: 'float',
|
||||
offset: 0x4089b4,
|
||||
value: (config.fieldOfView ?? 1) * (Math.PI / 180),
|
||||
default: 90
|
||||
},
|
||||
{ key: 'frillDistance', type: 'float', offset: 0x467958 },
|
||||
{
|
||||
key: 'soundInBackground',
|
||||
type: 'int8',
|
||||
offset: 0x3a4869,
|
||||
value: config.soundInBackground ? 0x27 : 0x14,
|
||||
default: false
|
||||
},
|
||||
{
|
||||
key: 'alwaysAutoLoot',
|
||||
type: 'bytes',
|
||||
tweaks: [
|
||||
[0x0c1ecf, [0x75]],
|
||||
[0x0c2b25, [0x75]]
|
||||
]
|
||||
},
|
||||
{ key: 'nameplateRange', type: 'float', offset: 0x40c448 },
|
||||
{ key: 'cameraDistance', type: 'float', offset: 0x4089a4 },
|
||||
{
|
||||
key: 'crossFactionResurrect' as never,
|
||||
type: 'bytes',
|
||||
default: true,
|
||||
tweaks: [
|
||||
[0x006e5fb8, [0x006e5fb9]],
|
||||
[0x006e62a8, [0x006e62a9]]
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'skillUiGateHijack' as never,
|
||||
type: 'bytes',
|
||||
default: true,
|
||||
forced: true,
|
||||
tweaks: [
|
||||
[
|
||||
0x002ddf90,
|
||||
[
|
||||
0x55, 0x8b, 0xec, 0x83, 0xec, 0x08, 0x53, 0x56,
|
||||
0x57, 0x8b, 0x3d, 0x60, 0xab, 0xce, 0x00, 0x83,
|
||||
0xff, 0xff, 0x89, 0x55, 0xfc, 0x89, 0x4d, 0xf8,
|
||||
0x74, 0x79, 0x8b, 0x75, 0x08, 0x8b, 0x15, 0x58,
|
||||
0xab, 0xce, 0x00, 0x8b, 0xc7, 0x23, 0xc6, 0x8d,
|
||||
0x04, 0x40, 0x8b, 0x4c, 0x82, 0x08, 0xf6, 0xc1,
|
||||
0x01, 0x8d, 0x44, 0x82, 0x04, 0x75, 0x04, 0x85,
|
||||
0xc9, 0x75, 0x05, 0x33, 0xc9, 0x8d, 0x49, 0x00,
|
||||
0xf6, 0xc1, 0x01, 0x75, 0x4e, 0x85, 0xc9, 0x74,
|
||||
0x4a, 0x39, 0x31, 0x74, 0x13, 0x8b, 0xc7, 0x23,
|
||||
0xc6, 0x8d, 0x04, 0x40, 0x8d, 0x04, 0x82, 0x8b,
|
||||
0x00, 0x03, 0xc1, 0x8b, 0x48, 0x04, 0xeb, 0xe0,
|
||||
0x8b, 0x59, 0x1c, 0x8b, 0x71, 0x18, 0x33, 0xff,
|
||||
0x85, 0xdb, 0x7e, 0x27, 0x8d, 0x64, 0x24, 0x00,
|
||||
0x8b, 0x4e, 0x0c, 0x8b, 0x56, 0x08, 0x6a, 0x00,
|
||||
0x6a, 0x00, 0x51, 0x8b, 0x4d, 0xf8, 0x52, 0x8b,
|
||||
0x55, 0xfc, 0xe8, 0xb9, 0xfd, 0xff, 0xff, 0x84,
|
||||
0xc0, 0x75, 0x13, 0x47, 0x83, 0xc6, 0x20, 0x3b,
|
||||
0xfb, 0x7c, 0xdd, 0x5f, 0x5e, 0x33, 0xc0, 0x5b,
|
||||
0x8b, 0xe5, 0x5d, 0xc2, 0x04, 0x00, 0x5f, 0x8b,
|
||||
0xc6, 0x5e, 0x5b, 0x8b, 0xe5, 0x5d, 0xc2, 0x04,
|
||||
0x00, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
] satisfies Tweak[];
|
||||
|
||||
// Apply patches
|
||||
Tweaks.forEach(t => {
|
||||
const val =
|
||||
config[t.key] ?? t.default ?? ConfigWtfSchema.parse({})[t.key];
|
||||
|
||||
Logger.log(`Applying "${t.key}" patch with value: ${val}`);
|
||||
if (t.type === 'float') {
|
||||
buffer.writeFloatLE(t.value ?? (val as never), t.offset);
|
||||
} else if (t.type === 'int8') {
|
||||
buffer.writeInt8(t.value ?? (val as never), t.offset);
|
||||
} else if (t.type === 'uint16') {
|
||||
if (!t.forced && !val) return;
|
||||
buffer.writeUInt16LE(t.value ?? (val as never), t.offset);
|
||||
} else if (t.type === 'bytes') {
|
||||
if (!t.forced && !val) return;
|
||||
t.tweaks.forEach(([offset, bytes]) =>
|
||||
Buffer.from(bytes).copy(buffer, offset)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await fs.writeFile(exePath, buffer);
|
||||
Logger.log('WoW.exe successfully patched');
|
||||
} catch (e) {
|
||||
Logger.error('Failed to patch WoW.exe', e);
|
||||
}
|
||||
};
|
||||
|
||||
export const patchConfig = async () => {
|
||||
const { clientDir, server, config } = Preferences.data;
|
||||
if (!clientDir) return;
|
||||
|
||||
const configPath = path.join(clientDir, 'WTF', 'Config.wtf');
|
||||
await fs.ensureDir(path.dirname(configPath));
|
||||
const raw = (await fs.pathExists(configPath))
|
||||
? await fs.readFile(configPath, { encoding: 'utf-8' })
|
||||
: '';
|
||||
if (raw) await fs.remove(configPath);
|
||||
|
||||
const configWtf = Object.fromEntries(
|
||||
raw
|
||||
.split('\n')
|
||||
.map(l => {
|
||||
const [_, k, v] = l.match(/SET (\w+) "(.+)"/) ?? [];
|
||||
return !k || !v ? undefined : [k, v];
|
||||
})
|
||||
.filter(isNotUndef)
|
||||
);
|
||||
|
||||
const primaryDisplay = screen.getPrimaryDisplay();
|
||||
const { width, height } = primaryDisplay.bounds;
|
||||
|
||||
const parsed = {
|
||||
scriptMemory: 512000,
|
||||
gxResolution: `${width}x${height}`,
|
||||
gxColorBits: primaryDisplay.colorDepth,
|
||||
gxDepthBits: primaryDisplay.colorDepth,
|
||||
gxRefresh: 60,
|
||||
gxMultisample: 8,
|
||||
gxMultisampleQuality: 0,
|
||||
gxTripleBuffer: 1,
|
||||
anisotropic: 16,
|
||||
frillDensity: 48,
|
||||
fullAlpha: 1,
|
||||
SmallCull: 0.01,
|
||||
DistCull: 888.8,
|
||||
shadowLevel: 0,
|
||||
trilinear: 1,
|
||||
specular: 1,
|
||||
pixelShaders: 1,
|
||||
M2UsePixelShaders: 1,
|
||||
particleDensity: 1,
|
||||
unitDrawDist: 300,
|
||||
weatherDensity: 3,
|
||||
movieSubtitle: 1,
|
||||
minimapZoom: 0,
|
||||
minimapInsideZoom: 0,
|
||||
SoundZoneMusicNoDelay: 1,
|
||||
patchList: configWtf['patchList'] ?? Servers[server].patchList,
|
||||
realmName: configWtf['realmName'] ?? Servers[server].realmName,
|
||||
gxWindow: configWtf['gxWindow'] ?? 1,
|
||||
gxMaximize: configWtf['gxMaximize'] ?? 1,
|
||||
gxCursor: configWtf['gxCursor'] ?? 1,
|
||||
checkAddonVersion: configWtf['checkAddonVersion'] ?? 0,
|
||||
...configWtf,
|
||||
CameraDistanceMax: config.cameraDistance,
|
||||
farClip: config.farClip,
|
||||
realmList: Servers[server].realmList,
|
||||
hwDetect: 0,
|
||||
M2UseShaders: 1
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
Object.entries(parsed)
|
||||
.filter(v => v[1] !== undefined && v[1] !== null)
|
||||
.map(l => `SET ${l[0]} "${l[1]}"`)
|
||||
.join('\n')
|
||||
);
|
||||
Logger.log('Config.wtf successfully patched');
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import path from 'path';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { type z } from 'zod';
|
||||
import { app } from 'electron';
|
||||
|
||||
import { PreferencesSchema } from '~common/schemas';
|
||||
import { omit } from '~common/utils';
|
||||
|
||||
const portableDir = process.env.PORTABLE_EXECUTABLE_DIR;
|
||||
|
||||
abstract class Preferences {
|
||||
static #data: z.infer<typeof PreferencesSchema>;
|
||||
|
||||
static readonly userDataDir = process.env.PORTABLE_EXECUTABLE_DIR
|
||||
? path.join(process.env.PORTABLE_EXECUTABLE_DIR, '.launcher')
|
||||
: app.getPath('userData');
|
||||
|
||||
static async load() {
|
||||
await fs.ensureDir(this.userDataDir);
|
||||
|
||||
const userDataPath = path.join(this.userDataDir, 'settings.json');
|
||||
try {
|
||||
const json = await fs.readJSON(userDataPath);
|
||||
return PreferencesSchema.parse({
|
||||
...json,
|
||||
isPortable: !!portableDir,
|
||||
clientDir: portableDir ?? json.clientDir
|
||||
});
|
||||
} catch (e) {
|
||||
return PreferencesSchema.parse({
|
||||
isPortable: !!portableDir,
|
||||
clientDir: portableDir
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static get data(): PreferencesSchema {
|
||||
return this.#data;
|
||||
}
|
||||
|
||||
static set data(newData: Partial<Omit<PreferencesSchema, 'portableDir'>>) {
|
||||
this.#data = { ...this.#data, ...newData };
|
||||
fs.writeJSON(
|
||||
path.join(this.userDataDir, 'settings.json'),
|
||||
omit(
|
||||
this.#data,
|
||||
portableDir ? ['isPortable', 'clientDir'] : ['isPortable']
|
||||
),
|
||||
{ spaces: 2 }
|
||||
);
|
||||
}
|
||||
|
||||
static async isValidClientDir(clientDir?: string) {
|
||||
return !!clientDir && (await fs.exists(path.join(clientDir, 'WoW.exe')));
|
||||
}
|
||||
}
|
||||
|
||||
export default Preferences;
|
||||
@@ -0,0 +1,115 @@
|
||||
import { app } from 'electron';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import Logger from 'electron-log/main';
|
||||
import { is } from '@electron-toolkit/utils';
|
||||
|
||||
import Observable from './observable';
|
||||
|
||||
export type SelfUpdaterStatus =
|
||||
| { state: 'idle'; currentVersion: string }
|
||||
| { state: 'checking'; currentVersion: string }
|
||||
| { state: 'unavailable'; currentVersion: string }
|
||||
| { state: 'available'; currentVersion: string; nextVersion: string }
|
||||
| { state: 'downloading'; currentVersion: string; nextVersion: string; progress: number }
|
||||
| { state: 'ready'; currentVersion: string; nextVersion: string }
|
||||
| { state: 'error'; currentVersion: string; message: string };
|
||||
|
||||
class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
|
||||
protected _value: SelfUpdaterStatus = {
|
||||
state: 'idle',
|
||||
currentVersion: app.getVersion()
|
||||
};
|
||||
|
||||
#initialized = false;
|
||||
#nextVersion: string | undefined;
|
||||
|
||||
get status(): SelfUpdaterStatus {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
private set status(v: SelfUpdaterStatus) {
|
||||
this._value = v;
|
||||
this._notifyObservers();
|
||||
}
|
||||
|
||||
init() {
|
||||
if (this.#initialized) return;
|
||||
this.#initialized = true;
|
||||
|
||||
if (is.dev) {
|
||||
Logger.info('[selfUpdater] dev mode — skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentVersion = app.getVersion();
|
||||
|
||||
autoUpdater.logger = Logger;
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoInstallOnAppQuit = false;
|
||||
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
Logger.info('[selfUpdater] checking');
|
||||
this.status = { state: 'checking', currentVersion };
|
||||
});
|
||||
autoUpdater.on('update-available', info => {
|
||||
Logger.info(`[selfUpdater] update available: ${info.version}`);
|
||||
this.#nextVersion = info.version;
|
||||
this.status = {
|
||||
state: 'available',
|
||||
currentVersion,
|
||||
nextVersion: info.version
|
||||
};
|
||||
});
|
||||
autoUpdater.on('update-not-available', info => {
|
||||
Logger.info(`[selfUpdater] up to date (current: ${info.version})`);
|
||||
this.status = { state: 'unavailable', currentVersion };
|
||||
});
|
||||
autoUpdater.on('error', err => {
|
||||
Logger.error('[selfUpdater] error', err);
|
||||
this.status = {
|
||||
state: 'error',
|
||||
currentVersion,
|
||||
message: err?.message ?? String(err)
|
||||
};
|
||||
});
|
||||
autoUpdater.on('download-progress', p => {
|
||||
Logger.info(`[selfUpdater] downloading ${Math.round(p.percent)}%`);
|
||||
this.status = {
|
||||
state: 'downloading',
|
||||
currentVersion,
|
||||
nextVersion: this.#nextVersion ?? '',
|
||||
progress: Math.max(0, Math.min(1, p.percent / 100))
|
||||
};
|
||||
});
|
||||
autoUpdater.on('update-downloaded', info => {
|
||||
Logger.info(
|
||||
`[selfUpdater] downloaded ${info.version} — awaiting user click`
|
||||
);
|
||||
this.status = {
|
||||
state: 'ready',
|
||||
currentVersion,
|
||||
nextVersion: info.version
|
||||
};
|
||||
});
|
||||
|
||||
autoUpdater.checkForUpdates().catch(err => {
|
||||
Logger.error('[selfUpdater] checkForUpdates failed', err);
|
||||
});
|
||||
}
|
||||
|
||||
triggerInstall() {
|
||||
if (this._value.state !== 'ready') {
|
||||
Logger.warn(
|
||||
`[selfUpdater] triggerInstall called in state ${this._value.state} — ignoring`
|
||||
);
|
||||
return;
|
||||
}
|
||||
Logger.info('[selfUpdater] user clicked install — quitting + running installer');
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
}
|
||||
}
|
||||
|
||||
const SelfUpdater = new SelfUpdaterClass();
|
||||
export default SelfUpdater;
|
||||
|
||||
export const initSelfUpdater = () => SelfUpdater.init();
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Tray, Menu, nativeImage, app } from 'electron';
|
||||
import Logger from 'electron-log/main';
|
||||
|
||||
import icon from '~build/icon.png?asset';
|
||||
|
||||
import { mainWindow } from '~main/index';
|
||||
|
||||
let tray: Tray | null = null;
|
||||
let isMinimizedToTray = false;
|
||||
|
||||
const restoreWindow = () => {
|
||||
if (!mainWindow) return;
|
||||
mainWindow.show();
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.focus();
|
||||
isMinimizedToTray = false;
|
||||
};
|
||||
|
||||
const ensureTray = () => {
|
||||
if (tray) return tray;
|
||||
const trayIcon = nativeImage.createFromPath(icon).resize({ width: 16, height: 16 });
|
||||
tray = new Tray(trayIcon);
|
||||
tray.setToolTip('OctoLauncher');
|
||||
tray.setContextMenu(
|
||||
Menu.buildFromTemplate([
|
||||
{ label: 'Show launcher', click: restoreWindow },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Quit', click: () => app.quit() }
|
||||
])
|
||||
);
|
||||
tray.on('click', restoreWindow);
|
||||
return tray;
|
||||
};
|
||||
|
||||
export const minimizeToTray = () => {
|
||||
if (!mainWindow) return;
|
||||
ensureTray();
|
||||
mainWindow.hide();
|
||||
isMinimizedToTray = true;
|
||||
Logger.info('Minimized to tray');
|
||||
};
|
||||
|
||||
export const restoreFromTray = () => {
|
||||
if (!isMinimizedToTray) return;
|
||||
restoreWindow();
|
||||
};
|
||||
|
||||
export const isInTray = () => isMinimizedToTray;
|
||||
|
||||
export const destroyTray = () => {
|
||||
tray?.destroy();
|
||||
tray = null;
|
||||
};
|
||||
@@ -0,0 +1,973 @@
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { exec } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
|
||||
import { app } from 'electron';
|
||||
import fetch from 'node-fetch';
|
||||
import fs from 'fs-extra';
|
||||
import {
|
||||
SFileOpenArchive,
|
||||
type HANDLE,
|
||||
SFileHasFile,
|
||||
SFileCloseArchive,
|
||||
SFileOpenFileEx,
|
||||
SFileReadFile,
|
||||
SFileGetFileSize,
|
||||
SFileCloseFile,
|
||||
SFileCreateFile,
|
||||
SFileWriteFile,
|
||||
SFileFinishFile,
|
||||
SFileFlushArchive,
|
||||
SFileRemoveFile,
|
||||
SFileCompactArchive
|
||||
} from 'stormlib-node';
|
||||
import {
|
||||
MPQ_COMPRESSION,
|
||||
MPQ_FILE,
|
||||
STREAM_FLAG
|
||||
} from 'stormlib-node/dist/enums';
|
||||
import Logger from 'electron-log/main';
|
||||
|
||||
import {
|
||||
asyncMap,
|
||||
formatFileSize,
|
||||
isNotUndef,
|
||||
nestedGet,
|
||||
nestedSet
|
||||
} from '~common/utils';
|
||||
import { mainWindow } from '~main/index';
|
||||
import { patchExecutable } from '~main/modules/patcher';
|
||||
import { getClientVersion } from '~main/utils';
|
||||
|
||||
import Preferences from './preferences';
|
||||
import Observable from './observable';
|
||||
|
||||
const getAvailableDiskSpace = async (probePath?: string): Promise<number> => {
|
||||
const target =
|
||||
probePath ||
|
||||
Preferences.data?.clientDir ||
|
||||
os.homedir() ||
|
||||
(os.platform() === 'win32' ? 'C:\\' : '/');
|
||||
try {
|
||||
const s = await fs.promises.statfs(target);
|
||||
return Number(s.bsize) * Number(s.bavail);
|
||||
} catch (e) {
|
||||
Logger.warn(
|
||||
`fs.statfs("${target}") failed; treating disk-space check as ` +
|
||||
`unavailable. Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
);
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
};
|
||||
|
||||
const isReadOnly = async (filePath: string) => {
|
||||
try {
|
||||
const { mode } = await fs.stat(filePath);
|
||||
return !(mode & fs.constants.S_IWUSR);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
type FolderTags = 'allowExtra';
|
||||
type FileTags = 'vanillaFixes';
|
||||
type FileManifest = { name: string } & (
|
||||
| { type: 'del' }
|
||||
| { type: 'dir'; files: FileManifest[]; tags?: FolderTags[] }
|
||||
| { type: 'mpq'; files: FileManifest[]; hash: string; size: number }
|
||||
| {
|
||||
type: 'file';
|
||||
hash: string;
|
||||
version?: number;
|
||||
size: number;
|
||||
tags?: FileTags[];
|
||||
}
|
||||
);
|
||||
|
||||
type CacheEntry = [hash: string, mtime: number];
|
||||
type CacheTree = { [key: string]: CacheTree & CacheEntry };
|
||||
|
||||
const getManifestSize = (m?: FileManifest): number =>
|
||||
(m?.type === 'del'
|
||||
? 0
|
||||
: m?.type === 'file'
|
||||
? m?.size
|
||||
: m?.files?.reduce((acc, v) => acc + getManifestSize(v), 0)) ?? 0;
|
||||
|
||||
const getManifestFiles = (m?: FileManifest, p = ''): string[] =>
|
||||
(m?.type === 'del'
|
||||
? [`-- ${path.join(p, m?.name)}`]
|
||||
: m?.type === 'file'
|
||||
? [`++ ${path.join(p, m?.name)}`]
|
||||
: m?.files?.flatMap(v => getManifestFiles(v, path.join(p, m?.name)))) ?? [];
|
||||
|
||||
const getManifestItem = (
|
||||
m?: FileManifest,
|
||||
p?: string[]
|
||||
): FileManifest | undefined => {
|
||||
if (!p?.length) return m;
|
||||
|
||||
if (m?.type === 'file' || m?.type === 'del')
|
||||
throw Error(`Can't access ${p.join('.')} from file ${m.name}`);
|
||||
|
||||
const [next, ...rest] = p;
|
||||
return getManifestItem(
|
||||
m?.files.find(f => f.name === next),
|
||||
rest
|
||||
);
|
||||
};
|
||||
|
||||
export const isGameRunning = (executablePath: string) =>
|
||||
os.platform() === 'win32'
|
||||
? new Promise<boolean>(resolve => {
|
||||
const exeName = path.basename(executablePath);
|
||||
exec(
|
||||
`tasklist /FI "IMAGENAME eq ${exeName}" /FO CSV /NH`,
|
||||
(error, stdout) => {
|
||||
if (error) {
|
||||
Logger.warn(
|
||||
`tasklist probe for "${exeName}" failed; assuming game ` +
|
||||
`is not running. Error: ${error.message}`
|
||||
);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
resolve(
|
||||
stdout.toLowerCase().includes(`"${exeName.toLowerCase()}"`)
|
||||
);
|
||||
}
|
||||
);
|
||||
})
|
||||
: false;
|
||||
|
||||
const toUrlPath = (p: string) => p.split(path.sep).map(encodeURIComponent).join('/');
|
||||
|
||||
const CDN_VERSION = import.meta.env.MAIN_VITE_CLIENT_VERSION || 'latest';
|
||||
|
||||
const fetchManifest = async () => {
|
||||
try {
|
||||
const r = await fetch(
|
||||
`${import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'}/api/file/${CDN_VERSION}/manifest.json`
|
||||
);
|
||||
const j = await r.json();
|
||||
await fs.writeJSON(path.join(Preferences.userDataDir, 'manifest.json'), j);
|
||||
return j.root as FileManifest;
|
||||
} catch (e) {
|
||||
Logger.error('Failed to reach update server', e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const buildClientUrl = (filePath: string) =>
|
||||
`${import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'}/client/${CDN_VERSION}/${toUrlPath(
|
||||
path.normalize(filePath)
|
||||
)}`;
|
||||
|
||||
export const fetchFile = async (
|
||||
filePath: string,
|
||||
onChunk?: (deltaBytes: number) => void
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch(buildClientUrl(filePath));
|
||||
if (!response.ok) throw Error(`HTTP ${response.status}`);
|
||||
if (!onChunk || !response.body) return await response.arrayBuffer();
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of response.body as NodeJS.ReadableStream) {
|
||||
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
|
||||
chunks.push(buf);
|
||||
onChunk(buf.byteLength);
|
||||
}
|
||||
const total = chunks.reduce((acc, c) => acc + c.byteLength, 0);
|
||||
const out = Buffer.concat(chunks, total);
|
||||
return out.buffer.slice(out.byteOffset, out.byteOffset + out.byteLength);
|
||||
} catch (e) {
|
||||
Logger.error(`Failed to download ${path.normalize(filePath)}`, e);
|
||||
throw Error(`Failed to download ${path.normalize(filePath)}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadFileToDisk = async (
|
||||
filePath: string,
|
||||
fullPath: string,
|
||||
expectedSize: number,
|
||||
onChunk: (deltaBytes: number) => void
|
||||
) => {
|
||||
const partPath = `${fullPath}.part`;
|
||||
await fs.ensureFile(partPath);
|
||||
let resumeFrom = 0;
|
||||
try {
|
||||
const stats = await fs.stat(partPath);
|
||||
if (stats.size > 0 && stats.size < expectedSize) resumeFrom = stats.size;
|
||||
else if (stats.size >= expectedSize) {
|
||||
await fs.truncate(partPath, 0);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
if (resumeFrom > 0) onChunk(resumeFrom);
|
||||
|
||||
const url = buildClientUrl(filePath);
|
||||
const headers: Record<string, string> = {};
|
||||
if (resumeFrom > 0) headers.Range = `bytes=${resumeFrom}-`;
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, { headers });
|
||||
} catch (e) {
|
||||
Logger.error(`Network error downloading ${filePath}`, e);
|
||||
throw Error(`Failed to download ${path.normalize(filePath)}`);
|
||||
}
|
||||
|
||||
if (!response.ok && response.status !== 206) {
|
||||
throw Error(`Failed to download ${path.normalize(filePath)}: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
// If we got 200, the server gave us the whole file
|
||||
// roll back and truncate
|
||||
if (resumeFrom > 0 && response.status === 200) {
|
||||
onChunk(-resumeFrom);
|
||||
await fs.truncate(partPath, 0);
|
||||
resumeFrom = 0;
|
||||
}
|
||||
|
||||
const writeStream = fs.createWriteStream(partPath, {
|
||||
flags: resumeFrom > 0 ? 'a' : 'w'
|
||||
});
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (!response.body) {
|
||||
reject(Error('No response body'));
|
||||
return;
|
||||
}
|
||||
const body = response.body as NodeJS.ReadableStream;
|
||||
body.on('data', (chunk: Buffer | Uint8Array) => {
|
||||
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
if (!writeStream.write(buf)) body.pause();
|
||||
onChunk(buf.byteLength);
|
||||
});
|
||||
writeStream.on('drain', () => body.resume());
|
||||
body.on('end', () => writeStream.end(resolve));
|
||||
body.on('error', reject);
|
||||
writeStream.on('error', reject);
|
||||
});
|
||||
} catch (e) {
|
||||
writeStream.destroy();
|
||||
Logger.error(`Download interrupted for ${filePath}`, e);
|
||||
throw Error(`Failed to download ${path.normalize(filePath)}`);
|
||||
}
|
||||
|
||||
const finalStats = await fs.stat(partPath);
|
||||
if (finalStats.size !== expectedSize) {
|
||||
throw Error(
|
||||
`Size mismatch for ${path.normalize(filePath)}: got ${finalStats.size}, expected ${expectedSize}. Will retry on next run.`
|
||||
);
|
||||
}
|
||||
|
||||
await fs.move(partPath, fullPath, { overwrite: true });
|
||||
};
|
||||
|
||||
type UpdaterState =
|
||||
| 'verifying'
|
||||
| 'serverUnreachable'
|
||||
| 'noClient'
|
||||
| 'updateAvailable'
|
||||
| 'updating'
|
||||
| 'upToDate'
|
||||
| 'failed';
|
||||
|
||||
export type UpdaterStatus = {
|
||||
state: UpdaterState;
|
||||
progress?: number;
|
||||
message?: string;
|
||||
bytesDone?: number;
|
||||
bytesTotal?: number;
|
||||
bytesPerSecond?: number;
|
||||
etaSeconds?: number;
|
||||
};
|
||||
|
||||
const RATE_WINDOW_MS = 5_000;
|
||||
const ETA_WARMUP_MS = 10_000;
|
||||
const ETA_PADDING = 1.15;
|
||||
|
||||
class ProgressTracker {
|
||||
#startedAt = Date.now();
|
||||
#samples: { t: number; bytesDone: number }[] = [];
|
||||
bytesDone: number;
|
||||
#baseline: number;
|
||||
|
||||
constructor(baseline = 0) {
|
||||
this.bytesDone = baseline;
|
||||
this.#baseline = baseline;
|
||||
}
|
||||
|
||||
add(delta: number) {
|
||||
this.bytesDone = Math.max(this.#baseline, this.bytesDone + delta);
|
||||
const now = Date.now();
|
||||
this.#samples.push({ t: now, bytesDone: this.bytesDone });
|
||||
const cutoff = now - RATE_WINDOW_MS;
|
||||
while (this.#samples.length > 2 && this.#samples[0].t < cutoff)
|
||||
this.#samples.shift();
|
||||
}
|
||||
|
||||
bytesPerSecond() {
|
||||
if (this.#samples.length < 2) return 0;
|
||||
const first = this.#samples[0];
|
||||
const last = this.#samples[this.#samples.length - 1];
|
||||
const dt = (last.t - first.t) / 1000;
|
||||
if (dt <= 0) return 0;
|
||||
return Math.max(0, (last.bytesDone - first.bytesDone) / dt);
|
||||
}
|
||||
|
||||
etaSeconds(bytesTotal: number) {
|
||||
if (Date.now() - this.#startedAt < ETA_WARMUP_MS) return undefined;
|
||||
const rate = this.bytesPerSecond();
|
||||
if (rate <= 0) return undefined;
|
||||
const remaining = bytesTotal - this.bytesDone;
|
||||
if (remaining <= 0) return 0;
|
||||
return (remaining / rate) * ETA_PADDING;
|
||||
}
|
||||
}
|
||||
|
||||
class UpdaterClass extends Observable<UpdaterStatus> {
|
||||
#manifest?: FileManifest;
|
||||
#clientTotalBytes = 0;
|
||||
#bytesAlreadyOnDisk = 0;
|
||||
#cachePath = path.join(Preferences.userDataDir, 'cache.json');
|
||||
#cache: CacheTree = fs.existsSync(this.#cachePath)
|
||||
? fs.readJSONSync(this.#cachePath)
|
||||
: {};
|
||||
|
||||
async #saveCache() {
|
||||
await fs.writeJSON(this.#cachePath, this.#cache);
|
||||
}
|
||||
|
||||
async #getHash(
|
||||
{
|
||||
clientPath,
|
||||
...m
|
||||
}: { clientPath: string } & (
|
||||
| { hMpq: HANDLE; mpqPath: string[] }
|
||||
| { hMpq?: never }
|
||||
),
|
||||
...filePath: string[]
|
||||
) {
|
||||
if (m.hMpq) {
|
||||
if (!SFileHasFile(m.hMpq, path.join(...filePath))) {
|
||||
nestedSet(this.#cache, filePath, undefined);
|
||||
return undefined;
|
||||
}
|
||||
const c = nestedGet<CacheEntry>(this.#cache, [...m.mpqPath, ...filePath]);
|
||||
|
||||
if (c?.[0]) return c[0];
|
||||
|
||||
const hFile = SFileOpenFileEx(m.hMpq, path.join(...filePath), 0);
|
||||
|
||||
try {
|
||||
const fileSize = Number(SFileGetFileSize(hFile).toString());
|
||||
|
||||
const buffer = new ArrayBuffer(fileSize);
|
||||
if (fileSize > 0) SFileReadFile(hFile, buffer);
|
||||
|
||||
const newHash = crypto
|
||||
.createHash('sha1')
|
||||
.update(new Uint8Array(buffer))
|
||||
.digest('hex')
|
||||
.toLocaleUpperCase();
|
||||
|
||||
nestedSet(this.#cache, [...m.mpqPath, ...filePath], { [0]: newHash });
|
||||
return newHash;
|
||||
} finally {
|
||||
SFileCloseFile(hFile);
|
||||
}
|
||||
}
|
||||
|
||||
if (!(await fs.exists(path.join(clientPath, ...filePath)))) {
|
||||
nestedSet(this.#cache, filePath, undefined);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const stats = await fs.stat(path.join(clientPath, ...filePath));
|
||||
if (stats.isDirectory())
|
||||
throw Error(`Tried to get hash of directory ${path.join(...filePath)}`);
|
||||
|
||||
const c = nestedGet<CacheEntry>(this.#cache, filePath);
|
||||
|
||||
if (c?.[0] && c[1] === stats.mtimeMs) return c[0];
|
||||
|
||||
const newHash = crypto
|
||||
.createHash('sha1')
|
||||
.update(await fs.readFile(path.join(clientPath, ...filePath)))
|
||||
.digest('hex')
|
||||
.toLocaleUpperCase();
|
||||
nestedSet(this.#cache, filePath, {
|
||||
...c,
|
||||
[0]: newHash,
|
||||
[1]: stats.mtimeMs
|
||||
});
|
||||
return newHash;
|
||||
}
|
||||
|
||||
protected _value: UpdaterStatus = { state: 'failed' };
|
||||
|
||||
get status() {
|
||||
return this._value;
|
||||
}
|
||||
private set status(v: UpdaterStatus) {
|
||||
this._value = v;
|
||||
this._notifyObservers(v);
|
||||
if (this.status.state === 'failed') {
|
||||
mainWindow?.setProgressBar(1, { mode: 'error' });
|
||||
} else if (this.status.progress === 1) {
|
||||
mainWindow?.setProgressBar(0);
|
||||
} else {
|
||||
mainWindow?.setProgressBar(this.status.progress ?? 0, {
|
||||
mode: this.status.progress === -1 ? 'indeterminate' : 'normal'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async verify() {
|
||||
if (this.status?.state === 'verifying' || this.status?.state === 'updating')
|
||||
return;
|
||||
|
||||
const clientPath = Preferences.data.clientDir;
|
||||
if (!clientPath) {
|
||||
this.status = { state: 'noClient' };
|
||||
return;
|
||||
}
|
||||
|
||||
if (os.platform() === 'win32' && clientPath.length > 220) {
|
||||
this.status = {
|
||||
state: 'failed',
|
||||
message:
|
||||
'Path to current install location is too long and may cause issues.'
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (await isGameRunning(path.join(clientPath, 'WoW.exe'))) {
|
||||
this.status = {
|
||||
state: 'failed',
|
||||
message: 'Please close WoW first, before updating.'
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.log(`Verifying client files at ${path.join(clientPath)}...`);
|
||||
this.status = {
|
||||
state: 'verifying',
|
||||
progress: -1,
|
||||
message: 'Looking for updates...'
|
||||
};
|
||||
|
||||
try {
|
||||
const vanillaFixes = Preferences.data.config.vanillaFixes;
|
||||
|
||||
const hashTree = await fetchManifest();
|
||||
if (!hashTree) {
|
||||
this.status = { state: 'serverUnreachable' };
|
||||
return;
|
||||
}
|
||||
this.#manifest = { type: 'dir', name: 'root', files: [] };
|
||||
|
||||
const totalSize = getManifestSize(hashTree);
|
||||
let i = 0;
|
||||
|
||||
const buildMpqTree = async (
|
||||
hMpq: HANDLE,
|
||||
mpqPath: string[],
|
||||
...filePath: string[]
|
||||
): Promise<FileManifest | undefined> => {
|
||||
const item = getManifestItem(hashTree, [...mpqPath, ...filePath]);
|
||||
if (!item) return undefined;
|
||||
|
||||
if (item.type === 'del') return item;
|
||||
|
||||
if (item.type === 'dir') {
|
||||
const files = (
|
||||
await asyncMap(item.files, f =>
|
||||
buildMpqTree(hMpq, mpqPath, ...filePath, f.name)
|
||||
)
|
||||
).filter(isNotUndef);
|
||||
return !files.length ? undefined : { ...item, files };
|
||||
}
|
||||
|
||||
if (item.type === 'mpq')
|
||||
throw Error(
|
||||
`There can't be an mpq archive inside mpq at path ${path.join(
|
||||
...mpqPath,
|
||||
...filePath
|
||||
)}`
|
||||
);
|
||||
|
||||
this.status = {
|
||||
state: 'verifying',
|
||||
progress: i / totalSize,
|
||||
message: `Verifying: [${mpqPath.at(-1)}] "${path.join(
|
||||
...filePath
|
||||
)}"...`
|
||||
};
|
||||
|
||||
i += item.size;
|
||||
|
||||
if (
|
||||
(await this.#getHash({ clientPath, hMpq, mpqPath }, ...filePath)) ===
|
||||
item.hash
|
||||
)
|
||||
return undefined;
|
||||
return item;
|
||||
};
|
||||
|
||||
const buildTree = async (
|
||||
...filePath: string[]
|
||||
): Promise<FileManifest | undefined> => {
|
||||
const item = getManifestItem(hashTree, filePath);
|
||||
if (!item) return undefined;
|
||||
|
||||
if (item.type === 'del') return item;
|
||||
|
||||
if (item.type === 'dir') {
|
||||
const files = (
|
||||
await asyncMap(item.files, f => buildTree(...filePath, f.name))
|
||||
).filter(isNotUndef);
|
||||
|
||||
return !files.length ? undefined : { ...item, files };
|
||||
}
|
||||
|
||||
if (item.type === 'mpq') {
|
||||
const patchPath = [
|
||||
...filePath.slice(0, -1),
|
||||
`${filePath.at(-1)}.mpq`
|
||||
];
|
||||
this.status = {
|
||||
state: 'verifying',
|
||||
progress: i / totalSize,
|
||||
message: `Verifying: "${path.join(...patchPath)}"...`
|
||||
};
|
||||
|
||||
if (!(await fs.exists(path.join(clientPath, ...patchPath)))) {
|
||||
i += item.size;
|
||||
return {
|
||||
type: 'file',
|
||||
name: `${item.name}.mpq`,
|
||||
hash: item.hash,
|
||||
size: item.size
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
(await this.#getHash({ clientPath }, ...patchPath)) === item.hash
|
||||
) {
|
||||
i += item.size;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const hMpq = SFileOpenArchive(
|
||||
path.join(clientPath, ...patchPath),
|
||||
STREAM_FLAG.READ_ONLY
|
||||
);
|
||||
|
||||
try {
|
||||
const files = (
|
||||
await asyncMap(item.files, f =>
|
||||
buildMpqTree(hMpq, filePath, f.name)
|
||||
)
|
||||
).filter(isNotUndef);
|
||||
return !files.length ? undefined : { ...item, files };
|
||||
} finally {
|
||||
SFileCloseArchive(hMpq);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.log(
|
||||
`Failed to verify ${path.join(
|
||||
...patchPath
|
||||
)}, will be downloaded fresh`,
|
||||
'warning',
|
||||
e
|
||||
);
|
||||
return {
|
||||
type: 'file',
|
||||
name: `${item.name}.mpq`,
|
||||
hash: item.hash,
|
||||
size: item.size
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (item.tags?.includes('vanillaFixes') && !vanillaFixes) {
|
||||
if (await fs.exists(path.join(clientPath, ...filePath))) {
|
||||
return {
|
||||
type: 'del',
|
||||
name: item.name
|
||||
};
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
this.status = {
|
||||
state: 'verifying',
|
||||
progress: i / totalSize,
|
||||
message: `Verifying: "${path.join(...filePath)}"...`
|
||||
};
|
||||
|
||||
i += item.size;
|
||||
|
||||
const hash = await this.#getHash({ clientPath }, ...filePath);
|
||||
|
||||
if (hash === item.hash) return undefined;
|
||||
|
||||
if (
|
||||
filePath.length === 1 &&
|
||||
filePath[0] === 'WoW.exe' &&
|
||||
hash &&
|
||||
hash === Preferences.data.expectedPatchedWowHash
|
||||
)
|
||||
return undefined;
|
||||
|
||||
if (hash && item.version) {
|
||||
const stats = await fs.stat(path.join(clientPath, ...filePath));
|
||||
if (item.version <= stats.mtimeMs) return undefined;
|
||||
}
|
||||
|
||||
return item;
|
||||
};
|
||||
|
||||
this.#manifest = await buildTree();
|
||||
|
||||
await this.#saveCache();
|
||||
|
||||
const toDownload = getManifestSize(this.#manifest);
|
||||
this.#clientTotalBytes = getManifestSize(hashTree);
|
||||
this.#bytesAlreadyOnDisk = Math.max(
|
||||
0,
|
||||
this.#clientTotalBytes - toDownload
|
||||
);
|
||||
const availableSpace = await getAvailableDiskSpace();
|
||||
|
||||
if (toDownload > availableSpace) {
|
||||
this.status = {
|
||||
state: 'failed',
|
||||
message: `Not enough disk space. Required: ${formatFileSize(
|
||||
toDownload
|
||||
)}, Available: ${formatFileSize(availableSpace)}`
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
this.status = this.#manifest
|
||||
? {
|
||||
state: 'updateAvailable',
|
||||
message: formatFileSize(toDownload),
|
||||
progress: this.#bytesAlreadyOnDisk / this.#clientTotalBytes,
|
||||
bytesDone: this.#bytesAlreadyOnDisk,
|
||||
bytesTotal: this.#clientTotalBytes
|
||||
}
|
||||
: { state: 'upToDate', progress: 1 };
|
||||
this.#manifest &&
|
||||
Logger.log(
|
||||
`Detected changes:\n\t${getManifestFiles(this.#manifest).join(
|
||||
',\n\t'
|
||||
)}`
|
||||
);
|
||||
|
||||
const currentLauncherVersion = app.getVersion();
|
||||
if (
|
||||
this.status.state === 'upToDate' &&
|
||||
Preferences.data.lastPatchedLauncherVersion !==
|
||||
currentLauncherVersion
|
||||
) {
|
||||
Logger.log(
|
||||
`Launcher version changed (${
|
||||
Preferences.data.lastPatchedLauncherVersion ?? 'unset'
|
||||
} -> ${currentLauncherVersion}); silently re-applying tweaks via patchExecutable`
|
||||
);
|
||||
void (async () => {
|
||||
try {
|
||||
await patchExecutable();
|
||||
const cd = Preferences.data.clientDir;
|
||||
if (cd) {
|
||||
const patchedHash = await this.#getHash(
|
||||
{ clientPath: cd },
|
||||
'WoW.exe'
|
||||
);
|
||||
await this.#saveCache();
|
||||
Preferences.data = {
|
||||
lastPatchedLauncherVersion: currentLauncherVersion,
|
||||
expectedPatchedWowHash: patchedHash
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.error(
|
||||
'Auto-rerun patchExecutable after launcher version bump failed',
|
||||
e
|
||||
);
|
||||
}
|
||||
})();
|
||||
}
|
||||
} catch (e) {
|
||||
const message =
|
||||
e instanceof Error ? e.message : 'Unexpected error occurred';
|
||||
Logger.error(`Verification failed: ${message}`, e);
|
||||
this.status = { state: 'failed', message };
|
||||
}
|
||||
}
|
||||
|
||||
async update(clean?: boolean) {
|
||||
if (this.status?.state === 'verifying' || this.status?.state === 'updating')
|
||||
return;
|
||||
|
||||
const clientPath = Preferences.data.clientDir;
|
||||
if (!clientPath) {
|
||||
this.status = { state: 'noClient' };
|
||||
return;
|
||||
}
|
||||
|
||||
if (await isGameRunning(path.join(clientPath, 'WoW.exe'))) {
|
||||
this.status = {
|
||||
state: 'failed',
|
||||
message: 'Please close WoW first, before updating.'
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.log(`Updating client files at ${path.join(clientPath)}...`);
|
||||
this.status = {
|
||||
state: 'updating',
|
||||
progress: -1,
|
||||
message: 'Preparing files...'
|
||||
};
|
||||
|
||||
try {
|
||||
if (clean) {
|
||||
this.status = {
|
||||
state: 'updating',
|
||||
progress: -1,
|
||||
message: 'Cleaning up old files...'
|
||||
};
|
||||
|
||||
const files = await fs.readdir(clientPath);
|
||||
for (const file of files) {
|
||||
if (file === 'OctoLauncher.exe') continue;
|
||||
await fs.rm(path.join(clientPath, file), {
|
||||
recursive: true,
|
||||
force: true
|
||||
});
|
||||
}
|
||||
|
||||
this.#bytesAlreadyOnDisk = 0;
|
||||
}
|
||||
const hashTree =
|
||||
(clean ? undefined : this.#manifest) ?? (await fetchManifest());
|
||||
|
||||
if (!hashTree) {
|
||||
this.status = { state: 'serverUnreachable' };
|
||||
return;
|
||||
}
|
||||
|
||||
const fullClientTotal =
|
||||
this.#clientTotalBytes > 0
|
||||
? this.#clientTotalBytes
|
||||
: getManifestSize(hashTree);
|
||||
this.#clientTotalBytes = fullClientTotal;
|
||||
const baseline = this.#bytesAlreadyOnDisk;
|
||||
const tracker = new ProgressTracker(baseline);
|
||||
let executableUpdate = false;
|
||||
let lastEmit = 0;
|
||||
const STATUS_EMIT_INTERVAL_MS = 250;
|
||||
|
||||
const emitProgress = (message: string, force = false) => {
|
||||
const now = Date.now();
|
||||
if (!force && now - lastEmit < STATUS_EMIT_INTERVAL_MS) return;
|
||||
lastEmit = now;
|
||||
this.status = {
|
||||
state: 'updating',
|
||||
progress: tracker.bytesDone / fullClientTotal,
|
||||
message,
|
||||
bytesDone: tracker.bytesDone,
|
||||
bytesTotal: fullClientTotal,
|
||||
bytesPerSecond: tracker.bytesPerSecond(),
|
||||
etaSeconds: tracker.etaSeconds(fullClientTotal)
|
||||
};
|
||||
};
|
||||
|
||||
const iterateMpqTree = async (
|
||||
hMpq: HANDLE,
|
||||
mpqPath: string[],
|
||||
...filePath: string[]
|
||||
) => {
|
||||
const item = getManifestItem(hashTree, [...mpqPath, ...filePath]);
|
||||
if (!item) return undefined;
|
||||
|
||||
if (item.type === 'del') {
|
||||
throw Error(
|
||||
`TODO: Deleting of files from MPQ not implemented at path ${path.join(
|
||||
...mpqPath,
|
||||
...filePath
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'dir') {
|
||||
for (const f of item.files)
|
||||
await iterateMpqTree(hMpq, mpqPath, ...filePath, f.name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === 'mpq')
|
||||
throw Error(
|
||||
`There can't be an mpq archive inside mpq at path ${path.join(
|
||||
...mpqPath,
|
||||
...filePath
|
||||
)}`
|
||||
);
|
||||
|
||||
const label = `Patching: [${mpqPath.at(-1)}] "${path.join(...filePath)}"`;
|
||||
emitProgress(label, true);
|
||||
|
||||
const data = await fetchFile(
|
||||
path.join(...mpqPath, ...filePath),
|
||||
delta => {
|
||||
tracker.add(delta);
|
||||
emitProgress(label);
|
||||
}
|
||||
);
|
||||
|
||||
if (SFileHasFile(hMpq, path.join(...filePath)))
|
||||
SFileRemoveFile(hMpq, path.join(...filePath));
|
||||
|
||||
const hFile = SFileCreateFile(
|
||||
hMpq,
|
||||
path.join(...filePath),
|
||||
0,
|
||||
data.byteLength,
|
||||
0,
|
||||
MPQ_FILE.COMPRESS
|
||||
);
|
||||
try {
|
||||
SFileWriteFile(hFile, data, MPQ_COMPRESSION.ZLIB);
|
||||
} finally {
|
||||
SFileFinishFile(hFile);
|
||||
}
|
||||
};
|
||||
|
||||
const iterateTree = async (...filePath: string[]) => {
|
||||
const item = getManifestItem(hashTree, filePath);
|
||||
if (!item) return undefined;
|
||||
|
||||
if (item.type === 'del') {
|
||||
const fullPath = path.join(clientPath, ...filePath);
|
||||
if (await isReadOnly(fullPath))
|
||||
throw Error(
|
||||
`Failed to delete "${fullPath}" because it's read-only.`
|
||||
);
|
||||
|
||||
await fs.remove(fullPath);
|
||||
|
||||
await this.#getHash({ clientPath }, ...filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === 'dir') {
|
||||
for (const i of item.files) await iterateTree(...filePath, i.name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === 'mpq') {
|
||||
const patchPath = [
|
||||
...filePath.slice(0, -1),
|
||||
`${filePath.at(-1)}.mpq`
|
||||
];
|
||||
const patchFile = path.join(clientPath, ...patchPath);
|
||||
const label = `Downloading: "${path.join(...patchPath)}"`;
|
||||
emitProgress(label, true);
|
||||
|
||||
if (!(await fs.exists(patchFile))) {
|
||||
await downloadFileToDisk(
|
||||
path.join(...patchPath),
|
||||
patchFile,
|
||||
item.size,
|
||||
delta => {
|
||||
tracker.add(delta);
|
||||
emitProgress(label);
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await isReadOnly(patchFile))
|
||||
throw Error(
|
||||
`Failed to update "${patchFile}" because it's read-only.`
|
||||
);
|
||||
|
||||
const hMpq = SFileOpenArchive(path.join(clientPath, ...patchPath), 0);
|
||||
try {
|
||||
for (const f of item.files)
|
||||
await iterateMpqTree(hMpq, filePath, f.name);
|
||||
SFileFlushArchive(hMpq);
|
||||
SFileCompactArchive(hMpq);
|
||||
} finally {
|
||||
SFileCloseArchive(hMpq);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const label = `Downloading: "${path.join(...filePath)}"`;
|
||||
emitProgress(label, true);
|
||||
|
||||
if (item.name === 'WoW.exe') executableUpdate = true;
|
||||
|
||||
const fullPath = path.join(clientPath, ...filePath);
|
||||
if (await fs.exists(fullPath) && (await isReadOnly(fullPath)))
|
||||
throw Error(`Failed to update "${fullPath}" because it's read-only.`);
|
||||
|
||||
await downloadFileToDisk(
|
||||
path.join(...filePath),
|
||||
fullPath,
|
||||
item.size,
|
||||
delta => {
|
||||
tracker.add(delta);
|
||||
emitProgress(label);
|
||||
}
|
||||
);
|
||||
|
||||
await this.#getHash({ clientPath }, ...filePath);
|
||||
};
|
||||
|
||||
await iterateTree();
|
||||
await this.#saveCache();
|
||||
|
||||
const currentLauncherVersion = app.getVersion();
|
||||
const launcherVersionChanged =
|
||||
Preferences.data.lastPatchedLauncherVersion !== currentLauncherVersion;
|
||||
|
||||
if (executableUpdate || launcherVersionChanged) {
|
||||
await patchExecutable();
|
||||
await this.#getHash({ clientPath }, 'WoW.exe');
|
||||
const patchedWowHash = await this.#getHash({ clientPath }, 'WoW.exe');
|
||||
await this.#saveCache();
|
||||
Preferences.data = {
|
||||
version: await getClientVersion(),
|
||||
lastPatchedLauncherVersion: currentLauncherVersion,
|
||||
expectedPatchedWowHash: patchedWowHash
|
||||
};
|
||||
}
|
||||
|
||||
this.#bytesAlreadyOnDisk = fullClientTotal;
|
||||
this.status = { state: 'upToDate', progress: 1 };
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.status = {
|
||||
state: 'failed',
|
||||
message: e instanceof Error ? e.message : 'Unexpected error occurred'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const Updater = new UpdaterClass();
|
||||
export default Updater;
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
export { type AppRouter } from './api/root';
|
||||
export { type UpdaterStatus } from './modules/updater';
|
||||
export { type AddonsStatus, type AddonData } from './modules/addons';
|
||||
export {
|
||||
type ModsStatus,
|
||||
type ModRowStatus
|
||||
} from './modules/mods';
|
||||
export { type NewsItem, type NewsFeed } from '../common/schemas';
|
||||
@@ -0,0 +1,43 @@
|
||||
import { type Worker, type WorkerOptions } from 'node:worker_threads';
|
||||
import path from 'node:path';
|
||||
|
||||
import Logger from 'electron-log/main';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import Preferences from './modules/preferences';
|
||||
|
||||
const isCallbackResponse = (data: any): data is { cb: string; args: any[] } =>
|
||||
data && typeof data === 'object' && 'cb' in data && 'args' in data;
|
||||
|
||||
export const runWorker = <T>(
|
||||
worker: (o: WorkerOptions) => Worker,
|
||||
workerData: Record<string, unknown>,
|
||||
callbacks?: Record<string, (...data: any[]) => void>
|
||||
) =>
|
||||
new Promise<T>((resolve, reject) =>
|
||||
worker({ workerData })
|
||||
.on('message', m =>
|
||||
isCallbackResponse(m) ? callbacks?.[m.cb](...m.args) : resolve(m)
|
||||
)
|
||||
.on('error', reject)
|
||||
);
|
||||
|
||||
export const getClientVersion = async () => {
|
||||
Logger.log('Reading client version...');
|
||||
|
||||
const exePath = path.join(Preferences.data.clientDir ?? '', 'WoW.exe');
|
||||
|
||||
if (!(await fs.exists(exePath))) {
|
||||
Logger.log('Client not found...');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const file = await fs.readFile(exePath);
|
||||
const buffer = Buffer.from(file);
|
||||
|
||||
const version = buffer.toString('utf-8', 0x00437c04, 0x00437c04 + 6);
|
||||
const build = buffer.toString('utf-8', 0x00437bfc, 0x00437bfc + 4);
|
||||
|
||||
Logger.log(`Client version is: ${version} (${build})`);
|
||||
return `${version} (${build})`;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { workerData, parentPort } from 'worker_threads';
|
||||
|
||||
import git from 'isomorphic-git';
|
||||
import http from 'isomorphic-git/http/node';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
const port = parentPort;
|
||||
if (!port) throw new Error('IllegalState');
|
||||
|
||||
const { dir, url, ref } = workerData;
|
||||
|
||||
fs.removeSync(dir);
|
||||
git
|
||||
.clone({
|
||||
dir,
|
||||
fs,
|
||||
http,
|
||||
url,
|
||||
ref,
|
||||
singleBranch: !ref || ref === 'master' || ref === 'main',
|
||||
onProgress: (...args) => port.postMessage({ cb: 'onProgress', args })
|
||||
})
|
||||
.then(() => port.postMessage(true));
|
||||
@@ -0,0 +1,56 @@
|
||||
import { workerData, parentPort } from 'worker_threads';
|
||||
|
||||
import git from 'isomorphic-git';
|
||||
import http from 'isomorphic-git/http/node';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
const port = parentPort;
|
||||
if (!port) throw new Error('IllegalState');
|
||||
|
||||
const { dir, remote, branch, ref } = workerData as {
|
||||
dir: string;
|
||||
remote: string;
|
||||
branch: string;
|
||||
ref?: string;
|
||||
};
|
||||
|
||||
const onProgress = (...args: unknown[]) =>
|
||||
port.postMessage({ cb: 'onProgress', args });
|
||||
|
||||
const removeUntrackedFiles = async () => {
|
||||
const status = await git.statusMatrix({ fs, dir });
|
||||
await Promise.all(
|
||||
status
|
||||
.filter(([, HEAD]) => HEAD === 0)
|
||||
.map(([filepath]) => fs.remove(`${dir}/${filepath}`))
|
||||
);
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
if (ref) {
|
||||
await git.fetch({ fs, http, dir, tags: true, singleBranch: false, onProgress });
|
||||
await git.checkout({ fs, dir, force: true, ref, onProgress });
|
||||
await removeUntrackedFiles();
|
||||
return;
|
||||
}
|
||||
|
||||
await git.checkout({
|
||||
fs,
|
||||
dir,
|
||||
force: true,
|
||||
ref: `${remote}/${branch}`,
|
||||
onProgress
|
||||
});
|
||||
await removeUntrackedFiles();
|
||||
await git.pull({
|
||||
fs,
|
||||
http,
|
||||
dir,
|
||||
ref: branch,
|
||||
singleBranch: true,
|
||||
author: { name: 'Octo Launcher' },
|
||||
onProgress
|
||||
});
|
||||
};
|
||||
|
||||
run().then(() => port.postMessage(true));
|
||||
@@ -0,0 +1,16 @@
|
||||
import path from 'path';
|
||||
|
||||
import { contextBridge } from 'electron';
|
||||
import { electronAPI } from '@electron-toolkit/preload';
|
||||
import { exposeElectronTRPC } from 'electron-trpc/main';
|
||||
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI);
|
||||
contextBridge.exposeInMainWorld('path', path);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
process.once('loaded', async () => {
|
||||
exposeElectronTRPC();
|
||||
});
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
import type path from 'path';
|
||||
|
||||
import { type ElectronAPI } from '@electron-toolkit/preload';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI;
|
||||
path: typeof path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { api } from './utils/api';
|
||||
import PageBackground from './assets/background.png';
|
||||
import Header from './components/Header';
|
||||
import LaunchPanel from './components/LaunchPanel';
|
||||
import SelfUpdateBanner from './components/SelfUpdateBanner';
|
||||
import TabsPanel, { type TabType } from './components/TabsPanel';
|
||||
import TopBar from './components/TopBar';
|
||||
import IconSpinner from './components/styled/IconSpinner';
|
||||
import usePreventDefaultEvents from './utils/usePreventDefaultEvents';
|
||||
|
||||
const App = () => {
|
||||
const { isLoading } = api.preferences.get.useQuery();
|
||||
const { data: appVersion } = api.general.appVersion.useQuery();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>();
|
||||
|
||||
usePreventDefaultEvents();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex grow flex-col gap-3 overflow-hidden bg-cover bg-top bg-no-repeat p-[44px]"
|
||||
style={{ backgroundImage: `url(${PageBackground})` }}
|
||||
>
|
||||
<TopBar />
|
||||
<SelfUpdateBanner />
|
||||
<Header {...{ activeTab, setActiveTab }} />
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex flex-grow items-center justify-center">
|
||||
<IconSpinner />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<TabsPanel activeTab={activeTab} />
|
||||
<LaunchPanel />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Launcher build label, anchored bottom-right.*/}
|
||||
{appVersion && (
|
||||
<span className="pointer-events-none absolute bottom-2 right-3 text-[10px] font-mono uppercase tracking-wider text-white/40 select-none">
|
||||
v{appVersion}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Clipboard, RefreshCw, ServerCrash } from 'lucide-react';
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
import log from 'electron-log/renderer';
|
||||
|
||||
import PageBackground from './assets/background.png';
|
||||
import TextButton from './components/styled/TextButton';
|
||||
|
||||
type State = {
|
||||
didCatch?: boolean;
|
||||
error?: Error;
|
||||
errorInfo?: ErrorInfo;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
log.error('Client crash:', error, errorInfo);
|
||||
this.setState({ didCatch: true, error, errorInfo });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.didCatch) return this.props.children;
|
||||
const { error, errorInfo } = this.state;
|
||||
const title = `Uncaught ${error?.name}: ${
|
||||
error?.message ?? 'Unknown error'
|
||||
}`;
|
||||
const detail = errorInfo?.componentStack.slice(1);
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 991 KiB |
@@ -0,0 +1,124 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { PreferencesSchema } from '~common/schemas';
|
||||
import zodResolver from '~renderer/utils/zodResolver';
|
||||
import { api } from '~renderer/utils/api';
|
||||
|
||||
import TextButton from './styled/TextButton';
|
||||
import FilePickerInput from './form/FilePickerInput';
|
||||
import CloseButton from './styled/CloseButton';
|
||||
|
||||
type Props = { close: () => void };
|
||||
|
||||
const ClientDirDialog = ({ close }: Props) => {
|
||||
const { data: pref } = api.preferences.get.useQuery();
|
||||
const setPref = api.preferences.set.useMutation();
|
||||
const isValidClientDir = api.preferences.isValidClientDir.useQuery(
|
||||
pref?.clientDir,
|
||||
{ enabled: !!pref?.isPortable }
|
||||
);
|
||||
|
||||
const verify = api.updater.verify.useMutation();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState,
|
||||
setValue,
|
||||
setError,
|
||||
reset
|
||||
} = useForm({
|
||||
defaultValues: { clientDir: pref?.clientDir ?? '' },
|
||||
resolver: zodResolver(PreferencesSchema.pick({ clientDir: true }))
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
pref && reset(pref);
|
||||
}, [reset, pref]);
|
||||
|
||||
if (pref?.isPortable) {
|
||||
return (
|
||||
<form className="tw-dialog">
|
||||
<CloseButton close={close} />
|
||||
<h2 className="color mb-2 text-xl">Install location</h2>
|
||||
<p>
|
||||
You are using the portable version of the launcher. Install location
|
||||
is determined by the location of the launcher executable.
|
||||
</p>
|
||||
{!isValidClientDir.isLoading && !isValidClientDir.data && (
|
||||
<p>
|
||||
<span className="text-secondary">Error: </span>
|
||||
WoW.exe not found in current folder. Please close the launcher and
|
||||
move it to your WoW 1.12 client directory.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="tw-dialog"
|
||||
onSubmit={handleSubmit(async ({ clientDir }) => {
|
||||
try {
|
||||
await setPref.mutateAsync({ clientDir });
|
||||
verify.mutateAsync();
|
||||
close();
|
||||
} catch (e) {
|
||||
setError('clientDir', {
|
||||
message: e instanceof Error ? e.message : JSON.stringify(e)
|
||||
});
|
||||
}
|
||||
})}
|
||||
>
|
||||
<CloseButton
|
||||
close={() => {
|
||||
reset();
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
<h3 className="tw-color">Install location</h3>
|
||||
<hr />
|
||||
|
||||
<p className="text-blueGray">
|
||||
Select a directory for the game client installation.
|
||||
</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">
|
||||
<label htmlFor="clientDir">Install directory:</label>
|
||||
<FilePickerInput
|
||||
{...register('clientDir')}
|
||||
title={watch('clientDir') ?? undefined}
|
||||
setValue={v =>
|
||||
setValue('clientDir', v, {
|
||||
shouldTouch: true,
|
||||
shouldDirty: true,
|
||||
shouldValidate: true
|
||||
})
|
||||
}
|
||||
options={{ properties: ['openDirectory', 'createDirectory'] }}
|
||||
/>
|
||||
</div>
|
||||
{formState.errors.clientDir && (
|
||||
<p className="text-secondary text-sm">
|
||||
{formState.errors.clientDir.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<TextButton
|
||||
type="submit"
|
||||
loading={formState.isSubmitting}
|
||||
className="self-end text-green"
|
||||
>
|
||||
Confirm
|
||||
</TextButton>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientDirDialog;
|
||||
@@ -0,0 +1,32 @@
|
||||
import OctoLogo from '~renderer/assets/logo.png';
|
||||
|
||||
import TextButton from './styled/TextButton';
|
||||
import { TabNames, type TabType } from './TabsPanel';
|
||||
|
||||
type Props = {
|
||||
activeTab?: TabType;
|
||||
setActiveTab: (tab?: TabType) => void;
|
||||
};
|
||||
|
||||
const Header = ({ activeTab, setActiveTab }: Props) => (
|
||||
<div className="-mb-3 flex select-none items-center gap-1">
|
||||
<button
|
||||
onClick={() => setActiveTab(undefined)}
|
||||
className="z-10 -my-3 mx-3 w-[180px] cursor-pointer"
|
||||
>
|
||||
<img src={OctoLogo} alt="OctoWoW" className="pointer-events-none" />
|
||||
</button>
|
||||
{TabNames.map(t => (
|
||||
<TextButton
|
||||
key={t}
|
||||
onClick={() => setActiveTab(t)}
|
||||
active={activeTab === t}
|
||||
className="uppercase"
|
||||
>
|
||||
{t}
|
||||
</TextButton>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useState, type ReactElement } from 'react';
|
||||
import cls from 'classnames';
|
||||
|
||||
import { type UpdaterStatus, type ModsStatus } from '~main/types';
|
||||
import { formatFileSize } from '~common/utils';
|
||||
import { api } from '~renderer/utils/api';
|
||||
|
||||
import Button from './styled/Button';
|
||||
import DialogButton from './styled/DialogButton';
|
||||
import ClientDirDialog from './ClientDirDialog';
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const s = Math.max(0, Math.round(seconds));
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
const rem = s % 60;
|
||||
if (m < 60) return rem ? `${m}m ${rem}s` : `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
const minRem = m % 60;
|
||||
return minRem ? `${h}h ${minRem}m` : `${h}h`;
|
||||
};
|
||||
|
||||
const ProgressDetails = ({ status }: { status: UpdaterStatus }) => {
|
||||
const { bytesDone, bytesTotal, bytesPerSecond, etaSeconds, progress } = status;
|
||||
if (bytesTotal === undefined || bytesDone === undefined) return null;
|
||||
|
||||
const pct = progress !== undefined && progress >= 0
|
||||
? `${(progress * 100).toFixed(1)}%`
|
||||
: '—';
|
||||
|
||||
return (
|
||||
<p className="s1 text-blueGray">
|
||||
<span className="tw-color">{pct}</span>
|
||||
<span> · {formatFileSize(bytesDone)} / {formatFileSize(bytesTotal)}</span>
|
||||
{bytesPerSecond !== undefined && bytesPerSecond > 0 && (
|
||||
<span> · {formatFileSize(bytesPerSecond)}/s</span>
|
||||
)}
|
||||
<span>
|
||||
{' · '}
|
||||
{etaSeconds !== undefined
|
||||
? `~${formatDuration(etaSeconds)} remaining`
|
||||
: 'calculating…'}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
const LaunchPanel = () => {
|
||||
const [status, setStatus] = useState<UpdaterStatus>({ state: 'verifying' });
|
||||
api.updater.observe.useSubscription(undefined, {
|
||||
onData: data => {
|
||||
console.log({ data });
|
||||
setStatus(data);
|
||||
},
|
||||
onError: err => console.log({ err }),
|
||||
onStarted: () => console.log('Started')
|
||||
});
|
||||
|
||||
const { data: pref } = api.preferences.get.useQuery();
|
||||
|
||||
const [modsStatus, setModsStatus] = useState<ModsStatus>();
|
||||
api.mods.observe.useSubscription(undefined, {
|
||||
onData: setModsStatus
|
||||
});
|
||||
|
||||
const verify = api.updater.verify.useMutation();
|
||||
const update = api.updater.update.useMutation();
|
||||
const start = api.launcher.start.useMutation();
|
||||
const applyMods = api.mods.applyAll.useMutation();
|
||||
|
||||
const props: Record<
|
||||
UpdaterStatus['state'],
|
||||
{ button: ReactElement; helperText?: ReactElement }
|
||||
> = {
|
||||
verifying: { button: <Button disabled>Verifying</Button> },
|
||||
serverUnreachable: {
|
||||
button: pref?.version ? (
|
||||
<Button onClick={() => start.mutateAsync()}>Play</Button>
|
||||
) : (
|
||||
<Button onClick={() => verify.mutateAsync()}>Retry</Button>
|
||||
),
|
||||
helperText: (
|
||||
<div className="-mb-2">
|
||||
<p>
|
||||
<span className="text-orange">Error: </span> Failed to reach update
|
||||
server
|
||||
</p>
|
||||
<p className="s1 text-blueGray">
|
||||
{pref?.version
|
||||
? `You can launch local version ${pref?.version}`
|
||||
: 'Please try again later'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
noClient: {
|
||||
button: (
|
||||
<DialogButton
|
||||
clickAway
|
||||
dialog={close => <ClientDirDialog close={close} />}
|
||||
>
|
||||
{open => (
|
||||
<Button primary onClick={open}>
|
||||
Install
|
||||
</Button>
|
||||
)}
|
||||
</DialogButton>
|
||||
)
|
||||
},
|
||||
updateAvailable: {
|
||||
button: <Button onClick={() => update.mutateAsync()}>Update</Button>,
|
||||
helperText: (
|
||||
<div className="-mb-2 flex flex-col gap-1">
|
||||
<p>Update available!</p>
|
||||
<p className="s1 text-blueGray">
|
||||
{status.progress !== undefined &&
|
||||
status.bytesDone !== undefined &&
|
||||
status.bytesTotal !== undefined && (
|
||||
<>
|
||||
<span className="tw-color">
|
||||
{(status.progress * 100).toFixed(1)}%
|
||||
</span>
|
||||
<span>
|
||||
{' '}
|
||||
· {formatFileSize(status.bytesDone)} /{' '}
|
||||
{formatFileSize(status.bytesTotal)} on disk ·{' '}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="break-all">{status.message}</span> remaining
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
updating: {
|
||||
button: <Button disabled>Updating</Button>,
|
||||
helperText: (
|
||||
<div className="-mb-2 flex flex-col gap-1">
|
||||
{status.message && (
|
||||
<p className="s1 truncate text-blueGray">{status.message}</p>
|
||||
)}
|
||||
<ProgressDetails status={status} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
upToDate: {
|
||||
button: modsStatus?.dirty ? (
|
||||
<Button
|
||||
primary
|
||||
onClick={() => applyMods.mutateAsync()}
|
||||
disabled={applyMods.isLoading || modsStatus?.state === 'busy'}
|
||||
>
|
||||
{modsStatus?.state === 'busy' ? 'Applying' : 'Update'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button primary onClick={() => start.mutateAsync()}>
|
||||
Play
|
||||
</Button>
|
||||
),
|
||||
helperText: (
|
||||
<div className="-mb-2">
|
||||
{modsStatus?.dirty ? (
|
||||
<p>Mods changed — apply before playing</p>
|
||||
) : (
|
||||
<p>Everything up to date!</p>
|
||||
)}
|
||||
<p className="s1 text-blueGray">Version: {pref?.version}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
failed: {
|
||||
button: <Button onClick={() => verify.mutateAsync()}>Retry</Button>,
|
||||
helperText: (
|
||||
<div className="-mb-2">
|
||||
<p>
|
||||
<span className="text-orange">Error: </span>
|
||||
{status.message}
|
||||
</p>
|
||||
<p className="s1 text-blueGray">
|
||||
Verify your game data by clicking Retry.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-grow flex-col justify-end gap-3">
|
||||
{props[status.state].helperText ??
|
||||
(status.message && (
|
||||
<p className="s1 -mb-2 text-blueGray">{status.message}</p>
|
||||
))}
|
||||
<div className="tw-loading-wrapper">
|
||||
{status.progress !== undefined && (
|
||||
<div
|
||||
className={cls('tw-loading', {
|
||||
'tw-loading-unknown': status.progress === -1
|
||||
})}
|
||||
style={
|
||||
status.progress !== -1
|
||||
? {
|
||||
clipPath: `inset(0 ${
|
||||
100 - Math.ceil(Math.abs(status.progress) * 100)
|
||||
}% 0 0)`
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{props[status.state].button}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LaunchPanel;
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
FilePen,
|
||||
FolderOpen,
|
||||
RefreshCw,
|
||||
ScrollText,
|
||||
ShieldCheck
|
||||
} from 'lucide-react';
|
||||
|
||||
import { PreferencesSchema } from '~common/schemas';
|
||||
import { api } from '~renderer/utils/api';
|
||||
import zodResolver from '~renderer/utils/zodResolver';
|
||||
|
||||
import TextButton from './styled/TextButton';
|
||||
import CheckboxInput from './form/CheckboxInput';
|
||||
import DialogButton from './styled/DialogButton';
|
||||
import ClientDirDialog from './ClientDirDialog';
|
||||
import CloseButton from './styled/CloseButton';
|
||||
|
||||
const MirrorStatus = () => {
|
||||
const [state, setState] = useState<string>('verifying');
|
||||
api.updater.observe.useSubscription(undefined, {
|
||||
onData: ({ state }) => setState(state)
|
||||
});
|
||||
|
||||
if (state === 'serverUnreachable')
|
||||
return <span className="s1 text-red">offline</span>;
|
||||
if (state === 'verifying' || state === 'updating')
|
||||
return <span className="s1 text-blueGray">checking…</span>;
|
||||
return <span className="s1 text-warmGreen">online</span>;
|
||||
};
|
||||
|
||||
type Props = { close: () => void };
|
||||
|
||||
const PreferencesDialog = ({ close }: Props) => {
|
||||
const { data: pref } = api.preferences.get.useQuery();
|
||||
const setPref = api.preferences.set.useMutation();
|
||||
|
||||
const verify = api.updater.verify.useMutation();
|
||||
const openInstallFolder = api.general.openInstallFolder.useMutation();
|
||||
const openLogFile = api.general.openLogFile.useMutation();
|
||||
|
||||
const { handleSubmit, watch, setValue, reset } = useForm({
|
||||
defaultValues: pref ?? {},
|
||||
resolver: zodResolver(PreferencesSchema)
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
pref && reset(pref);
|
||||
}, [reset, pref]);
|
||||
|
||||
const setBool = (key: keyof PreferencesSchema) => (v: boolean) =>
|
||||
setValue(key, v, {
|
||||
shouldTouch: true,
|
||||
shouldDirty: true,
|
||||
shouldValidate: true
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
className="tw-dialog !w-fit min-w-[480px] max-w-[640px] !gap-1 whitespace-nowrap"
|
||||
onSubmit={handleSubmit(async v => {
|
||||
await setPref.mutateAsync(v);
|
||||
close();
|
||||
})}
|
||||
>
|
||||
<CloseButton
|
||||
close={() => {
|
||||
reset();
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
<h3 className="tw-color">SETTINGS</h3>
|
||||
<hr className="mb-1" />
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<h4 className="tw-color">INSTALL LOCATION:</h4>
|
||||
<TextButton
|
||||
icon={FolderOpen}
|
||||
size={14}
|
||||
onClick={() => openInstallFolder.mutateAsync()}
|
||||
className="!p-1 text-blueGray"
|
||||
>
|
||||
Open folder
|
||||
</TextButton>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 border border-blueGray/20 bg-darkGray/40 px-3 py-1">
|
||||
<span
|
||||
title={pref?.clientDir}
|
||||
className="min-w-0 shrink grow overflow-hidden text-ellipsis"
|
||||
>
|
||||
{pref?.clientDir ?? 'Not selected'}
|
||||
</span>
|
||||
<DialogButton
|
||||
dialog={closeInner => (
|
||||
<ClientDirDialog
|
||||
close={() => {
|
||||
closeInner();
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
clickAway={pref?.isPortable}
|
||||
>
|
||||
{open => (
|
||||
<TextButton icon={FilePen} size={14} onClick={open} className="!p-1">
|
||||
Change
|
||||
</TextButton>
|
||||
)}
|
||||
</DialogButton>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
<h4 className="tw-color">DOWNLOAD MIRROR:</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<input type="radio" checked readOnly className="accent-warmGreen" />
|
||||
<span>Iceland</span>
|
||||
<MirrorStatus />
|
||||
<TextButton
|
||||
icon={RefreshCw}
|
||||
size={12}
|
||||
onClick={() => verify.mutateAsync()}
|
||||
title="Re-check"
|
||||
className="!p-0 text-blueGray"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex flex-col">
|
||||
<h4 className="tw-color">TROUBLESHOOTING:</h4>
|
||||
<TextButton
|
||||
icon={ShieldCheck}
|
||||
onClick={() => verify.mutateAsync().then(close)}
|
||||
className="text-warmGreen"
|
||||
>
|
||||
Verify game files
|
||||
</TextButton>
|
||||
<TextButton
|
||||
icon={ScrollText}
|
||||
onClick={() => openLogFile.mutateAsync()}
|
||||
className="text-pink"
|
||||
>
|
||||
Open log file
|
||||
</TextButton>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<h4 className="tw-color">GENERAL SETTINGS:</h4>
|
||||
<CheckboxInput
|
||||
value={!!watch('cleanWdb')}
|
||||
setValue={setBool('cleanWdb')}
|
||||
label="Clean WDB on each launch"
|
||||
/>
|
||||
<CheckboxInput
|
||||
value={!!watch('minimizeToTrayOnPlay')}
|
||||
setValue={setBool('minimizeToTrayOnPlay')}
|
||||
label="Minimize to tray while playing"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TextButton type="submit" className="mt-1 self-end text-green">
|
||||
Save
|
||||
</TextButton>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreferencesDialog;
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { api } from '~renderer/utils/api';
|
||||
|
||||
import Button from './styled/Button';
|
||||
|
||||
type Status =
|
||||
| { state: 'idle'; currentVersion: string }
|
||||
| { state: 'checking'; currentVersion: string }
|
||||
| { state: 'unavailable'; currentVersion: string }
|
||||
| { state: 'available'; currentVersion: string; nextVersion: string }
|
||||
| {
|
||||
state: 'downloading';
|
||||
currentVersion: string;
|
||||
nextVersion: string;
|
||||
progress: number;
|
||||
}
|
||||
| { state: 'ready'; currentVersion: string; nextVersion: string }
|
||||
| { state: 'error'; currentVersion: string; message: string };
|
||||
|
||||
const SelfUpdateBanner = () => {
|
||||
const [status, setStatus] = useState<Status>({
|
||||
state: 'idle',
|
||||
currentVersion: ''
|
||||
});
|
||||
api.selfUpdater.observe.useSubscription(undefined, {
|
||||
onData: setStatus
|
||||
});
|
||||
const install = api.selfUpdater.install.useMutation();
|
||||
|
||||
if (
|
||||
status.state === 'idle' ||
|
||||
status.state === 'checking' ||
|
||||
status.state === 'unavailable'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tone = status.state === 'error' ? 'border-red/40' : 'border-tw/40';
|
||||
const label =
|
||||
status.state === 'error'
|
||||
? `Update check failed: ${status.message}`
|
||||
: status.state === 'available'
|
||||
? `Launcher update ${'nextVersion' in status ? status.nextVersion : ''} available — preparing download…`
|
||||
: status.state === 'downloading'
|
||||
? `Downloading update ${status.nextVersion} · ${Math.round(
|
||||
status.progress * 100
|
||||
)}%`
|
||||
: status.state === 'ready'
|
||||
? `Launcher update ${status.nextVersion} ready to install`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative z-10 flex items-center gap-3 rounded-md border ${tone} bg-black/60 px-4 py-2 text-sm`}
|
||||
>
|
||||
<span className="flex-grow break-all">{label}</span>
|
||||
{status.state === 'ready' && (
|
||||
<Button
|
||||
primary
|
||||
onClick={() => install.mutateAsync()}
|
||||
disabled={install.isLoading}
|
||||
>
|
||||
Install now
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelfUpdateBanner;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
import log from 'electron-log/renderer';
|
||||
|
||||
import TextButton from './styled/TextButton';
|
||||
|
||||
type Props = {
|
||||
tabName: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type State = {
|
||||
error?: Error;
|
||||
componentStack?: string;
|
||||
};
|
||||
|
||||
class TabErrorBoundary extends Component<Props, State> {
|
||||
state: State = {};
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
log.error(`Tab "${this.props.tabName}" crashed:`, error, info);
|
||||
this.setState({ error, componentStack: info.componentStack ?? undefined });
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (prevProps.tabName !== this.props.tabName) {
|
||||
this.setState({ error: undefined, componentStack: undefined });
|
||||
}
|
||||
}
|
||||
|
||||
#reset = () => this.setState({ error: undefined, componentStack: undefined });
|
||||
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children;
|
||||
const { error, componentStack } = this.state;
|
||||
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">{this.props.tabName} crashed</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={this.#reset}
|
||||
className="self-end text-warmGreen"
|
||||
>
|
||||
Try again
|
||||
</TextButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default TabErrorBoundary;
|
||||
@@ -0,0 +1,30 @@
|
||||
import AddonsTab from './tabs/AddonsTab';
|
||||
import ModsTab from './tabs/ModsTab';
|
||||
import NewsTab from './tabs/NewsTab';
|
||||
import TweaksTab from './tabs/TweaksTab';
|
||||
import TabErrorBoundary from './TabErrorBoundary';
|
||||
|
||||
const Tabs = {
|
||||
'news': NewsTab,
|
||||
'tweaks': TweaksTab,
|
||||
'addons': AddonsTab,
|
||||
'mods': ModsTab
|
||||
} as const;
|
||||
|
||||
export const TabNames = Object.keys(Tabs) as TabType[];
|
||||
|
||||
export type TabType = keyof typeof Tabs;
|
||||
|
||||
type Props = { activeTab?: TabType };
|
||||
|
||||
const TabsPanel = ({ activeTab }: Props) => {
|
||||
const tab: TabType = activeTab ?? 'news';
|
||||
const Component = Tabs[tab];
|
||||
return (
|
||||
<TabErrorBoundary key={tab} tabName={tab}>
|
||||
<Component />
|
||||
</TabErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabsPanel;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Settings, Minus, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { api } from '~renderer/utils/api';
|
||||
|
||||
import DialogButton from './styled/DialogButton';
|
||||
import PreferencesDialog from './PreferencesDialog';
|
||||
import TextButton from './styled/TextButton';
|
||||
|
||||
const TopBar = () => {
|
||||
const [safeToQuit, setSafeToQuit] = useState(true);
|
||||
api.updater.observe.useSubscription(undefined, {
|
||||
onData: ({ state }) =>
|
||||
setSafeToQuit(state !== 'verifying' && state !== 'updating')
|
||||
});
|
||||
|
||||
const minimize = api.general.minimize.useMutation();
|
||||
const quit = api.general.quit.useMutation();
|
||||
return (
|
||||
<div
|
||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||
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">
|
||||
<DialogButton dialog={close => <PreferencesDialog close={close} />}>
|
||||
{open => (
|
||||
<TextButton
|
||||
icon={Settings}
|
||||
title="Settings"
|
||||
onClick={open}
|
||||
size={16}
|
||||
className="!p-1"
|
||||
/>
|
||||
)}
|
||||
</DialogButton>
|
||||
<TextButton
|
||||
icon={Minus}
|
||||
title="Minimize"
|
||||
onClick={() => minimize.mutateAsync()}
|
||||
size={16}
|
||||
className="!p-1"
|
||||
/>
|
||||
<DialogButton
|
||||
dialog={close => (
|
||||
<div className="tw-dialog">
|
||||
<h3 className="tw-color">Quit?</h3>
|
||||
<hr />
|
||||
<p className="text-blueGray">
|
||||
Your game is currently being updated. Quitting now may cause
|
||||
problems.
|
||||
</p>
|
||||
<div className="flex gap-2 self-end">
|
||||
<TextButton onClick={close}>Return</TextButton>
|
||||
<TextButton
|
||||
onClick={() => quit.mutateAsync()}
|
||||
className="text-red"
|
||||
>
|
||||
Quit
|
||||
</TextButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{open => (
|
||||
<TextButton
|
||||
icon={X}
|
||||
title="Quit"
|
||||
onClick={() => (!safeToQuit ? open() : quit.mutateAsync())}
|
||||
size={16}
|
||||
className="!p-1 hocus:text-red"
|
||||
/>
|
||||
)}
|
||||
</DialogButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopBar;
|
||||
@@ -0,0 +1,50 @@
|
||||
import cls from 'classnames';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
import TextButton from '../styled/TextButton';
|
||||
|
||||
const Checkbox = () => (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="shrink-0"
|
||||
>
|
||||
<rect
|
||||
x="1"
|
||||
y="1"
|
||||
width="10"
|
||||
height="10"
|
||||
rx="1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<rect x="3.5" y="3.5" width="5" height="5" fill="white" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
type Props = {
|
||||
label?: ReactNode;
|
||||
value: boolean;
|
||||
setValue: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
className?: cls.Value;
|
||||
};
|
||||
|
||||
const CheckboxInput = ({ label, value, setValue, disabled, className }: Props) => (
|
||||
<TextButton
|
||||
onClick={() => !disabled && setValue(!value)}
|
||||
icon={Checkbox}
|
||||
className={cls(
|
||||
'text-blueGray',
|
||||
{ '[&_*]:fill-none': !value, 'pointer-events-none opacity-40': disabled },
|
||||
className
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</TextButton>
|
||||
);
|
||||
|
||||
export default CheckboxInput;
|
||||
@@ -0,0 +1,47 @@
|
||||
import cls from 'classnames';
|
||||
import { forwardRef, type HTMLProps } from 'react';
|
||||
import { AppWindow, FolderOpen } from 'lucide-react';
|
||||
|
||||
import { api, type RouterInputs } from '~renderer/utils/api';
|
||||
|
||||
import TextButton from '../styled/TextButton';
|
||||
|
||||
type Props = HTMLProps<HTMLInputElement> & {
|
||||
setValue: (newVal: string) => void;
|
||||
options: RouterInputs['general']['filePicker'];
|
||||
};
|
||||
|
||||
const FilePickerInput = forwardRef<HTMLInputElement, Props>(
|
||||
({ setValue, options, className, ...props }, ref) => {
|
||||
const filePicker = api.general.filePicker.useMutation();
|
||||
return (
|
||||
<div className="relative flex grow">
|
||||
<input
|
||||
ref={ref}
|
||||
id={props.name}
|
||||
{...props}
|
||||
className={cls(
|
||||
'grow border-b border-blueGray bg-inherit p-1 pr-[44px] hocus:border-orange',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
<TextButton
|
||||
className="absolute right-1 top-0 h-full"
|
||||
icon={
|
||||
options.properties?.includes('openDirectory')
|
||||
? FolderOpen
|
||||
: AppWindow
|
||||
}
|
||||
title="Pick file"
|
||||
onClick={async () => {
|
||||
const r = await filePicker.mutateAsync(options);
|
||||
if (r.canceled) return;
|
||||
setValue(r.path[0]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default FilePickerInput;
|
||||
@@ -0,0 +1,59 @@
|
||||
import cls from 'classnames';
|
||||
import { type ChangeEvent, type FocusEvent, type HTMLProps, forwardRef } from 'react';
|
||||
|
||||
type Props = Omit<
|
||||
HTMLProps<HTMLInputElement>,
|
||||
'value' | 'min' | 'max' | 'step'
|
||||
> & {
|
||||
setValue: (v: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
sensitivity?: number;
|
||||
};
|
||||
const NumberGrabInput = forwardRef<HTMLInputElement, Props>(
|
||||
(
|
||||
{
|
||||
setValue,
|
||||
className,
|
||||
max = Infinity,
|
||||
min = -Infinity,
|
||||
step: _step,
|
||||
sensitivity: _sensitivity,
|
||||
type: _ignored,
|
||||
onChange,
|
||||
onBlur,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => (
|
||||
<input
|
||||
ref={ref}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
{...props}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const n = Number(e.currentTarget.value);
|
||||
if (Number.isFinite(n) && n > max) {
|
||||
e.currentTarget.value = String(max);
|
||||
}
|
||||
onChange?.(e);
|
||||
}}
|
||||
onBlur={(e: FocusEvent<HTMLInputElement>) => {
|
||||
const n = Number(e.currentTarget.value);
|
||||
const clamped = Math.max(
|
||||
Math.min(Number.isFinite(n) ? n : min, max),
|
||||
min
|
||||
);
|
||||
setValue(clamped);
|
||||
onBlur?.(e);
|
||||
}}
|
||||
onWheel={e => !e.shiftKey && e.currentTarget.blur()}
|
||||
className={cls(
|
||||
className,
|
||||
'w-[70px] cursor-text border-b border-blueGray bg-inherit p-1 text-center hocus:border-orange'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
export default NumberGrabInput;
|
||||
@@ -0,0 +1,44 @@
|
||||
import cls from 'classnames';
|
||||
|
||||
import TextButton from '../styled/TextButton';
|
||||
|
||||
const Radio = () => (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="6" cy="6" r="5.5" stroke="currentColor" />
|
||||
<path
|
||||
d="M8 6C8 7.10429 7.10429 8 6 8C4.89571 8 4 7.10429 4 6C4 4.8957 4.89571 4 6 4C7.10429 4 8 4.8957 8 6Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
type Props<T> = {
|
||||
value: T;
|
||||
setValue: (val: T) => void;
|
||||
options: { label: string; value: T }[];
|
||||
};
|
||||
|
||||
const RadioInput = <const T,>({ value, setValue, options }: Props<T>) => (
|
||||
<div className="flex justify-start">
|
||||
{options.map(o => (
|
||||
<TextButton
|
||||
key={`${o.value}`}
|
||||
onClick={() => setValue(o.value)}
|
||||
icon={Radio}
|
||||
className={cls('text-blueGray', {
|
||||
'[&_*]:fill-none': value !== o.value
|
||||
})}
|
||||
>
|
||||
{o.label}
|
||||
</TextButton>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default RadioInput;
|
||||
@@ -0,0 +1,17 @@
|
||||
import cls from 'classnames';
|
||||
import { forwardRef, type HTMLProps } from 'react';
|
||||
|
||||
const TextInput = forwardRef<HTMLInputElement, HTMLProps<HTMLInputElement>>(
|
||||
(props, ref) => (
|
||||
<input
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cls(
|
||||
'cursor-text border-b border-blueGray bg-inherit p-1 hocus:border-orange',
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
export default TextInput;
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ButtonHTMLAttributes } from 'react';
|
||||
import cls from 'classnames';
|
||||
import { type LucideIcon } from 'lucide-react';
|
||||
|
||||
import IconSpinner from './IconSpinner';
|
||||
|
||||
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
primary?: boolean;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
icon?: LucideIcon;
|
||||
};
|
||||
|
||||
const Button = ({
|
||||
primary,
|
||||
loading,
|
||||
disabled,
|
||||
icon: Icon,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: Props) => (
|
||||
<button
|
||||
{...props}
|
||||
onClick={props.onClick}
|
||||
tabIndex={!!loading || !!disabled ? -1 : props.tabIndex}
|
||||
className={cls('tw-button', className, {
|
||||
'pointer-events-none': !!disabled || !!loading,
|
||||
'grayscale': disabled,
|
||||
'tw-button-primary': primary
|
||||
})}
|
||||
>
|
||||
<span className={cls('select-none', { 'ml-[-12px]': !!loading || !!Icon })}>
|
||||
{loading ? (
|
||||
<IconSpinner size={23} strokeWidth={1.5} />
|
||||
) : Icon ? (
|
||||
<Icon size={23} strokeWidth={1.5} />
|
||||
) : null}
|
||||
{children}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
export default Button;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import TextButton from './TextButton';
|
||||
|
||||
const CloseButton = ({ close }: { close: () => void }) => (
|
||||
<TextButton
|
||||
title="Close"
|
||||
icon={X}
|
||||
size={16}
|
||||
onClick={close}
|
||||
className="absolute right-1 top-1 text-blueGray hocus:text-red"
|
||||
/>
|
||||
);
|
||||
export default CloseButton;
|
||||
@@ -0,0 +1,54 @@
|
||||
type Run = { text: string; color?: string };
|
||||
|
||||
const tokenize = (s: string): Run[] => {
|
||||
const runs: Run[] = [];
|
||||
const re = /\|c([0-9a-fA-F]{8})|\|r/g;
|
||||
let i = 0;
|
||||
let color: string | undefined;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(s)) !== null) {
|
||||
if (m.index > i) runs.push({ text: s.slice(i, m.index), color });
|
||||
if (m[0].toLowerCase() === '|r') {
|
||||
color = undefined;
|
||||
} else if (m[1]) {
|
||||
color = `#${m[1].slice(2).toLowerCase()}`;
|
||||
}
|
||||
i = re.lastIndex;
|
||||
}
|
||||
if (i < s.length) runs.push({ text: s.slice(i), color });
|
||||
return runs.filter(r => r.text.length > 0);
|
||||
};
|
||||
|
||||
export const stripColorCodes = (s: string) =>
|
||||
tokenize(s)
|
||||
.map(r => r.text)
|
||||
.join('');
|
||||
|
||||
export const ColoredText = ({
|
||||
children,
|
||||
className,
|
||||
style
|
||||
}: {
|
||||
children: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) => {
|
||||
const runs = tokenize(children);
|
||||
return (
|
||||
<p className={className} style={style}>
|
||||
{runs.map((r, i) =>
|
||||
r.color ? (
|
||||
<span
|
||||
key={i}
|
||||
className="text-size-inherit text-inherit"
|
||||
style={{ color: r.color }}
|
||||
>
|
||||
{r.text}
|
||||
</span>
|
||||
) : (
|
||||
<span key={i}>{r.text}</span>
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import cls from 'classnames';
|
||||
import {
|
||||
useRef,
|
||||
type ReactElement,
|
||||
useEffect,
|
||||
useCallback,
|
||||
type FC,
|
||||
isValidElement
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
type Props = {
|
||||
clickAway?: boolean;
|
||||
noBlur?: boolean;
|
||||
focusOnOpen?: boolean;
|
||||
afterClose?: () => void;
|
||||
dialog: ReactElement | ((close: () => void) => ReactElement);
|
||||
children: ReactElement | ((open: () => void) => ReactElement);
|
||||
};
|
||||
|
||||
const DialogButton = ({
|
||||
clickAway,
|
||||
noBlur,
|
||||
focusOnOpen,
|
||||
afterClose,
|
||||
dialog,
|
||||
children
|
||||
}: Props) => {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
|
||||
const open = useCallback(() => {
|
||||
if (!ref.current) return;
|
||||
!focusOnOpen && (ref.current.inert = true);
|
||||
ref.current.showModal();
|
||||
!focusOnOpen && (ref.current.inert = false);
|
||||
}, [focusOnOpen]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
ref.current?.close();
|
||||
}, []);
|
||||
|
||||
// Click away
|
||||
useEffect(() => {
|
||||
if (!clickAway) return;
|
||||
const callback = (e: MouseEvent) => e.target === ref.current && close();
|
||||
window.addEventListener('click', callback);
|
||||
return () => window.removeEventListener('click', callback);
|
||||
}, [clickAway, close]);
|
||||
|
||||
useEffect(() => {
|
||||
const callback = () => {
|
||||
afterClose?.();
|
||||
return (document.activeElement as HTMLElement)?.blur();
|
||||
};
|
||||
const r = ref.current;
|
||||
r?.addEventListener('close', callback);
|
||||
return () => r?.removeEventListener('close', callback);
|
||||
}, [afterClose]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{createPortal(
|
||||
<dialog
|
||||
ref={ref}
|
||||
onSubmit={e => e.stopPropagation()}
|
||||
className={cls(
|
||||
'h-full w-full items-center justify-center bg-[transparent] [&[open]]:flex',
|
||||
{ 'backdrop:backdrop-blur-md': !noBlur }
|
||||
)}
|
||||
>
|
||||
{typeof dialog === 'function' ? dialog(close) : dialog}
|
||||
</dialog>,
|
||||
document.body
|
||||
)}
|
||||
{typeof children === 'function' ? children(open) : children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DialogButton;
|
||||
@@ -0,0 +1,8 @@
|
||||
import cls from 'classnames';
|
||||
import { Loader2, type LucideProps } from 'lucide-react';
|
||||
|
||||
const IconSpinner = ({ className, ...props }: LucideProps) => (
|
||||
<Loader2 {...props} className={cls(className, 'animate-spin')} />
|
||||
);
|
||||
|
||||
export default IconSpinner;
|
||||
@@ -0,0 +1,66 @@
|
||||
import cls from 'classnames';
|
||||
import { type LucideIcon } from 'lucide-react';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
import IconSpinner from './IconSpinner';
|
||||
|
||||
type Props = {
|
||||
active?: boolean;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
size?: number;
|
||||
className?: cls.Value;
|
||||
style?: React.CSSProperties;
|
||||
} & (
|
||||
| { type: 'submit'; onClick?: never }
|
||||
| { type?: never; onClick: () => void }
|
||||
) &
|
||||
(
|
||||
| { children: ReactNode; icon?: LucideIcon; title?: never }
|
||||
| { children?: never; icon: LucideIcon; title: string }
|
||||
);
|
||||
|
||||
const TextButton = ({
|
||||
title,
|
||||
type,
|
||||
active,
|
||||
loading,
|
||||
disabled,
|
||||
icon: Icon,
|
||||
size,
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: Props) => (
|
||||
<button
|
||||
title={title ?? (typeof children === 'string' ? children : undefined)}
|
||||
type={type ?? 'button'}
|
||||
onClick={onClick}
|
||||
tabIndex={!!loading || !!disabled ? -1 : undefined}
|
||||
className={cls(
|
||||
'flex cursor-pointer items-center gap-2 border-0 p-2',
|
||||
className,
|
||||
{
|
||||
'tw-color drop-shadow-[0px_0px_10px_white]':
|
||||
active && !loading && !disabled,
|
||||
'pointer-events-none text-gray': !!loading || !!disabled,
|
||||
'tw-hocus': !loading && !disabled
|
||||
}
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<IconSpinner size={size ?? 24} strokeWidth={1.5} />
|
||||
) : (
|
||||
Icon && <Icon size={size} />
|
||||
)}
|
||||
{children && (
|
||||
<span className="cursor-pointer select-none tracking-wide text-inherit [font-size:_inherit]">
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
export default TextButton;
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, RefreshCw, Search } from 'lucide-react';
|
||||
|
||||
import { type AddonData, type AddonsStatus } from '~main/types';
|
||||
import { api } from '~renderer/utils/api';
|
||||
import TextButton from '~renderer/components/styled/TextButton';
|
||||
import useScrollHint from '~renderer/utils/useScrollHint';
|
||||
|
||||
import DialogButton from '../styled/DialogButton';
|
||||
import IconSpinner from '../styled/IconSpinner';
|
||||
|
||||
import AddonList from './addons/AddonList';
|
||||
import { type Dependencies } from './addons/AddonListItem';
|
||||
import CustomAddonDialog from './addons/CustomAddonDialog';
|
||||
|
||||
const localeFilter = (l: AddonData[], filter: string) => {
|
||||
const seen = new Set<string>();
|
||||
const deduped = l.filter(a => {
|
||||
if (seen.has(a.folder)) return false;
|
||||
seen.add(a.folder);
|
||||
return true;
|
||||
});
|
||||
return deduped
|
||||
.filter(
|
||||
a =>
|
||||
a.folder.toLocaleLowerCase().indexOf(filter.toLocaleLowerCase()) !== -1
|
||||
)
|
||||
.sort((a, b) => a.folder.localeCompare(b.folder));
|
||||
};
|
||||
|
||||
const AddonsTab = () => {
|
||||
const [data, setData] = useState<AddonsStatus>({
|
||||
state: 'verifying',
|
||||
addons: {},
|
||||
available: []
|
||||
});
|
||||
api.addons.observe.useSubscription(undefined, { onData: setData });
|
||||
|
||||
const isUpdateAvailable = Object.values(data.addons).some(
|
||||
a => a.status === 'outOfDate' || a.status === 'downloading'
|
||||
);
|
||||
const dependencies: Dependencies = Object.fromEntries([
|
||||
...data.available.map(a => [a.folder, 'available']),
|
||||
...Object.values(data.addons).map(a => [
|
||||
a.folder,
|
||||
a.progress ?? (a.status === 'upToDate' ? 'installed' : 'available')
|
||||
])
|
||||
]);
|
||||
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const verify = api.addons.verify.useMutation();
|
||||
const update = api.addons.update.useMutation();
|
||||
|
||||
const scrollRef = useScrollHint<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<div className="tw-surface relative flex min-h-0 flex-grow flex-col gap-3">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="relative -m-4 -mb-3 flex flex-grow flex-col gap-3 overflow-y-auto overflow-x-hidden p-4 pb-3"
|
||||
>
|
||||
<AddonList
|
||||
title="Installed"
|
||||
addons={localeFilter(Object.values(data.addons), filter)}
|
||||
dependencies={dependencies}
|
||||
/>
|
||||
<AddonList
|
||||
title="Available"
|
||||
addons={localeFilter(
|
||||
data.available.filter(a => !(a.folder in data.addons)),
|
||||
filter
|
||||
)}
|
||||
dependencies={dependencies}
|
||||
/>
|
||||
</div>
|
||||
<hr />
|
||||
<div className="-mb-4 -mt-3 grid grid-cols-[1fr_1fr_1fr] items-center justify-between gap-2 py-2">
|
||||
<TextButton
|
||||
onClick={() => verify.mutateAsync()}
|
||||
className="-ml-2 text-blueGray"
|
||||
icon={RefreshCw}
|
||||
size={18}
|
||||
loading={data.state !== 'done'}
|
||||
>
|
||||
Check for updates
|
||||
</TextButton>
|
||||
<DialogButton
|
||||
clickAway
|
||||
dialog={close => <CustomAddonDialog close={close} />}
|
||||
>
|
||||
{open => (
|
||||
<TextButton
|
||||
icon={Plus}
|
||||
size={18}
|
||||
onClick={open}
|
||||
className="s1 text-pink"
|
||||
>
|
||||
Add custom git addon
|
||||
</TextButton>
|
||||
)}
|
||||
</DialogButton>
|
||||
{data.state === 'verifying' ? (
|
||||
<IconSpinner size={18} className="justify-self-end" />
|
||||
) : isUpdateAvailable ? (
|
||||
<TextButton
|
||||
onClick={() => update.mutateAsync({})}
|
||||
className="justify-self-end text-warmGreen"
|
||||
>
|
||||
Update all
|
||||
</TextButton>
|
||||
) : (
|
||||
<p className="s1 justify-self-end text-blueGray">
|
||||
Everything is up to date.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute right-3 top-3">
|
||||
<div className="flex items-center gap-1 border-b border-blueGray bg-darkGray/70 p-1 hocus:border-orange">
|
||||
<input
|
||||
className="cursor-text bg-inherit"
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
/>
|
||||
<Search size={18} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default AddonsTab;
|
||||
@@ -0,0 +1,7 @@
|
||||
const ComingSoonTab = () => (
|
||||
<div className="tw-surface flex flex-grow flex-col items-center justify-center gap-2">
|
||||
<p className="italic text-blueGray">Coming soon...</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ComingSoonTab;
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ExternalLink, AlertTriangle, Sparkles } from 'lucide-react';
|
||||
import cls from 'classnames';
|
||||
|
||||
import { api } from '~renderer/utils/api';
|
||||
import useScrollHint from '~renderer/utils/useScrollHint';
|
||||
import { type ModRowStatus, type ModsStatus } from '~main/types';
|
||||
|
||||
import TextButton from '../styled/TextButton';
|
||||
import CheckboxInput from '../form/CheckboxInput';
|
||||
import IconSpinner from '../styled/IconSpinner';
|
||||
|
||||
const RowState = ({ row }: { row: ModRowStatus }) => {
|
||||
if (row.state === 'downloading' || row.state === 'installing')
|
||||
return <IconSpinner className="text-blueGray" />;
|
||||
if (row.state === 'uninstalling')
|
||||
return <IconSpinner className="text-blueGray" />;
|
||||
if (row.state === 'error')
|
||||
return (
|
||||
<span title={row.error}>
|
||||
<AlertTriangle size={14} className="text-red" />
|
||||
</span>
|
||||
);
|
||||
if (row.installedVersion && row.installedVersion !== row.latestVersion && !row.ignoreUpdates)
|
||||
return <span className="s1 text-pink">update</span>;
|
||||
return null;
|
||||
};
|
||||
|
||||
const ModRow = ({ row }: { row: ModRowStatus }) => {
|
||||
const toggle = api.mods.toggle.useMutation();
|
||||
const setIgnore = api.mods.setIgnoreUpdates.useMutation();
|
||||
const openLink = api.general.openLink.useMutation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-baseline gap-2">
|
||||
{row.recommended && (
|
||||
<Sparkles size={12} className="shrink-0 text-warmGreen" />
|
||||
)}
|
||||
<span className={cls(row.recommended && 'text-warmGreen')}>{row.name}</span>
|
||||
<span className="s1 text-warmGreen">{row.latestVersion}</span>
|
||||
</div>
|
||||
<CheckboxInput
|
||||
value={row.enabled}
|
||||
setValue={v => toggle.mutate({ id: row.id, enabled: v })}
|
||||
className="justify-self-center"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="s1 text-blueGray">{row.description}</p>
|
||||
<TextButton
|
||||
icon={ExternalLink}
|
||||
size={14}
|
||||
title={row.repoUrl}
|
||||
onClick={() => openLink.mutateAsync(row.repoUrl)}
|
||||
className="!p-0 text-blueGray"
|
||||
/>
|
||||
<RowState row={row} />
|
||||
</div>
|
||||
<CheckboxInput
|
||||
value={row.ignoreUpdates}
|
||||
setValue={v => setIgnore.mutate({ id: row.id, ignore: v })}
|
||||
label={<span className="s1">Ignore updates</span>}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ModsTab = () => {
|
||||
const [status, setStatus] = useState<ModsStatus>();
|
||||
api.mods.observe.useSubscription(undefined, {
|
||||
onData: setStatus
|
||||
});
|
||||
|
||||
const list = api.mods.list.useQuery(undefined, {
|
||||
refetchOnMount: true
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!status && list.data) setStatus(list.data);
|
||||
}, [list.data, status]);
|
||||
|
||||
const apply = api.mods.applyAll.useMutation();
|
||||
|
||||
const scrollRef = useScrollHint<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<div className="tw-surface flex min-h-0 flex-grow flex-col gap-3">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h4 className="tw-color">CUSTOM MODS</h4>
|
||||
{status?.dirty && (
|
||||
<span className="s1 text-pink">unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="s1 text-blueGray">
|
||||
<span className="text-orange">⚠</span> Enabling custom mods may not provide
|
||||
any performance benefits or may even cause game crashes depending on your
|
||||
system. Please try disabling them if you experience any issues.
|
||||
</p>
|
||||
<hr />
|
||||
<div
|
||||
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"
|
||||
>
|
||||
{status?.mods.map(row => <ModRow key={row.id} row={row} />)}
|
||||
</div>
|
||||
<hr />
|
||||
<div className="-mb-4 -mt-3 flex items-center gap-2 py-2">
|
||||
<p className="s1 flex-grow text-blueGray">
|
||||
<span className="text-warmGreen">Highlighted</span> mods are recommended.
|
||||
</p>
|
||||
<TextButton
|
||||
type="button"
|
||||
loading={apply.isLoading || status?.state === 'busy'}
|
||||
onClick={() => apply.mutateAsync()}
|
||||
className={cls(status?.dirty && 'text-green')}
|
||||
>
|
||||
Apply
|
||||
</TextButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModsTab;
|
||||
@@ -0,0 +1,102 @@
|
||||
import { AlertTriangle, ExternalLink, RefreshCw } from 'lucide-react';
|
||||
|
||||
import { type NewsItem } from '~main/types';
|
||||
import { api } from '~renderer/utils/api';
|
||||
import useScrollHint from '~renderer/utils/useScrollHint';
|
||||
|
||||
import IconSpinner from '../styled/IconSpinner';
|
||||
import TextButton from '../styled/TextButton';
|
||||
|
||||
const formatDate = (raw: string) => {
|
||||
const d = new Date(raw);
|
||||
if (Number.isNaN(d.getTime())) return raw;
|
||||
return d.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const NewsEntry = ({ item }: { item: NewsItem }) => {
|
||||
const openLink = api.general.openLink.useMutation();
|
||||
return (
|
||||
<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">
|
||||
<h5 className="tw-color">{item.title}</h5>
|
||||
<span className="s1 shrink-0 text-blueGray">{formatDate(item.date)}</span>
|
||||
</div>
|
||||
{item.author && (
|
||||
<span className="s1 italic text-blueGray">by {item.author}</span>
|
||||
)}
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">{item.body}</p>
|
||||
{item.url && (
|
||||
<TextButton
|
||||
icon={ExternalLink}
|
||||
size={14}
|
||||
className="-ml-2 self-start text-pink"
|
||||
onClick={() => openLink.mutateAsync(item.url!)}
|
||||
>
|
||||
Read more
|
||||
</TextButton>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
const NewsTab = () => {
|
||||
const query = api.news.list.useQuery(undefined, {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1
|
||||
});
|
||||
const scrollRef = useScrollHint<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<div className="tw-surface flex min-h-0 flex-grow flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="tw-color">News</h4>
|
||||
<TextButton
|
||||
icon={RefreshCw}
|
||||
size={18}
|
||||
className="-mr-2 text-blueGray"
|
||||
loading={query.isFetching}
|
||||
onClick={() => query.refetch()}
|
||||
title="Refresh"
|
||||
/>
|
||||
</div>
|
||||
<hr />
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="relative -m-4 -mt-0 flex flex-grow flex-col gap-3 overflow-y-auto overflow-x-hidden p-4 pt-0"
|
||||
>
|
||||
{query.isLoading ? (
|
||||
<div className="flex flex-grow flex-col items-center justify-center gap-2">
|
||||
<IconSpinner className="text-blueGray" />
|
||||
<p className="italic text-blueGray">Loading news...</p>
|
||||
</div>
|
||||
) : query.isError ? (
|
||||
<div className="flex flex-grow flex-col items-center justify-center gap-3">
|
||||
<AlertTriangle size={32} className="text-red" />
|
||||
<p className="italic text-blueGray">Couldn't reach the news feed.</p>
|
||||
<TextButton
|
||||
icon={RefreshCw}
|
||||
size={18}
|
||||
className="text-pink"
|
||||
onClick={() => query.refetch()}
|
||||
>
|
||||
Try again
|
||||
</TextButton>
|
||||
</div>
|
||||
) : !query.data?.length ? (
|
||||
<div className="flex flex-grow flex-col items-center justify-center">
|
||||
<p className="italic text-blueGray">No news yet — check back later.</p>
|
||||
</div>
|
||||
) : (
|
||||
query.data.map(item => <NewsEntry key={item.id} item={item} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsTab;
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useForm, type UseFormReturn } from 'react-hook-form';
|
||||
import { useEffect } from 'react';
|
||||
import cls from 'classnames';
|
||||
|
||||
import { api } from '~renderer/utils/api';
|
||||
import { ConfigWtfSchema } from '~common/schemas';
|
||||
import zodResolver from '~renderer/utils/zodResolver';
|
||||
import useScrollHint from '~renderer/utils/useScrollHint';
|
||||
|
||||
import TextButton from '../styled/TextButton';
|
||||
import CheckboxInput from '../form/CheckboxInput';
|
||||
import NumberGrabInput from '../form/NumberGrabInput';
|
||||
|
||||
type ItemProps = {
|
||||
type?: 'checkbox' | 'number';
|
||||
id: keyof ConfigWtfSchema;
|
||||
label: string;
|
||||
recommended?: boolean;
|
||||
text: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
sensitivity?: number;
|
||||
form: UseFormReturn<ConfigWtfSchema>;
|
||||
};
|
||||
|
||||
const Item = ({
|
||||
type = 'checkbox',
|
||||
id,
|
||||
label,
|
||||
recommended,
|
||||
text,
|
||||
form,
|
||||
...props
|
||||
}: ItemProps) => {
|
||||
const { watch, setValue, register } = form;
|
||||
const setOpts = {
|
||||
shouldTouch: true,
|
||||
shouldDirty: true,
|
||||
shouldValidate: true
|
||||
} as const;
|
||||
const watched = type === 'checkbox' ? watch(id) : undefined;
|
||||
const registered = type === 'number' ? register(id) : undefined;
|
||||
return (
|
||||
<>
|
||||
<p className={cls({ 'text-warmGreen': recommended })}>{label}</p>
|
||||
{type === 'checkbox' && (
|
||||
<CheckboxInput
|
||||
value={!!watched}
|
||||
setValue={v => setValue(id, v, setOpts)}
|
||||
className="justify-self-center"
|
||||
/>
|
||||
)}
|
||||
{type === 'number' && registered && (
|
||||
<NumberGrabInput
|
||||
{...registered}
|
||||
{...props}
|
||||
setValue={v => setValue(id, v, setOpts)}
|
||||
/>
|
||||
)}
|
||||
<p className="s1 text-blueGray">{text}</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const TweaksTab = () => {
|
||||
const { data: pref } = api.preferences.get.useQuery();
|
||||
const setPref = api.preferences.set.useMutation();
|
||||
|
||||
const applyPatch = api.patcher.apply.useMutation();
|
||||
const verify = api.updater.verify.useMutation();
|
||||
|
||||
const form = useForm<ConfigWtfSchema>({
|
||||
defaultValues: pref?.config ?? {},
|
||||
resolver: zodResolver(ConfigWtfSchema)
|
||||
});
|
||||
const { handleSubmit, reset } = form;
|
||||
|
||||
useEffect(() => {
|
||||
pref && reset(pref.config);
|
||||
}, [reset, pref]);
|
||||
|
||||
const scrollRef = useScrollHint<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(async config => {
|
||||
await setPref.mutateAsync({ config });
|
||||
await applyPatch.mutateAsync();
|
||||
await verify.mutateAsync();
|
||||
|
||||
reset(config);
|
||||
})}
|
||||
className="tw-surface flex min-h-0 flex-grow flex-col gap-3"
|
||||
>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="relative -m-4 -mb-3 grid flex-grow grid-cols-[auto_auto_1fr] content-start items-center gap-x-3 gap-y-1 overflow-y-auto p-4 pb-3"
|
||||
>
|
||||
<Item
|
||||
form={form}
|
||||
id="alwaysAutoLoot"
|
||||
label="Always auto-loot"
|
||||
text="Reverses auto-loot behavior to always auto-loot and disable auto-with bound key."
|
||||
/>
|
||||
<Item
|
||||
form={form}
|
||||
id="largeAddress"
|
||||
label="Large Address Aware"
|
||||
text="Allows the game to use more than 2GB of memory."
|
||||
recommended
|
||||
/>
|
||||
<Item
|
||||
form={form}
|
||||
type="number"
|
||||
id="nameplateRange"
|
||||
label="Nameplate range"
|
||||
text="Increases distance at which nameplates are visible. [Vanilla: 20] [Classic: 41]"
|
||||
min={0}
|
||||
max={41}
|
||||
/>
|
||||
|
||||
<h4 className="tw-color col-span-3 mt-3">Camera</h4>
|
||||
<Item
|
||||
form={form}
|
||||
id="fieldOfView"
|
||||
label="Field of View"
|
||||
type="number"
|
||||
text="Recommended for widescreen window resolutions. [Vanilla: 90] [Tweaks: 110]"
|
||||
min={90}
|
||||
max={180}
|
||||
step={5}
|
||||
/>
|
||||
<Item
|
||||
form={form}
|
||||
id="farClip"
|
||||
label="Render distance"
|
||||
type="number"
|
||||
text="Increases maximum render distance. [Vanilla: 777] [Tweaks: 10000]"
|
||||
min={100}
|
||||
max={10000}
|
||||
sensitivity={3}
|
||||
/>
|
||||
<Item
|
||||
form={form}
|
||||
id="frillDistance"
|
||||
label="Ground clutter distance"
|
||||
type="number"
|
||||
text="Changes ground clutter render distance. [Vanilla: 70] [Tweaks: 300]"
|
||||
min={0}
|
||||
max={300}
|
||||
sensitivity={0.3}
|
||||
/>
|
||||
<Item
|
||||
form={form}
|
||||
id="cameraDistance"
|
||||
label="Camera distance"
|
||||
type="number"
|
||||
text="Increases maximum camera (zoom out) distance. [Vanilla: 50] [Max:100]"
|
||||
min={50}
|
||||
max={100}
|
||||
/>
|
||||
|
||||
<h4 className="tw-color col-span-3 mt-3">Sounds</h4>
|
||||
<Item
|
||||
form={form}
|
||||
id="soundInBackground"
|
||||
label="Background sounds"
|
||||
text="Allows game sounds to play while the game is minimized."
|
||||
recommended
|
||||
/>
|
||||
</div>
|
||||
<hr />
|
||||
<div className="-mb-4 -mt-3 flex items-center gap-2 py-2">
|
||||
<p className="s1 flex-grow text-blueGray">
|
||||
<span className="s1 text-warmGreen">Highlighted</span> options are
|
||||
recommended and enabled by default
|
||||
</p>
|
||||
<TextButton
|
||||
onClick={async () => {
|
||||
const config = ConfigWtfSchema.parse({});
|
||||
await setPref.mutateAsync({ config });
|
||||
reset(config);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</TextButton>
|
||||
<TextButton type="submit" className="text-green">
|
||||
Apply
|
||||
</TextButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default TweaksTab;
|
||||
@@ -0,0 +1,138 @@
|
||||
import { type ReactNode, type PropsWithChildren } from 'react';
|
||||
import {
|
||||
AlertOctagon,
|
||||
ExternalLink,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Dot,
|
||||
DownloadCloud
|
||||
} from 'lucide-react';
|
||||
|
||||
import { type AddonData } from '~main/types';
|
||||
import { api } from '~renderer/utils/api';
|
||||
import TextButton from '~renderer/components/styled/TextButton';
|
||||
import { ColoredText } from '~renderer/components/styled/ColoredText';
|
||||
import useScrollHint from '~renderer/utils/useScrollHint';
|
||||
import IconSpinner from '~renderer/components/styled/IconSpinner';
|
||||
import CloseButton from '~renderer/components/styled/CloseButton';
|
||||
|
||||
import { type LocalDependencies } from './AddonListItem';
|
||||
|
||||
const AddonDetailItem = ({
|
||||
name,
|
||||
children
|
||||
}: PropsWithChildren<{ name: string }>) =>
|
||||
children ? (
|
||||
<div className="s1 pl-4 -indent-4 text-blueGray">
|
||||
{name}:{' '}
|
||||
{typeof children === 'string' ? (
|
||||
<ColoredText className="inline">{children}</ColoredText>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
type Props = AddonData & {
|
||||
close: () => void;
|
||||
warnings: { full: ReactNode; short: ReactNode }[];
|
||||
dependencies: LocalDependencies;
|
||||
};
|
||||
|
||||
const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => {
|
||||
const openLink = api.general.openLink.useMutation();
|
||||
const update = api.addons.update.useMutation();
|
||||
|
||||
const scrollRef = useScrollHint<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="tw-surface flex max-h-[calc(100vh_-_256px)] w-full max-w-md flex-col gap-3 overflow-y-auto"
|
||||
>
|
||||
<CloseButton close={close} />
|
||||
<ColoredText className="text-2xl">
|
||||
{addon.toc?.Title ?? addon.folder}
|
||||
</ColoredText>
|
||||
<hr />
|
||||
{addon.error && (
|
||||
<p className="s1 text-red">
|
||||
<AlertOctagon size={14} className="inline text-inherit" />{' '}
|
||||
{addon.error}
|
||||
</p>
|
||||
)}
|
||||
{warnings.map((w, i) => (
|
||||
<p key={i} className="s1 text-yellow">
|
||||
<AlertTriangle size={14} className="inline text-inherit" /> {w.full}
|
||||
</p>
|
||||
))}
|
||||
{(addon.toc?.Notes || addon.description) && (
|
||||
<ColoredText>{addon.toc?.Notes ?? addon.description ?? ''}</ColoredText>
|
||||
)}
|
||||
<div>
|
||||
<AddonDetailItem name="Source">
|
||||
{addon.git && (
|
||||
<TextButton
|
||||
onClick={() => openLink.mutateAsync(addon.git)}
|
||||
className="s1 -m-2 !inline"
|
||||
>
|
||||
Open on GitHub
|
||||
<ExternalLink size={12} className="ml-1 inline" />
|
||||
</TextButton>
|
||||
)}
|
||||
</AddonDetailItem>
|
||||
{addon.toc && (
|
||||
<>
|
||||
<AddonDetailItem name="Contributions">
|
||||
{addon.toc.Author}
|
||||
</AddonDetailItem>
|
||||
<AddonDetailItem name="Addon version">
|
||||
{addon.toc.Version}
|
||||
</AddonDetailItem>
|
||||
<AddonDetailItem name="Dependencies">
|
||||
{!!dependencies.length && (
|
||||
<ul className="pl-2">
|
||||
{dependencies.map(({ name, optional, status }) => (
|
||||
<li key={name}>
|
||||
{status === 'installed' ? (
|
||||
<Check size={16} className="inline text-darkGreen" />
|
||||
) : status === 'available' ? (
|
||||
<TextButton
|
||||
title="Download"
|
||||
icon={DownloadCloud}
|
||||
size={16}
|
||||
onClick={() =>
|
||||
update.mutateAsync({ toUpdate: [name] })
|
||||
}
|
||||
className="-m-2 !inline text-warmGreen"
|
||||
/>
|
||||
) : status === 'missing' ? (
|
||||
optional ? (
|
||||
<Dot size={16} className="inline text-blueGray" />
|
||||
) : (
|
||||
<X size={16} className="inline text-red" />
|
||||
)
|
||||
) : (
|
||||
<IconSpinner size={16} className="inline" />
|
||||
)}
|
||||
<p className="inline"> {name} </p>
|
||||
{!['installed', 'available', 'missing'].includes(
|
||||
status
|
||||
) ? (
|
||||
<p className="s1 inline text-blueGray">{status}</p>
|
||||
) : optional ? (
|
||||
<p className="s1 inline text-blueGray">(optional)</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</AddonDetailItem>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default AddonDetail;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
import log from 'electron-log/renderer';
|
||||
|
||||
type Props = { children: ReactNode; folder: string; row: number };
|
||||
type State = { hasError: boolean; message?: string };
|
||||
|
||||
class AddonItemErrorBoundary extends Component<Props, State> {
|
||||
state: State = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, message: error.message };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
log.error(
|
||||
`[AddonListItem] crash row=${this.props.row} folder=${this.props.folder}:`,
|
||||
error,
|
||||
info
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.hasError) return this.props.children;
|
||||
return (
|
||||
<div
|
||||
className="contents"
|
||||
style={{ gridRow: this.props.row + 1 }}
|
||||
>
|
||||
<div style={{ gridRow: this.props.row + 1, gridColumn: '1/5' }} className="-mx-4 px-4 py-1 text-red s1">
|
||||
Failed to render "{this.props.folder}": {this.state.message ?? 'unknown error'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AddonItemErrorBoundary;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import cls from 'classnames';
|
||||
|
||||
import { type AddonData } from '~main/types';
|
||||
|
||||
import AddonListItem, { type Dependencies } from './AddonListItem';
|
||||
import AddonItemErrorBoundary from './AddonItemErrorBoundary';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
addons: AddonData[];
|
||||
dependencies: Dependencies;
|
||||
};
|
||||
|
||||
const AddonList = ({ title, addons, dependencies }: Props) => {
|
||||
const [open, setOpen] = useState(true);
|
||||
if (!addons.length) return null;
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="mb-2 flex cursor-pointer items-center gap-1 border-0 bg-transparent p-0"
|
||||
>
|
||||
{open ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||
<h4 className="tw-color">{title}</h4>
|
||||
</button>
|
||||
<div
|
||||
className={cls(
|
||||
'grid grid-cols-[auto_auto_1fr_auto] items-center gap-x-3 gap-y-1',
|
||||
!open && 'hidden'
|
||||
)}
|
||||
>
|
||||
{addons.map((addon, i) => {
|
||||
const { ref: gitRef, ...rest } = addon;
|
||||
return (
|
||||
<AddonItemErrorBoundary
|
||||
key={`${addon.folder}#${i}`}
|
||||
folder={addon.folder}
|
||||
row={i}
|
||||
>
|
||||
<AddonListItem
|
||||
row={i}
|
||||
dependencies={dependencies}
|
||||
gitRef={gitRef}
|
||||
{...rest}
|
||||
/>
|
||||
</AddonItemErrorBoundary>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default AddonList;
|
||||
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
AlertOctagon,
|
||||
AlertTriangle,
|
||||
DownloadCloud,
|
||||
Github,
|
||||
HelpCircle,
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
import cls from 'classnames';
|
||||
|
||||
import { type AddonData } from '~main/types';
|
||||
import { api } from '~renderer/utils/api';
|
||||
import TextButton from '~renderer/components/styled/TextButton';
|
||||
import { ColoredText } from '~renderer/components/styled/ColoredText';
|
||||
import IconSpinner from '~renderer/components/styled/IconSpinner';
|
||||
import DialogButton from '~renderer/components/styled/DialogButton';
|
||||
import { isNotUndef } from '~common/utils';
|
||||
import CloseButton from '~renderer/components/styled/CloseButton';
|
||||
|
||||
import AddonDetail from './AddonDetail';
|
||||
|
||||
export type Dependencies = {
|
||||
[folder: string]: 'installed' | 'available' | string;
|
||||
};
|
||||
|
||||
export type LocalDependencies = {
|
||||
name: string;
|
||||
optional: boolean;
|
||||
status: 'installed' | 'available' | 'missing' | string;
|
||||
}[];
|
||||
|
||||
type Props = Omit<AddonData, 'ref'> & {
|
||||
row: number;
|
||||
dependencies: Dependencies;
|
||||
gitRef?: string;
|
||||
};
|
||||
|
||||
const toRepoUrl = (git?: string) =>
|
||||
git ? git.replace(/\.git$/, '') : undefined;
|
||||
|
||||
const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
|
||||
const update = api.addons.update.useMutation();
|
||||
const remove = api.addons.remove.useMutation();
|
||||
const openLink = api.general.openLink.useMutation();
|
||||
const repoUrl = toRepoUrl(addon.git);
|
||||
|
||||
const localDependencies: LocalDependencies = [
|
||||
...(addon.toc?.Dependencies?.split(', ')?.map(d => [d, false] as const) ??
|
||||
[]),
|
||||
...(addon.toc?.OptionalDeps?.split(', ')?.map(d => [d, true] as const) ??
|
||||
[])
|
||||
].map<LocalDependencies[number]>(([d, optional]) => ({
|
||||
name: d,
|
||||
optional,
|
||||
status: dependencies[d] ?? 'missing'
|
||||
}));
|
||||
|
||||
const warnings = [
|
||||
addon.toc && addon.toc?.Interface !== '11200'
|
||||
? {
|
||||
full: `This addon seems to be made for different game version (${addon.toc?.Interface}) and it may not function correctly`,
|
||||
short: 'Incorrect version'
|
||||
}
|
||||
: undefined,
|
||||
localDependencies.some(d => d.status !== 'installed' && !d.optional)
|
||||
? {
|
||||
full: `This addon has missing dependencies: ${localDependencies
|
||||
.filter(d => d.status !== 'installed' && !d.optional)
|
||||
.map(d => d.name)
|
||||
.join(', ')}`,
|
||||
short: 'Missing dependencies'
|
||||
}
|
||||
: undefined
|
||||
].filter(isNotUndef);
|
||||
|
||||
return (
|
||||
<div className="contents hover-row:bg-purple/30">
|
||||
<div
|
||||
className="-mx-4 h-full w-[200%]"
|
||||
style={{ gridRow: row + 1, gridColumn: '1/4' }}
|
||||
/>
|
||||
{addon.status === 'fetching' ? (
|
||||
<IconSpinner
|
||||
className="text-blueGray"
|
||||
size={18}
|
||||
style={{ gridRow: row + 1, gridColumn: 1 }}
|
||||
/>
|
||||
) : (
|
||||
<DialogButton
|
||||
clickAway
|
||||
dialog={close => (
|
||||
<AddonDetail
|
||||
close={close}
|
||||
warnings={warnings}
|
||||
dependencies={localDependencies}
|
||||
{...addon}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{open => (
|
||||
<TextButton
|
||||
icon={
|
||||
addon.status === 'invalid'
|
||||
? AlertOctagon
|
||||
: warnings.length
|
||||
? AlertTriangle
|
||||
: HelpCircle
|
||||
}
|
||||
onClick={open}
|
||||
title="Details"
|
||||
size={18}
|
||||
className={cls(
|
||||
'-mx-2',
|
||||
addon.status === 'invalid'
|
||||
? 'text-red'
|
||||
: warnings.length
|
||||
? 'text-yellow'
|
||||
: 'text-blueGray'
|
||||
)}
|
||||
style={{ gridRow: row + 1, gridColumn: 1 }}
|
||||
/>
|
||||
)}
|
||||
</DialogButton>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="-ml-2 flex items-center gap-1 whitespace-nowrap"
|
||||
style={{ gridRow: row + 1, gridColumn: 2 }}
|
||||
>
|
||||
<ColoredText>{addon.toc?.Title ?? addon.folder}</ColoredText>
|
||||
{repoUrl && (
|
||||
<TextButton
|
||||
icon={Github}
|
||||
size={14}
|
||||
title={`Open ${repoUrl} on GitHub`}
|
||||
onClick={() => openLink.mutateAsync(repoUrl)}
|
||||
className="!p-1 text-blueGray/60 hocus:text-pink"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ColoredText
|
||||
className="s1 py-1 text-blueGray"
|
||||
style={{ gridRow: row + 1, gridColumn: 3 }}
|
||||
>
|
||||
{addon.toc?.Notes ?? addon.description ?? ''}
|
||||
</ColoredText>
|
||||
|
||||
<div
|
||||
className="-m-2 flex items-center justify-end gap-2"
|
||||
style={{ gridRow: row + 1, gridColumn: 4 }}
|
||||
>
|
||||
{addon.status === 'downloading' ? (
|
||||
<>
|
||||
<p className="s1 text-blueGray">{addon.progress}</p>
|
||||
<IconSpinner size={18} className="text-blueGray" />
|
||||
</>
|
||||
) : addon.status === 'invalid' ? (
|
||||
<p className="s1 text-red">{addon.error}</p>
|
||||
) : warnings.length ? (
|
||||
<p className="s1 text-yellow">{warnings[0].short}</p>
|
||||
) : (
|
||||
<p className="s1 text-blueGray/50">
|
||||
{addon.status === 'upToDate'
|
||||
? 'Up to date'
|
||||
: !addon.git
|
||||
? 'Not versioned'
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
{addon.status === 'outOfDate' && (
|
||||
<TextButton
|
||||
onClick={() => update.mutateAsync({ toUpdate: [addon.folder] })}
|
||||
className="s1 -mx-2 justify-self-end"
|
||||
>
|
||||
Update
|
||||
</TextButton>
|
||||
)}
|
||||
{addon.status === 'available' ? (
|
||||
<TextButton
|
||||
// TODO: With dependencies checkbox
|
||||
onClick={() => update.mutateAsync({ toUpdate: [addon.folder] })}
|
||||
className="text-warmGreen"
|
||||
icon={DownloadCloud}
|
||||
size={18}
|
||||
title="Download"
|
||||
/>
|
||||
) : (
|
||||
<DialogButton
|
||||
clickAway
|
||||
dialog={close => (
|
||||
<div className="tw-dialog">
|
||||
<CloseButton close={close} />
|
||||
<h4 className="tw-color">Are you sure?</h4>
|
||||
<hr />
|
||||
<p className="text-blueGray">
|
||||
Are you sure you want to delete <span>{addon.folder}</span>{' '}
|
||||
addon?
|
||||
</p>
|
||||
<p className="text-blueGray">
|
||||
This will delete all files in the addon folder.
|
||||
</p>
|
||||
<TextButton
|
||||
icon={Trash2}
|
||||
onClick={async () => {
|
||||
await remove.mutateAsync({ toDelete: [addon.folder] });
|
||||
close();
|
||||
}}
|
||||
disabled={remove.isLoading}
|
||||
className="self-end text-red"
|
||||
>
|
||||
Delete
|
||||
</TextButton>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{open => (
|
||||
<TextButton
|
||||
onClick={open}
|
||||
className="text-red/50"
|
||||
icon={Trash2}
|
||||
size={18}
|
||||
title="Remove"
|
||||
/>
|
||||
)}
|
||||
</DialogButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddonListItem;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import CloseButton from '~renderer/components/styled/CloseButton';
|
||||
import IconSpinner from '~renderer/components/styled/IconSpinner';
|
||||
import TextButton from '~renderer/components/styled/TextButton';
|
||||
import { api } from '~renderer/utils/api';
|
||||
|
||||
const useDebounced = (value: string, delay: number) => {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
const CustomAddonDialog = ({ close }: { close: () => void }) => {
|
||||
const [url, setUrl] = useState('');
|
||||
const debouncedUrl = useDebounced(url, 500);
|
||||
const response = api.addons.checkGitUrl.useQuery(debouncedUrl, {
|
||||
enabled: !!debouncedUrl
|
||||
});
|
||||
const update = api.addons.install.useMutation();
|
||||
|
||||
return (
|
||||
<div className="tw-dialog">
|
||||
<CloseButton close={close} />
|
||||
<h3 className="tw-color">Install addon</h3>
|
||||
<hr />
|
||||
{response.data ? (
|
||||
<img src={response.data?.preview} alt="Preview" className="w-full" />
|
||||
) : (
|
||||
<div className="flex h-[191px] w-full items-center justify-center bg-darkPurple">
|
||||
{response.isFetching && <IconSpinner />}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1 border-b border-blueGray bg-darkGray/70 p-1 hocus:border-orange">
|
||||
<input
|
||||
className="w-full cursor-text bg-inherit"
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
/>
|
||||
{response.isFetching ? (
|
||||
<IconSpinner size={18} />
|
||||
) : response.data ? (
|
||||
<Check size={18} />
|
||||
) : (
|
||||
<X size={18} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<p className="s1 text-blueGray">
|
||||
{response.data
|
||||
? 'Ready to install'
|
||||
: 'Not a valid git repository URL'}
|
||||
</p>
|
||||
<TextButton
|
||||
onClick={() => {
|
||||
if (!response.data) return;
|
||||
update.mutateAsync(response.data);
|
||||
close();
|
||||
setUrl('');
|
||||
}}
|
||||
className={response.data ? 'text-warmGreen' : 'text-blueGray'}
|
||||
disabled={!response.data || response.isLoading}
|
||||
>
|
||||
Install
|
||||
</TextButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomAddonDialog;
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly MAIN_VITE_SERVER_URL: string;
|
||||
readonly MAIN_VITE_CLIENT_VERSION: string;
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: 'fontin';
|
||||
src: url('./assets/FontinSans-Regular.otf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'din';
|
||||
src: url('./assets/DINPro-Regular.otf');
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
*:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@apply w-2;
|
||||
@apply h-2;
|
||||
|
||||
&-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&-thumb {
|
||||
display: none;
|
||||
@apply bg-blueGray/40;
|
||||
|
||||
:hover& {
|
||||
display: initial;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@apply bg-blueGray;
|
||||
}
|
||||
}
|
||||
|
||||
&-corner {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.gutter {
|
||||
background: transparent;
|
||||
@apply transition-colors;
|
||||
|
||||
&:hover {
|
||||
@apply bg-orange/40;
|
||||
}
|
||||
|
||||
&&-horizontal {
|
||||
@apply -mx-1;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
&&-vertical {
|
||||
@apply -my-1;
|
||||
cursor: row-resize;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
:not(svg, svg *) {
|
||||
color: white;
|
||||
@apply font-din;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-size: 16px;
|
||||
line-height: 26px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
h1,
|
||||
.h1 {
|
||||
@apply font-fontin;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-size: 78px;
|
||||
line-height: 76px;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h2,
|
||||
.h2 {
|
||||
@apply font-fontin;
|
||||
font-weight: 700;
|
||||
font-size: 54px;
|
||||
line-height: 58px;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h3,
|
||||
.h3 {
|
||||
@apply font-fontin;
|
||||
font-weight: 400;
|
||||
font-size: 32px;
|
||||
line-height: 38px;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h4,
|
||||
.h4 {
|
||||
@apply font-fontin;
|
||||
font-weight: 400;
|
||||
font-size: 20px;
|
||||
line-height: 26px;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.l1 {
|
||||
font-size: 18px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.l2 {
|
||||
font-size: 24px;
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
.s1 {
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.tw-color {
|
||||
display: inline;
|
||||
@apply bg-gradient-to-t from-yellow to-pink;
|
||||
-webkit-box-decoration-break: clone;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.tw-surface {
|
||||
position: relative;
|
||||
@apply border border-blueGray/20 bg-darkGray/70 p-4;
|
||||
box-shadow: rgb(0 0 0 / 45%) 0px 25px 20px -20px;
|
||||
|
||||
& hr {
|
||||
@apply -mx-4 text-blueGray/20;
|
||||
}
|
||||
}
|
||||
|
||||
.tw-dialog {
|
||||
position: relative;
|
||||
@apply border border-blueGray/20 bg-darkGray/70 p-3;
|
||||
box-shadow: rgb(0 0 0 / 45%) 0px 25px 20px -20px;
|
||||
@apply relative flex w-3/5 flex-col gap-2;
|
||||
|
||||
& hr {
|
||||
@apply -mx-3 text-blueGray/20;
|
||||
}
|
||||
}
|
||||
|
||||
.tw-hocus {
|
||||
@apply hocus:text-orange hocus:drop-shadow-[0px_0px_15px_white];
|
||||
}
|
||||
|
||||
.tw-input {
|
||||
@apply rounded-[1px];
|
||||
@apply border border-gray/40 bg-darkerGray;
|
||||
@apply p-2;
|
||||
@apply placeholder:text-gray;
|
||||
@apply focus:border-blueGray;
|
||||
|
||||
&-underline {
|
||||
@apply tw-input;
|
||||
@apply border-0;
|
||||
@apply border-b;
|
||||
@apply bg-[transparent];
|
||||
}
|
||||
}
|
||||
|
||||
.tw-button {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
@apply py-2;
|
||||
@apply px-4;
|
||||
@apply bg-darkGray;
|
||||
@apply border;
|
||||
@apply rounded-[1px];
|
||||
|
||||
&:not(&-primary) {
|
||||
background: linear-gradient(#f1c22d40, #ff775740);
|
||||
@apply border-darkBrown;
|
||||
|
||||
& > span {
|
||||
background: linear-gradient(#f1c22d, #ff7757);
|
||||
-webkit-background-clip: text;
|
||||
}
|
||||
|
||||
& svg {
|
||||
stroke: #fb9f3a;
|
||||
}
|
||||
|
||||
&::before {
|
||||
@apply bg-orange;
|
||||
}
|
||||
}
|
||||
|
||||
&&-primary {
|
||||
@apply bg-darkGreen/30;
|
||||
@apply border-[#C8FF0022];
|
||||
|
||||
& > span {
|
||||
background: linear-gradient(#f7ff8a, #8dd958);
|
||||
-webkit-background-clip: text;
|
||||
}
|
||||
|
||||
& svg {
|
||||
stroke: #ccf068;
|
||||
}
|
||||
|
||||
&::before {
|
||||
@apply bg-warmGreen;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
&::after {
|
||||
@apply bg-warmGreen;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& > span {
|
||||
@apply flex items-center justify-center;
|
||||
@apply gap-2;
|
||||
@apply font-fontin;
|
||||
@apply font-bold;
|
||||
@apply uppercase;
|
||||
font-size: 20px;
|
||||
line-height: 30px;
|
||||
letter-spacing: 2px;
|
||||
-webkit-box-decoration-break: clone;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
& svg {
|
||||
font-size: 10px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
& > span {
|
||||
background: white;
|
||||
-webkit-background-clip: text;
|
||||
}
|
||||
|
||||
& svg {
|
||||
stroke: white;
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: 9px;
|
||||
bottom: 22px;
|
||||
left: 22px;
|
||||
right: 22px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
bottom: -46px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
border-radius: 50%;
|
||||
@apply bg-orange;
|
||||
opacity: 0.75;
|
||||
mix-blend-mode: hard-light;
|
||||
filter: blur(25px);
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
bottom: 5px;
|
||||
left: 18px;
|
||||
right: 18px;
|
||||
border-radius: 50%;
|
||||
opacity: 0.75;
|
||||
mix-blend-mode: hard-light;
|
||||
filter: blur(25px);
|
||||
}
|
||||
}
|
||||
|
||||
.tw-loading {
|
||||
@apply absolute inset-0 transition-all;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 119, 87, 0) 0%,
|
||||
#f89c42 30%,
|
||||
#f1c22d 50%,
|
||||
#f89c42 70%,
|
||||
rgba(255, 119, 87, 0) 100%
|
||||
);
|
||||
transition-duration: 300ms;
|
||||
|
||||
&-wrapper {
|
||||
@apply relative w-full before:absolute;
|
||||
height: 6px;
|
||||
&::before {
|
||||
@apply inset-0 opacity-20;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(146, 147, 145, 0) 0%,
|
||||
#929391 13%,
|
||||
#929391 87%,
|
||||
rgba(146, 147, 145, 0) 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&-unknown {
|
||||
@apply animate-progress opacity-20;
|
||||
background-image: linear-gradient(
|
||||
-45deg,
|
||||
#929391,
|
||||
#929391 33%,
|
||||
transparent 33%,
|
||||
transparent 66%,
|
||||
#929391 66%,
|
||||
#929391
|
||||
);
|
||||
background-size: 1rem 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Octo Launcher</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,65 @@
|
||||
import { StrictMode } from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { loggerLink } from '@trpc/client';
|
||||
import { ipcLink } from 'electron-trpc/renderer';
|
||||
import superjson from 'superjson';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { getQueryKey } from '@trpc/react-query';
|
||||
import log from 'electron-log/renderer';
|
||||
|
||||
import { api } from './utils/api';
|
||||
import App from './App';
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
|
||||
import './index.css';
|
||||
|
||||
window.addEventListener('error', e => {
|
||||
log.error('Uncaught error:', e.error ?? e.message, e.filename, e.lineno);
|
||||
});
|
||||
window.addEventListener('unhandledrejection', e => {
|
||||
log.error('Unhandled promise rejection:', e.reason);
|
||||
});
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
queryClient.setMutationDefaults(getQueryKey(api.preferences.set), {
|
||||
onSuccess: v =>
|
||||
queryClient.setQueryData(
|
||||
getQueryKey(api.preferences.get, undefined, 'query'),
|
||||
v
|
||||
)
|
||||
});
|
||||
|
||||
const trpcClient = api.createClient({
|
||||
transformer: superjson,
|
||||
links: [
|
||||
loggerLink({
|
||||
enabled: opts =>
|
||||
process.env.NODE_ENV === 'development' ||
|
||||
(opts.direction === 'down' && opts.result instanceof Error)
|
||||
}),
|
||||
ipcLink()
|
||||
]
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<api.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
{import.meta.env.DEV && <ReactQueryDevtools />}
|
||||
</QueryClientProvider>
|
||||
</api.Provider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createTRPCReact } from '@trpc/react-query';
|
||||
import { type inferRouterInputs, type inferRouterOutputs } from '@trpc/server';
|
||||
|
||||
import { type AppRouter } from '~main/types';
|
||||
|
||||
export const api = createTRPCReact<AppRouter>();
|
||||
|
||||
export type RouterInputs = inferRouterInputs<AppRouter>;
|
||||
export type RouterOutputs = inferRouterOutputs<AppRouter>;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const allowedElements = ['INPUT', 'TEXTAREA'];
|
||||
|
||||
const usePreventDefaultEvents = () => {
|
||||
useEffect(() => {
|
||||
const disableKeyboardEvents = (e: KeyboardEvent) => {
|
||||
if (allowedElements.includes((e.target as HTMLElement).tagName)) return;
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const disableFocus = (e: FocusEvent) => {
|
||||
if (allowedElements.includes((e.target as HTMLElement).tagName)) return;
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', disableKeyboardEvents, true);
|
||||
window.addEventListener('keyup', disableKeyboardEvents, true);
|
||||
window.addEventListener('focusin', disableFocus, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', disableKeyboardEvents, true);
|
||||
window.removeEventListener('keyup', disableKeyboardEvents, true);
|
||||
window.removeEventListener('focusin', disableFocus, true);
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
export default usePreventDefaultEvents;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useLayoutEffect, useRef } from 'react';
|
||||
|
||||
const FADE_PX = 24;
|
||||
|
||||
const setScrollHint = (tar: HTMLElement) => {
|
||||
const top = tar.scrollTop > 0 && tar.scrollHeight !== tar.clientHeight;
|
||||
const bottom = tar.scrollTop < tar.scrollHeight - tar.offsetHeight;
|
||||
|
||||
const topStr = top ? 'true' : 'false';
|
||||
const bottomStr = bottom ? 'true' : 'false';
|
||||
if (topStr === tar.dataset.top && bottomStr === tar.dataset.bottom) return;
|
||||
|
||||
tar.dataset.top = topStr;
|
||||
tar.dataset.bottom = bottomStr;
|
||||
|
||||
if (!top && !bottom) {
|
||||
tar.style.webkitMaskImage = '';
|
||||
return;
|
||||
}
|
||||
|
||||
tar.style.webkitMaskImage = `linear-gradient(${
|
||||
top ? `transparent, black calc(${FADE_PX}px)` : ''
|
||||
}${top && bottom ? ', ' : ''}${
|
||||
bottom ? `black calc(100% - ${FADE_PX}px), transparent` : ''
|
||||
})`;
|
||||
};
|
||||
|
||||
const useScrollHint = <T extends HTMLElement>() => {
|
||||
const ref = useRef<T>(null);
|
||||
useLayoutEffect(() => {
|
||||
const current = ref.current;
|
||||
if (!current) return;
|
||||
|
||||
let scheduled = false;
|
||||
const schedule = () => {
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
scheduled = false;
|
||||
setScrollHint(current);
|
||||
});
|
||||
};
|
||||
|
||||
schedule();
|
||||
|
||||
const observer = new ResizeObserver(schedule);
|
||||
observer.observe(current);
|
||||
current.addEventListener('scroll', schedule, { passive: true });
|
||||
window.addEventListener('resize', schedule);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
current?.removeEventListener('scroll', schedule);
|
||||
window.removeEventListener('resize', schedule);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return ref;
|
||||
};
|
||||
|
||||
export default useScrollHint;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type Resolver, type FieldValues } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
import { zodResolver as resolver } from '@hookform/resolvers/zod';
|
||||
|
||||
const zodResolver = <In extends FieldValues, Out extends FieldValues>(
|
||||
schema: z.ZodType<In, z.ZodTypeDef, Out>
|
||||
): Resolver<z.infer<z.ZodType<In, z.ZodTypeDef, Out>>> => resolver(schema);
|
||||
|
||||
export default zodResolver;
|
||||
Reference in New Issue
Block a user