5 Commits

Author SHA1 Message Date
OctoWoW fbad749f0c 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 <noreply@anthropic.com>
2026-07-04 16:02:46 -07:00
octoadmin 16e442ea0f ColoredText: case-insensitive color-code regex so uppercase |c/|r titles render
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 09:59:13 -07:00
OctoWoW 1047a90704 Fixed tweaks and mods, added localization, added antivirus walkthrough 2026-06-28 19:34:40 +00:00
octoadmin c2f7b7d6e4 Point addon sources at maintained forks; nampower to Emyrk v4.6.2 2026-06-20 15:05:43 -07:00
OctoWoW 14ab791f9b updating open source launcher 2026-06-20 01:43:20 -07:00
67 changed files with 5131 additions and 961 deletions
+6
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
node_modules/
dist/
dist*/
out/
release/
+9 -6
View File
@@ -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:
+19 -2
View File
@@ -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",
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "octo-launcher",
"version": "1.0.27",
"version": "1.2.0",
"description": "An Electron application for launching and updating the OctoWoW client",
"author": "OctoWoW",
"copyright": "Copyright © 2026 OctoWoW",
@@ -12,8 +12,11 @@
"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",
+23
View File
@@ -0,0 +1,23 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY server/package.json server/package-lock.json* ./
RUN npm install --omit=dev --no-audit --no-fund
COPY server/src/ ./src/
FROM node:22-alpine AS runtime
RUN adduser -D -H -s /sbin/nologin manifest
WORKDIR /app
COPY --from=build --chown=manifest:manifest /app /app
RUN npm install --no-save --no-audit --no-fund tsx@^4.21.0
USER manifest
EXPOSE 5000
ENV SOURCE_DIR=/srv/source
ENTRYPOINT ["node", "--import", "tsx/esm", "src/index.ts"]
+545
View File
@@ -14,6 +14,9 @@
"fs-extra": "^11.1.1",
"ts-node": "^10.9.1",
"typescript": "^5.2.2"
},
"devDependencies": {
"tsx": "^4.21.0"
}
},
"node_modules/@cspotcode/source-map-support": {
@@ -27,6 +30,448 @@
"node": ">=12"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz",
@@ -336,6 +781,48 @@
"node": ">= 0.8"
}
},
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -436,6 +923,21 @@
"node": ">=14.14"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -458,6 +960,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-tsconfig": {
"version": "4.14.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/gopd": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
@@ -717,6 +1232,16 @@
"node": ">= 0.8"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -873,6 +1398,26 @@
}
}
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+5 -3
View File
@@ -4,12 +4,11 @@
"main": "index.ts",
"type": "module",
"scripts": {
"dev": "node --loader ts-node/esm src/index.ts"
"dev": "tsx src/index.ts"
},
"dependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.9.0",
"dotenv": "^16.0.0",
"express": "^4.18.2",
"fs-extra": "^11.1.1",
"ts-node": "^10.9.1",
@@ -18,5 +17,8 @@
"eslintConfig": {
"extends": "@haaxor1689/eslint-config"
},
"prettier": "@haaxor1689/prettier-config"
"prettier": "@haaxor1689/prettier-config",
"devDependencies": {
"tsx": "^4.21.0"
}
}
+140 -47
View File
@@ -26,6 +26,9 @@ type CacheEntry = { at: number; data: ResolvedAddon[] };
let cache: CacheEntry | undefined;
let inFlight: Promise<ResolvedAddon[]> | undefined;
const normalizeColorCodes = (s: string): string =>
s.replace(/\|C(?=[0-9a-fA-F]{8})/g, '|c').replace(/\|R/g, '|r');
const parseToc = (content: string): TocData =>
content
.split('\n')
@@ -38,7 +41,7 @@ const parseToc = (content: string): TocData =>
})
.filter((e): e is readonly [string, string] => !!e)
.reduce<TocData>((acc, [k, v]) => {
acc[k] = v;
acc[k] = normalizeColorCodes(v);
return acc;
}, {});
@@ -52,57 +55,137 @@ const fetchWithTimeout = async (url: string, init?: RequestInit) => {
}
};
const parseGitUrl = (git: string) => {
// https://github.com/{owner}/{repo}.git
const m = git.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/);
if (!m || !m[1] || !m[2]) throw Error(`Unsupported git URL: ${git}`);
return { owner: m[1], repo: m[2] };
type RepoMeta = {
description?: string;
defaultBranch?: string;
lastUpdated?: string;
stars?: number;
};
type RawMeta = {
description?: string | null;
default_branch?: string;
pushed_at?: string | null;
updated_at?: string | null;
stargazers_count?: number | null;
stars_count?: number | null;
};
type Provider = {
apiUrl: (owner: string, repo: string) => string;
apiHeaders: () => Record<string, string>;
mapMeta: (json: RawMeta) => RepoMeta;
tocUrl: (owner: string, repo: string, ref: string, name: string) => string;
};
const githubProvider: Provider = {
apiUrl: (o, r) => `https://api.github.com/repos/${o}/${r}`,
apiHeaders: () => ({
Accept: 'application/vnd.github+json',
...(process.env.GITHUB_TOKEN && {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`
})
}),
mapMeta: j => ({
description: j.description ?? undefined,
defaultBranch: j.default_branch,
lastUpdated: j.pushed_at ?? undefined,
stars: j.stargazers_count ?? undefined
}),
tocUrl: (o, r, ref, name) =>
`https://raw.githubusercontent.com/${o}/${r}/${ref}/${name}.toc`
};
const GITEA_API = 'https://octowow.st/git/api/v1';
const giteaProvider: Provider = {
apiUrl: (o, r) => `${GITEA_API}/repos/${o}/${r}`,
apiHeaders: () => ({ Accept: 'application/json' }),
mapMeta: j => ({
description: j.description ?? undefined,
defaultBranch: j.default_branch,
lastUpdated: j.updated_at ?? undefined,
stars: j.stars_count ?? undefined
}),
tocUrl: (o, r, ref, name) =>
`${GITEA_API}/repos/${o}/${r}/raw/${name}.toc?ref=${encodeURIComponent(
ref
)}`
};
const parseGitUrl = (
git: string
): { owner: string; repo: string; provider: Provider } => {
const gh = git.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/);
if (gh && gh[1] && gh[2]) {
return { owner: gh[1], repo: gh[2], provider: githubProvider };
}
const gitea = git.match(/octowow\.st\/git\/([^/]+)\/([^/]+?)(?:\.git)?$/);
if (gitea && gitea[1] && gitea[2]) {
return { owner: gitea[1], repo: gitea[2], provider: giteaProvider };
}
throw Error(`Unsupported git URL: ${git}`);
};
const REQUIRED_TOC_KEYS = ['Interface'];
const tryFetchToc = async (
provider: Provider,
owner: string,
repo: string,
name: string,
ref: string
): Promise<TocData | undefined> => {
const res = await fetchWithTimeout(
provider.tocUrl(owner, repo, ref, name)
).catch(() => null);
if (!res?.ok) return undefined;
const parsed = parseToc(await res.text());
return REQUIRED_TOC_KEYS.every(k => typeof parsed[k] === 'string')
? parsed
: undefined;
};
const resolveOne = async (src: AddonSource): Promise<ResolvedAddon | null> => {
try {
const { owner, repo } = parseGitUrl(src.git);
const { owner, repo, provider } = parseGitUrl(src.git);
const name = src.name ?? repo;
const branch = src.branch ?? 'master';
const tocRef = src.ref ?? branch;
const tocUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${tocRef}/${name}.toc`;
const apiUrl = `https://api.github.com/repos/${owner}/${repo}`;
const apiRes = await fetchWithTimeout(provider.apiUrl(owner, repo), {
headers: provider.apiHeaders()
}).catch(() => null);
const [tocRes, apiRes] = await Promise.all([
fetchWithTimeout(tocUrl).catch(() => null),
fetchWithTimeout(apiUrl, {
headers: {
Accept: 'application/vnd.github+json',
...(process.env.GITHUB_TOKEN && {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`
})
}
}).catch(() => null)
]);
let meta: RepoMeta | undefined;
if (apiRes?.ok) meta = provider.mapMeta((await apiRes.json()) as RawMeta);
const candidates = src.ref
? [src.ref]
: src.branch
? [src.branch]
: [
...new Set(
[meta?.defaultBranch, 'main', 'master'].filter(
(b): b is string => !!b
)
)
];
let toc: TocData | undefined;
if (tocRes?.ok) {
const parsed = parseToc(await tocRes.text());
const required = ['Interface', 'Title', 'Author', 'Notes', 'Version'];
if (required.every(k => typeof parsed[k] === 'string')) {
toc = parsed;
let resolvedRef: string | undefined;
for (const ref of candidates) {
toc = await tryFetchToc(provider, owner, repo, name, ref);
if (toc) {
resolvedRef = ref;
break;
}
}
let description: string | undefined;
let lastUpdated: string | undefined;
let stars: number | undefined;
if (apiRes?.ok) {
const meta = (await apiRes.json()) as {
description?: string;
pushed_at?: string;
stargazers_count?: number;
};
description = meta.description ?? undefined;
lastUpdated = meta.pushed_at;
stars = meta.stargazers_count;
}
const effectiveBranch = src.ref
? undefined
: src.branch ?? resolvedRef ?? meta?.defaultBranch;
let description = meta?.description ?? undefined;
const lastUpdated = meta?.lastUpdated;
const stars = meta?.stars;
if (src.description) {
description = src.description;
@@ -110,7 +193,7 @@ const resolveOne = async (src: AddonSource): Promise<ResolvedAddon | null> => {
}
const result: ResolvedAddon = { name, owner, git: src.git };
if (src.branch !== undefined) result.branch = src.branch;
if (effectiveBranch !== undefined) result.branch = effectiveBranch;
if (src.ref !== undefined) result.ref = src.ref;
if (toc !== undefined) result.toc = toc;
if (description !== undefined) result.description = description;
@@ -147,26 +230,37 @@ const loadSources = async (): Promise<AddonSource[]> => {
if (!SOURCES_OVERRIDE_PATH) return defaultSources;
try {
if (await fs.pathExists(SOURCES_OVERRIDE_PATH)) {
const override = (await fs.readJSON(SOURCES_OVERRIDE_PATH)) as AddonSource[];
const override = (await fs.readJSON(
SOURCES_OVERRIDE_PATH
)) as AddonSource[];
if (Array.isArray(override) && override.length > 0) {
console.log(`Using addon sources override from ${SOURCES_OVERRIDE_PATH}`);
console.log(
`Using addon sources override from ${SOURCES_OVERRIDE_PATH}`
);
return override;
}
}
} catch (e) {
console.error(`Failed to read override at ${SOURCES_OVERRIDE_PATH}, using defaults:`, e);
console.error(
`Failed to read override at ${SOURCES_OVERRIDE_PATH}, using defaults:`,
e
);
}
return defaultSources;
};
const buildList = async (): Promise<ResolvedAddon[]> => {
const sources = await loadSources();
console.log(`Resolving metadata for ${sources.length} addons (concurrency=${FETCH_CONCURRENCY})...`);
console.log(
`Resolving metadata for ${sources.length} addons (concurrency=${FETCH_CONCURRENCY})...`
);
const t0 = Date.now();
const results = await poolMap(sources, FETCH_CONCURRENCY, resolveOne);
const ok = results.filter((r): r is ResolvedAddon => r !== null);
ok.sort((a, b) => a.name.localeCompare(b.name));
console.log(`Resolved ${ok.length}/${sources.length} addons in ${Date.now() - t0}ms`);
console.log(
`Resolved ${ok.length}/${sources.length} addons in ${Date.now() - t0}ms`
);
return ok;
};
@@ -174,7 +268,6 @@ export const getAddons = async (force = false): Promise<ResolvedAddon[]> => {
if (!force && cache && Date.now() - cache.at < CACHE_TTL_MS) {
return cache.data;
}
// Deduplicate concurrent callers — only one scrape in flight at a time.
if (inFlight) return inFlight;
inFlight = buildList()
.then(data => {
+247 -196
View File
@@ -1,196 +1,247 @@
export type AddonSource = {
git: string;
branch?: string;
name?: string;
description?: string;
ref?: string;
};
export const defaultSources: AddonSource[] = [
{ git: 'https://github.com/CosminPOP/AtlasLoot.git', name: 'AtlasLoot' },
{
git: 'https://github.com/byCFM2/Atlas-TW.git',
branch: 'main',
ref: 'pre-rewrite-backup'
},
{
git: 'https://github.com/shirsig/aux-addon-vanilla.git',
name: 'aux-addon',
description: 'Auction House replacement with advanced filtering and search'
},
{ git: 'https://github.com/absir/Bagshui.git', branch: 'main' },
{ git: 'https://github.com/pepopo978/BetterCharacterStats.git', branch: 'main' },
{ git: 'https://github.com/pepopo978/BigWigs.git' },
{
git: 'https://github.com/DBFBlackbull/BitesCookBook.git',
description: 'Tracks which items are used in cooking and what they create'
},
{ git: 'https://github.com/bhhandley/CleveRoidMacros.git', branch: 'main' },
{
git: 'https://github.com/Cinecom/ConsumesManager.git',
branch: 'main',
description: 'Tracks consumables and food buffs across alts, bank, and mail'
},
{
git: 'https://github.com/Kirchlive/cursive-raid.git',
name: 'Cursive-Raid',
description: 'Raid debuff tracker with profiles and multi-curse assist (SuperWoW)'
},
{ git: 'https://github.com/Player-Doite/DoiteAuras.git', branch: 'main' },
{ git: 'https://github.com/Stormhand-dev/DragonflightUI-Reforged.git', branch: 'main' },
{
git: 'https://github.com/Fiurs-Hearth/ExtraResourceBars.git',
description: 'Adds extra resource bars (mana, energy, rage) to the UI'
},
{ git: 'https://github.com/tilare/FlightTracker.git', branch: 'main' },
{ git: 'https://github.com/lookino/Flyout.git', branch: 'main' },
{
git: 'https://github.com/trumpetx/GetHead.git',
description: 'Recovers Onyxia and Nefarian heads from disenchant grief'
},
{
git: 'https://github.com/zanthor/GNS.git',
branch: 'main',
description: 'Custom naming for Goblin Brainwashing Device specializations'
},
{ git: 'https://github.com/vatichild/guda.git', name: 'Guda', branch: 'main' },
{ git: 'https://github.com/vatichild/GudaPlates.git', branch: 'main' },
{ git: 'https://github.com/andresuarezschou/HCDeaths.git', branch: 'main' },
{
git: 'https://github.com/Arthur-Helias/InstanceJournal.git',
description: "Encounter Journal reimagined for Turtle WoW"
},
{
git: 'https://github.com/Einherjarn/ItemRack.git',
description: 'Item set manager with quick-swap menus for inventory'
},
{
git: 'https://github.com/CosminPOP/_LazyPig.git',
name: '_LazyPig',
description: 'Auto-dismount, auto-accept, auto-roll, and chat spam filter. /lp to configure'
},
{ git: 'https://github.com/Spartelfant/LevelRange-Turtle.git', branch: 'main' },
{ git: 'https://github.com/tilare/MessageBox.git', branch: 'main' },
{
git: 'https://github.com/tdymel/ModifiedPowerAuras.git',
description: "Advanced version of Sinesther's Power Auras"
},
{
git: 'https://github.com/tilare/ModernMapMarkers.git',
branch: 'main',
description: 'Shows dungeons, raids, world bosses, and travel routes on the world map'
},
{
git: 'https://github.com/vegeta1k95/ModernSpellBook.git',
description: 'Retail-style spellbook UI for vanilla'
},
{ git: 'https://github.com/tilare/MovementTracker.git', branch: 'main' },
{
git: 'https://github.com/pepopo978/NampowerSettings.git',
description: 'Settings panel for the Nampower spellqueue addon'
},
{
git: 'https://github.com/BlackHobbiT/necrosis-twow.git',
branch: 'main',
description: 'Warlock helper: pets, soul shards, summoning, demon timers'
},
{
git: 'https://github.com/zanthor/OG-RaidHelper.git',
branch: 'main',
description: 'Raid management: roles, trade distribution, soft-reserve validation'
},
{
git: 'https://github.com/CosminPOP/PallyPower.git',
description: 'Paladin buff and assignment manager for raids and parties'
},
{
git: 'https://github.com/Cliencer/pfExtend.git',
branch: 'main',
description: 'pfQuest extension showing all monster drops and quest chains. /pfex'
},
{ git: 'https://github.com/shagu/pfQuest.git' },
{ git: 'https://github.com/shagu/pfQuest-turtle.git' },
{ git: 'https://github.com/shagu/pfUI.git' },
{
git: 'https://github.com/jrc13245/pfUI-addonskinner.git',
branch: 'main',
description: 'pfUI module that re-skins other addons to match the pfUI theme'
},
{
git: 'https://github.com/Bombg/pfUI-bettertotems.git',
branch: 'main',
description: 'pfUI module with improved Shaman totem timers'
},
{
git: 'https://github.com/Arthur-Helias/pfUI-LocationPlus.git',
name: 'pfUI-locplus',
description: 'Adds a location panel and zone info to pfUI'
},
{ git: 'https://github.com/acid9000/PizzaWorldBuffs.git', branch: 'main' },
{
git: 'https://github.com/npfs666/ProcDoc.git',
branch: 'main',
description: 'Visual proc alerts with pulsing images so you never miss them'
},
{ git: 'https://github.com/SabineWren/Quiver.git', branch: 'main' },
{
git: 'https://github.com/hazlema/Rested.git',
description: 'Progress bar showing your rested XP while resting'
},
{ git: 'https://github.com/Otari98/Rinse.git' },
{
git: 'https://github.com/anzz1/SellValue.git',
description: 'Shows item vendor sell value in tooltips when not at a vendor'
},
{ git: 'https://github.com/shagu/ShaguDPS.git' },
{
git: 'https://github.com/shagu/ShaguPlates.git',
description: 'Nameplates with castbars and class colors. /splates'
},
{ git: 'https://github.com/shagu/ShaguTweaks.git' },
{
git: 'https://github.com/shagu/ShaguTweaks-extras.git',
description: 'Extras module for ShaguTweaks (additional UI tweaks)'
},
{ git: 'https://github.com/pepopo978/SimpleActionSets.git' },
{ git: 'https://github.com/Siventt/AttackBar.git' },
{
git: 'https://github.com/Player-Doite/Tactica.git',
branch: 'main',
description: 'Auto-build raids: invite/gearcheck, tactics, masterloot, role sync'
},
{
git: 'https://github.com/Otari98/Tmog.git',
description: 'Transmog item browser with collection info in tooltips'
},
{
git: 'https://github.com/whtmst/T-RestedXP.git',
branch: 'main',
description: 'Tracks 0% and 100% rested XP thresholds'
},
{ git: 'https://github.com/sica42/TurtleCalendar.git', branch: 'main' },
{
git: 'https://github.com/sica42/TurtleMail.git',
description: 'Mailbox UI enhancement: bulk send, search, multi-mail'
},
{ git: 'https://github.com/tempranova/turtlerp.git', name: 'TurtleRP', branch: 'main' },
{ git: 'https://github.com/CosminPOP/TWThreat.git' },
{
git: 'https://github.com/whtmst/UnitXP_SP3_Addon.git',
branch: 'main',
description: 'Settings UI for the UnitXP SuperWoW client patch'
},
{
git: 'https://github.com/tdymel/VCB.git',
description: 'Smart consolidated buff frames with extensive customization'
},
{
git: 'https://github.com/Fiurs-Hearth/WIIIUI.git',
description: 'Compact custom UI replacement for Turtle WoW'
},
{ git: 'https://github.com/refaim/WIM.git' },
{
git: 'https://github.com/Arthur-Helias/ZonesLevel.git',
description: "Shows zone level range under the title on the world map"
}
];
export type AddonSource = {
git: string;
branch?: string;
name?: string;
description?: string;
ref?: string;
};
export const defaultSources: AddonSource[] = [
{
git: 'https://github.com/Alukarho/AI_VoiceOver.git',
description: 'Adds AI-generated voice acting to NPC quest and gossip dialogue'
},
{
git: 'https://github.com/McPewPew/ArcHUD2.git',
description: 'Combat HUD showing health and power as arcs around your character'
},
{ git: 'https://github.com/CosminPOP/AtlasLoot.git', name: 'AtlasLoot' },
{
git: 'https://github.com/byCFM2/Atlas-TW.git',
name: 'Atlas-CFM'
},
{
git: 'https://github.com/Road-block/AuldLangSyne.git',
description: 'Adds personal notes to friends, ignore, and guild lists, remembered while offline'
},
{
git: 'https://github.com/FSuhas/AutoLFM.git',
description: 'Automated "Looking For More" broadcaster for Turtle WoW dungeons and raids'
},
{
git: 'https://github.com/shirsig/aux-addon-vanilla.git',
name: 'aux-addon',
description: 'Auction House replacement with advanced filtering and search'
},
{ git: 'https://github.com/absir/Bagshui.git' },
{ git: 'https://github.com/pepopo978/BetterCharacterStats.git', branch: 'main' },
{ git: 'https://github.com/pepopo978/BigWigs.git' },
{
git: 'https://github.com/DBFBlackbull/BitesCookBook.git',
description: 'Tracks which items are used in cooking and what they create'
},
{
git: 'https://github.com/brotalnia/BlizzPlates.git',
description: 'Adds castbars, debuffs, and class icons to the default Blizzard nameplates'
},
{
git: 'https://github.com/MDGitHubRepo/CallOfElements.git',
description: 'All-in-one Shaman totem bar and totem/healing manager'
},
{ git: 'https://github.com/bhhandley/CleveRoidMacros.git' },
{
git: 'https://github.com/Cinecom/ConsumesManager.git',
description: 'Tracks consumables and food buffs across alts, bank, and mail'
},
{
git: 'https://github.com/Kirchlive/cursive-raid.git',
name: 'Cursive-Raid',
description: 'Raid debuff tracker with profiles and multi-curse assist (SuperWoW)'
},
{
git: 'https://github.com/Zerf/Decursive.git',
description: 'Raid/party debuff-cleaning helper that dispels whoever needs it'
},
{
git: 'https://github.com/DeterminedPanda/DifficultBulletinBoard.git',
description: 'Organizes LFG, profession, and hardcore chat announcements into a bulletin board'
},
{ git: 'https://github.com/Player-Doite/DoiteAuras.git' },
{ git: 'https://github.com/Stormhand-dev/DragonflightUI-Reforged.git' },
{
git: 'https://github.com/Fiurs-Hearth/ExtraResourceBars.git',
description: 'Adds extra resource bars (mana, energy, rage) to the UI'
},
{
git: 'https://github.com/SeVeN7000/FishingBuddy.git',
description: 'Auto-equips fishing gear and tracks catches, fish, and zone info'
},
{ git: 'https://github.com/tilare/FlightTracker.git' },
{ git: 'https://github.com/lookino/Flyout.git' },
{
git: 'https://github.com/trumpetx/GetHead.git',
description: 'Recovers Onyxia and Nefarian heads from disenchant grief'
},
{
git: 'https://github.com/zanthor/GNS.git',
description: 'Custom naming for Goblin Brainwashing Device specializations'
},
{ git: 'https://github.com/vatichild/guda.git', name: 'Guda' },
{ git: 'https://github.com/vatichild/GudaPlates.git' },
{ git: 'https://github.com/andresuarezschou/HCDeaths.git' },
{
git: 'https://github.com/Arthur-Helias/InstanceJournal.git',
description: "Encounter Journal reimagined for Turtle WoW"
},
{
git: 'https://github.com/Einherjarn/ItemRack.git',
description: 'Item set manager with quick-swap menus for inventory'
},
{
git: 'https://github.com/CosminPOP/_LazyPig.git',
name: '_LazyPig',
description: 'Auto-dismount, auto-accept, auto-roll, and chat spam filter. /lp to configure'
},
{ git: 'https://github.com/Spartelfant/LevelRange-Turtle.git' },
{ git: 'https://github.com/tilare/MessageBox.git' },
{
git: 'https://github.com/tdymel/ModifiedPowerAuras.git',
description: "Advanced version of Sinesther's Power Auras"
},
{
git: 'https://github.com/tilare/ModernMapMarkers.git',
description: 'Shows dungeons, raids, world bosses, and travel routes on the world map'
},
{
git: 'https://github.com/vegeta1k95/ModernSpellBook.git',
description: 'Retail-style spellbook UI for vanilla'
},
{ git: 'https://github.com/tilare/MovementTracker.git' },
{
git: 'https://github.com/Dusk-92/NampowerSettings.git',
description: 'Settings panel for the Nampower spellqueue addon'
},
{
git: 'https://github.com/BlackHobbiT/necrosis-twow.git',
name: 'Necrosis',
description: 'Warlock helper: pets, soul shards, summoning, demon timers'
},
{
git: 'https://github.com/gnwl/NotGrid.git',
name: 'notgrid',
description: 'Grid-like compact party/raid frames with buff/debuff, aggro, and proximity tracking'
},
{
git: 'https://github.com/zanthor/OG-RaidHelper.git',
description: 'Raid management: roles, trade distribution, soft-reserve validation'
},
{
git: 'https://github.com/sica42/Outfitter.git',
description: 'Equipment set manager to save and quickly swap gear outfits, with Turtle mount fixes'
},
{
git: 'https://github.com/CosminPOP/PallyPower.git',
description: 'Paladin buff and assignment manager for raids and parties'
},
{
git: 'https://github.com/Cliencer/pfExtend.git',
description: 'pfQuest extension showing all monster drops and quest chains. /pfex'
},
{ git: 'https://github.com/shagu/pfQuest.git' },
{ git: 'https://github.com/shagu/pfQuest-turtle.git' },
{ git: 'https://github.com/shagu/pfUI.git' },
{
git: 'https://github.com/jrc13245/pfUI-addonskinner.git',
description: 'pfUI module that re-skins other addons to match the pfUI theme'
},
{
git: 'https://github.com/Bombg/pfUI-bettertotems.git',
description: 'pfUI module with improved Shaman totem timers'
},
{
git: 'https://github.com/Arthur-Helias/pfUI-LocationPlus.git',
name: 'pfUI-locplus',
description: 'Adds a location panel and zone info to pfUI'
},
{ git: 'https://github.com/acid9000/PizzaWorldBuffs.git' },
{
git: 'https://github.com/npfs666/ProcDoc.git',
description: 'Visual proc alerts with pulsing images so you never miss them'
},
{ git: 'https://github.com/SabineWren/Quiver.git' },
{
git: 'https://github.com/hazlema/Rested.git',
description: 'Progress bar showing your rested XP while resting'
},
{ git: 'https://github.com/Otari98/Rinse.git' },
{
git: 'https://github.com/anzz1/SellValue.git',
description: 'Shows item vendor sell value in tooltips when not at a vendor'
},
{ git: 'https://github.com/shagu/ShaguDPS.git' },
{
git: 'https://github.com/shagu/ShaguPlates.git',
description: 'Nameplates with castbars and class colors. /splates'
},
{ git: 'https://github.com/shagu/ShaguTweaks.git' },
{
git: 'https://github.com/shagu/ShaguTweaks-extras.git',
description: 'Extras module for ShaguTweaks (additional UI tweaks)'
},
{ git: 'https://github.com/pepopo978/SimpleActionSets.git' },
{
git: 'https://github.com/balakethelock/SuperAPI.git',
description: 'Companion compatibility addon bridging the SuperWoW client mod\'s expanded Lua API'
},
{
git: 'https://github.com/jrc13245/SuperMacro-turtle-SuperWoW.git',
name: 'SuperMacro',
description: 'Extended macros with long macros, keybind execution, item links, and a code editor'
},
{ git: 'https://github.com/Siventt/AttackBar.git' },
{
git: 'https://github.com/Player-Doite/Tactica.git',
description: 'Auto-build raids: invite/gearcheck, tactics, masterloot, role sync'
},
{
git: 'https://github.com/Otari98/Tmog.git',
description: 'Transmog item browser with collection info in tooltips'
},
{
git: 'https://github.com/whtmst/T-RestedXP.git',
description: 'Tracks 0% and 100% rested XP thresholds'
},
{ git: 'https://github.com/sica42/TurtleCalendar.git' },
{
git: 'https://github.com/sica42/TurtleMail.git',
description: 'Mailbox UI enhancement: bulk send, search, multi-mail'
},
{ git: 'https://github.com/tempranova/turtlerp.git', name: 'TurtleRP' },
{ git: 'https://github.com/CosminPOP/TWThreat.git' },
{
git: 'https://github.com/RetroCro/unitscan-turtle-hc.git',
description: 'Hardcore unitscan fork for Turtle WoW that alerts on rares, elites, and dangerous mobs'
},
{
git: 'https://github.com/whtmst/UnitXP_SP3_Addon.git',
description: 'Settings UI for the UnitXP SuperWoW client patch'
},
{
git: 'https://github.com/tdymel/VCB.git',
description: 'Smart consolidated buff frames with extensive customization'
},
{
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',
description: 'Compact custom UI replacement for Turtle WoW'
},
{ git: 'https://github.com/refaim/WIM.git' },
{
git: 'https://github.com/Arthur-Helias/ZonesLevel.git',
description: "Shows zone level range under the title on the world map"
}
];
+225 -10
View File
@@ -16,7 +16,33 @@ const allowedExtra = [
const vanillaFixes = ['VfPatcher.dll', 'd3d9.dll', 'dxvk.conf'];
const skipFiles = new Set(['manifest.json', 'wow-client.zip', '.gitkeep']);
const skipFiles = new Set([
'manifest.json',
'manifest.json.tmp',
'wow-client.zip',
'.gitkeep',
'.manifest-overrides.json'
]);
const skipPatterns: RegExp[] = [
/\.bak([.\-]|$)/,
/\.crashing(\.|$)/,
/\.torrent$/,
/^manifest\.json\./
];
const isSkipPattern = (file: string) => skipPatterns.some(p => p.test(file));
const skipDirsPosix = new Set([
'Interface/GlueXML',
'Interface/FrameXML',
'Errors',
'Logs',
'Screenshots',
'WDB',
'WTF/Account'
]);
const isSkipDir = (...filePath: string[]) =>
skipDirsPosix.has(filePath.join('/'));
type FolderTags = 'allowExtra';
type FileTags = 'vanillaFixes';
@@ -31,8 +57,23 @@ type FileManifest = { name: string } & (
size: number;
tags?: FileTags[];
}
| { type: 'del' }
);
export type BuildProgress = {
state: 'idle' | 'building' | 'ready' | 'failed';
done: number;
total: number;
currentFile: string;
startedAt: number | null;
finishedAt: number | null;
error: string | null;
};
export type ProgressCallback = (
p: Pick<BuildProgress, 'done' | 'total' | 'currentFile'>
) => void;
const getHash = (...filePath: string[]): Promise<string> =>
new Promise((resolve, reject) => {
const hash = crypto.createHash('sha1');
@@ -42,9 +83,100 @@ const getHash = (...filePath: string[]): Promise<string> =>
stream.on('end', () => resolve(hash.digest('hex').toLocaleUpperCase()));
});
export const buildCache = async (clientPath: string) => {
const countFiles = async (
clientPath: string,
...filePath: string[]
): Promise<number> => {
let total = 0;
const dir = path.join(clientPath, ...filePath);
const files = await fs.readdir(dir);
for (const file of files.sort()) {
if (skipFiles.has(file)) continue;
if (isSkipPattern(file)) continue;
const stats = await fs.stat(path.join(dir, file));
if (stats.isDirectory()) {
if (isSkipDir(...filePath, file)) continue;
if (file.match(/patch-./)) {
const mpqPath = path.join(dir, `${file}.mpq`);
if (await fs.pathExists(mpqPath)) total += 1;
}
total += await countFiles(clientPath, ...filePath, file);
} else {
total += 1;
}
}
return total;
};
export const buildCache = async (
clientPath: string,
onProgress?: ProgressCallback
) => {
console.log('Building cache...');
const prevManifestPath = path.join(clientPath, 'manifest.json');
let prevManifestMtimeMs = 0;
const prevHashByPath = new Map<string, string>();
const prevVersionByPath = new Map<string, number | undefined>();
const prevSizeByPath = new Map<string, number>();
try {
const prevStat = await fs.stat(prevManifestPath);
prevManifestMtimeMs = prevStat.mtimeMs;
const prev = await fs.readJSON(prevManifestPath);
const walk = (node: FileManifest, prefix: string[]) => {
if (node.type === 'dir' || node.type === 'mpq') {
const newPrefix = node.name ? [...prefix, node.name] : prefix;
if (node.type === 'mpq') {
const mpqKey = [...newPrefix.slice(0, -1), node.name + '.mpq'].join(
'/'
);
prevHashByPath.set(mpqKey, node.hash);
prevSizeByPath.set(mpqKey, node.size);
}
for (const child of node.files) walk(child, newPrefix);
} else {
const key = [...prefix, node.name].join('/');
prevHashByPath.set(key, node.hash);
prevVersionByPath.set(key, node.version);
prevSizeByPath.set(key, node.size);
}
};
walk(prev.root, []);
console.log(
`mtime-skip: loaded ${prevHashByPath.size} cached hashes from ` +
`prior manifest (mtime=${new Date(prevManifestMtimeMs).toISOString()})`
);
} catch (e) {
console.log(
'mtime-skip: no usable prior manifest, full rebuild ' +
`(${(e as Error).message})`
);
}
const total = await countFiles(clientPath);
let done = 0;
let reused = 0;
const tick = (currentFile: string) => {
done += 1;
onProgress?.({ done, total, currentFile });
};
console.log(`Building cache: ${total} files to hash...`);
const getHashCached = async (
relPath: string,
mtimeMs: number,
...filePath: string[]
): Promise<string> => {
if (prevManifestMtimeMs > 0 && mtimeMs <= prevManifestMtimeMs) {
const cached = prevHashByPath.get(relPath);
if (cached) {
reused++;
return cached;
}
}
return getHash(clientPath, ...filePath);
};
const buildTree = async (...filePath: string[]): Promise<FileManifest[]> => {
const files = await fs.readdir(path.join(clientPath, ...filePath));
@@ -52,21 +184,34 @@ export const buildCache = async (clientPath: string) => {
const tree: FileManifest[] = [];
for (const file of files.sort()) {
if (skipFiles.has(file)) continue;
if (isSkipPattern(file)) continue;
const stats = await fs.stat(path.join(clientPath, ...filePath, file));
if (stats.isDirectory()) {
if (isSkipDir(...filePath, file)) continue;
if (file.match(/patch-./)) {
patches.push(file);
const mpqRelPath = path
.join(...filePath, `${file}.mpq`)
.split(path.sep)
.join('/');
const mpqStat = await fs.stat(
path.join(clientPath, ...filePath, `${file}.mpq`)
);
tree.push({
type: 'mpq',
name: file,
files: await buildTree(...filePath, file),
size: (
await fs.stat(path.join(clientPath, ...filePath, `${file}.mpq`))
).size,
hash: await getHash(clientPath, ...filePath, `${file}.mpq`)
size: mpqStat.size,
hash: await getHashCached(
mpqRelPath,
mpqStat.mtimeMs,
...filePath,
`${file}.mpq`
)
});
tick(mpqRelPath);
} else {
const tags: FolderTags[] = [];
allowedExtra.includes(path.join(...filePath, file)) &&
@@ -81,8 +226,8 @@ export const buildCache = async (clientPath: string) => {
continue;
}
// Skip if extracted mpq patch
if (patches.find(v => file.match(v))) continue;
const allowModifiedPaths = new Set([
'WTF/Config.wtf',
'Data/fonts.MPQ',
@@ -102,22 +247,92 @@ export const buildCache = async (clientPath: string) => {
tree.push({
type: 'file',
name: file,
hash: await getHash(clientPath, ...filePath, file),
hash: await getHashCached(fullPath, stats.mtimeMs, ...filePath, file),
version: allowModified ? stats.mtimeMs : undefined,
size: stats.size,
tags: tags.length ? tags : undefined
});
tick(fullPath);
}
return tree;
};
await fs.writeJSON(path.join(clientPath, 'manifest.json'), {
const rootFiles = await buildTree();
const overridesPath = path.join(clientPath, '.manifest-overrides.json');
try {
if (await fs.pathExists(overridesPath)) {
const ov = await fs.readJSON(overridesPath);
const dels: string[] = Array.isArray(ov.del) ? ov.del : [];
for (const relPath of dels) {
const parts = relPath.split('/').filter(Boolean);
if (parts.length === 0) continue;
const fileName = parts.pop()!;
let dirNode: FileManifest = {
type: 'dir',
name: '',
files: rootFiles
} as FileManifest;
let ok = true;
for (const seg of parts) {
if (dirNode.type !== 'dir' && dirNode.type !== 'mpq') {
ok = false;
break;
}
let child = dirNode.files.find(f => f.name === seg);
if (!child) {
child = {
type: 'dir',
name: seg,
files: [],
tags: ['allowExtra']
};
dirNode.files.push(child);
}
dirNode = child;
}
if (!ok || (dirNode.type !== 'dir' && dirNode.type !== 'mpq')) {
console.warn(
`manifest-overrides: del path "${relPath}" hit a non-dir ` +
`node, skipping`
);
continue;
}
const exists = dirNode.files.some(
f => f.name === fileName && f.type === 'del'
);
if (!exists) {
dirNode.files.push({ type: 'del', name: fileName } as FileManifest);
console.log(
`manifest-overrides: inserted {type:'del', name:'${fileName}'} ` +
`under ${parts.join('/') || '<root>'}`
);
}
}
}
} catch (e) {
console.warn(
`manifest-overrides: failed to apply ${overridesPath}, continuing` +
`without overrides (${(e as Error).message})`
);
}
const finalPath = path.join(clientPath, 'manifest.json');
const tmpPath = path.join(clientPath, 'manifest.json.tmp');
await fs.writeJSON(tmpPath, {
build: 3,
buildName: '3',
root: {
type: 'dir',
name: '',
files: await buildTree()
files: rootFiles
}
});
await fs.rename(tmpPath, finalPath);
if (prevManifestMtimeMs > 0) {
console.log(
`mtime-skip: reused ${reused}/${total} cached hashes ` +
`(re-hashed ${total - reused})`
);
}
};
+114 -35
View File
@@ -1,61 +1,92 @@
import path from 'path';
import { config as loadEnv } from 'dotenv';
import express from 'express';
loadEnv();
import fs from 'fs-extra';
import { buildCache } from './cache.js';
import { buildCache, type BuildProgress } from './cache.js';
import { getAddons, warmUp as warmUpAddons } from './addons-resolver.js';
// Set SOURCE_DIR to your local WoW client directory (see server/.env.example).
const SourceDir: string = (() => {
const dir = process.env.SOURCE_DIR;
if (!dir) {
console.error(
'ERROR: SOURCE_DIR is not set.\n' +
'Set it to your local WoW client directory.\n' +
'Example: SOURCE_DIR="C:\\\\WoW\\\\client" npm run dev\n' +
'Or create server/.env — see server/.env.example.'
);
process.exit(1);
}
return dir;
})();
const SourceDir = process.env.SOURCE_DIR || './client';
const app = express();
const port = 5000;
const buildProgress: BuildProgress = {
state: 'idle',
done: 0,
total: 0,
currentFile: '',
startedAt: null,
finishedAt: null,
error: null
};
let buildInFlight: Promise<void> | null = null;
const ensureManifestBuilt = (): Promise<void> => {
if (buildInFlight) return buildInFlight;
buildInFlight = buildCache(SourceDir).catch(e => {
buildInFlight = null;
throw e;
});
buildProgress.state = 'building';
buildProgress.done = 0;
buildProgress.total = 0;
buildProgress.currentFile = '';
buildProgress.startedAt = Date.now();
buildProgress.finishedAt = null;
buildProgress.error = null;
buildInFlight = buildCache(SourceDir, p => {
buildProgress.done = p.done;
buildProgress.total = p.total;
buildProgress.currentFile = p.currentFile;
})
.then(() => {
buildProgress.state = 'ready';
buildProgress.finishedAt = Date.now();
})
.catch(e => {
buildProgress.state = 'failed';
buildProgress.error = e instanceof Error ? e.message : String(e);
buildProgress.finishedAt = Date.now();
buildInFlight = null;
throw e;
});
return buildInFlight;
};
app.get('/api/build-status', (_req, res) => {
res.json(buildProgress);
});
app.get('/api/file/:version/manifest.json', async (_req, res) => {
console.log(`Fetching manifest`);
const filePath = path.join(SourceDir, 'manifest.json');
if (!fs.existsSync(filePath)) await ensureManifestBuilt();
res.json(await fs.readJSON(filePath));
if (await fs.pathExists(filePath)) {
res.json(await fs.readJSON(filePath));
return;
}
void ensureManifestBuilt().catch(() => {});
res.setHeader('Retry-After', '5');
res.status(503).json({
error: 'manifest_building',
message:
'Manifest is being built for the first time on this server. ' +
'Poll /api/build-status for progress; retry this endpoint when ready.',
buildProgress
});
});
app.get(
'/api/file/:version/*',
async (req: express.Request<{ 0: string }>, res) => {
const filePath = req.params[0];
const resolved = path.resolve(SourceDir, filePath);
if (!resolved.startsWith(path.resolve(SourceDir) + path.sep)) {
res.status(403).send('Forbidden');
console.log(`Fetching file: ${filePath}`);
const root = path.resolve(SourceDir);
const target = path.resolve(SourceDir, filePath);
if (target !== root && !target.startsWith(root + path.sep)) {
res.status(403).end();
return;
}
console.log(`Fetching file: ${filePath}`);
res.sendFile(resolved);
res.sendFile(target);
}
);
@@ -70,19 +101,67 @@ app.get('/api/addons.json', async (req, res) => {
}
});
const newestSourceMtime = async (dir: string): Promise<number> => {
let newest = 0;
const entries = await fs.readdir(dir);
for (const name of entries) {
if (name === 'manifest.json' || name === 'manifest.json.tmp') continue;
const full = path.join(dir, name);
const stat = await fs.stat(full);
if (stat.isDirectory()) {
const inner = await newestSourceMtime(full);
if (inner > newest) newest = inner;
} else if (stat.mtimeMs > newest) {
newest = stat.mtimeMs;
}
}
return newest;
};
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
warmUpAddons();
void (async () => {
const manifestPath = path.join(SourceDir, 'manifest.json');
if (fs.existsSync(manifestPath)) return;
console.log(`Pre-warming manifest cache for ${SourceDir}...`);
if (!fs.existsSync(manifestPath)) {
console.log(`Pre-warming manifest cache for ${SourceDir}...`);
try {
await ensureManifestBuilt();
console.log(`Manifest cache pre-warm complete.`);
} catch (e) {
console.error(
'Manifest pre-warm failed (will fall back to lazy build on first request):',
e
);
}
return;
}
buildProgress.state = 'ready';
buildProgress.finishedAt = Date.now();
try {
await ensureManifestBuilt();
console.log(`Manifest cache pre-warm complete.`);
const manifestStat = await fs.stat(manifestPath);
const newest = await newestSourceMtime(SourceDir);
if (newest > manifestStat.mtimeMs) {
console.log(
`Manifest is stale (newest source mtime ${new Date(
newest
).toISOString()} > manifest ${new Date(
manifestStat.mtimeMs
).toISOString()}); rebuilding in background.`
);
ensureManifestBuilt()
.then(() => console.log('Background manifest rebuild complete.'))
.catch(e => console.error('Background manifest rebuild failed:', e));
} else {
console.log(
`Manifest cache already on disk at ${manifestPath} and up to date; no rebuild needed.`
);
}
} catch (e) {
console.error('Manifest pre-warm failed:', e);
console.error('Manifest staleness check failed:', e);
}
})();
});
+8 -9
View File
@@ -15,8 +15,6 @@ export type ModSource =
| {
kind: 'directFile';
url: string;
versionUrl?: string;
latestVersionUrl?: string;
parseLatest?: 'githubRelease' | 'gitlabRelease' | 'codebergRelease';
apiUrl?: string;
pinnedTag?: string;
@@ -25,7 +23,6 @@ export type ModSource =
| {
kind: 'archive';
url: string;
latestVersionUrl?: string;
apiUrl?: string;
parseLatest?: 'githubRelease' | 'gitlabRelease' | 'codebergRelease';
pinnedTag?: string;
@@ -67,15 +64,15 @@ export const MODS: ModEntry[] = [
{
id: 'nampower',
name: 'nampower',
version: 'v4.6.0',
version: 'v4.6.2',
description:
'A client modification that minimizes your input lag if you have higher latency.',
repoUrl: 'https://gitea.com/avitasia/nampower',
repoUrl: 'https://github.com/Emyrk/nampower',
requires: ['vanillaFixes'],
source: {
kind: 'directFile',
url: 'https://gitea.com/avitasia/nampower/releases/download/v4.6.0/nampower.dll',
pinnedTag: 'v4.6.0',
url: 'https://github.com/Emyrk/nampower/releases/download/v4.6.2/nampower.dll',
pinnedTag: 'v4.6.2',
assetName: 'nampower.dll'
},
registerInDllsTxt: 'nampower.dll'
@@ -139,7 +136,8 @@ export const MODS: ModEntry[] = [
id: 'vanillaFixes',
name: 'vanillaFixes',
version: 'v1.5.3',
description: 'A client modification that eliminates stutter and animation lag.',
description:
'A client modification that eliminates stutter and animation lag.',
recommended: true,
repoUrl: 'https://github.com/hannesmann/vanillafixes',
source: {
@@ -160,7 +158,8 @@ export const MODS: ModEntry[] = [
id: 'vanillaHelpers',
name: 'vanillaHelpers',
version: 'v1.1.2',
description: 'Utility library that might be required by other patches and addons.',
description:
'Utility library that might be required by other patches and addons.',
repoUrl: 'https://github.com/isfir/VanillaHelpers',
requires: ['vanillaFixes'],
source: {
+32 -3
View File
@@ -36,6 +36,18 @@ export const ModStateSchema = z.object({
});
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({
isPortable: z.boolean().optional(),
server: z.enum(['live', 'ptr']).default('live'),
@@ -44,7 +56,12 @@ export const PreferencesSchema = z.object({
lastPatchedLauncherVersion: z.string().optional(),
expectedPatchedWowHash: z.string().optional(),
minimizeToTrayOnPlay: f.boolean(true),
cleanWdb: f.boolean(),
cleanWdb: f.boolean(true),
locale: z
.enum(['enUS', 'deDE', 'zhCN', 'esES', 'ptBR', 'ruRU'])
.default('enUS'),
localePatchLetter: z.string().optional(),
localePatchLocale: z.string().optional(),
rememberPosition: f.boolean(),
windowPosition: z
.object({
@@ -55,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<typeof PreferencesSchema>;
@@ -100,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<typeof NewsItemSchema>;
@@ -108,3 +127,13 @@ export const NewsFeedSchema = z.object({
items: z.array(NewsItemSchema)
});
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>;
+9 -1
View File
@@ -1,10 +1,18 @@
type Path = readonly (string | number)[];
const isUnsafeKey = (key: string | number) =>
key === '__proto__' || key === 'constructor' || key === 'prototype';
export const nestedGet = <T>(object: unknown, path: Path) =>
path.reduce((obj, key) => obj?.[key], object) as T;
path.reduce(
(obj, key) => (isUnsafeKey(key) ? undefined : obj?.[key]),
object
) as T;
export const nestedSet = (obj: any, path: Path, value: unknown) => {
const [key, ...rest] = path;
if (isUnsafeKey(key)) return;
if (path.length === 1) {
obj[key] = value;
return;
+2
View File
@@ -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
});
+44
View File
@@ -0,0 +1,44 @@
import fetch from 'node-fetch';
import Logger from 'electron-log/main';
import { ForumAnnouncementSchema, type ForumAnnouncement } from '~common/schemas';
import { createTRPCRouter, publicProcedure } from '../trpc';
const FETCH_TIMEOUT_MS = 8_000;
const fetchLatestAnnouncement = async (): Promise<ForumAnnouncement | null> => {
const url = `${
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
}/forum/octonews.php?forum=35&mode=full`;
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw Error(`HTTP ${res.status}`);
const json = (await res.json()) as unknown;
if (!json || typeof json !== 'object' || !('id' in json)) return null;
const parsed = ForumAnnouncementSchema.safeParse(json);
if (!parsed.success) {
Logger.error(
'Forum announcement failed schema validation',
parsed.error.flatten()
);
throw Error('Malformed forum announcement');
}
return parsed.data;
} finally {
clearTimeout(t);
}
};
export const forumRouter = createTRPCRouter({
latestAnnouncement: publicProcedure.query(async () => {
try {
return await fetchLatestAnnouncement();
} catch (e) {
Logger.error('Failed to fetch forum announcement', e);
throw e;
}
})
});
+12
View File
@@ -4,11 +4,22 @@ 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
@@ -22,6 +33,7 @@ export const generalRouter = createTRPCRouter({
const file = Logger.transports.file.getFile().path;
shell.openPath(file);
}),
addDefenderExclusion: publicProcedure.mutation(() => addDefenderExclusions()),
filePicker: publicProcedure
.input(
z.object({
+63 -58
View File
@@ -2,7 +2,6 @@ import path from 'path';
import { spawn } from 'child_process';
import fs from 'fs-extra';
import { inject } from 'dll-inject';
import Logger from 'electron-log/main';
import Preferences from '~main/modules/preferences';
@@ -10,95 +9,101 @@ import Mods from '~main/modules/mods';
import { mainWindow } from '~main/index';
import { isGameRunning } from '~main/modules/updater';
import { patchConfig } from '~main/modules/patcher';
import { applyLocalePatch } from '~main/modules/localePatch';
import { minimizeToTray, restoreFromTray } from '~main/modules/tray';
import { getMod } from '~common/mods';
import { createTRPCRouter, publicProcedure } from '../trpc';
const ensureChainloaderTweak = async (clientDir: string): Promise<boolean> => {
if (Preferences.data.config.vanillaFixes) return true;
const chainloaderNeeded = async (clientDir: string): Promise<boolean> => {
const installed = Mods.status.mods.filter(r => r.installedVersion);
if (installed.some(r => r.id === 'vanillaFixes')) return true;
if (installed.some(r => getMod(r.id)?.requires?.includes('vanillaFixes')))
return true;
const installedMods = Mods.status.mods.filter(r => r.installedVersion);
const anyDependsOnVf = installedMods.some(r =>
getMod(r.id)?.requires?.includes('vanillaFixes')
);
let dllsTxtHasEntries = false;
const dllsPath = path.join(clientDir, 'dlls.txt');
if (await fs.pathExists(dllsPath)) {
const raw = await fs.readFile(dllsPath, 'utf8');
dllsTxtHasEntries = raw
.split(/\r?\n/)
.some(l => l.trim() && !l.trim().startsWith('#'));
return raw.split(/\r?\n/).some(l => l.trim() && !l.trim().startsWith('#'));
}
if (!anyDependsOnVf && !dllsTxtHasEntries) return false;
Logger.info(
`Auto-enabling vanillaFixes Tweak (chainloader required): ${
anyDependsOnVf ? 'a dependent mod is installed' : ''
}${anyDependsOnVf && dllsTxtHasEntries ? ' + ' : ''}${
dllsTxtHasEntries ? 'dlls.txt has user entries' : ''
}.`
);
Preferences.data = {
config: { ...Preferences.data.config, vanillaFixes: true }
};
return true;
return false;
};
export const launcherRouter = createTRPCRouter({
start: publicProcedure.mutation(async () => {
const { cleanWdb, minimizeToTrayOnPlay, config, clientDir } =
Preferences.data;
if (!clientDir) return false;
type StartResult = { ok: boolean; error?: string };
const clientPath = path.join(clientDir, 'WoW.exe');
Logger.log(`Launching ${clientPath}...`);
if (await isGameRunning(clientPath)) return false;
export const launcherRouter = createTRPCRouter({
start: publicProcedure.mutation(async (): Promise<StartResult> => {
const { cleanWdb, minimizeToTrayOnPlay, clientDir } = Preferences.data;
if (!clientDir) return { ok: false, error: 'No game folder is set.' };
const exePath = path.join(clientDir, 'WoW.exe');
if (!(await fs.pathExists(exePath)))
return { ok: false, error: 'WoW.exe was not found in the game folder.' };
if (await isGameRunning(exePath))
return { ok: false, error: 'WoW is already running.' };
if (cleanWdb) {
Logger.log('Cleaning up WDB...');
await fs.remove(path.join(clientPath, 'WDB'));
await fs.remove(path.join(clientDir, 'WDB'));
}
Logger.log('Checking Config.wtf...');
await patchConfig();
Logger.log('Launching WoW...');
const process = spawn(clientPath, { detached: !minimizeToTrayOnPlay });
Logger.log('Applying UI language...');
await applyLocalePatch(clientDir, Preferences.data.locale);
const wantChainloader = await ensureChainloaderTweak(clientDir);
if (wantChainloader) {
Logger.log('Injecting VanillaFixes...');
const vfPath = path.join(clientDir, 'VfPatcher.dll');
const loaderPath = path.join(clientDir, 'VanillaFixes.exe');
const needsLoader = await chainloaderNeeded(clientDir);
const useLoader = needsLoader && (await fs.pathExists(loaderPath));
if (needsLoader && !useLoader)
Logger.warn(
'VanillaFixes.exe is missing but mods/dlls.txt expect a chainloader; ' +
'launching WoW.exe directly (mods will not load).'
);
if (!(await fs.pathExists(vfPath))) {
Logger.warn(
`VfPatcher.dll missing at ${vfPath} — chainloader needed but ` +
'the vanillaFixes mod is not installed. Skipping inject; ' +
'dlls.txt entries and dependent mods will not load. Install ' +
"vanillaFixes from the Mods tab to fix."
);
} else {
const status = inject('WoW.exe', vfPath);
if (status) {
Logger.error(`Injecting failed with error code ${status}...`);
return true;
}
}
const octoLocale = Preferences.data.locale || 'enUS';
const gameEnv = { ...process.env, OCTO_LOCALE: octoLocale };
Logger.log(
useLoader
? `Launching via VanillaFixes (OCTO_LOCALE=${octoLocale})...`
: `Launching ${exePath} (OCTO_LOCALE=${octoLocale})...`
);
const child = useLoader
? spawn(loaderPath, ['WoW.exe'], {
env: gameEnv,
cwd: clientDir,
detached: !minimizeToTrayOnPlay
})
: spawn(exePath, {
env: gameEnv,
cwd: clientDir,
detached: !minimizeToTrayOnPlay
});
try {
await new Promise<void>((resolve, reject) => {
child.once('spawn', resolve);
child.once('error', reject);
});
} catch (e) {
Logger.error('Failed to launch the game', e);
const message = e instanceof Error ? e.message : String(e);
return { ok: false, error: `Failed to launch the game: ${message}` };
}
child.on('error', e => Logger.error('Game process error', e));
if (!minimizeToTrayOnPlay) {
mainWindow?.close();
return true;
return { ok: true };
}
minimizeToTray();
process.on('exit', () => {
child.on('exit', () => {
Logger.log('WoW stopped');
restoreFromTray();
});
return true;
return { ok: true };
})
});
+13
View File
@@ -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())
});
+3 -1
View File
@@ -8,7 +8,9 @@ import { createTRPCRouter, publicProcedure } from '../trpc';
const FETCH_TIMEOUT_MS = 8_000;
const fetchNews = async (): Promise<NewsItem[]> => {
const url = `${import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'}/news.json`;
const 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 {
+3 -1
View File
@@ -1,5 +1,6 @@
import { patchConfig, patchExecutable } from '~main/modules/patcher';
import Preferences from '~main/modules/preferences';
import Updater from '~main/modules/updater';
import { getClientVersion } from '~main/utils';
import { createTRPCRouter, publicProcedure } from '../trpc';
@@ -7,7 +8,8 @@ import { createTRPCRouter, publicProcedure } from '../trpc';
export const patcherRouter = createTRPCRouter({
apply: publicProcedure.mutation(async () => {
await patchExecutable();
await patchConfig();
await patchConfig(true);
await Updater.recordPatchedWow();
Preferences.data = { version: await getClientVersion() };
})
});
+3
View File
@@ -2,6 +2,7 @@ import { z } from 'zod';
import { PreferencesSchema } from '~common/schemas';
import Preferences from '~main/modules/preferences';
import { applyLocalePatch } from '~main/modules/localePatch';
import { createTRPCRouter, publicProcedure } from '../trpc';
@@ -11,6 +12,8 @@ export const preferencesRouter = createTRPCRouter({
.input(PreferencesSchema.partial())
.mutation(async ({ input }) => {
Preferences.data = input;
if (input.locale !== undefined)
await applyLocalePatch(Preferences.data.clientDir, input.locale);
return Preferences.data;
}),
isValidClientDir: publicProcedure
+115 -25
View File
@@ -1,6 +1,6 @@
import { join } from 'path';
import { app, shell, BrowserWindow } 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();
@@ -22,10 +27,33 @@ app.disableHardwareAcceleration();
export let mainWindow: BrowserWindow | null = null;
const isOnScreen = (
pos?: {
x: number;
y: number;
width: number;
height: number;
} | null
) => {
if (!pos) return false;
return screen.getAllDisplays().some(d => {
const a = d.workArea;
return (
pos.x < a.x + a.width &&
pos.x + pos.width > a.x &&
pos.y < a.y + a.height &&
pos.y + pos.height > a.y
);
});
};
const createWindow = async () => {
const position = Preferences.data.rememberPosition
? Preferences.data.windowPosition
: { width: 1000, height: 700 };
const saved =
Preferences.data.rememberPosition &&
isOnScreen(Preferences.data.windowPosition)
? Preferences.data.windowPosition
: undefined;
const position = saved ?? { width: 1000, height: 700 };
mainWindow = new BrowserWindow({
...position,
@@ -50,16 +78,22 @@ const createWindow = async () => {
Logger.error('Renderer unresponsive');
});
mainWindow.webContents.on('console-message', (_e, level, message, line, sourceId) => {
const lvl = level === 3 ? 'error' : level === 2 ? 'warn' : 'info';
Logger[lvl](`[renderer:${lvl}] ${message} (${sourceId}:${line})`);
});
mainWindow.webContents.on(
'console-message',
(_e, level, message, line, sourceId) => {
const lvl = level === 3 ? 'error' : level === 2 ? 'warn' : 'info';
Logger[lvl](`[renderer:${lvl}] ${message} (${sourceId}:${line})`);
}
);
mainWindow.webContents.on('before-input-event', (_e, input) => {
if (input.type !== 'keyDown') return;
if (input.key === 'F12') {
mainWindow?.webContents.toggleDevTools();
return;
}
if ((input.control || input.meta) && input.key.toLowerCase() === 'c')
mainWindow?.webContents.copy();
});
createIPCHandler({ router: appRouter, windows: [mainWindow] });
@@ -77,7 +111,7 @@ const createWindow = async () => {
const [width = 0, height = 0] = mainWindow.getSize();
Preferences.data = { windowPosition: { x, y, width, height } };
});
if (is.dev && process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL);
} else {
@@ -85,23 +119,79 @@ const createWindow = async () => {
}
};
app.whenReady().then(async () => {
Preferences.data = await Preferences.load();
const gotSingleInstanceLock = is.dev || app.requestSingleInstanceLock();
Addons.verify();
Updater.verify();
Mods.verify();
initSelfUpdater();
electronApp.setAppUserModelId('com.electron');
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window);
if (!gotSingleInstanceLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
if (!mainWindow.isVisible()) mainWindow.show();
mainWindow.focus();
});
await createWindow();
});
app.whenReady().then(async () => {
Preferences.data = await Preferences.load();
app.on('window-all-closed', async () => {
app.quit();
});
Addons.verify();
Updater.verify();
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);
});
await createWindow();
});
app.on('window-all-closed', () => {
app.quit();
});
}
+74 -24
View File
@@ -35,22 +35,50 @@ type AddonsList = {
}[];
const readTocData = (content: string) =>
content
(content.charCodeAt(0) === 0xfeff ? content.slice(1) : content)
.split('\n')
.filter(l => l.startsWith('## '))
.map(l => l.slice(3))
.map(l => {
const [key, value] = l.split(':');
return [key.trim(), value.trim()];
const idx = l.indexOf(':');
if (idx === -1) return null;
return [l.slice(0, idx).trim(), l.slice(idx + 1).trim()] as const;
})
.filter((e): e is readonly [string, string] => !!e)
.reduce((acc, [key, value]) => {
acc[key] = value;
return acc;
}, {} as TocData);
const isUnsafeFolder = (name?: string) =>
!name || name === '.' || name === '..' || /[/\\]/.test(name);
const ALLOWED_GIT_HOSTS = [
'github.com',
'gitlab.com',
'gitea.com',
'codeberg.org',
'octowow.st'
];
const isAllowedGitUrl = (url: string) => {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:') return false;
const host = parsed.hostname.toLowerCase();
return ALLOWED_GIT_HOSTS.some(h => host === h || host.endsWith('.' + h));
} catch {
return false;
}
};
const fetchAddons = async () => {
try {
const response = await fetch(`${import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'}/api/addons.json`);
const response = await fetch(
`${
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
}/api/addons.json`
);
return (await response.json()) as AddonsList;
} catch (e) {
Logger.error('Failed to reach update server', e);
@@ -103,35 +131,35 @@ class AddonsClass extends Observable<AddonsStatus> {
};
async checkGitUrl(url: string) {
const gitUrl = url.endsWith('.git') ? url : `${url}.git`;
const clean = url.trim().replace(/\/+$/, '');
const gitUrl = clean.endsWith('.git') ? clean : `${clean}.git`;
if (!isAllowedGitUrl(gitUrl)) return undefined;
try {
await git.getRemoteInfo({
http,
url: gitUrl
});
// Only fetch preview from known public git hosts to prevent SSRF.
const allowed = ['github.com', 'gitlab.com', 'gitea.com', 'codeberg.org'];
let preview: string | undefined;
try {
const host = new URL(url).hostname.toLowerCase();
if (allowed.some(h => host === h || host.endsWith('.' + h))) {
if (isAllowedGitUrl(url)) {
const response = await fetch(url).then(r => r.text());
preview = response.match(
/property="og:image" content="([^"]*)"/
)?.[1];
}
} catch {
// preview stays undefined
}
const folder = gitUrl.slice(0, -4).split('/').at(-1);
if (isUnsafeFolder(folder)) return undefined;
return {
status: 'available',
folder: gitUrl.slice(0, -4).split('/').at(-1),
folder,
git: gitUrl,
preview
} as AddonData;
} catch (e) {
} catch {
return undefined;
}
}
@@ -162,7 +190,7 @@ class AddonsClass extends Observable<AddonsStatus> {
}
const addonsPath = path.join(clientPath, 'Interface', 'Addons');
const dirs = await fs.pathExists(addonsPath)
const dirs = (await fs.pathExists(addonsPath))
? await fs.readdir(addonsPath)
: [];
const addons: AddonsStatus['addons'] = Object.fromEntries(
@@ -223,9 +251,7 @@ class AddonsClass extends Observable<AddonsStatus> {
.catch(() => null);
const remoteCommit = avail?.ref
? await git
.resolveRef({ fs, dir, ref: avail.ref })
.catch(() => null)
? await git.resolveRef({ fs, dir, ref: avail.ref }).catch(() => null)
: await git
.log({ fs, dir, ref: `${remote.remote}/${branch}`, depth: 1 })
.then(r => r[0].oid)
@@ -249,7 +275,9 @@ class AddonsClass extends Observable<AddonsStatus> {
Logger.log(
isUpToDate
? `Addon "${folder}" is up to date${avail?.ref ? ` (pinned ${avail.ref})` : ''}`
? `Addon "${folder}" is up to date${
avail?.ref ? ` (pinned ${avail.ref})` : ''
}`
: `Addon "${folder}" has an update available`
);
} catch (e) {
@@ -268,13 +296,16 @@ class AddonsClass extends Observable<AddonsStatus> {
const VERIFY_CONCURRENCY = 6;
let idx = 0;
await Promise.all(
Array.from({ length: Math.min(VERIFY_CONCURRENCY, folders.length) }, async () => {
while (true) {
const i = idx++;
if (i >= folders.length) return;
await verifyOne(folders[i]);
Array.from(
{ length: Math.min(VERIFY_CONCURRENCY, folders.length) },
async () => {
while (true) {
const i = idx++;
if (i >= folders.length) return;
await verifyOne(folders[i]);
}
}
})
)
);
this.status = { ...this.status, state: 'done' };
@@ -330,7 +361,7 @@ class AddonsClass extends Observable<AddonsStatus> {
{ onProgress: this.#onProgress(folder, data) }
);
}
const toc = await readTocData(
const toc = readTocData(
await fs.readFile(path.join(dir, `${folder}.toc`), 'utf-8')
);
@@ -363,6 +394,25 @@ class AddonsClass extends Observable<AddonsStatus> {
async install(data: AddonData) {
const clientPath = Preferences.data.clientDir;
if (!clientPath) return;
if (isUnsafeFolder(data.folder)) {
Logger.error(`Refusing addon with unsafe folder name: "${data.folder}"`);
this.#setAddon(data.folder, {
...data,
status: 'invalid',
error: 'Invalid addon name'
});
return;
}
if (!data.git || !isAllowedGitUrl(data.git)) {
Logger.error(`Refusing addon from disallowed git host: "${data.git}"`);
this.#setAddon(data.folder, {
...data,
status: 'invalid',
error: 'Addon URL is not from an allowed git host'
});
return;
}
const addonsPath = path.join(clientPath, 'Interface', 'Addons');
const dir = path.join(addonsPath, data.folder);
+80
View File
@@ -0,0 +1,80 @@
import { spawn } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
import { app } from 'electron';
import Logger from 'electron-log/main';
import Preferences from './preferences';
export type ExclusionResult = { ok: boolean; error?: string; paths?: string[] };
const psSingleQuote = (s: string) => `'${s.replace(/'/g, "''")}'`;
export const addDefenderExclusions = async (): Promise<ExclusionResult> => {
if (os.platform() !== 'win32')
return {
ok: false,
error: 'Antivirus exclusions are only needed on Windows.'
};
const clientDir = Preferences.data.clientDir;
if (!clientDir)
return {
ok: false,
error: 'Set your game folder first, then add the exclusion.'
};
const launcherDir =
process.env.PORTABLE_EXECUTABLE_DIR ?? path.dirname(app.getPath('exe'));
const paths = [...new Set([clientDir, launcherDir])];
const inner = [
'$ErrorActionPreference = "Stop"',
'try {',
...paths.map(p => `Add-MpPreference -ExclusionPath ${psSingleQuote(p)}`),
'Add-MpPreference -ExclusionProcess "WoW.exe"',
'Add-MpPreference -ExclusionProcess "VanillaFixes.exe"',
'exit 0',
'} catch { exit 2 }'
].join('\n');
const encoded = Buffer.from(inner, 'utf16le').toString('base64');
const outer =
'try { $p = Start-Process powershell -Verb RunAs -WindowStyle Hidden ' +
"-Wait -PassThru -ArgumentList '-NoProfile','-NonInteractive'," +
`'-EncodedCommand','${encoded}'; exit $p.ExitCode } catch { exit 1 }`;
return new Promise<ExclusionResult>(resolve => {
const child = spawn(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-Command', outer],
{ windowsHide: true }
);
let stderr = '';
child.stderr.on('data', d => (stderr += String(d)));
child.on('error', e => {
Logger.error('Failed to launch PowerShell for Defender exclusion', e);
resolve({ ok: false, error: 'Could not run Windows PowerShell.' });
});
child.on('exit', code => {
if (code === 0) {
Logger.info(`Added Defender exclusions: ${paths.join(', ')}`);
resolve({ ok: true, paths });
} else if (code === 1) {
resolve({
ok: false,
error:
'No permission granted. Click Yes on the Windows prompt to add the exclusion.'
});
} else {
Logger.error(`Defender exclusion failed (code ${code}): ${stderr}`);
resolve({
ok: false,
error:
'Could not add the exclusion automatically. You may need to add it in Windows Security manually.'
});
}
});
});
};
+132
View File
@@ -0,0 +1,132 @@
import os from 'node:os';
import { spawn } from 'node:child_process';
import { app } from 'electron';
import Logger from 'electron-log/main';
import type { HardwareInfo } from '~common/schemas';
export const HARDWARE_SCHEMA_VERSION = 1;
export const FARCLIP_FLOOR = 777;
export const FARCLIP_CEILING = 3000;
const VIDEO_CLASS_GUID = '{4d36e968-e325-11ce-bfc1-08002be10318}';
const getVramMb = (): Promise<{
mb: number | null;
source: HardwareInfo['vramSource'];
}> => {
if (os.platform() !== 'win32')
return Promise.resolve({ mb: null, source: 'none' });
const script = [
'$ErrorActionPreference = "Stop"',
`$base = 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Class\\${VIDEO_CLASS_GUID}'`,
'$max = [int64]0',
'Get-ChildItem $base -ErrorAction SilentlyContinue | ForEach-Object {',
' $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue',
' $v = [int64]0',
" if ($p.'HardwareInformation.qwMemorySize') { $v = [int64]$p.'HardwareInformation.qwMemorySize' }",
" elseif ($p.'HardwareInformation.MemorySize') {",
" $m = $p.'HardwareInformation.MemorySize'",
' if ($m -is [byte[]]) { $v = [int64][System.BitConverter]::ToUInt32($m, 0) } else { $v = [int64]$m }',
' }',
' if ($v -gt $max) { $max = $v }',
'}',
'Write-Output $max'
].join('\n');
const encoded = Buffer.from(script, 'utf16le').toString('base64');
return new Promise(resolve => {
let settled = false;
const finish = (mb: number | null, source: HardwareInfo['vramSource']) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ mb, source });
};
const child = spawn(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded],
{ windowsHide: true }
);
const timer = setTimeout(() => {
child.kill();
Logger.warn('VRAM detection timed out');
finish(null, 'none');
}, 8000);
let stdout = '';
child.stdout.on('data', d => (stdout += String(d)));
child.on('error', e => {
Logger.warn('VRAM detection failed to launch PowerShell', e);
finish(null, 'none');
});
child.on('exit', code => {
const bytes = Number(stdout.trim());
if (code === 0 && Number.isFinite(bytes) && bytes > 0)
finish(Math.round(bytes / 1024 / 1024), 'registry');
else finish(null, 'none');
});
});
};
const getGpuModel = async (): Promise<string> => {
try {
const info = (await app.getGPUInfo('complete')) as {
auxAttributes?: { glRenderer?: string };
gpuDevice?: { active?: boolean; vendorId?: number; deviceId?: number }[];
};
const renderer = info?.auxAttributes?.glRenderer?.trim();
if (renderer) return renderer;
const active = info?.gpuDevice?.find(d => d.active) ?? info?.gpuDevice?.[0];
if (active) return `vendor ${active.vendorId} device ${active.deviceId}`;
} catch (e) {
Logger.warn('GPU info detection failed', e);
}
return 'unknown';
};
export const detectHardware = async (): Promise<HardwareInfo> => {
const cpus = os.cpus();
const [vram, gpuModel] = await Promise.all([getVramMb(), getGpuModel()]);
const info: HardwareInfo = {
totalRamMb: Math.round(os.totalmem() / 1024 / 1024),
cpuCores: cpus.length,
cpuModel: cpus[0]?.model?.trim() || 'unknown',
gpuModel,
vramMb: vram.mb,
vramSource: vram.source,
detectedAt: new Date().toISOString(),
schemaVersion: HARDWARE_SCHEMA_VERSION
};
Logger.info('Detected hardware', info);
return info;
};
const clampFarClip = (n: number) =>
Math.min(FARCLIP_CEILING, Math.max(FARCLIP_FLOOR, Math.round(n)));
export const recommendFarClip = (hw: HardwareInfo | null): number => {
if (!hw) return clampFarClip(1000);
const ramGb = hw.totalRamMb / 1024;
const cores = hw.cpuCores;
const vramTrusted = hw.vramSource !== 'none' && hw.vramMb != null;
const vramGb = vramTrusted ? (hw.vramMb as number) / 1024 : null;
if (ramGb < 6 || cores <= 2) return clampFarClip(FARCLIP_FLOOR);
if (vramGb === null)
return clampFarClip(ramGb >= 8 && cores >= 4 ? 1500 : 1000);
if (ramGb < 8 || vramGb < 2) return clampFarClip(1000);
if (ramGb >= 32 && vramGb >= 8 && cores >= 8) return clampFarClip(3000);
if (ramGb >= 16 && vramGb >= 4 && cores >= 6) return clampFarClip(2200);
if (ramGb >= 8 && vramGb >= 2 && cores >= 4) return clampFarClip(1500);
return clampFarClip(1000);
};
+116
View File
@@ -0,0 +1,116 @@
import path from 'node:path';
import fs from 'fs-extra';
import {
SFileOpenArchive,
SFileCloseArchive,
SFileHasFile
} from 'stormlib-node';
import { STREAM_FLAG } from 'stormlib-node/dist/enums';
import Logger from 'electron-log/main';
import Preferences from './preferences';
const PREFERRED = 'L';
const LETTERS = 'BCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const MARKER = 'octolocale.marker';
const patchFile = (dataDir: string, letter: string) =>
path.join(dataDir, `patch-${letter}.mpq`);
const prebuiltFor = (dataDir: string, locale: string) =>
path.join(dataDir, locale, 'patch-L.mpq');
const isOurPatch = (mpqPath: string): boolean => {
if (!fs.existsSync(mpqPath)) return false;
try {
const h = SFileOpenArchive(mpqPath, STREAM_FLAG.READ_ONLY);
try {
return SFileHasFile(h, MARKER);
} finally {
SFileCloseArchive(h);
}
} catch {
return false;
}
};
const usableSlot = (dataDir: string, letter: string): boolean => {
if (fs.existsSync(path.join(dataDir, `patch-${letter}.MPQ`))) return false;
const f = patchFile(dataDir, letter);
return !fs.existsSync(f) || isOurPatch(f);
};
const removeOurPatch = async (dataDir: string) => {
for (const l of LETTERS) {
const f = patchFile(dataDir, l);
if (isOurPatch(f)) await fs.remove(f).catch(() => {});
}
if (Preferences.data.localePatchLetter || Preferences.data.localePatchLocale)
Preferences.data = {
localePatchLetter: undefined,
localePatchLocale: undefined
};
};
export const applyLocalePatch = async (
clientDir: string | undefined,
locale: string | undefined
): Promise<void> => {
if (!clientDir) return;
const dataDir = path.join(clientDir, 'Data');
const nextLocale = !locale || locale === 'enUS' ? undefined : locale;
if (Preferences.data.localePatchLocale !== nextLocale)
await fs.remove(path.join(clientDir, 'WDB')).catch(() => {});
if (!locale || locale === 'enUS') {
await removeOurPatch(dataDir);
return;
}
const source = prebuiltFor(dataDir, locale);
if (!(await fs.pathExists(source))) {
Logger.warn(
`Locale patch: no prebuilt patch-L for ${locale}; leaving UI as-is`
);
return;
}
const tracked = Preferences.data.localePatchLetter;
const letter =
(tracked && usableSlot(dataDir, tracked) ? tracked : undefined) ??
(usableSlot(dataDir, PREFERRED)
? PREFERRED
: LETTERS.find(l => usableSlot(dataDir, l)));
if (!letter) {
Logger.warn('Locale patch: no usable patch slot');
return;
}
const target = patchFile(dataDir, letter);
try {
if (
Preferences.data.localePatchLocale === locale &&
isOurPatch(target) &&
fs.statSync(target).mtimeMs >= fs.statSync(source).mtimeMs
)
return;
} catch {
}
try {
for (const l of LETTERS) {
if (l === letter) continue;
const f = patchFile(dataDir, l);
if (isOurPatch(f)) await fs.remove(f).catch(() => {});
}
await fs.copy(source, target, { overwrite: true });
Preferences.data = { localePatchLetter: letter, localePatchLocale: locale };
Logger.log(
`Locale patch: swapped prebuilt ${locale} -> patch-${letter}.mpq`
);
} catch (e) {
Logger.error('Locale patch: failed to swap in prebuilt patch', e);
}
};
+65 -71
View File
@@ -1,5 +1,4 @@
import path from 'path';
import os from 'os';
import fs from 'fs-extra';
import fetch from 'node-fetch';
@@ -12,8 +11,17 @@ import { type ModState } from '~common/schemas';
import Preferences from './preferences';
import Observable from './observable';
import Updater from './updater';
import { addDll, removeDll } from './dllsTxt';
const MOD_DOWNLOAD_TIMEOUT_MS = 60_000;
const AV_ERROR =
'Windows Defender blocked this download. Use "Allow through antivirus" and apply again.';
const looksLikeAvBlock = (msg: string) =>
/windows defender|virus|potentially unwanted/i.test(msg);
export type ModRowStatus = {
id: ModId;
name: string;
@@ -36,8 +44,6 @@ export type ModsStatus = {
mods: ModRowStatus[];
};
const VERSION_CACHE_MS = 10 * 60 * 1000;
class ModsClass extends Observable<ModsStatus> {
protected _value: ModsStatus = {
state: 'verifying',
@@ -45,21 +51,6 @@ class ModsClass extends Observable<ModsStatus> {
mods: []
};
#latestCache = new Map<ModId, { v: string; ts: number }>();
installedFilePaths(): Set<string> {
const set = new Set<string>();
const mods = Preferences.data?.mods ?? {};
for (const id of Object.keys(mods) as ModId[]) {
const state = mods[id];
if (!state?.installedFiles?.length) continue;
for (const rel of state.installedFiles) {
set.add(rel.replace(/\\/g, '/').toLowerCase());
}
}
return set;
}
get status(): ModsStatus {
return this._value;
}
@@ -119,6 +110,13 @@ class ModsClass extends Observable<ModsStatus> {
const clientDir = Preferences.data?.clientDir;
if (clientDir) {
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, '0\n', 'utf8').catch(() => {});
}
for (const m of MODS) {
const state = Preferences.data?.mods?.[m.id];
let installedVersion = state?.installedVersion;
@@ -140,54 +138,28 @@ class ModsClass extends Observable<ModsStatus> {
}
}
const latest = await this.#fetchLatestVersion(m).catch(() => m.version);
if (clientDir && m.registerInDllsTxt)
await (installedVersion
? addDll(clientDir, m.registerInDllsTxt)
: removeDll(clientDir, m.registerInDllsTxt)
).catch(() => {});
this.#patchRow(m.id, {
installedVersion,
latestVersion: latest,
latestVersion: m.version,
enabled: !!state?.enabled,
ignoreUpdates: !!state?.ignoreUpdates
});
}
this._value = { ...this._value, state: 'idle', dirty: this.#computeDirty() };
this._value = {
...this._value,
state: 'idle',
dirty: this.#computeDirty()
};
this._notifyObservers();
}
async #fetchLatestVersion(m: ModEntry): Promise<string> {
if (m.source.kind === 'managed') return m.version;
const cached = this.#latestCache.get(m.id);
if (cached && Date.now() - cached.ts < VERSION_CACHE_MS) return cached.v;
const apiUrl =
'apiUrl' in m.source && m.source.apiUrl ? m.source.apiUrl : undefined;
const parser =
'parseLatest' in m.source && m.source.parseLatest
? m.source.parseLatest
: undefined;
if (!apiUrl || !parser) {
const v = ('pinnedTag' in m.source && m.source.pinnedTag) || m.version;
this.#latestCache.set(m.id, { v, ts: Date.now() });
return v;
}
try {
const res = await fetch(apiUrl, {
headers: { 'User-Agent': 'OctoLauncher' }
});
if (!res.ok) throw new Error(`${apiUrl}${res.status}`);
const json = (await res.json()) as { tag_name?: string };
const tag = json.tag_name ?? m.version;
this.#latestCache.set(m.id, { v: tag, ts: Date.now() });
return tag;
} catch (e) {
Logger.warn(`Could not check latest version for ${m.id}:`, e);
const v = ('pinnedTag' in m.source && m.source.pinnedTag) || m.version;
return v;
}
}
async toggle(id: ModId, enabled: boolean) {
const cur = Preferences.data?.mods?.[id];
await this.#savePref(id, {
@@ -210,12 +182,17 @@ class ModsClass extends Observable<ModsStatus> {
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.');
return;
}
if (this._value.state === 'busy') {
Logger.warn('applyAll already running; ignoring re-entrant call.');
return;
}
await this.verify();
this._value = { ...this._value, state: 'busy' };
this._notifyObservers();
@@ -226,6 +203,7 @@ class ModsClass extends Observable<ModsStatus> {
return 0;
});
const failures = new Map<ModId, string>();
for (const row of queue) {
const m = getMod(row.id);
if (!m) continue;
@@ -242,21 +220,22 @@ class ModsClass extends Observable<ModsStatus> {
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);
}
} catch (e) {
Logger.error(`Failed to apply ${m.id}:`, e);
this.#patchRow(m.id, {
state: 'error',
error: e instanceof Error ? e.message : String(e)
});
const msg = e instanceof Error ? e.message : String(e);
failures.set(m.id, looksLikeAvBlock(msg) ? AV_ERROR : msg);
}
}
this._value = { ...this._value, state: 'idle' };
await this.verify();
for (const [id, error] of failures)
this.#patchRow(id, { state: 'error', error });
await Updater.verify();
}
async #install(m: ModEntry) {
@@ -265,18 +244,25 @@ class ModsClass extends Observable<ModsStatus> {
if (m.source.kind === 'managed') return;
Logger.info(`Installing mod ${m.id}...`);
this.#patchRow(m.id, { state: 'downloading', progress: 0, error: undefined });
this.#patchRow(m.id, {
state: 'downloading',
progress: 0,
error: undefined
});
const written: string[] = [];
const missing: string[] = [];
if (m.source.kind === 'directFile') {
const dest = path.join(clientDir, m.source.assetName);
await this.#downloadTo(m.source.url, dest);
written.push(m.source.assetName);
} else if (m.source.kind === 'archive') {
const scratch = path.join(clientDir, '.octolauncher-tmp');
await fs.ensureDir(scratch);
const tmp = path.join(
os.tmpdir(),
`octolauncher-${m.id}-${Date.now()}.${m.source.format}`
scratch,
`${m.id}-${Date.now()}.${m.source.format}`
);
await this.#downloadTo(m.source.url, tmp);
this.#patchRow(m.id, { state: 'installing' });
@@ -288,7 +274,7 @@ class ModsClass extends Observable<ModsStatus> {
for (const [src, dst] of Object.entries(map)) {
const entry = entries.find(e => e.entryName === src);
if (!entry) {
Logger.warn(`Mod ${m.id}: zip entry ${src} not found.`);
missing.push(src);
continue;
}
const target = path.join(clientDir, dst);
@@ -297,16 +283,13 @@ class ModsClass extends Observable<ModsStatus> {
written.push(dst);
}
} else {
const stagingDir = path.join(
os.tmpdir(),
`octolauncher-${m.id}-${Date.now()}-extract`
);
const stagingDir = path.join(scratch, `${m.id}-${Date.now()}-extract`);
await fs.ensureDir(stagingDir);
await tar.x({ file: tmp, cwd: stagingDir });
for (const [src, dst] of Object.entries(map)) {
const srcPath = path.join(stagingDir, src);
if (!(await fs.pathExists(srcPath))) {
Logger.warn(`Mod ${m.id}: tar entry ${src} not found.`);
missing.push(src);
continue;
}
const target = path.join(clientDir, dst);
@@ -319,6 +302,11 @@ class ModsClass extends Observable<ModsStatus> {
await fs.remove(tmp).catch(() => {});
}
if (missing.length)
throw new Error(
`${m.name}: download is missing expected file(s): ${missing.join(', ')}`
);
if (m.registerInDllsTxt) {
await addDll(clientDir, m.registerInDllsTxt);
}
@@ -371,12 +359,18 @@ class ModsClass extends Observable<ModsStatus> {
async #downloadTo(url: string, dest: string) {
const res = await fetch(url, {
headers: { 'User-Agent': 'OctoLauncher' }
headers: { 'User-Agent': 'OctoLauncher' },
timeout: MOD_DOWNLOAD_TIMEOUT_MS
});
if (!res.ok) throw new Error(`Download failed ${res.status}: ${url}`);
await fs.ensureDir(path.dirname(dest));
const buf = await res.arrayBuffer();
await fs.writeFile(dest, Buffer.from(buf));
if (!(await fs.pathExists(dest)))
throw new Error(
`Downloaded file disappeared after writing: ${path.basename(dest)}. ` +
'This is often Windows Defender quarantine; if so, use "Allow through antivirus" and apply again.'
);
}
async #savePref(id: ModId, state: ModState) {
+2 -1
View File
@@ -12,7 +12,8 @@ abstract class Observable<T> {
try {
l(v);
return true;
} catch {
} catch (err) {
console.error('Observer threw, removing listener', err);
return false;
}
});
+83 -58
View File
@@ -22,17 +22,24 @@ const Servers = {
}
} as const;
type Tweak = { key: keyof PreferencesSchema['config']; default?: unknown; forced?: boolean } & (
| {
type: 'bytes';
tweaks: [number, number[]][];
}
| {
type: 'int8' | 'uint16' | 'float';
offset: number;
value?: number;
}
);
type TweakKey =
| { synthetic?: false; key: keyof PreferencesSchema['config'] }
| { synthetic: true; key: string };
type Tweak = TweakKey & {
default?: unknown;
forced?: boolean;
} & (
| {
type: 'bytes';
tweaks: [number, number[]][];
}
| {
type: 'int8' | 'uint16' | 'float';
offset: number;
value?: number;
}
);
export const patchExecutable = async () => {
Logger.log('Patching WoW.exe...');
@@ -81,7 +88,8 @@ export const patchExecutable = async () => {
{ key: 'nameplateRange', type: 'float', offset: 0x40c448 },
{ key: 'cameraDistance', type: 'float', offset: 0x4089a4 },
{
key: 'crossFactionResurrect' as never,
synthetic: true,
key: 'crossFactionResurrect',
type: 'bytes',
default: true,
tweaks: [
@@ -90,7 +98,8 @@ export const patchExecutable = async () => {
]
},
{
key: 'skillUiGateHijack' as never,
synthetic: true,
key: 'skillUiGateHijack',
type: 'bytes',
default: true,
forced: true,
@@ -98,47 +107,57 @@ export const patchExecutable = async () => {
[
0x002ddf90,
[
0x55, 0x8b, 0xec, 0x83, 0xec, 0x08, 0x53, 0x56,
0x57, 0x8b, 0x3d, 0x60, 0xab, 0xce, 0x00, 0x83,
0xff, 0xff, 0x89, 0x55, 0xfc, 0x89, 0x4d, 0xf8,
0x74, 0x79, 0x8b, 0x75, 0x08, 0x8b, 0x15, 0x58,
0xab, 0xce, 0x00, 0x8b, 0xc7, 0x23, 0xc6, 0x8d,
0x04, 0x40, 0x8b, 0x4c, 0x82, 0x08, 0xf6, 0xc1,
0x01, 0x8d, 0x44, 0x82, 0x04, 0x75, 0x04, 0x85,
0xc9, 0x75, 0x05, 0x33, 0xc9, 0x8d, 0x49, 0x00,
0xf6, 0xc1, 0x01, 0x75, 0x4e, 0x85, 0xc9, 0x74,
0x4a, 0x39, 0x31, 0x74, 0x13, 0x8b, 0xc7, 0x23,
0xc6, 0x8d, 0x04, 0x40, 0x8d, 0x04, 0x82, 0x8b,
0x00, 0x03, 0xc1, 0x8b, 0x48, 0x04, 0xeb, 0xe0,
0x8b, 0x59, 0x1c, 0x8b, 0x71, 0x18, 0x33, 0xff,
0x85, 0xdb, 0x7e, 0x27, 0x8d, 0x64, 0x24, 0x00,
0x8b, 0x4e, 0x0c, 0x8b, 0x56, 0x08, 0x6a, 0x00,
0x6a, 0x00, 0x51, 0x8b, 0x4d, 0xf8, 0x52, 0x8b,
0x55, 0xfc, 0xe8, 0xb9, 0xfd, 0xff, 0xff, 0x84,
0xc0, 0x75, 0x13, 0x47, 0x83, 0xc6, 0x20, 0x3b,
0xfb, 0x7c, 0xdd, 0x5f, 0x5e, 0x33, 0xc0, 0x5b,
0x8b, 0xe5, 0x5d, 0xc2, 0x04, 0x00, 0x5f, 0x8b,
0xc6, 0x5e, 0x5b, 0x8b, 0xe5, 0x5d, 0xc2, 0x04,
0x00, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90
0x55, 0x8b, 0xec, 0x83, 0xec, 0x08, 0x53, 0x56, 0x57, 0x8b, 0x3d,
0x60, 0xab, 0xce, 0x00, 0x83, 0xff, 0xff, 0x89, 0x55, 0xfc, 0x89,
0x4d, 0xf8, 0x74, 0x79, 0x8b, 0x75, 0x08, 0x8b, 0x15, 0x58, 0xab,
0xce, 0x00, 0x8b, 0xc7, 0x23, 0xc6, 0x8d, 0x04, 0x40, 0x8b, 0x4c,
0x82, 0x08, 0xf6, 0xc1, 0x01, 0x8d, 0x44, 0x82, 0x04, 0x75, 0x04,
0x85, 0xc9, 0x75, 0x05, 0x33, 0xc9, 0x8d, 0x49, 0x00, 0xf6, 0xc1,
0x01, 0x75, 0x4e, 0x85, 0xc9, 0x74, 0x4a, 0x39, 0x31, 0x74, 0x13,
0x8b, 0xc7, 0x23, 0xc6, 0x8d, 0x04, 0x40, 0x8d, 0x04, 0x82, 0x8b,
0x00, 0x03, 0xc1, 0x8b, 0x48, 0x04, 0xeb, 0xe0, 0x8b, 0x59, 0x1c,
0x8b, 0x71, 0x18, 0x33, 0xff, 0x85, 0xdb, 0x7e, 0x27, 0x8d, 0x64,
0x24, 0x00, 0x8b, 0x4e, 0x0c, 0x8b, 0x56, 0x08, 0x6a, 0x00, 0x6a,
0x00, 0x51, 0x8b, 0x4d, 0xf8, 0x52, 0x8b, 0x55, 0xfc, 0xe8, 0xb9,
0xfd, 0xff, 0xff, 0x84, 0xc0, 0x75, 0x13, 0x47, 0x83, 0xc6, 0x20,
0x3b, 0xfb, 0x7c, 0xdd, 0x5f, 0x5e, 0x33, 0xc0, 0x5b, 0x8b, 0xe5,
0x5d, 0xc2, 0x04, 0x00, 0x5f, 0x8b, 0xc6, 0x5e, 0x5b, 0x8b, 0xe5,
0x5d, 0xc2, 0x04, 0x00, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90
]
]
]
},
{
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[];
// Apply patches
Tweaks.forEach(t => {
const val =
config[t.key] ?? t.default ?? ConfigWtfSchema.parse({})[t.key];
const val = t.synthetic
? t.default
: config[t.key] ?? t.default ?? ConfigWtfSchema.parse({})[t.key];
Logger.log(`Applying "${t.key}" patch with value: ${val}`);
if (t.type === 'float') {
buffer.writeFloatLE(t.value ?? (val as never), t.offset);
buffer.writeFloatLE(t.value ?? (val as number), t.offset);
} else if (t.type === 'int8') {
buffer.writeInt8(t.value ?? (val as never), t.offset);
buffer.writeInt8(t.value ?? (val as number), t.offset);
} else if (t.type === 'uint16') {
if (!t.forced && !val) return;
buffer.writeUInt16LE(t.value ?? (val as never), t.offset);
buffer.writeUInt16LE(t.value ?? (val as number), t.offset);
} else if (t.type === 'bytes') {
if (!t.forced && !val) return;
t.tweaks.forEach(([offset, bytes]) =>
@@ -151,11 +170,12 @@ export const patchExecutable = async () => {
Logger.log('WoW.exe successfully patched');
} catch (e) {
Logger.error('Failed to patch WoW.exe', e);
throw e instanceof Error ? e : new Error('Failed to patch WoW.exe');
}
};
export const patchConfig = async () => {
const { clientDir, server, config } = Preferences.data;
export const patchConfig = async (forceTweaks = false) => {
const { clientDir, server, config, locale } = Preferences.data;
if (!clientDir) return;
const configPath = path.join(clientDir, 'WTF', 'Config.wtf');
@@ -163,20 +183,21 @@ export const patchConfig = async () => {
const raw = (await fs.pathExists(configPath))
? await fs.readFile(configPath, { encoding: 'utf-8' })
: '';
if (raw) await fs.remove(configPath);
const configWtf = Object.fromEntries(
raw
.split('\n')
.split(/\r?\n/)
.map(l => {
const [_, k, v] = l.match(/SET (\w+) "(.+)"/) ?? [];
return !k || !v ? undefined : [k, v];
const [, k, v] = l.match(/SET (\w+) "(.*)"/) ?? [];
return !k || v === undefined ? undefined : [k, v];
})
.filter(isNotUndef)
);
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,
@@ -210,20 +231,24 @@ export const patchConfig = async () => {
gxMaximize: configWtf['gxMaximize'] ?? 1,
gxCursor: configWtf['gxCursor'] ?? 1,
checkAddonVersion: configWtf['checkAddonVersion'] ?? 0,
farClip: configWtf['farClip'] ?? config.farClip,
CameraDistanceMax: configWtf['CameraDistanceMax'] ?? config.cameraDistance,
...configWtf,
CameraDistanceMax: config.cameraDistance,
farClip: config.farClip,
locale,
realmList: Servers[server].realmList,
hwDetect: 0,
M2UseShaders: 1
M2UseShaders: 1,
...(forceTweaks
? { farClip: config.farClip, CameraDistanceMax: config.cameraDistance }
: {})
};
await fs.writeFile(
configPath,
Object.entries(parsed)
.filter(v => v[1] !== undefined && v[1] !== null)
.map(l => `SET ${l[0]} "${l[1]}"`)
.join('\n')
);
const body = Object.entries(parsed)
.filter(v => v[1] !== undefined && v[1] !== null)
.map(l => `SET ${l[0]} "${l[1]}"`)
.join('\n');
const tmpPath = `${configPath}.tmp`;
await fs.writeFile(tmpPath, body);
await fs.move(tmpPath, configPath, { overwrite: true });
Logger.log('Config.wtf successfully patched');
};
+64 -15
View File
@@ -3,6 +3,7 @@ import path from 'path';
import fs from 'fs-extra';
import { type z } from 'zod';
import { app } from 'electron';
import Logger from 'electron-log/main';
import { PreferencesSchema } from '~common/schemas';
import { omit } from '~common/utils';
@@ -11,6 +12,7 @@ const portableDir = process.env.PORTABLE_EXECUTABLE_DIR;
abstract class Preferences {
static #data: z.infer<typeof PreferencesSchema>;
static #writeChain: Promise<void> = Promise.resolve();
static readonly userDataDir = process.env.PORTABLE_EXECUTABLE_DIR
? path.join(process.env.PORTABLE_EXECUTABLE_DIR, '.launcher')
@@ -18,21 +20,44 @@ abstract class Preferences {
static async load() {
await fs.ensureDir(this.userDataDir);
const settingsPath = path.join(this.userDataDir, 'settings.json');
const userDataPath = path.join(this.userDataDir, 'settings.json');
let json: Record<string, unknown>;
try {
const json = await fs.readJSON(userDataPath);
return PreferencesSchema.parse({
...json,
isPortable: !!portableDir,
clientDir: portableDir ?? json.clientDir
});
} catch (e) {
json = await fs.readJSON(settingsPath);
} catch {
return PreferencesSchema.parse({
isPortable: !!portableDir,
clientDir: portableDir
});
}
const merged = {
...json,
isPortable: !!portableDir,
clientDir: portableDir ?? json.clientDir
};
const parsed = PreferencesSchema.safeParse(merged);
if (parsed.success) return parsed.data;
Logger.warn(
'settings.json failed validation; salvaging valid fields',
parsed.error
);
await fs.copy(settingsPath, `${settingsPath}.corrupt`).catch(() => {});
const salvaged: Record<string, unknown> = {
isPortable: !!portableDir,
clientDir: portableDir ?? json.clientDir
};
const shape = PreferencesSchema.shape;
for (const key of Object.keys(shape) as (keyof typeof shape)[]) {
if (!(key in merged)) continue;
const value = (merged as Record<string, unknown>)[key];
if (shape[key].safeParse(value).success) salvaged[key] = value;
}
return PreferencesSchema.parse(salvaged);
}
static get data(): PreferencesSchema {
@@ -41,14 +66,38 @@ abstract class Preferences {
static set data(newData: Partial<Omit<PreferencesSchema, 'portableDir'>>) {
this.#data = { ...this.#data, ...newData };
fs.writeJSON(
path.join(this.userDataDir, 'settings.json'),
omit(
this.#data,
portableDir ? ['isPortable', 'clientDir'] : ['isPortable']
),
{ spaces: 2 }
const settingsPath = path.join(this.userDataDir, 'settings.json');
const delta = omit(
newData,
portableDir ? ['isPortable', 'clientDir'] : ['isPortable']
);
const snapshot = omit(
this.#data,
portableDir ? ['isPortable', 'clientDir'] : ['isPortable']
);
this.#writeChain = this.#writeChain
.then(async () => {
let onDisk: unknown = null;
try {
onDisk = await fs.readJSON(settingsPath);
} catch {
onDisk = null;
}
const base =
!!onDisk && typeof onDisk === 'object' && !Array.isArray(onDisk)
? (onDisk as Record<string, unknown>)
: null;
const merged = base ? { ...base, ...delta } : snapshot;
const tmp = `${settingsPath}.tmp`;
await fs.writeJSON(tmp, merged, { spaces: 2 });
await fs.move(tmp, settingsPath, { overwrite: true });
})
.catch(e => Logger.error('Failed to persist settings.json', e));
}
static save() {
return this.#writeChain;
}
static async isValidClientDir(clientDir?: string) {
+12 -5
View File
@@ -10,7 +10,12 @@ export type SelfUpdaterStatus =
| { state: 'checking'; currentVersion: string }
| { state: 'unavailable'; currentVersion: string }
| { state: 'available'; currentVersion: string; nextVersion: string }
| { state: 'downloading'; currentVersion: string; nextVersion: string; progress: number }
| {
state: 'downloading';
currentVersion: string;
nextVersion: string;
progress: number;
}
| { state: 'ready'; currentVersion: string; nextVersion: string }
| { state: 'error'; currentVersion: string; message: string };
@@ -37,7 +42,7 @@ class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
this.#initialized = true;
if (is.dev) {
Logger.info('[selfUpdater] dev mode skipping');
Logger.info('[selfUpdater] dev mode, skipping');
return;
}
@@ -83,7 +88,7 @@ class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
});
autoUpdater.on('update-downloaded', info => {
Logger.info(
`[selfUpdater] downloaded ${info.version} awaiting user click`
`[selfUpdater] downloaded ${info.version}, awaiting user click`
);
this.status = {
state: 'ready',
@@ -100,11 +105,13 @@ class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
triggerInstall() {
if (this._value.state !== 'ready') {
Logger.warn(
`[selfUpdater] triggerInstall called in state ${this._value.state} ignoring`
`[selfUpdater] triggerInstall called in state ${this._value.state}, ignoring`
);
return;
}
Logger.info('[selfUpdater] user clicked install — quitting + running installer');
Logger.info(
'[selfUpdater] user clicked install, quitting + running installer'
);
autoUpdater.quitAndInstall(false, true);
}
}
+173 -51
View File
@@ -50,6 +50,7 @@ const getAvailableDiskSpace = async (probePath?: string): Promise<number> => {
os.homedir() ||
(os.platform() === 'win32' ? 'C:\\' : '/');
try {
// @ts-expect-error statfs exists at runtime (Node 18) but not @types/node 16
const s = await fs.promises.statfs(target);
return Number(s.bsize) * Number(s.bavail);
} catch (e) {
@@ -65,11 +66,23 @@ const isReadOnly = async (filePath: string) => {
try {
const { mode } = await fs.stat(filePath);
return !(mode & fs.constants.S_IWUSR);
} catch (e) {
} catch {
return false;
}
};
const friendlyError = (e: unknown): string => {
const msg = e instanceof Error ? e.message : String(e);
if (/EPERM|EACCES|EROFS|read-only/i.test(msg))
return (
'Cannot write to the game folder. Move your OctoWoW install out of ' +
'Program Files (or any protected folder), or run the launcher as ' +
'administrator, then try again.'
);
if (/ENOSPC/i.test(msg)) return 'Not enough disk space to finish the update.';
return msg;
};
type FolderTags = 'allowExtra';
type FileTags = 'vanillaFixes';
type FileManifest = { name: string } & (
@@ -141,18 +154,54 @@ export const isGameRunning = (executablePath: string) =>
})
: false;
const toUrlPath = (p: string) => p.split(path.sep).map(encodeURIComponent).join('/');
const toUrlPath = (p: string) =>
p.split(path.sep).map(encodeURIComponent).join('/');
const CDN_VERSION = import.meta.env.MAIN_VITE_CLIENT_VERSION || 'latest';
const CONNECT_TIMEOUT_MS = 30_000;
const STALL_TIMEOUT_MS = 60_000;
const isUnsafeName = (name: string) =>
!name ||
name === '.' ||
name === '..' ||
/[/\\]/.test(name) ||
path.isAbsolute(name);
const manifestPathsSafe = (m: FileManifest, isRoot = false): boolean => {
if (!isRoot && isUnsafeName(m.name)) return false;
if (m.type === 'dir' || m.type === 'mpq')
return m.files.every(f => manifestPathsSafe(f));
return true;
};
const fetchManifest = async () => {
try {
const r = await fetch(
`${import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'}/api/file/${CDN_VERSION}/manifest.json`
`${
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
}/api/file/${CDN_VERSION}/manifest.json`,
{ timeout: CONNECT_TIMEOUT_MS }
);
if (!r.ok) {
Logger.error(`Update server returned HTTP ${r.status}`);
return null;
}
const j = await r.json();
if (!j || typeof j !== 'object' || !('root' in j) || !j.root) {
Logger.error('Update server returned a malformed manifest');
return null;
}
const root = j.root as FileManifest;
if (!manifestPathsSafe(root, true)) {
Logger.error(
'Update server manifest has unsafe path names; refusing it.'
);
return null;
}
await fs.writeJSON(path.join(Preferences.userDataDir, 'manifest.json'), j);
return j.root as FileManifest;
return root;
} catch (e) {
Logger.error('Failed to reach update server', e);
return null;
@@ -160,22 +209,26 @@ const fetchManifest = async () => {
};
const buildClientUrl = (filePath: string) =>
`${import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'}/client/${CDN_VERSION}/${toUrlPath(
path.normalize(filePath)
)}`;
`${
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
}/client/${CDN_VERSION}/${toUrlPath(path.normalize(filePath))}`;
export const fetchFile = async (
filePath: string,
onChunk?: (deltaBytes: number) => void
) => {
try {
const response = await fetch(buildClientUrl(filePath));
const response = await fetch(buildClientUrl(filePath), {
timeout: CONNECT_TIMEOUT_MS
});
if (!response.ok) throw Error(`HTTP ${response.status}`);
if (!onChunk || !response.body) return await response.arrayBuffer();
const chunks: Buffer[] = [];
for await (const chunk of response.body as NodeJS.ReadableStream) {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
const buf = Buffer.isBuffer(chunk)
? chunk
: Buffer.from(chunk);
chunks.push(buf);
onChunk(buf.byteLength);
}
@@ -203,7 +256,8 @@ export const downloadFileToDisk = async (
else if (stats.size >= expectedSize) {
await fs.truncate(partPath, 0);
}
} catch {
} catch (e) {
Logger.warn(`Failed to resume from "${partPath}".`, e);
}
if (resumeFrom > 0) onChunk(resumeFrom);
@@ -212,20 +266,25 @@ export const downloadFileToDisk = async (
const headers: Record<string, string> = {};
if (resumeFrom > 0) headers.Range = `bytes=${resumeFrom}-`;
const controller = new AbortController();
let response;
try {
response = await fetch(url, { headers });
response = await fetch(url, {
headers,
signal: controller.signal,
timeout: CONNECT_TIMEOUT_MS
});
} catch (e) {
Logger.error(`Network error downloading ${filePath}`, e);
throw Error(`Failed to download ${path.normalize(filePath)}`);
}
if (!response.ok && response.status !== 206) {
throw Error(`Failed to download ${path.normalize(filePath)}: HTTP ${response.status}`);
throw Error(
`Failed to download ${path.normalize(filePath)}: HTTP ${response.status}`
);
}
// If we got 200, the server gave us the whole file
// roll back and truncate
if (resumeFrom > 0 && response.status === 200) {
onChunk(-resumeFrom);
await fs.truncate(partPath, 0);
@@ -236,6 +295,7 @@ export const downloadFileToDisk = async (
flags: resumeFrom > 0 ? 'a' : 'w'
});
let stalled = false;
try {
await new Promise<void>((resolve, reject) => {
if (!response.body) {
@@ -243,26 +303,53 @@ export const downloadFileToDisk = async (
return;
}
const body = response.body as NodeJS.ReadableStream;
let stallTimer: NodeJS.Timeout;
const armStall = () => {
clearTimeout(stallTimer);
stallTimer = setTimeout(() => {
stalled = true;
controller.abort();
}, STALL_TIMEOUT_MS);
};
armStall();
body.on('data', (chunk: Buffer | Uint8Array) => {
armStall();
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
if (!writeStream.write(buf)) body.pause();
onChunk(buf.byteLength);
});
writeStream.on('drain', () => body.resume());
body.on('end', () => writeStream.end(resolve));
body.on('error', reject);
writeStream.on('error', reject);
body.on('end', () => {
clearTimeout(stallTimer);
writeStream.end(resolve);
});
body.on('error', err => {
clearTimeout(stallTimer);
reject(err);
});
writeStream.on('error', err => {
clearTimeout(stallTimer);
reject(err);
});
});
} catch (e) {
writeStream.destroy();
Logger.error(`Download interrupted for ${filePath}`, e);
throw Error(`Failed to download ${path.normalize(filePath)}`);
throw Error(
stalled
? `Download stalled for ${path.normalize(
filePath
)}; no data received in ${STALL_TIMEOUT_MS / 1000}s.`
: `Failed to download ${path.normalize(filePath)}`
);
}
const finalStats = await fs.stat(partPath);
if (finalStats.size !== expectedSize) {
throw Error(
`Size mismatch for ${path.normalize(filePath)}: got ${finalStats.size}, expected ${expectedSize}. Will retry on next run.`
`Size mismatch for ${path.normalize(filePath)}: got ${
finalStats.size
}, expected ${expectedSize}. Will retry on next run.`
);
}
@@ -336,9 +423,15 @@ class UpdaterClass extends Observable<UpdaterStatus> {
#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);
@@ -465,6 +558,10 @@ class UpdaterClass extends Observable<UpdaterStatus> {
try {
const vanillaFixes = Preferences.data.config.vanillaFixes;
const modOwnedFiles = new Set<string>();
for (const state of Object.values(Preferences.data.mods ?? {}))
for (const rel of state?.installedFiles ?? [])
modOwnedFiles.add(rel.replace(/\\/g, '/').toLowerCase());
const hashTree = await fetchManifest();
if (!hashTree) {
@@ -527,7 +624,11 @@ class UpdaterClass extends Observable<UpdaterStatus> {
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 = (
@@ -582,11 +683,10 @@ class UpdaterClass extends Observable<UpdaterStatus> {
SFileCloseArchive(hMpq);
}
} catch (e) {
Logger.log(
Logger.warn(
`Failed to verify ${path.join(
...patchPath
)}, will be downloaded fresh`,
'warning',
e
);
return {
@@ -598,13 +698,13 @@ class UpdaterClass extends Observable<UpdaterStatus> {
}
}
if (item.tags?.includes('vanillaFixes') && !vanillaFixes) {
if (await fs.exists(path.join(clientPath, ...filePath))) {
return {
type: 'del',
name: item.name
};
} else {
if (item.tags?.includes('vanillaFixes')) {
if (modOwnedFiles.has(filePath.join('/').toLowerCase()))
return undefined;
if (!vanillaFixes) {
if (await fs.exists(path.join(clientPath, ...filePath)))
return { type: 'del', name: item.name };
return undefined;
}
}
@@ -678,8 +778,7 @@ class UpdaterClass extends Observable<UpdaterStatus> {
const currentLauncherVersion = app.getVersion();
if (
this.status.state === 'upToDate' &&
Preferences.data.lastPatchedLauncherVersion !==
currentLauncherVersion
Preferences.data.lastPatchedLauncherVersion !== currentLauncherVersion
) {
Logger.log(
`Launcher version changed (${
@@ -710,8 +809,7 @@ class UpdaterClass extends Observable<UpdaterStatus> {
})();
}
} catch (e) {
const message =
e instanceof Error ? e.message : 'Unexpected error occurred';
const message = friendlyError(e);
Logger.error(`Verification failed: ${message}`, e);
this.status = { state: 'failed', message };
}
@@ -744,15 +842,31 @@ class UpdaterClass extends Observable<UpdaterStatus> {
try {
if (clean) {
const resolvedClientPath = path.resolve(clientPath);
const isFilesystemRoot =
path.parse(resolvedClientPath).root === resolvedClientPath;
if (
isFilesystemRoot ||
!fs.existsSync(path.join(resolvedClientPath, 'WoW.exe'))
) {
this.status = {
state: 'failed',
message:
'Refusing to clean: the client folder does not look like a valid WoW install.'
};
return;
}
this.status = {
state: 'updating',
progress: -1,
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
@@ -804,12 +918,10 @@ class UpdaterClass extends Observable<UpdaterStatus> {
if (!item) return undefined;
if (item.type === 'del') {
throw Error(
`TODO: Deleting of files from MPQ not implemented at path ${path.join(
...mpqPath,
...filePath
)}`
);
if (SFileHasFile(hMpq, path.join(...filePath)))
SFileRemoveFile(hMpq, path.join(...filePath));
nestedSet(this.#cache, [...mpqPath, ...filePath], undefined);
return;
}
if (item.type === 'dir') {
@@ -826,7 +938,9 @@ class UpdaterClass extends Observable<UpdaterStatus> {
)}`
);
const label = `Patching: [${mpqPath.at(-1)}] "${path.join(...filePath)}"`;
const label = `Patching: [${mpqPath.at(-1)}] "${path.join(
...filePath
)}"`;
emitProgress(label, true);
const data = await fetchFile(
@@ -853,6 +967,7 @@ class UpdaterClass extends Observable<UpdaterStatus> {
} finally {
SFileFinishFile(hFile);
}
nestedSet(this.#cache, [...mpqPath, ...filePath], undefined);
};
const iterateTree = async (...filePath: string[]) => {
@@ -922,7 +1037,7 @@ class UpdaterClass extends Observable<UpdaterStatus> {
if (item.name === 'WoW.exe') executableUpdate = true;
const fullPath = path.join(clientPath, ...filePath);
if (await fs.exists(fullPath) && (await isReadOnly(fullPath)))
if ((await fs.exists(fullPath)) && (await isReadOnly(fullPath)))
throw Error(`Failed to update "${fullPath}" because it's read-only.`);
await downloadFileToDisk(
@@ -947,7 +1062,6 @@ class UpdaterClass extends Observable<UpdaterStatus> {
if (executableUpdate || launcherVersionChanged) {
await patchExecutable();
await this.#getHash({ clientPath }, 'WoW.exe');
const patchedWowHash = await this.#getHash({ clientPath }, 'WoW.exe');
await this.#saveCache();
Preferences.data = {
@@ -960,13 +1074,21 @@ class UpdaterClass extends Observable<UpdaterStatus> {
this.#bytesAlreadyOnDisk = fullClientTotal;
this.status = { state: 'upToDate', progress: 1 };
} catch (e) {
console.error(e);
this.status = {
state: 'failed',
message: e instanceof Error ? e.message : 'Unexpected error occurred'
};
Logger.error('Update failed', e);
this.status = { state: 'failed', message: friendlyError(e) };
}
}
async recordPatchedWow() {
const clientPath = Preferences.data.clientDir;
if (!clientPath) return;
const patchedWowHash = await this.#getHash({ clientPath }, 'WoW.exe');
await this.#saveCache();
Preferences.data = {
lastPatchedLauncherVersion: app.getVersion(),
expectedPatchedWowHash: patchedWowHash
};
}
}
const Updater = new UpdaterClass();
+5 -1
View File
@@ -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';
+33 -7
View File
@@ -6,8 +6,15 @@ import fs from 'fs-extra';
import Preferences from './modules/preferences';
const isCallbackResponse = (data: any): data is { cb: string; args: any[] } =>
data && typeof data === 'object' && 'cb' in data && 'args' in data;
const isCallbackResponse = (
data: unknown
): data is { cb: string; args: unknown[] } =>
typeof data === 'object' &&
data !== null &&
'cb' in data &&
typeof (data as { cb: unknown }).cb === 'string' &&
'args' in data &&
Array.isArray((data as { args: unknown }).args);
export const runWorker = <T>(
worker: (o: WorkerOptions) => Worker,
@@ -16,10 +23,16 @@ export const runWorker = <T>(
) =>
new Promise<T>((resolve, reject) =>
worker({ workerData })
.on('message', m =>
isCallbackResponse(m) ? callbacks?.[m.cb](...m.args) : resolve(m)
)
.on('message', (m: unknown) => {
if (!isCallbackResponse(m)) return resolve(m as T);
const callback = callbacks?.[m.cb];
if (callback) callback(...m.args);
else Logger.warn('Unknown worker callback', m.cb);
})
.on('error', reject)
.on('exit', code =>
reject(new Error(`Worker exited (code ${code}) without finishing`))
)
);
export const getClientVersion = async () => {
@@ -35,8 +48,21 @@ export const getClientVersion = async () => {
const file = await fs.readFile(exePath);
const buffer = Buffer.from(file);
const version = buffer.toString('utf-8', 0x00437c04, 0x00437c04 + 6);
const build = buffer.toString('utf-8', 0x00437bfc, 0x00437bfc + 4);
const VERSION_OFFSET = 0x00437c04;
const VERSION_LEN = 6;
const BUILD_OFFSET = 0x00437bfc;
const BUILD_LEN = 4;
const version = buffer.toString(
'utf-8',
VERSION_OFFSET,
VERSION_OFFSET + VERSION_LEN
);
const build = buffer.toString(
'utf-8',
BUILD_OFFSET,
BUILD_OFFSET + BUILD_LEN
);
Logger.log(`Client version is: ${version} (${build})`);
return `${version} (${build})`;
+18 -6
View File
@@ -9,15 +9,27 @@ if (!port) throw new Error('IllegalState');
const { dir, url, ref } = workerData;
fs.removeSync(dir);
git
.clone({
dir,
const tmpDir = `${dir}.tmp`;
const run = async () => {
await fs.remove(tmpDir);
await git.clone({
dir: tmpDir,
fs,
http,
url,
ref,
singleBranch: !ref || ref === 'master' || ref === 'main',
onProgress: (...args) => port.postMessage({ cb: 'onProgress', args })
})
.then(() => port.postMessage(true));
});
await fs.remove(dir);
await fs.move(tmpDir, dir);
};
run()
.then(() => port.postMessage(true))
.catch(async err => {
await fs.remove(tmpDir).catch(() => undefined);
throw err;
});
+14 -14
View File
@@ -5,7 +5,7 @@ import http from 'isomorphic-git/http/node';
import fs from 'fs-extra';
const port = parentPort;
if (!port) throw new Error('IllegalState');
if (!port) throw new Error('gitPull worker has no parentPort');
const { dir, remote, branch, ref } = workerData as {
dir: string;
@@ -17,20 +17,17 @@ const { dir, remote, branch, ref } = workerData as {
const onProgress = (...args: unknown[]) =>
port.postMessage({ cb: 'onProgress', args });
const removeUntrackedFiles = async () => {
const status = await git.statusMatrix({ fs, dir });
await Promise.all(
status
.filter(([, HEAD]) => HEAD === 0)
.map(([filepath]) => fs.remove(`${dir}/${filepath}`))
);
};
const run = async () => {
if (ref) {
await git.fetch({ fs, http, dir, tags: true, singleBranch: false, onProgress });
await git.fetch({
fs,
http,
dir,
tags: true,
singleBranch: false,
onProgress
});
await git.checkout({ fs, dir, force: true, ref, onProgress });
await removeUntrackedFiles();
return;
}
@@ -41,7 +38,6 @@ const run = async () => {
ref: `${remote}/${branch}`,
onProgress
});
await removeUntrackedFiles();
await git.pull({
fs,
http,
@@ -53,4 +49,8 @@ const run = async () => {
});
};
run().then(() => port.postMessage(true));
run()
.then(() => port.postMessage(true))
.catch(err => {
throw err;
});
+4 -2
View File
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { api } from './utils/api';
import PageBackground from './assets/background.png';
import AntivirusModal from './components/AntivirusModal';
import Header from './components/Header';
import LaunchPanel from './components/LaunchPanel';
import SelfUpdateBanner from './components/SelfUpdateBanner';
@@ -38,12 +39,13 @@ const App = () => {
</>
)}
{/* Launcher build label, anchored bottom-right.*/}
{appVersion && (
<span className="pointer-events-none absolute bottom-2 right-3 text-[10px] font-mono uppercase tracking-wider text-white/40 select-none">
<span className="pointer-events-none absolute bottom-2 right-3 select-none font-mono text-[10px] uppercase tracking-wider text-white/40">
v{appVersion}
</span>
)}
<AntivirusModal />
</div>
);
};
+56 -42
View File
@@ -2,6 +2,8 @@ import { Clipboard, RefreshCw, ServerCrash } from 'lucide-react';
import { Component, type ErrorInfo, type ReactNode } from 'react';
import log from 'electron-log/renderer';
import { useT } from '~renderer/i18n';
import PageBackground from './assets/background.png';
import TextButton from './components/styled/TextButton';
@@ -15,6 +17,59 @@ type Props = {
children: ReactNode;
};
const ErrorFallback = ({
error,
errorInfo
}: {
error?: Error;
errorInfo?: ErrorInfo;
}) => {
const t = useT();
const title = t('misc.uncaughtError', {
name: error?.name ?? '',
message: error?.message ?? t('misc.unknownError')
});
const detail = errorInfo?.componentStack?.slice(1);
return (
<div
className="relative flex h-screen w-screen grow flex-col overflow-hidden border border-blueGray/10 bg-cover bg-top bg-no-repeat p-3"
style={{ backgroundImage: `url(${PageBackground})` }}
>
<div className="tw-surface flex grow flex-col gap-3">
<div className="flex items-center gap-2">
<ServerCrash size={26} className="text-red" />
<h3 className="text-red">{t('misc.somethingWentWrong')}</h3>
</div>
<hr />
<div className="text-white">{title}</div>
<pre className="s1 -mt-2 grow overflow-auto text-blueGray">
{detail}
</pre>
<hr />
<div className="-mx-3 -mb-3 flex justify-end gap-2">
<TextButton
icon={Clipboard}
onClick={() =>
navigator.clipboard.writeText(
`\`\`\`\n${title}\n${detail}\n\`\`\``
)
}
>
{t('misc.copyError')}
</TextButton>
<TextButton
icon={RefreshCw}
onClick={() => window.location.reload()}
className="text-warmGreen"
>
{t('misc.reload')}
</TextButton>
</div>
</div>
</div>
);
};
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
@@ -29,48 +84,7 @@ class ErrorBoundary extends Component<Props, State> {
render() {
if (!this.state.didCatch) return this.props.children;
const { error, errorInfo } = this.state;
const title = `Uncaught ${error?.name}: ${
error?.message ?? 'Unknown error'
}`;
const detail = errorInfo?.componentStack.slice(1);
return (
<div
className="relative flex h-screen w-screen grow flex-col overflow-hidden border border-blueGray/10 bg-cover bg-top bg-no-repeat p-3"
style={{ backgroundImage: `url(${PageBackground})` }}
>
<div className="tw-surface flex grow flex-col gap-3">
<div className="flex items-center gap-2">
<ServerCrash size={26} className="text-red" />
<h3 className="text-red">Something went wrong!</h3>
</div>
<hr />
<div className="text-white">{title}</div>
<pre className="s1 -mt-2 grow overflow-auto text-blueGray">
{detail}
</pre>
<hr />
<div className="-mx-3 -mb-3 flex justify-end gap-2">
<TextButton
icon={Clipboard}
onClick={() =>
navigator.clipboard.writeText(
`\`\`\`\n${title}\n${detail}\n\`\`\``
)
}
>
Copy error
</TextButton>
<TextButton
icon={RefreshCw}
onClick={() => window.location.reload()}
className="text-warmGreen"
>
Reload
</TextButton>
</div>
</div>
</div>
);
return <ErrorFallback error={error} errorInfo={errorInfo} />;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+194
View File
@@ -0,0 +1,194 @@
import { useEffect, useRef, useState } from 'react';
import { ShieldAlert, HelpCircle } from 'lucide-react';
import { createPortal } from 'react-dom';
import { api } from '~renderer/utils/api';
import { type ModsStatus } from '~main/types';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton';
const DETECTION = 'Trojan:Win32/Vigorf.A';
const AntivirusModal = () => {
const t = useT();
const [status, setStatus] = useState<ModsStatus>();
api.mods.observe.useSubscription(undefined, { onData: setStatus });
const addExclusion = api.general.addDefenderExclusion.useMutation();
const blocked = (status?.mods ?? [])
.filter(m => m.state === 'error' && m.error?.includes('Defender'))
.map(m => m.name);
const blockedKey = blocked.join(',');
const dialogRef = useRef<HTMLDialogElement>(null);
const [view, setView] = useState<'av' | 'why' | null>(null);
useEffect(() => {
if (view) {
dialogRef.current?.showModal();
(document.activeElement as HTMLElement | null)?.blur();
} else dialogRef.current?.close();
}, [view]);
useEffect(() => {
if (blockedKey) setView('av');
}, [blockedKey]);
const names = blockedKey ? blockedKey.split(',') : [];
return createPortal(
<dialog
ref={dialogRef}
onClose={() => setView(null)}
className="h-full w-full items-center justify-center bg-[transparent] backdrop:backdrop-blur-sm [&[open]]:flex"
>
{view === 'av' && (
<div className="tw-dialog !w-fit min-w-[380px] max-w-[480px] !gap-3">
<h3 className="tw-color">{t('av.blockedTitle')}</h3>
<p className="s1">
{names.length === 1 ? t('av.blockedOne') : t('av.blockedMany')}
</p>
<ul className="gap-0.5 flex flex-col pl-1">
{names.map(n => (
<li key={n} className="s1 text-orange">
{n}
</li>
))}
</ul>
<p className="s1 text-blueGray">{t('av.blockedExplain')}</p>
<TextButton
icon={HelpCircle}
onClick={() => setView('why')}
className="self-start text-yellow hocus:!text-yellow"
>
{t('av.whyHappening')}
</TextButton>
<TextButton
icon={ShieldAlert}
loading={addExclusion.isLoading}
onClick={() => addExclusion.mutate()}
className="self-start text-orange"
>
{t('av.allowThrough')}
</TextButton>
{addExclusion.data?.ok === true && (
<span className="s1 text-warmGreen">{t('av.addedRetry')}</span>
)}
{addExclusion.data?.ok === false && (
<span className="s1 text-orange">{addExclusion.data.error}</span>
)}
<div className="mt-1 flex justify-end">
<TextButton onClick={() => setView(null)} className="text-green">
{t('av.close')}
</TextButton>
</div>
</div>
)}
{view === 'why' && (
<div className="tw-dialog !w-fit min-w-[420px] max-w-[560px] !gap-3">
<h3 className="tw-color">{t('av.whyTitle')}</h3>
<div className="flex max-h-[60vh] transform-gpu flex-col gap-3 overflow-y-auto overscroll-contain pr-1 [will-change:transform]">
<p className="s1 text-blueGray">
{t('av.whyIntro', { detection: DETECTION })}{' '}
<span className="text-warmGreen">{t('av.falsePositive')}</span>.
</p>
<div>
<p className="tw-color text-[21px] leading-tight">
{t('av.whatSetsItOff')}
</p>
<p className="s1 text-blueGray">{t('av.whatSetsItOffIntro')}</p>
<ul className="mt-1 flex flex-col gap-1 pl-1">
<li className="s1 text-blueGray">
<span className="text-orange">{t('av.dllInjection')}</span>:{' '}
{t('av.dllInjectionText')}
</li>
<li className="s1 text-blueGray">
<span className="text-orange">{t('av.exePatching')}</span>:{' '}
{t('av.exePatchingText')}
</li>
</ul>
<p className="s1 mt-1 text-blueGray">{t('av.heuristicNote')}</p>
</div>
<div>
<p className="tw-color text-[21px] leading-tight">
{t('av.whatModsDo')}
</p>
<ul className="mt-1 flex flex-col gap-1 pl-1">
<li className="s1 text-blueGray">
<span className="text-warmGreen">VanillaFixes</span>:{' '}
{t('av.modVanillaFixes')}
</li>
<li className="s1 text-blueGray">
<span className="text-warmGreen">nampower</span>:{' '}
{t('av.modNampower')}
</li>
<li className="s1 text-blueGray">
<span className="text-warmGreen">SuperWoW and UnitXP</span>:{' '}
{t('av.modSuperWowUnitXp')}
</li>
<li className="s1 text-blueGray">
<span className="text-warmGreen">DXVK</span>:{' '}
{t('av.modDxvk')}
</li>
</ul>
</div>
<div>
<p className="tw-color text-[21px] leading-tight">
{t('av.howToVerify')}
</p>
<p className="s1 mt-1 text-blueGray">
{t('av.howToVerifyText', {
detection: DETECTION
})}
</p>
</div>
<div>
<p className="tw-color text-[21px] leading-tight">
{t('av.whatAllowDoesTitle')}
</p>
<p className="s1 mt-1 text-blueGray">
{t('av.whatAllowDoesIntro')}{' '}
<span className="text-warmGreen">Add-MpPreference</span>{' '}
{t('av.whatAllowDoesIntroAfter')}
</p>
<ul className="mt-1 flex flex-col gap-1 pl-1">
<li className="s1 text-blueGray">
{t('av.exclusionFoldersBefore')}{' '}
<span className="text-warmGreen">{t('av.gameFolder')}</span>{' '}
{t('av.exclusionFoldersAnd')}{' '}
<span className="text-warmGreen">
{t('av.launcherFolder')}
</span>
{t('av.exclusionFoldersAfter')}
</li>
<li className="s1 text-blueGray">
<span className="text-warmGreen">WoW.exe</span>{' '}
{t('av.exclusionExesAnd')}{' '}
<span className="text-warmGreen">VanillaFixes.exe</span>{' '}
{t('av.exclusionExesAfter')}
</li>
</ul>
<p className="s1 mt-1 text-blueGray">
{t('av.whatAllowDoesOutro')}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-3">
<TextButton onClick={() => setView('av')} className="text-blueGray">
{t('av.back')}
</TextButton>
<TextButton onClick={() => setView(null)} className="text-green">
{t('av.close')}
</TextButton>
</div>
</div>
)}
</dialog>,
document.body
);
};
export default AntivirusModal;
+14 -19
View File
@@ -4,6 +4,7 @@ import { useEffect } from 'react';
import { PreferencesSchema } from '~common/schemas';
import zodResolver from '~renderer/utils/zodResolver';
import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton';
import FilePickerInput from './form/FilePickerInput';
@@ -12,6 +13,7 @@ import CloseButton from './styled/CloseButton';
type Props = { close: () => void };
const ClientDirDialog = ({ close }: Props) => {
const t = useT();
const { data: pref } = api.preferences.get.useQuery();
const setPref = api.preferences.set.useMutation();
const isValidClientDir = api.preferences.isValidClientDir.useQuery(
@@ -42,16 +44,14 @@ const ClientDirDialog = ({ close }: Props) => {
return (
<form className="tw-dialog">
<CloseButton close={close} />
<h2 className="color mb-2 text-xl">Install location</h2>
<p>
You are using the portable version of the launcher. Install location
is determined by the location of the launcher executable.
</p>
<h2 className="color mb-2 text-xl">
{t('prefs.installLocationTitle')}
</h2>
<p>{t('prefs.portableInfo')}</p>
{!isValidClientDir.isLoading && !isValidClientDir.data && (
<p>
<span className="text-secondary">Error: </span>
WoW.exe not found in current folder. Please close the launcher and
move it to your WoW 1.12 client directory.
<span className="text-secondary">{t('prefs.errorLabel')}</span>
{t('prefs.wowExeNotFound', { exe: 'WoW.exe' })}
</p>
)}
</form>
@@ -64,7 +64,7 @@ const ClientDirDialog = ({ close }: Props) => {
onSubmit={handleSubmit(async ({ clientDir }) => {
try {
await setPref.mutateAsync({ clientDir });
verify.mutateAsync();
verify.mutate();
close();
} catch (e) {
setError('clientDir', {
@@ -79,18 +79,13 @@ const ClientDirDialog = ({ close }: Props) => {
close();
}}
/>
<h3 className="tw-color">Install location</h3>
<h3 className="tw-color">{t('prefs.installLocationTitle')}</h3>
<hr />
<p className="text-blueGray">
Select a directory for the game client installation.
</p>
<p className="text-blueGray">
You may also choose a directory with an existing Turtle WoW or Vanilla
WoW installation, and it will be automatically upgraded.
</p>
<p className="text-blueGray">{t('prefs.selectDirectory')}</p>
<p className="text-blueGray">{t('prefs.upgradeExisting')}</p>
<div className="flex items-center gap-3">
<label htmlFor="clientDir">Install directory:</label>
<label htmlFor="clientDir">{t('prefs.installDirectory')}</label>
<FilePickerInput
{...register('clientDir')}
title={watch('clientDir') ?? undefined}
@@ -115,7 +110,7 @@ const ClientDirDialog = ({ close }: Props) => {
loading={formState.isSubmitting}
className="self-end text-green"
>
Confirm
{t('prefs.confirm')}
</TextButton>
</form>
);
@@ -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;
+24 -19
View File
@@ -1,5 +1,7 @@
import OctoLogo from '~renderer/assets/logo.png';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton';
import { TabNames, type TabType } from './TabsPanel';
@@ -8,25 +10,28 @@ type Props = {
setActiveTab: (tab?: TabType) => void;
};
const Header = ({ activeTab, setActiveTab }: Props) => (
<div className="-mb-3 flex select-none items-center gap-1">
<button
onClick={() => setActiveTab(undefined)}
className="z-10 -my-3 mx-3 w-[180px] cursor-pointer"
>
<img src={OctoLogo} alt="OctoWoW" className="pointer-events-none" />
</button>
{TabNames.map(t => (
<TextButton
key={t}
onClick={() => setActiveTab(t)}
active={activeTab === t}
className="uppercase"
const Header = ({ activeTab, setActiveTab }: Props) => {
const t = useT();
return (
<div className="-mb-3 flex select-none items-center gap-1">
<button
onClick={() => setActiveTab(undefined)}
className="z-10 -my-3 mx-3 w-[180px] cursor-pointer"
>
{t}
</TextButton>
))}
</div>
);
<img src={OctoLogo} alt="OctoWoW" className="pointer-events-none" />
</button>
{TabNames.map(tab => (
<TextButton
key={tab}
onClick={() => setActiveTab(tab)}
active={activeTab === tab}
className="uppercase"
>
{t(`tab.${tab}`)}
</TextButton>
))}
</div>
);
};
export default Header;
@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from 'react';
import { Globe } from 'lucide-react';
import { createPortal } from 'react-dom';
import cls from 'classnames';
import { api } from '~renderer/utils/api';
import { useLocale } from '~renderer/i18n';
import { type Lang } from '~renderer/i18n/translations';
const LOCALES: { value: Lang; code: string; label: string }[] = [
{ value: 'enUS', code: 'En', label: 'English' },
{ value: 'deDE', code: 'De', label: 'Deutsch' },
{ value: 'zhCN', code: 'Zh', label: '中文' },
{ value: 'esES', code: 'Es', label: 'Español' },
{ value: 'ptBR', code: 'Pt', label: 'Português' },
{ value: 'ruRU', code: 'Ru', label: 'Русский' }
];
const LanguageDropdown = () => {
const { lang, setLang, t } = useLocale();
const setPref = api.preferences.set.useMutation();
const code = LOCALES.find(l => l.value === lang)?.code ?? 'En';
const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ top: number; right: number }>();
const btnRef = useRef<HTMLButtonElement>(null);
const toggle = () => {
const r = btnRef.current?.getBoundingClientRect();
if (r) setPos({ top: r.bottom + 4, right: window.innerWidth - r.right });
setOpen(o => !o);
};
useEffect(() => {
if (!open) return;
const close = () => setOpen(false);
window.addEventListener('click', close);
return () => window.removeEventListener('click', close);
}, [open]);
const pick = (v: Lang) => {
setLang(v);
setPref.mutate({ locale: v });
setOpen(false);
};
return (
<>
<button
ref={btnRef}
type="button"
title={t('topbar.language')}
onClick={e => {
e.stopPropagation();
toggle();
}}
className="bg-transparent flex cursor-pointer items-center border-0 px-1 text-[12px] tracking-wide hocus:text-orange"
>
<Globe size={14} />
{code}
</button>
{open &&
pos &&
createPortal(
<div
onClick={e => e.stopPropagation()}
style={{ top: pos.top, right: pos.right }}
className="fixed z-50 flex flex-col border border-blueGray/30 bg-darkGray py-1 shadow-[0_8px_20px_rgba(0,0,0,0.5)]"
>
{LOCALES.map(l => (
<button
key={l.value}
type="button"
title={l.label}
onClick={() => pick(l.value)}
className={cls(
'bg-transparent cursor-pointer border-0 px-3 py-1 text-center text-[12px] hocus:bg-orange/20',
l.value === lang ? 'text-warmGreen' : 'text-white'
)}
>
{l.code}
</button>
))}
</div>,
document.body
)}
</>
);
};
export default LanguageDropdown;
+58 -37
View File
@@ -1,9 +1,11 @@
import { useState, type ReactElement } from 'react';
import cls from 'classnames';
import log from 'electron-log/renderer';
import { type UpdaterStatus, type ModsStatus } from '~main/types';
import { formatFileSize } from '~common/utils';
import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import Button from './styled/Button';
import DialogButton from './styled/DialogButton';
@@ -20,40 +22,43 @@ const formatDuration = (seconds: number) => {
return minRem ? `${h}h ${minRem}m` : `${h}h`;
};
const formatPercent = (progress: number) => `${(progress * 100).toFixed(1)}%`;
const ProgressDetails = ({ status }: { status: UpdaterStatus }) => {
const { bytesDone, bytesTotal, bytesPerSecond, etaSeconds, progress } = status;
const t = useT();
const { bytesDone, bytesTotal, bytesPerSecond, etaSeconds, progress } =
status;
if (bytesTotal === undefined || bytesDone === undefined) return null;
const pct = progress !== undefined && progress >= 0
? `${(progress * 100).toFixed(1)}%`
: '—';
const pct =
progress !== undefined && progress >= 0 ? formatPercent(progress) : '—';
return (
<p className="s1 text-blueGray">
<span className="tw-color">{pct}</span>
<span> · {formatFileSize(bytesDone)} / {formatFileSize(bytesTotal)}</span>
<span>
{' '}
· {formatFileSize(bytesDone)} / {formatFileSize(bytesTotal)}
</span>
{bytesPerSecond !== undefined && bytesPerSecond > 0 && (
<span> · {formatFileSize(bytesPerSecond)}/s</span>
)}
<span>
{' · '}
{etaSeconds !== undefined
? `~${formatDuration(etaSeconds)} remaining`
: 'calculating'}
? `~${formatDuration(etaSeconds)} ${t('launch.remaining')}`
: t('launch.calculating')}
</span>
</p>
);
};
const LaunchPanel = () => {
const t = useT();
const [status, setStatus] = useState<UpdaterStatus>({ state: 'verifying' });
api.updater.observe.useSubscription(undefined, {
onData: data => {
console.log({ data });
setStatus(data);
},
onError: err => console.log({ err }),
onStarted: () => console.log('Started')
onData: setStatus,
onError: err => log.error('Updater subscription error:', err)
});
const { data: pref } = api.preferences.get.useQuery();
@@ -72,23 +77,25 @@ const LaunchPanel = () => {
UpdaterStatus['state'],
{ button: ReactElement; helperText?: ReactElement }
> = {
verifying: { button: <Button disabled>Verifying</Button> },
verifying: { button: <Button disabled>{t('launch.verifying')}</Button> },
serverUnreachable: {
button: pref?.version ? (
<Button onClick={() => start.mutateAsync()}>Play</Button>
<Button onClick={() => start.mutateAsync()}>{t('launch.play')}</Button>
) : (
<Button onClick={() => verify.mutateAsync()}>Retry</Button>
<Button onClick={() => verify.mutateAsync()}>
{t('launch.retry')}
</Button>
),
helperText: (
<div className="-mb-2">
<p>
<span className="text-orange">Error: </span> Failed to reach update
server
<span className="text-orange">{t('launch.errorLabel')}</span>{' '}
{t('launch.serverFail')}
</p>
<p className="s1 text-blueGray">
{pref?.version
? `You can launch local version ${pref?.version}`
: 'Please try again later'}
? t('launch.localVersion', { version: pref.version })
: t('launch.tryLater')}
</p>
</div>
)
@@ -101,39 +108,44 @@ const LaunchPanel = () => {
>
{open => (
<Button primary onClick={open}>
Install
{t('launch.install')}
</Button>
)}
</DialogButton>
)
},
updateAvailable: {
button: <Button onClick={() => update.mutateAsync()}>Update</Button>,
button: (
<Button onClick={() => update.mutateAsync()}>
{t('launch.update')}
</Button>
),
helperText: (
<div className="-mb-2 flex flex-col gap-1">
<p>Update available!</p>
<p>{t('launch.updateAvailable')}</p>
<p className="s1 text-blueGray">
{status.progress !== undefined &&
status.bytesDone !== undefined &&
status.bytesTotal !== undefined && (
<>
<span className="tw-color">
{(status.progress * 100).toFixed(1)}%
{formatPercent(status.progress)}
</span>
<span>
{' '}
· {formatFileSize(status.bytesDone)} /{' '}
{formatFileSize(status.bytesTotal)} on disk ·{' '}
{formatFileSize(status.bytesTotal)} {t('launch.onDisk')} ·{' '}
</span>
</>
)}
<span className="break-all">{status.message}</span> remaining
<span className="break-all">{status.message}</span>{' '}
{t('launch.remaining')}
</p>
</div>
)
},
updating: {
button: <Button disabled>Updating</Button>,
button: <Button disabled>{t('launch.updating')}</Button>,
helperText: (
<div className="-mb-2 flex flex-col gap-1">
{status.message && (
@@ -150,35 +162,41 @@ const LaunchPanel = () => {
onClick={() => applyMods.mutateAsync()}
disabled={applyMods.isLoading || modsStatus?.state === 'busy'}
>
{modsStatus?.state === 'busy' ? 'Applying' : 'Update'}
{modsStatus?.state === 'busy'
? t('launch.applying')
: t('launch.update')}
</Button>
) : (
<Button primary onClick={() => start.mutateAsync()}>
Play
{t('launch.play')}
</Button>
),
helperText: (
<div className="-mb-2">
{modsStatus?.dirty ? (
<p>Mods changed apply before playing</p>
<p>{t('launch.modsChanged')}</p>
) : (
<p>Everything up to date!</p>
<p>{t('launch.upToDate')}</p>
)}
<p className="s1 text-blueGray">Version: {pref?.version}</p>
<p className="s1 text-blueGray">
{t('launch.version', { version: pref?.version ?? '' })}
</p>
</div>
)
},
failed: {
button: <Button onClick={() => verify.mutateAsync()}>Retry</Button>,
button: (
<Button onClick={() => verify.mutateAsync()}>
{t('launch.retry')}
</Button>
),
helperText: (
<div className="-mb-2">
<p>
<span className="text-orange">Error: </span>
<span className="text-orange">{t('launch.errorLabel')}</span>{' '}
{status.message}
</p>
<p className="s1 text-blueGray">
Verify your game data by clicking Retry.
</p>
<p className="s1 text-blueGray">{t('launch.verifyHint')}</p>
</div>
)
}
@@ -191,6 +209,9 @@ const LaunchPanel = () => {
(status.message && (
<p className="s1 -mb-2 text-blueGray">{status.message}</p>
))}
{start.data && !start.data.ok && start.data.error && (
<p className="s1 -mb-2 text-orange">{start.data.error}</p>
)}
<div className="tw-loading-wrapper">
{status.progress !== undefined && (
<div
+58 -26
View File
@@ -5,12 +5,14 @@ import {
FolderOpen,
RefreshCw,
ScrollText,
ShieldAlert,
ShieldCheck
} from 'lucide-react';
import { PreferencesSchema } from '~common/schemas';
import { api } from '~renderer/utils/api';
import zodResolver from '~renderer/utils/zodResolver';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton';
import CheckboxInput from './form/CheckboxInput';
@@ -19,27 +21,33 @@ import ClientDirDialog from './ClientDirDialog';
import CloseButton from './styled/CloseButton';
const MirrorStatus = () => {
const t = useT();
const [state, setState] = useState<string>('verifying');
api.updater.observe.useSubscription(undefined, {
onData: ({ state }) => setState(state)
});
if (state === 'serverUnreachable')
return <span className="s1 text-red">offline</span>;
return <span className="s1 text-red">{t('prefs.mirrorOffline')}</span>;
if (state === 'verifying' || state === 'updating')
return <span className="s1 text-blueGray">checking</span>;
return <span className="s1 text-warmGreen">online</span>;
return (
<span className="s1 text-blueGray">{t('prefs.mirrorChecking')}</span>
);
return <span className="s1 text-warmGreen">{t('prefs.mirrorOnline')}</span>;
};
type Props = { close: () => void };
const PreferencesDialog = ({ close }: Props) => {
const t = useT();
const { data: pref } = api.preferences.get.useQuery();
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();
const { handleSubmit, watch, setValue, reset } = useForm({
defaultValues: pref ?? {},
@@ -59,9 +67,12 @@ const PreferencesDialog = ({ close }: Props) => {
return (
<form
className="tw-dialog !w-fit min-w-[480px] max-w-[640px] !gap-1 whitespace-nowrap"
className="tw-dialog !w-fit min-w-[480px] max-w-[640px] !gap-1"
onSubmit={handleSubmit(async v => {
await setPref.mutateAsync(v);
await setPref.mutateAsync({
cleanWdb: v.cleanWdb,
minimizeToTrayOnPlay: v.minimizeToTrayOnPlay
});
close();
})}
>
@@ -71,26 +82,26 @@ const PreferencesDialog = ({ close }: Props) => {
close();
}}
/>
<h3 className="tw-color">SETTINGS</h3>
<h3 className="tw-color">{t('prefs.title')}</h3>
<hr className="mb-1" />
<div className="flex items-center gap-3">
<h4 className="tw-color">INSTALL LOCATION:</h4>
<h4 className="tw-color">{t('prefs.installLocation')}</h4>
<TextButton
icon={FolderOpen}
size={14}
onClick={() => openInstallFolder.mutateAsync()}
className="!p-1 text-blueGray"
>
Open folder
{t('prefs.openFolder')}
</TextButton>
</div>
<div className="flex items-center gap-2 border border-blueGray/20 bg-darkGray/40 px-3 py-1">
<span
title={pref?.clientDir}
className="min-w-0 shrink grow overflow-hidden text-ellipsis"
className="min-w-0 shrink grow overflow-hidden text-ellipsis whitespace-nowrap"
>
{pref?.clientDir ?? 'Not selected'}
{pref?.clientDir ?? t('prefs.notSelected')}
</span>
<DialogButton
dialog={closeInner => (
@@ -104,15 +115,20 @@ const PreferencesDialog = ({ close }: Props) => {
clickAway={pref?.isPortable}
>
{open => (
<TextButton icon={FilePen} size={14} onClick={open} className="!p-1">
Change
<TextButton
icon={FilePen}
size={14}
onClick={open}
className="!p-1"
>
{t('prefs.change')}
</TextButton>
)}
</DialogButton>
</div>
<div className="mt-1 flex items-center gap-3">
<h4 className="tw-color">DOWNLOAD MIRROR:</h4>
<h4 className="tw-color">{t('prefs.downloadMirror')}</h4>
</div>
<div className="flex items-center gap-2 pl-2">
<input type="radio" checked readOnly className="accent-warmGreen" />
@@ -122,47 +138,63 @@ const PreferencesDialog = ({ close }: Props) => {
icon={RefreshCw}
size={12}
onClick={() => verify.mutateAsync()}
title="Re-check"
title={t('prefs.recheck')}
className="!p-0 text-blueGray"
/>
</div>
<div className="flex items-start gap-3">
<div className="flex flex-col">
<h4 className="tw-color">TROUBLESHOOTING:</h4>
<div className="flex min-w-0 flex-col">
<h4 className="tw-color">{t('prefs.troubleshooting')}</h4>
<TextButton
icon={ShieldCheck}
onClick={() => verify.mutateAsync().then(close)}
className="text-warmGreen"
onClick={() => repair.mutateAsync().then(close)}
className="!items-start text-left text-warmGreen"
>
Verify game files
{t('prefs.verifyGameFiles')}
</TextButton>
<TextButton
icon={ScrollText}
onClick={() => openLogFile.mutateAsync()}
className="text-pink"
className="!items-start text-left text-pink"
>
Open log file
{t('prefs.openLogFile')}
</TextButton>
<TextButton
icon={ShieldAlert}
onClick={() => addExclusion.mutateAsync()}
loading={addExclusion.isLoading}
className="!items-start text-left text-orange"
>
{t('prefs.allowThroughAntivirus')}
</TextButton>
{addExclusion.data?.ok === true && (
<span className="s1 text-warmGreen">
{t('prefs.exclusionAdded')}
</span>
)}
{addExclusion.data?.ok === false && (
<span className="s1 text-orange">{addExclusion.data.error}</span>
)}
</div>
<div className="flex flex-col">
<h4 className="tw-color">GENERAL SETTINGS:</h4>
<div className="flex min-w-0 flex-col">
<h4 className="tw-color">{t('prefs.generalSettings')}</h4>
<CheckboxInput
value={!!watch('cleanWdb')}
setValue={setBool('cleanWdb')}
label="Clean WDB on each launch"
label={t('prefs.cleanWdb')}
/>
<CheckboxInput
value={!!watch('minimizeToTrayOnPlay')}
setValue={setBool('minimizeToTrayOnPlay')}
label="Minimize to tray while playing"
label={t('prefs.minimizeToTray')}
/>
</div>
</div>
<TextButton type="submit" className="mt-1 self-end text-green">
Save
{t('prefs.save')}
</TextButton>
</form>
);
+15 -10
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import Button from './styled/Button';
@@ -19,6 +20,7 @@ type Status =
| { state: 'error'; currentVersion: string; message: string };
const SelfUpdateBanner = () => {
const t = useT();
const [status, setStatus] = useState<Status>({
state: 'idle',
currentVersion: ''
@@ -39,16 +41,19 @@ const SelfUpdateBanner = () => {
const tone = status.state === 'error' ? 'border-red/40' : 'border-tw/40';
const label =
status.state === 'error'
? `Update check failed: ${status.message}`
? t('misc.selfUpdateCheckFailed', { message: status.message })
: status.state === 'available'
? `Launcher update ${'nextVersion' in status ? status.nextVersion : ''} available — preparing download…`
: status.state === 'downloading'
? `Downloading update ${status.nextVersion} · ${Math.round(
status.progress * 100
)}%`
: status.state === 'ready'
? `Launcher update ${status.nextVersion} ready to install`
: '';
? t('misc.selfUpdateAvailable', {
version: status.nextVersion
})
: status.state === 'downloading'
? t('misc.selfUpdateDownloading', {
version: status.nextVersion,
percent: Math.round(status.progress * 100)
})
: status.state === 'ready'
? t('misc.selfUpdateReady', { version: status.nextVersion })
: '';
return (
<div
@@ -61,7 +66,7 @@ const SelfUpdateBanner = () => {
onClick={() => install.mutateAsync()}
disabled={install.isLoading}
>
Install now
{t('misc.selfUpdateInstallNow')}
</Button>
)}
</div>
+49 -23
View File
@@ -2,6 +2,8 @@ import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Component, type ErrorInfo, type ReactNode } from 'react';
import log from 'electron-log/renderer';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton';
type Props = {
@@ -14,6 +16,47 @@ type State = {
componentStack?: string;
};
type FallbackProps = {
tabName: string;
error: Error;
componentStack?: string;
onReset: () => void;
};
const TabErrorFallback = ({
tabName,
error,
componentStack,
onReset
}: FallbackProps) => {
const t = useT();
return (
<div className="tw-surface flex min-h-0 flex-grow flex-col gap-3">
<div className="flex items-center gap-2">
<AlertTriangle size={22} className="text-red" />
<h4 className="text-red">{t('misc.tabCrashed', { tab: tabName })}</h4>
</div>
<hr />
<p className="text-white">
{error.name}: {error.message}
</p>
{componentStack && (
<pre className="s1 max-h-[200px] overflow-auto whitespace-pre-wrap text-blueGray">
{componentStack.trim()}
</pre>
)}
<hr />
<TextButton
icon={RefreshCw}
onClick={onReset}
className="self-end text-warmGreen"
>
{t('misc.tryAgain')}
</TextButton>
</div>
);
};
class TabErrorBoundary extends Component<Props, State> {
state: State = {};
@@ -38,29 +81,12 @@ class TabErrorBoundary extends Component<Props, State> {
if (!this.state.error) return this.props.children;
const { error, componentStack } = this.state;
return (
<div className="tw-surface flex min-h-0 flex-grow flex-col gap-3">
<div className="flex items-center gap-2">
<AlertTriangle size={22} className="text-red" />
<h4 className="text-red">{this.props.tabName} crashed</h4>
</div>
<hr />
<p className="text-white">
{error.name}: {error.message}
</p>
{componentStack && (
<pre className="s1 max-h-[200px] overflow-auto whitespace-pre-wrap text-blueGray">
{componentStack.trim()}
</pre>
)}
<hr />
<TextButton
icon={RefreshCw}
onClick={this.#reset}
className="self-end text-warmGreen"
>
Try again
</TextButton>
</div>
<TabErrorFallback
tabName={this.props.tabName}
error={error}
componentStack={componentStack}
onReset={this.#reset}
/>
);
}
}
+15 -11
View File
@@ -2,12 +2,15 @@ import { Settings, Minus, X } from 'lucide-react';
import { useState } from 'react';
import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import DialogButton from './styled/DialogButton';
import PreferencesDialog from './PreferencesDialog';
import TextButton from './styled/TextButton';
import LanguageDropdown from './LanguageDropdown';
const TopBar = () => {
const t = useT();
const [safeToQuit, setSafeToQuit] = useState(true);
api.updater.observe.useSubscription(undefined, {
onData: ({ state }) =>
@@ -21,12 +24,16 @@ const TopBar = () => {
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
className="absolute left-0 right-0 top-0 flex justify-end pr-2 pt-2 opacity-50"
>
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties} className="flex">
<div
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
className="flex items-center"
>
<LanguageDropdown />
<DialogButton dialog={close => <PreferencesDialog close={close} />}>
{open => (
<TextButton
icon={Settings}
title="Settings"
title={t('topbar.settings')}
onClick={open}
size={16}
className="!p-1"
@@ -35,7 +42,7 @@ const TopBar = () => {
</DialogButton>
<TextButton
icon={Minus}
title="Minimize"
title={t('topbar.minimize')}
onClick={() => minimize.mutateAsync()}
size={16}
className="!p-1"
@@ -43,19 +50,16 @@ const TopBar = () => {
<DialogButton
dialog={close => (
<div className="tw-dialog">
<h3 className="tw-color">Quit?</h3>
<h3 className="tw-color">{t('quit.title')}</h3>
<hr />
<p className="text-blueGray">
Your game is currently being updated. Quitting now may cause
problems.
</p>
<p className="text-blueGray">{t('quit.warn')}</p>
<div className="flex gap-2 self-end">
<TextButton onClick={close}>Return</TextButton>
<TextButton onClick={close}>{t('quit.return')}</TextButton>
<TextButton
onClick={() => quit.mutateAsync()}
className="text-red"
>
Quit
{t('topbar.quit')}
</TextButton>
</div>
</div>
@@ -64,7 +68,7 @@ const TopBar = () => {
{open => (
<TextButton
icon={X}
title="Quit"
title={t('topbar.quit')}
onClick={() => (!safeToQuit ? open() : quit.mutateAsync())}
size={16}
className="!p-1 hocus:text-red"
@@ -33,12 +33,18 @@ type Props = {
className?: cls.Value;
};
const CheckboxInput = ({ label, value, setValue, disabled, className }: Props) => (
const CheckboxInput = ({
label,
value,
setValue,
disabled,
className
}: Props) => (
<TextButton
onClick={() => !disabled && setValue(!value)}
icon={Checkbox}
className={cls(
'text-blueGray',
'!items-start text-left text-blueGray',
{ '[&_*]:fill-none': !value, 'pointer-events-none opacity-40': disabled },
className
)}
@@ -39,7 +39,6 @@ const DialogButton = ({
ref.current?.close();
}, []);
// Click away
useEffect(() => {
if (!clickAway) return;
const callback = (e: MouseEvent) => e.target === ref.current && close();
@@ -53,7 +53,7 @@ const TextButton = ({
{loading ? (
<IconSpinner size={size ?? 24} strokeWidth={1.5} />
) : (
Icon && <Icon size={size} />
Icon && <Icon size={size} className="shrink-0" />
)}
{children && (
<span className="cursor-pointer select-none tracking-wide text-inherit [font-size:_inherit]">
+34 -10
View File
@@ -5,6 +5,7 @@ import { type AddonData, type AddonsStatus } from '~main/types';
import { api } from '~renderer/utils/api';
import TextButton from '~renderer/components/styled/TextButton';
import useScrollHint from '~renderer/utils/useScrollHint';
import { useT } from '~renderer/i18n';
import DialogButton from '../styled/DialogButton';
import IconSpinner from '../styled/IconSpinner';
@@ -13,6 +14,17 @@ import AddonList from './addons/AddonList';
import { type Dependencies } from './addons/AddonListItem';
import CustomAddonDialog from './addons/CustomAddonDialog';
const RECOMMENDED = new Set([
'AtlasLoot',
'pfExtend',
'pfQuest',
'pfQuest-turtle',
'SellValue',
'ShaguTweaks',
'ShaguTweaks-extras',
'TurtleMail'
]);
const localeFilter = (l: AddonData[], filter: string) => {
const seen = new Set<string>();
const deduped = l.filter(a => {
@@ -21,14 +33,14 @@ const localeFilter = (l: AddonData[], filter: string) => {
return true;
});
return deduped
.filter(
a =>
a.folder.toLocaleLowerCase().indexOf(filter.toLocaleLowerCase()) !== -1
.filter(a =>
a.folder.toLocaleLowerCase().includes(filter.toLocaleLowerCase())
)
.sort((a, b) => a.folder.localeCompare(b.folder));
};
const AddonsTab = () => {
const t = useT();
const [data, setData] = useState<AddonsStatus>({
state: 'verifying',
addons: {},
@@ -61,14 +73,26 @@ const AddonsTab = () => {
className="relative -m-4 -mb-3 flex flex-grow flex-col gap-3 overflow-y-auto overflow-x-hidden p-4 pb-3"
>
<AddonList
title="Installed"
title={t('addons.sectionInstalled')}
addons={localeFilter(Object.values(data.addons), filter)}
dependencies={dependencies}
/>
<AddonList
title="Available"
title={t('addons.sectionRecommended')}
addons={localeFilter(
data.available.filter(a => !(a.folder in data.addons)),
data.available.filter(
a => !(a.folder in data.addons) && RECOMMENDED.has(a.folder)
),
filter
)}
dependencies={dependencies}
/>
<AddonList
title={t('addons.sectionAvailable')}
addons={localeFilter(
data.available.filter(
a => !(a.folder in data.addons) && !RECOMMENDED.has(a.folder)
),
filter
)}
dependencies={dependencies}
@@ -83,7 +107,7 @@ const AddonsTab = () => {
size={18}
loading={data.state !== 'done'}
>
Check for updates
{t('addons.checkForUpdates')}
</TextButton>
<DialogButton
clickAway
@@ -96,7 +120,7 @@ const AddonsTab = () => {
onClick={open}
className="s1 text-pink"
>
Add custom git addon
{t('addons.addCustomGitAddon')}
</TextButton>
)}
</DialogButton>
@@ -107,11 +131,11 @@ const AddonsTab = () => {
onClick={() => update.mutateAsync({})}
className="justify-self-end text-warmGreen"
>
Update all
{t('addons.updateAll')}
</TextButton>
) : (
<p className="s1 justify-self-end text-blueGray">
Everything is up to date.
{t('addons.everythingUpToDate')}
</p>
)}
</div>
+10 -5
View File
@@ -1,7 +1,12 @@
const ComingSoonTab = () => (
<div className="tw-surface flex flex-grow flex-col items-center justify-center gap-2">
<p className="italic text-blueGray">Coming soon...</p>
</div>
);
import { useT } from '~renderer/i18n';
const ComingSoonTab = () => {
const t = useT();
return (
<div className="tw-surface flex flex-grow flex-col items-center justify-center gap-2">
<p className="italic text-blueGray">{t('misc.comingSoon')}</p>
</div>
);
};
export default ComingSoonTab;
+98 -18
View File
@@ -1,9 +1,11 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { ExternalLink, AlertTriangle, Sparkles } from 'lucide-react';
import { createPortal } from 'react-dom';
import cls from 'classnames';
import { api } from '~renderer/utils/api';
import useScrollHint from '~renderer/utils/useScrollHint';
import { useT } from '~renderer/i18n';
import { type ModRowStatus, type ModsStatus } from '~main/types';
import TextButton from '../styled/TextButton';
@@ -11,9 +13,8 @@ import CheckboxInput from '../form/CheckboxInput';
import IconSpinner from '../styled/IconSpinner';
const RowState = ({ row }: { row: ModRowStatus }) => {
if (row.state === 'downloading' || row.state === 'installing')
return <IconSpinner className="text-blueGray" />;
if (row.state === 'uninstalling')
const t = useT();
if (['downloading', 'installing', 'uninstalling'].includes(row.state))
return <IconSpinner className="text-blueGray" />;
if (row.state === 'error')
return (
@@ -21,12 +22,17 @@ const RowState = ({ row }: { row: ModRowStatus }) => {
<AlertTriangle size={14} className="text-red" />
</span>
);
if (row.installedVersion && row.installedVersion !== row.latestVersion && !row.ignoreUpdates)
return <span className="s1 text-pink">update</span>;
if (
row.installedVersion &&
row.installedVersion !== row.latestVersion &&
!row.ignoreUpdates
)
return <span className="s1 text-pink">{t('mods.update')}</span>;
return null;
};
const ModRow = ({ row }: { row: ModRowStatus }) => {
const t = useT();
const toggle = api.mods.toggle.useMutation();
const setIgnore = api.mods.setIgnoreUpdates.useMutation();
const openLink = api.general.openLink.useMutation();
@@ -37,7 +43,9 @@ const ModRow = ({ row }: { row: ModRowStatus }) => {
{row.recommended && (
<Sparkles size={12} className="shrink-0 text-warmGreen" />
)}
<span className={cls(row.recommended && 'text-warmGreen')}>{row.name}</span>
<span className={cls(row.recommended && 'text-warmGreen')}>
{row.name}
</span>
<span className="s1 text-warmGreen">{row.latestVersion}</span>
</div>
<CheckboxInput
@@ -59,13 +67,14 @@ const ModRow = ({ row }: { row: ModRowStatus }) => {
<CheckboxInput
value={row.ignoreUpdates}
setValue={v => setIgnore.mutate({ id: row.id, ignore: v })}
label={<span className="s1">Ignore updates</span>}
label={<span className="s1">{t('mods.ignoreUpdates')}</span>}
/>
</>
);
};
const ModsTab = () => {
const t = useT();
const [status, setStatus] = useState<ModsStatus>();
api.mods.observe.useSubscription(undefined, {
onData: setStatus
@@ -82,40 +91,111 @@ const ModsTab = () => {
const scrollRef = useScrollHint<HTMLDivElement>();
const mods = status?.mods ?? [];
const enabledIds = new Set(mods.filter(m => m.enabled).map(m => m.id));
const modName = (id: string) => mods.find(m => m.id === id)?.name ?? id;
const missingDeps = [
...new Set(
mods
.filter(m => m.enabled)
.flatMap(m => m.requires.filter(d => !enabledIds.has(d)))
)
];
const pendingDepMessage = missingDeps
.map(dep => {
const requiredBy = mods
.filter(m => m.enabled && m.requires.includes(dep))
.map(m => m.name)
.join(', ');
return t('mods.depRequired', { mod: modName(dep), requiredBy });
})
.join('\n');
const dialogRef = useRef<HTMLDialogElement>(null);
const [shownDepMessage, setShownDepMessage] = useState<string | null>(null);
useEffect(() => {
if (shownDepMessage) {
dialogRef.current?.showModal();
(document.activeElement as HTMLElement | null)?.blur();
} else dialogRef.current?.close();
}, [shownDepMessage]);
const onApply = () => {
if (missingDeps.length) {
setShownDepMessage(pendingDepMessage);
return;
}
apply.mutateAsync();
};
const showApply =
!!status?.dirty || apply.isLoading || status?.state === 'busy';
return (
<div className="tw-surface flex min-h-0 flex-grow flex-col gap-3">
<div className="flex items-baseline justify-between">
<h4 className="tw-color">CUSTOM MODS</h4>
<h4 className="tw-color">{t('mods.title')}</h4>
{status?.dirty && (
<span className="s1 text-pink">unsaved changes</span>
<span className="s1 text-pink">{t('mods.unsavedChanges')}</span>
)}
</div>
<p className="s1 text-blueGray">
<span className="text-orange"></span> Enabling custom mods may not provide
any performance benefits or may even cause game crashes depending on your
system. Please try disabling them if you experience any issues.
<span className="text-orange"></span> {t('mods.warning')}
</p>
{missingDeps.length > 0 && (
<p className="s1 text-orange">
{' '}
{t('mods.enableRequired', {
mods: missingDeps.map(modName).join(', ')
})}
</p>
)}
<hr />
<div
ref={scrollRef}
className="relative -m-4 -mt-0 grid flex-grow grid-cols-[auto_auto_1fr_auto] content-start items-center gap-x-4 gap-y-2 overflow-y-auto p-4 pt-0"
>
{status?.mods.map(row => <ModRow key={row.id} row={row} />)}
{status?.mods.map(row => (
<ModRow key={row.id} row={row} />
))}
</div>
<hr />
<div className="-mb-4 -mt-3 flex items-center gap-2 py-2">
<p className="s1 flex-grow text-blueGray">
<span className="text-warmGreen">Highlighted</span> mods are recommended.
<span className="text-warmGreen">{t('mods.highlighted')}</span>{' '}
{t('mods.highlightedRecommended')}
</p>
<TextButton
type="button"
loading={apply.isLoading || status?.state === 'busy'}
onClick={() => apply.mutateAsync()}
className={cls(status?.dirty && 'text-green')}
onClick={onApply}
className={cls('text-green', !showApply && 'invisible')}
>
Apply
{t('mods.apply')}
</TextButton>
</div>
{createPortal(
<dialog
ref={dialogRef}
onClose={() => setShownDepMessage(null)}
className="h-full w-full items-center justify-center bg-[transparent] backdrop:backdrop-blur-sm [&[open]]:flex"
>
{shownDepMessage && (
<div className="tw-dialog !w-fit min-w-[360px] max-w-[460px] !gap-3">
<h3 className="tw-color">{t('mods.cantApplyYet')}</h3>
<p className="s1 whitespace-pre-line">{shownDepMessage}</p>
<TextButton
onClick={() => setShownDepMessage(null)}
className="self-end text-green"
>
{t('mods.close')}
</TextButton>
</div>
)}
</dialog>,
document.body
)}
</div>
);
};
+30 -11
View File
@@ -2,8 +2,10 @@ import { AlertTriangle, ExternalLink, RefreshCw } from 'lucide-react';
import { type NewsItem } from '~main/types';
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';
@@ -18,15 +20,20 @@ const formatDate = (raw: string) => {
};
const NewsEntry = ({ item }: { item: NewsItem }) => {
const t = useT();
const openLink = api.general.openLink.useMutation();
return (
<article className="flex flex-col gap-1 border-b border-blueGray/30 pb-3 last:border-0">
<div className="flex items-baseline justify-between gap-3">
<h5 className="tw-color">{item.title}</h5>
<span className="s1 shrink-0 text-blueGray">{formatDate(item.date)}</span>
<span className="s1 shrink-0 text-blueGray">
{formatDate(item.date)}
</span>
</div>
{item.author && (
<span className="s1 italic text-blueGray">by {item.author}</span>
<span className="s1 italic text-blueGray">
{t('misc.newsByAuthor', { author: item.author })}
</span>
)}
<p className="whitespace-pre-wrap text-sm leading-relaxed">{item.body}</p>
{item.url && (
@@ -36,14 +43,16 @@ const NewsEntry = ({ item }: { item: NewsItem }) => {
className="-ml-2 self-start text-pink"
onClick={() => openLink.mutateAsync(item.url!)}
>
Read more
{t('misc.newsReadMore')}
</TextButton>
)}
</article>
);
};
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,
refetchOnWindowFocus: false,
@@ -52,16 +61,16 @@ const NewsTab = () => {
const scrollRef = useScrollHint<HTMLDivElement>();
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">
<h4 className="tw-color">News</h4>
<h4 className="tw-color">{t('misc.announcementsTitle')}</h4>
<TextButton
icon={RefreshCw}
size={18}
className="-mr-2 text-blueGray"
loading={query.isFetching}
onClick={() => query.refetch()}
title="Refresh"
title={t('misc.refresh')}
/>
</div>
<hr />
@@ -72,24 +81,24 @@ const NewsTab = () => {
{query.isLoading ? (
<div className="flex flex-grow flex-col items-center justify-center gap-2">
<IconSpinner className="text-blueGray" />
<p className="italic text-blueGray">Loading news...</p>
<p className="italic text-blueGray">{t('misc.newsLoading')}</p>
</div>
) : query.isError ? (
<div className="flex flex-grow flex-col items-center justify-center gap-3">
<AlertTriangle size={32} className="text-red" />
<p className="italic text-blueGray">Couldn't reach the news feed.</p>
<p className="italic text-blueGray">{t('misc.newsError')}</p>
<TextButton
icon={RefreshCw}
size={18}
className="text-pink"
onClick={() => query.refetch()}
>
Try again
{t('misc.tryAgain')}
</TextButton>
</div>
) : !query.data?.length ? (
<div className="flex flex-grow flex-col items-center justify-center">
<p className="italic text-blueGray">No news yet check back later.</p>
<p className="italic text-blueGray">{t('misc.newsEmpty')}</p>
</div>
) : (
query.data.map(item => <NewsEntry key={item.id} item={item} />)
@@ -99,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;
+67 -28
View File
@@ -6,6 +6,7 @@ import { api } from '~renderer/utils/api';
import { ConfigWtfSchema } from '~common/schemas';
import zodResolver from '~renderer/utils/zodResolver';
import useScrollHint from '~renderer/utils/useScrollHint';
import { useT } from '~renderer/i18n';
import TextButton from '../styled/TextButton';
import CheckboxInput from '../form/CheckboxInput';
@@ -64,6 +65,7 @@ const Item = ({
};
const TweaksTab = () => {
const t = useT();
const { data: pref } = api.preferences.get.useQuery();
const setPref = api.preferences.set.useMutation();
@@ -74,7 +76,19 @@ const TweaksTab = () => {
defaultValues: pref?.config ?? {},
resolver: zodResolver(ConfigWtfSchema)
});
const { handleSubmit, reset } = 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 =
setPref.isLoading || applyPatch.isLoading || verify.isLoading;
useEffect(() => {
pref && reset(pref.config);
@@ -85,7 +99,7 @@ const TweaksTab = () => {
return (
<form
onSubmit={handleSubmit(async config => {
await setPref.mutateAsync({ config });
await setPref.mutateAsync({ config, farClipUserSet: true });
await applyPatch.mutateAsync();
await verify.mutateAsync();
@@ -100,33 +114,35 @@ const TweaksTab = () => {
<Item
form={form}
id="alwaysAutoLoot"
label="Always auto-loot"
text="Reverses auto-loot behavior to always auto-loot and disable auto-with bound key."
label={t('tweaks.alwaysAutoLoot.label')}
text={t('tweaks.alwaysAutoLoot.text')}
/>
<Item
form={form}
id="largeAddress"
label="Large Address Aware"
text="Allows the game to use more than 2GB of memory."
label={t('tweaks.largeAddress.label')}
text={t('tweaks.largeAddress.text')}
recommended
/>
<Item
form={form}
type="number"
id="nameplateRange"
label="Nameplate range"
text="Increases distance at which nameplates are visible. [Vanilla: 20] [Classic: 41]"
label={t('tweaks.nameplateRange.label')}
text={t('tweaks.nameplateRange.text')}
min={0}
max={41}
/>
<h4 className="tw-color col-span-3 mt-3">Camera</h4>
<h4 className="tw-color col-span-3 mt-3">
{t('tweaks.cameraHeading')}
</h4>
<Item
form={form}
id="fieldOfView"
label="Field of View"
label={t('tweaks.fieldOfView.label')}
type="number"
text="Recommended for widescreen window resolutions. [Vanilla: 90] [Tweaks: 110]"
text={t('tweaks.fieldOfView.text')}
min={90}
max={180}
step={5}
@@ -134,9 +150,13 @@ const TweaksTab = () => {
<Item
form={form}
id="farClip"
label="Render distance"
label={t('tweaks.farClip.label')}
type="number"
text="Increases maximum render distance. [Vanilla: 777] [Tweaks: 10000]"
text={farClipText}
recommended={
recommendedFarClip != null &&
Number(farClipValue) === recommendedFarClip
}
min={100}
max={10000}
sensitivity={3}
@@ -144,9 +164,9 @@ const TweaksTab = () => {
<Item
form={form}
id="frillDistance"
label="Ground clutter distance"
label={t('tweaks.frillDistance.label')}
type="number"
text="Changes ground clutter render distance. [Vanilla: 70] [Tweaks: 300]"
text={t('tweaks.frillDistance.text')}
min={0}
max={300}
sensitivity={0.3}
@@ -154,40 +174,59 @@ const TweaksTab = () => {
<Item
form={form}
id="cameraDistance"
label="Camera distance"
label={t('tweaks.cameraDistance.label')}
type="number"
text="Increases maximum camera (zoom out) distance. [Vanilla: 50] [Max:100]"
text={t('tweaks.cameraDistance.text')}
min={50}
max={100}
/>
<h4 className="tw-color col-span-3 mt-3">Sounds</h4>
<h4 className="tw-color col-span-3 mt-3">
{t('tweaks.soundsHeading')}
</h4>
<Item
form={form}
id="soundInBackground"
label="Background sounds"
text="Allows game sounds to play while the game is minimized."
label={t('tweaks.soundInBackground.label')}
text={t('tweaks.soundInBackground.text')}
recommended
/>
</div>
<hr />
<div className="-mb-4 -mt-3 flex items-center gap-2 py-2">
<p className="s1 flex-grow text-blueGray">
<span className="s1 text-warmGreen">Highlighted</span> options are
recommended and enabled by default
{applyPatch.isError ? (
<span className="text-orange">
{t('tweaks.applyFailed', {
message: applyPatch.error?.message ?? ''
})}
</span>
) : (
<>
<span className="s1 text-warmGreen">
{t('tweaks.highlighted')}
</span>{' '}
{t('tweaks.recommendedNote')}
</>
)}
</p>
<TextButton
onClick={async () => {
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);
}}
>
Reset
</TextButton>
<TextButton type="submit" className="text-green">
Apply
{t('tweaks.reset')}
</TextButton>
{(formState.isDirty || isApplying) && (
<TextButton type="submit" loading={isApplying} className="text-green">
{t('tweaks.apply')}
</TextButton>
)}
</div>
</form>
);
@@ -16,6 +16,7 @@ import { ColoredText } from '~renderer/components/styled/ColoredText';
import useScrollHint from '~renderer/utils/useScrollHint';
import IconSpinner from '~renderer/components/styled/IconSpinner';
import CloseButton from '~renderer/components/styled/CloseButton';
import { useT } from '~renderer/i18n';
import { type LocalDependencies } from './AddonListItem';
@@ -41,6 +42,7 @@ type Props = AddonData & {
};
const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => {
const t = useT();
const openLink = api.general.openLink.useMutation();
const update = api.addons.update.useMutation();
@@ -71,26 +73,26 @@ const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => {
<ColoredText>{addon.toc?.Notes ?? addon.description ?? ''}</ColoredText>
)}
<div>
<AddonDetailItem name="Source">
<AddonDetailItem name={t('addons.detailSource')}>
{addon.git && (
<TextButton
onClick={() => openLink.mutateAsync(addon.git)}
className="s1 -m-2 !inline"
>
Open on GitHub
{t('addons.openOnGithubShort')}
<ExternalLink size={12} className="ml-1 inline" />
</TextButton>
)}
</AddonDetailItem>
{addon.toc && (
<>
<AddonDetailItem name="Contributions">
<AddonDetailItem name={t('addons.detailContributions')}>
{addon.toc.Author}
</AddonDetailItem>
<AddonDetailItem name="Addon version">
<AddonDetailItem name={t('addons.detailAddonVersion')}>
{addon.toc.Version}
</AddonDetailItem>
<AddonDetailItem name="Dependencies">
<AddonDetailItem name={t('addons.detailDependencies')}>
{!!dependencies.length && (
<ul className="pl-2">
{dependencies.map(({ name, optional, status }) => (
@@ -99,7 +101,7 @@ const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => {
<Check size={16} className="inline text-darkGreen" />
) : status === 'available' ? (
<TextButton
title="Download"
title={t('addons.download')}
icon={DownloadCloud}
size={16}
onClick={() =>
@@ -122,7 +124,9 @@ const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => {
) ? (
<p className="s1 inline text-blueGray">{status}</p>
) : optional ? (
<p className="s1 inline text-blueGray">(optional)</p>
<p className="s1 inline text-blueGray">
{t('addons.optional')}
</p>
) : null}
</li>
))}
@@ -16,6 +16,7 @@ import IconSpinner from '~renderer/components/styled/IconSpinner';
import DialogButton from '~renderer/components/styled/DialogButton';
import { isNotUndef } from '~common/utils';
import CloseButton from '~renderer/components/styled/CloseButton';
import { useT } from '~renderer/i18n';
import AddonDetail from './AddonDetail';
@@ -39,6 +40,7 @@ const toRepoUrl = (git?: string) =>
git ? git.replace(/\.git$/, '') : undefined;
const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
const t = useT();
const update = api.addons.update.useMutation();
const remove = api.addons.remove.useMutation();
const openLink = api.general.openLink.useMutation();
@@ -58,17 +60,21 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
const warnings = [
addon.toc && addon.toc?.Interface !== '11200'
? {
full: `This addon seems to be made for different game version (${addon.toc?.Interface}) and it may not function correctly`,
short: 'Incorrect version'
full: t('addons.warnIncorrectVersionFull', {
version: addon.toc?.Interface ?? ''
}),
short: t('addons.warnIncorrectVersionShort')
}
: undefined,
localDependencies.some(d => d.status !== 'installed' && !d.optional)
? {
full: `This addon has missing dependencies: ${localDependencies
.filter(d => d.status !== 'installed' && !d.optional)
.map(d => d.name)
.join(', ')}`,
short: 'Missing dependencies'
full: t('addons.warnMissingDependenciesFull', {
deps: localDependencies
.filter(d => d.status !== 'installed' && !d.optional)
.map(d => d.name)
.join(', ')
}),
short: t('addons.warnMissingDependenciesShort')
}
: undefined
].filter(isNotUndef);
@@ -107,7 +113,7 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
: HelpCircle
}
onClick={open}
title="Details"
title={t('addons.details')}
size={18}
className={cls(
'-mx-2',
@@ -132,7 +138,7 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
<TextButton
icon={Github}
size={14}
title={`Open ${repoUrl} on GitHub`}
title={t('addons.openOnGithub', { url: repoUrl })}
onClick={() => openLink.mutateAsync(repoUrl)}
className="!p-1 text-blueGray/60 hocus:text-pink"
/>
@@ -162,9 +168,9 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
) : (
<p className="s1 text-blueGray/50">
{addon.status === 'upToDate'
? 'Up to date'
? t('addons.upToDate')
: !addon.git
? 'Not versioned'
? t('addons.notVersioned')
: ''}
</p>
)}
@@ -173,17 +179,16 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
onClick={() => update.mutateAsync({ toUpdate: [addon.folder] })}
className="s1 -mx-2 justify-self-end"
>
Update
{t('addons.update')}
</TextButton>
)}
{addon.status === 'available' ? (
<TextButton
// TODO: With dependencies checkbox
onClick={() => update.mutateAsync({ toUpdate: [addon.folder] })}
className="text-warmGreen"
icon={DownloadCloud}
size={18}
title="Download"
title={t('addons.download')}
/>
) : (
<DialogButton
@@ -191,14 +196,13 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
dialog={close => (
<div className="tw-dialog">
<CloseButton close={close} />
<h4 className="tw-color">Are you sure?</h4>
<h4 className="tw-color">{t('addons.deleteConfirmTitle')}</h4>
<hr />
<p className="text-blueGray">
Are you sure you want to delete <span>{addon.folder}</span>{' '}
addon?
{t('addons.deleteConfirmBody', { folder: addon.folder })}
</p>
<p className="text-blueGray">
This will delete all files in the addon folder.
{t('addons.deleteConfirmFiles')}
</p>
<TextButton
icon={Trash2}
@@ -209,7 +213,7 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
disabled={remove.isLoading}
className="self-end text-red"
>
Delete
{t('addons.delete')}
</TextButton>
</div>
)}
@@ -220,7 +224,7 @@ const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
className="text-red/50"
icon={Trash2}
size={18}
title="Remove"
title={t('addons.remove')}
/>
)}
</DialogButton>
@@ -5,6 +5,7 @@ import CloseButton from '~renderer/components/styled/CloseButton';
import IconSpinner from '~renderer/components/styled/IconSpinner';
import TextButton from '~renderer/components/styled/TextButton';
import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
const useDebounced = (value: string, delay: number) => {
const [debouncedValue, setDebouncedValue] = useState(value);
@@ -17,6 +18,7 @@ const useDebounced = (value: string, delay: number) => {
};
const CustomAddonDialog = ({ close }: { close: () => void }) => {
const t = useT();
const [url, setUrl] = useState('');
const debouncedUrl = useDebounced(url, 500);
const response = api.addons.checkGitUrl.useQuery(debouncedUrl, {
@@ -27,10 +29,14 @@ const CustomAddonDialog = ({ close }: { close: () => void }) => {
return (
<div className="tw-dialog">
<CloseButton close={close} />
<h3 className="tw-color">Install addon</h3>
<h3 className="tw-color">{t('addons.installAddon')}</h3>
<hr />
{response.data ? (
<img src={response.data?.preview} alt="Preview" className="w-full" />
<img
src={response.data?.preview}
alt={t('addons.previewAlt')}
className="w-full"
/>
) : (
<div className="flex h-[191px] w-full items-center justify-center bg-darkPurple">
{response.isFetching && <IconSpinner />}
@@ -53,8 +59,8 @@ const CustomAddonDialog = ({ close }: { close: () => void }) => {
<div className="flex items-center justify-end gap-2">
<p className="s1 text-blueGray">
{response.data
? 'Ready to install'
: 'Not a valid git repository URL'}
? t('addons.readyToInstall')
: t('addons.invalidGitUrl')}
</p>
<TextButton
onClick={() => {
@@ -66,7 +72,7 @@ const CustomAddonDialog = ({ close }: { close: () => void }) => {
className={response.data ? 'text-warmGreen' : 'text-blueGray'}
disabled={!response.data || response.isLoading}
>
Install
{t('addons.install')}
</TextButton>
</div>
</div>
+52
View File
@@ -0,0 +1,52 @@
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode
} from 'react';
import { api } from '~renderer/utils/api';
import { translations, type Lang } from './translations';
type Params = Record<string, string | number>;
type Translate = (key: string, params?: Params) => string;
type LocaleCtx = {
lang: Lang;
setLang: (lang: Lang) => void;
t: Translate;
};
const LocaleContext = createContext<LocaleCtx>({
lang: 'enUS',
setLang: () => {},
t: key => key
});
export const LocaleProvider = ({ children }: { children: ReactNode }) => {
const { data: pref } = api.preferences.get.useQuery();
const [lang, setLang] = useState<Lang>('enUS');
useEffect(() => {
if (pref?.locale) setLang(pref.locale);
}, [pref?.locale]);
const t: Translate = (key, params) => {
let s = translations[lang]?.[key] ?? translations.enUS[key] ?? key;
if (params)
for (const [k, v] of Object.entries(params))
s = s.replace(`{${k}}`, String(v));
return s;
};
return (
<LocaleContext.Provider value={{ lang, setLang, t }}>
{children}
</LocaleContext.Provider>
);
};
export const useLocale = () => useContext(LocaleContext);
export const useT = () => useContext(LocaleContext).t;
File diff suppressed because it is too large Load Diff
+123
View File
@@ -25,6 +25,16 @@ body {
height: 100vh;
}
body {
-webkit-user-select: text;
user-select: text;
}
::selection {
background: rgb(248 156 66 / 0.45);
color: #fff;
}
#root {
position: relative;
width: 100%;
@@ -192,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];
}
+4 -1
View File
@@ -11,6 +11,7 @@ import log from 'electron-log/renderer';
import { api } from './utils/api';
import App from './App';
import ErrorBoundary from './ErrorBoundary';
import { LocaleProvider } from './i18n';
import './index.css';
@@ -56,7 +57,9 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<ErrorBoundary>
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<App />
<LocaleProvider>
<App />
</LocaleProvider>
{import.meta.env.DEV && <ReactQueryDevtools />}
</QueryClientProvider>
</api.Provider>
+5 -10
View File
@@ -2,29 +2,24 @@ import { useEffect } from 'react';
const allowedElements = ['INPUT', 'TEXTAREA'];
const isClipboardShortcut = (e: KeyboardEvent) =>
(e.ctrlKey || e.metaKey) &&
['a', 'c', 'v', 'x'].includes(e.key.toLowerCase());
const usePreventDefaultEvents = () => {
useEffect(() => {
const disableKeyboardEvents = (e: KeyboardEvent) => {
if (allowedElements.includes((e.target as HTMLElement).tagName)) return;
e.preventDefault();
};
const disableFocus = (e: FocusEvent) => {
if (allowedElements.includes((e.target as HTMLElement).tagName)) return;
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
if (isClipboardShortcut(e)) return;
e.preventDefault();
};
window.addEventListener('keydown', disableKeyboardEvents, true);
window.addEventListener('keyup', disableKeyboardEvents, true);
window.addEventListener('focusin', disableFocus, true);
return () => {
window.removeEventListener('keydown', disableKeyboardEvents, true);
window.removeEventListener('keyup', disableKeyboardEvents, true);
window.removeEventListener('focusin', disableFocus, true);
};
}, []);
};