From fbad749f0ca57c822d98e1cd3c40fd86a41270c3 Mon Sep 17 00:00:00 2001 From: OctoWoW Date: Sat, 4 Jul 2026 16:02:46 -0700 Subject: [PATCH] Sync launcher: stop phantom update prompt, forum News panel, hardware-aware render distance 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 --- .gitattributes | 6 + .gitignore | 2 +- electron-builder.yml | 15 +- package-lock.json | 21 +- package.json | 10 +- server/src/addons-resolver.ts | 1 - server/src/addons-sources.ts | 5 +- server/src/cache.ts | 7 +- server/src/index.ts | 1 - src/common/mods.ts | 3 +- src/common/schemas.ts | 28 ++- src/common/utils.ts | 1 - src/main/api/root.ts | 2 + src/main/api/routers/forum.ts | 44 ++++ src/main/api/routers/general.ts | 10 + src/main/api/routers/mods.ts | 13 ++ src/main/api/routers/news.ts | 4 +- src/main/index.ts | 50 +++- src/main/modules/addons.ts | 2 - src/main/modules/hardware.ts | 132 +++++++++++ src/main/modules/localePatch.ts | 1 - src/main/modules/mods.ts | 7 +- src/main/modules/patcher.ts | 21 +- src/main/modules/updater.ts | 22 +- src/main/types.d.ts | 6 +- src/main/utils.ts | 1 - src/renderer/assets/parchment.jpg | Bin 0 -> 31947 bytes .../components/ForumAnnouncementPanel.tsx | 218 ++++++++++++++++++ src/renderer/components/PreferencesDialog.tsx | 3 +- .../components/styled/ColoredText.tsx | 2 +- .../components/styled/DialogButton.tsx | 1 - src/renderer/components/tabs/NewsTab.tsx | 18 +- src/renderer/components/tabs/TweaksTab.tsx | 24 +- src/renderer/i18n/translations.ts | 25 +- src/renderer/index.css | 113 +++++++++ 35 files changed, 763 insertions(+), 56 deletions(-) create mode 100644 src/main/api/routers/forum.ts create mode 100644 src/main/modules/hardware.ts create mode 100644 src/renderer/assets/parchment.jpg create mode 100644 src/renderer/components/ForumAnnouncementPanel.tsx diff --git a/.gitattributes b/.gitattributes index 0802e5d..cdc1d78 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,9 @@ hooks/* text eol=lf *.sh 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 diff --git a/.gitignore b/.gitignore index b07f44e..02e3b7a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ node_modules/ -dist/ +dist*/ out/ release/ diff --git a/electron-builder.yml b/electron-builder.yml index 0ffddd2..0e0a033 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -1,18 +1,23 @@ productName: OctoLauncher +appId: st.octowow.launcher directories: buildResources: build - output: dist + output: distprod files: - '!**/.vscode/*' - '!src/*' - '!electron.vite.config.{js,ts,mjs,cjs}' - # Strip every project-root .md file from the asar bundle - '!*.md' - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,.prettierrc.cjs,dev-app-update.yml}' - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' - '!{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/**' - '!WTF/**' - '!server/**' @@ -20,10 +25,8 @@ files: - '!**/node_modules/**/{__tests__,test,tests,docs,example,examples,demo,demos,benchmark,benchmarks}/**' - '!**/node_modules/**/*.{tsx,map,markdown}' - '!**/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}' -asarUnpack: - - resources/** npmRebuild: false electronLanguages: en win: diff --git a/package-lock.json b/package-lock.json index 3297f57..74368df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "octo-launcher", - "version": "1.0.18", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "octo-launcher", - "version": "1.0.18", + "version": "1.2.0", "hasInstallScript": true, "dependencies": { "@electron-toolkit/preload": "^1.0.3", @@ -20,6 +20,7 @@ "adm-zip": "^0.5.17", "classnames": "^2.3.2", "dll-inject": "^0.0.3", + "dompurify": "^3.4.11", "electron-log": "^5.1.5", "electron-trpc": "^0.5.2", "electron-updater": "^5.3.0", @@ -1785,6 +1786,13 @@ "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.5.tgz", "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": { "version": "1.10.9", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.9.tgz", @@ -3425,6 +3433,15 @@ "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": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz", diff --git a/package.json b/package.json index 7eaa889..7e393f3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "octo-launcher", - "version": "1.1.1", + "version": "1.2.0", "description": "An Electron application for launching and updating the OctoWoW client", "author": "OctoWoW", "copyright": "Copyright © 2026 OctoWoW", @@ -9,11 +9,14 @@ "start": "electron-vite preview", "dev": "electron-vite 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:test": "electron-vite build --mode test", + "build:ptr": "electron-vite build --mode ptr", "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": { "@electron-toolkit/preload": "^1.0.3", @@ -27,6 +30,7 @@ "adm-zip": "^0.5.17", "classnames": "^2.3.2", "dll-inject": "^0.0.3", + "dompurify": "^3.4.11", "electron-log": "^5.1.5", "electron-trpc": "^0.5.2", "electron-updater": "^5.3.0", diff --git a/server/src/addons-resolver.ts b/server/src/addons-resolver.ts index 4138bd9..c1b2ba1 100644 --- a/server/src/addons-resolver.ts +++ b/server/src/addons-resolver.ts @@ -112,7 +112,6 @@ const giteaProvider: Provider = { )}` }; -// only allow known git hosts over https const parseGitUrl = ( git: string ): { owner: string; repo: string; provider: Provider } => { diff --git a/server/src/addons-sources.ts b/server/src/addons-sources.ts index 8a8736e..b3cbc87 100644 --- a/server/src/addons-sources.ts +++ b/server/src/addons-sources.ts @@ -231,8 +231,9 @@ export const defaultSources: AddonSource[] = [ description: 'Smart consolidated buff frames with extensive customization' }, { - git: 'https://github.com/acolis/VitalWatch.git', - description: 'Customizable health, mana, and aggro alert system for solo, group, and raid play' + git: 'https://octowow.st/git/shaga/LifeSafer_LowHealthWarning.git', + 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', diff --git a/server/src/cache.ts b/server/src/cache.ts index d16ebad..f78c01c 100644 --- a/server/src/cache.ts +++ b/server/src/cache.ts @@ -24,7 +24,12 @@ const skipFiles = new Set([ '.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 skipDirsPosix = new Set([ diff --git a/server/src/index.ts b/server/src/index.ts index fbbd65b..77e015f 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -79,7 +79,6 @@ app.get( const filePath = req.params[0]; console.log(`Fetching file: ${filePath}`); - // keep the resolved path inside SourceDir const root = path.resolve(SourceDir); const target = path.resolve(SourceDir, filePath); if (target !== root && !target.startsWith(root + path.sep)) { diff --git a/src/common/mods.ts b/src/common/mods.ts index 58ee7c8..d003629 100644 --- a/src/common/mods.ts +++ b/src/common/mods.ts @@ -93,8 +93,7 @@ export const MODS: ModEntry[] = [ pinnedTag: '0.2', format: 'zip', extractMap: { - 'VanillaMultiMonitorFix.dll': 'VanillaMultiMonitorFix.dll', - 'VMMFix_preferred_monitor.txt': 'VMMFix_preferred_monitor.txt' + 'VanillaMultiMonitorFix.dll': 'VanillaMultiMonitorFix.dll' } }, registerInDllsTxt: 'VanillaMultiMonitorFix.dll' diff --git a/src/common/schemas.ts b/src/common/schemas.ts index 0d360c5..c49b897 100644 --- a/src/common/schemas.ts +++ b/src/common/schemas.ts @@ -36,6 +36,18 @@ export const ModStateSchema = z.object({ }); export type ModState = z.infer; +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; + export const PreferencesSchema = z.object({ isPortable: z.boolean().optional(), server: z.enum(['live', 'ptr']).default('live'), @@ -60,7 +72,9 @@ export const PreferencesSchema = z.object({ }) .nullish(), 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; @@ -105,7 +119,7 @@ export const NewsItemSchema = z.object({ date: z.string(), body: z.string(), url: z.string().url().optional(), - author: z.string().optional() + author: z.string().nullish() }); export type NewsItem = z.infer; @@ -113,3 +127,13 @@ export const NewsFeedSchema = z.object({ items: z.array(NewsItemSchema) }); export type NewsFeed = z.infer; + +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; diff --git a/src/common/utils.ts b/src/common/utils.ts index 46e4481..f2ebd19 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -1,6 +1,5 @@ type Path = readonly (string | number)[]; -// reject prototype-polluting keys const isUnsafeKey = (key: string | number) => key === '__proto__' || key === 'constructor' || key === 'prototype'; diff --git a/src/main/api/root.ts b/src/main/api/root.ts index 38e5360..f4b7e8b 100644 --- a/src/main/api/root.ts +++ b/src/main/api/root.ts @@ -6,6 +6,7 @@ import { patcherRouter } from './routers/patcher'; import { generalRouter } from './routers/general'; import { preferencesRouter } from './routers/preferences'; import { newsRouter } from './routers/news'; +import { forumRouter } from './routers/forum'; import { modsRouter } from './routers/mods'; import { selfUpdaterRouter } from './routers/selfUpdater'; @@ -17,6 +18,7 @@ export const appRouter = createTRPCRouter({ patcher: patcherRouter, updater: updaterRouter, news: newsRouter, + forum: forumRouter, mods: modsRouter, selfUpdater: selfUpdaterRouter }); diff --git a/src/main/api/routers/forum.ts b/src/main/api/routers/forum.ts new file mode 100644 index 0000000..b1163b8 --- /dev/null +++ b/src/main/api/routers/forum.ts @@ -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 => { + 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; + } + }) +}); diff --git a/src/main/api/routers/general.ts b/src/main/api/routers/general.ts index d374e63..7bee935 100644 --- a/src/main/api/routers/general.ts +++ b/src/main/api/routers/general.ts @@ -5,11 +5,21 @@ import { z } from 'zod'; import { mainWindow } from '~main/index'; import Preferences from '~main/modules/preferences'; import { addDefenderExclusions } from '~main/modules/defender'; +import { detectHardware, recommendFarClip } from '~main/modules/hardware'; import { createTRPCRouter, publicProcedure } from '../trpc'; export const generalRouter = createTRPCRouter({ 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()), minimize: publicProcedure.mutation(() => mainWindow?.minimize()), openLink: publicProcedure diff --git a/src/main/api/routers/mods.ts b/src/main/api/routers/mods.ts index e229156..81f5d05 100644 --- a/src/main/api/routers/mods.ts +++ b/src/main/api/routers/mods.ts @@ -1,6 +1,10 @@ +import path from 'path'; + import { z } from 'zod'; import Mods from '~main/modules/mods'; +import Preferences from '~main/modules/preferences'; +import { isGameRunning } from '~main/modules/updater'; import { ModIdSchema } from '~common/mods'; import { createTRPCRouter, publicProcedure } from '../trpc'; @@ -15,5 +19,14 @@ export const modsRouter = createTRPCRouter({ .input(z.object({ id: ModIdSchema, ignore: z.boolean() })) .mutation(({ input }) => Mods.setIgnoreUpdates(input.id, input.ignore)), 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()) }); diff --git a/src/main/api/routers/news.ts b/src/main/api/routers/news.ts index fa996de..622fc86 100644 --- a/src/main/api/routers/news.ts +++ b/src/main/api/routers/news.ts @@ -8,7 +8,9 @@ import { createTRPCRouter, publicProcedure } from '../trpc'; const FETCH_TIMEOUT_MS = 8_000; const fetchNews = async (): Promise => { - 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 t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); try { diff --git a/src/main/index.ts b/src/main/index.ts index 946dc97..98507ae 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,6 @@ 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 { createIPCHandler } from 'electron-trpc/main'; import Logger from 'electron-log/main'; @@ -13,6 +13,11 @@ import Updater from './modules/updater'; import Addons from './modules/addons'; import Mods from './modules/mods'; import { initSelfUpdater } from './modules/selfUpdater'; +import { + detectHardware, + recommendFarClip, + HARDWARE_SCHEMA_VERSION +} from './modules/hardware'; Logger.initialize(); Logger.errorHandler.startCatching(); @@ -134,8 +139,51 @@ if (!gotSingleInstanceLock) { Mods.verify(); 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'); + 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) => { optimizer.watchWindowShortcuts(window); }); diff --git a/src/main/modules/addons.ts b/src/main/modules/addons.ts index 06a0fac..12472a0 100644 --- a/src/main/modules/addons.ts +++ b/src/main/modules/addons.ts @@ -53,7 +53,6 @@ const readTocData = (content: string) => const isUnsafeFolder = (name?: string) => !name || name === '.' || name === '..' || /[/\\]/.test(name); -// only allow known git hosts over https const ALLOWED_GIT_HOSTS = [ 'github.com', 'gitlab.com', @@ -150,7 +149,6 @@ class AddonsClass extends Observable { )?.[1]; } } catch { - /* ignore */ } const folder = gitUrl.slice(0, -4).split('/').at(-1); diff --git a/src/main/modules/hardware.ts b/src/main/modules/hardware.ts new file mode 100644 index 0000000..4b55b3e --- /dev/null +++ b/src/main/modules/hardware.ts @@ -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 => { + 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 => { + 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); +}; diff --git a/src/main/modules/localePatch.ts b/src/main/modules/localePatch.ts index 4b6c7de..7f9e166 100644 --- a/src/main/modules/localePatch.ts +++ b/src/main/modules/localePatch.ts @@ -97,7 +97,6 @@ export const applyLocalePatch = async ( ) return; } catch { - /* ignore */ } try { diff --git a/src/main/modules/mods.ts b/src/main/modules/mods.ts index 21396a3..cdaf799 100644 --- a/src/main/modules/mods.ts +++ b/src/main/modules/mods.ts @@ -114,7 +114,7 @@ class ModsClass extends Observable { const vmmfDll = path.join(clientDir, 'VanillaMultiMonitorFix.dll'); const vmmfCfg = path.join(clientDir, 'VMMFix_preferred_monitor.txt'); if ((await fs.pathExists(vmmfDll)) && !(await fs.pathExists(vmmfCfg))) - await fs.writeFile(vmmfCfg, '1\n', 'utf8').catch(() => {}); + await fs.writeFile(vmmfCfg, '0\n', 'utf8').catch(() => {}); } for (const m of MODS) { @@ -182,7 +182,7 @@ class ModsClass extends Observable { this.#patchRow(id, { ignoreUpdates: ignore }); } - async applyAll() { + async applyAll(opts: { repairOnly?: boolean } = {}) { const clientDir = Preferences.data?.clientDir; if (!clientDir) { Logger.warn('No clientDir set; cannot apply mods.'); @@ -192,6 +192,7 @@ class ModsClass extends Observable { Logger.warn('applyAll already running; ignoring re-entrant call.'); return; } + await this.verify(); this._value = { ...this._value, state: 'busy' }; this._notifyObservers(); @@ -219,7 +220,7 @@ class ModsClass extends Observable { await this.#install(m); } else if (!wantInstalled && isInstalled) { await this.#uninstall(m); - } else if (wantInstalled && updateAvailable) { + } else if (wantInstalled && updateAvailable && !opts.repairOnly) { await this.#uninstall(m); await this.#install(m); } diff --git a/src/main/modules/patcher.ts b/src/main/modules/patcher.ts index da69efd..afd7aa6 100644 --- a/src/main/modules/patcher.ts +++ b/src/main/modules/patcher.ts @@ -97,7 +97,6 @@ export const patchExecutable = async () => { [0x006e62a8, [0x006e62a9]] ] }, - // version-pinned in-place patch of the WoW.exe routine at this offset { synthetic: true, 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[]; @@ -180,7 +195,9 @@ export const patchConfig = async (forceTweaks = false) => { ); 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 = { scriptMemory: 512000, diff --git a/src/main/modules/updater.ts b/src/main/modules/updater.ts index 80145ac..08cc171 100644 --- a/src/main/modules/updater.ts +++ b/src/main/modules/updater.ts @@ -423,9 +423,15 @@ class UpdaterClass extends Observable { #clientTotalBytes = 0; #bytesAlreadyOnDisk = 0; #cachePath = path.join(Preferences.userDataDir, 'cache.json'); - #cache: CacheTree = fs.existsSync(this.#cachePath) - ? fs.readJSONSync(this.#cachePath) - : {}; + #cache: CacheTree = this.#readCache(); + + #readCache(): CacheTree { + try { + return fs.existsSync(this.#cachePath) ? fs.readJSONSync(this.#cachePath) : {}; + } catch { + return {}; + } + } async #saveCache() { await fs.writeJSON(this.#cachePath, this.#cache); @@ -618,7 +624,11 @@ class UpdaterClass extends Observable { const item = getManifestItem(hashTree, filePath); 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') { const files = ( @@ -832,7 +842,6 @@ class UpdaterClass extends Observable { try { if (clean) { - // never wipe a drive root or a non-client dir const resolvedClientPath = path.resolve(clientPath); const isFilesystemRoot = path.parse(resolvedClientPath).root === resolvedClientPath; @@ -854,9 +863,10 @@ class UpdaterClass extends Observable { message: 'Cleaning up old files...' }; + const preserve = new Set(['octolauncher.exe', 'wtf', 'interface', 'screenshots']); const files = await fs.readdir(clientPath); for (const file of files) { - if (file === 'OctoLauncher.exe') continue; + if (preserve.has(file.toLowerCase())) continue; await fs.rm(path.join(clientPath, file), { recursive: true, force: true diff --git a/src/main/types.d.ts b/src/main/types.d.ts index d2ffd9f..198387b 100644 --- a/src/main/types.d.ts +++ b/src/main/types.d.ts @@ -5,4 +5,8 @@ export { type ModsStatus, type ModRowStatus } from './modules/mods'; -export { type NewsItem, type NewsFeed } from '../common/schemas'; +export { + type NewsItem, + type NewsFeed, + type ForumAnnouncement +} from '../common/schemas'; diff --git a/src/main/utils.ts b/src/main/utils.ts index ccd46fa..eab9a5c 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -48,7 +48,6 @@ export const getClientVersion = async () => { const file = await fs.readFile(exePath); const buffer = Buffer.from(file); - // Fixed addresses in the 1.12.1 client binary. const VERSION_OFFSET = 0x00437c04; const VERSION_LEN = 6; const BUILD_OFFSET = 0x00437bfc; diff --git a/src/renderer/assets/parchment.jpg b/src/renderer/assets/parchment.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5164ce747f479d466a7fa32f1c2aef6463098e60 GIT binary patch literal 31947 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 { + 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(); + + const data = query.data; + const safeHtml = useMemo( + () => (data ? DOMPurify.sanitize(data.html, SANITIZE_CONFIG) : ''), + [data] + ); + + const onBodyClick = useCallback( + (e: React.MouseEvent) => { + 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 ( + + ); +}; + +export default ForumAnnouncementPanel; diff --git a/src/renderer/components/PreferencesDialog.tsx b/src/renderer/components/PreferencesDialog.tsx index 149e5a9..5d75aa6 100644 --- a/src/renderer/components/PreferencesDialog.tsx +++ b/src/renderer/components/PreferencesDialog.tsx @@ -44,6 +44,7 @@ const PreferencesDialog = ({ close }: Props) => { const setPref = api.preferences.set.useMutation(); const verify = api.updater.verify.useMutation(); + const repair = api.mods.repair.useMutation(); const openInstallFolder = api.general.openInstallFolder.useMutation(); const openLogFile = api.general.openLogFile.useMutation(); const addExclusion = api.general.addDefenderExclusion.useMutation(); @@ -147,7 +148,7 @@ const PreferencesDialog = ({ close }: Props) => {

