Sync launcher: stop phantom update prompt, forum News panel, hardware-aware render distance
Build check / build (push) Has been cancelled
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:
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
dist/
|
dist*/
|
||||||
out/
|
out/
|
||||||
release/
|
release/
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Generated
+19
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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 } => {
|
||||||
|
|||||||
@@ -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
@@ -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([
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,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';
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -97,7 +97,6 @@ export const applyLocalePatch = async (
|
|||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Vendored
+5
-1
@@ -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';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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]',
|
||||||
|
|||||||
@@ -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];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user