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 0000000..5164ce7 Binary files /dev/null and b/src/renderer/assets/parchment.jpg differ diff --git a/src/renderer/components/ForumAnnouncementPanel.tsx b/src/renderer/components/ForumAnnouncementPanel.tsx new file mode 100644 index 0000000..10b5533 --- /dev/null +++ b/src/renderer/components/ForumAnnouncementPanel.tsx @@ -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(); + + 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]; }