{t('prefs.troubleshooting')}

verify.mutateAsync().then(close)} + onClick={() => repair.mutateAsync().then(close)} className="!items-start text-left text-warmGreen" > {t('prefs.verifyGameFiles')} diff --git a/src/renderer/components/styled/ColoredText.tsx b/src/renderer/components/styled/ColoredText.tsx index 67e3f71..57c9b66 100644 --- a/src/renderer/components/styled/ColoredText.tsx +++ b/src/renderer/components/styled/ColoredText.tsx @@ -2,7 +2,7 @@ type Run = { text: string; color?: string }; const tokenize = (s: string): 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 color: string | undefined; let m: RegExpExecArray | null; diff --git a/src/renderer/components/styled/DialogButton.tsx b/src/renderer/components/styled/DialogButton.tsx index 2763653..f492ab1 100644 --- a/src/renderer/components/styled/DialogButton.tsx +++ b/src/renderer/components/styled/DialogButton.tsx @@ -39,7 +39,6 @@ const DialogButton = ({ ref.current?.close(); }, []); - // Click away useEffect(() => { if (!clickAway) return; const callback = (e: MouseEvent) => e.target === ref.current && close(); diff --git a/src/renderer/components/tabs/NewsTab.tsx b/src/renderer/components/tabs/NewsTab.tsx index da2685b..b35949e 100644 --- a/src/renderer/components/tabs/NewsTab.tsx +++ b/src/renderer/components/tabs/NewsTab.tsx @@ -5,6 +5,7 @@ import { api } from '~renderer/utils/api'; import { useT } from '~renderer/i18n'; import useScrollHint from '~renderer/utils/useScrollHint'; +import ForumAnnouncementPanel from '../ForumAnnouncementPanel'; import IconSpinner from '../styled/IconSpinner'; 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 query = api.news.list.useQuery(undefined, { staleTime: 5 * 60 * 1000, @@ -59,9 +61,9 @@ const NewsTab = () => { const scrollRef = useScrollHint(); return ( -
+
-

