Sync launcher: stop phantom update prompt, forum News panel, hardware-aware render distance
Build check / build (push) Has been cancelled

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
OctoWoW
2026-07-04 16:02:46 -07:00
parent 16e442ea0f
commit fbad749f0c
35 changed files with 763 additions and 56 deletions
+6
View File
@@ -2,3 +2,9 @@
hooks/* text eol=lf hooks/* text eol=lf
*.sh text eol=lf *.sh text eol=lf
*.py text eol=lf *.py text eol=lf
.gitea/** export-ignore
.env.ptr export-ignore
electron-builder.ptr.yml export-ignore
server/Dockerfile export-ignore
scripts/publish-oss.sh export-ignore
+1 -1
View File
@@ -1,6 +1,6 @@
node_modules/ node_modules/
dist/ dist*/
out/ out/
release/ release/
+9 -6
View File
@@ -1,18 +1,23 @@
productName: OctoLauncher productName: OctoLauncher
appId: st.octowow.launcher
directories: directories:
buildResources: build buildResources: build
output: dist output: distprod
files: files:
- '!**/.vscode/*' - '!**/.vscode/*'
- '!src/*' - '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}' - '!electron.vite.config.{js,ts,mjs,cjs}'
# Strip every project-root .md file from the asar bundle
- '!*.md' - '!*.md'
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,.prettierrc.cjs,dev-app-update.yml}' - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,.prettierrc.cjs,dev-app-update.yml}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
- '!{tailwind.config.ts,postcss.config.cjs}' - '!{tailwind.config.ts,postcss.config.cjs}'
- '!{dist,dist-new,dist-test,out/main/chunks}/**' - '!dist*/**'
- '!out/main/chunks/**'
- '!{.gitea,.github}/**'
- '!Tools/**'
- '!**/builder-debug.yml'
- '!**/electron-builder.*'
- '!.launcher/**' - '!.launcher/**'
- '!WTF/**' - '!WTF/**'
- '!server/**' - '!server/**'
@@ -20,10 +25,8 @@ files:
- '!**/node_modules/**/{__tests__,test,tests,docs,example,examples,demo,demos,benchmark,benchmarks}/**' - '!**/node_modules/**/{__tests__,test,tests,docs,example,examples,demo,demos,benchmark,benchmarks}/**'
- '!**/node_modules/**/*.{tsx,map,markdown}' - '!**/node_modules/**/*.{tsx,map,markdown}'
- '!**/node_modules/**/build/Release/obj/**' - '!**/node_modules/**/build/Release/obj/**'
- '!**/node_modules/**/build/Release/{*.iobj,*.ipdb,*.recipe}' - '!**/node_modules/**/build/Release/{*.iobj,*.ipdb,*.recipe,*.exp,*.lib,*.pdb,*.obj}'
- '!**/node_modules/**/*.{vcxproj,vcxproj.filters}' - '!**/node_modules/**/*.{vcxproj,vcxproj.filters}'
asarUnpack:
- resources/**
npmRebuild: false npmRebuild: false
electronLanguages: en electronLanguages: en
win: win:
+19 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "octo-launcher", "name": "octo-launcher",
"version": "1.0.18", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "octo-launcher", "name": "octo-launcher",
"version": "1.0.18", "version": "1.2.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^1.0.3", "@electron-toolkit/preload": "^1.0.3",
@@ -20,6 +20,7 @@
"adm-zip": "^0.5.17", "adm-zip": "^0.5.17",
"classnames": "^2.3.2", "classnames": "^2.3.2",
"dll-inject": "^0.0.3", "dll-inject": "^0.0.3",
"dompurify": "^3.4.11",
"electron-log": "^5.1.5", "electron-log": "^5.1.5",
"electron-trpc": "^0.5.2", "electron-trpc": "^0.5.2",
"electron-updater": "^5.3.0", "electron-updater": "^5.3.0",
@@ -1785,6 +1786,13 @@
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.5.tgz", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.5.tgz",
"integrity": "sha512-+d+WYC1BxJ6yVOgUgzK8gWvp5qF8ssV5r4nsDcZWKRWcDQLQ619tvWAxJQYGgBrO1MnLJC7a5GtiYsAoQ47dJg==" "integrity": "sha512-+d+WYC1BxJ6yVOgUgzK8gWvp5qF8ssV5r4nsDcZWKRWcDQLQ619tvWAxJQYGgBrO1MnLJC7a5GtiYsAoQ47dJg=="
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/verror": { "node_modules/@types/verror": {
"version": "1.10.9", "version": "1.10.9",
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.9.tgz", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.9.tgz",
@@ -3425,6 +3433,15 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/dompurify": {
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "9.0.2", "version": "9.0.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz",
+7 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "octo-launcher", "name": "octo-launcher",
"version": "1.1.1", "version": "1.2.0",
"description": "An Electron application for launching and updating the OctoWoW client", "description": "An Electron application for launching and updating the OctoWoW client",
"author": "OctoWoW", "author": "OctoWoW",
"copyright": "Copyright © 2026 OctoWoW", "copyright": "Copyright © 2026 OctoWoW",
@@ -9,11 +9,14 @@
"start": "electron-vite preview", "start": "electron-vite preview",
"dev": "electron-vite dev", "dev": "electron-vite dev",
"server": "cd server && npm run dev", "server": "cd server && npm run dev",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps && node scripts/scrub-native-paths.cjs",
"build": "electron-vite build", "build": "electron-vite build",
"build:test": "electron-vite build --mode test", "build:test": "electron-vite build --mode test",
"build:ptr": "electron-vite build --mode ptr",
"pack": "electron-builder --config", "pack": "electron-builder --config",
"dist": "tsc && npm run build && npm run pack" "pack:ptr": "electron-builder --config electron-builder.ptr.yml",
"dist": "tsc && npm run build && npm run pack",
"dist:ptr": "tsc && npm run build:ptr && npm run pack:ptr"
}, },
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^1.0.3", "@electron-toolkit/preload": "^1.0.3",
@@ -27,6 +30,7 @@
"adm-zip": "^0.5.17", "adm-zip": "^0.5.17",
"classnames": "^2.3.2", "classnames": "^2.3.2",
"dll-inject": "^0.0.3", "dll-inject": "^0.0.3",
"dompurify": "^3.4.11",
"electron-log": "^5.1.5", "electron-log": "^5.1.5",
"electron-trpc": "^0.5.2", "electron-trpc": "^0.5.2",
"electron-updater": "^5.3.0", "electron-updater": "^5.3.0",
-1
View File
@@ -112,7 +112,6 @@ const giteaProvider: Provider = {
)}` )}`
}; };
// only allow known git hosts over https
const parseGitUrl = ( const parseGitUrl = (
git: string git: string
): { owner: string; repo: string; provider: Provider } => { ): { owner: string; repo: string; provider: Provider } => {
+3 -2
View File
@@ -231,8 +231,9 @@ export const defaultSources: AddonSource[] = [
description: 'Smart consolidated buff frames with extensive customization' description: 'Smart consolidated buff frames with extensive customization'
}, },
{ {
git: 'https://github.com/acolis/VitalWatch.git', git: 'https://octowow.st/git/shaga/LifeSafer_LowHealthWarning.git',
description: 'Customizable health, mana, and aggro alert system for solo, group, and raid play' branch: 'main',
description: 'Low health and mana fullscreen flash warnings with heartbeat sound; re-enables the hidden Blizzard alert effect'
}, },
{ {
git: 'https://github.com/Fiurs-Hearth/WIIIUI.git', git: 'https://github.com/Fiurs-Hearth/WIIIUI.git',
+6 -1
View File
@@ -24,7 +24,12 @@ const skipFiles = new Set([
'.manifest-overrides.json' '.manifest-overrides.json'
]); ]);
const skipPatterns: RegExp[] = [/\.bak(\.|$)/, /\.crashing(\.|$)/]; const skipPatterns: RegExp[] = [
/\.bak([.\-]|$)/,
/\.crashing(\.|$)/,
/\.torrent$/,
/^manifest\.json\./
];
const isSkipPattern = (file: string) => skipPatterns.some(p => p.test(file)); const isSkipPattern = (file: string) => skipPatterns.some(p => p.test(file));
const skipDirsPosix = new Set([ const skipDirsPosix = new Set([
-1
View File
@@ -79,7 +79,6 @@ app.get(
const filePath = req.params[0]; const filePath = req.params[0];
console.log(`Fetching file: ${filePath}`); console.log(`Fetching file: ${filePath}`);
// keep the resolved path inside SourceDir
const root = path.resolve(SourceDir); const root = path.resolve(SourceDir);
const target = path.resolve(SourceDir, filePath); const target = path.resolve(SourceDir, filePath);
if (target !== root && !target.startsWith(root + path.sep)) { if (target !== root && !target.startsWith(root + path.sep)) {
+1 -2
View File
@@ -93,8 +93,7 @@ export const MODS: ModEntry[] = [
pinnedTag: '0.2', pinnedTag: '0.2',
format: 'zip', format: 'zip',
extractMap: { extractMap: {
'VanillaMultiMonitorFix.dll': 'VanillaMultiMonitorFix.dll', 'VanillaMultiMonitorFix.dll': 'VanillaMultiMonitorFix.dll'
'VMMFix_preferred_monitor.txt': 'VMMFix_preferred_monitor.txt'
} }
}, },
registerInDllsTxt: 'VanillaMultiMonitorFix.dll' registerInDllsTxt: 'VanillaMultiMonitorFix.dll'
+26 -2
View File
@@ -36,6 +36,18 @@ export const ModStateSchema = z.object({
}); });
export type ModState = z.infer<typeof ModStateSchema>; export type ModState = z.infer<typeof ModStateSchema>;
export const HardwareInfoSchema = z.object({
totalRamMb: z.number(),
cpuCores: z.number(),
cpuModel: z.string(),
gpuModel: z.string(),
vramMb: z.number().nullable(),
vramSource: z.enum(['registry', 'wmi', 'none']),
detectedAt: z.string(),
schemaVersion: z.number()
});
export type HardwareInfo = z.infer<typeof HardwareInfoSchema>;
export const PreferencesSchema = z.object({ export const PreferencesSchema = z.object({
isPortable: z.boolean().optional(), isPortable: z.boolean().optional(),
server: z.enum(['live', 'ptr']).default('live'), server: z.enum(['live', 'ptr']).default('live'),
@@ -60,7 +72,9 @@ export const PreferencesSchema = z.object({
}) })
.nullish(), .nullish(),
config: ConfigWtfSchema.default({}), config: ConfigWtfSchema.default({}),
mods: z.record(ModStateSchema).default({}) mods: z.record(ModStateSchema).default({}),
hardware: HardwareInfoSchema.optional(),
farClipUserSet: z.boolean().optional()
}); });
export type PreferencesSchema = z.infer<typeof PreferencesSchema>; export type PreferencesSchema = z.infer<typeof PreferencesSchema>;
@@ -105,7 +119,7 @@ export const NewsItemSchema = z.object({
date: z.string(), date: z.string(),
body: z.string(), body: z.string(),
url: z.string().url().optional(), url: z.string().url().optional(),
author: z.string().optional() author: z.string().nullish()
}); });
export type NewsItem = z.infer<typeof NewsItemSchema>; export type NewsItem = z.infer<typeof NewsItemSchema>;
@@ -113,3 +127,13 @@ export const NewsFeedSchema = z.object({
items: z.array(NewsItemSchema) items: z.array(NewsItemSchema)
}); });
export type NewsFeed = z.infer<typeof NewsFeedSchema>; export type NewsFeed = z.infer<typeof NewsFeedSchema>;
export const ForumAnnouncementSchema = z.object({
id: z.string(),
title: z.string(),
author: z.string().nullish(),
date: z.string(),
url: z.string().url(),
html: z.string()
});
export type ForumAnnouncement = z.infer<typeof ForumAnnouncementSchema>;
-1
View File
@@ -1,6 +1,5 @@
type Path = readonly (string | number)[]; type Path = readonly (string | number)[];
// reject prototype-polluting keys
const isUnsafeKey = (key: string | number) => const isUnsafeKey = (key: string | number) =>
key === '__proto__' || key === 'constructor' || key === 'prototype'; key === '__proto__' || key === 'constructor' || key === 'prototype';
+2
View File
@@ -6,6 +6,7 @@ import { patcherRouter } from './routers/patcher';
import { generalRouter } from './routers/general'; import { generalRouter } from './routers/general';
import { preferencesRouter } from './routers/preferences'; import { preferencesRouter } from './routers/preferences';
import { newsRouter } from './routers/news'; import { newsRouter } from './routers/news';
import { forumRouter } from './routers/forum';
import { modsRouter } from './routers/mods'; import { modsRouter } from './routers/mods';
import { selfUpdaterRouter } from './routers/selfUpdater'; import { selfUpdaterRouter } from './routers/selfUpdater';
@@ -17,6 +18,7 @@ export const appRouter = createTRPCRouter({
patcher: patcherRouter, patcher: patcherRouter,
updater: updaterRouter, updater: updaterRouter,
news: newsRouter, news: newsRouter,
forum: forumRouter,
mods: modsRouter, mods: modsRouter,
selfUpdater: selfUpdaterRouter selfUpdater: selfUpdaterRouter
}); });
+44
View File
@@ -0,0 +1,44 @@
import fetch from 'node-fetch';
import Logger from 'electron-log/main';
import { ForumAnnouncementSchema, type ForumAnnouncement } from '~common/schemas';
import { createTRPCRouter, publicProcedure } from '../trpc';
const FETCH_TIMEOUT_MS = 8_000;
const fetchLatestAnnouncement = async (): Promise<ForumAnnouncement | null> => {
const url = `${
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
}/forum/octonews.php?forum=35&mode=full`;
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw Error(`HTTP ${res.status}`);
const json = (await res.json()) as unknown;
if (!json || typeof json !== 'object' || !('id' in json)) return null;
const parsed = ForumAnnouncementSchema.safeParse(json);
if (!parsed.success) {
Logger.error(
'Forum announcement failed schema validation',
parsed.error.flatten()
);
throw Error('Malformed forum announcement');
}
return parsed.data;
} finally {
clearTimeout(t);
}
};
export const forumRouter = createTRPCRouter({
latestAnnouncement: publicProcedure.query(async () => {
try {
return await fetchLatestAnnouncement();
} catch (e) {
Logger.error('Failed to fetch forum announcement', e);
throw e;
}
})
});
+10
View File
@@ -5,11 +5,21 @@ import { z } from 'zod';
import { mainWindow } from '~main/index'; import { mainWindow } from '~main/index';
import Preferences from '~main/modules/preferences'; import Preferences from '~main/modules/preferences';
import { addDefenderExclusions } from '~main/modules/defender'; import { addDefenderExclusions } from '~main/modules/defender';
import { detectHardware, recommendFarClip } from '~main/modules/hardware';
import { createTRPCRouter, publicProcedure } from '../trpc'; import { createTRPCRouter, publicProcedure } from '../trpc';
export const generalRouter = createTRPCRouter({ export const generalRouter = createTRPCRouter({
appVersion: publicProcedure.query(() => app.getVersion()), appVersion: publicProcedure.query(() => app.getVersion()),
hardware: publicProcedure.query(() => {
const hardware = Preferences.data.hardware ?? null;
return { hardware, recommendedFarClip: recommendFarClip(hardware) };
}),
redetectHardware: publicProcedure.mutation(async () => {
const hardware = await detectHardware();
Preferences.data = { hardware };
return { hardware, recommendedFarClip: recommendFarClip(hardware) };
}),
quit: publicProcedure.mutation(() => app.quit()), quit: publicProcedure.mutation(() => app.quit()),
minimize: publicProcedure.mutation(() => mainWindow?.minimize()), minimize: publicProcedure.mutation(() => mainWindow?.minimize()),
openLink: publicProcedure openLink: publicProcedure
+13
View File
@@ -1,6 +1,10 @@
import path from 'path';
import { z } from 'zod'; import { z } from 'zod';
import Mods from '~main/modules/mods'; import Mods from '~main/modules/mods';
import Preferences from '~main/modules/preferences';
import { isGameRunning } from '~main/modules/updater';
import { ModIdSchema } from '~common/mods'; import { ModIdSchema } from '~common/mods';
import { createTRPCRouter, publicProcedure } from '../trpc'; import { createTRPCRouter, publicProcedure } from '../trpc';
@@ -15,5 +19,14 @@ export const modsRouter = createTRPCRouter({
.input(z.object({ id: ModIdSchema, ignore: z.boolean() })) .input(z.object({ id: ModIdSchema, ignore: z.boolean() }))
.mutation(({ input }) => Mods.setIgnoreUpdates(input.id, input.ignore)), .mutation(({ input }) => Mods.setIgnoreUpdates(input.id, input.ignore)),
applyAll: publicProcedure.mutation(() => Mods.applyAll()), applyAll: publicProcedure.mutation(() => Mods.applyAll()),
repair: publicProcedure.mutation(async () => {
const clientDir = Preferences.data?.clientDir;
if (clientDir) {
const exePath = path.join(clientDir, 'WoW.exe');
if (await isGameRunning(exePath))
throw new Error('Please close WoW first before verifying files.');
}
return Mods.applyAll({ repairOnly: true });
}),
observe: publicProcedure.subscription(() => Mods.observe()) observe: publicProcedure.subscription(() => Mods.observe())
}); });
+3 -1
View File
@@ -8,7 +8,9 @@ import { createTRPCRouter, publicProcedure } from '../trpc';
const FETCH_TIMEOUT_MS = 8_000; const FETCH_TIMEOUT_MS = 8_000;
const fetchNews = async (): Promise<NewsItem[]> => { const fetchNews = async (): Promise<NewsItem[]> => {
const url = `${import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'}/news.json`; const url = `${
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
}/forum/octonews.php?mode=list&forum=2&limit=3`;
const controller = new AbortController(); const controller = new AbortController();
const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try { try {
+49 -1
View File
@@ -1,6 +1,6 @@
import { join } from 'path'; import { join } from 'path';
import { app, shell, BrowserWindow, screen } from 'electron'; import { app, shell, session, BrowserWindow, screen } from 'electron';
import { electronApp, optimizer, is } from '@electron-toolkit/utils'; import { electronApp, optimizer, is } from '@electron-toolkit/utils';
import { createIPCHandler } from 'electron-trpc/main'; import { createIPCHandler } from 'electron-trpc/main';
import Logger from 'electron-log/main'; import Logger from 'electron-log/main';
@@ -13,6 +13,11 @@ import Updater from './modules/updater';
import Addons from './modules/addons'; import Addons from './modules/addons';
import Mods from './modules/mods'; import Mods from './modules/mods';
import { initSelfUpdater } from './modules/selfUpdater'; import { initSelfUpdater } from './modules/selfUpdater';
import {
detectHardware,
recommendFarClip,
HARDWARE_SCHEMA_VERSION
} from './modules/hardware';
Logger.initialize(); Logger.initialize();
Logger.errorHandler.startCatching(); Logger.errorHandler.startCatching();
@@ -134,8 +139,51 @@ if (!gotSingleInstanceLock) {
Mods.verify(); Mods.verify();
initSelfUpdater(); initSelfUpdater();
void (async () => {
try {
let hardware = Preferences.data.hardware;
if (!hardware || hardware.schemaVersion < HARDWARE_SCHEMA_VERSION) {
hardware = await detectHardware();
Preferences.data = { hardware };
}
const rec = recommendFarClip(hardware ?? null);
if (
Preferences.data.farClipUserSet !== true &&
Preferences.data.config.farClip !== rec
)
Preferences.data = {
config: { ...Preferences.data.config, farClip: rec }
};
} catch (e) {
Logger.error('Hardware detection / farClip recommendation failed', e);
}
})();
electronApp.setAppUserModelId('st.octowow.launcher'); electronApp.setAppUserModelId('st.octowow.launcher');
if (app.isPackaged) {
const serverOrigin = new URL(
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
).origin;
session.defaultSession.webRequest.onHeadersReceived((details, cb) => {
cb({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
[
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
`img-src 'self' data: https://octowow.st https://forum.octowow.st ${serverOrigin}`,
"font-src 'self' data:",
"connect-src 'self'"
].join('; ')
]
}
});
});
}
app.on('browser-window-created', (_, window) => { app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window); optimizer.watchWindowShortcuts(window);
}); });
-2
View File
@@ -53,7 +53,6 @@ const readTocData = (content: string) =>
const isUnsafeFolder = (name?: string) => const isUnsafeFolder = (name?: string) =>
!name || name === '.' || name === '..' || /[/\\]/.test(name); !name || name === '.' || name === '..' || /[/\\]/.test(name);
// only allow known git hosts over https
const ALLOWED_GIT_HOSTS = [ const ALLOWED_GIT_HOSTS = [
'github.com', 'github.com',
'gitlab.com', 'gitlab.com',
@@ -150,7 +149,6 @@ class AddonsClass extends Observable<AddonsStatus> {
)?.[1]; )?.[1];
} }
} catch { } catch {
/* ignore */
} }
const folder = gitUrl.slice(0, -4).split('/').at(-1); const folder = gitUrl.slice(0, -4).split('/').at(-1);
+132
View File
@@ -0,0 +1,132 @@
import os from 'node:os';
import { spawn } from 'node:child_process';
import { app } from 'electron';
import Logger from 'electron-log/main';
import type { HardwareInfo } from '~common/schemas';
export const HARDWARE_SCHEMA_VERSION = 1;
export const FARCLIP_FLOOR = 777;
export const FARCLIP_CEILING = 3000;
const VIDEO_CLASS_GUID = '{4d36e968-e325-11ce-bfc1-08002be10318}';
const getVramMb = (): Promise<{
mb: number | null;
source: HardwareInfo['vramSource'];
}> => {
if (os.platform() !== 'win32')
return Promise.resolve({ mb: null, source: 'none' });
const script = [
'$ErrorActionPreference = "Stop"',
`$base = 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Class\\${VIDEO_CLASS_GUID}'`,
'$max = [int64]0',
'Get-ChildItem $base -ErrorAction SilentlyContinue | ForEach-Object {',
' $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue',
' $v = [int64]0',
" if ($p.'HardwareInformation.qwMemorySize') { $v = [int64]$p.'HardwareInformation.qwMemorySize' }",
" elseif ($p.'HardwareInformation.MemorySize') {",
" $m = $p.'HardwareInformation.MemorySize'",
' if ($m -is [byte[]]) { $v = [int64][System.BitConverter]::ToUInt32($m, 0) } else { $v = [int64]$m }',
' }',
' if ($v -gt $max) { $max = $v }',
'}',
'Write-Output $max'
].join('\n');
const encoded = Buffer.from(script, 'utf16le').toString('base64');
return new Promise(resolve => {
let settled = false;
const finish = (mb: number | null, source: HardwareInfo['vramSource']) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ mb, source });
};
const child = spawn(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded],
{ windowsHide: true }
);
const timer = setTimeout(() => {
child.kill();
Logger.warn('VRAM detection timed out');
finish(null, 'none');
}, 8000);
let stdout = '';
child.stdout.on('data', d => (stdout += String(d)));
child.on('error', e => {
Logger.warn('VRAM detection failed to launch PowerShell', e);
finish(null, 'none');
});
child.on('exit', code => {
const bytes = Number(stdout.trim());
if (code === 0 && Number.isFinite(bytes) && bytes > 0)
finish(Math.round(bytes / 1024 / 1024), 'registry');
else finish(null, 'none');
});
});
};
const getGpuModel = async (): Promise<string> => {
try {
const info = (await app.getGPUInfo('complete')) as {
auxAttributes?: { glRenderer?: string };
gpuDevice?: { active?: boolean; vendorId?: number; deviceId?: number }[];
};
const renderer = info?.auxAttributes?.glRenderer?.trim();
if (renderer) return renderer;
const active = info?.gpuDevice?.find(d => d.active) ?? info?.gpuDevice?.[0];
if (active) return `vendor ${active.vendorId} device ${active.deviceId}`;
} catch (e) {
Logger.warn('GPU info detection failed', e);
}
return 'unknown';
};
export const detectHardware = async (): Promise<HardwareInfo> => {
const cpus = os.cpus();
const [vram, gpuModel] = await Promise.all([getVramMb(), getGpuModel()]);
const info: HardwareInfo = {
totalRamMb: Math.round(os.totalmem() / 1024 / 1024),
cpuCores: cpus.length,
cpuModel: cpus[0]?.model?.trim() || 'unknown',
gpuModel,
vramMb: vram.mb,
vramSource: vram.source,
detectedAt: new Date().toISOString(),
schemaVersion: HARDWARE_SCHEMA_VERSION
};
Logger.info('Detected hardware', info);
return info;
};
const clampFarClip = (n: number) =>
Math.min(FARCLIP_CEILING, Math.max(FARCLIP_FLOOR, Math.round(n)));
export const recommendFarClip = (hw: HardwareInfo | null): number => {
if (!hw) return clampFarClip(1000);
const ramGb = hw.totalRamMb / 1024;
const cores = hw.cpuCores;
const vramTrusted = hw.vramSource !== 'none' && hw.vramMb != null;
const vramGb = vramTrusted ? (hw.vramMb as number) / 1024 : null;
if (ramGb < 6 || cores <= 2) return clampFarClip(FARCLIP_FLOOR);
if (vramGb === null)
return clampFarClip(ramGb >= 8 && cores >= 4 ? 1500 : 1000);
if (ramGb < 8 || vramGb < 2) return clampFarClip(1000);
if (ramGb >= 32 && vramGb >= 8 && cores >= 8) return clampFarClip(3000);
if (ramGb >= 16 && vramGb >= 4 && cores >= 6) return clampFarClip(2200);
if (ramGb >= 8 && vramGb >= 2 && cores >= 4) return clampFarClip(1500);
return clampFarClip(1000);
};
-1
View File
@@ -97,7 +97,6 @@ export const applyLocalePatch = async (
) )
return; return;
} catch { } catch {
/* ignore */
} }
try { try {
+4 -3
View File
@@ -114,7 +114,7 @@ class ModsClass extends Observable<ModsStatus> {
const vmmfDll = path.join(clientDir, 'VanillaMultiMonitorFix.dll'); const vmmfDll = path.join(clientDir, 'VanillaMultiMonitorFix.dll');
const vmmfCfg = path.join(clientDir, 'VMMFix_preferred_monitor.txt'); const vmmfCfg = path.join(clientDir, 'VMMFix_preferred_monitor.txt');
if ((await fs.pathExists(vmmfDll)) && !(await fs.pathExists(vmmfCfg))) if ((await fs.pathExists(vmmfDll)) && !(await fs.pathExists(vmmfCfg)))
await fs.writeFile(vmmfCfg, '1\n', 'utf8').catch(() => {}); await fs.writeFile(vmmfCfg, '0\n', 'utf8').catch(() => {});
} }
for (const m of MODS) { for (const m of MODS) {
@@ -182,7 +182,7 @@ class ModsClass extends Observable<ModsStatus> {
this.#patchRow(id, { ignoreUpdates: ignore }); this.#patchRow(id, { ignoreUpdates: ignore });
} }
async applyAll() { async applyAll(opts: { repairOnly?: boolean } = {}) {
const clientDir = Preferences.data?.clientDir; const clientDir = Preferences.data?.clientDir;
if (!clientDir) { if (!clientDir) {
Logger.warn('No clientDir set; cannot apply mods.'); Logger.warn('No clientDir set; cannot apply mods.');
@@ -192,6 +192,7 @@ class ModsClass extends Observable<ModsStatus> {
Logger.warn('applyAll already running; ignoring re-entrant call.'); Logger.warn('applyAll already running; ignoring re-entrant call.');
return; return;
} }
await this.verify();
this._value = { ...this._value, state: 'busy' }; this._value = { ...this._value, state: 'busy' };
this._notifyObservers(); this._notifyObservers();
@@ -219,7 +220,7 @@ class ModsClass extends Observable<ModsStatus> {
await this.#install(m); await this.#install(m);
} else if (!wantInstalled && isInstalled) { } else if (!wantInstalled && isInstalled) {
await this.#uninstall(m); await this.#uninstall(m);
} else if (wantInstalled && updateAvailable) { } else if (wantInstalled && updateAvailable && !opts.repairOnly) {
await this.#uninstall(m); await this.#uninstall(m);
await this.#install(m); await this.#install(m);
} }
+19 -2
View File
@@ -97,7 +97,6 @@ export const patchExecutable = async () => {
[0x006e62a8, [0x006e62a9]] [0x006e62a8, [0x006e62a9]]
] ]
}, },
// version-pinned in-place patch of the WoW.exe routine at this offset
{ {
synthetic: true, synthetic: true,
key: 'skillUiGateHijack', key: 'skillUiGateHijack',
@@ -127,6 +126,22 @@ export const patchExecutable = async () => {
] ]
] ]
] ]
},
{
synthetic: true,
key: 'octowowUrlAllowlist',
type: 'bytes',
default: true,
forced: true,
tweaks: [
[
0x45ccd8,
[
0x6f, 0x63, 0x74, 0x6f, 0x77, 0x6f, 0x77, 0x2e, 0x73, 0x74,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00
]
]
]
} }
] satisfies Tweak[]; ] satisfies Tweak[];
@@ -180,7 +195,9 @@ export const patchConfig = async (forceTweaks = false) => {
); );
const primaryDisplay = screen.getPrimaryDisplay(); const primaryDisplay = screen.getPrimaryDisplay();
const { width, height } = primaryDisplay.bounds; const scale = primaryDisplay.scaleFactor || 1;
const width = Math.round(primaryDisplay.bounds.width * scale);
const height = Math.round(primaryDisplay.bounds.height * scale);
const parsed = { const parsed = {
scriptMemory: 512000, scriptMemory: 512000,
+16 -6
View File
@@ -423,9 +423,15 @@ class UpdaterClass extends Observable<UpdaterStatus> {
#clientTotalBytes = 0; #clientTotalBytes = 0;
#bytesAlreadyOnDisk = 0; #bytesAlreadyOnDisk = 0;
#cachePath = path.join(Preferences.userDataDir, 'cache.json'); #cachePath = path.join(Preferences.userDataDir, 'cache.json');
#cache: CacheTree = fs.existsSync(this.#cachePath) #cache: CacheTree = this.#readCache();
? fs.readJSONSync(this.#cachePath)
: {}; #readCache(): CacheTree {
try {
return fs.existsSync(this.#cachePath) ? fs.readJSONSync(this.#cachePath) : {};
} catch {
return {};
}
}
async #saveCache() { async #saveCache() {
await fs.writeJSON(this.#cachePath, this.#cache); await fs.writeJSON(this.#cachePath, this.#cache);
@@ -618,7 +624,11 @@ class UpdaterClass extends Observable<UpdaterStatus> {
const item = getManifestItem(hashTree, filePath); const item = getManifestItem(hashTree, filePath);
if (!item) return undefined; if (!item) return undefined;
if (item.type === 'del') return item; if (item.type === 'del') {
if (await fs.exists(path.join(clientPath, ...filePath)))
return item;
return undefined;
}
if (item.type === 'dir') { if (item.type === 'dir') {
const files = ( const files = (
@@ -832,7 +842,6 @@ class UpdaterClass extends Observable<UpdaterStatus> {
try { try {
if (clean) { if (clean) {
// never wipe a drive root or a non-client dir
const resolvedClientPath = path.resolve(clientPath); const resolvedClientPath = path.resolve(clientPath);
const isFilesystemRoot = const isFilesystemRoot =
path.parse(resolvedClientPath).root === resolvedClientPath; path.parse(resolvedClientPath).root === resolvedClientPath;
@@ -854,9 +863,10 @@ class UpdaterClass extends Observable<UpdaterStatus> {
message: 'Cleaning up old files...' message: 'Cleaning up old files...'
}; };
const preserve = new Set(['octolauncher.exe', 'wtf', 'interface', 'screenshots']);
const files = await fs.readdir(clientPath); const files = await fs.readdir(clientPath);
for (const file of files) { for (const file of files) {
if (file === 'OctoLauncher.exe') continue; if (preserve.has(file.toLowerCase())) continue;
await fs.rm(path.join(clientPath, file), { await fs.rm(path.join(clientPath, file), {
recursive: true, recursive: true,
force: true force: true
+5 -1
View File
@@ -5,4 +5,8 @@ export {
type ModsStatus, type ModsStatus,
type ModRowStatus type ModRowStatus
} from './modules/mods'; } from './modules/mods';
export { type NewsItem, type NewsFeed } from '../common/schemas'; export {
type NewsItem,
type NewsFeed,
type ForumAnnouncement
} from '../common/schemas';
-1
View File
@@ -48,7 +48,6 @@ export const getClientVersion = async () => {
const file = await fs.readFile(exePath); const file = await fs.readFile(exePath);
const buffer = Buffer.from(file); const buffer = Buffer.from(file);
// Fixed addresses in the 1.12.1 client binary.
const VERSION_OFFSET = 0x00437c04; const VERSION_OFFSET = 0x00437c04;
const VERSION_LEN = 6; const VERSION_LEN = 6;
const BUILD_OFFSET = 0x00437bfc; const BUILD_OFFSET = 0x00437bfc;
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

@@ -0,0 +1,218 @@
import DOMPurify from 'dompurify';
import { AlertTriangle, ExternalLink, RefreshCw } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import useScrollHint from '~renderer/utils/useScrollHint';
import Parchment from '~renderer/assets/parchment.jpg';
import IconSpinner from './styled/IconSpinner';
import TextButton from './styled/TextButton';
const SANITIZE_CONFIG = {
ALLOWED_TAGS: [
'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'a', 'ul', 'ol', 'li',
'blockquote', 'cite', 'span', 'div', 'img', 'br', 'p', 'code', 'pre',
'dl', 'dt', 'dd', 'hr', 'sub', 'sup', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'table', 'thead', 'tbody', 'tr', 'td', 'th'
],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'style'],
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:)/i,
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'style', 'link', 'meta']
};
const LIGHT_NAMED = new Set([
'white', 'snow', 'ivory', 'floralwhite', 'ghostwhite', 'seashell', 'beige',
'linen', 'cornsilk', 'lightyellow', 'lightgoldenrodyellow', 'lemonchiffon',
'yellow', 'aqua', 'cyan', 'lime', 'aquamarine', 'azure', 'mintcream',
'honeydew', 'lavender', 'lavenderblush', 'aliceblue', 'whitesmoke',
'gainsboro', 'silver', 'lightgray', 'lightgrey', 'antiquewhite', 'papayawhip',
'blanchedalmond', 'bisque', 'moccasin', 'navajowhite', 'peachpuff', 'khaki',
'wheat', 'greenyellow', 'chartreuse', 'springgreen', 'palegoldenrod', 'gold'
]);
const LIGHT_THRESHOLD = 165;
const SAFE_STYLE_PROPS = new Set([
'color',
'background-color',
'font-weight',
'font-style',
'text-decoration',
'text-align',
'font-size'
]);
const colorIsTooLight = (raw: string): boolean => {
const v = raw.trim().toLowerCase();
const lum = (r: number, g: number, b: number) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
const hex = v.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
if (hex) {
const h =
hex[1].length === 3
? hex[1]
.split('')
.map(c => c + c)
.join('')
: hex[1];
return (
lum(
parseInt(h.slice(0, 2), 16),
parseInt(h.slice(2, 4), 16),
parseInt(h.slice(4, 6), 16)
) > LIGHT_THRESHOLD
);
}
const rgb = v.match(/^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)/);
if (rgb) return lum(+rgb[1], +rgb[2], +rgb[3]) > LIGHT_THRESHOLD;
return LIGHT_NAMED.has(v);
};
const clampFontSize = (val: string): string => {
const m = val.match(/^(\d+(?:\.\d+)?)(px|pt|%|em|rem)$/i);
if (!m) return val;
const n = parseFloat(m[1]);
const unit = m[2].toLowerCase();
const max = unit === 'px' ? 28 : unit === 'pt' ? 21 : unit === '%' ? 200 : 2;
return `${Math.min(n, max)}${unit}`;
};
DOMPurify.addHook('afterSanitizeAttributes', node => {
if (node.tagName === 'IMG') node.setAttribute('loading', 'lazy');
const style = node.getAttribute('style');
if (!style) return;
const kept: string[] = [];
for (const decl of style.split(';')) {
const idx = decl.indexOf(':');
if (idx < 0) continue;
const prop = decl.slice(0, idx).trim().toLowerCase();
let val = decl.slice(idx + 1).trim();
if (!val || !SAFE_STYLE_PROPS.has(prop)) continue;
if (prop === 'color' && colorIsTooLight(val)) continue;
if (prop === 'font-size') val = clampFontSize(val);
kept.push(`${prop}:${val}`);
}
if (kept.length) node.setAttribute('style', kept.join(';'));
else node.removeAttribute('style');
});
const formatDate = (raw: string) => {
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return raw;
return d.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const ForumAnnouncementPanel = () => {
const t = useT();
const openLink = api.general.openLink.useMutation();
const query = api.forum.latestAnnouncement.useQuery(undefined, {
staleTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1
});
const scrollRef = useScrollHint<HTMLDivElement>();
const data = query.data;
const safeHtml = useMemo(
() => (data ? DOMPurify.sanitize(data.html, SANITIZE_CONFIG) : ''),
[data]
);
const onBodyClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const anchor = (e.target as HTMLElement).closest('a[href]');
if (!anchor) return;
e.preventDefault();
const href = anchor.getAttribute('href');
if (href) openLink.mutateAsync(href);
},
[openLink]
);
return (
<aside
className="parchment-post flex min-h-0 flex-grow flex-col gap-3"
style={{ backgroundImage: `url(${Parchment})` }}
>
<div className="flex items-start justify-between gap-2">
<h4 className="parchment-post-title min-w-0 flex-1 break-words">
{data?.title ?? t('forum.title')}
</h4>
<TextButton
icon={RefreshCw}
size={18}
className="-mr-2 -mt-1 shrink-0"
loading={query.isFetching}
onClick={() => query.refetch()}
title={t('misc.refresh')}
/>
</div>
{data?.author && (
<span className="parchment-post-meta">
{t('misc.newsByAuthor', { author: data.author })} · {formatDate(data.date)}
</span>
)}
<hr />
<div
ref={scrollRef}
className="relative -mx-4 flex min-h-0 flex-grow flex-col overflow-y-auto overflow-x-hidden px-4"
>
{query.isLoading ? (
<div className="flex flex-grow flex-col items-center justify-center gap-2">
<IconSpinner className="parchment-post-muted" />
<p className="parchment-post-muted italic">{t('forum.loading')}</p>
</div>
) : query.isError ? (
<div className="flex flex-grow flex-col items-center justify-center gap-3">
<AlertTriangle size={32} className="text-red" />
<p className="parchment-post-muted italic">{t('forum.error')}</p>
<TextButton
icon={RefreshCw}
size={18}
onClick={() => query.refetch()}
>
{t('misc.tryAgain')}
</TextButton>
</div>
) : !data ? (
<div className="flex flex-grow flex-col items-center justify-center">
<p className="parchment-post-muted italic">{t('forum.empty')}</p>
</div>
) : (
<div
className="parchment-post-body"
onClick={onBodyClick}
dangerouslySetInnerHTML={{ __html: safeHtml }}
/>
)}
</div>
{data && (
<TextButton
icon={ExternalLink}
size={14}
className="-ml-2 self-start"
onClick={() => openLink.mutateAsync(data.url)}
>
{t('forum.readFullPost')}
</TextButton>
)}
</aside>
);
};
export default ForumAnnouncementPanel;
@@ -44,6 +44,7 @@ const PreferencesDialog = ({ close }: Props) => {
const setPref = api.preferences.set.useMutation(); const setPref = api.preferences.set.useMutation();
const verify = api.updater.verify.useMutation(); const verify = api.updater.verify.useMutation();
const repair = api.mods.repair.useMutation();
const openInstallFolder = api.general.openInstallFolder.useMutation(); const openInstallFolder = api.general.openInstallFolder.useMutation();
const openLogFile = api.general.openLogFile.useMutation(); const openLogFile = api.general.openLogFile.useMutation();
const addExclusion = api.general.addDefenderExclusion.useMutation(); const addExclusion = api.general.addDefenderExclusion.useMutation();
@@ -147,7 +148,7 @@ const PreferencesDialog = ({ close }: Props) => {
<h4 className="tw-color">{t('prefs.troubleshooting')}</h4> <h4 className="tw-color">{t('prefs.troubleshooting')}</h4>
<TextButton <TextButton
icon={ShieldCheck} icon={ShieldCheck}
onClick={() => verify.mutateAsync().then(close)} onClick={() => repair.mutateAsync().then(close)}
className="!items-start text-left text-warmGreen" className="!items-start text-left text-warmGreen"
> >
{t('prefs.verifyGameFiles')} {t('prefs.verifyGameFiles')}
@@ -2,7 +2,7 @@ type Run = { text: string; color?: string };
const tokenize = (s: string): Run[] => { const tokenize = (s: string): Run[] => {
const runs: Run[] = []; const runs: Run[] = [];
const re = /\|c([0-9a-fA-F]{8})|\|r/gi; const re = /\|c([0-9a-fA-F]{8})|\|r/g;
let i = 0; let i = 0;
let color: string | undefined; let color: string | undefined;
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
@@ -39,7 +39,6 @@ const DialogButton = ({
ref.current?.close(); ref.current?.close();
}, []); }, []);
// Click away
useEffect(() => { useEffect(() => {
if (!clickAway) return; if (!clickAway) return;
const callback = (e: MouseEvent) => e.target === ref.current && close(); const callback = (e: MouseEvent) => e.target === ref.current && close();
+15 -3
View File
@@ -5,6 +5,7 @@ import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n'; import { useT } from '~renderer/i18n';
import useScrollHint from '~renderer/utils/useScrollHint'; import useScrollHint from '~renderer/utils/useScrollHint';
import ForumAnnouncementPanel from '../ForumAnnouncementPanel';
import IconSpinner from '../styled/IconSpinner'; import IconSpinner from '../styled/IconSpinner';
import TextButton from '../styled/TextButton'; import TextButton from '../styled/TextButton';
@@ -49,7 +50,8 @@ const NewsEntry = ({ item }: { item: NewsItem }) => {
); );
}; };
const NewsTab = () => { // The "Announcements" list — most-recent forum topics as short previews + links.
const AnnouncementsBox = () => {
const t = useT(); const t = useT();
const query = api.news.list.useQuery(undefined, { const query = api.news.list.useQuery(undefined, {
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
@@ -59,9 +61,9 @@ const NewsTab = () => {
const scrollRef = useScrollHint<HTMLDivElement>(); const scrollRef = useScrollHint<HTMLDivElement>();
return ( return (
<div className="tw-surface flex min-h-0 flex-grow flex-col gap-3"> <div className="tw-surface flex min-h-0 w-[360px] shrink-0 flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="tw-color">{t('misc.newsTitle')}</h4> <h4 className="tw-color">{t('misc.announcementsTitle')}</h4>
<TextButton <TextButton
icon={RefreshCw} icon={RefreshCw}
size={18} size={18}
@@ -106,4 +108,14 @@ const NewsTab = () => {
); );
}; };
// The News tab holds both boxes side by side: the parchment "newsletter" (the
// featured Nautilus News Network post, biggest) and the "Announcements" list.
// Living inside the tab means they only show on News — not on Tweaks/Addons/Mods.
const NewsTab = () => (
<div className="flex min-h-0 flex-grow gap-3">
<ForumAnnouncementPanel />
<AnnouncementsBox />
</div>
);
export default NewsTab; export default NewsTab;
+20 -4
View File
@@ -78,6 +78,15 @@ const TweaksTab = () => {
}); });
const { handleSubmit, reset, formState } = form; const { handleSubmit, reset, formState } = form;
const { data: hw } = api.general.hardware.useQuery();
const recommendedFarClip = hw?.recommendedFarClip;
const farClipValue = form.watch('farClip');
const farClipText =
t('tweaks.farClip.text') +
(recommendedFarClip != null
? ' ' + t('tweaks.farClip.recommendedHint', { value: recommendedFarClip })
: '');
const isApplying = const isApplying =
setPref.isLoading || applyPatch.isLoading || verify.isLoading; setPref.isLoading || applyPatch.isLoading || verify.isLoading;
@@ -90,7 +99,7 @@ const TweaksTab = () => {
return ( return (
<form <form
onSubmit={handleSubmit(async config => { onSubmit={handleSubmit(async config => {
await setPref.mutateAsync({ config }); await setPref.mutateAsync({ config, farClipUserSet: true });
await applyPatch.mutateAsync(); await applyPatch.mutateAsync();
await verify.mutateAsync(); await verify.mutateAsync();
@@ -143,7 +152,11 @@ const TweaksTab = () => {
id="farClip" id="farClip"
label={t('tweaks.farClip.label')} label={t('tweaks.farClip.label')}
type="number" type="number"
text={t('tweaks.farClip.text')} text={farClipText}
recommended={
recommendedFarClip != null &&
Number(farClipValue) === recommendedFarClip
}
min={100} min={100}
max={10000} max={10000}
sensitivity={3} sensitivity={3}
@@ -199,8 +212,11 @@ const TweaksTab = () => {
</p> </p>
<TextButton <TextButton
onClick={async () => { onClick={async () => {
const config = ConfigWtfSchema.parse({}); const config =
await setPref.mutateAsync({ config }); recommendedFarClip != null
? { ...ConfigWtfSchema.parse({}), farClip: recommendedFarClip }
: ConfigWtfSchema.parse({});
await setPref.mutateAsync({ config, farClipUserSet: false });
reset(config); reset(config);
}} }}
> >
+19 -6
View File
@@ -48,7 +48,8 @@ const enUS: Dict = {
'Recommended for widescreen window resolutions. [Vanilla: 90] [Tweaks: 110]', 'Recommended for widescreen window resolutions. [Vanilla: 90] [Tweaks: 110]',
'tweaks.farClip.label': 'Render distance', 'tweaks.farClip.label': 'Render distance',
'tweaks.farClip.text': 'tweaks.farClip.text':
'Increases maximum render distance. [Vanilla: 777] [Tweaks: 10000]', 'Increases maximum render distance. Very high values can crash the game on world entry. [Vanilla: 777]',
'tweaks.farClip.recommendedHint': 'Recommended for your PC: {value}.',
'tweaks.frillDistance.label': 'Ground clutter distance', 'tweaks.frillDistance.label': 'Ground clutter distance',
'tweaks.frillDistance.text': 'tweaks.frillDistance.text':
'Changes ground clutter render distance. [Vanilla: 70] [Tweaks: 300]', 'Changes ground clutter render distance. [Vanilla: 70] [Tweaks: 300]',
@@ -65,6 +66,7 @@ const enUS: Dict = {
'tweaks.reset': 'Reset', 'tweaks.reset': 'Reset',
'tweaks.apply': 'Apply', 'tweaks.apply': 'Apply',
'misc.newsTitle': 'News', 'misc.newsTitle': 'News',
'misc.announcementsTitle': 'Announcements',
'misc.newsByAuthor': 'by {author}', 'misc.newsByAuthor': 'by {author}',
'misc.newsReadMore': 'Read more', 'misc.newsReadMore': 'Read more',
'misc.refresh': 'Refresh', 'misc.refresh': 'Refresh',
@@ -72,6 +74,11 @@ const enUS: Dict = {
'misc.newsError': "Couldn't reach the news feed.", 'misc.newsError': "Couldn't reach the news feed.",
'misc.newsEmpty': 'No news yet. Check back later.', 'misc.newsEmpty': 'No news yet. Check back later.',
'misc.tryAgain': 'Try again', 'misc.tryAgain': 'Try again',
'forum.title': 'Nautilus News Network',
'forum.readFullPost': 'Read full post on the forum',
'forum.loading': 'Loading latest dispatch...',
'forum.error': "Couldn't reach the forum.",
'forum.empty': 'No dispatches yet.',
'misc.comingSoon': 'Coming soon...', 'misc.comingSoon': 'Coming soon...',
'misc.selfUpdateCheckFailed': 'Update check failed: {message}', 'misc.selfUpdateCheckFailed': 'Update check failed: {message}',
'misc.selfUpdateAvailable': 'misc.selfUpdateAvailable':
@@ -268,7 +275,8 @@ const deDE: Dict = {
'Empfohlen für Breitbild-Fensterauflösungen. [Vanilla: 90] [Tweaks: 110]', 'Empfohlen für Breitbild-Fensterauflösungen. [Vanilla: 90] [Tweaks: 110]',
'tweaks.farClip.label': 'Sichtweite', 'tweaks.farClip.label': 'Sichtweite',
'tweaks.farClip.text': 'tweaks.farClip.text':
'Erhöht die maximale Sichtweite. [Vanilla: 777] [Tweaks: 10000]', 'Erhöht die maximale Sichtweite. Sehr hohe Werte können beim Betreten der Welt zum Absturz führen. [Vanilla: 777]',
'tweaks.farClip.recommendedHint': 'Empfohlen für deinen PC: {value}.',
'tweaks.frillDistance.label': 'Bodendetail-Distanz', 'tweaks.frillDistance.label': 'Bodendetail-Distanz',
'tweaks.frillDistance.text': 'tweaks.frillDistance.text':
'Ändert die Renderdistanz für Bodendetails. [Vanilla: 70] [Tweaks: 300]', 'Ändert die Renderdistanz für Bodendetails. [Vanilla: 70] [Tweaks: 300]',
@@ -490,7 +498,9 @@ const zhCN: Dict = {
'tweaks.fieldOfView.text': 'tweaks.fieldOfView.text':
'推荐用于宽屏窗口分辨率。[Vanilla: 90] [Tweaks: 110]', '推荐用于宽屏窗口分辨率。[Vanilla: 90] [Tweaks: 110]',
'tweaks.farClip.label': '渲染距离', 'tweaks.farClip.label': '渲染距离',
'tweaks.farClip.text': '增加最大渲染距离。[Vanilla: 777] [Tweaks: 10000]', 'tweaks.farClip.text':
'增加最大渲染距离。数值过高可能导致进入游戏世界时崩溃。[Vanilla: 777]',
'tweaks.farClip.recommendedHint': '为您的电脑推荐:{value}。',
'tweaks.frillDistance.label': '地表杂物距离', 'tweaks.frillDistance.label': '地表杂物距离',
'tweaks.frillDistance.text': 'tweaks.frillDistance.text':
'更改地表杂物的渲染距离。[Vanilla: 70] [Tweaks: 300]', '更改地表杂物的渲染距离。[Vanilla: 70] [Tweaks: 300]',
@@ -698,7 +708,8 @@ const esES: Dict = {
'Recomendado para resoluciones de ventana panorámicas. [Vanilla: 90] [Tweaks: 110]', 'Recomendado para resoluciones de ventana panorámicas. [Vanilla: 90] [Tweaks: 110]',
'tweaks.farClip.label': 'Distancia de renderizado', 'tweaks.farClip.label': 'Distancia de renderizado',
'tweaks.farClip.text': 'tweaks.farClip.text':
'Aumenta la distancia máxima de renderizado. [Vanilla: 777] [Tweaks: 10000]', 'Aumenta la distancia máxima de renderizado. Los valores muy altos pueden bloquear el juego al entrar al mundo. [Vanilla: 777]',
'tweaks.farClip.recommendedHint': 'Recomendado para tu PC: {value}.',
'tweaks.frillDistance.label': 'Distancia de la maleza', 'tweaks.frillDistance.label': 'Distancia de la maleza',
'tweaks.frillDistance.text': 'tweaks.frillDistance.text':
'Cambia la distancia de renderizado de la maleza del suelo. [Vanilla: 70] [Tweaks: 300]', 'Cambia la distancia de renderizado de la maleza del suelo. [Vanilla: 70] [Tweaks: 300]',
@@ -922,7 +933,8 @@ const ptBR: Dict = {
'Recomendado para resoluções de janela widescreen. [Vanilla: 90] [Tweaks: 110]', 'Recomendado para resoluções de janela widescreen. [Vanilla: 90] [Tweaks: 110]',
'tweaks.farClip.label': 'Distância de renderização', 'tweaks.farClip.label': 'Distância de renderização',
'tweaks.farClip.text': 'tweaks.farClip.text':
'Aumenta a distância máxima de renderização. [Vanilla: 777] [Tweaks: 10000]', 'Aumenta a distância máxima de renderização. Valores muito altos podem travar o jogo ao entrar no mundo. [Vanilla: 777]',
'tweaks.farClip.recommendedHint': 'Recomendado para o seu PC: {value}.',
'tweaks.frillDistance.label': 'Distância da vegetação do solo', 'tweaks.frillDistance.label': 'Distância da vegetação do solo',
'tweaks.frillDistance.text': 'tweaks.frillDistance.text':
'Altera a distância de renderização da vegetação do solo. [Vanilla: 70] [Tweaks: 300]', 'Altera a distância de renderização da vegetação do solo. [Vanilla: 70] [Tweaks: 300]',
@@ -1144,7 +1156,8 @@ const ruRU: Dict = {
'Рекомендуется для широкоэкранных разрешений. [Vanilla: 90] [Tweaks: 110]', 'Рекомендуется для широкоэкранных разрешений. [Vanilla: 90] [Tweaks: 110]',
'tweaks.farClip.label': 'Дальность прорисовки', 'tweaks.farClip.label': 'Дальность прорисовки',
'tweaks.farClip.text': 'tweaks.farClip.text':
'Увеличивает максимальную дальность прорисовки. [Vanilla: 777] [Tweaks: 10000]', 'Увеличивает максимальную дальность прорисовки. Слишком высокие значения могут привести к вылету при входе в мир. [Vanilla: 777]',
'tweaks.farClip.recommendedHint': 'Рекомендовано для вашего ПК: {value}.',
'tweaks.frillDistance.label': 'Дальность травы и деталей', 'tweaks.frillDistance.label': 'Дальность травы и деталей',
'tweaks.frillDistance.text': 'tweaks.frillDistance.text':
'Изменяет дальность прорисовки травы и мелких деталей земли. [Vanilla: 70] [Tweaks: 300]', 'Изменяет дальность прорисовки травы и мелких деталей земли. [Vanilla: 70] [Tweaks: 300]',
+113
View File
@@ -202,6 +202,119 @@ input[type='number'] {
} }
} }
.parchment-post {
position: relative;
color: #2b1a0c;
background-color: #e9dab5;
background-size: cover;
background-position: center;
@apply border p-4;
border-color: rgba(60, 40, 15, 0.4);
box-shadow: rgb(0 0 0 / 45%) 0px 25px 20px -20px;
& .parchment-post-title {
@apply font-fontin uppercase;
font-size: 18px;
line-height: 24px;
letter-spacing: 0.03em;
color: #3a230f;
}
& .parchment-post-meta {
font-size: 14px;
line-height: 20px;
font-style: italic;
color: #6b5836;
}
& .parchment-post-muted {
color: #7a6a47;
}
& hr {
@apply -mx-4;
border: 0;
border-top: 1px solid rgba(60, 40, 15, 0.25);
}
& button {
color: #6b3e15;
}
& button:hover,
& button:focus {
color: #9c4a12;
}
& .parchment-post-body,
& .parchment-post-body * {
color: #2b1a0c;
}
& .parchment-post-body {
font-size: 15px;
line-height: 24px;
word-break: break-word;
overflow-wrap: anywhere;
}
& .parchment-post-body a {
color: #6b3e15;
text-decoration: underline;
cursor: pointer;
}
& .parchment-post-body a:hover {
color: #9c4a12;
}
& .parchment-post-body p {
margin: 6px 0;
}
& .parchment-post-body strong,
& .parchment-post-body b {
font-weight: 700;
}
& .parchment-post-body em,
& .parchment-post-body i {
font-style: italic;
}
& .parchment-post-body ul {
list-style: disc;
padding-left: 20px;
margin: 6px 0;
}
& .parchment-post-body ol {
list-style: decimal;
padding-left: 20px;
margin: 6px 0;
}
& .parchment-post-body blockquote {
border-left: 3px solid #b08a57;
background: rgba(120, 80, 30, 0.08);
padding: 8px 12px;
margin: 8px 0;
font-style: italic;
}
& .parchment-post-body code,
& .parchment-post-body pre,
& .parchment-post-body .codebox {
font-family: monospace;
background: rgba(60, 40, 15, 0.1);
border: 1px solid rgba(60, 40, 15, 0.2);
border-radius: 2px;
padding: 8px;
display: block;
white-space: pre-wrap;
word-break: break-word;
}
& .parchment-post-body img {
max-width: 100%;
height: auto;
}
& .parchment-post-body h1,
& .parchment-post-body h2,
& .parchment-post-body h3,
& .parchment-post-body h4 {
color: #3a230f;
}
}
.tw-hocus { .tw-hocus {
@apply hocus:text-orange hocus:drop-shadow-[0px_0px_15px_white]; @apply hocus:text-orange hocus:drop-shadow-[0px_0px_15px_white];
} }