{t('misc.newsTitle')}

+

{t('misc.announcementsTitle')}

{ ); }; +// 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 = () => ( +
+ + +
+); + export default NewsTab; diff --git a/src/renderer/components/tabs/TweaksTab.tsx b/src/renderer/components/tabs/TweaksTab.tsx index 9ce4446..65c287d 100644 --- a/src/renderer/components/tabs/TweaksTab.tsx +++ b/src/renderer/components/tabs/TweaksTab.tsx @@ -78,6 +78,15 @@ const TweaksTab = () => { }); 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 = setPref.isLoading || applyPatch.isLoading || verify.isLoading; @@ -90,7 +99,7 @@ const TweaksTab = () => { return (
{ - await setPref.mutateAsync({ config }); + await setPref.mutateAsync({ config, farClipUserSet: true }); await applyPatch.mutateAsync(); await verify.mutateAsync(); @@ -143,7 +152,11 @@ const TweaksTab = () => { id="farClip" label={t('tweaks.farClip.label')} type="number" - text={t('tweaks.farClip.text')} + text={farClipText} + recommended={ + recommendedFarClip != null && + Number(farClipValue) === recommendedFarClip + } min={100} max={10000} sensitivity={3} @@ -199,8 +212,11 @@ const TweaksTab = () => {

{ - const config = ConfigWtfSchema.parse({}); - await setPref.mutateAsync({ config }); + const config = + recommendedFarClip != null + ? { ...ConfigWtfSchema.parse({}), farClip: recommendedFarClip } + : ConfigWtfSchema.parse({}); + await setPref.mutateAsync({ config, farClipUserSet: false }); reset(config); }} > diff --git a/src/renderer/i18n/translations.ts b/src/renderer/i18n/translations.ts index 94d797b..803d9c9 100644 --- a/src/renderer/i18n/translations.ts +++ b/src/renderer/i18n/translations.ts @@ -48,7 +48,8 @@ const enUS: Dict = { 'Recommended for widescreen window resolutions. [Vanilla: 90] [Tweaks: 110]', 'tweaks.farClip.label': 'Render distance', 'tweaks.farClip.text': - 'Increases maximum render distance. [Vanilla: 777] [Tweaks: 10000]', + '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.text': 'Changes ground clutter render distance. [Vanilla: 70] [Tweaks: 300]', @@ -65,6 +66,7 @@ const enUS: Dict = { 'tweaks.reset': 'Reset', 'tweaks.apply': 'Apply', 'misc.newsTitle': 'News', + 'misc.announcementsTitle': 'Announcements', 'misc.newsByAuthor': 'by {author}', 'misc.newsReadMore': 'Read more', 'misc.refresh': 'Refresh', @@ -72,6 +74,11 @@ const enUS: Dict = { 'misc.newsError': "Couldn't reach the news feed.", 'misc.newsEmpty': 'No news yet. Check back later.', '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.selfUpdateCheckFailed': 'Update check failed: {message}', 'misc.selfUpdateAvailable': @@ -268,7 +275,8 @@ const deDE: Dict = { 'Empfohlen für Breitbild-Fensterauflösungen. [Vanilla: 90] [Tweaks: 110]', 'tweaks.farClip.label': 'Sichtweite', 'tweaks.farClip.text': - 'Erhöht die maximale Sichtweite. [Vanilla: 777] [Tweaks: 10000]', + '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.text': 'Ändert die Renderdistanz für Bodendetails. [Vanilla: 70] [Tweaks: 300]', @@ -490,7 +498,9 @@ const zhCN: Dict = { 'tweaks.fieldOfView.text': '推荐用于宽屏窗口分辨率。[Vanilla: 90] [Tweaks: 110]', 'tweaks.farClip.label': '渲染距离', - 'tweaks.farClip.text': '增加最大渲染距离。[Vanilla: 777] [Tweaks: 10000]', + 'tweaks.farClip.text': + '增加最大渲染距离。数值过高可能导致进入游戏世界时崩溃。[Vanilla: 777]', + 'tweaks.farClip.recommendedHint': '为您的电脑推荐:{value}。', 'tweaks.frillDistance.label': '地表杂物距离', 'tweaks.frillDistance.text': '更改地表杂物的渲染距离。[Vanilla: 70] [Tweaks: 300]', @@ -698,7 +708,8 @@ const esES: Dict = { 'Recomendado para resoluciones de ventana panorámicas. [Vanilla: 90] [Tweaks: 110]', 'tweaks.farClip.label': 'Distancia de renderizado', 'tweaks.farClip.text': - 'Aumenta la distancia máxima de renderizado. [Vanilla: 777] [Tweaks: 10000]', + '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.text': '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]', 'tweaks.farClip.label': 'Distância de renderização', '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.text': '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]', 'tweaks.farClip.label': 'Дальность прорисовки', 'tweaks.farClip.text': - 'Увеличивает максимальную дальность прорисовки. [Vanilla: 777] [Tweaks: 10000]', + 'Увеличивает максимальную дальность прорисовки. Слишком высокие значения могут привести к вылету при входе в мир. [Vanilla: 777]', + 'tweaks.farClip.recommendedHint': 'Рекомендовано для вашего ПК: {value}.', 'tweaks.frillDistance.label': 'Дальность травы и деталей', 'tweaks.frillDistance.text': 'Изменяет дальность прорисовки травы и мелких деталей земли. [Vanilla: 70] [Tweaks: 300]', diff --git a/src/renderer/index.css b/src/renderer/index.css index 084e476..3d4bacd 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -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 { @apply hocus:text-orange hocus:drop-shadow-[0px_0px_15px_white]; }