mirror of
https://github.com/k1tbyte/Wand-Enhancer.git
synced 2026-09-04 16:23:17 +00:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c0e4dc36a | ||
|
|
318a12b133 | ||
|
|
56e1fc2137 | ||
|
|
a236456960 | ||
|
|
df9aedcadc | ||
|
|
2cac40558d | ||
|
|
ff0c8615f8 | ||
|
|
9bcb4bb991 | ||
|
|
8b83750bf2 | ||
|
|
43e898c66c | ||
|
|
f2e88e9247 | ||
|
|
5d1aa69a97 | ||
|
|
e04e313fa3 | ||
|
|
572b61ac25 | ||
|
|
6716da5c80 | ||
|
|
20956c3228 | ||
|
|
f798714f8d | ||
|
|
643c8f8b62 | ||
|
|
e7c7beb621 | ||
|
|
710e717677 | ||
|
|
7e8cadff24 | ||
|
|
7d28eb7d52 | ||
|
|
413c38dbcb | ||
|
|
3b776c52fc | ||
|
|
9c88caf49b | ||
|
|
537608c381 | ||
|
|
b0279ee812 | ||
|
|
c007e11cce | ||
|
|
a7f0eae670 | ||
|
|
1c9a8fb780 | ||
|
|
ec07dc63f0 | ||
|
|
88556ec70f | ||
|
|
c02bad919d | ||
|
|
b9faf80f86 | ||
|
|
6395ca3a27 | ||
|
|
4ce47dc6d2 | ||
|
|
8c6d87671c | ||
|
|
a0b3968d33 | ||
|
|
6906a67a2e | ||
|
|
37ce6b3f4a | ||
|
|
8756e41fb9 | ||
|
|
544b9f0fb0 |
@@ -0,0 +1,44 @@
|
|||||||
|
# Auto-detect text files and normalize line endings to LF
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Web panel & source files
|
||||||
|
*.ts text eol=lf
|
||||||
|
*.tsx text eol=lf
|
||||||
|
*.js text eol=lf
|
||||||
|
*.mjs text eol=lf
|
||||||
|
*.cjs text eol=lf
|
||||||
|
*.json text eol=lf
|
||||||
|
*.css text eol=lf
|
||||||
|
*.html text eol=lf
|
||||||
|
*.md text eol=lf
|
||||||
|
*.svg text eol=lf
|
||||||
|
*.po text eol=lf
|
||||||
|
|
||||||
|
# Windows scripts / C# solutions
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
*.ps1 text eol=lf
|
||||||
|
*.cs text eol=crlf
|
||||||
|
*.sln text eol=crlf
|
||||||
|
*.csproj text eol=crlf
|
||||||
|
|
||||||
|
# Binary files
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.webp binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.ttf binary
|
||||||
|
*.eot binary
|
||||||
|
*.dll binary
|
||||||
|
*.exe binary
|
||||||
|
*.zip binary
|
||||||
|
|
||||||
|
# GitHub Linguist
|
||||||
|
# The web panel is the bundled frontend shipped inside the C# patcher.
|
||||||
|
# Mark it as vendored so GitHub Linguist keeps it out of the repository's
|
||||||
|
# language statistics - the project is a C# app, not a TypeScript one.
|
||||||
|
web-panel/** linguist-vendored
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
patreon: kitbyte
|
||||||
|
|
||||||
|
custom: [
|
||||||
|
"https://www.patreon.com/kitbyte/gift",
|
||||||
|
"https://tronscan.org/#/address/TQdvau8pAy5Tg1Aa588tTcPCFgbcHtuoxc",
|
||||||
|
"https://www.blockchain.com/explorer/addresses/btc/1EZKDcyU8REm9JW5xwXJqSpn5Xaq5yAWWX",
|
||||||
|
"https://etherscan.io/address/0xd904d9d0557f88bbb1c4ab3582b4ca0d8a730e8d"
|
||||||
|
]
|
||||||
@@ -8,16 +8,16 @@ body:
|
|||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
**🚨 STOP BEFORE YOU POST: THIS PROJECT HAS NO OFFICIAL YOUTUBE TUTORIALS. 🚨**
|
**STOP BEFORE YOU POST: THIS PROJECT DOES NOT PUBLISH OFFICIAL EXECUTABLE DOWNLOADS.**
|
||||||
If you downloaded an executable from a YouTube video link, you downloaded a virus/password stealer from a scammer. **Run an antivirus immediately and change your passwords.** Do not open issues about stolen accounts here — this project is not related to those videos.
|
Build WandEnhancer yourself from your own fork or local source. If you downloaded an executable from YouTube, Discord, a mirror, or any other third-party website, treat it as untrusted. Do not open issues about third-party binaries or stolen accounts here - this project is not related to those downloads.
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: scam_check
|
id: build_source_check
|
||||||
attributes:
|
attributes:
|
||||||
label: ⚠️ Download Source Confirmation (REQUIRED)
|
label: Build Source Confirmation (REQUIRED)
|
||||||
description: You must check this box to proceed.
|
description: You must check this box to proceed.
|
||||||
options:
|
options:
|
||||||
- label: I confirm that I downloaded this tool DIRECTLY from this official GitHub repository, and NOT from a YouTube video, Discord, or any other third-party website.
|
- label: I confirm that I built WandEnhancer myself from my own fork or local source, and did not download an executable from YouTube, Discord, mirrors, issue comments, or any other third-party website.
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
@@ -67,4 +67,4 @@ body:
|
|||||||
id: additional_context
|
id: additional_context
|
||||||
attributes:
|
attributes:
|
||||||
label: Screenshots & Additional context
|
label: Screenshots & Additional context
|
||||||
description: Drag and drop screenshots here, or add any other context about the problem.
|
description: Drag and drop screenshots here, or add any other context about the problem.
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Build executable
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: windows-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v5
|
||||||
|
with:
|
||||||
|
version: 10.17.0
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: web-panel/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Build unsigned executable
|
||||||
|
shell: pwsh
|
||||||
|
run: ./build.ps1 -Configuration Release
|
||||||
|
|
||||||
|
- name: Upload unsigned executable
|
||||||
|
uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: WandEnhancer-unsigned
|
||||||
|
path: WandEnhancer/bin/Release/WandEnhancer.exe
|
||||||
|
if-no-files-found: error
|
||||||
@@ -10,9 +10,12 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
mirror:
|
mirror:
|
||||||
|
if: github.repository == 'k1tbyte/Wand-Enhancer'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
@@ -16,15 +16,15 @@ jobs:
|
|||||||
RELEASE_VERSION: ${{ github.ref_name }}
|
RELEASE_VERSION: ${{ github.ref_name }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v5
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
@@ -48,7 +48,9 @@ jobs:
|
|||||||
name: ${{ github.ref_name }}
|
name: ${{ github.ref_name }}
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ github.ref_name }}
|
||||||
body_path: release-notes.md
|
body_path: release-notes.md
|
||||||
files: |
|
files: CHANGELOG.md
|
||||||
WandEnhancer/bin/Release/WandEnhancer.exe
|
fail_on_unmatched_files: true
|
||||||
CHANGELOG.md
|
# A tag carrying a suffix (1.1.0.0-rc.1) publishes as a pre-release and
|
||||||
fail_on_unmatched_files: true
|
# does not become the "Latest release" on the repository page.
|
||||||
|
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||||
|
make_latest: ${{ !contains(github.ref_name, '-') }}
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
validate-release-metadata:
|
validate-release-metadata:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Validate version and changelog sync
|
- name: Validate version and changelog sync
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: ./scripts/validate-release-metadata.ps1
|
run: ./scripts/validate-release-metadata.ps1
|
||||||
|
|||||||
+3
-1
@@ -145,4 +145,6 @@ appsettings.json
|
|||||||
*DotSettings.user
|
*DotSettings.user
|
||||||
.tmp
|
.tmp
|
||||||
.source
|
.source
|
||||||
web-panel/bridge/wand-remote-bridge.cjs
|
web-panel/bridge/wand-remote-bridge.cjs
|
||||||
|
.vscode
|
||||||
|
docs
|
||||||
@@ -6,13 +6,14 @@ This repository patches the Wand Electron app from a .NET Framework WPF desktop
|
|||||||
|
|
||||||
## Remote Web Panel
|
## Remote Web Panel
|
||||||
|
|
||||||
- The default local remote port is `3223`. Keep C# and frontend constants aligned.
|
- The default local remote port is `3223`. Keep bridge and frontend constants aligned; C# must not duplicate the presentation URL or port.
|
||||||
- The embedded panel must stay small because the desktop patcher embeds it and then injects it into Wand's `app.asar`.
|
- The embedded panel must stay small because the desktop patcher embeds it and then injects it into Wand's `app.asar`.
|
||||||
|
- Remote tooltip links and every rendered `remote-qr-code` are redirected by `web-panel/bridge/scripts/default/remote-popup-cleanup.js`. It reuses Wand's loaded QR renderer through the webpack runtime, keeps the local URL visible as a fallback, and hides the Pro onboarding remote mobile app card. Do not reintroduce C# ASAR patches for the tooltip URL or QR component; a changed UI bundle must not make the whole remote-panel patch fail.
|
||||||
- Production builds must not include mock data, debug routes, sourcemaps, local fonts, heavy icon libraries, or runtime class helper packages.
|
- Production builds must not include mock data, debug routes, sourcemaps, local fonts, heavy icon libraries, or runtime class helper packages.
|
||||||
- The Electron bridge is authored as modular CommonJS source under `web-panel/bridge/source.cjs` and `web-panel/bridge/bridge-modules/`, but production runtime must be bundled/minified into `web-panel/dist/bridge.cjs` by `pnpm run build:bridge`. Do not copy `bridge-modules` into Wand or embed them as ASAR resources.
|
- The Electron bridge is authored as TypeScript under `web-panel/bridge/src/`, but production runtime must be bundled/minified into `web-panel/dist/bridge.cjs` by `pnpm run build:bridge`. Do not copy bridge source into Wand or embed it as ASAR resources.
|
||||||
- Mock/demo data is dev-only and must be reached through `import.meta.env.DEV` dynamic imports.
|
- Mock/demo data is dev-only and must be reached through `import.meta.env.DEV` dynamic imports.
|
||||||
- Source can use React-compatible imports, but production runtime resolves them to Preact aliases in `web-panel/vite.config.ts`.
|
- Source can use React-compatible imports, but production runtime resolves them to Preact aliases in `web-panel/vite.config.ts`.
|
||||||
- UI uses Tailwind CSS and lightweight shadcn-style local primitives under `web-panel/src/components/ui/`.
|
- UI uses Tailwind CSS and lightweight local primitives under `web-panel/src/shared/ui/`.
|
||||||
- Default renderer script sources live in `web-panel/bridge/scripts/default/` and are bundled/minified into `web-panel/dist/renderer-scripts/` by `pnpm run build:bridge`. Custom user scripts are selected in the WPF patch modal and copied from `PatchConfig.CustomScriptPaths`; only existing `.js` files are accepted. A local `renderer-scripts/` folder next to the patcher exe is still copied as an advanced fallback.
|
- Default renderer script sources live in `web-panel/bridge/scripts/default/` and are bundled/minified into `web-panel/dist/renderer-scripts/` by `pnpm run build:bridge`. Custom user scripts are selected in the WPF patch modal and copied from `PatchConfig.CustomScriptPaths`; only existing `.js` files are accepted. A local `renderer-scripts/` folder next to the patcher exe is still copied as an advanced fallback.
|
||||||
- `web-panel/bridge/scripts/default/installed-apps-sync.js` resolves Wand's renderer services/store and publishes `My Games` snapshots through the `wand-remote-installed-apps` IPC channel. The synced list must mirror Wand's `my_games` source criteria: catalog games come from `installedGameVersions`, and extra installed unsupported titles come from `correlatedUnavailableTitles` whose `games[].correlationIds` match `installedApps`.
|
- `web-panel/bridge/scripts/default/installed-apps-sync.js` resolves Wand's renderer services/store and publishes `My Games` snapshots through the `wand-remote-installed-apps` IPC channel. The synced list must mirror Wand's `my_games` source criteria: catalog games come from `installedGameVersions`, and extra installed unsupported titles come from `correlatedUnavailableTitles` whose `games[].correlationIds` match `installedApps`.
|
||||||
- If the injected renderer cannot read a populated `correlatedUnavailableTitles` slice from the live store, `installed-apps-sync.js` must fall back to Wand's `/v3/unavailable_titles` correlation lookup through the renderer API client instead of degrading to raw install entries or an empty `My Games` list.
|
- If the injected renderer cannot read a populated `correlatedUnavailableTitles` slice from the live store, `installed-apps-sync.js` must fall back to Wand's `/v3/unavailable_titles` correlation lookup through the renderer API client instead of degrading to raw install entries or an empty `My Games` list.
|
||||||
@@ -22,6 +23,18 @@ This repository patches the Wand Electron app from a .NET Framework WPF desktop
|
|||||||
- The websocket `hello` snapshot must still send cached `installed_apps` and `game_status` even when no trainer snapshot is active yet; do not reintroduce a handshake path that returns early after `trainer_changed`.
|
- The websocket `hello` snapshot must still send cached `installed_apps` and `game_status` even when no trainer snapshot is active yet; do not reintroduce a handshake path that returns early after `trainer_changed`.
|
||||||
- Remote Play/Stop uses the websocket `remote_command` message. The bridge forwards it over `wand-remote-command` / `wand-remote-command-response`, and `installed-apps-sync.js` resolves Wand's trainer API + trainer service to launch a trainer for a `gameId` or end the current trainer.
|
- Remote Play/Stop uses the websocket `remote_command` message. The bridge forwards it over `wand-remote-command` / `wand-remote-command-response`, and `installed-apps-sync.js` resolves Wand's trainer API + trainer service to launch a trainer for a `gameId` or end the current trainer.
|
||||||
- Remote Play must construct Wand's real trainer launch request class (`69482.vO`) before calling `trainerService.launch(...)`. Passing a plain object launches the game process but breaks Wand's `getMetadata(vO)`-based trainer state, causing missing status, disappearing play/close buttons, and stuck loading behavior.
|
- Remote Play must construct Wand's real trainer launch request class (`69482.vO`) before calling `trainerService.launch(...)`. Passing a plain object launches the game process but breaks Wand's `getMetadata(vO)`-based trainer state, causing missing status, disappearing play/close buttons, and stuck loading behavior.
|
||||||
|
- Pro activation is a C# asar patch (`EPatchType.ActivatePro`, independent of the remote panel / bridge). It wraps the returned promise of three account-returning service methods so `subscription:{period:"yearly",state:"active"}` is injected before the response reaches the store: `getUserAccount`, `setAccountWandBrandExperience` and `setAccountLanguage`. A fourth patch (`setAccountReducer`) rewrites the `ACTION_SET_ACCOUNT` store reducer so any account write (periodic `refreshAccount`, push/profile updates, etc.) keeps Pro even when it bypasses those API methods. Pro is `am(account) = !!account.subscription` (flags/512 are irrelevant). `setAccountLanguage` is the one the original two patches missed, which is why Pro dropped on language change. `setAccountWandBrandExperience` does not exist on every build, so it is declared optional through `CapabilityHints`.
|
||||||
|
|
||||||
|
## Patch Engine
|
||||||
|
|
||||||
|
- Patches are located structurally, not by shape. A patch anchors on something Wand does not rename between builds — an API endpoint, an IPC channel name, a public method name — and then walks the delimiter structure (`Core/Js/JsCursor.cs`) to the edit site. Identifiers that do change (`#Xe`, `l.vO`, the numeric Remote source) are read out of the located region, never baked into a pattern. A rebuild that only reminifies therefore needs no change here.
|
||||||
|
- Never write a regex that spans a whole method body or matches across a bundle. Scope patterns to a located `JsFunction` via `Resolve`, where they run against a few hundred characters instead of megabytes.
|
||||||
|
- A patch is one `PatchEntry` in `Core/EnhancerConfig.cs` with a `Locate` delegate returning the edits to splice. Return `null` when the anchor is absent from this file — that means "not my file", not "failure". Throw only when the anchor IS present but the surrounding structure is unrecognisable; that is a genuinely unsupported build and must fail loudly.
|
||||||
|
- Injected JavaScript lives in `WandEnhancer/Patches/*.js` and is embedded as `patches/<name>.js`. Load it with `PatchPayload.Load(name, "key", value, ...)`, which fills `${key}` placeholders in one pass. Do not put payload JS back into C# string literals.
|
||||||
|
- Multiple edits from one patch are applied highest-offset-first, so their positions stay valid. Keep them non-overlapping.
|
||||||
|
- A patch that only exists on some builds sets `CapabilityHints`: absent capability logs a skip, a detected-but-unpatchable capability still fails the run.
|
||||||
|
- When a build really does restructure something, add a fallback branch inside that patch's `Locate` rather than a version table — old shapes keep working because the old branch is still there.
|
||||||
|
- Verify against real bundles, minified and prettified, before shipping: locating must succeed on both and the patched files must pass `node --check`.
|
||||||
|
|
||||||
## ASAR Patch Pipeline
|
## ASAR Patch Pipeline
|
||||||
|
|
||||||
@@ -32,14 +45,14 @@ This repository patches the Wand Electron app from a .NET Framework WPF desktop
|
|||||||
- The `DevToolsOnF12` patch anchors on the Electron main-process `<app>.whenReady().then(` site and attaches a `before-input-event` hook to every `BrowserWindow.webContents`. Do not patch the renderer keydown listener — the minified `ACTION_OPEN_DEV_TOOLS` dispatch site is not stable across Wand releases.
|
- The `DevToolsOnF12` patch anchors on the Electron main-process `<app>.whenReady().then(` site and attaches a `before-input-event` hook to every `BrowserWindow.webContents`. Do not patch the renderer keydown listener — the minified `ACTION_OPEN_DEV_TOOLS` dispatch site is not stable across Wand releases.
|
||||||
- Cheats can be pinned per game in the web panel via `pinned-storage.ts` (`localStorage` key `wand-remote.pinned-cheats.v1:<gameId>`). Pinned cheats render as a virtual `pinned` category at the top of the list; their normal category placement is preserved.
|
- Cheats can be pinned per game in the web panel via `pinned-storage.ts` (`localStorage` key `wand-remote.pinned-cheats.v1:<gameId>`). Pinned cheats render as a virtual `pinned` category at the top of the list; their normal category placement is preserved.
|
||||||
- Custom quick presets are per trainer/game and stored by `preset-storage.ts` under `localStorage` key `wand-remote.presets.v1:<gameId-or-trainerId>`. Presets capture persistent cheat values only; do not include `button` one-shot cheats in saved presets.
|
- Custom quick presets are per trainer/game and stored by `preset-storage.ts` under `localStorage` key `wand-remote.presets.v1:<gameId-or-trainerId>`. Presets capture persistent cheat values only; do not include `button` one-shot cheats in saved presets.
|
||||||
- All `localStorage` access in `web-panel/src/features/remote-panel/` MUST go through the shared helpers in `storage.ts` (`loadJson` / `saveJson` / `loadStringSet` / `saveStringSet`). Do not reintroduce per-module `try/catch` + `JSON.parse` duplication in `pinned-storage`, `preset-storage`, or `game-pin-storage`. Trainer/game storage IDs are derived through the shared `getTrainerStorageId(trainer)` helper in `storage.ts`; do not re-implement the `gameId → titleId → trainerId → 'global'` precedence inline.
|
- All `localStorage` access in `web-panel/src/` MUST go through `web-panel/src/shared/storage.ts` (`loadJson` / `saveJson` / `loadStringSet` / `saveStringSet`). Do not reintroduce per-capability `try/catch` + `JSON.parse` duplication. Trainer/game storage IDs are derived through the shared `getTrainerStorageId(trainer)` helper; do not re-implement the `gameId → titleId → trainerId → 'global'` precedence inline.
|
||||||
- All shared bridge port/path/IPC channel/WS-opcode/protocol-version constants live in `web-panel/bridge/bridge-modules/constants.cjs` (exports `IPC_CHANNEL`, `WS_OPCODE`, `BRIDGE_PROTOCOL_VERSION`, `BRIDGE_SERVER_VERSION`, `RENDERER_INJECTION_DELAYS_MS`). Do not redeclare `3223`, `/remote/*`, IPC channel strings, raw WS opcode numbers (1/8/9/10), or the 500/2000 ms injection delays inline. The renderer-script-side equivalents (e.g. `vO`/`TRAINER_LAUNCH_REQUEST_EXPORT_KEY`, snapshot key prefixes, bootstrap log throttle) live in `web-panel/bridge/scripts/default/installed-apps-sync/constants.js`.
|
- Shared web protocol version, port, and HTTP/WS paths live in `web-panel/protocol/web-contract.json`. Bridge-only IPC channels, WS opcodes, and renderer injection delays live in `web-panel/bridge/src/constants.ts`. Do not redeclare these values inline.
|
||||||
- UI string-union types follow the `E*` enum convention from `.claude/rules/frontend-conventions.md` (currently `ECheatType` in `protocol.ts`, `EConnectionStatus` in `state.ts`); the wire string values must remain on the right-hand side of the enum members. Reducer `PanelAction` `type` tags stay as discriminated-union string literals (the union itself provides the discrimination — converting it to an enum loses pattern matching).
|
- UI string-union types follow the `E*` enum convention from `.claude/rules/frontend-conventions.md` (currently `ECheatType` in `protocol/messages.ts`, `EConnectionStatus` in `remote-session/remote-session.reducer.ts`); wire string values must remain on the right-hand side of enum members. Reducer action tags stay as discriminated-union string literals.
|
||||||
- Cheat input controls live one-per-file under `web-panel/src/features/remote-panel/controls/` (`ToggleControl`, `SliderControl`, `ScalarControl`, `NumberControl`, `ActionButton`, `SelectionControl`, `IncrementalControl`); shared `SliderTrack` / `StepButton` / `ControlInternalProps` are in `controls/shared.tsx` and number formatting helpers in `controls/format-number.ts`. `controls/CheatControl.tsx` is a thin dispatcher map keyed by `ECheatType` — do not inline new control bodies into it.
|
- Cheat input controls live one-per-file under `web-panel/src/trainer/controls/`; shared `SliderTrack` / `StepButton` / `ControlInternalProps` are in `controls/shared.tsx` and number formatting helpers in `controls/format-number.ts`. `controls/CheatControl.tsx` remains a thin dispatcher keyed by `ECheatType`.
|
||||||
- Mobile drawer performance is sensitive to `backdrop-filter`. Keep drawer panels and nested glass controls blur-free under coarse pointers, and do not add per-row `backdrop-blur-*` inside drawer lists.
|
- Mobile drawer performance is sensitive to `backdrop-filter`. Keep drawer panels and nested glass controls blur-free under coarse pointers, and do not add per-row `backdrop-blur-*` inside drawer lists.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
- Web panel build: `cd web-panel && pnpm run build` (runs type-check, Vite build, then `build:bridge` into `dist`).
|
- Web panel build: `cd web-panel && pnpm run build` (runs type-check, Vite build, then `build:bridge` into `dist`).
|
||||||
- Bridge/script syntax checks after build: `node --check web-panel/dist/bridge.cjs` and `node --check web-panel/dist/renderer-scripts/remote-popup-cleanup.js`.
|
- Bridge/script syntax checks after build: `node --check web-panel/dist/bridge.cjs` and `node --check web-panel/dist/renderer-scripts/remote-popup-cleanup.js`.
|
||||||
- Production dist should contain only static assets and should not contain `mock-instance`, `Mock Adventure`, `Simulation`, `Debug session`, `mock=1`, `demo-session`, `vite.svg`, `tailwind-merge`, `class-variance-authority`, or `clsx`.
|
- Production dist should contain only static assets and should not contain `mock-instance`, `Mock Adventure`, `Simulation`, `Debug session`, `mock=1`, `demo-session`, `vite.svg`, `tailwind-merge`, `class-variance-authority`, or `clsx`.
|
||||||
|
|||||||
@@ -73,22 +73,26 @@ namespace AsarSharp
|
|||||||
filesystem.InsertFile(filename, shouldUnpack, file, placeholder);
|
filesystem.InsertFile(filename, shouldUnpack, file, placeholder);
|
||||||
break;
|
break;
|
||||||
case FileType.Link:
|
case FileType.Link:
|
||||||
throw new NotImplementedException();
|
throw new NotSupportedException($"Packing symlinks is not supported: '{filename}'");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool ShouldUnpackPath(string relativePath)
|
/// <summary>
|
||||||
|
/// Matches the directory path (relative to the archive root) against the unpack regex.
|
||||||
|
/// </summary>
|
||||||
|
private bool ShouldUnpackPath(string relativeParentPath)
|
||||||
{
|
{
|
||||||
return _options?.Unpack?.IsMatch(relativePath) == true;
|
return _options?.Unpack?.IsMatch(relativeParentPath) == true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InsertsDone(Filesystem filesystem, List<Disk.BasicFileInfo> files)
|
private void InsertsDone(Filesystem filesystem, List<Disk.BasicFileInfo> files)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(
|
string dir = Path.GetDirectoryName(_destPath);
|
||||||
Path.GetDirectoryName(_destPath)
|
if (!string.IsNullOrEmpty(dir))
|
||||||
?? throw new InvalidOperationException());
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
Disk.WriteFileSystem(_destPath, filesystem,
|
Disk.WriteFileSystem(_destPath, filesystem,
|
||||||
new Disk.FilesystemFilesAndLinks { Files = files, Links = null }, _metadata);
|
new Disk.FilesystemFilesAndLinks { Files = files }, _metadata);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-12
@@ -41,9 +41,12 @@ namespace AsarSharp
|
|||||||
var destFilename = Path.Combine(dest, filename);
|
var destFilename = Path.Combine(dest, filename);
|
||||||
var file = filesystem.GetFile(filename, followLinks);
|
var file = filesystem.GetFile(filename, followLinks);
|
||||||
|
|
||||||
// Path-traversal guard.
|
// Path-traversal (zip-slip) guard. Uses the normalising
|
||||||
string relativePath = Extensions.GetRelativePath(dest, destFilename);
|
// containment check: GetRelativePath's fast path strips the
|
||||||
if (relativePath.StartsWith(".."))
|
// prefix literally without resolving "..", so a crafted entry
|
||||||
|
// such as "a/../../evil" would otherwise pass this check and be
|
||||||
|
// written outside "dest".
|
||||||
|
if (!Extensions.IsPathInside(dest, destFilename))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"{fullPath}: file \"{destFilename}\" writes out of the package");
|
$"{fullPath}: file \"{destFilename}\" writes out of the package");
|
||||||
@@ -156,20 +159,19 @@ namespace AsarSharp
|
|||||||
FilesystemEntry file, HashSet<string> dirCache)
|
FilesystemEntry file, HashSet<string> dirCache)
|
||||||
{
|
{
|
||||||
var linkSrcPath = Extensions.GetDirectoryName(Path.Combine(dest, file.Link));
|
var linkSrcPath = Extensions.GetDirectoryName(Path.Combine(dest, file.Link));
|
||||||
var linkDestPath = Extensions.GetDirectoryName(destFilename);
|
|
||||||
var relativeLinkPath = Extensions.GetRelativePath(linkDestPath, linkSrcPath);
|
|
||||||
|
|
||||||
try { File.Delete(destFilename); }
|
if (!Extensions.IsPathInside(dest, linkSrcPath))
|
||||||
catch { /* ignore — failing to remove an existing link is non-fatal */ }
|
|
||||||
|
|
||||||
var linkTo = Path.Combine(relativeLinkPath, Path.GetFileName(file.Link));
|
|
||||||
|
|
||||||
if (Extensions.GetRelativePath(dest, linkSrcPath).StartsWith(".."))
|
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"{fullPath}: file \"{file.Link}\" links out of the package to \"{linkSrcPath}\"");
|
$"{fullPath}: file \"{file.Link}\" links out of the package to \"{linkSrcPath}\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try { File.Delete(destFilename); }
|
||||||
|
catch (Exception e) when (e is IOException || e is UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// Nothing to replace, or the old entry is locked; the copy below reports the real failure.
|
||||||
|
}
|
||||||
|
|
||||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
{
|
{
|
||||||
var targetPath = Path.Combine(linkSrcPath, Path.GetFileName(file.Link));
|
var targetPath = Path.Combine(linkSrcPath, Path.GetFileName(file.Link));
|
||||||
@@ -186,8 +188,10 @@ namespace AsarSharp
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
var linkDestPath = Extensions.GetDirectoryName(destFilename);
|
||||||
|
var relativeLinkPath = Extensions.GetRelativePath(linkDestPath, linkSrcPath);
|
||||||
EnsureParentDir(destFilename, dirCache);
|
EnsureParentDir(destFilename, dirCache);
|
||||||
Extensions.CreateSymbolicLink(linkTo, destFilename);
|
Extensions.CreateSymbolicLink(Path.Combine(relativeLinkPath, Path.GetFileName(file.Link)), destFilename);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ namespace AsarSharp.AsarFileSystem
|
|||||||
public static class Disk
|
public static class Disk
|
||||||
{
|
{
|
||||||
private const int StreamBufferSize = 1024 * 1024;
|
private const int StreamBufferSize = 1024 * 1024;
|
||||||
private static readonly ConcurrentDictionary<string, Filesystem> _filesystemCache =
|
|
||||||
new ConcurrentDictionary<string, Filesystem>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
public class ArchiveHeader
|
public class ArchiveHeader
|
||||||
{
|
{
|
||||||
@@ -25,7 +23,6 @@ namespace AsarSharp.AsarFileSystem
|
|||||||
public class FilesystemFilesAndLinks
|
public class FilesystemFilesAndLinks
|
||||||
{
|
{
|
||||||
public List<BasicFileInfo> Files { get; set; } = new List<BasicFileInfo>();
|
public List<BasicFileInfo> Files { get; set; } = new List<BasicFileInfo>();
|
||||||
public List<BasicFileInfo> Links { get; set; } = new List<BasicFileInfo>();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class BasicFileInfo
|
public class BasicFileInfo
|
||||||
@@ -42,14 +39,14 @@ namespace AsarSharp.AsarFileSystem
|
|||||||
65536, FileOptions.SequentialScan))
|
65536, FileOptions.SequentialScan))
|
||||||
{
|
{
|
||||||
byte[] sizeBuf = new byte[8];
|
byte[] sizeBuf = new byte[8];
|
||||||
if (fs.Read(sizeBuf, 0, 8) != 8)
|
if (fs.ReadFull(sizeBuf, 0, 8) != 8)
|
||||||
throw new Exception("Unable to read header size");
|
throw new Exception("Unable to read header size");
|
||||||
|
|
||||||
var sizePickle = Pickle.CreateFromBuffer(sizeBuf);
|
var sizePickle = Pickle.CreateFromBuffer(sizeBuf);
|
||||||
var size = sizePickle.CreateIterator().ReadUInt32();
|
var size = sizePickle.CreateIterator().ReadUInt32();
|
||||||
|
|
||||||
var headerBuf = new byte[size];
|
var headerBuf = new byte[size];
|
||||||
if (fs.Read(headerBuf, 0, (int)size) != size)
|
if (fs.ReadFull(headerBuf, 0, (int)size) != size)
|
||||||
throw new Exception("Unable to read header");
|
throw new Exception("Unable to read header");
|
||||||
|
|
||||||
var headerPickle = Pickle.CreateFromBuffer(headerBuf);
|
var headerPickle = Pickle.CreateFromBuffer(headerBuf);
|
||||||
@@ -65,62 +62,28 @@ namespace AsarSharp.AsarFileSystem
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the header fresh every time: an archive is repacked in place during a patch run,
|
||||||
|
/// so a cached header would hand out stale offsets on the next read of the same path.
|
||||||
|
/// </summary>
|
||||||
public static Filesystem ReadFilesystemSync(string archivePath)
|
public static Filesystem ReadFilesystemSync(string archivePath)
|
||||||
{
|
{
|
||||||
return _filesystemCache.GetOrAdd(archivePath, key =>
|
var header = ReadArchiveHeaderSync(archivePath);
|
||||||
{
|
var filesystem = new Filesystem(archivePath);
|
||||||
var header = ReadArchiveHeaderSync(key);
|
filesystem.SetHeader(header.Header, header.HeaderSize);
|
||||||
var filesystem = new Filesystem(key);
|
return filesystem;
|
||||||
filesystem.SetHeader(header.Header, header.HeaderSize);
|
|
||||||
return filesystem;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public static byte[] ReadFileSync(Filesystem filesystem, string filename, FilesystemEntry info)
|
|
||||||
{
|
|
||||||
if (!info.IsFile || !info.Size.HasValue)
|
|
||||||
throw new ArgumentException("Entry is not a file", nameof(info));
|
|
||||||
|
|
||||||
long size = info.Size.Value;
|
|
||||||
byte[] buffer = new byte[size];
|
|
||||||
|
|
||||||
if (size <= 0) return buffer;
|
|
||||||
|
|
||||||
if (info.Unpacked == true)
|
|
||||||
{
|
|
||||||
string filePath = Path.Combine($"{filesystem.GetRootPath()}.unpacked", filename);
|
|
||||||
return File.ReadAllBytes(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
using (var fs = new FileStream(filesystem.GetRootPath(), FileMode.Open, FileAccess.Read,
|
|
||||||
FileShare.Read, 65536, FileOptions.RandomAccess))
|
|
||||||
{
|
|
||||||
long offset = 8 + filesystem.GetHeaderSize() + long.Parse(info.Offset);
|
|
||||||
fs.Position = offset;
|
|
||||||
int bytesRead = fs.Read(buffer, 0, (int)size);
|
|
||||||
if (bytesRead != size)
|
|
||||||
throw new Exception($"Failed to read entire file, got {bytesRead} bytes instead of {size}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return buffer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
public static bool UncacheFilesystem(string archivePath)
|
|
||||||
{
|
|
||||||
return _filesystemCache.TryRemove(archivePath, out _);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void UncacheAll()
|
|
||||||
{
|
|
||||||
_filesystemCache.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void CopyFile(string dest, string rootPath, string filename)
|
public static void CopyFile(string dest, string rootPath, string filename)
|
||||||
{
|
{
|
||||||
if (dest == null || rootPath == null || filename == null)
|
if (dest == null)
|
||||||
throw new ArgumentNullException();
|
throw new ArgumentNullException(nameof(dest));
|
||||||
|
if (rootPath == null)
|
||||||
|
throw new ArgumentNullException(nameof(rootPath));
|
||||||
|
if (filename == null)
|
||||||
|
throw new ArgumentNullException(nameof(filename));
|
||||||
|
|
||||||
string normalizedDestRoot = Path.GetFullPath(dest)
|
string normalizedDestRoot = Path.GetFullPath(dest)
|
||||||
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
@@ -165,7 +128,56 @@ namespace AsarSharp.AsarFileSystem
|
|||||||
var buf = new byte[StreamBufferSize];
|
var buf = new byte[StreamBufferSize];
|
||||||
var blockBuf = new byte[4 * 1024 * 1024]; // shared across all files — avoids 4MB alloc per file
|
var blockBuf = new byte[4 * 1024 * 1024]; // shared across all files — avoids 4MB alloc per file
|
||||||
|
|
||||||
using (var fs = new FileStream(dest, FileMode.Create, FileAccess.Write, FileShare.None, StreamBufferSize, FileOptions.SequentialScan))
|
// Build beside the target and swap at the end. Writing straight into dest truncates
|
||||||
|
// it on open, so any failure mid-write left the caller with a destroyed archive.
|
||||||
|
string tempPath = dest + ".building";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
WriteArchive(tempPath, dest, fileSystem, lists, serializerSettings,
|
||||||
|
headerPickle, sizePickle, sizePickleSize, buf, blockBuf);
|
||||||
|
ReplaceFile(tempPath, dest);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
TryDelete(tempPath);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ReplaceFile(string tempPath, string dest)
|
||||||
|
{
|
||||||
|
if (!File.Exists(dest))
|
||||||
|
{
|
||||||
|
File.Move(tempPath, dest);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A read-only or hidden archive would fail the swap the same way an overwrite does.
|
||||||
|
Extensions.ClearAttributes(dest);
|
||||||
|
// File.Replace swaps in one step, so dest is never observed missing or half-written.
|
||||||
|
File.Replace(tempPath, dest, null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryDelete(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is IOException || e is UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// Leftover build file only wastes space; the real failure is already propagating.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteArchive(string archivePath, string dest, Filesystem fileSystem,
|
||||||
|
FilesystemFilesAndLinks lists, JsonSerializerSettings serializerSettings,
|
||||||
|
Pickle headerPickle, Pickle sizePickle, int sizePickleSize, byte[] buf, byte[] blockBuf)
|
||||||
|
{
|
||||||
|
using (var fs = new FileStream(archivePath, FileMode.Create, FileAccess.Write, FileShare.None, StreamBufferSize, FileOptions.SequentialScan))
|
||||||
{
|
{
|
||||||
sizePickle.WriteTo(fs);
|
sizePickle.WriteTo(fs);
|
||||||
headerPickle.WriteTo(fs);
|
headerPickle.WriteTo(fs);
|
||||||
@@ -192,6 +204,18 @@ namespace AsarSharp.AsarFileSystem
|
|||||||
var patchedSizePickle = Pickle.CreateEmpty();
|
var patchedSizePickle = Pickle.CreateEmpty();
|
||||||
patchedSizePickle.WriteUInt32((uint)patchedPickle.GetTotalSize());
|
patchedSizePickle.WriteUInt32((uint)patchedPickle.GetTotalSize());
|
||||||
|
|
||||||
|
// The rewrite lands on top of the placeholder header, so it must be exactly as
|
||||||
|
// long. Placeholder hashes are the same width as real ones, so this holds unless
|
||||||
|
// a file changed size between crawl and write - which would silently shred the
|
||||||
|
// payload that follows.
|
||||||
|
if (patchedPickle.GetTotalSize() != headerPickle.GetTotalSize() ||
|
||||||
|
patchedSizePickle.GetTotalSize() != sizePickleSize)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"ASAR header changed size while packing (a source file was modified mid-build). " +
|
||||||
|
"Aborting rather than writing a corrupt archive.");
|
||||||
|
}
|
||||||
|
|
||||||
fs.Position = 0;
|
fs.Position = 0;
|
||||||
patchedSizePickle.WriteTo(fs);
|
patchedSizePickle.WriteTo(fs);
|
||||||
patchedPickle.WriteTo(fs);
|
patchedPickle.WriteTo(fs);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ namespace AsarSharp.AsarFileSystem
|
|||||||
_headerSize = headerSize;
|
_headerSize = headerSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
public FilesystemEntry SearchNodeFromDirectory(string p)
|
public FilesystemEntry SearchNodeFromDirectory(string p, bool create = true)
|
||||||
{
|
{
|
||||||
FilesystemEntry json = _header;
|
FilesystemEntry json = _header;
|
||||||
|
|
||||||
@@ -59,12 +59,31 @@ namespace AsarSharp.AsarFileSystem
|
|||||||
string seg = p.Substring(start, segLen);
|
string seg = p.Substring(start, segLen);
|
||||||
|
|
||||||
if (!json.IsDirectory)
|
if (!json.IsDirectory)
|
||||||
throw new Exception($"Unexpected directory state while traversing: {p}");
|
{
|
||||||
|
if (create)
|
||||||
|
throw new Exception($"Unexpected directory state while traversing: {p}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json.Files == null)
|
||||||
|
{
|
||||||
|
if (create)
|
||||||
|
json.Files = new Dictionary<string, FilesystemEntry>(StringComparer.Ordinal);
|
||||||
|
else
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!json.Files.TryGetValue(seg, out var child))
|
if (!json.Files.TryGetValue(seg, out var child))
|
||||||
{
|
{
|
||||||
child = new FilesystemEntry { Files = new Dictionary<string, FilesystemEntry>(StringComparer.Ordinal) };
|
if (create)
|
||||||
json.Files[seg] = child;
|
{
|
||||||
|
child = new FilesystemEntry { Files = new Dictionary<string, FilesystemEntry>(StringComparer.Ordinal) };
|
||||||
|
json.Files[seg] = child;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
json = child;
|
json = child;
|
||||||
start = end + 1;
|
start = end + 1;
|
||||||
@@ -81,7 +100,7 @@ namespace AsarSharp.AsarFileSystem
|
|||||||
|
|
||||||
string name = Path.GetFileName(rel);
|
string name = Path.GetFileName(rel);
|
||||||
string dir = Extensions.GetDirectoryName(rel);
|
string dir = Extensions.GetDirectoryName(rel);
|
||||||
var parent = SearchNodeFromDirectory(dir);
|
var parent = SearchNodeFromDirectory(dir, true);
|
||||||
|
|
||||||
if (parent.Files == null)
|
if (parent.Files == null)
|
||||||
parent.Files = new Dictionary<string, FilesystemEntry>(StringComparer.Ordinal);
|
parent.Files = new Dictionary<string, FilesystemEntry>(StringComparer.Ordinal);
|
||||||
@@ -111,18 +130,23 @@ namespace AsarSharp.AsarFileSystem
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public FilesystemEntry GetNode(string p, bool followLinks = true)
|
public FilesystemEntry GetNode(string p, bool followLinks = true, int linkDepth = 0)
|
||||||
{
|
{
|
||||||
|
if (linkDepth > 40)
|
||||||
|
throw new Exception($"Symlink loop detected at {p}");
|
||||||
|
|
||||||
p = p.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
|
p = p.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
|
||||||
FilesystemEntry node = SearchNodeFromDirectory(Extensions.GetDirectoryName(p));
|
FilesystemEntry node = SearchNodeFromDirectory(Extensions.GetDirectoryName(p), false);
|
||||||
|
if (node == null)
|
||||||
|
return null;
|
||||||
string name = Path.GetFileName(p);
|
string name = Path.GetFileName(p);
|
||||||
|
|
||||||
if (node.IsLink && followLinks)
|
if (node.IsLink && followLinks)
|
||||||
return GetNode(Path.Combine(node.Link, name));
|
return GetNode(Path.Combine(node.Link, name), followLinks, linkDepth + 1);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(name))
|
if (!string.IsNullOrEmpty(name))
|
||||||
{
|
{
|
||||||
if (node.IsDirectory && node.Files.TryGetValue(name, out var entry))
|
if (node.IsDirectory && node.Files != null && node.Files.TryGetValue(name, out var entry))
|
||||||
return entry;
|
return entry;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -130,16 +154,17 @@ namespace AsarSharp.AsarFileSystem
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
public FilesystemEntry GetFile(string p, bool followLinks = true)
|
public FilesystemEntry GetFile(string p, bool followLinks = true, int linkDepth = 0)
|
||||||
{
|
{
|
||||||
FilesystemEntry info = GetNode(p, followLinks);
|
if (linkDepth > 40)
|
||||||
|
throw new Exception($"Symlink loop detected at {p}");
|
||||||
|
|
||||||
|
FilesystemEntry info = GetNode(p, followLinks, linkDepth);
|
||||||
if (info == null) throw new Exception($"\"{p}\" was not found in this archive");
|
if (info == null) throw new Exception($"\"{p}\" was not found in this archive");
|
||||||
if (info.IsLink && followLinks) return GetFile(info.Link, followLinks);
|
if (info.IsLink && followLinks) return GetFile(info.Link, followLinks, linkDepth + 1);
|
||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string ReadLink(string path) => throw new NotImplementedException();
|
|
||||||
|
|
||||||
#region Writing
|
#region Writing
|
||||||
|
|
||||||
public FilesystemEntry SearchNodeFromPath(string p)
|
public FilesystemEntry SearchNodeFromPath(string p)
|
||||||
@@ -159,7 +184,7 @@ namespace AsarSharp.AsarFileSystem
|
|||||||
public void InsertFile(string path, bool shouldUnpack, CrawledFileType file,
|
public void InsertFile(string path, bool shouldUnpack, CrawledFileType file,
|
||||||
IntegrityHelper.FileIntegrity precomputedIntegrity = null)
|
IntegrityHelper.FileIntegrity precomputedIntegrity = null)
|
||||||
{
|
{
|
||||||
var (dirNode, _) = SearchNodeFromPathWithParent(Path.GetDirectoryName(path) ?? path);
|
var (dirNode, _) = SearchNodeFromPathWithParent(path);
|
||||||
var node = SearchNodeFromPath(path);
|
var node = SearchNodeFromPath(path);
|
||||||
|
|
||||||
long size;
|
long size;
|
||||||
|
|||||||
@@ -9,13 +9,6 @@ namespace AsarSharp.AsarFileSystem
|
|||||||
{
|
{
|
||||||
public FileType Type { get; set; }
|
public FileType Type { get; set; }
|
||||||
public FileSystemInfo Stat { get; set; }
|
public FileSystemInfo Stat { get; set; }
|
||||||
public TransformedFile Transformed { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class TransformedFile
|
|
||||||
{
|
|
||||||
public string Path { get; set; }
|
|
||||||
public FileSystemInfo Stat { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum FileType
|
public enum FileType
|
||||||
@@ -36,7 +29,7 @@ namespace AsarSharp.AsarFileSystem
|
|||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
|
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
|
||||||
{
|
{
|
||||||
return null;
|
throw new IOException($"Failed to read attributes for '{filename}'", ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isDirectory = (attributes & FileAttributes.Directory) == FileAttributes.Directory;
|
bool isDirectory = (attributes & FileAttributes.Directory) == FileAttributes.Directory;
|
||||||
@@ -59,7 +52,6 @@ namespace AsarSharp.AsarFileSystem
|
|||||||
foreach (var fullPath in CrawlIterative(dir))
|
foreach (var fullPath in CrawlIterative(dir))
|
||||||
{
|
{
|
||||||
var type = DetermineFileType(fullPath);
|
var type = DetermineFileType(fullPath);
|
||||||
if (type == null) continue;
|
|
||||||
metadata[fullPath] = type;
|
metadata[fullPath] = type;
|
||||||
if (type.Type == FileType.Link) links.Add(fullPath);
|
if (type.Type == FileType.Link) links.Add(fullPath);
|
||||||
filenames.Add(fullPath);
|
filenames.Add(fullPath);
|
||||||
@@ -77,7 +69,8 @@ namespace AsarSharp.AsarFileSystem
|
|||||||
{
|
{
|
||||||
if (string.Equals(filename, link, StringComparison.OrdinalIgnoreCase)) continue;
|
if (string.Equals(filename, link, StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
|
||||||
if (filename.StartsWith(link, StringComparison.OrdinalIgnoreCase))
|
// Require a separator after the prefix so "…/foobar" does not match link "…/foo".
|
||||||
|
if (filename.StartsWith(link + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
string rel = Extensions.GetRelativePath(link, fileDir);
|
string rel = Extensions.GetRelativePath(link, fileDir);
|
||||||
if (!rel.StartsWith("..", StringComparison.Ordinal))
|
if (!rel.StartsWith("..", StringComparison.Ordinal))
|
||||||
@@ -120,7 +113,7 @@ namespace AsarSharp.AsarFileSystem
|
|||||||
foreach (var entry in entries)
|
foreach (var entry in entries)
|
||||||
{
|
{
|
||||||
result.Add(entry.FullName);
|
result.Add(entry.FullName);
|
||||||
if (entry is DirectoryInfo subDir)
|
if (entry is DirectoryInfo subDir && (subDir.Attributes & FileAttributes.ReparsePoint) == 0)
|
||||||
stack.Push(subDir);
|
stack.Push(subDir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using AsarSharp.Utils;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace AsarSharp.Integrity
|
namespace AsarSharp.Integrity
|
||||||
@@ -60,7 +61,9 @@ namespace AsarSharp.Integrity
|
|||||||
var blockHashes = new List<string>(estimatedBlockCount);
|
var blockHashes = new List<string>(estimatedBlockCount);
|
||||||
int bytesRead;
|
int bytesRead;
|
||||||
|
|
||||||
while ((bytesRead = fileStream.Read(reusableBuffer, 0, reusableBuffer.Length)) > 0)
|
// ReadFull, not Read: a short read would hash a partial block and produce
|
||||||
|
// integrity blocks Electron rejects.
|
||||||
|
while ((bytesRead = fileStream.ReadFull(reusableBuffer, 0, reusableBuffer.Length)) > 0)
|
||||||
{
|
{
|
||||||
blockHashes.Add(ToLowerHex(blockHash.ComputeHash(reusableBuffer, 0, bytesRead)));
|
blockHashes.Add(ToLowerHex(blockHash.ComputeHash(reusableBuffer, 0, bytesRead)));
|
||||||
fileHash.AppendData(reusableBuffer, 0, bytesRead);
|
fileHash.AppendData(reusableBuffer, 0, bytesRead);
|
||||||
|
|||||||
@@ -28,16 +28,18 @@ namespace AsarSharp.PickleTools
|
|||||||
{
|
{
|
||||||
if (buffer != null)
|
if (buffer != null)
|
||||||
{
|
{
|
||||||
|
if (buffer.Length < SIZE_UINT32)
|
||||||
|
throw new ArgumentException("Buffer is too small.", nameof(buffer));
|
||||||
|
|
||||||
_header = buffer;
|
_header = buffer;
|
||||||
_headerSize = buffer.Length - GetPayloadSize();
|
int payloadSize = GetPayloadSize();
|
||||||
|
if (payloadSize > buffer.Length)
|
||||||
|
throw new ArgumentException("Payload size exceeds buffer length.", nameof(buffer));
|
||||||
|
|
||||||
|
_headerSize = buffer.Length - payloadSize;
|
||||||
_capacityAfterHeader = CAPACITY_READ_ONLY;
|
_capacityAfterHeader = CAPACITY_READ_ONLY;
|
||||||
_writeOffset = 0;
|
_writeOffset = 0;
|
||||||
|
|
||||||
if (_headerSize > buffer.Length)
|
|
||||||
{
|
|
||||||
_headerSize = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_headerSize != AlignInt(_headerSize, SIZE_UINT32))
|
if (_headerSize != AlignInt(_headerSize, SIZE_UINT32))
|
||||||
{
|
{
|
||||||
_headerSize = 0;
|
_headerSize = 0;
|
||||||
@@ -86,7 +88,7 @@ namespace AsarSharp.PickleTools
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public bool WriteBool(bool value) => WriteInt(value ? 1 : 0);
|
|
||||||
|
|
||||||
public bool WriteInt(int value)
|
public bool WriteInt(int value)
|
||||||
{
|
{
|
||||||
@@ -121,74 +123,7 @@ namespace AsarSharp.PickleTools
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool WriteInt64(long value)
|
|
||||||
{
|
|
||||||
const int dataLength = SIZE_INT64;
|
|
||||||
int newSize = _writeOffset + dataLength;
|
|
||||||
|
|
||||||
if (newSize > _capacityAfterHeader)
|
|
||||||
{
|
|
||||||
Resize(Math.Max((int)_capacityAfterHeader * 2, newSize));
|
|
||||||
}
|
|
||||||
|
|
||||||
WriteInt64LE(value, _headerSize + _writeOffset);
|
|
||||||
SetPayloadSize(newSize);
|
|
||||||
_writeOffset = newSize;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public bool WriteUInt64(ulong value)
|
|
||||||
{
|
|
||||||
const int dataLength = SIZE_UINT64;
|
|
||||||
int newSize = _writeOffset + dataLength;
|
|
||||||
|
|
||||||
if (newSize > _capacityAfterHeader)
|
|
||||||
{
|
|
||||||
Resize(Math.Max((int)_capacityAfterHeader * 2, newSize));
|
|
||||||
}
|
|
||||||
|
|
||||||
WriteUInt64LE(value, _headerSize + _writeOffset);
|
|
||||||
SetPayloadSize(newSize);
|
|
||||||
_writeOffset = newSize;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool WriteFloat(float value)
|
|
||||||
{
|
|
||||||
const int dataLength = SIZE_FLOAT;
|
|
||||||
int newSize = _writeOffset + dataLength;
|
|
||||||
|
|
||||||
if (newSize > _capacityAfterHeader)
|
|
||||||
{
|
|
||||||
Resize(Math.Max((int)_capacityAfterHeader * 2, newSize));
|
|
||||||
}
|
|
||||||
|
|
||||||
int bits = BitConverter.ToInt32(BitConverter.GetBytes(value), 0);
|
|
||||||
WriteInt32LE(bits, _headerSize + _writeOffset);
|
|
||||||
|
|
||||||
SetPayloadSize(newSize);
|
|
||||||
_writeOffset = newSize;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool WriteDouble(double value)
|
|
||||||
{
|
|
||||||
const int dataLength = SIZE_DOUBLE;
|
|
||||||
int newSize = _writeOffset + dataLength;
|
|
||||||
|
|
||||||
if (newSize > _capacityAfterHeader)
|
|
||||||
{
|
|
||||||
Resize(Math.Max((int)_capacityAfterHeader * 2, newSize));
|
|
||||||
}
|
|
||||||
|
|
||||||
long bits = BitConverter.DoubleToInt64Bits(value);
|
|
||||||
WriteInt64LE(bits, _headerSize + _writeOffset);
|
|
||||||
|
|
||||||
SetPayloadSize(newSize);
|
|
||||||
_writeOffset = newSize;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool WriteString(string value)
|
public bool WriteString(string value)
|
||||||
{
|
{
|
||||||
@@ -226,13 +161,25 @@ namespace AsarSharp.PickleTools
|
|||||||
WriteUInt32LE((uint)payloadSize, 0);
|
WriteUInt32LE((uint)payloadSize, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public int GetPayloadSize() => (int)ReadUInt32LE(0);
|
public int GetPayloadSize()
|
||||||
|
{
|
||||||
|
uint size = ReadUInt32LE(0);
|
||||||
|
if (size > int.MaxValue)
|
||||||
|
throw new InvalidOperationException("Payload size exceeds maximum allowed (2GB).");
|
||||||
|
return (int)size;
|
||||||
|
}
|
||||||
|
|
||||||
private void Resize(int newCapacity)
|
private void Resize(int newCapacity)
|
||||||
{
|
{
|
||||||
newCapacity = AlignInt(newCapacity, PAYLOAD_UNIT);
|
newCapacity = AlignInt(newCapacity, PAYLOAD_UNIT);
|
||||||
byte[] newHeader = new byte[_header.Length + newCapacity];
|
// The backing array must hold the header plus the full advertised
|
||||||
Buffer.BlockCopy(_header, 0, newHeader, 0, _header.Length);
|
// payload capacity (matches Chromium's realloc(header_size_ + new_capacity)).
|
||||||
|
// Sizing it from _header.Length under-allocates by _headerSize on the
|
||||||
|
// first growth (when _header is still empty), leaving the payload region
|
||||||
|
// _headerSize bytes short of _capacityAfterHeader and overrunning the
|
||||||
|
// buffer when a write fills the payload.
|
||||||
|
byte[] newHeader = new byte[_headerSize + newCapacity];
|
||||||
|
Buffer.BlockCopy(_header, 0, newHeader, 0, Math.Min(_header.Length, newHeader.Length));
|
||||||
_header = newHeader;
|
_header = newHeader;
|
||||||
_capacityAfterHeader = newCapacity;
|
_capacityAfterHeader = newCapacity;
|
||||||
}
|
}
|
||||||
@@ -269,29 +216,7 @@ namespace AsarSharp.PickleTools
|
|||||||
_header[offset + 3] = (byte)(value >> 24);
|
_header[offset + 3] = (byte)(value >> 24);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void WriteInt64LE(long value, int offset)
|
|
||||||
{
|
|
||||||
_header[offset] = (byte)value;
|
|
||||||
_header[offset + 1] = (byte)(value >> 8);
|
|
||||||
_header[offset + 2] = (byte)(value >> 16);
|
|
||||||
_header[offset + 3] = (byte)(value >> 24);
|
|
||||||
_header[offset + 4] = (byte)(value >> 32);
|
|
||||||
_header[offset + 5] = (byte)(value >> 40);
|
|
||||||
_header[offset + 6] = (byte)(value >> 48);
|
|
||||||
_header[offset + 7] = (byte)(value >> 56);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void WriteUInt64LE(ulong value, int offset)
|
|
||||||
{
|
|
||||||
_header[offset] = (byte)value;
|
|
||||||
_header[offset + 1] = (byte)(value >> 8);
|
|
||||||
_header[offset + 2] = (byte)(value >> 16);
|
|
||||||
_header[offset + 3] = (byte)(value >> 24);
|
|
||||||
_header[offset + 4] = (byte)(value >> 32);
|
|
||||||
_header[offset + 5] = (byte)(value >> 40);
|
|
||||||
_header[offset + 6] = (byte)(value >> 48);
|
|
||||||
_header[offset + 7] = (byte)(value >> 56);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@@ -18,10 +18,7 @@ namespace AsarSharp.PickleTools
|
|||||||
_endIndex = pickle.GetPayloadSize();
|
_endIndex = pickle.GetPayloadSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool ReadBool()
|
|
||||||
{
|
|
||||||
return ReadInt() != 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int ReadInt()
|
public int ReadInt()
|
||||||
{
|
{
|
||||||
@@ -33,25 +30,7 @@ namespace AsarSharp.PickleTools
|
|||||||
return ReadBytes(Pickle.SIZE_UINT32, BitConverter.ToUInt32);
|
return ReadBytes(Pickle.SIZE_UINT32, BitConverter.ToUInt32);
|
||||||
}
|
}
|
||||||
|
|
||||||
public long ReadInt64()
|
|
||||||
{
|
|
||||||
return ReadBytes(Pickle.SIZE_INT64, BitConverter.ToInt64);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ulong ReadUInt64()
|
|
||||||
{
|
|
||||||
return ReadBytes(Pickle.SIZE_UINT64, BitConverter.ToUInt64);
|
|
||||||
}
|
|
||||||
|
|
||||||
public float ReadFloat()
|
|
||||||
{
|
|
||||||
return ReadBytes(Pickle.SIZE_FLOAT, BitConverter.ToSingle);
|
|
||||||
}
|
|
||||||
|
|
||||||
public double ReadDouble()
|
|
||||||
{
|
|
||||||
return ReadBytes(Pickle.SIZE_DOUBLE, BitConverter.ToDouble);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string ReadString()
|
public string ReadString()
|
||||||
{
|
{
|
||||||
@@ -75,7 +54,7 @@ namespace AsarSharp.PickleTools
|
|||||||
|
|
||||||
private int GetReadPayloadOffsetAndAdvance(int length)
|
private int GetReadPayloadOffsetAndAdvance(int length)
|
||||||
{
|
{
|
||||||
if (length > _endIndex - _readIndex)
|
if (length < 0 || length > _endIndex - _readIndex)
|
||||||
{
|
{
|
||||||
_readIndex = _endIndex;
|
_readIndex = _endIndex;
|
||||||
throw new InvalidOperationException($"Failed to read data with length of {length}");
|
throw new InvalidOperationException($"Failed to read data with length of {length}");
|
||||||
|
|||||||
+150
-31
@@ -1,12 +1,36 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Security.AccessControl;
|
||||||
|
using System.Security.Principal;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace AsarSharp.Utils
|
namespace AsarSharp.Utils
|
||||||
{
|
{
|
||||||
internal static class Extensions
|
public static class Extensions
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Fills <paramref name="count"/> bytes. Stream.Read may legally return fewer than
|
||||||
|
/// asked for; treating a short read as EOF corrupts header parsing and block hashes.
|
||||||
|
/// Returns the bytes actually read, which is less than count only at end of stream.
|
||||||
|
/// </summary>
|
||||||
|
public static int ReadFull(this Stream stream, byte[] buffer, int offset, int count)
|
||||||
|
{
|
||||||
|
int total = 0;
|
||||||
|
while (total < count)
|
||||||
|
{
|
||||||
|
int read = stream.Read(buffer, offset + total, count - total);
|
||||||
|
if (read <= 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
total += read;
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compute path relative to <paramref name="relativeTo"/>.
|
/// Compute path relative to <paramref name="relativeTo"/>.
|
||||||
/// Fast common-case (path is inside relativeTo): plain prefix-strip.
|
/// Fast common-case (path is inside relativeTo): plain prefix-strip.
|
||||||
@@ -97,6 +121,27 @@ namespace AsarSharp.Utils
|
|||||||
|
|
||||||
private static bool IsSeparator(char c) => c == '/' || c == '\\';
|
private static bool IsSeparator(char c) => c == '/' || c == '\\';
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Security check for archive extraction: returns true only when
|
||||||
|
/// <paramref name="candidate"/> resolves to a location inside
|
||||||
|
/// <paramref name="root"/>. Both paths are fully normalised first, so
|
||||||
|
/// embedded ".." segments cannot escape the root (zip-slip). The
|
||||||
|
/// <see cref="GetRelativePath"/> fast path must not be used here because
|
||||||
|
/// it strips the prefix literally without resolving "..".
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsPathInside(string root, string candidate)
|
||||||
|
{
|
||||||
|
string fullRoot = TrimTrailingSeparators(Path.GetFullPath(root));
|
||||||
|
string fullCandidate = TrimTrailingSeparators(Path.GetFullPath(candidate));
|
||||||
|
|
||||||
|
if (string.Equals(fullRoot, fullCandidate, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return fullCandidate.Length > fullRoot.Length
|
||||||
|
&& fullCandidate.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& IsSeparator(fullCandidate[fullRoot.Length]);
|
||||||
|
}
|
||||||
|
|
||||||
public static string GetDirectoryName(string path)
|
public static string GetDirectoryName(string path)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(path))
|
if (string.IsNullOrEmpty(path))
|
||||||
@@ -110,6 +155,83 @@ namespace AsarSharp.Utils
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Overwrites <paramref name="destination"/>, clearing attributes on both ends. CopyFile
|
||||||
|
/// carries the source's ReadOnly flag onto the copy and then refuses to overwrite what it
|
||||||
|
/// produced, failing with "Access to the path is denied" - so one read-only source (an exe
|
||||||
|
/// run straight out of a .zip, say) poisons the destination for every later run.
|
||||||
|
/// </summary>
|
||||||
|
public static void CopyOver(string source, string destination)
|
||||||
|
{
|
||||||
|
ClearAttributes(destination);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Copy(source, destination, true);
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException e)
|
||||||
|
{
|
||||||
|
throw new UnauthorizedAccessException($"{e.Message} {DescribeDenial(destination)}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
ClearAttributes(destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "Access to the path is denied" names none of the half-dozen things that cause it, and
|
||||||
|
/// the state is gone by the time anyone reads the report. Attributes were already cleared
|
||||||
|
/// above, which rules the most common cause out before the message is even written.
|
||||||
|
/// </summary>
|
||||||
|
private static string DescribeDenial(string destination)
|
||||||
|
{
|
||||||
|
if (Directory.Exists(destination))
|
||||||
|
{
|
||||||
|
return "The destination is a directory, not a file.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(destination))
|
||||||
|
{
|
||||||
|
return "The destination does not exist, so the containing folder is refusing new files.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"Attributes {File.GetAttributes(destination)}, owner {DescribeOwner(destination)}, " +
|
||||||
|
$"running as {Environment.UserName}. A read-only flag, antivirus, folder " +
|
||||||
|
"permissions or a delete still pending on the file are the usual causes.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DescribeOwner(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return File.GetAccessControl(path).GetOwner(typeof(NTAccount)).Value;
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is IdentityNotMappedException || e is UnauthorizedAccessException ||
|
||||||
|
e is InvalidOperationException || e is PrivilegeNotHeldException ||
|
||||||
|
e is PlatformNotSupportedException)
|
||||||
|
{
|
||||||
|
return "unreadable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resets a file to Normal: ReadOnly, Hidden and System all block an overwrite. Best
|
||||||
|
/// effort - a file that denies even this reports it properly through the write that follows.
|
||||||
|
/// </summary>
|
||||||
|
public static void ClearAttributes(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
File.SetAttributes(path, FileAttributes.Normal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is UnauthorizedAccessException || e is IOException)
|
||||||
|
{
|
||||||
|
// Swallowed so the caller's own failure is the one that surfaces.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static void CopyDirectory(string sourceDir, string destinationDir)
|
public static void CopyDirectory(string sourceDir, string destinationDir)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(destinationDir);
|
Directory.CreateDirectory(destinationDir);
|
||||||
@@ -117,7 +239,7 @@ namespace AsarSharp.Utils
|
|||||||
foreach (var file in Directory.GetFiles(sourceDir))
|
foreach (var file in Directory.GetFiles(sourceDir))
|
||||||
{
|
{
|
||||||
var destFile = Path.Combine(destinationDir, Path.GetFileName(file));
|
var destFile = Path.Combine(destinationDir, Path.GetFileName(file));
|
||||||
File.Copy(file, destFile, true);
|
CopyOver(file, destFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var dir in Directory.GetDirectories(sourceDir))
|
foreach (var dir in Directory.GetDirectories(sourceDir))
|
||||||
@@ -149,19 +271,7 @@ namespace AsarSharp.Utils
|
|||||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var process = new System.Diagnostics.Process
|
RunTool("chmod", $"{permission} \"{filePath}\"");
|
||||||
{
|
|
||||||
StartInfo = new System.Diagnostics.ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "chmod",
|
|
||||||
Arguments = $"{permission} \"{filePath}\"",
|
|
||||||
UseShellExecute = false,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
CreateNoWindow = true
|
|
||||||
}
|
|
||||||
};
|
|
||||||
process.Start();
|
|
||||||
process.WaitForExit();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -169,32 +279,41 @@ namespace AsarSharp.Utils
|
|||||||
{
|
{
|
||||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
{
|
{
|
||||||
NativeMethods.CreateSymbolicLink(linkPath, linkTarget,
|
bool success = NativeMethods.CreateSymbolicLink(linkPath, linkTarget,
|
||||||
Directory.Exists(linkTarget)
|
Directory.Exists(linkTarget)
|
||||||
? NativeMethods.SymLinkFlag.Directory
|
? NativeMethods.SymLinkFlag.Directory
|
||||||
: NativeMethods.SymLinkFlag.File);
|
: NativeMethods.SymLinkFlag.File);
|
||||||
|
if (!success)
|
||||||
|
throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var process = new System.Diagnostics.Process
|
RunTool("ln", $"-s \"{linkTarget}\" \"{linkPath}\"");
|
||||||
{
|
|
||||||
StartInfo = new System.Diagnostics.ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "ln",
|
|
||||||
Arguments = $"-s \"{linkTarget}\" \"{linkPath}\"",
|
|
||||||
UseShellExecute = false,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
CreateNoWindow = true
|
|
||||||
}
|
|
||||||
};
|
|
||||||
process.Start();
|
|
||||||
process.WaitForExit();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public static bool IsWindowsPlatform()
|
public static bool IsWindowsPlatform()
|
||||||
{
|
{
|
||||||
return Environment.OSVersion.Platform == PlatformID.Win32NT;
|
return RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunTool(string fileName, string arguments)
|
||||||
|
{
|
||||||
|
using (var process = new System.Diagnostics.Process
|
||||||
|
{
|
||||||
|
StartInfo = new System.Diagnostics.ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = fileName,
|
||||||
|
Arguments = arguments,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
{
|
||||||
|
process.Start();
|
||||||
|
process.WaitForExit();
|
||||||
|
if (process.ExitCode != 0)
|
||||||
|
throw new InvalidOperationException($"Tool {fileName} failed with exit code {process.ExitCode}.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+131
-1
@@ -3,6 +3,136 @@
|
|||||||
This file is the source of truth for release notes.
|
This file is the source of truth for release notes.
|
||||||
The newest entry must match the version in `WandEnhancer/Properties/AssemblyInfo.cs`.
|
The newest entry must match the version in `WandEnhancer/Properties/AssemblyInfo.cs`.
|
||||||
|
|
||||||
|
## [2.0.0.0] - 2026-08-29
|
||||||
|
|
||||||
|
### Important
|
||||||
|
|
||||||
|
- The bundled `version.dll` proxy is gone. The launcher now starts Wand and keeps the patch applied in every process Electron spawns, for as long as Wand runs. This is what fixes Wand refusing to launch after enhancing on related issues: #207 #210 #211 #213 #214 #217
|
||||||
|
- The native helper and its CMake build step were removed. Building from source no longer needs `CMake` or the Visual Studio C++ workload.
|
||||||
|
- WandEnhancer now installs itself as the Wand launcher entry point, so starting Wand goes through the patcher. Restoring a backup puts the original launcher back.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Auto-patch after Wand updates.** Enable *Auto-apply after updates* in the patch dialog and your selection is saved next to the launcher. When Wand updates and drops the patches, the next launch re-applies them. On failure the UI opens and shows which patch broke instead of silently starting an unpatched client.
|
||||||
|
- **Rewritten patch engine with legacy version support.** Patches are located structurally instead of by regex signature: each anchors on something Wand does not rename between builds. A client rebuild that only re-minifies no longer breaks patching, and older clients keep working. #178 #186
|
||||||
|
- A patch whose feature is missing from your client is now reported as skipped instead of failing the whole run, and failures name the patch that broke.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Fixed the in-game overlay never appearing after enhancing, while Wand itself looked healthy. Wand creates the process that draws the overlay when a game starts, minutes or hours after launch, and only the startup processes were being covered - that one shut down the instant it opened the patched files, so nothing ever drew the overlay.
|
||||||
|
- Fixed the "Buy Pro" banner still showing after a successful patch, and Pro not activating on newer clients.
|
||||||
|
- Fixed the Enhancer closing itself when any button was pressed. #184
|
||||||
|
- A failed patch now puts your original Wand files back instead of leaving a half-patched install behind. Packing also builds the archive beside the old one and swaps it in at the end, so a failure can no longer destroy `app.asar`. #221
|
||||||
|
- Fixed patching and *Restore* both failing with "Access to the path is denied" after the first successful patch. Copying carried the read-only flag from the patcher onto the launcher it installs, and then refused to overwrite what it had written - so running WandEnhancer straight out of the downloaded `.zip`, which Windows marks read-only, broke every later run. #214
|
||||||
|
- Fixed a half-written backup reporting the installation as patched, which blocked patching and restore at the same time.
|
||||||
|
- Fixed invalid ASAR integrity metadata produced from short reads, which could yield an archive the client rejects. #170
|
||||||
|
- Fixed the packer silently dropping files it could not read, for example while Wand was still running.
|
||||||
|
- Fixed archive tree lookups resolving the wrong parent and creating phantom directories in the header.
|
||||||
|
- Fixed hangs on symlink cycles and directory junctions while reading or packing an archive.
|
||||||
|
- Fixed the language switcher leaking a resource dictionary on every switch. #164
|
||||||
|
- Fixed *Restore* freezing the window while it ran.
|
||||||
|
- Fixed Squirrel install and update arguments breaking when the Windows user profile path contains spaces.
|
||||||
|
- Fixed a latent crash path from a patch type that had no configuration entry. #172
|
||||||
|
- Remote panel: fixed a blank page when the interface translations failed to load.
|
||||||
|
- Remote panel: fixed number inputs eating the decimal point while typing, and steppers drifting on fractional steps.
|
||||||
|
- Remote panel: fixed the increment control refusing to step from a value outside its option list.
|
||||||
|
- Remote panel: fixed endless two-second reconnect attempts, and reconnecting again after you disconnected on purpose.
|
||||||
|
- Remote panel: fixed installed-game updates not arriving when only the install location changed.
|
||||||
|
- Remote panel: fixed value writes silently doing nothing when the client bound to the bridge before it was ready.
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- When Wand fails to start, the Enhancer window now opens by itself with the reason already in the log, instead of leaving you to find a log file. The same lines are still written to a `launcher.log` next to the launcher: every process Electron starts and whether the patch reached it, plus exit and crash codes.
|
||||||
|
- Log messages in the desktop app are now translated into all 12 supported languages.
|
||||||
|
- The remote panel is now usable with a keyboard and a screen reader: dialogs trap focus and close on Escape, and controls have accessible names. Pinning a mod previously required a swipe and had no keyboard path at all, so mod rows now have a pin button.
|
||||||
|
|
||||||
|
### Security and Privacy
|
||||||
|
|
||||||
|
- The panel's static file server now resolves every request inside the panel directory.
|
||||||
|
- The local bridge enforces the WebSocket framing rules required of a server (RFC 6455).
|
||||||
|
- Late trainer events naming a different trainer no longer overwrite the active trainer's values.
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- The Electron bridge is now fully type-checked; roughly 200 latent typing gaps were fixed.
|
||||||
|
- `build.ps1` and CI now run lint, type-check, and a dist verification step that syntax-checks the bundles and fails when dev-only payloads leak into a production build. CI runs on pull requests and pushes to `master`.
|
||||||
|
- Removed dead code: the `version.dll` project, an unused control and converter, and unused Pickle helpers.
|
||||||
|
|
||||||
|
## [1.0.9.4] - 2026-07-21
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Fixed the Remote Web Panel QR code still opening the official Wand mobile client after Wand changed its bundled QR renderer export. The renderer bridge now resolves the current export without adding a fragile C# ASAR patch. #140
|
||||||
|
- Fixed Quick Presets reporting that a preset was saved when browser local storage rejected the write. Failed writes now leave the existing preset list unchanged and show an error, and the save dialog now stays above the bottom navigation dock.
|
||||||
|
- Fixed the patcher giving up on process termination because it reused a stale process snapshot by @divya0795 in #145. Related issue: #136
|
||||||
|
- Fixed ASAR extraction path traversal and corrupt Pickle payload allocation by @divya0795 in #143.
|
||||||
|
- Fixed backup restore so `app.asar.unpacked` is restored together with `app.asar`, and the injected `version.dll` is removed after a successful restore.
|
||||||
|
- Fixed `version.dll` requiring Visual C++ runtime DLLs on some systems by statically linking the runtime. Release builds now reject accidental dynamic VCRUNTIME, MSVCP, or UCRT dependencies. #128
|
||||||
|
|
||||||
|
### Security and Privacy
|
||||||
|
|
||||||
|
- Removed bearer credentials and local installation paths from the Remote Web Panel WebSocket protocol. Trainer localization now stays inside the Electron bridge.
|
||||||
|
- Hardened the local bridge against malformed HTTP URLs, invalid Host headers, and oversized WebSocket frames, and removed the production installed-apps debug endpoint.
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- GitLab mirror jobs are now skipped in forks instead of failing when the upstream mirror credentials are unavailable.
|
||||||
|
|
||||||
|
## [1.0.9.3] - 2026-07-04
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Fixed the Remote Web Panel no longer applying on newer Wand builds and reporting "unsupported version". The remote bridge patches now resolve Wand's minified internal names dynamically instead of relying on hardcoded ones that broke on Wand updates. #118 #123 #124 #126
|
||||||
|
- Fixed Pro reverting to Free (with random sign-outs and the return of ads and the time limit) after linking a phone with Wand's mobile activation code. That native pairing triggers a server-side sign-out on a patched client, so the patcher now disables it; use the built-in Remote Web Panel to control Wand from another device instead. #120
|
||||||
|
|
||||||
|
## [1.0.9.2] - 2026-06-28
|
||||||
|
|
||||||
|
### Important
|
||||||
|
|
||||||
|
- Official releases no longer include downloadable `.exe` files. To update, sync your fork and rerun the `Build executable` workflow, or follow the instructions in [How to use](https://github.com/k1tbyte/Wand-Enhancer#-how-to-use).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed the built-in WandEnhancer updater. Official GitHub releases no longer ship executable assets.
|
||||||
|
- Removed System.Net.Http
|
||||||
|
- Removed self-signed certificate generation to prevent AV false positives.
|
||||||
|
- Switched official releases to publish release notes only.
|
||||||
|
|
||||||
|
## [1.0.9.1] - 2026-06-24
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Fixed Pro features disappearing after a day or two when Wand refreshed account data in the background; account store updates now preserve the patched active subscription by @Kava-4 in #110. Related issue #106
|
||||||
|
- Fixed the new Pro account reducer guard so normal account updates do not fail while keeping Pro active.
|
||||||
|
|
||||||
|
## [1.0.9.0] - 2026-06-15
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- The Remote Web Panel now shows mod names, descriptions, and instructions translated to your WeMod account language by @YifePlayte in #98. Related issue: #85
|
||||||
|
- Added a language selector to the Remote Web Panel (English, Russian, German, French, Spanish, Simplified Chinese) with automatic detection from the browser language.
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- Release builds are now code-signed, which reduces false-positive antivirus and VirusTotal detections.
|
||||||
|
- Reworked the Remote Web Panel internals around feature capabilities for easier maintenance, with no change to existing behavior.
|
||||||
|
|
||||||
|
## [1.0.8.4] - 2026-06-10
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Fixed QR code issues on the latest Wand version.
|
||||||
|
- Fixed application hang that occurred after Wand updates with pending patches.
|
||||||
|
|
||||||
|
## [1.0.8.3] - 2026-06-06
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Fixed the Remote Web Panel patches so they reliably apply on newer Wand builds by making the remote bridge patch anchors version-resilient.
|
||||||
|
- Fixed Pro activation being lost after changing the app language; the account language endpoint now keeps the patched subscription.
|
||||||
|
- Fixed "WeMod directory not found" when Wand/WeMod is installed outside the default location or only one brand folder exists. The patcher now also resolves the install directory from a running Wand/WeMod process. #82
|
||||||
|
- Hid the Pro "Remote" onboarding card in the Explore Pro benefits dialog. #86
|
||||||
|
|
||||||
## [1.0.8.2] - 2026-05-15
|
## [1.0.8.2] - 2026-05-15
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
@@ -126,4 +256,4 @@ The newest entry must match the version in `WandEnhancer/Properties/AssemblyInfo
|
|||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|
||||||
- Basic ElectronJS wrapper over the original script.
|
- Basic ElectronJS wrapper over the original script.
|
||||||
|
|||||||
+1
-1
@@ -97,7 +97,7 @@ Suggestions for new features or improvements are welcome! Create an Issue descri
|
|||||||
git tag 1.0.8.0
|
git tag 1.0.8.0
|
||||||
git push origin 1.0.8.0
|
git push origin 1.0.8.0
|
||||||
```
|
```
|
||||||
6. GitHub Actions will validate the version, build the project, extract the matching changelog section, and publish the release automatically.
|
6. GitHub Actions will validate the version, build the project, extract the matching changelog section, and publish a notes-only release automatically. Official releases do not attach compiled binaries.
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,17 @@
|
|||||||
# WandEnhancer
|
# WandEnhancer
|
||||||
|
|
||||||
[](https://gitlab.com/kitbyte/wand-enhancer)
|
[](https://gitlab.com/kitbyte/wand-enhancer)
|
||||||
[](https://www.virustotal.com/gui/file/f6897cf583e9f8ea11e0ee4c3fb99b86c50336b28de706e3e0b9181b4e3cf223)
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4>An open-source interoperability tool designed to extend local client-side configurations and improve the UX of the Wand application.</h4>
|
<h4>An open-source interoperability tool designed to extend local client-side configurations and improve the UX of the Wand application.</h4>
|
||||||
|
|
||||||
**🚨 IMPORTANT NOTICE: THIS PROJECT HAS NO OFFICIAL YOUTUBE TUTORIALS OR GUIDES. 🚨
|
**🚨 IMPORTANT NOTICE: THIS PROJECT HAS NO OFFICIAL YOUTUBE TUTORIALS, GUIDES, OR PREBUILT EXECUTABLE DOWNLOADS. 🚨
|
||||||
There are no official videos showing how to install or use this tool. Scammers are creating fake tutorials using this project's name and placing malware/password stealers in the video descriptions. If you downloaded an .exe or archive from a YouTube link, YOU HAVE DOWNLOADED MALWARE. The only official, safe, and original source for this project is this exact GitHub repository. We are not responsible for third-party downloads.**
|
There are no official videos showing how to install or use this tool. Scammers are creating fake tutorials using this project's name and placing malware/password stealers in the video descriptions. Official GitHub releases contain release notes only, not `.exe` files. If you downloaded an `.exe` or archive from a YouTube link, a random website, or a third-party mirror, you did not get it from this project. We are not responsible for third-party downloads.**
|
||||||
|
|
||||||
## 👾 Is it safe to use?
|
## 👾 What does it access?
|
||||||
|
|
||||||
Yes. This project is entirely open-source, allowing anyone to audit the code. It operates strictly locally, does not require internet access, and makes zero network requests. It simply adjusts local client settings to enhance your user experience.
|
The .NET patcher modifies files in the selected local Wand installation and does not contact an update or telemetry service. Wand itself remains an online application, build tools restore declared dependencies, and the optional Remote Web Panel deliberately starts a LAN HTTP/WebSocket server and uses Wand API/CDN data. Review the source and build the executable from your own fork; unsigned patching tools can trigger generic antivirus heuristics.
|
||||||
|
|
||||||
## 💫 What features are improved?
|
## 💫 What features are improved?
|
||||||
|
|
||||||
@@ -37,14 +36,65 @@ WandEnhancer includes a built-in **Remote Web Panel** allowing you to control ap
|
|||||||
### Troubleshooting & Remote Access:
|
### Troubleshooting & Remote Access:
|
||||||
- **Page isn't loading?** First, ensure both your PC and phone are connected to the **same local network**. Some routers and guest Wi-Fi networks enable client isolation/AP isolation, which blocks devices on the same SSID from reaching each other. If it still does not load, check Windows Firewall and allow inbound traffic on TCP port `3223` for your local network. If Windows marked your connection as **Public**, switching it to **Private** can also help.
|
- **Page isn't loading?** First, ensure both your PC and phone are connected to the **same local network**. Some routers and guest Wi-Fi networks enable client isolation/AP isolation, which blocks devices on the same SSID from reaching each other. If it still does not load, check Windows Firewall and allow inbound traffic on TCP port `3223` for your local network. If Windows marked your connection as **Public**, switching it to **Private** can also help.
|
||||||
- **Using mobile data or a different network?** If you want to use the panel over mobile data (LTE/5G) or from an entirely different network, you can use [Tailscale](https://tailscale.com/) or similar VPN tools.
|
- **Using mobile data or a different network?** If you want to use the panel over mobile data (LTE/5G) or from an entirely different network, you can use [Tailscale](https://tailscale.com/) or similar VPN tools.
|
||||||
|
- The panel uses plain HTTP on port `3223` and has no pairing code. Anyone who can reach that port can view the panel and control the active trainer, so use it only on a trusted LAN/VPN and never expose the port directly to the internet.
|
||||||
|
- The panel protocol does not include your Wand bearer token or installation-path fields.
|
||||||
|
|
||||||
## 👀 How to use?
|
## 👀 How to use?
|
||||||
|
|
||||||
1. Go to the [Releases](https://github.com/k1tbyte/Wand-Enhancer/releases) page.
|
This repository does not publish official compiled binaries. Build your own executable from your own fork using GitHub Actions.
|
||||||
2. Download the latest binary release.
|
|
||||||
3. Run the enhancer to apply local client modifications.
|
|
||||||
|
|
||||||
> Source archives are intended for developers who want to build the project locally. They are not prebuilt binaries.
|
1. Sign in to GitHub and fork this repository.
|
||||||
|
2. Use **Sync fork** before each build so your fork contains the latest fixes.
|
||||||
|
3. Open your fork, go to the **Actions** tab, and enable workflows if GitHub asks you to.
|
||||||
|
4. Select the **Build executable** workflow.
|
||||||
|
5. Click **Run workflow**, keep the default branch, and start the run.
|
||||||
|
6. Wait for the workflow to finish, open the completed run, and download the artifact.
|
||||||
|
7. Extract the artifact zip and run `WandEnhancer.exe` to apply local client modifications.
|
||||||
|
|
||||||
|
*Here how you do it:*
|
||||||
|
|
||||||
|
https://github.com/user-attachments/assets/7966cabe-0aa6-424d-8c2f-981ad91e0f91
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 🧩 Custom scripts
|
||||||
|
|
||||||
|
You can inject your own JavaScript into Wand at patch time to tweak or fix things in the client UI. This reuses the same renderer injection the Remote Web Panel uses, so it requires the **Remote Web Panel** patch to be enabled.
|
||||||
|
|
||||||
|
**How to add a script**
|
||||||
|
|
||||||
|
- In the patch dialog, add one or more `.js` files (only existing `.js` files are accepted), **or**
|
||||||
|
- Drop `.js` files into a `renderer-scripts/` folder placed next to the patcher executable.
|
||||||
|
|
||||||
|
Then patch as usual — your scripts are bundled into the client and run inside Wand's window.
|
||||||
|
|
||||||
|
**How it runs**
|
||||||
|
|
||||||
|
- Each script runs inside Wand's renderer (full DOM access, plus Node `require`).
|
||||||
|
- It is wrapped so a thrown error is logged and never crashes Wand.
|
||||||
|
- It may run **more than once** per launch (on load and again shortly after), so guard one‑time work behind a global flag.
|
||||||
|
- A small `WandEnhancer` helper is available: `WandEnhancer.log(...)`, `WandEnhancer.remoteUrl`, `WandEnhancer.apiVersion`.
|
||||||
|
|
||||||
|
**Minimal example** (`hello.js`)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Injected scripts can run multiple times — guard one-time setup.
|
||||||
|
if (!globalThis.__helloScriptInstalled) {
|
||||||
|
globalThis.__helloScriptInstalled = true;
|
||||||
|
|
||||||
|
WandEnhancer.log("Hello from my custom script!", WandEnhancer.remoteUrl);
|
||||||
|
|
||||||
|
new MutationObserver(() => {
|
||||||
|
const dialog = document.querySelector("ux-dialog:not([data-seen])");
|
||||||
|
if (dialog) {
|
||||||
|
dialog.setAttribute("data-seen", "1");
|
||||||
|
WandEnhancer.log("A dialog opened.");
|
||||||
|
}
|
||||||
|
}).observe(document.documentElement, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Scripts run with the same privileges as the Wand client. Only add scripts you trust and understand.
|
||||||
|
|
||||||
## 🛠️ How to build from source
|
## 🛠️ How to build from source
|
||||||
|
|
||||||
@@ -52,28 +102,34 @@ Building from source on Windows requires a local development environment.
|
|||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- `CMake`
|
|
||||||
- `Node.js` and `pnpm`
|
- `Node.js` and `pnpm`
|
||||||
- `Visual Studio 2022` or `Build Tools for Visual Studio 2022` with `MSBuild`
|
- `Visual Studio 2022` or `Build Tools for Visual Studio 2022` with `MSBuild`
|
||||||
- Visual Studio `Desktop development with C++` workload
|
|
||||||
- .NET Framework 4.8 desktop build tools / targeting pack
|
- .NET Framework 4.8 desktop build tools / targeting pack
|
||||||
|
|
||||||
### Build steps
|
### Build steps
|
||||||
|
|
||||||
1. Clone this repository.
|
1. Clone this repository.
|
||||||
2. Install the requirements above and make sure `cmake`, `pnpm`, and `MSBuild` are available.
|
2. Install the requirements above and make sure `pnpm` and `MSBuild` are available.
|
||||||
3. Run `build.cmd` from Command Prompt or PowerShell.
|
3. Run `build.cmd` from Command Prompt or PowerShell.
|
||||||
|
|
||||||
The build script installs the web panel dependencies, builds the frontend, compiles the native helper with CMake, restores NuGet packages, and builds the WPF solution.
|
The build script installs the web panel dependencies, type-checks and lints the panel, builds the frontend and bridge, restores NuGet packages, and builds the WPF solution.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ❓ Q&A
|
## ❓ Q&A
|
||||||
|
|
||||||
- **I applied the configuration but get stuck on 'Loading...'**
|
- **Why is there no `.exe` in GitHub Releases?**
|
||||||
- Just close the application completely and restart it.
|
- Official releases are notes-only on purpose. The project no longer distributes prebuilt executables because unsigned or self-built patching tools are repeatedly reuploaded, mislabeled, and flagged by third-party scanners. Build the executable from your own fork using GitHub Actions instead.
|
||||||
|
- **Where do I download the executable?**
|
||||||
|
- From your own fork's **Actions** artifact after running the **Build executable** workflow. Do not download `.exe` files from YouTube descriptions, random mirrors, Discord attachments, or issue comments.
|
||||||
|
- **Why does Windows Defender or SmartScreen warn about my build?**
|
||||||
|
- The GitHub Actions artifact is unsigned and uncommon, so Windows may warn even when the code was built directly from your fork. Review the source, verify the workflow logs, and only run binaries you built yourself.
|
||||||
|
- **Can I use a binary built by someone else?**
|
||||||
|
- You can, but you should treat it as untrusted. This repository cannot verify or support third-party builds.
|
||||||
- **Does this send data anywhere?**
|
- **Does this send data anywhere?**
|
||||||
- No. All operations are strictly offline and local to your machine.
|
- The .NET patching step is local. The optional Remote Web Panel listens on your LAN and may request trainer translations/artwork through Wand's existing API/CDN paths; it does not include an updater or project telemetry.
|
||||||
|
- **How do I learn about a new version without an in-app update check?**
|
||||||
|
- On GitHub choose **Watch → Custom → Releases**, then sync your fork and run **Build executable** when a release is published.
|
||||||
|
|
||||||
---
|
---
|
||||||
## 🖼️ Screenshots
|
## 🖼️ Screenshots
|
||||||
@@ -83,14 +139,20 @@ The build script installs the web panel dependencies, builds the frontend, compi
|
|||||||

|

|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📜 License
|
## 📜 License
|
||||||
This project is licensed under the Apache-2.0 - see the [LICENSE](LICENSE.md) file for details.
|
This project is licensed under the Apache-2.0 - see the [LICENSE](LICENSE.md) file for details.
|
||||||
|
|
||||||
---
|
|
||||||
## ❤️ Support
|
## ❤️ Support
|
||||||
[](https://ko-fi.com/kitbyte)
|
|
||||||
|
If you find this project useful, you can support its development using any of the options below 🙌
|
||||||
|
|
||||||
|
[](https://www.patreon.com/kitbyte/gift)
|
||||||
|
[](https://tronscan.org/#/address/TQdvau8pAy5Tg1Aa588tTcPCFgbcHtuoxc)
|
||||||
|
[](https://www.blockchain.com/explorer/addresses/btc/1EZKDcyU8REm9JW5xwXJqSpn5Xaq5yAWWX)
|
||||||
|
[](https://etherscan.io/address/0xd904d9d0557f88bbb1c4ab3582b4ca0d8a730e8d)
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -98,5 +160,3 @@ This project is licensed under the Apache-2.0 - see the [LICENSE](LICENSE.md) fi
|
|||||||
> This project is a third-party enhancement tool intended solely for educational, research, and local interoperability purposes. It does not distribute any proprietary code or bypass server-side validations. All modifications are performed locally to customize the user's interface.
|
> This project is a third-party enhancement tool intended solely for educational, research, and local interoperability purposes. It does not distribute any proprietary code or bypass server-side validations. All modifications are performed locally to customize the user's interface.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[](https://www.star-history.com/#k1tbyte/Wand-Enhancer&Date)
|
|
||||||
@@ -15,7 +15,6 @@
|
|||||||
<FontFamily x:Key="Inter" >pack://application:,,,/Style/#Inter 18pt 18pt</FontFamily>
|
<FontFamily x:Key="Inter" >pack://application:,,,/Style/#Inter 18pt 18pt</FontFamily>
|
||||||
|
|
||||||
<converters:ToVisibilityConverter x:Key="ToVisibilityConverter"/>
|
<converters:ToVisibilityConverter x:Key="ToVisibilityConverter"/>
|
||||||
<converters:ToVisibilityInvertedConverter x:Key="ToVisibilityInvertedConverter"/>
|
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
</Application>
|
</Application>
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using WandEnhancer.Models;
|
|
||||||
|
|
||||||
namespace WandEnhancer
|
namespace WandEnhancer
|
||||||
{
|
{
|
||||||
@@ -8,36 +7,15 @@ namespace WandEnhancer
|
|||||||
{
|
{
|
||||||
public const string RepoName = "Wand-Enhancer";
|
public const string RepoName = "Wand-Enhancer";
|
||||||
public const string Owner = "k1tbyte";
|
public const string Owner = "k1tbyte";
|
||||||
/*public const string PatchRegistryName = "patchRegistry.json";*/
|
|
||||||
public static readonly string RepositoryUrl = $"https://github.com/{Owner}/{RepoName}";
|
public static readonly string RepositoryUrl = $"https://github.com/{Owner}/{RepoName}";
|
||||||
public static readonly Version Version;
|
public static readonly Version Version;
|
||||||
public static readonly string[] WeModBrandNames = { "Wand", "WeMod" };
|
public static readonly string[] WeModBrandNames = { "Wand", "WeMod" };
|
||||||
public const string AppSettingsFileName = "appsettings.json";
|
public const string AppSettingsFileName = "appsettings.json";
|
||||||
|
public const string AutoPatchConfigFileName = "enhancer.json";
|
||||||
public const string ProxyDllResouceName = "proxydll";
|
|
||||||
|
|
||||||
// cmp dword ptr [rdx], 0
|
static Constants()
|
||||||
// jnz loc_XXXXXXXX
|
|
||||||
// mov rsi, rdx
|
|
||||||
/*public static Signature ExePatchSignature = new Signature(
|
|
||||||
"83 3A 00 0F ?? ?? 01 00 00 48 89 D6 48 B8",
|
|
||||||
4,
|
|
||||||
new byte[]{ 0x84, 0x17 },
|
|
||||||
new byte[]{ 0x85, 0x22 }
|
|
||||||
);*/
|
|
||||||
|
|
||||||
/*// ...
|
|
||||||
// test eax, eax (0x85 for r/m16/32/64)
|
|
||||||
// jnz short loc_1403A4DD2 (Integrity check failed)
|
|
||||||
// call near ptr funk_1445527E0
|
|
||||||
// ...
|
|
||||||
private const string PatchSignature = "E8 ?? ?? ?? ?? ?? C0 75 ?? F6 C3 01 74 ?? 48 89 F9 E8 ?? ?? ?? ??";
|
|
||||||
private static readonly byte[] PatchBytes = { 0x31 };
|
|
||||||
private const int PatchOffset = 0x5;*/
|
|
||||||
|
|
||||||
static Constants()
|
|
||||||
{
|
{
|
||||||
Version = Assembly.GetExecutingAssembly().GetName().Version;
|
Version = Assembly.GetExecutingAssembly().GetName().Version;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,29 +18,7 @@ namespace WandEnhancer.Converters
|
|||||||
|
|
||||||
public virtual object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
public virtual object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
{
|
{
|
||||||
switch (value)
|
return value is bool booleanValue && booleanValue ? True : False;
|
||||||
{
|
|
||||||
case null:
|
|
||||||
return False;
|
|
||||||
case bool booleanValue:
|
|
||||||
return booleanValue ? True : False;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(value is int intValue))
|
|
||||||
{
|
|
||||||
return True;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (parameter)
|
|
||||||
{
|
|
||||||
case null:
|
|
||||||
return intValue == 0 ? False : True;
|
|
||||||
case int param:
|
|
||||||
return intValue > param ? True : False;
|
|
||||||
default:
|
|
||||||
//Because object not null
|
|
||||||
return True;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public virtual object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
public virtual object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
|||||||
@@ -9,10 +9,4 @@ namespace WandEnhancer.Converters
|
|||||||
{ }
|
{ }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class ToVisibilityInvertedConverter : BaseBooleanConverter<Visibility>
|
|
||||||
{
|
|
||||||
public ToVisibilityInvertedConverter() :
|
|
||||||
base(Visibility.Collapsed, Visibility.Visible)
|
|
||||||
{ }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using AsarSharp.Utils;
|
||||||
|
|
||||||
|
namespace WandEnhancer.Core
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Electron's fuse wire: a 32-byte sentinel followed by [version][fuseCount][state per fuse].
|
||||||
|
/// Clearing the ASAR integrity fuse is what lets a patched app.asar load at all - the archive
|
||||||
|
/// no longer hashes to the value baked into the executable, and a process that opens it with
|
||||||
|
/// the fuse still set exits with -36861.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ElectronFuse
|
||||||
|
{
|
||||||
|
private const int AsarIntegrityIndex = 4;
|
||||||
|
private const byte StateRemoved = (byte)'r';
|
||||||
|
private const byte SupportedWireVersion = 1;
|
||||||
|
private const int MinFuseCount = 5;
|
||||||
|
private const int SentinelLength = 32;
|
||||||
|
private const int WireHeaderLength = 2;
|
||||||
|
private const int StateFromSentinel = SentinelLength + WireHeaderLength + AsarIntegrityIndex;
|
||||||
|
private const int MatchLength = StateFromSentinel + 1;
|
||||||
|
private const int ChunkSize = 1 << 20;
|
||||||
|
|
||||||
|
private static readonly byte[] Sentinel =
|
||||||
|
Encoding.ASCII.GetBytes("dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Offset of the fuse state from the image base, read once from the file on disk. Every
|
||||||
|
/// process started from that file maps it at the same offset, so the scan is not repeated
|
||||||
|
/// per process - reading 200+ MB out of each one would not fit in the second we have
|
||||||
|
/// before Electron opens the archive.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>-1 when the file carries no fuse block.</returns>
|
||||||
|
public static long FindStateRva(string exePath)
|
||||||
|
{
|
||||||
|
// Share everything: Wand is normally already running when this is asked again.
|
||||||
|
using (var stream = new FileStream(exePath, FileMode.Open, FileAccess.Read,
|
||||||
|
FileShare.ReadWrite | FileShare.Delete, ChunkSize, FileOptions.SequentialScan))
|
||||||
|
{
|
||||||
|
long offset = FindStateOffset(stream);
|
||||||
|
return offset < 0 ? -1 : ToRva(stream, offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears the fuse in a running process. False when the image does not look like the one
|
||||||
|
/// <see cref="FindStateRva"/> measured, or the write was refused.
|
||||||
|
/// </summary>
|
||||||
|
public static bool ClearIn(IntPtr process, long stateRva)
|
||||||
|
{
|
||||||
|
IntPtr imageBase = GetImageBase(process);
|
||||||
|
if (imageBase == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var block = new byte[MatchLength];
|
||||||
|
var start = new IntPtr(imageBase.ToInt64() + stateRva - StateFromSentinel);
|
||||||
|
|
||||||
|
if (!ReadProcessMemory(process, start, block, block.Length, out int read) || read != block.Length)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sentinel is checked again inside the process: a Wand update swaps the
|
||||||
|
// executable under a running launcher, and a stale offset would otherwise put a
|
||||||
|
// byte into unrelated memory.
|
||||||
|
if (!MatchesSentinel(block, 0) ||
|
||||||
|
block[SentinelLength] != SupportedWireVersion ||
|
||||||
|
block[SentinelLength + 1] < MinFuseCount)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block[StateFromSentinel] == StateRemoved)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = new IntPtr(imageBase.ToInt64() + stateRva);
|
||||||
|
if (!VirtualProtectEx(process, target, (UIntPtr)1, PAGE_READWRITE, out uint previous))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool written = WriteProcessMemory(process, target, new[] { StateRemoved }, 1, out _);
|
||||||
|
VirtualProtectEx(process, target, (UIntPtr)1, previous, out _);
|
||||||
|
return written;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read out of the PEB rather than the module list: a process that has only just been
|
||||||
|
/// created - or one still suspended, which is where the main process is patched - has no
|
||||||
|
/// module list yet, but the kernel fills in the image base before the first instruction.
|
||||||
|
/// </summary>
|
||||||
|
private static IntPtr GetImageBase(IntPtr process)
|
||||||
|
{
|
||||||
|
var info = new PROCESS_BASIC_INFORMATION();
|
||||||
|
if (NtQueryInformationProcess(process, ProcessBasicInformation, ref info,
|
||||||
|
Marshal.SizeOf(info), out _) != 0 || info.PebBaseAddress == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
var buffer = new byte[IntPtr.Size];
|
||||||
|
var address = new IntPtr(info.PebBaseAddress.ToInt64() + PebImageBaseOffset);
|
||||||
|
return ReadProcessMemory(process, address, buffer, buffer.Length, out int read) && read == buffer.Length
|
||||||
|
? new IntPtr(BitConverter.ToInt64(buffer, 0))
|
||||||
|
: IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long FindStateOffset(Stream stream)
|
||||||
|
{
|
||||||
|
var buffer = new byte[ChunkSize + MatchLength];
|
||||||
|
long bufferStart = 0;
|
||||||
|
int filled = 0;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
filled += stream.ReadFull(buffer, filled, buffer.Length - filled);
|
||||||
|
if (filled < MatchLength)
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int limit = filled - MatchLength;
|
||||||
|
// Byte by byte: the linker is free to place the sentinel at any alignment.
|
||||||
|
for (int i = 0; i <= limit; i++)
|
||||||
|
{
|
||||||
|
if (buffer[i] != Sentinel[0] || !MatchesSentinel(buffer, i))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int wire = i + SentinelLength;
|
||||||
|
if (buffer[wire] != SupportedWireVersion || buffer[wire + 1] < MinFuseCount)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bufferStart + i + StateFromSentinel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A short fill is end of file, and a tail shorter than a match cannot hold one.
|
||||||
|
if (filled < buffer.Length)
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Buffer.BlockCopy(buffer, limit, buffer, 0, MatchLength);
|
||||||
|
bufferStart += limit;
|
||||||
|
filled = MatchLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Maps a file offset through the section table to an offset from the image base.</summary>
|
||||||
|
private static long ToRva(Stream stream, long fileOffset)
|
||||||
|
{
|
||||||
|
var head = new byte[4096];
|
||||||
|
stream.Position = 0;
|
||||||
|
if (stream.ReadFull(head, 0, head.Length) < head.Length)
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int peHeader = BitConverter.ToInt32(head, 0x3C);
|
||||||
|
int sectionCount = BitConverter.ToUInt16(head, peHeader + 6);
|
||||||
|
int sectionTable = peHeader + 24 + BitConverter.ToUInt16(head, peHeader + 20);
|
||||||
|
if (sectionTable + sectionCount * SectionEntrySize > head.Length)
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < sectionCount; i++)
|
||||||
|
{
|
||||||
|
int entry = sectionTable + i * SectionEntrySize;
|
||||||
|
long virtualAddress = BitConverter.ToUInt32(head, entry + 12);
|
||||||
|
long rawSize = BitConverter.ToUInt32(head, entry + 16);
|
||||||
|
long rawStart = BitConverter.ToUInt32(head, entry + 20);
|
||||||
|
|
||||||
|
if (fileOffset >= rawStart && fileOffset < rawStart + rawSize)
|
||||||
|
{
|
||||||
|
return virtualAddress + (fileOffset - rawStart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesSentinel(byte[] buffer, int offset)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < SentinelLength; i++)
|
||||||
|
{
|
||||||
|
if (buffer[offset + i] != Sentinel[i])
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#region P/Invoke
|
||||||
|
|
||||||
|
private const int SectionEntrySize = 40;
|
||||||
|
private const uint PAGE_READWRITE = 0x04;
|
||||||
|
private const int ProcessBasicInformation = 0;
|
||||||
|
private const int PebImageBaseOffset = 0x10;
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct PROCESS_BASIC_INFORMATION
|
||||||
|
{
|
||||||
|
public IntPtr ExitStatus;
|
||||||
|
public IntPtr PebBaseAddress;
|
||||||
|
public IntPtr AffinityMask;
|
||||||
|
public IntPtr BasePriority;
|
||||||
|
public IntPtr UniqueProcessId;
|
||||||
|
public IntPtr InheritedFromUniqueProcessId;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("ntdll.dll")]
|
||||||
|
private static extern int NtQueryInformationProcess(
|
||||||
|
IntPtr hProcess, int processInformationClass, ref PROCESS_BASIC_INFORMATION processInformation,
|
||||||
|
int processInformationLength, out int returnLength);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
private static extern bool ReadProcessMemory(
|
||||||
|
IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, int dwSize, out int lpNumberOfBytesRead);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
private static extern bool WriteProcessMemory(
|
||||||
|
IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, int dwSize, out int lpNumberOfBytesWritten);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
private static extern bool VirtualProtectEx(
|
||||||
|
IntPtr hProcess, IntPtr lpAddress, UIntPtr dwSize, uint flNewProtect, out uint lpflOldProtect);
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
}
|
||||||
+245
-162
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -18,6 +18,8 @@ namespace WandEnhancer.Core
|
|||||||
private const string AppAsarUnpackedDirectoryName = "app.asar.unpacked";
|
private const string AppAsarUnpackedDirectoryName = "app.asar.unpacked";
|
||||||
private const string AppAsarBackupFileName = "app.asar.backup";
|
private const string AppAsarBackupFileName = "app.asar.backup";
|
||||||
private const string AppAsarUnpackedBackupDirectoryName = "app.asar.unpacked.backup";
|
private const string AppAsarUnpackedBackupDirectoryName = "app.asar.unpacked.backup";
|
||||||
|
private const string ProxyDllFileName = "version.dll";
|
||||||
|
private const string StubBackupSuffix = ".stub";
|
||||||
private const string WebPanelDirectoryName = "web-panel";
|
private const string WebPanelDirectoryName = "web-panel";
|
||||||
private const string WebPanelDistDirectoryName = "dist";
|
private const string WebPanelDistDirectoryName = "dist";
|
||||||
private const string LocalCustomScriptsDirectoryName = "renderer-scripts";
|
private const string LocalCustomScriptsDirectoryName = "renderer-scripts";
|
||||||
@@ -28,7 +30,6 @@ namespace WandEnhancer.Core
|
|||||||
private const string AppBundleFilePrefix = "app-";
|
private const string AppBundleFilePrefix = "app-";
|
||||||
private const string AppBundleFileSuffix = ".bundle.js";
|
private const string AppBundleFileSuffix = ".bundle.js";
|
||||||
private const string IndexBundleFileName = "index.js";
|
private const string IndexBundleFileName = "index.js";
|
||||||
private const string JavaScriptFileExtension = ".js";
|
|
||||||
private const string JavaScriptFileSearchPattern = "*.js";
|
private const string JavaScriptFileSearchPattern = "*.js";
|
||||||
private const string DuplicateScriptSuffix = ".custom";
|
private const string DuplicateScriptSuffix = ".custom";
|
||||||
private const int FirstDuplicateScriptIndex = 1;
|
private const int FirstDuplicateScriptIndex = 1;
|
||||||
@@ -36,79 +37,46 @@ namespace WandEnhancer.Core
|
|||||||
private readonly WeModConfig _weModConfig;
|
private readonly WeModConfig _weModConfig;
|
||||||
private readonly Action<string, ELogType> _logger;
|
private readonly Action<string, ELogType> _logger;
|
||||||
private readonly PatchConfig _config;
|
private readonly PatchConfig _config;
|
||||||
|
private readonly JavaScriptPatchApplier _jsPatchApplier;
|
||||||
private readonly string _asarPath;
|
private readonly string _asarPath;
|
||||||
private readonly string _backupPath;
|
private readonly string _backupPath;
|
||||||
private readonly string _unpackedPath;
|
private readonly string _unpackedPath;
|
||||||
private readonly string _unpackedBackupPath;
|
private readonly string _unpackedBackupPath;
|
||||||
|
|
||||||
|
/// <summary>For <see cref="Restore"/>, which needs the install paths but no patch selection.</summary>
|
||||||
|
public Enhancer(WeModConfig weModConfig, Action<string, ELogType> logger)
|
||||||
|
: this(weModConfig, logger, null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public Enhancer(WeModConfig weModConfig, Action<string, ELogType> logger, PatchConfig config)
|
public Enhancer(WeModConfig weModConfig, Action<string, ELogType> logger, PatchConfig config)
|
||||||
{
|
{
|
||||||
_weModConfig = weModConfig;
|
_weModConfig = weModConfig;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_config = config;
|
_config = config;
|
||||||
|
_jsPatchApplier = new JavaScriptPatchApplier(logger);
|
||||||
|
|
||||||
_asarPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarFileName);
|
_asarPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarFileName);
|
||||||
_unpackedPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarUnpackedDirectoryName);
|
_unpackedPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarUnpackedDirectoryName);
|
||||||
_backupPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarBackupFileName);
|
_backupPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarBackupFileName);
|
||||||
_unpackedBackupPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarUnpackedBackupDirectoryName);
|
_unpackedBackupPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarUnpackedBackupDirectoryName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string ApplyJsPatch(string fileName, string js, EnhancerConfig.PatchEntry patch, EPatchType patchType, out bool patchApplied)
|
/// <summary>
|
||||||
|
/// Both halves of the backup must exist. Accepting either one on its own reported a
|
||||||
|
/// half-written backup as patched, which blocked patching while <see cref="Restore"/>
|
||||||
|
/// refused to run - leaving the user with no way forward.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsPatched(string rootDirectory)
|
||||||
{
|
{
|
||||||
patchApplied = false;
|
var resources = Path.Combine(rootDirectory, ResourcesDirectoryName);
|
||||||
|
return File.Exists(Path.Combine(resources, AppAsarBackupFileName))
|
||||||
if (patch.Applied)
|
&& Directory.Exists(Path.Combine(resources, AppAsarUnpackedBackupDirectoryName));
|
||||||
{
|
|
||||||
return js;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!CanSearchPatchInFile(fileName, patch) || !ContainsSearchHint(js, patch.SearchHints))
|
|
||||||
{
|
|
||||||
return js;
|
|
||||||
}
|
|
||||||
|
|
||||||
var match = patch.Target.Match(js);
|
|
||||||
if (!match.Success)
|
|
||||||
{
|
|
||||||
return js;
|
|
||||||
}
|
|
||||||
|
|
||||||
var prefix = $"[ENHANCER] [{patchType} -> {patch.Name}]";
|
|
||||||
|
|
||||||
if(patch.SingleMatch && match.NextMatch().Success)
|
|
||||||
{
|
|
||||||
throw new Exception(
|
|
||||||
$"{prefix} Patch failed. Multiple target functions found. Looks like the version is not supported");
|
|
||||||
}
|
|
||||||
|
|
||||||
string patchSource = patch.Patch;
|
|
||||||
|
|
||||||
if (patch.Resolver != null)
|
|
||||||
{
|
|
||||||
string resolvedField = patch.Resolver.Handler(match.Value);
|
|
||||||
if (string.IsNullOrEmpty(resolvedField))
|
|
||||||
{
|
|
||||||
throw new Exception($"{prefix} Resolver failed to find field name");
|
|
||||||
}
|
|
||||||
|
|
||||||
patchSource = patchSource.Replace(patch.Resolver.Placeholder, resolvedField);
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger($"{prefix} Found target function in: " + Path.GetFileName(fileName), ELogType.Info);
|
|
||||||
|
|
||||||
string newJs = patch.SingleMatch
|
|
||||||
? patch.Target.Replace(js, patchSource, 1)
|
|
||||||
: patch.Target.Replace(js, patchSource);
|
|
||||||
_logger($"{prefix} Patch applied", ELogType.Success);
|
|
||||||
patch.Applied = true;
|
|
||||||
patchApplied = true;
|
|
||||||
|
|
||||||
return newJs;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PatchAsar()
|
private void PatchAsar()
|
||||||
{
|
{
|
||||||
var items = Directory.EnumerateFiles(_unpackedPath, $"*{JavaScriptFileExtension}", SearchOption.TopDirectoryOnly)
|
var items = Directory.EnumerateFiles(_unpackedPath, JavaScriptFileSearchPattern, SearchOption.TopDirectoryOnly)
|
||||||
.Where(IsCandidateBundleFile)
|
.Where(IsCandidateBundleFile)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -116,7 +84,7 @@ namespace WandEnhancer.Core
|
|||||||
{
|
{
|
||||||
throw new Exception("[ENHANCER] No app bundle found");
|
throw new Exception("[ENHANCER] No app bundle found");
|
||||||
}
|
}
|
||||||
|
|
||||||
var remainingPatches = new HashSet<EPatchType>(_config.PatchTypes);
|
var remainingPatches = new HashSet<EPatchType>(_config.PatchTypes);
|
||||||
var enhancerConfig = EnhancerConfig.GetInstance();
|
var enhancerConfig = EnhancerConfig.GetInstance();
|
||||||
|
|
||||||
@@ -131,20 +99,22 @@ namespace WandEnhancer.Core
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
string data = File.ReadAllText(item);
|
string data = File.ReadAllText(item);
|
||||||
bool fileChanged = false;
|
bool fileChanged = false;
|
||||||
|
|
||||||
foreach (var entry in remainingPatches.ToList())
|
foreach (var entry in remainingPatches.ToList())
|
||||||
{
|
{
|
||||||
var entries = enhancerConfig[entry];
|
var entries = enhancerConfig[entry];
|
||||||
foreach (var patchEntry in entries)
|
foreach (var patchEntry in entries)
|
||||||
{
|
{
|
||||||
bool patchApplied;
|
bool patchApplied;
|
||||||
data = ApplyJsPatch(item, data, patchEntry, entry, out patchApplied);
|
data = _jsPatchApplier.Apply(item, data, patchEntry, entry, out patchApplied);
|
||||||
fileChanged = fileChanged || patchApplied;
|
fileChanged = fileChanged || patchApplied;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional patches stay in the scan until every file has been checked, because
|
||||||
|
// their capability may still show up in a bundle we have not read yet.
|
||||||
if (entries.All(x => x.Applied))
|
if (entries.All(x => x.Applied))
|
||||||
{
|
{
|
||||||
remainingPatches.Remove(entry);
|
remainingPatches.Remove(entry);
|
||||||
@@ -156,11 +126,27 @@ namespace WandEnhancer.Core
|
|||||||
File.WriteAllText(item, data);
|
File.WriteAllText(item, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if(remainingPatches.Count > 0)
|
ReportUnappliedPatches(remainingPatches, enhancerConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReportUnappliedPatches(IEnumerable<EPatchType> remainingPatches, Dictionary<EPatchType, EnhancerConfig.PatchEntry[]> enhancerConfig)
|
||||||
|
{
|
||||||
|
var unapplied = remainingPatches
|
||||||
|
.SelectMany(patchType => enhancerConfig[patchType]
|
||||||
|
.Where(patch => !patch.Applied)
|
||||||
|
.Select(patch => new { Label = JavaScriptPatchApplier.FormatLabel(patchType, patch), Patch = patch }))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var skipped in unapplied.Where(entry => entry.Patch.IsResolved))
|
||||||
{
|
{
|
||||||
var failedPatches = string.Join(", ", remainingPatches.Select(p => p.ToString()));
|
_logger($"[ENHANCER] [{skipped.Label}] Capability not present, skipping", ELogType.Info);
|
||||||
throw new Exception($"[ENHANCER] Failed to apply patches: {failedPatches}. The version may not be supported.");
|
}
|
||||||
|
|
||||||
|
var failed = unapplied.Where(entry => !entry.Patch.IsResolved).Select(entry => entry.Label).ToList();
|
||||||
|
if (failed.Count > 0)
|
||||||
|
{
|
||||||
|
throw new Exception($"[ENHANCER] Failed to apply patches: {string.Join(", ", failed)}. The version may not be supported.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,44 +160,9 @@ namespace WandEnhancer.Core
|
|||||||
|
|
||||||
private static bool CouldFileContainRemainingPatch(string filePath, IEnumerable<EPatchType> remainingPatches, Dictionary<EPatchType, EnhancerConfig.PatchEntry[]> enhancerConfig)
|
private static bool CouldFileContainRemainingPatch(string filePath, IEnumerable<EPatchType> remainingPatches, Dictionary<EPatchType, EnhancerConfig.PatchEntry[]> enhancerConfig)
|
||||||
{
|
{
|
||||||
foreach (var patchType in remainingPatches)
|
return remainingPatches
|
||||||
{
|
.SelectMany(patchType => enhancerConfig[patchType])
|
||||||
foreach (var patchEntry in enhancerConfig[patchType])
|
.Any(patchEntry => !patchEntry.Applied && JavaScriptPatchApplier.CanSearchFile(filePath, patchEntry));
|
||||||
{
|
|
||||||
if (patchEntry.Applied)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (CanSearchPatchInFile(filePath, patchEntry))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool CanSearchPatchInFile(string filePath, EnhancerConfig.PatchEntry patch)
|
|
||||||
{
|
|
||||||
if (patch.CandidateFileNames == null || patch.CandidateFileNames.Length == 0)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
string fileName = Path.GetFileName(filePath);
|
|
||||||
return patch.CandidateFileNames.Any(candidate => fileName.Equals(candidate, StringComparison.OrdinalIgnoreCase));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool ContainsSearchHint(string source, string[] searchHints)
|
|
||||||
{
|
|
||||||
if (searchHints == null || searchHints.Length == 0)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return searchHints.Any(searchHint => source.IndexOf(searchHint, StringComparison.Ordinal) >= 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FindWorkspacePath(params string[] segments)
|
private static string FindWorkspacePath(params string[] segments)
|
||||||
@@ -231,25 +182,6 @@ namespace WandEnhancer.Core
|
|||||||
throw new FileNotFoundException($"Required workspace artifact not found: {Path.Combine(segments)}");
|
throw new FileNotFoundException($"Required workspace artifact not found: {Path.Combine(segments)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void CopyDirectory(string sourceDir, string destinationDir)
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(destinationDir);
|
|
||||||
|
|
||||||
foreach (var directory in Directory.GetDirectories(sourceDir, "*", SearchOption.AllDirectories))
|
|
||||||
{
|
|
||||||
var relativePath = directory.Substring(sourceDir.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
|
||||||
Directory.CreateDirectory(Path.Combine(destinationDir, relativePath));
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var file in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories))
|
|
||||||
{
|
|
||||||
var relativePath = file.Substring(sourceDir.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
|
||||||
var destinationPath = Path.Combine(destinationDir, relativePath);
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath) ?? destinationDir);
|
|
||||||
File.Copy(file, destinationPath, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int CopyJavaScriptFiles(string sourceDir, string destinationDir)
|
private static int CopyJavaScriptFiles(string sourceDir, string destinationDir)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(sourceDir) || !Directory.Exists(sourceDir))
|
if (string.IsNullOrEmpty(sourceDir) || !Directory.Exists(sourceDir))
|
||||||
@@ -257,16 +189,9 @@ namespace WandEnhancer.Core
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
Directory.CreateDirectory(destinationDir);
|
return CopySelectedJavaScriptFiles(
|
||||||
|
Directory.GetFiles(sourceDir, JavaScriptFileSearchPattern, SearchOption.TopDirectoryOnly),
|
||||||
int copied = 0;
|
destinationDir);
|
||||||
foreach (var file in Directory.GetFiles(sourceDir, JavaScriptFileSearchPattern, SearchOption.TopDirectoryOnly))
|
|
||||||
{
|
|
||||||
File.Copy(file, GetAvailableScriptPath(destinationDir, Path.GetFileName(file)));
|
|
||||||
copied++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return copied;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetAvailableScriptPath(string destinationDir, string fileName)
|
private static string GetAvailableScriptPath(string destinationDir, string fileName)
|
||||||
@@ -350,20 +275,15 @@ namespace WandEnhancer.Core
|
|||||||
Directory.CreateDirectory(destinationDir);
|
Directory.CreateDirectory(destinationDir);
|
||||||
|
|
||||||
int copied = 0;
|
int copied = 0;
|
||||||
foreach (var file in files.Where(IsJavaScriptFile).Distinct(StringComparer.OrdinalIgnoreCase))
|
foreach (var file in files.Where(WeModInstalls.IsJavaScriptFile).Distinct(StringComparer.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
File.Copy(file, GetAvailableScriptPath(destinationDir, Path.GetFileName(file)));
|
AsarSharp.Utils.Extensions.CopyOver(file, GetAvailableScriptPath(destinationDir, Path.GetFileName(file)));
|
||||||
copied++;
|
copied++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return copied;
|
return copied;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsJavaScriptFile(string file)
|
|
||||||
{
|
|
||||||
return File.Exists(file) && string.Equals(Path.GetExtension(file), JavaScriptFileExtension, StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void InjectRemotePanelFiles()
|
private void InjectRemotePanelFiles()
|
||||||
{
|
{
|
||||||
if (!_config.PatchTypes.Contains(EPatchType.RemoteWebPanelPreview))
|
if (!_config.PatchTypes.Contains(EPatchType.RemoteWebPanelPreview))
|
||||||
@@ -383,7 +303,7 @@ namespace WandEnhancer.Core
|
|||||||
|
|
||||||
if (CopyEmbeddedDirectory(EmbeddedRemotePanelDistPrefix, targetRoot) == 0)
|
if (CopyEmbeddedDirectory(EmbeddedRemotePanelDistPrefix, targetRoot) == 0)
|
||||||
{
|
{
|
||||||
CopyDirectory(FindWorkspacePath(WebPanelDirectoryName, WebPanelDistDirectoryName), targetRoot);
|
AsarSharp.Utils.Extensions.CopyDirectory(FindWorkspacePath(WebPanelDirectoryName, WebPanelDistDirectoryName), targetRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!File.Exists(targetBridgePath))
|
if (!File.Exists(targetBridgePath))
|
||||||
@@ -405,40 +325,94 @@ namespace WandEnhancer.Core
|
|||||||
_logger($"[ENHANCER] Injected remote panel assets and renderer scripts into app.asar (default: {defaultScriptCount}, selected: {selectedScriptCount}, local: {localScriptCount})", ELogType.Info);
|
_logger($"[ENHANCER] Injected remote panel assets and renderer scripts into app.asar (default: {defaultScriptCount}, selected: {selectedScriptCount}, local: {localScriptCount})", ELogType.Info);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AttachProxyDll()
|
private string SquirrelRoot
|
||||||
{
|
{
|
||||||
var assembly = Assembly.GetExecutingAssembly();
|
get
|
||||||
var dll = assembly.GetManifestResourceStream(Constants.ProxyDllResouceName);
|
|
||||||
if (dll == null)
|
|
||||||
{
|
{
|
||||||
throw new Exception("[ENHANCER] Proxy DLL resource not found");
|
string root = Directory.GetParent(_weModConfig.RootDirectory)?.FullName;
|
||||||
|
if (string.IsNullOrEmpty(root))
|
||||||
|
{
|
||||||
|
throw new Exception("[ENHANCER] Cannot determine Squirrel root directory");
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
}
|
}
|
||||||
var destPath = Path.Combine(_weModConfig.RootDirectory, "version.dll");
|
}
|
||||||
using (var fileStream = File.Create(destPath))
|
|
||||||
|
private void DeployLauncher()
|
||||||
|
{
|
||||||
|
string stubPath = Path.Combine(SquirrelRoot, _weModConfig.ExecutableName);
|
||||||
|
string stubBackup = stubPath + StubBackupSuffix;
|
||||||
|
string self = Assembly.GetExecutingAssembly().Location;
|
||||||
|
|
||||||
|
// Auto-patch runs from inside the deployed launcher: it cannot overwrite its own
|
||||||
|
// running image, and does not need to - it is already in place.
|
||||||
|
if (string.Equals(self, stubPath, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
dll.CopyTo(fileStream);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File.Exists(stubPath) && !File.Exists(stubBackup))
|
||||||
|
{
|
||||||
|
AsarSharp.Utils.Extensions.CopyOver(stubPath, stubBackup);
|
||||||
|
}
|
||||||
|
|
||||||
|
AsarSharp.Utils.Extensions.CopyOver(self, stubPath);
|
||||||
|
_logger("[ENHANCER] Launcher deployed to root directory", ELogType.Info);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveAutoPatchConfig()
|
||||||
|
{
|
||||||
|
string path = Path.Combine(SquirrelRoot, Constants.AutoPatchConfigFileName);
|
||||||
|
File.WriteAllText(path, Newtonsoft.Json.JsonConvert.SerializeObject(_config, Newtonsoft.Json.Formatting.Indented));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DeleteAutoPatchConfig()
|
||||||
|
{
|
||||||
|
string path = Path.Combine(SquirrelRoot, Constants.AutoPatchConfigFileName);
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the patch selection saved next to the launcher, or null when absent or unreadable.</summary>
|
||||||
|
public static PatchConfig LoadAutoPatchConfig(string launcherDirectory)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string path = Path.Combine(launcherDirectory, Constants.AutoPatchConfigFileName);
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Newtonsoft.Json.JsonConvert.DeserializeObject<PatchConfig>(File.ReadAllText(path));
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is IOException || e is Newtonsoft.Json.JsonException || e is UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
_logger("[ENHANCER] Proxy DLL attached", ELogType.Info);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Patch()
|
public void Patch()
|
||||||
{
|
{
|
||||||
Common.TryKillProcess(_weModConfig.BrandName);
|
ProcessTerminator.TryKillProcess(_weModConfig.BrandName);
|
||||||
if (!File.Exists(_backupPath))
|
if (!File.Exists(_backupPath))
|
||||||
{
|
{
|
||||||
_logger("[ENHANCER] Creating backup...", ELogType.Info);
|
_logger("[ENHANCER] Creating backup...", ELogType.Info);
|
||||||
File.Copy(_asarPath, _backupPath);
|
AsarSharp.Utils.Extensions.CopyOver(_asarPath, _backupPath);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger("[ENHANCER] Backup found, restoring pristine app.asar before patching...", ELogType.Info);
|
_logger("[ENHANCER] Backup found, restoring pristine app.asar before patching...", ELogType.Info);
|
||||||
File.Copy(_backupPath, _asarPath, true);
|
AsarSharp.Utils.Extensions.CopyOver(_backupPath, _asarPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Directory.Exists(_unpackedBackupPath) && Directory.Exists(_unpackedPath))
|
if (!Directory.Exists(_unpackedBackupPath) && Directory.Exists(_unpackedPath))
|
||||||
{
|
{
|
||||||
_logger("[ENHANCER] Creating backup of app.asar.unpacked...", ELogType.Info);
|
_logger("[ENHANCER] Creating backup of app.asar.unpacked...", ELogType.Info);
|
||||||
CopyDirectory(_unpackedPath, _unpackedBackupPath);
|
AsarSharp.Utils.Extensions.CopyDirectory(_unpackedPath, _unpackedBackupPath);
|
||||||
}
|
}
|
||||||
else if (Directory.Exists(_unpackedBackupPath))
|
else if (Directory.Exists(_unpackedBackupPath))
|
||||||
{
|
{
|
||||||
@@ -448,18 +422,51 @@ namespace WandEnhancer.Core
|
|||||||
Directory.Delete(_unpackedPath, true);
|
Directory.Delete(_unpackedPath, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
CopyDirectory(_unpackedBackupPath, _unpackedPath);
|
AsarSharp.Utils.Extensions.CopyDirectory(_unpackedBackupPath, _unpackedPath);
|
||||||
}
|
}
|
||||||
else if (!Directory.Exists(_unpackedPath))
|
else if (!Directory.Exists(_unpackedPath))
|
||||||
{
|
{
|
||||||
throw new Exception("[ENHANCER] app.asar.unpacked is missing and no backup exists. Restore the original Wand installation files or reinstall Wand, then patch again.");
|
throw new Exception("[ENHANCER] app.asar.unpacked is missing and no backup exists. Restore the original Wand installation files or reinstall Wand, then patch again.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if(!File.Exists(_asarPath))
|
if (!File.Exists(_asarPath))
|
||||||
{
|
{
|
||||||
throw new Exception("app.asar not found");
|
throw new Exception("app.asar not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Everything past this point mutates the installation. A half-applied patch does
|
||||||
|
// not boot - the fuse is only cleared by the deployed launcher, so a patched
|
||||||
|
// app.asar without it dies with -36861 - so failure has to put the files back.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ExtractSources();
|
||||||
|
PatchAsar();
|
||||||
|
InjectRemotePanelFiles();
|
||||||
|
PackSources();
|
||||||
|
DeployLauncher();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
RollbackQuietly();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
// enhancer.json only exists to drive auto-patch. Without it the launcher still
|
||||||
|
// runs Wand (fuse patch only), so drop it when the user opts out.
|
||||||
|
if (_config.AutoApplyAfterUpdate)
|
||||||
|
{
|
||||||
|
SaveAutoPatchConfig();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DeleteAutoPatchConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger("[ENHANCER] Done!", ELogType.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ExtractSources()
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger("[ENHANCER] Extracting app.asar...", ELogType.Info);
|
_logger("[ENHANCER] Extracting app.asar...", ELogType.Info);
|
||||||
@@ -467,12 +474,12 @@ namespace WandEnhancer.Core
|
|||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
throw new Exception($"[ENHANCER] Failed to unpack app.asar: {e.Message}");
|
throw new Exception($"[ENHANCER] Failed to unpack app.asar: {e.Message}", e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
PatchAsar();
|
|
||||||
InjectRemotePanelFiles();
|
|
||||||
|
|
||||||
|
private void PackSources()
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
new AsarCreator(_unpackedPath, _asarPath, new CreateOptions
|
new AsarCreator(_unpackedPath, _asarPath, new CreateOptions
|
||||||
@@ -482,12 +489,88 @@ namespace WandEnhancer.Core
|
|||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
throw new Exception($"[ENHANCER] Failed to pack app.asar: {e.Message}");
|
throw new Exception($"[ENHANCER] Failed to pack app.asar: {e.Message}", e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
AttachProxyDll();
|
|
||||||
|
/// <summary>
|
||||||
_logger("[ENHANCER] Done!", ELogType.Success);
|
/// Best-effort restore after a failed patch. Never throws: the caller is already
|
||||||
|
/// propagating the real failure and it must not be replaced by a cleanup error.
|
||||||
|
/// </summary>
|
||||||
|
private void RollbackQuietly()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(_backupPath))
|
||||||
|
{
|
||||||
|
AsarSharp.Utils.Extensions.CopyOver(_backupPath, _asarPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Directory.Exists(_unpackedBackupPath))
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_unpackedPath))
|
||||||
|
{
|
||||||
|
Directory.Delete(_unpackedPath, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
AsarSharp.Utils.Extensions.CopyDirectory(_unpackedBackupPath, _unpackedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger("[ENHANCER] Patch failed - the original Wand files were restored.", ELogType.Warn);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
_logger($"[ENHANCER] Patch failed and the rollback did not finish: {e.Message}. " +
|
||||||
|
"Use Restore before launching Wand.", ELogType.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Restore()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_backupPath) || !Directory.Exists(_unpackedBackupPath))
|
||||||
|
{
|
||||||
|
throw new Exception("[ENHANCER] Backup is incomplete. Restore the original Wand installation files or reinstall Wand.");
|
||||||
|
}
|
||||||
|
|
||||||
|
ProcessTerminator.TryKillProcess(_weModConfig.BrandName);
|
||||||
|
AsarSharp.Utils.Extensions.CopyOver(_backupPath, _asarPath);
|
||||||
|
|
||||||
|
if (Directory.Exists(_unpackedPath))
|
||||||
|
{
|
||||||
|
Directory.Delete(_unpackedPath, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
AsarSharp.Utils.Extensions.CopyDirectory(_unpackedBackupPath, _unpackedPath);
|
||||||
|
|
||||||
|
// Clean up legacy proxy DLL
|
||||||
|
var proxyDllPath = Path.Combine(_weModConfig.RootDirectory, ProxyDllFileName);
|
||||||
|
if (File.Exists(proxyDllPath))
|
||||||
|
{
|
||||||
|
File.Delete(proxyDllPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore original Squirrel stub and drop the auto-patch config
|
||||||
|
string squirrelRoot = SquirrelRoot;
|
||||||
|
string stubPath = Path.Combine(squirrelRoot, _weModConfig.ExecutableName);
|
||||||
|
string stubBackup = stubPath + StubBackupSuffix;
|
||||||
|
if (File.Exists(stubBackup))
|
||||||
|
{
|
||||||
|
AsarSharp.Utils.Extensions.CopyOver(stubBackup, stubPath);
|
||||||
|
File.Delete(stubBackup);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var leftover in new[] { Constants.AutoPatchConfigFileName, LauncherLog.FileName })
|
||||||
|
{
|
||||||
|
string path = Path.Combine(squirrelRoot, leftover);
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Delete(_backupPath);
|
||||||
|
Directory.Delete(_unpackedBackupPath, true);
|
||||||
|
_logger("[ENHANCER] Backup restored successfully.", ELogType.Success);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,44 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using WandEnhancer.Core.Js;
|
||||||
using WandEnhancer.Models;
|
using WandEnhancer.Models;
|
||||||
|
|
||||||
namespace WandEnhancer.Core
|
namespace WandEnhancer.Core
|
||||||
{
|
{
|
||||||
public static class EnhancerConfig
|
/// <summary>
|
||||||
|
/// Patch definitions. Each entry anchors on something Wand does not rename between builds -
|
||||||
|
/// an API endpoint, an IPC channel name or a public method name - and then navigates the
|
||||||
|
/// delimiter structure to the edit site. Minified identifiers are read out of the located
|
||||||
|
/// region rather than baked into a pattern, so a rebuild does not invalidate a patch.
|
||||||
|
/// </summary>
|
||||||
|
internal static class EnhancerConfig
|
||||||
{
|
{
|
||||||
private const int RemoteWebPanelDefaultPort = 3223;
|
/// <summary>Locates the edits a patch must make, or null when the anchor is absent from this file.</summary>
|
||||||
private static readonly string RemoteWebPanelFallbackUrl = $"http://localhost:{RemoteWebPanelDefaultPort}/remote/";
|
public delegate JsEdit[] PatchLocator(JsCursor js);
|
||||||
|
|
||||||
public class ResolveContext
|
public sealed class PatchEntry
|
||||||
{
|
{
|
||||||
public string Placeholder { get; set; }
|
|
||||||
public Func<string, string> Handler { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class PatchEntry
|
|
||||||
{
|
|
||||||
public Regex Target { get; set; }
|
|
||||||
public string Patch { get; set; }
|
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
public bool Applied { get; set; }
|
public PatchLocator Locate { get; set; }
|
||||||
public bool SingleMatch { get; set; } = true;
|
|
||||||
public string[] CandidateFileNames { get; set; }
|
public string[] CandidateFileNames { get; set; }
|
||||||
public string[] SearchHints { get; set; }
|
public string[] SearchHints { get; set; }
|
||||||
public ResolveContext Resolver { get; set; }
|
|
||||||
|
/// <summary>Marks the patch optional: builds without these strings lack the feature entirely.</summary>
|
||||||
|
public string[] CapabilityHints { get; set; }
|
||||||
|
|
||||||
|
public bool Applied { get; set; }
|
||||||
|
public bool CapabilityDetected { get; set; }
|
||||||
|
|
||||||
|
public bool IsOptional => CapabilityHints != null && CapabilityHints.Length > 0;
|
||||||
|
|
||||||
|
/// <summary>True once the patch is applied, or once a scan proved the feature is absent.</summary>
|
||||||
|
public bool IsResolved => Applied || (IsOptional && !CapabilityDetected);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Dictionary<EPatchType, PatchEntry[]> GetInstance()
|
public static Dictionary<EPatchType, PatchEntry[]> GetInstance()
|
||||||
{
|
{
|
||||||
return new Dictionary<EPatchType, PatchEntry[]>()
|
return new Dictionary<EPatchType, PatchEntry[]>
|
||||||
{
|
{
|
||||||
{
|
{
|
||||||
EPatchType.ActivatePro,
|
EPatchType.ActivatePro,
|
||||||
@@ -38,40 +46,41 @@ namespace WandEnhancer.Core
|
|||||||
{
|
{
|
||||||
new PatchEntry
|
new PatchEntry
|
||||||
{
|
{
|
||||||
SearchHints = new[] { "getUserAccount()", "/v3/account" },
|
|
||||||
Resolver = new ResolveContext
|
|
||||||
{
|
|
||||||
Handler = (targetFunction) =>
|
|
||||||
{
|
|
||||||
var fetchMatch = Regex.Match(targetFunction, @"return\s+this\.#(\w+)\.fetch");
|
|
||||||
return fetchMatch.Success ? fetchMatch.Groups[1].Value : null;
|
|
||||||
},
|
|
||||||
Placeholder = "<service_name>"
|
|
||||||
},
|
|
||||||
Name = "getUserAccount",
|
Name = "getUserAccount",
|
||||||
Target = new Regex(@"getUserAccount\(\)\{.*?return\s+this\.#\w+\.fetch\(\{.*?\}\)\}",
|
SearchHints = new[] { "getUserAccount(" },
|
||||||
RegexOptions.Singleline),
|
Locate = js => ForceProSubscription(js, "getUserAccount")
|
||||||
Patch =
|
|
||||||
"getUserAccount(){return this.#<service_name>.fetch({endpoint:\"/v3/account\",method:\"GET\",name:\"/v3/account\",collectMetrics:0}).then(response=>{response.subscription={period:\"yearly\",state:\"active\"};return response;})}"
|
|
||||||
},
|
},
|
||||||
new PatchEntry
|
new PatchEntry
|
||||||
{
|
{
|
||||||
SearchHints = new[] { "setAccountWandBrandExperience()", "/v3/account/brand_experience_wand" },
|
|
||||||
Resolver = new ResolveContext
|
|
||||||
{
|
|
||||||
Handler = (targetFunction) =>
|
|
||||||
{
|
|
||||||
var match = Regex.Match(targetFunction, @"return\s+this\.#(\w+)\.post");
|
|
||||||
return match.Success ? match.Groups[1].Value : null;
|
|
||||||
},
|
|
||||||
Placeholder = "<service_name>"
|
|
||||||
},
|
|
||||||
Name = "setAccountWandBrandExperience",
|
Name = "setAccountWandBrandExperience",
|
||||||
Target = new Regex(
|
SearchHints = new[] { "setAccountWandBrandExperience(" },
|
||||||
@"setAccountWandBrandExperience\(\)\{.*?return\s+this\.#\w+\.post\(""/v3/account/brand_experience_wand""\)\}",
|
CapabilityHints = new[] { "/v3/account/brand_experience_wand" },
|
||||||
RegexOptions.Singleline),
|
Locate = js => ForceProSubscription(js, "setAccountWandBrandExperience")
|
||||||
Patch =
|
},
|
||||||
"setAccountWandBrandExperience(){return this.#<service_name>.post(\"/v3/account/brand_experience_wand\").then(response=>{response.subscription={period:\"yearly\",state:\"active\"};return response;})}"
|
new PatchEntry
|
||||||
|
{
|
||||||
|
// Changing language returns a fresh account object that would otherwise
|
||||||
|
// overwrite the patched subscription in the store.
|
||||||
|
Name = "setAccountLanguage",
|
||||||
|
SearchHints = new[] { "setAccountLanguage(" },
|
||||||
|
Locate = js => ForceProSubscription(js, "setAccountLanguage")
|
||||||
|
},
|
||||||
|
new PatchEntry
|
||||||
|
{
|
||||||
|
// Catches every path that dispatches ACTION_SET_ACCOUNT without going
|
||||||
|
// through the account API methods above (refresh, push, profile edits).
|
||||||
|
Name = "setAccountReducer",
|
||||||
|
SearchHints = new[] { "ACTION_SET_ACCOUNT" },
|
||||||
|
Locate = LocateAccountReducer
|
||||||
|
},
|
||||||
|
new PatchEntry
|
||||||
|
{
|
||||||
|
// Wand's own phone pairing performs a server-side device handoff that
|
||||||
|
// signs this desktop session out. The injected panel does not use it.
|
||||||
|
Name = "disableNativeRemotePairing",
|
||||||
|
SearchHints = new[] { "requestRemoteAuthCode" },
|
||||||
|
Locate = js => Edits(js.FindFunction("requestRemoteAuthCode")?
|
||||||
|
.ReplaceBody(PatchPayload.Load("disable-native-pairing")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -79,15 +88,12 @@ namespace WandEnhancer.Core
|
|||||||
EPatchType.DisableUpdates,
|
EPatchType.DisableUpdates,
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
// Regex consumes 4 closing parens (`)))) `); the 5th (registerHandler's own close)
|
|
||||||
// remains in the original file after replacement. Patch must end with 3 parens — NOT 4.
|
|
||||||
new PatchEntry
|
new PatchEntry
|
||||||
{
|
{
|
||||||
|
Name = "disableUpdateCheck",
|
||||||
CandidateFileNames = new[] { "index.js" },
|
CandidateFileNames = new[] { "index.js" },
|
||||||
SearchHints = new[] { "ACTION_CHECK_FOR_UPDATE" },
|
SearchHints = new[] { "ACTION_CHECK_FOR_UPDATE" },
|
||||||
Target = new Regex(@"registerHandler\(""ACTION_CHECK_FOR_UPDATE"".*?\)\)\)\)",
|
Locate = LocateUpdateHandler
|
||||||
RegexOptions.Singleline),
|
|
||||||
Patch = "registerHandler(\"ACTION_CHECK_FOR_UPDATE\",(e=>expectUpdateFeedUrl(e,(e=>null)))"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -97,18 +103,12 @@ namespace WandEnhancer.Core
|
|||||||
{
|
{
|
||||||
new PatchEntry
|
new PatchEntry
|
||||||
{
|
{
|
||||||
|
// Hooked in the main process: the renderer's keydown dispatcher is
|
||||||
|
// reshaped on every Wand release, the Electron app API is not.
|
||||||
Name = "devToolsBeforeInputEvent",
|
Name = "devToolsBeforeInputEvent",
|
||||||
CandidateFileNames = new[] { "index.js" },
|
CandidateFileNames = new[] { "index.js" },
|
||||||
SearchHints = new[] { "whenReady().then(" },
|
SearchHints = new[] { "whenReady().then(" },
|
||||||
// Anchor on the Electron main-process `<app>.whenReady().then(`
|
Locate = LocateDevToolsHook
|
||||||
// call. This site is far more stable than the minified renderer
|
|
||||||
// keydown listener that previously held the F12 -> ACTION_OPEN_DEV_TOOLS
|
|
||||||
// dispatch (its identifiers and shape change on every Wand release).
|
|
||||||
// We attach a `before-input-event` hook to every BrowserWindow's
|
|
||||||
// webContents which toggles DevTools on F12 directly from the main
|
|
||||||
// process, bypassing the renderer dispatcher entirely.
|
|
||||||
Target = new Regex(@"(?<app>\w+)\.whenReady\(\)\.then\("),
|
|
||||||
Patch = "${app}.on(\"browser-window-created\",((_,w)=>{try{w.webContents.on(\"before-input-event\",((_,i)=>{if(\"F12\"===i.key&&\"keyDown\"===i.type){w.webContents.isDevToolsOpened()?w.webContents.closeDevTools():w.webContents.openDevTools({mode:\"detach\"})}}))}catch(e){}})),${app}.whenReady().then("
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -121,63 +121,261 @@ namespace WandEnhancer.Core
|
|||||||
Name = "remoteBridgeMainBoot",
|
Name = "remoteBridgeMainBoot",
|
||||||
CandidateFileNames = new[] { "index.js" },
|
CandidateFileNames = new[] { "index.js" },
|
||||||
SearchHints = new[] { "whenReady().then(run)" },
|
SearchHints = new[] { "whenReady().then(run)" },
|
||||||
Target = new Regex(@"(?<app>\w+)\.whenReady\(\)\.then\(run\)"),
|
Locate = LocateBridgeBoot
|
||||||
Patch = "${app}.whenReady().then(()=>{try{const p=require(\"node:path\");require(p.join(__dirname,\"remote-panel\",\"bridge.cjs\")).installWandRuntime(require(\"electron\"));}catch(e){try{const fs=require(\"node:fs\"),os=require(\"node:os\"),p=require(\"node:path\");fs.appendFileSync(p.join(os.tmpdir(),\"wand-remote-bridge.log\"),\"[\"+new Date().toISOString()+\"] [boot-error] \"+(e&&e.stack||e)+\"\\n\");}catch(_){}}return run()})"
|
|
||||||
},
|
},
|
||||||
new PatchEntry
|
new PatchEntry
|
||||||
{
|
{
|
||||||
Name = "remoteBridgeReset",
|
Name = "remoteBridgeReset",
|
||||||
SearchHints = new[] { "client-state" },
|
SearchHints = new[] { "client-state" },
|
||||||
Target = new Regex(@"#Je\(\)\{this\.#Oe&&\(this\.#Oe\.dispose\(\),this\.#Oe=null\),this\.#Pe=Date\.now\(\)\.toString\(\),this\.#ke=null,this\.#_e=\[],this\.#Ee=null\}"),
|
Locate = LocateBridgeReset
|
||||||
Patch = "#Je(){this.#Oe&&(this.#Oe.dispose(),this.#Oe=null),this.#Pe=Date.now().toString(),this.#ke=null,this.#_e=[],this.#Ee=null,this.__wandRemoteTrainerInfo=null,this.__wandRemoteBridge?.sync(null)}"
|
|
||||||
},
|
},
|
||||||
new PatchEntry
|
new PatchEntry
|
||||||
{
|
{
|
||||||
Name = "remoteBridgeSyncSnapshot",
|
Name = "remoteBridgeSyncSnapshot",
|
||||||
SearchHints = new[] { "client-state" },
|
SearchHints = new[] { "client-state" },
|
||||||
Target = new Regex(@"#Be\(\)\{if\(this\.status===i\.Connected\)\{let e,t=!1,s=this\.#Ee\?\.getMetadata\(h\.vO\)\?\.gameVersion\?\?null,i=!1;const n=this\.#Ve\[this\.#ke\?\?""""\]\|\|null;this\.#Re&&\(e=this\.#Ae\.getPreferredInstallationInfo\(this\.#Re\),e\.app&&\(t=!0,s\?\?=e\.version\?\?null,i=""number""==typeof e\.version&&!this\.#_e\.includes\(e\.version\)\)\),this\.#Me\?\.send\(""client-state"",\{instanceId:this\.#Pe,trainerId:this\.#ke,trainerLoading:this\.#Ee\?\.isLoading\(\),gameInstalled:t,gameVersion:s,needsCompatibilityWarning:i,values:this\.#Ke\(\),themeId:this\.#We,settings:R\(this\.settings\),language:this\.#Ne,accountUuid:this\.account\.uuid,notesReadHash:n,isTimeLimitExpired:""expired""===this\.#Fe\.timerState\}\)\}\}"),
|
Locate = LocateBridgeSync
|
||||||
Patch = "#Be(){let e,t=!1,s=this.#Ee?.getMetadata(h.vO)?.gameVersion??null,o=!1;const n=this.#Ve[this.#ke??\"\"]||null;this.#Re&&(e=this.#Ae.getPreferredInstallationInfo(this.#Re),e.app&&(t=!0,s??=e.version??null,o=\"number\"==typeof e.version&&!this.#_e.includes(e.version)));this.status===i.Connected&&this.#Me?.send(\"client-state\",{instanceId:this.#Pe,trainerId:this.#ke,trainerLoading:this.#Ee?.isLoading(),gameInstalled:t,gameVersion:s,needsCompatibilityWarning:o,values:this.#Ke(),themeId:this.#We,settings:R(this.settings),language:this.#Ne,accountUuid:this.account.uuid,notesReadHash:n,isTimeLimitExpired:\"expired\"===this.#Fe.timerState});this.__wandRemoteBridge?.sync({instanceId:this.#Pe,trainerId:this.#ke,trainerInfo:this.__wandRemoteTrainerInfo??null,metadata:this.#Ee?.getMetadata(h.vO)??null,trainerLoading:this.#Ee?.isLoading()??false,gameInstalled:t,gameVersion:s,needsCompatibilityWarning:o,language:this.#Ne,themeId:this.#We,notesReadHash:n,isTimeLimitExpired:\"expired\"===this.#Fe.timerState,values:this.#Ke()})}"
|
|
||||||
},
|
},
|
||||||
new PatchEntry
|
new PatchEntry
|
||||||
{
|
{
|
||||||
Name = "remoteBridgeBindHandler",
|
Name = "remoteBridgeBindHandler",
|
||||||
SearchHints = new[] { "client-state" },
|
SearchHints = new[] { "setCurrentTrainer(" },
|
||||||
Target = new Regex(@"setCurrentTrainer\(e,t=null\)\{const s=e\?\.trainerId\|\|null,i=\(s\?e\?\.gameId:null\)\|\|null,n=\(s\?e\?\.supportedVersions:null\)\|\|\[];if\(s===this\.#ke&&t===this\.#Ee\)return;"),
|
Locate = LocateBridgeBindHandler
|
||||||
Patch = "setCurrentTrainer(e,t=null){this.__wandRemoteBridge||(this.__wandRemoteBridge=(()=>{try{const r=globalThis.require||require;const{ipcRenderer:c}=r(\"electron\");try{c.invoke(\"wand-remote-url\").then((u=>{u&&(globalThis.__wandRemoteBridgeUrl=u)}))}catch(e){}const send=(ch,p)=>{try{return c.invoke(ch,p&&JSON.parse(JSON.stringify(p)))}catch(e){}};return{sync:(s)=>send(\"wand-remote-sync\",s),valueChanged:(s)=>send(\"wand-remote-value-changed\",s),setHandler:(h)=>{if(this.__wandRemoteBridgeBound)return;this.__wandRemoteBridgeBound=true;try{c.invoke(\"wand-remote-set-handler-bind\")}catch(e){}c.on(\"wand-remote-set-value\",(_e,req)=>{try{h(req)}catch(e){}})}}}catch(e){try{const r=globalThis.require||require,fs=r(\"node:fs\"),os=r(\"node:os\"),p=r(\"node:path\");fs.appendFileSync(p.join(os.tmpdir(),\"wand-remote-bridge.log\"),\"[\"+new Date().toISOString()+\"] [renderer-bind-error] \"+(e&&e.stack||e)+\"\\n\");}catch(_){}return null}})());this.__wandRemoteBridge?.setHandler((e=>{if(!this.#Ee||!e?.target)return!1;return this.#Ee.isActive()?this.#Ee.setValue(e.target,e.value,g.kL.Remote,e.cheatId):!1}));this.__wandRemoteTrainerInfo=e??null;const s=e?.trainerId||null,i=(s?e?.gameId:null)||null,n=(s?e?.supportedVersions:null)||[];if(s===this.#ke&&t===this.#Ee)return;"
|
|
||||||
},
|
},
|
||||||
new PatchEntry
|
new PatchEntry
|
||||||
{
|
{
|
||||||
Name = "remoteBridgeValueDelta",
|
Name = "remoteBridgeValueDelta",
|
||||||
SearchHints = new[] { "client-value-changed" },
|
SearchHints = new[] { "client-value-changed" },
|
||||||
Target = new Regex(@"#ct\(e,t\)\{t\.push\(e\.onValueSet\(e=>\{this\.status===i\.Connected&&e\.source!==g\.kL\.Remote&&this\.#Me\?\.send\(""client-value-changed"",\{instanceId:this\.#Pe,name:e\.name,value:e\.value,cheatId:e\.cheatId\}\)\}\)\),this\.#Be\(\)\}"),
|
Locate = LocateBridgeValueDelta
|
||||||
Patch = "#ct(e,t){t.push(e.onValueSet(e=>{this.status===i.Connected&&e.source!==g.kL.Remote&&this.#Me?.send(\"client-value-changed\",{instanceId:this.#Pe,name:e.name,value:e.value,cheatId:e.cheatId}),this.__wandRemoteBridge?.valueChanged({trainerId:this.#ke,target:e.name,value:e.value,oldValue:e.oldValue,source:String(e.source??\"desktop\"),cheatId:e.cheatId})})),this.#Be()}"
|
|
||||||
},
|
|
||||||
new PatchEntry
|
|
||||||
{
|
|
||||||
Name = "remoteTooltipPreviewUrl",
|
|
||||||
SearchHints = new[] { "remote_tooltip.scan_the_qr_code_or_visit_the_site", "remote_tooltip.connect_to_wand_remote" },
|
|
||||||
Target = new Regex(@"remoteUrl=""wemodwebsite://remote"""),
|
|
||||||
Patch = "remoteUrl=globalThis.__wandRemoteBridgeUrl||\"" + RemoteWebPanelFallbackUrl + "\""
|
|
||||||
},
|
|
||||||
new PatchEntry
|
|
||||||
{
|
|
||||||
Name = "remoteQrPreviewUrl",
|
|
||||||
SearchHints = new[] { "resources/elements/remote-qr-code" },
|
|
||||||
Resolver = new ResolveContext
|
|
||||||
{
|
|
||||||
Handler = (matchContent) =>
|
|
||||||
{
|
|
||||||
var match = Regex.Match(matchContent, @"this\.canvasElement&&(\w+)\.mo");
|
|
||||||
return match.Success ? match.Groups[1].Value : null;
|
|
||||||
},
|
|
||||||
Placeholder = "<qr_writer>"
|
|
||||||
},
|
|
||||||
Target = new Regex(@"this\.canvasElement&&\w+\.mo\(this\.canvasElement,`\$\{\w+\.A\.wemodWebsiteUrl\}/remote`,this\.options\)"),
|
|
||||||
Patch = "this.canvasElement&&<qr_writer>.mo(this.canvasElement,globalThis.__wandRemoteBridgeUrl||\"" + RemoteWebPanelFallbackUrl + "\",this.options)"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Wraps the account-returning promise so the resolved account always reports an active subscription.</summary>
|
||||||
|
private static JsEdit[] ForceProSubscription(JsCursor js, string methodName)
|
||||||
|
{
|
||||||
|
return Edits(js.FindFunction(methodName)?.WrapReturn(PatchPayload.Load("pro-subscription")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsEdit[] LocateAccountReducer(JsCursor js)
|
||||||
|
{
|
||||||
|
int anchor = js.IndexOf("\"ACTION_SET_ACCOUNT\"");
|
||||||
|
var reducer = anchor < 0 ? null : js.FindFunctionAfter(anchor);
|
||||||
|
if (reducer == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The payload's ${account} survives PatchPayload untouched and is resolved by the
|
||||||
|
// regex replacement below, which is what carries the original identifier through.
|
||||||
|
return Edits(reducer.ReplaceInBody(
|
||||||
|
@"account:\s*(?<account>[\w$]+)",
|
||||||
|
PatchPayload.Load("pro-account-reducer")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsEdit[] LocateUpdateHandler(JsCursor js)
|
||||||
|
{
|
||||||
|
int callOpen = js.FindCall("registerHandler", "\"ACTION_CHECK_FOR_UPDATE\"");
|
||||||
|
if (callOpen < 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Edits(new JsEdit(callOpen + 1, js.MatchClose(callOpen), PatchPayload.Load("disable-updates")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsEdit[] LocateDevToolsHook(JsCursor js)
|
||||||
|
{
|
||||||
|
var match = WhenReady.Match(js.Text);
|
||||||
|
if (!match.Success)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = PatchPayload.Load("devtools-f12", "app", match.Groups["app"].Value);
|
||||||
|
return Edits(new JsEdit(match.Index, match.Index, payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsEdit[] LocateBridgeBoot(JsCursor js)
|
||||||
|
{
|
||||||
|
var match = WhenReadyThenRun.Match(js.Text);
|
||||||
|
if (!match.Success)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = PatchPayload.Load("remote-bridge-boot", "app", match.Groups["app"].Value);
|
||||||
|
return Edits(new JsEdit(match.Index, match.Index + match.Length, payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Clears the bridge alongside the session fields the reset method already nulls out.</summary>
|
||||||
|
private static JsEdit[] LocateBridgeReset(JsCursor js)
|
||||||
|
{
|
||||||
|
var sync = FindClientStateMethod(js);
|
||||||
|
var reset = sync == null ? null : js.FunctionEndingAt(js.SkipWhitespaceBack(sync.Start - 1));
|
||||||
|
if (reset == null || reset.Body.IndexOf("Date.now()", StringComparison.Ordinal) < 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Edits(reset.InsertAtEnd(PatchPayload.Load("remote-bridge-reset")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mirrors Wand's own client-state payload to the bridge by copying the object literal
|
||||||
|
/// verbatim, so fields Wand adds or drops between builds carry over untouched.
|
||||||
|
/// </summary>
|
||||||
|
private static JsEdit[] LocateBridgeSync(JsCursor js)
|
||||||
|
{
|
||||||
|
int sendOpen = js.FindCall("send", "\"client-state\"");
|
||||||
|
if (sendOpen < 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var method = js.EnclosingFunction(sendOpen);
|
||||||
|
int snapshotOpen = js.IndexOf("{", sendOpen);
|
||||||
|
int snapshotClose = js.MatchClose(snapshotOpen);
|
||||||
|
if (method == null || snapshotOpen < 0 || snapshotClose < 0)
|
||||||
|
{
|
||||||
|
throw new Exception("client-state payload object could not be located");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prettified builds leave a trailing comma inside the literal; appending after it
|
||||||
|
// would produce an illegal hole.
|
||||||
|
string snapshot = js.Text.Substring(snapshotOpen + 1, snapshotClose - snapshotOpen - 1)
|
||||||
|
.Trim()
|
||||||
|
.TrimEnd(',');
|
||||||
|
|
||||||
|
var payload = PatchPayload.Load(
|
||||||
|
"remote-bridge-sync",
|
||||||
|
"snapshot", snapshot,
|
||||||
|
"trainer", method.Resolve(@"this\.(?<trainer>#[\w$]+)\s*\?\.\s*getMetadata", "trainer"),
|
||||||
|
"metadata", method.Resolve(@"getMetadata\(\s*(?<metadata>[\w$]+\.[\w$]+)\s*\)", "metadata"));
|
||||||
|
|
||||||
|
var edits = new List<JsEdit> { new JsEdit(js.MatchClose(sendOpen) + 1, payload) };
|
||||||
|
edits.AddRange(HoistConnectedGuard(js, sendOpen));
|
||||||
|
return edits.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Some builds wrap the whole snapshot method in <c>if (status === Connected)</c>. The bridge
|
||||||
|
/// must publish regardless of Wand's own remote status, so the guard is moved onto the send
|
||||||
|
/// itself, leaving the block - and the locals the payload reads - intact.
|
||||||
|
/// </summary>
|
||||||
|
private static IEnumerable<JsEdit> HoistConnectedGuard(JsCursor js, int sendOpen)
|
||||||
|
{
|
||||||
|
int blockOpen = js.EnclosingOpener(sendOpen, '{');
|
||||||
|
int closeParen = blockOpen < 0 ? -1 : js.SkipWhitespaceBack(blockOpen - 1);
|
||||||
|
if (closeParen < 0 || js.Text[closeParen] != ')')
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stack = js.OpenerStack(closeParen);
|
||||||
|
if (stack.Count == 0 || js.NameBefore(stack[0]) != "if")
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
int openParen = stack[0];
|
||||||
|
string test = js.Text.Substring(openParen + 1, closeParen - openParen - 1);
|
||||||
|
|
||||||
|
// Only the connection guard may be hoisted. A nested unrelated `if` would otherwise
|
||||||
|
// have its condition moved onto the send, and an `else` branch would be orphaned by
|
||||||
|
// turning the block into a bare one.
|
||||||
|
if (test.IndexOf("this.status", StringComparison.Ordinal) < 0 || HasElseBranch(js, blockOpen))
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
int guardStart = js.SkipWhitespaceBack(openParen - 1) - 1;
|
||||||
|
|
||||||
|
int calleeStart = sendOpen;
|
||||||
|
while (calleeStart > 0 && IsCalleeChar(js.Text[calleeStart - 1]))
|
||||||
|
{
|
||||||
|
calleeStart--;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return new JsEdit(calleeStart, calleeStart, $"({test})&&");
|
||||||
|
yield return new JsEdit(guardStart, blockOpen, string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasElseBranch(JsCursor js, int blockOpen)
|
||||||
|
{
|
||||||
|
int afterBlock = js.SkipWhitespaceForward(js.MatchClose(blockOpen) + 1);
|
||||||
|
return string.CompareOrdinal(js.Text, afterBlock, "else", 0, 4) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsEdit[] LocateBridgeBindHandler(JsCursor js)
|
||||||
|
{
|
||||||
|
var method = js.FindFunction("setCurrentTrainer");
|
||||||
|
if (method == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same call reveals both the active-trainer field and the numeric or enum value
|
||||||
|
// Wand uses for a remote-originated write. Wand has sibling call sites for other
|
||||||
|
// sources (Overlay), so an ambiguous match would silently bind the wrong one.
|
||||||
|
var setValue = MatchExactlyOnce(RemoteSetValue, js.Text, "Remote setValue call");
|
||||||
|
|
||||||
|
return Edits(method.InsertAtStart(PatchPayload.Load(
|
||||||
|
"remote-bridge-renderer",
|
||||||
|
"trainer", setValue.Groups["trainer"].Value,
|
||||||
|
"remoteSource", setValue.Groups["source"].Value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsEdit[] LocateBridgeValueDelta(JsCursor js)
|
||||||
|
{
|
||||||
|
int sendOpen = js.FindCall("send", "\"client-value-changed\"");
|
||||||
|
if (sendOpen < 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int sendClose = js.MatchClose(sendOpen);
|
||||||
|
return Edits(new JsEdit(sendClose + 1, PatchPayload.Load("remote-bridge-value-delta")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsFunction FindClientStateMethod(JsCursor js)
|
||||||
|
{
|
||||||
|
int sendOpen = js.FindCall("send", "\"client-state\"");
|
||||||
|
return sendOpen < 0 ? null : js.EnclosingFunction(sendOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsEdit[] Edits(JsEdit edit)
|
||||||
|
{
|
||||||
|
return edit == null ? null : new[] { edit };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsCalleeChar(char value)
|
||||||
|
{
|
||||||
|
return char.IsLetterOrDigit(value) || value == '_' || value == '$' || value == '#'
|
||||||
|
|| value == '.' || value == '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Match that must be unambiguous: zero or several hits mean an unsupported build.</summary>
|
||||||
|
private static Match MatchExactlyOnce(Regex pattern, string text, string what)
|
||||||
|
{
|
||||||
|
var match = pattern.Match(text);
|
||||||
|
if (!match.Success)
|
||||||
|
{
|
||||||
|
throw new Exception($"{what} could not be located");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.NextMatch().Success)
|
||||||
|
{
|
||||||
|
throw new Exception($"{what} matched more than once; cannot tell which call site is the right one");
|
||||||
|
}
|
||||||
|
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Regex WhenReady = new Regex(@"(?<app>[\w$]+)\.whenReady\(\)\.then\(");
|
||||||
|
private static readonly Regex WhenReadyThenRun = new Regex(@"(?<app>[\w$]+)\.whenReady\(\)\.then\(run\)");
|
||||||
|
private static readonly Regex RemoteSetValue =
|
||||||
|
new Regex(@"this\.(?<trainer>#[\w$]+)\.setValue\(\s*e\.name\s*,\s*e\.value\s*,\s*(?<source>[^,]+?)\s*,");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using WandEnhancer.View.MainWindow;
|
||||||
|
|
||||||
|
namespace WandEnhancer.Core
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Starts Wand and keeps the ASAR integrity fuse cleared in every process Electron spawns,
|
||||||
|
/// for as long as Wand runs. Covering only the startup burst is not enough: the renderer
|
||||||
|
/// behind the in-game overlay is created when a game launches, and exits with -36861 the
|
||||||
|
/// moment it opens the patched archive, leaving the overlay dead while Wand itself looks
|
||||||
|
/// healthy.
|
||||||
|
/// Wand is put in a job object, which every descendant joins on its own, and the kernel
|
||||||
|
/// posts each new process to a completion port. A debugger would report the same events, but
|
||||||
|
/// it is inherited too - by a game started from Wand included - and games treat a debug port
|
||||||
|
/// as tampering. Nothing here is attached to the game beyond reading its image path.
|
||||||
|
/// </summary>
|
||||||
|
internal static class FuseLauncher
|
||||||
|
{
|
||||||
|
private const int AsarIntegrityExitCode = -36861;
|
||||||
|
|
||||||
|
/// <returns>False when the session ended badly enough to be worth showing the user.</returns>
|
||||||
|
public static bool Launch(string exePath, string args, Action<string, ELogType> log = null)
|
||||||
|
{
|
||||||
|
long stateRva = ElectronFuse.FindStateRva(exePath);
|
||||||
|
|
||||||
|
var startupInfo = new STARTUPINFO { cb = Marshal.SizeOf<STARTUPINFO>() };
|
||||||
|
var commandLine = new StringBuilder(
|
||||||
|
string.IsNullOrEmpty(args) ? $"\"{exePath}\"" : $"\"{exePath}\" {args}");
|
||||||
|
|
||||||
|
// Suspended, so the fuse is cleared and the job is attached before Wand runs its
|
||||||
|
// first instruction. Every child is then born inside the job.
|
||||||
|
if (!CreateProcessW(null, commandLine, IntPtr.Zero, IntPtr.Zero, false, CREATE_SUSPENDED,
|
||||||
|
IntPtr.Zero, System.IO.Path.GetDirectoryName(exePath), ref startupInfo, out var info))
|
||||||
|
{
|
||||||
|
log?.Invoke($"Could not start Wand (win32 error {Marshal.GetLastWin32Error()}).", ELogType.Error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
IntPtr job = IntPtr.Zero;
|
||||||
|
IntPtr port = IntPtr.Zero;
|
||||||
|
bool resumed = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
log?.Invoke($"Started {exePath} as pid {info.dwProcessId}.", ELogType.Info);
|
||||||
|
|
||||||
|
if (stateRva < 0)
|
||||||
|
{
|
||||||
|
log?.Invoke($"No Electron fuse block in {exePath}. A patched Wand will exit " +
|
||||||
|
$"with {AsarIntegrityExitCode}; an unpatched one is unaffected.", ELogType.Error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool mainCleared = ElectronFuse.ClearIn(info.hProcess, stateRva);
|
||||||
|
log?.Invoke(mainCleared
|
||||||
|
? $"pid {info.dwProcessId} started - fuse cleared."
|
||||||
|
: $"Fuse not cleared in pid {info.dwProcessId}; it may exit with {AsarIntegrityExitCode}.",
|
||||||
|
mainCleared ? ELogType.Info : ELogType.Warn);
|
||||||
|
|
||||||
|
if (!TryTrackChildren(info.hProcess, out job, out port))
|
||||||
|
{
|
||||||
|
log?.Invoke($"Could not watch Wand for new processes (win32 error {Marshal.GetLastWin32Error()}). " +
|
||||||
|
"Wand will run, but the in-game overlay will not.", ELogType.Error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ResumeThread(info.hThread);
|
||||||
|
resumed = true;
|
||||||
|
|
||||||
|
ClearFuseInNewProcesses(port, exePath, stateRva, info.dwProcessId, mainCleared, log);
|
||||||
|
|
||||||
|
if (!GetExitCodeProcess(info.hProcess, out int exitCode))
|
||||||
|
{
|
||||||
|
log?.Invoke("Wand exited, and its exit code could not be read.", ELogType.Error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
log?.Invoke($"Wand exited with code {DescribeCode(exitCode)}.",
|
||||||
|
exitCode == 0 ? ELogType.Info : ELogType.Error);
|
||||||
|
return exitCode == 0;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!resumed)
|
||||||
|
{
|
||||||
|
ResumeThread(info.hThread);
|
||||||
|
}
|
||||||
|
|
||||||
|
CloseHandle(info.hThread);
|
||||||
|
CloseHandle(info.hProcess);
|
||||||
|
if (port != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
CloseHandle(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
CloseHandle(job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// No limits are set on the job: it exists only to be told about new processes. That also
|
||||||
|
/// keeps KILL_ON_JOB_CLOSE off, so Wand outlives the launcher rather than dying with it.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryTrackChildren(IntPtr process, out IntPtr job, out IntPtr port)
|
||||||
|
{
|
||||||
|
port = IntPtr.Zero;
|
||||||
|
job = CreateJobObject(IntPtr.Zero, null);
|
||||||
|
if (job == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
port = CreateIoCompletionPort(INVALID_HANDLE_VALUE, IntPtr.Zero, UIntPtr.Zero, 1);
|
||||||
|
if (port == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var association = new JOBOBJECT_ASSOCIATE_COMPLETION_PORT { CompletionKey = IntPtr.Zero, CompletionPort = port };
|
||||||
|
return SetInformationJobObject(job, JobObjectAssociateCompletionPortInformation,
|
||||||
|
ref association, Marshal.SizeOf(association))
|
||||||
|
&& AssignProcessToJobObject(job, process);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Blocks until the last process in the job is gone.</summary>
|
||||||
|
private static void ClearFuseInNewProcesses(IntPtr port, string exePath, long stateRva,
|
||||||
|
int mainProcessId, bool mainCleared, Action<string, ELogType> log)
|
||||||
|
{
|
||||||
|
var tracked = new Dictionary<int, IntPtr>();
|
||||||
|
int cleared = mainCleared ? 1 : 0;
|
||||||
|
int missed = mainCleared ? 0 : 1;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (GetQueuedCompletionStatus(port, out uint message, out _, out IntPtr value, INFINITE))
|
||||||
|
{
|
||||||
|
if (message == JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
int processId = value.ToInt32();
|
||||||
|
if (message == JOB_OBJECT_MSG_EXIT_PROCESS || message == JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS)
|
||||||
|
{
|
||||||
|
ReportExit(tracked, processId, log);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The main process is announced here too, having been patched while it was
|
||||||
|
// still suspended, and a game started from Wand joins the job like any child.
|
||||||
|
if (message != JOB_OBJECT_MSG_NEW_PROCESS || processId == mainProcessId ||
|
||||||
|
!IsImage(processId, exePath))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The handle is kept open: it is what makes the exit code readable later, and
|
||||||
|
// it also stops Windows handing the pid to someone else in the meantime.
|
||||||
|
IntPtr process = OpenProcess(ProcessAccess, false, processId);
|
||||||
|
if (process != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
tracked[processId] = process;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process != IntPtr.Zero && ElectronFuse.ClearIn(process, stateRva))
|
||||||
|
{
|
||||||
|
cleared++;
|
||||||
|
log?.Invoke($"pid {processId} started - fuse cleared.", ELogType.Info);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
missed++;
|
||||||
|
log?.Invoke($"Fuse not cleared in pid {processId}; it may exit with {AsarIntegrityExitCode}.",
|
||||||
|
ELogType.Warn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
foreach (var handle in tracked.Values)
|
||||||
|
{
|
||||||
|
CloseHandle(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log?.Invoke($"Wand closed: fuse cleared in {cleared} processes" + (missed == 0 ? "." : $", {missed} missed."),
|
||||||
|
missed == 0 ? ELogType.Info : ELogType.Warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Only anomalies are reported: on a normal shutdown every process exits with 0, and a
|
||||||
|
/// line each would bury the one death that matters.
|
||||||
|
/// </summary>
|
||||||
|
private static void ReportExit(Dictionary<int, IntPtr> tracked, int processId, Action<string, ELogType> log)
|
||||||
|
{
|
||||||
|
if (!tracked.TryGetValue(processId, out IntPtr process))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracked.Remove(processId);
|
||||||
|
if (GetExitCodeProcess(process, out int exitCode) && exitCode != 0)
|
||||||
|
{
|
||||||
|
log?.Invoke($"pid {processId} exited with code {DescribeCode(exitCode)}.", ELogType.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
CloseHandle(process);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identity check before anything heavier: a game started from Wand is in the job as well,
|
||||||
|
/// and is opened for nothing beyond the right the task manager uses to read a path.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsImage(int processId, string exePath)
|
||||||
|
{
|
||||||
|
IntPtr process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, processId);
|
||||||
|
if (process == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = new StringBuilder(MaxPathLength);
|
||||||
|
int length = path.Capacity;
|
||||||
|
return QueryFullProcessImageName(process, 0, path, ref length) &&
|
||||||
|
string.Equals(path.ToString(), exePath, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CloseHandle(process);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DescribeCode(int code)
|
||||||
|
{
|
||||||
|
switch (code)
|
||||||
|
{
|
||||||
|
case 0: return "0";
|
||||||
|
case AsarIntegrityExitCode:
|
||||||
|
return $"{code} (ASAR integrity check failed - the fuse was not cleared in time)";
|
||||||
|
// Chromium breaks into a debugger that is not there when it hits a fatal error.
|
||||||
|
case unchecked((int)0x80000003): return $"0x{code:X8} (Wand aborted itself during startup)";
|
||||||
|
case unchecked((int)0xC0000005): return $"0x{code:X8} (access violation)";
|
||||||
|
case unchecked((int)0xC0000135): return $"0x{code:X8} (a required DLL is missing)";
|
||||||
|
case unchecked((int)0xC0000142): return $"0x{code:X8} (a DLL failed to initialise)";
|
||||||
|
case unchecked((int)0xC0000409): return $"0x{code:X8} (stack buffer overrun)";
|
||||||
|
default: return $"{code} (0x{code:X8})";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#region P/Invoke
|
||||||
|
|
||||||
|
private const uint CREATE_SUSPENDED = 0x4;
|
||||||
|
private const uint ProcessAccess = 0x0008 | 0x0010 | 0x0020 | 0x0400;
|
||||||
|
private const uint PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
|
||||||
|
private const int MaxPathLength = 260;
|
||||||
|
private const int JobObjectAssociateCompletionPortInformation = 7;
|
||||||
|
private const uint JOB_OBJECT_MSG_ACTIVE_PROCESS_ZERO = 4;
|
||||||
|
private const uint JOB_OBJECT_MSG_NEW_PROCESS = 6;
|
||||||
|
private const uint JOB_OBJECT_MSG_EXIT_PROCESS = 7;
|
||||||
|
private const uint JOB_OBJECT_MSG_ABNORMAL_EXIT_PROCESS = 8;
|
||||||
|
private const uint INFINITE = 0xFFFFFFFF;
|
||||||
|
private static readonly IntPtr INVALID_HANDLE_VALUE = new IntPtr(-1);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct STARTUPINFO
|
||||||
|
{
|
||||||
|
public int cb;
|
||||||
|
public IntPtr lpReserved, lpDesktop, lpTitle;
|
||||||
|
public int dwX, dwY, dwXSize, dwYSize;
|
||||||
|
public int dwXCountChars, dwYCountChars, dwFillAttribute, dwFlags;
|
||||||
|
public short wShowWindow, cbReserved2;
|
||||||
|
public IntPtr lpReserved2, hStdInput, hStdOutput, hStdError;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct PROCESS_INFORMATION
|
||||||
|
{
|
||||||
|
public IntPtr hProcess, hThread;
|
||||||
|
public int dwProcessId, dwThreadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct JOBOBJECT_ASSOCIATE_COMPLETION_PORT
|
||||||
|
{
|
||||||
|
public IntPtr CompletionKey;
|
||||||
|
public IntPtr CompletionPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||||
|
private static extern bool CreateProcessW(
|
||||||
|
string lpApplicationName, StringBuilder lpCommandLine,
|
||||||
|
IntPtr lpProcessAttributes, IntPtr lpThreadAttributes,
|
||||||
|
bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment,
|
||||||
|
string lpCurrentDirectory, ref STARTUPINFO lpStartupInfo,
|
||||||
|
out PROCESS_INFORMATION lpProcessInformation);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
private static extern uint ResumeThread(IntPtr hThread);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||||
|
private static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
private static extern bool SetInformationJobObject(
|
||||||
|
IntPtr hJob, int jobObjectInformationClass,
|
||||||
|
ref JOBOBJECT_ASSOCIATE_COMPLETION_PORT lpJobObjectInformation, int cbJobObjectInformationLength);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
private static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
private static extern IntPtr CreateIoCompletionPort(
|
||||||
|
IntPtr fileHandle, IntPtr existingCompletionPort, UIntPtr completionKey, uint numberOfConcurrentThreads);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
private static extern bool GetQueuedCompletionStatus(
|
||||||
|
IntPtr completionPort, out uint lpNumberOfBytes, out IntPtr lpCompletionKey,
|
||||||
|
out IntPtr lpOverlapped, uint dwMilliseconds);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
private static extern IntPtr OpenProcess(uint dwDesiredAccess, bool bInheritHandle, int dwProcessId);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||||
|
private static extern bool QueryFullProcessImageName(
|
||||||
|
IntPtr hProcess, uint dwFlags, StringBuilder lpExeName, ref int lpdwSize);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
private static extern bool GetExitCodeProcess(IntPtr hProcess, out int lpExitCode);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll")]
|
||||||
|
private static extern bool CloseHandle(IntPtr hObject);
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using WandEnhancer.Core.Js;
|
||||||
|
using WandEnhancer.Models;
|
||||||
|
using WandEnhancer.View.MainWindow;
|
||||||
|
|
||||||
|
namespace WandEnhancer.Core
|
||||||
|
{
|
||||||
|
internal sealed class JavaScriptPatchApplier
|
||||||
|
{
|
||||||
|
private readonly Action<string, ELogType> _logger;
|
||||||
|
|
||||||
|
public JavaScriptPatchApplier(Action<string, ELogType> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Apply(string fileName, string source, EnhancerConfig.PatchEntry patch, EPatchType patchType, out bool patchApplied)
|
||||||
|
{
|
||||||
|
patchApplied = false;
|
||||||
|
if (patch.Applied || !CanSearchFile(fileName, patch))
|
||||||
|
{
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
patch.CapabilityDetected |= ContainsAny(source, patch.CapabilityHints);
|
||||||
|
if (!ContainsAny(source, patch.SearchHints))
|
||||||
|
{
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
string label = FormatLabel(patchType, patch);
|
||||||
|
JsEdit[] edits;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
edits = patch.Locate(new JsCursor(source));
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
throw new Exception($"[ENHANCER] [{label}] {e.Message}. The version may not be supported.", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (edits == null || edits.Length == 0)
|
||||||
|
{
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger($"[ENHANCER] [{label}] Found target in: {Path.GetFileName(fileName)}", ELogType.Info);
|
||||||
|
foreach (var edit in edits.OrderByDescending(edit => edit.Start))
|
||||||
|
{
|
||||||
|
source = edit.ApplyTo(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger($"[ENHANCER] [{label}] Patch applied", ELogType.Success);
|
||||||
|
patch.Applied = true;
|
||||||
|
patchApplied = true;
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string FormatLabel(EPatchType patchType, EnhancerConfig.PatchEntry patch)
|
||||||
|
{
|
||||||
|
return string.IsNullOrEmpty(patch.Name) ? patchType.ToString() : $"{patchType} -> {patch.Name}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool CanSearchFile(string filePath, EnhancerConfig.PatchEntry patch)
|
||||||
|
{
|
||||||
|
if (patch.CandidateFileNames == null || patch.CandidateFileNames.Length == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
string fileName = Path.GetFileName(filePath);
|
||||||
|
return patch.CandidateFileNames.Any(candidate => fileName.Equals(candidate, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsAny(string source, string[] hints)
|
||||||
|
{
|
||||||
|
return hints != null && hints.Any(hint => source.IndexOf(hint, StringComparison.Ordinal) >= 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace WandEnhancer.Core.Js
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Navigates minified JavaScript by matching delimiters rather than by matching shape.
|
||||||
|
/// Wand renames identifiers on every build but never renames its API endpoints, IPC
|
||||||
|
/// channel names or public method names, so anchoring on those and walking the
|
||||||
|
/// delimiter structure keeps a patch valid across builds.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class JsCursor
|
||||||
|
{
|
||||||
|
private const string RegexPrecedingChars = "(,=:[!&|?{};+-*%~^<>";
|
||||||
|
private const int NameLookbackChars = 128;
|
||||||
|
private static readonly Regex NameBeforeParen = new Regex(@"[#\w$]+$");
|
||||||
|
private static readonly Regex FunctionKeyword = new Regex(@"(?<![\w$.])function\s*\*?\s*[\w$]*\s*\(");
|
||||||
|
private static readonly HashSet<string> BlockKeywords =
|
||||||
|
new HashSet<string>(StringComparer.Ordinal) { "if", "for", "while", "switch", "catch", "with", "do", "else" };
|
||||||
|
|
||||||
|
// A slash after one of these is a regex literal, not division. Minifiers emit
|
||||||
|
// `return/re/.test(x)` with no space, so missing these desyncs the whole scan.
|
||||||
|
private static readonly HashSet<string> RegexPrecedingKeywords =
|
||||||
|
new HashSet<string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
"return", "typeof", "instanceof", "in", "of", "new", "delete", "void",
|
||||||
|
"throw", "case", "do", "else", "yield", "await"
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly string _text;
|
||||||
|
|
||||||
|
public JsCursor(string text)
|
||||||
|
{
|
||||||
|
_text = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Text => _text;
|
||||||
|
|
||||||
|
public int IndexOf(string value, int from = 0)
|
||||||
|
{
|
||||||
|
return from >= _text.Length ? -1 : _text.IndexOf(value, from, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Index of the delimiter closing the one at <paramref name="openIndex"/>, or -1.</summary>
|
||||||
|
public int MatchClose(int openIndex)
|
||||||
|
{
|
||||||
|
char open = _text[openIndex];
|
||||||
|
char close = CloserOf(open);
|
||||||
|
int depth = 0;
|
||||||
|
|
||||||
|
for (int index = openIndex; index < _text.Length;)
|
||||||
|
{
|
||||||
|
char current = _text[index];
|
||||||
|
if (current == open)
|
||||||
|
{
|
||||||
|
depth++;
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
else if (current == close)
|
||||||
|
{
|
||||||
|
if (--depth == 0)
|
||||||
|
{
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
index = SkipToken(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Open delimiters enclosing <paramref name="index"/>, innermost first.</summary>
|
||||||
|
public List<int> OpenerStack(int index)
|
||||||
|
{
|
||||||
|
var stack = new List<int>();
|
||||||
|
for (int cursor = 0; cursor < index && cursor < _text.Length;)
|
||||||
|
{
|
||||||
|
char current = _text[cursor];
|
||||||
|
if (current == '{' || current == '(' || current == '[')
|
||||||
|
{
|
||||||
|
stack.Add(cursor);
|
||||||
|
cursor++;
|
||||||
|
}
|
||||||
|
else if (current == '}' || current == ')' || current == ']')
|
||||||
|
{
|
||||||
|
if (stack.Count > 0)
|
||||||
|
{
|
||||||
|
stack.RemoveAt(stack.Count - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cursor = SkipToken(cursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stack.Reverse();
|
||||||
|
return stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Innermost enclosing delimiter of the given kind, or -1.</summary>
|
||||||
|
public int EnclosingOpener(int index, char kind)
|
||||||
|
{
|
||||||
|
foreach (int opener in OpenerStack(index))
|
||||||
|
{
|
||||||
|
if (_text[opener] == kind)
|
||||||
|
{
|
||||||
|
return opener;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Innermost named function or method whose body contains <paramref name="index"/>.</summary>
|
||||||
|
public JsFunction EnclosingFunction(int index)
|
||||||
|
{
|
||||||
|
foreach (int opener in OpenerStack(index))
|
||||||
|
{
|
||||||
|
if (_text[opener] != '{')
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var function = ReadFunctionAt(opener);
|
||||||
|
if (function != null)
|
||||||
|
{
|
||||||
|
return function;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The named function whose body closes at <paramref name="closeIndex"/>, or null.</summary>
|
||||||
|
public JsFunction FunctionEndingAt(int closeIndex)
|
||||||
|
{
|
||||||
|
if (closeIndex < 0 || closeIndex >= _text.Length || _text[closeIndex] != '}')
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stack = OpenerStack(closeIndex);
|
||||||
|
return stack.Count == 0 ? null : ReadFunctionAt(stack[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>First function declared as <c>name(...)</c>, ignoring property and call sites.</summary>
|
||||||
|
public JsFunction FindFunction(string name)
|
||||||
|
{
|
||||||
|
var pattern = new Regex($@"(?<![#\w$.]){Regex.Escape(name)}\s*\(");
|
||||||
|
for (var match = pattern.Match(_text); match.Success; match = match.NextMatch())
|
||||||
|
{
|
||||||
|
int closeParen = MatchClose(match.Index + match.Length - 1);
|
||||||
|
if (closeParen < 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int bodyOpen = SkipWhitespaceForward(closeParen + 1);
|
||||||
|
if (bodyOpen < _text.Length && _text[bodyOpen] == '{')
|
||||||
|
{
|
||||||
|
var function = ReadFunctionAt(bodyOpen);
|
||||||
|
if (function != null && function.Name == name)
|
||||||
|
{
|
||||||
|
return function;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>First <c>function name(...) { }</c> declared at or after <paramref name="index"/>.</summary>
|
||||||
|
public JsFunction FindFunctionAfter(int index)
|
||||||
|
{
|
||||||
|
var match = FunctionKeyword.Match(_text, index);
|
||||||
|
if (!match.Success)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int closeParen = MatchClose(match.Index + match.Length - 1);
|
||||||
|
if (closeParen < 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int bodyOpen = SkipWhitespaceForward(closeParen + 1);
|
||||||
|
return bodyOpen < _text.Length && _text[bodyOpen] == '{' ? ReadFunctionAt(bodyOpen) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Index of the opening parenthesis of <c>callee(... "literal" ...)</c>, or -1. Wand reuses the
|
||||||
|
/// same channel names for inbound listeners and outbound sends, so the callee disambiguates.
|
||||||
|
/// </summary>
|
||||||
|
public int FindCall(string callee, string literal)
|
||||||
|
{
|
||||||
|
for (int anchor = IndexOf(literal); anchor >= 0; anchor = IndexOf(literal, anchor + 1))
|
||||||
|
{
|
||||||
|
int open = EnclosingOpener(anchor, '(');
|
||||||
|
if (open >= 0 && NameBefore(open) == callee)
|
||||||
|
{
|
||||||
|
return open;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Trailing identifier directly before <paramref name="index"/>, e.g. <c>send</c> of <c>a?.send(</c>.</summary>
|
||||||
|
public string NameBefore(int index)
|
||||||
|
{
|
||||||
|
int end = SkipWhitespaceBack(index - 1) + 1;
|
||||||
|
var match = MatchNameEndingAt(end);
|
||||||
|
return match.Success ? match.Value.TrimStart('#') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Identifier ending at <paramref name="end"/>, searched in a bounded window so
|
||||||
|
/// multi-megabyte bundles are not copied on every lookup.</summary>
|
||||||
|
private Match MatchNameEndingAt(int end)
|
||||||
|
{
|
||||||
|
int windowStart = Math.Max(0, end - NameLookbackChars);
|
||||||
|
return NameBeforeParen.Match(_text.Substring(windowStart, end - windowStart));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int SkipWhitespaceBack(int index)
|
||||||
|
{
|
||||||
|
while (index >= 0 && char.IsWhiteSpace(_text[index]))
|
||||||
|
{
|
||||||
|
index--;
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int SkipWhitespaceForward(int index)
|
||||||
|
{
|
||||||
|
while (index < _text.Length && char.IsWhiteSpace(_text[index]))
|
||||||
|
{
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsFunction ReadFunctionAt(int bodyOpen)
|
||||||
|
{
|
||||||
|
int closeParen = SkipWhitespaceBack(bodyOpen - 1);
|
||||||
|
if (closeParen < 0 || _text[closeParen] != ')')
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stack = OpenerStack(closeParen);
|
||||||
|
if (stack.Count == 0 || _text[stack[0]] != '(')
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int nameEnd = SkipWhitespaceBack(stack[0] - 1) + 1;
|
||||||
|
var nameMatch = MatchNameEndingAt(nameEnd);
|
||||||
|
if (!nameMatch.Success || BlockKeywords.Contains(nameMatch.Value))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int bodyClose = MatchClose(bodyOpen);
|
||||||
|
return bodyClose < 0
|
||||||
|
? null
|
||||||
|
: new JsFunction(nameMatch.Value, nameEnd - nameMatch.Length, bodyOpen, bodyClose, _text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int SkipToken(int index)
|
||||||
|
{
|
||||||
|
char current = _text[index];
|
||||||
|
if (current == '"' || current == '\'' || current == '`')
|
||||||
|
{
|
||||||
|
return SkipString(index, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current != '/' || index + 1 >= _text.Length)
|
||||||
|
{
|
||||||
|
return index + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
char next = _text[index + 1];
|
||||||
|
if (next == '/')
|
||||||
|
{
|
||||||
|
int lineEnd = _text.IndexOf('\n', index);
|
||||||
|
return lineEnd < 0 ? _text.Length : lineEnd + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next == '*')
|
||||||
|
{
|
||||||
|
int commentEnd = _text.IndexOf("*/", index + 2, StringComparison.Ordinal);
|
||||||
|
return commentEnd < 0 ? _text.Length : commentEnd + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return StartsRegexLiteral(index) ? SkipRegexLiteral(index) : index + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int SkipString(int index, char quote)
|
||||||
|
{
|
||||||
|
for (int cursor = index + 1; cursor < _text.Length; cursor++)
|
||||||
|
{
|
||||||
|
char current = _text[cursor];
|
||||||
|
if (current == '\\')
|
||||||
|
{
|
||||||
|
cursor++;
|
||||||
|
}
|
||||||
|
else if (current == quote)
|
||||||
|
{
|
||||||
|
return cursor + 1;
|
||||||
|
}
|
||||||
|
else if (quote == '`' && current == '$' && cursor + 1 < _text.Length && _text[cursor + 1] == '{')
|
||||||
|
{
|
||||||
|
int interpolationEnd = MatchClose(cursor + 1);
|
||||||
|
cursor = interpolationEnd < 0 ? _text.Length : interpolationEnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _text.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int SkipRegexLiteral(int index)
|
||||||
|
{
|
||||||
|
bool inCharacterClass = false;
|
||||||
|
for (int cursor = index + 1; cursor < _text.Length; cursor++)
|
||||||
|
{
|
||||||
|
char current = _text[cursor];
|
||||||
|
if (current == '\\')
|
||||||
|
{
|
||||||
|
cursor++;
|
||||||
|
}
|
||||||
|
else if (current == '[')
|
||||||
|
{
|
||||||
|
inCharacterClass = true;
|
||||||
|
}
|
||||||
|
else if (current == ']')
|
||||||
|
{
|
||||||
|
inCharacterClass = false;
|
||||||
|
}
|
||||||
|
else if (current == '\n')
|
||||||
|
{
|
||||||
|
return index + 1;
|
||||||
|
}
|
||||||
|
else if (current == '/' && !inCharacterClass)
|
||||||
|
{
|
||||||
|
return cursor + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _text.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool StartsRegexLiteral(int index)
|
||||||
|
{
|
||||||
|
int previous = SkipWhitespaceBack(index - 1);
|
||||||
|
if (previous < 0 || RegexPrecedingChars.IndexOf(_text[previous]) >= 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return IsIdentifierChar(_text[previous]) && RegexPrecedingKeywords.Contains(WordEndingAt(previous));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The identifier ending at <paramref name="end"/> inclusive, or "" when there is none.</summary>
|
||||||
|
private string WordEndingAt(int end)
|
||||||
|
{
|
||||||
|
int start = end;
|
||||||
|
while (start >= 0 && IsIdentifierChar(_text[start]))
|
||||||
|
{
|
||||||
|
start--;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A preceding '.' makes it a member name (`x.in`), never a keyword.
|
||||||
|
if (start >= 0 && _text[start] == '.')
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _text.Substring(start + 1, end - start);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsIdentifierChar(char value)
|
||||||
|
{
|
||||||
|
return char.IsLetterOrDigit(value) || value == '_' || value == '$';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static char CloserOf(char open)
|
||||||
|
{
|
||||||
|
switch (open)
|
||||||
|
{
|
||||||
|
case '{': return '}';
|
||||||
|
case '(': return ')';
|
||||||
|
case '[': return ']';
|
||||||
|
default: throw new ArgumentException($"Not an opening delimiter: {open}", nameof(open));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace WandEnhancer.Core.Js
|
||||||
|
{
|
||||||
|
/// <summary>A named function or class method located in a bundle, addressed by delimiter position.</summary>
|
||||||
|
internal sealed class JsFunction
|
||||||
|
{
|
||||||
|
private static readonly Regex ReturnKeyword = new Regex(@"(?<![\w$])return(?![\w$])");
|
||||||
|
|
||||||
|
private readonly string _source;
|
||||||
|
private JsCursor _body;
|
||||||
|
|
||||||
|
public JsFunction(string name, int start, int bodyOpen, int bodyClose, string source)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
Start = start;
|
||||||
|
BodyOpen = bodyOpen;
|
||||||
|
BodyClose = bodyClose;
|
||||||
|
_source = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name { get; }
|
||||||
|
public int Start { get; }
|
||||||
|
public int BodyOpen { get; }
|
||||||
|
public int BodyClose { get; }
|
||||||
|
|
||||||
|
public string Body => _source.Substring(BodyOpen + 1, BodyClose - BodyOpen - 1);
|
||||||
|
|
||||||
|
private JsCursor BodyCursor => _body ?? (_body = new JsCursor(Body));
|
||||||
|
|
||||||
|
/// <summary>Captures a group from a pattern matched against this body only, not the whole bundle.</summary>
|
||||||
|
public string Resolve(string pattern, string group)
|
||||||
|
{
|
||||||
|
var match = Regex.Match(Body, pattern, RegexOptions.Singleline);
|
||||||
|
if (!match.Success || string.IsNullOrEmpty(match.Groups[group].Value))
|
||||||
|
{
|
||||||
|
throw new Exception($"Could not resolve '{group}' inside {Name}()");
|
||||||
|
}
|
||||||
|
|
||||||
|
return match.Groups[group].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Rewrites the first match of a pattern scoped to this body; <c>${group}</c> back-references work.</summary>
|
||||||
|
public JsEdit ReplaceInBody(string pattern, string replacement)
|
||||||
|
{
|
||||||
|
var match = Regex.Match(Body, pattern, RegexOptions.Singleline);
|
||||||
|
if (!match.Success)
|
||||||
|
{
|
||||||
|
throw new Exception($"Pattern '{pattern}' not found inside {Name}()");
|
||||||
|
}
|
||||||
|
|
||||||
|
int start = BodyOpen + 1 + match.Index;
|
||||||
|
return new JsEdit(start, start + match.Length, match.Result(replacement));
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsEdit InsertAtStart(string code) => new JsEdit(BodyOpen + 1, BodyOpen + 1, code);
|
||||||
|
|
||||||
|
public JsEdit InsertAtEnd(string code) => new JsEdit(BodyClose, BodyClose, code);
|
||||||
|
|
||||||
|
public JsEdit ReplaceBody(string code) => new JsEdit(BodyOpen + 1, BodyClose, code);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rewrites the last top-level <c>return X</c> as <c>return WRAPPER</c>, where the wrapper's
|
||||||
|
/// <c>$0</c> placeholder receives the original expression.
|
||||||
|
/// </summary>
|
||||||
|
public JsEdit WrapReturn(string wrapper)
|
||||||
|
{
|
||||||
|
var body = BodyCursor;
|
||||||
|
int keywordEnd = -1;
|
||||||
|
for (var match = ReturnKeyword.Match(body.Text); match.Success; match = match.NextMatch())
|
||||||
|
{
|
||||||
|
if (body.OpenerStack(match.Index).Count == 0)
|
||||||
|
{
|
||||||
|
keywordEnd = match.Index + match.Length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keywordEnd < 0)
|
||||||
|
{
|
||||||
|
throw new Exception($"No top-level return statement in {Name}()");
|
||||||
|
}
|
||||||
|
|
||||||
|
int expressionStart = body.SkipWhitespaceForward(keywordEnd);
|
||||||
|
int expressionEnd = FindStatementEnd(body, expressionStart);
|
||||||
|
string expression = body.Text.Substring(expressionStart, expressionEnd - expressionStart);
|
||||||
|
|
||||||
|
return new JsEdit(
|
||||||
|
BodyOpen + 1 + expressionStart,
|
||||||
|
BodyOpen + 1 + expressionEnd,
|
||||||
|
wrapper.Replace("$0", $"({expression})"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int FindStatementEnd(JsCursor body, int start)
|
||||||
|
{
|
||||||
|
for (int cursor = start; cursor < body.Text.Length; cursor++)
|
||||||
|
{
|
||||||
|
if (body.Text[cursor] == ';' && body.OpenerStack(cursor).Count == 0)
|
||||||
|
{
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return body.Text.Length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A splice: replace <c>[Start, End)</c> of the bundle with <see cref="Text"/>.</summary>
|
||||||
|
internal sealed class JsEdit
|
||||||
|
{
|
||||||
|
public JsEdit(int start, int end, string text)
|
||||||
|
{
|
||||||
|
Start = start;
|
||||||
|
End = end;
|
||||||
|
Text = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An insertion at <paramref name="at"/>, replacing nothing.</summary>
|
||||||
|
public JsEdit(int at, string text) : this(at, at, text)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Start { get; }
|
||||||
|
public int End { get; }
|
||||||
|
public string Text { get; }
|
||||||
|
|
||||||
|
public string ApplyTo(string source) => source.Substring(0, Start) + Text + source.Substring(End);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace WandEnhancer.Core.Js
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Loads injected JavaScript from embedded <c>Patches/*.js</c> files so payloads stay
|
||||||
|
/// lintable source rather than escaped C# string literals.
|
||||||
|
/// </summary>
|
||||||
|
internal static class PatchPayload
|
||||||
|
{
|
||||||
|
private const string ResourcePrefix = "patches/";
|
||||||
|
|
||||||
|
private static readonly ConcurrentDictionary<string, string> Cache =
|
||||||
|
new ConcurrentDictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
private static readonly Regex Placeholder = new Regex(@"\$\{(?<name>\w+)\}");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads a payload, replacing each <c>${name}</c> placeholder from alternating name/value pairs.
|
||||||
|
/// Substitution is a single pass, so injected bundle text is never rescanned for placeholders.
|
||||||
|
/// Unknown placeholders are left intact for the caller's own regex replacement to resolve.
|
||||||
|
/// </summary>
|
||||||
|
public static string Load(string name, params string[] placeholders)
|
||||||
|
{
|
||||||
|
if (placeholders.Length % 2 != 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Placeholders must be name/value pairs", nameof(placeholders));
|
||||||
|
}
|
||||||
|
|
||||||
|
var values = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
for (int index = 0; index < placeholders.Length; index += 2)
|
||||||
|
{
|
||||||
|
values[placeholders[index]] = placeholders[index + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Placeholder.Replace(
|
||||||
|
Cache.GetOrAdd(name, ReadResource),
|
||||||
|
match => values.TryGetValue(match.Groups["name"].Value, out var value) ? value : match.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadResource(string name)
|
||||||
|
{
|
||||||
|
string resourceName = $"{ResourcePrefix}{name}.js";
|
||||||
|
using (var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName))
|
||||||
|
{
|
||||||
|
if (stream == null)
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException($"Embedded patch payload not found: {resourceName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var reader = new StreamReader(stream))
|
||||||
|
{
|
||||||
|
return reader.ReadToEnd().Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using WandEnhancer.View.MainWindow;
|
||||||
|
|
||||||
|
namespace WandEnhancer.Core
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Append-only log written next to the deployed launcher. Launch mode has no window and
|
||||||
|
/// exits as soon as Wand is up, so without this file a "Wand does not start" report
|
||||||
|
/// carries no evidence at all. Every operation swallows its own failure: diagnostics must
|
||||||
|
/// never be the reason Wand fails to launch.
|
||||||
|
/// </summary>
|
||||||
|
internal static class LauncherLog
|
||||||
|
{
|
||||||
|
public const string FileName = "launcher.log";
|
||||||
|
private const long MaxBytes = 512 * 1024;
|
||||||
|
|
||||||
|
private static string _path;
|
||||||
|
|
||||||
|
public static void Open(string launcherDirectory, string header)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var file = new FileInfo(Path.Combine(launcherDirectory, FileName));
|
||||||
|
// Dropped whole rather than trimmed: the session being diagnosed is the last
|
||||||
|
// one, and keeping half a rotated file is not worth the code.
|
||||||
|
if (file.Exists && file.Length > MaxBytes)
|
||||||
|
{
|
||||||
|
file.Delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
_path = file.FullName;
|
||||||
|
Write($"=== {DateTime.Now:yyyy-MM-dd} {header}", ELogType.Info);
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is IOException || e is UnauthorizedAccessException ||
|
||||||
|
e is ArgumentException || e is NotSupportedException)
|
||||||
|
{
|
||||||
|
_path = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Write(string message, ELogType type)
|
||||||
|
{
|
||||||
|
if (_path == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.AppendAllText(_path,
|
||||||
|
$"{DateTime.Now:HH:mm:ss.fff} [{type.ToString().ToUpperInvariant()}] {message}{Environment.NewLine}");
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is IOException || e is UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// A log line lost to a locked or full disk must not abort the launch.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,22 @@ namespace WandEnhancer.Core.Services
|
|||||||
|
|
||||||
private static CultureInfo _currentLanguage;
|
private static CultureInfo _currentLanguage;
|
||||||
private static ResourceDictionary _englishBaseDictionary;
|
private static ResourceDictionary _englishBaseDictionary;
|
||||||
|
private static ResourceDictionary _activeLocaleDictionary;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Localized string for <paramref name="key"/>, falling back to the key itself so a
|
||||||
|
/// missing entry is visible rather than silently blank.
|
||||||
|
/// </summary>
|
||||||
|
public static string Get(string key)
|
||||||
|
{
|
||||||
|
return Application.Current?.TryFindResource(key) as string ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Localized format string filled with <paramref name="args"/>.</summary>
|
||||||
|
public static string Format(string key, params object[] args)
|
||||||
|
{
|
||||||
|
return string.Format(Get(key), args);
|
||||||
|
}
|
||||||
|
|
||||||
public static CultureInfo CurrentLanguage
|
public static CultureInfo CurrentLanguage
|
||||||
{
|
{
|
||||||
@@ -104,20 +120,19 @@ namespace WandEnhancer.Core.Services
|
|||||||
localeDict[entry.Key] = targetDict[entry.Key];
|
localeDict[entry.Key] = targetDict[entry.Key];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find and replace the old locale dictionary
|
// Track the dictionary we injected: it is built by merging entries, so its Source is
|
||||||
var oldDict = Application.Current.Resources.MergedDictionaries
|
// null and a Source-based lookup never finds it - every switch used to append another.
|
||||||
.FirstOrDefault(d => d.Source != null && d.Source.OriginalString.StartsWith("Locale/lang."));
|
var merged = Application.Current.Resources.MergedDictionaries;
|
||||||
|
if (_activeLocaleDictionary != null && merged.Contains(_activeLocaleDictionary))
|
||||||
if (oldDict != null)
|
|
||||||
{
|
{
|
||||||
var index = Application.Current.Resources.MergedDictionaries.IndexOf(oldDict);
|
merged[merged.IndexOf(_activeLocaleDictionary)] = localeDict;
|
||||||
Application.Current.Resources.MergedDictionaries.Remove(oldDict);
|
|
||||||
Application.Current.Resources.MergedDictionaries.Insert(index, localeDict);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Application.Current.Resources.MergedDictionaries.Add(localeDict);
|
merged.Add(localeDict);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_activeLocaleDictionary = localeDict;
|
||||||
|
|
||||||
if (saveSettings)
|
if (saveSettings)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ namespace WandEnhancer.Core.Services
|
|||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
// Settings loading is non-critical - silently fall back to defaults
|
// Unreadable or corrupt settings must not block startup; defaults apply.
|
||||||
// This can fail due to file permissions, corrupted JSON, etc.
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -42,8 +41,7 @@ namespace WandEnhancer.Core.Services
|
|||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
// Settings saving is non-critical - silently ignore errors
|
// A read-only install directory must not break the app; the choice is lost, not fatal.
|
||||||
// This can fail due to file permissions or read-only directories
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
<!--#region MainWindow -->
|
<!--#region MainWindow -->
|
||||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
|
||||||
<s:String x:Key="mw_update_available">Eine neue Version ist verfügbar</s:String>
|
|
||||||
<s:String x:Key="mw_folder_path">Ordnerpfad</s:String>
|
<s:String x:Key="mw_folder_path">Ordnerpfad</s:String>
|
||||||
<s:String x:Key="mw_folder_not_found">Ordner nicht gefunden</s:String>
|
<s:String x:Key="mw_folder_not_found">Ordner nicht gefunden</s:String>
|
||||||
<s:String x:Key="mw_patch">Anwenden</s:String>
|
<s:String x:Key="mw_patch">Anwenden</s:String>
|
||||||
@@ -36,21 +34,25 @@
|
|||||||
<s:String x:Key="pv_custom_scripts_hint">Ausgewählte .js-Dateien werden in Wand gepackt und im Renderer geladen.</s:String>
|
<s:String x:Key="pv_custom_scripts_hint">Ausgewählte .js-Dateien werden in Wand gepackt und im Renderer geladen.</s:String>
|
||||||
<s:String x:Key="pv_no_custom_scripts">Keine Skripte ausgewählt</s:String>
|
<s:String x:Key="pv_no_custom_scripts">Keine Skripte ausgewählt</s:String>
|
||||||
<s:String x:Key="pv_start">Starten</s:String>
|
<s:String x:Key="pv_start">Starten</s:String>
|
||||||
|
<s:String x:Key="pv_auto_apply">Nach Updates automatisch anwenden</s:String>
|
||||||
<s:String x:Key="pv_popup_title">Was werden wir verbessern?</s:String>
|
<s:String x:Key="pv_popup_title">Was werden wir verbessern?</s:String>
|
||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
<!--#region UpdatePopup -->
|
<!--#region Runtime log -->
|
||||||
<s:String x:Key="up_warning">Vor dem Update wird dringend empfohlen, Änderungen rückgängig zu machen, falls sie angewendet wurden</s:String>
|
<s:String x:Key="log_install_found">WeMod-Verzeichnis unter {0} ({1}) gefunden</s:String>
|
||||||
<s:String x:Key="up_current_version">Aktuelle Version</s:String>
|
<s:String x:Key="log_already_patched">WeMod ist bereits gepatcht. Wenn Sie erneut patchen möchten, stellen Sie bitte zuerst das Backup wieder her.</s:String>
|
||||||
<s:String x:Key="up_latest_version">Neueste Version</s:String>
|
<s:String x:Key="log_ready">Bereit zum Patchen.</s:String>
|
||||||
<s:String x:Key="up_release_notes">Versionshinweise</s:String>
|
<s:String x:Key="log_install_not_found">WeMod-Verzeichnis nicht gefunden.</s:String>
|
||||||
<s:String x:Key="up_release_notes_unavailable">Für diese Version sind keine Versionshinweise verfügbar.</s:String>
|
<s:String x:Key="log_no_directory">Vorgang nicht möglich. Bitte geben Sie zuerst das Verzeichnis an.</s:String>
|
||||||
<s:String x:Key="up_show_more">Gesamtes Changelog anzeigen</s:String>
|
<s:String x:Key="log_invalid_directory">Der ausgewählte Ordner {0} ist kein gültiges WeMod-Verzeichnis.</s:String>
|
||||||
<s:String x:Key="up_show_less">Nur aktuelle Hinweise anzeigen</s:String>
|
<s:String x:Key="log_restore_failed">Fehler beim Wiederherstellen des Backups: {0}</s:String>
|
||||||
<s:String x:Key="up_loading_changelog">Changelog wird geladen...</s:String>
|
<s:String x:Key="log_patch_failed">Fehler beim Patchen: {0}</s:String>
|
||||||
<s:String x:Key="up_changelog_failed">Das vollständige Changelog konnte nicht geladen werden. Stattdessen werden die aktuellen Hinweise angezeigt.</s:String>
|
<s:String x:Key="log_copied">Protokolle in die Zwischenablage kopiert.</s:String>
|
||||||
<s:String x:Key="up_update_now">Jetzt aktualisieren</s:String>
|
<s:String x:Key="log_copy_failed">Fehler beim Kopieren der Protokolle: {0}</s:String>
|
||||||
<s:String x:Key="up_popup_title">Update verfügbar!</s:String>
|
<s:String x:Key="log_exported">Protokolle nach {0} exportiert.</s:String>
|
||||||
|
<s:String x:Key="log_export_failed">Fehler beim Exportieren der Protokolle: {0}</s:String>
|
||||||
|
<s:String x:Key="log_open_link_failed">{0} konnte nicht in einem Browser geöffnet werden.</s:String>
|
||||||
|
<s:String x:Key="dialog_pick_install">Wählen Sie das WeMod-Verzeichnis aus</s:String>
|
||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
<!--#region MainWindow -->
|
<!--#region MainWindow -->
|
||||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
|
||||||
<s:String x:Key="mw_update_available">A new version is available</s:String>
|
|
||||||
<s:String x:Key="mw_folder_path">Folder path</s:String>
|
<s:String x:Key="mw_folder_path">Folder path</s:String>
|
||||||
<s:String x:Key="mw_folder_not_found">Folder not found</s:String>
|
<s:String x:Key="mw_folder_not_found">Folder not found</s:String>
|
||||||
<s:String x:Key="mw_patch">Enhance</s:String>
|
<s:String x:Key="mw_patch">Enhance</s:String>
|
||||||
@@ -36,21 +34,25 @@
|
|||||||
<s:String x:Key="pv_custom_scripts_hint">Selected .js files are packed into Wand and loaded in the renderer.</s:String>
|
<s:String x:Key="pv_custom_scripts_hint">Selected .js files are packed into Wand and loaded in the renderer.</s:String>
|
||||||
<s:String x:Key="pv_no_custom_scripts">No scripts selected</s:String>
|
<s:String x:Key="pv_no_custom_scripts">No scripts selected</s:String>
|
||||||
<s:String x:Key="pv_start">Start</s:String>
|
<s:String x:Key="pv_start">Start</s:String>
|
||||||
|
<s:String x:Key="pv_auto_apply">Auto-apply after updates</s:String>
|
||||||
<s:String x:Key="pv_popup_title">What are we gonna enhance?</s:String>
|
<s:String x:Key="pv_popup_title">What are we gonna enhance?</s:String>
|
||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
<!--#region UpdatePopup -->
|
<!--#region Runtime log -->
|
||||||
<s:String x:Key="up_warning">Before updating, it is strongly recommended to roll back modifications if they have been applied</s:String>
|
<s:String x:Key="log_install_found">WeMod directory found at {0} ({1})</s:String>
|
||||||
<s:String x:Key="up_current_version">Current version</s:String>
|
<s:String x:Key="log_already_patched">WeMod already patched. If you want to patch again, please restore the backup first.</s:String>
|
||||||
<s:String x:Key="up_latest_version">Latest version</s:String>
|
<s:String x:Key="log_ready">Ready for patching.</s:String>
|
||||||
<s:String x:Key="up_release_notes">Release notes</s:String>
|
<s:String x:Key="log_install_not_found">WeMod directory not found.</s:String>
|
||||||
<s:String x:Key="up_release_notes_unavailable">Release notes are unavailable for this release.</s:String>
|
<s:String x:Key="log_no_directory">Cant be done. Please specify the directory first.</s:String>
|
||||||
<s:String x:Key="up_show_more">Show full changelog</s:String>
|
<s:String x:Key="log_invalid_directory">The selected folder {0} is not a valid WeMod directory.</s:String>
|
||||||
<s:String x:Key="up_show_less">Show latest notes</s:String>
|
<s:String x:Key="log_restore_failed">Failed to restore backup: {0}</s:String>
|
||||||
<s:String x:Key="up_loading_changelog">Loading changelog...</s:String>
|
<s:String x:Key="log_patch_failed">Failed to patch: {0}</s:String>
|
||||||
<s:String x:Key="up_changelog_failed">Failed to load the full changelog. The latest notes are shown instead.</s:String>
|
<s:String x:Key="log_copied">Logs copied to clipboard.</s:String>
|
||||||
<s:String x:Key="up_update_now">Update now</s:String>
|
<s:String x:Key="log_copy_failed">Failed to copy logs: {0}</s:String>
|
||||||
<s:String x:Key="up_popup_title">Update available!</s:String>
|
<s:String x:Key="log_exported">Logs exported to {0}.</s:String>
|
||||||
|
<s:String x:Key="log_export_failed">Failed to export logs: {0}</s:String>
|
||||||
|
<s:String x:Key="log_open_link_failed">Could not open {0} in a browser.</s:String>
|
||||||
|
<s:String x:Key="dialog_pick_install">Select the WeMod directory</s:String>
|
||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
<!--#region MainWindow -->
|
<!--#region MainWindow -->
|
||||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
|
||||||
<s:String x:Key="mw_update_available">Una nueva versión está disponible</s:String>
|
|
||||||
<s:String x:Key="mw_folder_path">Ruta de la carpeta</s:String>
|
<s:String x:Key="mw_folder_path">Ruta de la carpeta</s:String>
|
||||||
<s:String x:Key="mw_folder_not_found">Carpeta no encontrada</s:String>
|
<s:String x:Key="mw_folder_not_found">Carpeta no encontrada</s:String>
|
||||||
<s:String x:Key="mw_patch">Aplicar</s:String>
|
<s:String x:Key="mw_patch">Aplicar</s:String>
|
||||||
@@ -36,21 +34,25 @@
|
|||||||
<s:String x:Key="pv_custom_scripts_hint">Los archivos .js seleccionados se empaquetan en Wand y se cargan en el renderer.</s:String>
|
<s:String x:Key="pv_custom_scripts_hint">Los archivos .js seleccionados se empaquetan en Wand y se cargan en el renderer.</s:String>
|
||||||
<s:String x:Key="pv_no_custom_scripts">No hay scripts seleccionados</s:String>
|
<s:String x:Key="pv_no_custom_scripts">No hay scripts seleccionados</s:String>
|
||||||
<s:String x:Key="pv_start">Iniciar</s:String>
|
<s:String x:Key="pv_start">Iniciar</s:String>
|
||||||
|
<s:String x:Key="pv_auto_apply">Aplicar automáticamente tras actualizar</s:String>
|
||||||
<s:String x:Key="pv_popup_title">¿Qué vamos a mejorar?</s:String>
|
<s:String x:Key="pv_popup_title">¿Qué vamos a mejorar?</s:String>
|
||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
<!--#region UpdatePopup -->
|
<!--#region Runtime log -->
|
||||||
<s:String x:Key="up_warning">Antes de actualizar, se recomienda encarecidamente revertir las modificaciones si se han aplicado</s:String>
|
<s:String x:Key="log_install_found">Directorio de WeMod encontrado en {0} ({1})</s:String>
|
||||||
<s:String x:Key="up_current_version">Versión actual</s:String>
|
<s:String x:Key="log_already_patched">WeMod ya está parcheado. Si quieres parchear de nuevo, restaura la copia de seguridad primero.</s:String>
|
||||||
<s:String x:Key="up_latest_version">Última versión</s:String>
|
<s:String x:Key="log_ready">Listo para parchear.</s:String>
|
||||||
<s:String x:Key="up_release_notes">Notas de la versión</s:String>
|
<s:String x:Key="log_install_not_found">Directorio de WeMod no encontrado.</s:String>
|
||||||
<s:String x:Key="up_release_notes_unavailable">Las notas de la versión no están disponibles para esta versión.</s:String>
|
<s:String x:Key="log_no_directory">No se puede realizar. Por favor, especifica el directorio primero.</s:String>
|
||||||
<s:String x:Key="up_show_more">Mostrar changelog completo</s:String>
|
<s:String x:Key="log_invalid_directory">La carpeta seleccionada {0} no es un directorio de WeMod válido.</s:String>
|
||||||
<s:String x:Key="up_show_less">Mostrar solo las notas actuales</s:String>
|
<s:String x:Key="log_restore_failed">Error al restaurar la copia de seguridad: {0}</s:String>
|
||||||
<s:String x:Key="up_loading_changelog">Cargando changelog...</s:String>
|
<s:String x:Key="log_patch_failed">Error al parchear: {0}</s:String>
|
||||||
<s:String x:Key="up_changelog_failed">No se pudo cargar el changelog completo. Se muestran las notas actuales.</s:String>
|
<s:String x:Key="log_copied">Registros copiados al portapapeles.</s:String>
|
||||||
<s:String x:Key="up_update_now">Actualizar ahora</s:String>
|
<s:String x:Key="log_copy_failed">Error al copiar los registros: {0}</s:String>
|
||||||
<s:String x:Key="up_popup_title">¡Actualización disponible!</s:String>
|
<s:String x:Key="log_exported">Registros exportados a {0}.</s:String>
|
||||||
|
<s:String x:Key="log_export_failed">Error al exportar los registros: {0}</s:String>
|
||||||
|
<s:String x:Key="log_open_link_failed">No se pudo abrir {0} en un navegador.</s:String>
|
||||||
|
<s:String x:Key="dialog_pick_install">Selecciona el directorio de WeMod</s:String>
|
||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
<!--#region MainWindow -->
|
<!--#region MainWindow -->
|
||||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
|
||||||
<s:String x:Key="mw_update_available">Une nouvelle version est disponible</s:String>
|
|
||||||
<s:String x:Key="mw_folder_path">Chemin du dossier</s:String>
|
<s:String x:Key="mw_folder_path">Chemin du dossier</s:String>
|
||||||
<s:String x:Key="mw_folder_not_found">Dossier non trouvé</s:String>
|
<s:String x:Key="mw_folder_not_found">Dossier non trouvé</s:String>
|
||||||
<s:String x:Key="mw_patch">Appliquer</s:String>
|
<s:String x:Key="mw_patch">Appliquer</s:String>
|
||||||
@@ -36,21 +34,25 @@
|
|||||||
<s:String x:Key="pv_custom_scripts_hint">Les fichiers .js sélectionnés sont intégrés dans Wand et chargés dans le renderer.</s:String>
|
<s:String x:Key="pv_custom_scripts_hint">Les fichiers .js sélectionnés sont intégrés dans Wand et chargés dans le renderer.</s:String>
|
||||||
<s:String x:Key="pv_no_custom_scripts">Aucun script sélectionné</s:String>
|
<s:String x:Key="pv_no_custom_scripts">Aucun script sélectionné</s:String>
|
||||||
<s:String x:Key="pv_start">Démarrer</s:String>
|
<s:String x:Key="pv_start">Démarrer</s:String>
|
||||||
|
<s:String x:Key="pv_auto_apply">Appliquer automatiquement après les mises à jour</s:String>
|
||||||
<s:String x:Key="pv_popup_title">Qu'allons-nous modifier ?</s:String>
|
<s:String x:Key="pv_popup_title">Qu'allons-nous modifier ?</s:String>
|
||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
<!--#region UpdatePopup -->
|
<!--#region Runtime log -->
|
||||||
<s:String x:Key="up_warning">Avant la mise à jour, il est fortement recommandé d'annuler les modifications si elles ont été appliquées</s:String>
|
<s:String x:Key="log_install_found">Répertoire WeMod trouvé à {0} ({1})</s:String>
|
||||||
<s:String x:Key="up_current_version">Version actuelle</s:String>
|
<s:String x:Key="log_already_patched">WeMod est déjà patché. Si vous souhaitez le patcher à nouveau, veuillez d'abord restaurer la sauvegarde.</s:String>
|
||||||
<s:String x:Key="up_latest_version">Dernière version</s:String>
|
<s:String x:Key="log_ready">Prêt pour le patch.</s:String>
|
||||||
<s:String x:Key="up_release_notes">Notes de version</s:String>
|
<s:String x:Key="log_install_not_found">Répertoire WeMod introuvable.</s:String>
|
||||||
<s:String x:Key="up_release_notes_unavailable">Les notes de version ne sont pas disponibles pour cette version.</s:String>
|
<s:String x:Key="log_no_directory">Impossible. Veuillez d'abord spécifier le répertoire.</s:String>
|
||||||
<s:String x:Key="up_show_more">Afficher le changelog complet</s:String>
|
<s:String x:Key="log_invalid_directory">Le dossier sélectionné {0} n'est pas un répertoire WeMod valide.</s:String>
|
||||||
<s:String x:Key="up_show_less">Afficher uniquement les notes actuelles</s:String>
|
<s:String x:Key="log_restore_failed">Échec de la restauration de la sauvegarde : {0}</s:String>
|
||||||
<s:String x:Key="up_loading_changelog">Chargement du changelog...</s:String>
|
<s:String x:Key="log_patch_failed">Échec du patch : {0}</s:String>
|
||||||
<s:String x:Key="up_changelog_failed">Impossible de charger le changelog complet. Les notes actuelles sont affichées à la place.</s:String>
|
<s:String x:Key="log_copied">Journaux copiés dans le presse-papiers.</s:String>
|
||||||
<s:String x:Key="up_update_now">Mettre à jour maintenant</s:String>
|
<s:String x:Key="log_copy_failed">Échec de la copie des journaux : {0}</s:String>
|
||||||
<s:String x:Key="up_popup_title">Mise à jour disponible !</s:String>
|
<s:String x:Key="log_exported">Journaux exportés vers {0}.</s:String>
|
||||||
|
<s:String x:Key="log_export_failed">Échec de l'exportation des journaux : {0}</s:String>
|
||||||
|
<s:String x:Key="log_open_link_failed">Impossible d'ouvrir {0} dans un navigateur.</s:String>
|
||||||
|
<s:String x:Key="dialog_pick_install">Sélectionnez le répertoire WeMod</s:String>
|
||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
<!--#region MainWindow -->
|
<!--#region MainWindow -->
|
||||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
|
||||||
<s:String x:Key="mw_update_available">È disponibile una nuova versione</s:String>
|
|
||||||
<s:String x:Key="mw_folder_path">Percorso cartella</s:String>
|
<s:String x:Key="mw_folder_path">Percorso cartella</s:String>
|
||||||
<s:String x:Key="mw_folder_not_found">Cartella non trovata</s:String>
|
<s:String x:Key="mw_folder_not_found">Cartella non trovata</s:String>
|
||||||
<s:String x:Key="mw_patch">Applica</s:String>
|
<s:String x:Key="mw_patch">Applica</s:String>
|
||||||
@@ -36,21 +34,25 @@
|
|||||||
<s:String x:Key="pv_custom_scripts_hint">I file .js selezionati vengono inseriti in Wand e caricati nel renderer.</s:String>
|
<s:String x:Key="pv_custom_scripts_hint">I file .js selezionati vengono inseriti in Wand e caricati nel renderer.</s:String>
|
||||||
<s:String x:Key="pv_no_custom_scripts">Nessuno script selezionato</s:String>
|
<s:String x:Key="pv_no_custom_scripts">Nessuno script selezionato</s:String>
|
||||||
<s:String x:Key="pv_start">Avvia</s:String>
|
<s:String x:Key="pv_start">Avvia</s:String>
|
||||||
|
<s:String x:Key="pv_auto_apply">Applica automaticamente dopo gli aggiornamenti</s:String>
|
||||||
<s:String x:Key="pv_popup_title">Cosa modificheremo?</s:String>
|
<s:String x:Key="pv_popup_title">Cosa modificheremo?</s:String>
|
||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
<!--#region UpdatePopup -->
|
<!--#region Runtime log -->
|
||||||
<s:String x:Key="up_warning">Prima dell'aggiornamento, si consiglia vivamente di annullare le modifiche se sono state applicate</s:String>
|
<s:String x:Key="log_install_found">Directory di WeMod trovata in {0} ({1})</s:String>
|
||||||
<s:String x:Key="up_current_version">Versione corrente</s:String>
|
<s:String x:Key="log_already_patched">WeMod è già stato patchato. Se vuoi patchare di nuovo, ripristina prima il backup.</s:String>
|
||||||
<s:String x:Key="up_latest_version">Ultima versione</s:String>
|
<s:String x:Key="log_ready">Pronto per il patching.</s:String>
|
||||||
<s:String x:Key="up_release_notes">Note di rilascio</s:String>
|
<s:String x:Key="log_install_not_found">Directory di WeMod non trovata.</s:String>
|
||||||
<s:String x:Key="up_release_notes_unavailable">Le note di rilascio non sono disponibili per questa versione.</s:String>
|
<s:String x:Key="log_no_directory">Impossibile procedere. Specifica prima la directory.</s:String>
|
||||||
<s:String x:Key="up_show_more">Mostra il changelog completo</s:String>
|
<s:String x:Key="log_invalid_directory">La cartella selezionata {0} non è una directory valida di WeMod.</s:String>
|
||||||
<s:String x:Key="up_show_less">Mostra solo le note correnti</s:String>
|
<s:String x:Key="log_restore_failed">Impossibile ripristinare il backup: {0}</s:String>
|
||||||
<s:String x:Key="up_loading_changelog">Caricamento del changelog...</s:String>
|
<s:String x:Key="log_patch_failed">Impossibile eseguire il patch: {0}</s:String>
|
||||||
<s:String x:Key="up_changelog_failed">Impossibile caricare il changelog completo. Vengono mostrate solo le note correnti.</s:String>
|
<s:String x:Key="log_copied">Log copiati negli appunti.</s:String>
|
||||||
<s:String x:Key="up_update_now">Aggiorna ora</s:String>
|
<s:String x:Key="log_copy_failed">Impossibile copiare i log: {0}</s:String>
|
||||||
<s:String x:Key="up_popup_title">Aggiornamento disponibile!</s:String>
|
<s:String x:Key="log_exported">Log esportati in {0}.</s:String>
|
||||||
|
<s:String x:Key="log_export_failed">Impossibile esportare i log: {0}</s:String>
|
||||||
|
<s:String x:Key="log_open_link_failed">Impossibile aprire {0} in un browser.</s:String>
|
||||||
|
<s:String x:Key="dialog_pick_install">Seleziona la directory di WeMod</s:String>
|
||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
<!--#region MainWindow -->
|
<!--#region MainWindow -->
|
||||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
|
||||||
<s:String x:Key="mw_update_available">新しいバージョンが利用可能です</s:String>
|
|
||||||
<s:String x:Key="mw_folder_path">フォルダパス</s:String>
|
<s:String x:Key="mw_folder_path">フォルダパス</s:String>
|
||||||
<s:String x:Key="mw_folder_not_found">フォルダが見つかりません</s:String>
|
<s:String x:Key="mw_folder_not_found">フォルダが見つかりません</s:String>
|
||||||
<s:String x:Key="mw_patch">適用</s:String>
|
<s:String x:Key="mw_patch">適用</s:String>
|
||||||
@@ -36,21 +34,25 @@
|
|||||||
<s:String x:Key="pv_custom_scripts_hint">選択した .js ファイルは Wand に組み込まれ、レンダラーで読み込まれます。</s:String>
|
<s:String x:Key="pv_custom_scripts_hint">選択した .js ファイルは Wand に組み込まれ、レンダラーで読み込まれます。</s:String>
|
||||||
<s:String x:Key="pv_no_custom_scripts">スクリプトが選択されていません</s:String>
|
<s:String x:Key="pv_no_custom_scripts">スクリプトが選択されていません</s:String>
|
||||||
<s:String x:Key="pv_start">開始</s:String>
|
<s:String x:Key="pv_start">開始</s:String>
|
||||||
|
<s:String x:Key="pv_auto_apply">更新後に自動適用</s:String>
|
||||||
<s:String x:Key="pv_popup_title">何を改善しますか?</s:String>
|
<s:String x:Key="pv_popup_title">何を改善しますか?</s:String>
|
||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
<!--#region UpdatePopup -->
|
<!--#region Runtime log -->
|
||||||
<s:String x:Key="up_warning">アップデート前に、変更が適用されている場合はロールバックすることを強くお勧めします</s:String>
|
<s:String x:Key="log_install_found">WeModディレクトリが {0} ({1}) に見つかりました</s:String>
|
||||||
<s:String x:Key="up_current_version">現在のバージョン</s:String>
|
<s:String x:Key="log_already_patched">WeModは既にパッチが適用されています。もう一度パッチを適用する場合は、まずバックアップを復元してください。</s:String>
|
||||||
<s:String x:Key="up_latest_version">最新バージョン</s:String>
|
<s:String x:Key="log_ready">パッチ適用の準備ができました。</s:String>
|
||||||
<s:String x:Key="up_release_notes">リリースノート</s:String>
|
<s:String x:Key="log_install_not_found">WeModディレクトリが見つかりません。</s:String>
|
||||||
<s:String x:Key="up_release_notes_unavailable">このリリースのリリースノートは利用できません。</s:String>
|
<s:String x:Key="log_no_directory">実行できません。先にディレクトリを指定してください。</s:String>
|
||||||
<s:String x:Key="up_show_more">完全な変更履歴を表示</s:String>
|
<s:String x:Key="log_invalid_directory">選択したフォルダ {0} は有効なWeModディレクトリではありません。</s:String>
|
||||||
<s:String x:Key="up_show_less">最新のリリースノートのみ表示</s:String>
|
<s:String x:Key="log_restore_failed">バックアップの復元に失敗しました: {0}</s:String>
|
||||||
<s:String x:Key="up_loading_changelog">変更履歴を読み込み中...</s:String>
|
<s:String x:Key="log_patch_failed">パッチの適用に失敗しました: {0}</s:String>
|
||||||
<s:String x:Key="up_changelog_failed">完全な変更履歴を読み込めませんでした。代わりに最新のリリースノートを表示しています。</s:String>
|
<s:String x:Key="log_copied">ログをクリップボードにコピーしました。</s:String>
|
||||||
<s:String x:Key="up_update_now">今すぐ更新</s:String>
|
<s:String x:Key="log_copy_failed">ログのコピーに失敗しました: {0}</s:String>
|
||||||
<s:String x:Key="up_popup_title">アップデート利用可能!</s:String>
|
<s:String x:Key="log_exported">ログを {0} にエクスポートしました。</s:String>
|
||||||
|
<s:String x:Key="log_export_failed">ログのエクスポートに失敗しました: {0}</s:String>
|
||||||
|
<s:String x:Key="log_open_link_failed">{0} をブラウザで開くことができませんでした。</s:String>
|
||||||
|
<s:String x:Key="dialog_pick_install">WeModディレクトリを選択してください</s:String>
|
||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
<!--#region MainWindow -->
|
<!--#region MainWindow -->
|
||||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
|
||||||
<s:String x:Key="mw_update_available">Dostępna jest nowa wersja</s:String>
|
|
||||||
<s:String x:Key="mw_folder_path">Ścieżka folderu</s:String>
|
<s:String x:Key="mw_folder_path">Ścieżka folderu</s:String>
|
||||||
<s:String x:Key="mw_folder_not_found">Folder nie znaleziony</s:String>
|
<s:String x:Key="mw_folder_not_found">Folder nie znaleziony</s:String>
|
||||||
<s:String x:Key="mw_patch">Zastosuj</s:String>
|
<s:String x:Key="mw_patch">Zastosuj</s:String>
|
||||||
@@ -36,21 +34,25 @@
|
|||||||
<s:String x:Key="pv_custom_scripts_hint">Wybrane pliki .js są pakowane do Wand i ładowane w rendererze.</s:String>
|
<s:String x:Key="pv_custom_scripts_hint">Wybrane pliki .js są pakowane do Wand i ładowane w rendererze.</s:String>
|
||||||
<s:String x:Key="pv_no_custom_scripts">Nie wybrano skryptów</s:String>
|
<s:String x:Key="pv_no_custom_scripts">Nie wybrano skryptów</s:String>
|
||||||
<s:String x:Key="pv_start">Rozpocznij</s:String>
|
<s:String x:Key="pv_start">Rozpocznij</s:String>
|
||||||
|
<s:String x:Key="pv_auto_apply">Zastosuj automatycznie po aktualizacji</s:String>
|
||||||
<s:String x:Key="pv_popup_title">Co będziemy ulepszać?</s:String>
|
<s:String x:Key="pv_popup_title">Co będziemy ulepszać?</s:String>
|
||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
<!--#region UpdatePopup -->
|
<!--#region Runtime log -->
|
||||||
<s:String x:Key="up_warning">Przed aktualizacją zdecydowanie zaleca się cofnięcie zmian, jeśli zostały zastosowane</s:String>
|
<s:String x:Key="log_install_found">Katalog WeMod znaleziony w {0} ({1})</s:String>
|
||||||
<s:String x:Key="up_current_version">Aktualna wersja</s:String>
|
<s:String x:Key="log_already_patched">WeMod został już zaktualizowany. Jeśli chcesz zaktualizować ponownie, najpierw przywróć kopię zapasową.</s:String>
|
||||||
<s:String x:Key="up_latest_version">Najnowsza wersja</s:String>
|
<s:String x:Key="log_ready">Gotowy do aktualizacji (patchowania).</s:String>
|
||||||
<s:String x:Key="up_release_notes">Informacje o wydaniu</s:String>
|
<s:String x:Key="log_install_not_found">Nie znaleziono katalogu WeMod.</s:String>
|
||||||
<s:String x:Key="up_release_notes_unavailable">Informacje o wydaniu są niedostępne dla tej wersji.</s:String>
|
<s:String x:Key="log_no_directory">Nie można tego zrobić. Proszę najpierw określić katalog.</s:String>
|
||||||
<s:String x:Key="up_show_more">Pokaż cały changelog</s:String>
|
<s:String x:Key="log_invalid_directory">Wybrany folder {0} nie jest prawidłowym katalogiem WeMod.</s:String>
|
||||||
<s:String x:Key="up_show_less">Pokaż tylko bieżące zmiany</s:String>
|
<s:String x:Key="log_restore_failed">Nie udało się przywrócić kopii zapasowej: {0}</s:String>
|
||||||
<s:String x:Key="up_loading_changelog">Ładowanie changeloga...</s:String>
|
<s:String x:Key="log_patch_failed">Nie udało się zaktualizować: {0}</s:String>
|
||||||
<s:String x:Key="up_changelog_failed">Nie udało się załadować pełnego changeloga. Zamiast tego wyświetlono bieżące zmiany.</s:String>
|
<s:String x:Key="log_copied">Logi skopiowane do schowka.</s:String>
|
||||||
<s:String x:Key="up_update_now">Aktualizuj teraz</s:String>
|
<s:String x:Key="log_copy_failed">Nie udało się skopiować logów: {0}</s:String>
|
||||||
<s:String x:Key="up_popup_title">Dostępna aktualizacja!</s:String>
|
<s:String x:Key="log_exported">Logi wyeksportowane do {0}.</s:String>
|
||||||
|
<s:String x:Key="log_export_failed">Nie udało się wyeksportować logów: {0}</s:String>
|
||||||
|
<s:String x:Key="log_open_link_failed">Nie można otworzyć {0} w przeglądarce.</s:String>
|
||||||
|
<s:String x:Key="dialog_pick_install">Wybierz katalog WeMod</s:String>
|
||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
<!--#region MainWindow -->
|
<!--#region MainWindow -->
|
||||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
|
||||||
<s:String x:Key="mw_update_available">Uma nova versão está disponível</s:String>
|
|
||||||
<s:String x:Key="mw_folder_path">Caminho da pasta</s:String>
|
<s:String x:Key="mw_folder_path">Caminho da pasta</s:String>
|
||||||
<s:String x:Key="mw_folder_not_found">Pasta não encontrada</s:String>
|
<s:String x:Key="mw_folder_not_found">Pasta não encontrada</s:String>
|
||||||
<s:String x:Key="mw_patch">Aplicar</s:String>
|
<s:String x:Key="mw_patch">Aplicar</s:String>
|
||||||
@@ -36,21 +34,25 @@
|
|||||||
<s:String x:Key="pv_custom_scripts_hint">Os arquivos .js selecionados são empacotados no Wand e carregados no renderer.</s:String>
|
<s:String x:Key="pv_custom_scripts_hint">Os arquivos .js selecionados são empacotados no Wand e carregados no renderer.</s:String>
|
||||||
<s:String x:Key="pv_no_custom_scripts">Nenhum script selecionado</s:String>
|
<s:String x:Key="pv_no_custom_scripts">Nenhum script selecionado</s:String>
|
||||||
<s:String x:Key="pv_start">Iniciar</s:String>
|
<s:String x:Key="pv_start">Iniciar</s:String>
|
||||||
|
<s:String x:Key="pv_auto_apply">Aplicar automaticamente após atualizações</s:String>
|
||||||
<s:String x:Key="pv_popup_title">O que vamos melhorar?</s:String>
|
<s:String x:Key="pv_popup_title">O que vamos melhorar?</s:String>
|
||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
<!--#region UpdatePopup -->
|
<!--#region Runtime log -->
|
||||||
<s:String x:Key="up_warning">Antes de atualizar, é altamente recomendável reverter as modificações se elas foram aplicadas</s:String>
|
<s:String x:Key="log_install_found">Diretório do WeMod encontrado em {0} ({1})</s:String>
|
||||||
<s:String x:Key="up_current_version">Versão atual</s:String>
|
<s:String x:Key="log_already_patched">O WeMod já foi modificado. Se quiser modificar novamente, restaure o backup primeiro.</s:String>
|
||||||
<s:String x:Key="up_latest_version">Versão mais recente</s:String>
|
<s:String x:Key="log_ready">Pronto para modificar.</s:String>
|
||||||
<s:String x:Key="up_release_notes">Notas da versão</s:String>
|
<s:String x:Key="log_install_not_found">Diretório do WeMod não encontrado.</s:String>
|
||||||
<s:String x:Key="up_release_notes_unavailable">As notas da versão não estão disponíveis para esta versão.</s:String>
|
<s:String x:Key="log_no_directory">Não é possível fazer isso. Por favor, especifique o diretório primeiro.</s:String>
|
||||||
<s:String x:Key="up_show_more">Mostrar changelog completo</s:String>
|
<s:String x:Key="log_invalid_directory">A pasta selecionada {0} não é um diretório válido do WeMod.</s:String>
|
||||||
<s:String x:Key="up_show_less">Mostrar apenas as notas atuais</s:String>
|
<s:String x:Key="log_restore_failed">Falha ao restaurar o backup: {0}</s:String>
|
||||||
<s:String x:Key="up_loading_changelog">Carregando changelog...</s:String>
|
<s:String x:Key="log_patch_failed">Falha ao modificar: {0}</s:String>
|
||||||
<s:String x:Key="up_changelog_failed">Falha ao carregar o changelog completo. As notas atuais estão sendo exibidas.</s:String>
|
<s:String x:Key="log_copied">Logs copiados para a área de transferência.</s:String>
|
||||||
<s:String x:Key="up_update_now">Atualizar agora</s:String>
|
<s:String x:Key="log_copy_failed">Falha ao copiar logs: {0}</s:String>
|
||||||
<s:String x:Key="up_popup_title">Atualização disponível!</s:String>
|
<s:String x:Key="log_exported">Logs exportados para {0}.</s:String>
|
||||||
|
<s:String x:Key="log_export_failed">Falha ao exportar logs: {0}</s:String>
|
||||||
|
<s:String x:Key="log_open_link_failed">Não foi possível abrir {0} no navegador.</s:String>
|
||||||
|
<s:String x:Key="dialog_pick_install">Selecione o diretório do WeMod</s:String>
|
||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
<!--#region MainWindow -->
|
<!--#region MainWindow -->
|
||||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
|
||||||
<s:String x:Key="mw_update_available">Доступна новая версия</s:String>
|
|
||||||
<s:String x:Key="mw_folder_path">Путь к папке</s:String>
|
<s:String x:Key="mw_folder_path">Путь к папке</s:String>
|
||||||
<s:String x:Key="mw_folder_not_found">Папка не найдена</s:String>
|
<s:String x:Key="mw_folder_not_found">Папка не найдена</s:String>
|
||||||
<s:String x:Key="mw_patch">Применить</s:String>
|
<s:String x:Key="mw_patch">Применить</s:String>
|
||||||
@@ -36,21 +34,25 @@
|
|||||||
<s:String x:Key="pv_custom_scripts_hint">Выбранные .js попадут в Wand и загрузятся в renderer.</s:String>
|
<s:String x:Key="pv_custom_scripts_hint">Выбранные .js попадут в Wand и загрузятся в renderer.</s:String>
|
||||||
<s:String x:Key="pv_no_custom_scripts">Скрипты не выбраны</s:String>
|
<s:String x:Key="pv_no_custom_scripts">Скрипты не выбраны</s:String>
|
||||||
<s:String x:Key="pv_start">Начать</s:String>
|
<s:String x:Key="pv_start">Начать</s:String>
|
||||||
|
<s:String x:Key="pv_auto_apply">Авто-патч после обновлений</s:String>
|
||||||
<s:String x:Key="pv_popup_title">Что будем улучшать?</s:String>
|
<s:String x:Key="pv_popup_title">Что будем улучшать?</s:String>
|
||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
<!--#region UpdatePopup -->
|
<!--#region Runtime log -->
|
||||||
<s:String x:Key="up_warning">Перед обновлением настоятельно рекомендуется откатить изменения, если они были применены</s:String>
|
<s:String x:Key="log_install_found">Директория WeMod найдена в {0} ({1})</s:String>
|
||||||
<s:String x:Key="up_current_version">Текущая версия</s:String>
|
<s:String x:Key="log_already_patched">WeMod уже пропатчен. Если вы хотите пропатчить снова, сначала восстановите резервную копию.</s:String>
|
||||||
<s:String x:Key="up_latest_version">Новая версия</s:String>
|
<s:String x:Key="log_ready">Готово к патчингу.</s:String>
|
||||||
<s:String x:Key="up_release_notes">Что нового</s:String>
|
<s:String x:Key="log_install_not_found">Директория WeMod не найдена.</s:String>
|
||||||
<s:String x:Key="up_release_notes_unavailable">Для этого релиза патчноуты недоступны.</s:String>
|
<s:String x:Key="log_no_directory">Невозможно выполнить. Пожалуйста, сначала укажите директорию.</s:String>
|
||||||
<s:String x:Key="up_show_more">Показать весь changelog</s:String>
|
<s:String x:Key="log_invalid_directory">Выбранная папка {0} не является допустимой директорией WeMod.</s:String>
|
||||||
<s:String x:Key="up_show_less">Показать только актуальные изменения</s:String>
|
<s:String x:Key="log_restore_failed">Не удалось восстановить резервную копию: {0}</s:String>
|
||||||
<s:String x:Key="up_loading_changelog">Загрузка changelog...</s:String>
|
<s:String x:Key="log_patch_failed">Не удалось пропатчить: {0}</s:String>
|
||||||
<s:String x:Key="up_changelog_failed">Не удалось загрузить полный changelog. Показаны только актуальные изменения.</s:String>
|
<s:String x:Key="log_copied">Логи скопированы в буфер обмена.</s:String>
|
||||||
<s:String x:Key="up_update_now">Обновить сейчас</s:String>
|
<s:String x:Key="log_copy_failed">Не удалось скопировать логи: {0}</s:String>
|
||||||
<s:String x:Key="up_popup_title">Доступно обновление!</s:String>
|
<s:String x:Key="log_exported">Логи экспортированы в {0}.</s:String>
|
||||||
|
<s:String x:Key="log_export_failed">Не удалось экспортировать логи: {0}</s:String>
|
||||||
|
<s:String x:Key="log_open_link_failed">Не удалось открыть {0} в браузере.</s:String>
|
||||||
|
<s:String x:Key="dialog_pick_install">Выберите директорию WeMod</s:String>
|
||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
<!--#region MainWindow -->
|
<!--#region MainWindow -->
|
||||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
|
||||||
<s:String x:Key="mw_update_available">Yeni bir sürüm mevcut</s:String>
|
|
||||||
<s:String x:Key="mw_folder_path">Klasör yolu</s:String>
|
<s:String x:Key="mw_folder_path">Klasör yolu</s:String>
|
||||||
<s:String x:Key="mw_folder_not_found">Klasör bulunamadı</s:String>
|
<s:String x:Key="mw_folder_not_found">Klasör bulunamadı</s:String>
|
||||||
<s:String x:Key="mw_patch">Uygula</s:String>
|
<s:String x:Key="mw_patch">Uygula</s:String>
|
||||||
@@ -36,21 +34,25 @@
|
|||||||
<s:String x:Key="pv_custom_scripts_hint">Seçilen .js dosyaları Wand içine paketlenir ve renderer'da yüklenir.</s:String>
|
<s:String x:Key="pv_custom_scripts_hint">Seçilen .js dosyaları Wand içine paketlenir ve renderer'da yüklenir.</s:String>
|
||||||
<s:String x:Key="pv_no_custom_scripts">Betik seçilmedi</s:String>
|
<s:String x:Key="pv_no_custom_scripts">Betik seçilmedi</s:String>
|
||||||
<s:String x:Key="pv_start">Başlat</s:String>
|
<s:String x:Key="pv_start">Başlat</s:String>
|
||||||
|
<s:String x:Key="pv_auto_apply">Güncellemelerden sonra otomatik uygula</s:String>
|
||||||
<s:String x:Key="pv_popup_title">Neyi geliştireceğiz?</s:String>
|
<s:String x:Key="pv_popup_title">Neyi geliştireceğiz?</s:String>
|
||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
<!--#region UpdatePopup -->
|
<!--#region Runtime log -->
|
||||||
<s:String x:Key="up_warning">Güncellemeden önce, değişiklikler uygulandıysa geri almak şiddetle tavsiye edilir</s:String>
|
<s:String x:Key="log_install_found">WeMod dizini {0} konumunda bulundu ({1})</s:String>
|
||||||
<s:String x:Key="up_current_version">Geçerli sürüm</s:String>
|
<s:String x:Key="log_already_patched">WeMod zaten yamanmış. Tekrar yamamak istiyorsanız, lütfen önce yedeği geri yükleyin.</s:String>
|
||||||
<s:String x:Key="up_latest_version">En son sürüm</s:String>
|
<s:String x:Key="log_ready">Yama işlemi için hazır.</s:String>
|
||||||
<s:String x:Key="up_release_notes">Sürüm notları</s:String>
|
<s:String x:Key="log_install_not_found">WeMod dizini bulunamadı.</s:String>
|
||||||
<s:String x:Key="up_release_notes_unavailable">Bu sürüm için sürüm notları kullanılamıyor.</s:String>
|
<s:String x:Key="log_no_directory">İşlem yapılamıyor. Lütfen önce dizini belirtin.</s:String>
|
||||||
<s:String x:Key="up_show_more">Tüm changelog'u göster</s:String>
|
<s:String x:Key="log_invalid_directory">Seçilen {0} klasörü geçerli bir WeMod dizini değil.</s:String>
|
||||||
<s:String x:Key="up_show_less">Yalnızca güncel notları göster</s:String>
|
<s:String x:Key="log_restore_failed">Yedek geri yüklenemedi: {0}</s:String>
|
||||||
<s:String x:Key="up_loading_changelog">Changelog yükleniyor...</s:String>
|
<s:String x:Key="log_patch_failed">Yama yapılamadı: {0}</s:String>
|
||||||
<s:String x:Key="up_changelog_failed">Tam changelog yüklenemedi. Bunun yerine güncel notlar gösteriliyor.</s:String>
|
<s:String x:Key="log_copied">Günlükler panoya kopyalandı.</s:String>
|
||||||
<s:String x:Key="up_update_now">Şimdi güncelle</s:String>
|
<s:String x:Key="log_copy_failed">Günlükler kopyalanamadı: {0}</s:String>
|
||||||
<s:String x:Key="up_popup_title">Güncelleme mevcut!</s:String>
|
<s:String x:Key="log_exported">Günlükler {0} konumuna dışa aktarıldı.</s:String>
|
||||||
|
<s:String x:Key="log_export_failed">Günlükler dışa aktarılamadı: {0}</s:String>
|
||||||
|
<s:String x:Key="log_open_link_failed">{0} bir tarayıcıda açılamadı.</s:String>
|
||||||
|
<s:String x:Key="dialog_pick_install">WeMod dizinini seçin</s:String>
|
||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
<!--#region MainWindow -->
|
<!--#region MainWindow -->
|
||||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
|
||||||
<s:String x:Key="mw_update_available">Доступна нова версія</s:String>
|
|
||||||
<s:String x:Key="mw_folder_path">Шлях до папки</s:String>
|
<s:String x:Key="mw_folder_path">Шлях до папки</s:String>
|
||||||
<s:String x:Key="mw_folder_not_found">Папку не знайдено</s:String>
|
<s:String x:Key="mw_folder_not_found">Папку не знайдено</s:String>
|
||||||
<s:String x:Key="mw_patch">Застосувати</s:String>
|
<s:String x:Key="mw_patch">Застосувати</s:String>
|
||||||
@@ -36,21 +34,25 @@
|
|||||||
<s:String x:Key="pv_custom_scripts_hint">Вибрані файли .js пакуються у Wand і завантажуються в рендерері.</s:String>
|
<s:String x:Key="pv_custom_scripts_hint">Вибрані файли .js пакуються у Wand і завантажуються в рендерері.</s:String>
|
||||||
<s:String x:Key="pv_no_custom_scripts">Скрипти не вибрано</s:String>
|
<s:String x:Key="pv_no_custom_scripts">Скрипти не вибрано</s:String>
|
||||||
<s:String x:Key="pv_start">Почати</s:String>
|
<s:String x:Key="pv_start">Почати</s:String>
|
||||||
|
<s:String x:Key="pv_auto_apply">Автоматично застосовувати після оновлень</s:String>
|
||||||
<s:String x:Key="pv_popup_title">Що будемо покращувати?</s:String>
|
<s:String x:Key="pv_popup_title">Що будемо покращувати?</s:String>
|
||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
<!--#region UpdatePopup -->
|
<!--#region Runtime log -->
|
||||||
<s:String x:Key="up_warning">Перед оновленням наполегливо рекомендується відкотити зміни, якщо вони були застосовані</s:String>
|
<s:String x:Key="log_install_found">Директорію WeMod знайдено в {0} ({1})</s:String>
|
||||||
<s:String x:Key="up_current_version">Поточна версія</s:String>
|
<s:String x:Key="log_already_patched">WeMod вже пропатчено. Якщо ви хочете пропатчити знову, спершу відновіть резервну копію.</s:String>
|
||||||
<s:String x:Key="up_latest_version">Остання версія</s:String>
|
<s:String x:Key="log_ready">Готово до патчингу.</s:String>
|
||||||
<s:String x:Key="up_release_notes">Нотатки до релізу</s:String>
|
<s:String x:Key="log_install_not_found">Директорію WeMod не знайдено.</s:String>
|
||||||
<s:String x:Key="up_release_notes_unavailable">Нотатки до цього релізу недоступні.</s:String>
|
<s:String x:Key="log_no_directory">Не вдається виконати. Будь ласка, спочатку вкажіть директорію.</s:String>
|
||||||
<s:String x:Key="up_show_more">Показати весь список змін</s:String>
|
<s:String x:Key="log_invalid_directory">Вибрана папка {0} не є дійсною директорією WeMod.</s:String>
|
||||||
<s:String x:Key="up_show_less">Показати лише актуальні зміни</s:String>
|
<s:String x:Key="log_restore_failed">Не вдалося відновити резервну копію: {0}</s:String>
|
||||||
<s:String x:Key="up_loading_changelog">Завантаження списку змін...</s:String>
|
<s:String x:Key="log_patch_failed">Не вдалося пропатчити: {0}</s:String>
|
||||||
<s:String x:Key="up_changelog_failed">Не вдалося завантажити повний список змін. Натомість показано лише актуальні зміни.</s:String>
|
<s:String x:Key="log_copied">Логи скопійовано в буфер обміну.</s:String>
|
||||||
<s:String x:Key="up_update_now">Оновити зараз</s:String>
|
<s:String x:Key="log_copy_failed">Не вдалося скопіювати логи: {0}</s:String>
|
||||||
<s:String x:Key="up_popup_title">Доступне оновлення!</s:String>
|
<s:String x:Key="log_exported">Логи експортовано до {0}.</s:String>
|
||||||
|
<s:String x:Key="log_export_failed">Не вдалося експортувати логи: {0}</s:String>
|
||||||
|
<s:String x:Key="log_open_link_failed">Не вдалося відкрити {0} у браузері.</s:String>
|
||||||
|
<s:String x:Key="dialog_pick_install">Виберіть директорію WeMod</s:String>
|
||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
<!--#region MainWindow -->
|
<!--#region MainWindow -->
|
||||||
<s:String x:Key="mw_title">WandEnhancer</s:String>
|
|
||||||
<s:String x:Key="mw_update_available">有新版本可用</s:String>
|
|
||||||
<s:String x:Key="mw_folder_path">文件夹路径</s:String>
|
<s:String x:Key="mw_folder_path">文件夹路径</s:String>
|
||||||
<s:String x:Key="mw_folder_not_found">未找到文件夹</s:String>
|
<s:String x:Key="mw_folder_not_found">未找到文件夹</s:String>
|
||||||
<s:String x:Key="mw_patch">增强</s:String>
|
<s:String x:Key="mw_patch">增强</s:String>
|
||||||
@@ -36,21 +34,25 @@
|
|||||||
<s:String x:Key="pv_custom_scripts_hint">选中的 .js 文件会打包到 Wand 并在渲染器中加载。</s:String>
|
<s:String x:Key="pv_custom_scripts_hint">选中的 .js 文件会打包到 Wand 并在渲染器中加载。</s:String>
|
||||||
<s:String x:Key="pv_no_custom_scripts">未选择脚本</s:String>
|
<s:String x:Key="pv_no_custom_scripts">未选择脚本</s:String>
|
||||||
<s:String x:Key="pv_start">开始</s:String>
|
<s:String x:Key="pv_start">开始</s:String>
|
||||||
|
<s:String x:Key="pv_auto_apply">更新后自动应用</s:String>
|
||||||
<s:String x:Key="pv_popup_title">我们要增强什么?</s:String>
|
<s:String x:Key="pv_popup_title">我们要增强什么?</s:String>
|
||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
<!--#region UpdatePopup -->
|
<!--#region Runtime log -->
|
||||||
<s:String x:Key="up_warning">在更新之前,强烈建议回滚已应用的修改</s:String>
|
<s:String x:Key="log_install_found">在 {0} ({1}) 找到 WeMod 目录</s:String>
|
||||||
<s:String x:Key="up_current_version">当前版本</s:String>
|
<s:String x:Key="log_already_patched">WeMod 已经修补过。如果想再次修补,请先恢复备份。</s:String>
|
||||||
<s:String x:Key="up_latest_version">最新版本</s:String>
|
<s:String x:Key="log_ready">准备修补。</s:String>
|
||||||
<s:String x:Key="up_release_notes">更新说明</s:String>
|
<s:String x:Key="log_install_not_found">未找到 WeMod 目录。</s:String>
|
||||||
<s:String x:Key="up_release_notes_unavailable">此版本的更新说明不可用。</s:String>
|
<s:String x:Key="log_no_directory">无法执行。请先指定目录。</s:String>
|
||||||
<s:String x:Key="up_show_more">显示完整更新日志</s:String>
|
<s:String x:Key="log_invalid_directory">选择的文件夹 {0} 不是有效的 WeMod 目录。</s:String>
|
||||||
<s:String x:Key="up_show_less">仅显示当前说明</s:String>
|
<s:String x:Key="log_restore_failed">恢复备份失败: {0}</s:String>
|
||||||
<s:String x:Key="up_loading_changelog">正在加载更新日志...</s:String>
|
<s:String x:Key="log_patch_failed">修补失败: {0}</s:String>
|
||||||
<s:String x:Key="up_changelog_failed">无法加载完整更新日志。当前仅显示本次说明。</s:String>
|
<s:String x:Key="log_copied">日志已复制到剪贴板。</s:String>
|
||||||
<s:String x:Key="up_update_now">立即更新</s:String>
|
<s:String x:Key="log_copy_failed">复制日志失败: {0}</s:String>
|
||||||
<s:String x:Key="up_popup_title">有更新可用!</s:String>
|
<s:String x:Key="log_exported">日志已导出至 {0}。</s:String>
|
||||||
|
<s:String x:Key="log_export_failed">导出日志失败: {0}</s:String>
|
||||||
|
<s:String x:Key="log_open_link_failed">无法在浏览器中打开 {0}。</s:String>
|
||||||
|
<s:String x:Key="dialog_pick_install">选择 WeMod 目录</s:String>
|
||||||
<!--#endregion -->
|
<!--#endregion -->
|
||||||
|
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -1,41 +1,22 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using Newtonsoft.Json;
|
|
||||||
using WandEnhancer.Utils;
|
|
||||||
|
|
||||||
namespace WandEnhancer.Models
|
namespace WandEnhancer.Models
|
||||||
{
|
{
|
||||||
|
|
||||||
public enum EPatchType
|
public enum EPatchType
|
||||||
{
|
{
|
||||||
ActivatePro = 1,
|
ActivatePro = 1,
|
||||||
DisableUpdates = 2,
|
DisableUpdates = 2,
|
||||||
DisableTelemetry = 4,
|
|
||||||
DevToolsOnF12 = 8,
|
DevToolsOnF12 = 8,
|
||||||
RemoteWebPanelPreview = 16
|
RemoteWebPanelPreview = 16
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class PatchConfig
|
public sealed class PatchConfig
|
||||||
{
|
{
|
||||||
private string _path;
|
|
||||||
public HashSet<EPatchType> PatchTypes { get; set; }
|
public HashSet<EPatchType> PatchTypes { get; set; }
|
||||||
|
|
||||||
public List<string> CustomScriptPaths { get; set; } = new List<string>();
|
public List<string> CustomScriptPaths { get; set; } = new List<string>();
|
||||||
|
|
||||||
public bool AutoApplyPatches { get; set; }
|
|
||||||
|
|
||||||
[JsonIgnore]
|
|
||||||
public WeModConfig AppProps { get; private set; }
|
|
||||||
|
|
||||||
public string Path
|
/// <summary>When set, the patch selection is saved so the launcher re-applies it after a Wand update.</summary>
|
||||||
{
|
public bool AutoApplyAfterUpdate { get; set; }
|
||||||
get => _path;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
_path = value;
|
|
||||||
AppProps = Extensions.CheckWeModPath(_path) ?? throw new Exception("Invalid WeMod path");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace WandEnhancer.Models
|
|
||||||
{
|
|
||||||
public sealed class Signature
|
|
||||||
{
|
|
||||||
public readonly byte[] OriginalBytes;
|
|
||||||
public readonly byte[] PatchBytes;
|
|
||||||
public readonly byte[] Sequence;
|
|
||||||
public readonly byte[] Mask;
|
|
||||||
public readonly int Offset;
|
|
||||||
|
|
||||||
public int Length => Sequence.Length;
|
|
||||||
|
|
||||||
public static implicit operator byte[](Signature signature) => signature.Sequence;
|
|
||||||
|
|
||||||
public Signature(string signature, int offset, byte[] patchBytes, byte[] originalBytes)
|
|
||||||
{
|
|
||||||
Parse(signature, out Sequence, out Mask);
|
|
||||||
PatchBytes = patchBytes;
|
|
||||||
OriginalBytes = originalBytes;
|
|
||||||
Offset = offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Parse(string signatureStr, out byte[] pattern, out byte[] mask)
|
|
||||||
{
|
|
||||||
var parts = signatureStr.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
var length = parts.Length;
|
|
||||||
|
|
||||||
pattern = new byte[length];
|
|
||||||
mask = new byte[length];
|
|
||||||
|
|
||||||
for (var i = 0; i < length; i++)
|
|
||||||
{
|
|
||||||
if (parts[i] == "??" || parts[i] == "?")
|
|
||||||
{
|
|
||||||
pattern[i] = 0;
|
|
||||||
// wildcard byte
|
|
||||||
mask[i] = 0;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
pattern[i] = Convert.ToByte(parts[i], 16);
|
|
||||||
mask[i] = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
${app}.on("browser-window-created",((_,w)=>{try{w.webContents.on("before-input-event",((_,i)=>{if("F12"===i.key&&"keyDown"===i.type){w.webContents.isDevToolsOpened()?w.webContents.closeDevTools():w.webContents.openDevTools({mode:"detach"})}}))}catch(e){}})),
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
return Promise.reject(new Error("wand-enhancer: native mobile pairing disabled"))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"ACTION_CHECK_FOR_UPDATE",(e=>expectUpdateFeedUrl(e,(e=>null)))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
account:((account)=>account&&"object"==typeof account?{...account,subscription:{period:"yearly",state:"active"}}:account)(${account})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
$0.then((response)=>{response&&"object"==typeof response&&(response.subscription={period:"yearly",state:"active"});return response})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
${app}.whenReady().then(()=>{try{const p=require("node:path");require(p.join(__dirname,"remote-panel","bridge.cjs")).installWandRuntime(require("electron"))}catch(e){try{const fs=require("node:fs"),os=require("node:os"),p=require("node:path");fs.appendFileSync(p.join(os.tmpdir(),"wand-remote-bridge.log"),"["+new Date().toISOString()+"] [boot-error] "+(e&&e.stack||e)+"\n")}catch(_){}}return run()})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
this.__wandRemoteBridge||(this.__wandRemoteBridge=(()=>{try{const r=globalThis.require||require;const{ipcRenderer:c}=r("electron");try{c.invoke("wand-remote-url").then((u=>{u&&(globalThis.__wandRemoteBridgeUrl=u)}))}catch(e){}const send=(ch,p)=>{try{return c.invoke(ch,p&&JSON.parse(JSON.stringify(p)))}catch(e){}};return{sync:(s)=>send("wand-remote-sync",s),valueChanged:(s)=>send("wand-remote-value-changed",s),setHandler:(h)=>{if(this.__wandRemoteBridgeBound)return;this.__wandRemoteBridgeBound=true;try{c.invoke("wand-remote-set-handler-bind")}catch(e){}c.on("wand-remote-set-value",(_e,req)=>{try{h(req)}catch(e){}})}}}catch(e){try{const r=globalThis.require||require,fs=r("node:fs"),os=r("node:os"),p=r("node:path");fs.appendFileSync(p.join(os.tmpdir(),"wand-remote-bridge.log"),"["+new Date().toISOString()+"] [renderer-bind-error] "+(e&&e.stack||e)+"\n")}catch(_){}return null}})());this.__wandRemoteBridge?.setHandler((e=>{if(!this.${trainer}||!e?.target)return!1;return this.${trainer}.isActive()?this.${trainer}.setValue(e.target,e.value,${remoteSource},e.cheatId):!1}));this.__wandRemoteTrainerInfo=e??null;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
;this.__wandRemoteTrainerInfo=null,this.__wandRemoteBridge?.sync(null)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
,this.__wandRemoteBridge?.sync({${snapshot},trainerInfo:this.__wandRemoteTrainerInfo??null,metadata:this.${trainer}?.getMetadata(${metadata})??null})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
,this.__wandRemoteBridge?.valueChanged({target:e.name,value:e.value,oldValue:e.oldValue,source:String(e.source??"desktop"),cheatId:e.cheatId})
|
||||||
+164
-14
@@ -1,45 +1,195 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
|
using WandEnhancer.Core;
|
||||||
|
using WandEnhancer.Models;
|
||||||
|
using WandEnhancer.Utils;
|
||||||
using WandEnhancer.View.MainWindow;
|
using WandEnhancer.View.MainWindow;
|
||||||
|
|
||||||
namespace WandEnhancer
|
namespace WandEnhancer
|
||||||
{
|
{
|
||||||
public static class Program
|
public static class Program
|
||||||
{
|
{
|
||||||
|
/// <summary>Log lines from a failed startup auto-patch, replayed by the UI when it opens.</summary>
|
||||||
|
public static readonly List<KeyValuePair<string, ELogType>> StartupLog =
|
||||||
|
new List<KeyValuePair<string, ELogType>>();
|
||||||
|
|
||||||
[STAThread]
|
[STAThread]
|
||||||
public static void Main(string[] args)
|
public static void Main(string[] args)
|
||||||
{
|
{
|
||||||
|
if (TryLaunchMode(args))
|
||||||
|
return;
|
||||||
|
|
||||||
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
|
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
|
||||||
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
|
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
|
||||||
|
|
||||||
List<LogEntry> logEntries = new List<LogEntry>();
|
bool startupFailed = StartupLog.Exists(entry => entry.Value == ELogType.Error);
|
||||||
if (args.Length > 0)
|
|
||||||
{
|
|
||||||
// TODO: Command line arguments handling
|
|
||||||
}
|
|
||||||
|
|
||||||
var application = new App();
|
var application = new App();
|
||||||
application.InitializeComponent();
|
application.InitializeComponent();
|
||||||
application.MainWindow = new MainWindow();
|
var window = new MainWindow();
|
||||||
foreach (var logEntry in logEntries)
|
|
||||||
{
|
// Launch mode has no window at all, so a user whose Wand never opened would have to
|
||||||
MainWindow.Instance.ViewModel.LogList.Add(logEntry);
|
// be told where launcher.log lives. Put the same lines in front of them instead.
|
||||||
}
|
if (startupFailed)
|
||||||
|
window.Loaded += (sender, e) => BringToFront(window);
|
||||||
|
|
||||||
|
application.MainWindow = window;
|
||||||
application.Run();
|
application.Run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool TryLaunchMode(string[] args)
|
||||||
|
{
|
||||||
|
string myExe = Assembly.GetExecutingAssembly().Location;
|
||||||
|
string myName = Path.GetFileNameWithoutExtension(myExe);
|
||||||
|
|
||||||
|
if (!Constants.WeModBrandNames.Any(
|
||||||
|
n => n.Equals(myName, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
string myDir = Path.GetDirectoryName(myExe);
|
||||||
|
string forwardedArgs = args.Length > 0 ? QuoteArguments(args) : null;
|
||||||
|
|
||||||
|
LauncherLog.Open(myDir, $"WandEnhancer {Constants.Version} | {myExe}" +
|
||||||
|
(forwardedArgs == null ? "" : $" | args {forwardedArgs}"));
|
||||||
|
|
||||||
|
if (args.Length > 0 &&
|
||||||
|
args[0].StartsWith("--squirrel", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
string updateExe = Path.Combine(myDir, "Update.exe");
|
||||||
|
if (File.Exists(updateExe))
|
||||||
|
{
|
||||||
|
LauncherLog.Write($"Squirrel hook {args[0]} forwarded to Update.exe.", ELogType.Info);
|
||||||
|
Process.Start(updateExe, QuoteArguments(args));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
LauncherLog.Write($"Squirrel hook {args[0]} ignored: Update.exe is missing.", ELogType.Warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = WeModInstalls.FindLatestWeMod(myDir);
|
||||||
|
if (config == null)
|
||||||
|
{
|
||||||
|
LauncherLog.Write($"No Wand install found under {myDir}; opening the UI instead.", ELogType.Error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isPatched = Enhancer.IsPatched(config.RootDirectory);
|
||||||
|
LauncherLog.Write($"Install {config.ExecutablePath} is {(isPatched ? "patched" : "not patched")}.",
|
||||||
|
ELogType.Info);
|
||||||
|
|
||||||
|
// A fresh Wand version drops our patches; re-apply the saved selection automatically.
|
||||||
|
// On failure fall through to the UI so the user sees which patch broke.
|
||||||
|
if (!isPatched && !TryAutoPatch(config, myDir))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// RecordStartupLog, not LauncherLog.Write: whatever the launcher says has to survive
|
||||||
|
// into the window on the failure path below.
|
||||||
|
return FuseLauncher.Launch(config.ExecutablePath, forwardedArgs, RecordStartupLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static void BringToFront(System.Windows.Window window)
|
||||||
|
{
|
||||||
|
window.WindowState = System.Windows.WindowState.Normal;
|
||||||
|
window.Topmost = true;
|
||||||
|
window.Activate();
|
||||||
|
window.Topmost = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static string QuoteArguments(IEnumerable<string> args)
|
||||||
|
{
|
||||||
|
return string.Join(" ", args.Select(QuoteArgument));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string QuoteArgument(string value)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(value) && value.IndexOfAny(new[] { ' ', '\t', '"' }) < 0)
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backslashes are literal unless they run into the closing quote, where they double.
|
||||||
|
var quoted = new System.Text.StringBuilder("\"");
|
||||||
|
int backslashes = 0;
|
||||||
|
foreach (char current in value ?? string.Empty)
|
||||||
|
{
|
||||||
|
if (current == '\\')
|
||||||
|
{
|
||||||
|
backslashes++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current == '"')
|
||||||
|
{
|
||||||
|
quoted.Append('\\', backslashes * 2 + 1).Append('"');
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
quoted.Append('\\', backslashes).Append(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
backslashes = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return quoted.Append('\\', backslashes * 2).Append('"').ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryAutoPatch(WeModConfig config, string launcherDir)
|
||||||
|
{
|
||||||
|
var patchConfig = Enhancer.LoadAutoPatchConfig(launcherDir);
|
||||||
|
if (patchConfig == null)
|
||||||
|
return true; // nothing saved to replay; launch as-is
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
new Enhancer(config, RecordStartupLog, patchConfig).Patch();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
// Localization resources are not loaded yet in launcher mode (no Application),
|
||||||
|
// so these two replay into the UI log in English by design.
|
||||||
|
RecordStartupLog($"Auto-patch failed: {e.Message}", ELogType.Error);
|
||||||
|
RecordStartupLog("The new Wand version may need updated patches. Restore the backup and patch again.", ELogType.Warn);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Buffers for the UI and mirrors to disk: auto-patch runs headless, so the
|
||||||
|
/// file is the only copy if the user never opens the window afterwards.</summary>
|
||||||
|
private static void RecordStartupLog(string message, ELogType type)
|
||||||
|
{
|
||||||
|
StartupLog.Add(new KeyValuePair<string, ELogType>(message, type));
|
||||||
|
LauncherLog.Write(message, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Fires on the finalizer thread for a task nobody awaited. Non-fatal since .NET 4.5:
|
||||||
|
// record it and mark it observed rather than killing a patch mid-run.
|
||||||
private static void OnUnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e)
|
private static void OnUnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e)
|
||||||
{
|
{
|
||||||
MessageBox.Show(e.Exception.ToString());
|
e.SetObserved();
|
||||||
Environment.Exit(1);
|
RecordStartupLog($"Background task failed: {e.Exception.GetBaseException().Message}", ELogType.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
|
private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
|
||||||
{
|
{
|
||||||
MessageBox.Show(e.ExceptionObject.ToString());
|
var error = e.ExceptionObject as Exception;
|
||||||
|
MessageBox.Show(
|
||||||
|
error?.Message ?? e.ExceptionObject?.ToString() ?? "Unknown error",
|
||||||
|
Constants.RepoName,
|
||||||
|
MessageBoxButtons.OK,
|
||||||
|
MessageBoxIcon.Error);
|
||||||
Environment.Exit(1);
|
Environment.Exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,5 +51,5 @@ using System.Windows;
|
|||||||
// You can specify all the values or you can default the Build and Revision Numbers
|
// You can specify all the values or you can default the Build and Revision Numbers
|
||||||
// by using the '*' as shown below:
|
// by using the '*' as shown below:
|
||||||
// [assembly: AssemblyVersion("1.0.*")]
|
// [assembly: AssemblyVersion("1.0.*")]
|
||||||
[assembly: AssemblyVersion("1.0.8.2")]
|
[assembly: AssemblyVersion("2.0.0.0")]
|
||||||
[assembly: AssemblyFileVersion("1.0.8.2")]
|
[assembly: AssemblyFileVersion("2.0.0.0")]
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using System.Windows.Input;
|
|
||||||
|
|
||||||
namespace WandEnhancer.ReactiveUICore
|
|
||||||
{
|
|
||||||
public sealed class AsyncRelayCommand : ICommand
|
|
||||||
{
|
|
||||||
private readonly Func<object, Task> _execute;
|
|
||||||
private readonly Func<object, bool> _canExecute;
|
|
||||||
|
|
||||||
private long _isExecuting;
|
|
||||||
|
|
||||||
public AsyncRelayCommand(Func<object, Task> execute, Func<object, bool> canExecute = null)
|
|
||||||
{
|
|
||||||
this._execute = execute;
|
|
||||||
this._canExecute = canExecute ?? (o => true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public event EventHandler CanExecuteChanged
|
|
||||||
{
|
|
||||||
add => CommandManager.RequerySuggested += value;
|
|
||||||
remove => CommandManager.RequerySuggested -= value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void RaiseCanExecuteChanged() => CommandManager.InvalidateRequerySuggested();
|
|
||||||
|
|
||||||
public bool CanExecute(object parameter) => Interlocked.Read(ref _isExecuting) == 0 && _canExecute(parameter);
|
|
||||||
|
|
||||||
public async void Execute(object parameter)
|
|
||||||
{
|
|
||||||
Interlocked.Exchange(ref _isExecuting, 1);
|
|
||||||
RaiseCanExecuteChanged();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _execute(parameter);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
Interlocked.Exchange(ref _isExecuting, 0);
|
|
||||||
RaiseCanExecuteChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.IO;
|
|
||||||
using System.Reflection;
|
|
||||||
using System.Threading;
|
|
||||||
|
|
||||||
namespace WandEnhancer.Utils
|
|
||||||
{
|
|
||||||
public static class Common
|
|
||||||
{
|
|
||||||
public static void TryKillProcess(string processName)
|
|
||||||
{
|
|
||||||
Process[] processes = Process.GetProcessesByName(processName);
|
|
||||||
for (int i = 0; processes.Length > i || i < 5; i++)
|
|
||||||
{
|
|
||||||
foreach (var process in processes)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
process.Kill();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
processes = Process.GetProcessesByName(processName);
|
|
||||||
Thread.Sleep(250);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (processes.Length > 0)
|
|
||||||
{
|
|
||||||
throw new Exception("Failed to kill WeMod");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string GetCurrentDir()
|
|
||||||
{
|
|
||||||
var assemblyLocation = Assembly.GetExecutingAssembly().Location;
|
|
||||||
return Path.GetDirectoryName(assemblyLocation) ?? throw new InvalidOperationException();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string ComputeSha256Hash(string input)
|
|
||||||
{
|
|
||||||
using (var sha256 = System.Security.Cryptography.SHA256.Create())
|
|
||||||
{
|
|
||||||
var bytes = System.Text.Encoding.UTF8.GetBytes(input);
|
|
||||||
var hashBytes = sha256.ComputeHash(bytes);
|
|
||||||
return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using WandEnhancer.Models;
|
|
||||||
|
|
||||||
namespace WandEnhancer.Utils
|
|
||||||
{
|
|
||||||
public static class Extensions
|
|
||||||
{
|
|
||||||
public static WeModConfig CheckWeModPath(string versionRoot)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
|
|
||||||
foreach (var name in Constants.WeModBrandNames)
|
|
||||||
{
|
|
||||||
var exeName = $"{name}.exe";
|
|
||||||
var path = Path.Combine(versionRoot, exeName);
|
|
||||||
if (File.Exists(path) && File.Exists(Path.Combine(versionRoot, "resources", "app.asar")))
|
|
||||||
{
|
|
||||||
return new WeModConfig
|
|
||||||
{
|
|
||||||
BrandName = name,
|
|
||||||
ExecutableName = exeName,
|
|
||||||
RootDirectory = versionRoot
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static WeModConfig FindWeMod()
|
|
||||||
{
|
|
||||||
string localAppDataPath = Environment.GetEnvironmentVariable("LOCALAPPDATA");
|
|
||||||
|
|
||||||
foreach (var folder in Constants.WeModBrandNames)
|
|
||||||
{
|
|
||||||
var weModDir = Path.Combine(localAppDataPath ?? "", folder);
|
|
||||||
if(Directory.Exists(weModDir))
|
|
||||||
{
|
|
||||||
return FindLatestWeMod(weModDir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string Base64Decode(string base64EncodedData)
|
|
||||||
{
|
|
||||||
var base64EncodedBytes = System.Convert.FromBase64String(base64EncodedData);
|
|
||||||
return System.Text.Encoding.UTF8.GetString(base64EncodedBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string Base64Encode(string plainText)
|
|
||||||
{
|
|
||||||
var plainTextBytes = System.Text.Encoding.UTF8.GetBytes(plainText);
|
|
||||||
return System.Convert.ToBase64String(plainTextBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static WeModConfig FindLatestWeMod(string root)
|
|
||||||
{
|
|
||||||
var appFolders = Directory.EnumerateDirectories(root)
|
|
||||||
.Select(folderPath => new DirectoryInfo(folderPath))
|
|
||||||
.Where(dirInfo => Regex.IsMatch(dirInfo.Name, @"^app-\w+"))
|
|
||||||
.Select(dirInfo => new
|
|
||||||
{
|
|
||||||
Name = dirInfo.Name,
|
|
||||||
Path = dirInfo.FullName,
|
|
||||||
LastModified = dirInfo.LastWriteTime
|
|
||||||
})
|
|
||||||
.OrderByDescending(item => item.LastModified)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
|
|
||||||
return appFolders
|
|
||||||
.Select(folder => CheckWeModPath(folder.Path))
|
|
||||||
.FirstOrDefault(config => config != null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace WandEnhancer.Utils
|
||||||
|
{
|
||||||
|
public static class ProcessTerminator
|
||||||
|
{
|
||||||
|
private const int KillAttempts = 5;
|
||||||
|
private const int KillRetryDelayMs = 250;
|
||||||
|
|
||||||
|
public static void TryKillProcess(string processName)
|
||||||
|
{
|
||||||
|
// The launcher itself runs as Wand.exe; never target our own process.
|
||||||
|
int selfId = Process.GetCurrentProcess().Id;
|
||||||
|
|
||||||
|
for (int attempt = 0; attempt < KillAttempts; attempt++)
|
||||||
|
{
|
||||||
|
var processes = Others(Process.GetProcessesByName(processName), selfId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (processes.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var process in processes)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
process.Kill();
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is InvalidOperationException || e is System.ComponentModel.Win32Exception)
|
||||||
|
{
|
||||||
|
// Already exited, or protected: the post-loop check decides the outcome.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
foreach (var process in processes)
|
||||||
|
{
|
||||||
|
process.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread.Sleep(KillRetryDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
var survivors = Others(Process.GetProcessesByName(processName), selfId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (survivors.Length > 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Failed to close {processName}. Close it manually and try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
foreach (var process in survivors)
|
||||||
|
{
|
||||||
|
process.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Process[] Others(Process[] processes, int selfId)
|
||||||
|
{
|
||||||
|
var result = new List<Process>(processes.Length);
|
||||||
|
foreach (var process in processes)
|
||||||
|
{
|
||||||
|
if (process.Id == selfId)
|
||||||
|
{
|
||||||
|
process.Dispose();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(process);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Reflection;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using System.Net.Http;
|
|
||||||
using Newtonsoft.Json;
|
|
||||||
|
|
||||||
namespace WandEnhancer.Utils
|
|
||||||
{
|
|
||||||
public class UpdateReleaseInfo
|
|
||||||
{
|
|
||||||
public string Version { get; set; }
|
|
||||||
|
|
||||||
public string LatestNotes { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class GitHubRelease
|
|
||||||
{
|
|
||||||
public class AssetsType
|
|
||||||
{
|
|
||||||
public string Name { get; set; }
|
|
||||||
|
|
||||||
[JsonProperty("browser_download_url")]
|
|
||||||
public string Url { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[JsonProperty("tag_name")]
|
|
||||||
public string TagName { get; set; }
|
|
||||||
|
|
||||||
[JsonProperty("assets")]
|
|
||||||
public AssetsType[] Assets { get; set; }
|
|
||||||
|
|
||||||
[JsonProperty("body")]
|
|
||||||
public string Body { get; set; }
|
|
||||||
|
|
||||||
[JsonProperty("published_at")]
|
|
||||||
public DateTimeOffset PublishedAt { get; set; }
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public class Updater
|
|
||||||
{
|
|
||||||
private GitHubRelease _release = null;
|
|
||||||
private UpdateReleaseInfo _updateInfo = null;
|
|
||||||
private string _fullChangelog = null;
|
|
||||||
private static readonly HttpClient _httpClient = new HttpClient()
|
|
||||||
{
|
|
||||||
DefaultRequestHeaders =
|
|
||||||
{
|
|
||||||
{ "User-Agent", "GitHub-Updater" }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private static readonly string ApiUrl = $"https://api.github.com/repos/{Constants.Owner}/{Constants.RepoName}/releases/latest";
|
|
||||||
private static readonly string ReleasesApiUrl = $"https://api.github.com/repos/{Constants.Owner}/{Constants.RepoName}/releases?per_page=20";
|
|
||||||
public async Task<bool> CheckForUpdates()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var currentVersion = Assembly.GetExecutingAssembly().GetName().Version;
|
|
||||||
var response = await _httpClient.GetAsync(ApiUrl);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
_release = JsonConvert.DeserializeObject<GitHubRelease>(await response.Content.ReadAsStringAsync());
|
|
||||||
_updateInfo = null;
|
|
||||||
_fullChangelog = null;
|
|
||||||
|
|
||||||
if (_release == null)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var latestVersion = ParseVersion(_release.TagName);
|
|
||||||
|
|
||||||
if (latestVersion <= currentVersion)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateInfo = new UpdateReleaseInfo
|
|
||||||
{
|
|
||||||
Version = NormalizeVersion(_release.TagName),
|
|
||||||
LatestNotes = NormalizeText(_release.Body)
|
|
||||||
};
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<UpdateReleaseInfo> GetUpdateInfoAsync()
|
|
||||||
{
|
|
||||||
if (_updateInfo != null)
|
|
||||||
{
|
|
||||||
return _updateInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await CheckForUpdates()
|
|
||||||
? _updateInfo
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> GetFullChangelogAsync()
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(_fullChangelog))
|
|
||||||
{
|
|
||||||
return NormalizeText(_fullChangelog);
|
|
||||||
}
|
|
||||||
|
|
||||||
_fullChangelog = await TryLoadFullChangelogAsync();
|
|
||||||
|
|
||||||
return NormalizeText(_fullChangelog);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task Update()
|
|
||||||
{
|
|
||||||
if (_release == null)
|
|
||||||
{
|
|
||||||
throw new Exception("No release found");
|
|
||||||
}
|
|
||||||
|
|
||||||
var asset = _release.Assets.FirstOrDefault(o => o.Name.EndsWith(".exe"));
|
|
||||||
if(asset == null)
|
|
||||||
{
|
|
||||||
throw new Exception("No asset found");
|
|
||||||
}
|
|
||||||
|
|
||||||
// download to temp
|
|
||||||
var downloadPath = Path.Combine(Path.GetTempPath(), asset.Name);
|
|
||||||
|
|
||||||
using(var response = await _httpClient.GetAsync(asset.Url))
|
|
||||||
using(var fileStream = File.Create(downloadPath))
|
|
||||||
{
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
await response.Content.CopyToAsync(fileStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
ApplyUpdate(downloadPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private static void ApplyUpdate(string filePath)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var currentExecutable = Assembly.GetExecutingAssembly().Location;
|
|
||||||
|
|
||||||
var psCommand = $"Start-Sleep -Seconds 2; " +
|
|
||||||
$"Copy-Item -Path '{filePath}' -Destination '{currentExecutable}' -Force; " +
|
|
||||||
$"Remove-Item -Path '{filePath}' -Force; " +
|
|
||||||
$"Start-Sleep -Seconds 1; " +
|
|
||||||
$"Start-Process -FilePath '{currentExecutable}';";
|
|
||||||
|
|
||||||
var startInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "powershell.exe",
|
|
||||||
Arguments = $"-WindowStyle Hidden -ExecutionPolicy Bypass -Command \"{psCommand}\"",
|
|
||||||
UseShellExecute = true,
|
|
||||||
CreateNoWindow = true,
|
|
||||||
WindowStyle = ProcessWindowStyle.Hidden
|
|
||||||
};
|
|
||||||
|
|
||||||
Process.Start(startInfo);
|
|
||||||
|
|
||||||
Task.Delay(500).ContinueWith(_ =>
|
|
||||||
{
|
|
||||||
App.Shutdown();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
throw new Exception($"Update failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Version ParseVersion(string versionTag)
|
|
||||||
{
|
|
||||||
return new Version(NormalizeVersion(versionTag));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string NormalizeVersion(string versionTag)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(versionTag))
|
|
||||||
{
|
|
||||||
throw new ArgumentException("Version tag cannot be empty.", nameof(versionTag));
|
|
||||||
}
|
|
||||||
|
|
||||||
return versionTag.Trim().TrimStart('v', 'V');
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string NormalizeText(string text)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return NormalizeLineEndings(text).Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string NormalizeLineEndings(string text)
|
|
||||||
{
|
|
||||||
return text
|
|
||||||
.Replace("\r\n", "\n")
|
|
||||||
.Replace('\r', '\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<string> TryLoadFullChangelogAsync()
|
|
||||||
{
|
|
||||||
return await TryBuildReleaseHistoryAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<string> TryBuildReleaseHistoryAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var response = await _httpClient.GetAsync(ReleasesApiUrl);
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var releases = JsonConvert.DeserializeObject<GitHubRelease[]>(await response.Content.ReadAsStringAsync());
|
|
||||||
if (releases == null || releases.Length == 0)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return BuildReleaseHistory(releases);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildReleaseHistory(GitHubRelease[] releases)
|
|
||||||
{
|
|
||||||
var builder = new StringBuilder();
|
|
||||||
|
|
||||||
foreach (var release in releases.Where(item => !string.IsNullOrWhiteSpace(item?.TagName)))
|
|
||||||
{
|
|
||||||
if (builder.Length > 0)
|
|
||||||
{
|
|
||||||
builder.AppendLine();
|
|
||||||
builder.AppendLine();
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.Append("## [")
|
|
||||||
.Append(NormalizeVersion(release.TagName))
|
|
||||||
.Append("]");
|
|
||||||
|
|
||||||
if (release.PublishedAt != default(DateTimeOffset))
|
|
||||||
{
|
|
||||||
builder.Append(" - ")
|
|
||||||
.Append(release.PublishedAt.UtcDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
|
|
||||||
}
|
|
||||||
|
|
||||||
var notes = NormalizeText(release.Body);
|
|
||||||
if (string.IsNullOrWhiteSpace(notes))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.AppendLine();
|
|
||||||
builder.AppendLine();
|
|
||||||
builder.Append(notes);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NormalizeText(builder.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using WandEnhancer.Models;
|
||||||
|
|
||||||
|
namespace WandEnhancer.Utils
|
||||||
|
{
|
||||||
|
public static class WeModInstalls
|
||||||
|
{
|
||||||
|
public const string JavaScriptFileExtension = ".js";
|
||||||
|
|
||||||
|
public static WeModConfig CheckWeModPath(string versionRoot)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var name in Constants.WeModBrandNames)
|
||||||
|
{
|
||||||
|
var exeName = $"{name}.exe";
|
||||||
|
var path = Path.Combine(versionRoot, exeName);
|
||||||
|
if (File.Exists(path) && File.Exists(Path.Combine(versionRoot, "resources", "app.asar")))
|
||||||
|
{
|
||||||
|
return new WeModConfig
|
||||||
|
{
|
||||||
|
BrandName = name,
|
||||||
|
ExecutableName = exeName,
|
||||||
|
RootDirectory = versionRoot
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is IOException || e is UnauthorizedAccessException || e is ArgumentException)
|
||||||
|
{
|
||||||
|
// An unreadable or malformed candidate directory is not this install.
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static WeModConfig FindWeMod()
|
||||||
|
{
|
||||||
|
string localAppDataPath = Environment.GetEnvironmentVariable("LOCALAPPDATA");
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(localAppDataPath))
|
||||||
|
{
|
||||||
|
foreach (var folder in Constants.WeModBrandNames)
|
||||||
|
{
|
||||||
|
var weModDir = Path.Combine(localAppDataPath, folder);
|
||||||
|
if (!Directory.Exists(weModDir))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep scanning the other brand folders if this one has no valid
|
||||||
|
// install instead of giving up on the first folder that exists.
|
||||||
|
var config = FindLatestWeMod(weModDir);
|
||||||
|
if (config != null)
|
||||||
|
{
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: a running Wand/WeMod process reveals the install directory
|
||||||
|
// wherever it lives (non-default LOCALAPPDATA, moved install, other drive).
|
||||||
|
return FindWeModFromRunningProcess();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WeModConfig FindWeModFromRunningProcess()
|
||||||
|
{
|
||||||
|
foreach (var name in Constants.WeModBrandNames)
|
||||||
|
{
|
||||||
|
Process[] processes;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
processes = Process.GetProcessesByName(name);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var process in processes)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var exePath = process.MainModule?.FileName;
|
||||||
|
if (string.IsNullOrEmpty(exePath))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process may be the versioned exe (dir is the install root) or
|
||||||
|
// the launcher stub at the parent (dir holds `app-*` subfolders).
|
||||||
|
var processDir = Path.GetDirectoryName(exePath);
|
||||||
|
var config = CheckWeModPath(processDir) ?? FindLatestWeMod(processDir);
|
||||||
|
if (config != null)
|
||||||
|
{
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// MainModule throws on access-denied / bitness mismatch; skip.
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
process.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsJavaScriptFile(string path)
|
||||||
|
{
|
||||||
|
return File.Exists(path)
|
||||||
|
&& string.Equals(Path.GetExtension(path), JavaScriptFileExtension, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static WeModConfig FindLatestWeMod(string root)
|
||||||
|
{
|
||||||
|
var appFolders = Directory.EnumerateDirectories(root)
|
||||||
|
.Select(folderPath => new DirectoryInfo(folderPath))
|
||||||
|
.Where(dirInfo => Regex.IsMatch(dirInfo.Name, @"^app-\w+"))
|
||||||
|
.Select(dirInfo => new
|
||||||
|
{
|
||||||
|
Name = dirInfo.Name,
|
||||||
|
Path = dirInfo.FullName,
|
||||||
|
LastModified = dirInfo.LastWriteTime
|
||||||
|
})
|
||||||
|
.OrderByDescending(item => item.LastModified)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
|
||||||
|
return appFolders
|
||||||
|
.Select(folder => CheckWeModPath(folder.Path))
|
||||||
|
.FirstOrDefault(config => config != null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
namespace WandEnhancer.Utils.Win32
|
|
||||||
{
|
|
||||||
public class Shortcut
|
|
||||||
{
|
|
||||||
public class ShortcutParams
|
|
||||||
{
|
|
||||||
public string FileName { get; set; }
|
|
||||||
public string TargetPath { get; set; }
|
|
||||||
public string Arguments { get; set; }
|
|
||||||
public string WorkingDirectory { get; set; }
|
|
||||||
public string Description { get; set; }
|
|
||||||
public string Hotkey { get; set; }
|
|
||||||
public string IconPath { get; set; }
|
|
||||||
};
|
|
||||||
|
|
||||||
private static readonly Type m_type = Type.GetTypeFromProgID("WScript.Shell");
|
|
||||||
private static readonly object m_shell = Activator.CreateInstance(m_type);
|
|
||||||
|
|
||||||
[ComImport, TypeLibType(0x1040), Guid("F935DC23-1CF0-11D0-ADB9-00C04FD58A0B")]
|
|
||||||
private interface IWshShortcut
|
|
||||||
{
|
|
||||||
[DispId(0)]
|
|
||||||
string FullName { [return: MarshalAs(UnmanagedType.BStr)][DispId(0)] get; }
|
|
||||||
[DispId(0x3e8)]
|
|
||||||
string Arguments { [return: MarshalAs(UnmanagedType.BStr)][DispId(0x3e8)] get; [param: In, MarshalAs(UnmanagedType.BStr)][DispId(0x3e8)] set; }
|
|
||||||
[DispId(0x3e9)]
|
|
||||||
string Description { [return: MarshalAs(UnmanagedType.BStr)][DispId(0x3e9)] get; [param: In, MarshalAs(UnmanagedType.BStr)][DispId(0x3e9)] set; }
|
|
||||||
[DispId(0x3ea)]
|
|
||||||
string Hotkey { [return: MarshalAs(UnmanagedType.BStr)][DispId(0x3ea)] get; [param: In, MarshalAs(UnmanagedType.BStr)][DispId(0x3ea)] set; }
|
|
||||||
[DispId(0x3eb)]
|
|
||||||
string IconLocation { [return: MarshalAs(UnmanagedType.BStr)][DispId(0x3eb)] get; [param: In, MarshalAs(UnmanagedType.BStr)][DispId(0x3eb)] set; }
|
|
||||||
[DispId(0x3ec)]
|
|
||||||
string RelativePath { [param: In, MarshalAs(UnmanagedType.BStr)][DispId(0x3ec)] set; }
|
|
||||||
[DispId(0x3ed)]
|
|
||||||
string TargetPath { [return: MarshalAs(UnmanagedType.BStr)][DispId(0x3ed)] get; [param: In, MarshalAs(UnmanagedType.BStr)][DispId(0x3ed)] set; }
|
|
||||||
[DispId(0x3ee)]
|
|
||||||
int WindowStyle { [DispId(0x3ee)] get; [param: In][DispId(0x3ee)] set; }
|
|
||||||
[DispId(0x3ef)]
|
|
||||||
string WorkingDirectory { [return: MarshalAs(UnmanagedType.BStr)][DispId(0x3ef)] get; [param: In, MarshalAs(UnmanagedType.BStr)][DispId(0x3ef)] set; }
|
|
||||||
[TypeLibFunc((short)0x40), DispId(0x7d0)]
|
|
||||||
void Load([In, MarshalAs(UnmanagedType.BStr)] string PathLink);
|
|
||||||
[DispId(0x7d1)]
|
|
||||||
void Save();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void CreateShortcut(string fileName, string targetPath, string arguments, string workingDirectory, string description, string iconPath)
|
|
||||||
{
|
|
||||||
IWshShortcut shortcut = (IWshShortcut)m_type.InvokeMember("CreateShortcut", System.Reflection.BindingFlags.InvokeMethod, null, m_shell, new object[] { fileName });
|
|
||||||
shortcut.Description = description;
|
|
||||||
shortcut.TargetPath = targetPath;
|
|
||||||
shortcut.WorkingDirectory = workingDirectory;
|
|
||||||
shortcut.Arguments = arguments;
|
|
||||||
if (!string.IsNullOrEmpty(iconPath))
|
|
||||||
shortcut.IconLocation = iconPath;
|
|
||||||
shortcut.Save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<UserControl x:Class="WandEnhancer.View.Controls.InfoItem"
|
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
|
||||||
xmlns:local="clr-namespace:WandEnhancer.View.Controls"
|
|
||||||
mc:Ignorable="d"
|
|
||||||
d:DesignHeight="300" d:DesignWidth="300">
|
|
||||||
<Grid>
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="Auto"/>
|
|
||||||
<ColumnDefinition Width="*"/>
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
<Viewbox Width="20" Height="20" VerticalAlignment="Top">
|
|
||||||
<Path Fill="{Binding IconColor}" Data="{Binding IconData}"/>
|
|
||||||
</Viewbox>
|
|
||||||
<TextBlock Grid.Column="1" VerticalAlignment="Center" Margin="5 0 5 0" TextWrapping="Wrap"
|
|
||||||
FontSize="12" Text="{Binding Text}"/>
|
|
||||||
</Grid>
|
|
||||||
</UserControl>
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
using System.Windows;
|
|
||||||
using System.Windows.Controls;
|
|
||||||
using System.Windows.Media;
|
|
||||||
|
|
||||||
namespace WandEnhancer.View.Controls
|
|
||||||
{
|
|
||||||
public partial class InfoItem : UserControl
|
|
||||||
{
|
|
||||||
public static readonly DependencyProperty IconDataProperty =
|
|
||||||
DependencyProperty.Register(nameof(IconData), typeof(Geometry), typeof(InfoItem));
|
|
||||||
|
|
||||||
public static readonly DependencyProperty IconColorProperty =
|
|
||||||
DependencyProperty.Register(nameof(IconColor), typeof(Brush), typeof(InfoItem));
|
|
||||||
|
|
||||||
public static readonly DependencyProperty TextProperty =
|
|
||||||
DependencyProperty.Register(nameof(Text), typeof(string), typeof(InfoItem));
|
|
||||||
|
|
||||||
public Geometry IconData
|
|
||||||
{
|
|
||||||
get => (Geometry)GetValue(IconDataProperty);
|
|
||||||
set => SetValue(IconDataProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Brush IconColor
|
|
||||||
{
|
|
||||||
get => (Brush)GetValue(IconColorProperty);
|
|
||||||
set => SetValue(IconColorProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string Text
|
|
||||||
{
|
|
||||||
get => (string)GetValue(TextProperty);
|
|
||||||
set => SetValue(TextProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public InfoItem()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
this.DataContext = this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<StackPanel Grid.Row="0" x:Name="TitleContainer" Orientation="Horizontal">
|
<StackPanel Grid.Row="0" x:Name="TitleContainer" Orientation="Horizontal">
|
||||||
<TextBlock x:Name="Title" Text="This is title" Foreground="{DynamicResource Foreground}"
|
<TextBlock x:Name="Title" Foreground="{DynamicResource Foreground}"
|
||||||
HorizontalAlignment="Left" FontWeight="Bold" FontSize="16"
|
HorizontalAlignment="Left" FontWeight="Bold" FontSize="16"
|
||||||
VerticalAlignment="Bottom"/>
|
VerticalAlignment="Bottom"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace WandEnhancer.View.MainWindow
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// What the view model needs from the shell window. Exists so the view model does not
|
||||||
|
/// hold the concrete window or reach through a static Instance, which made every command
|
||||||
|
/// untestable and crashed whenever the singleton was not set yet.
|
||||||
|
/// </summary>
|
||||||
|
public interface IShellView
|
||||||
|
{
|
||||||
|
void OpenPopup(FrameworkElement content, string title);
|
||||||
|
void ClosePopup();
|
||||||
|
void ScrollLogIntoView(LogEntry entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Modal file/folder pickers, kept behind a seam so commands stay headless-testable.</summary>
|
||||||
|
public interface IFileDialogs
|
||||||
|
{
|
||||||
|
/// <summary>Chosen folder, or null when cancelled.</summary>
|
||||||
|
string PickFolder(string description, string initialPath);
|
||||||
|
|
||||||
|
/// <summary>Chosen file path, or null when cancelled.</summary>
|
||||||
|
string PickSaveFile(string filter, string suggestedFileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,12 +45,6 @@
|
|||||||
v 1.0.0
|
v 1.0.0
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
|
|
||||||
<Button Background="SpringGreen" Foreground="{DynamicResource Muted}"
|
|
||||||
FontWeight="Medium" Padding="20 0" Margin="10 5 20 5"
|
|
||||||
ToolTip="Click to update"
|
|
||||||
Command="{Binding UpdateCommand}"
|
|
||||||
Visibility="{Binding IsUpdateAvailable, Converter={StaticResource ToVisibilityConverter}}"
|
|
||||||
Content="{DynamicResource mw_update_available}"/>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
|
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
|
||||||
@@ -189,9 +183,10 @@
|
|||||||
Content="{DynamicResource mw_patch}"/>
|
Content="{DynamicResource mw_patch}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Button HorizontalAlignment="Right"
|
<Button HorizontalAlignment="Right"
|
||||||
Command="{Binding RestoreBackupCommand }"
|
Command="{Binding RestoreBackupCommand}"
|
||||||
FontWeight="Bold" FontSize="16" Width="200"
|
FontWeight="Bold" FontSize="16" Width="200"
|
||||||
Style="{StaticResource ColoredButton}"
|
Style="{StaticResource ColoredButton}"
|
||||||
|
IsEnabled="{Binding IsIdle}"
|
||||||
Visibility="{Binding AlreadyPatched, Converter={StaticResource ToVisibilityConverter}}"
|
Visibility="{Binding AlreadyPatched, Converter={StaticResource ToVisibilityConverter}}"
|
||||||
Content="{DynamicResource mw_restore}"/>
|
Content="{DynamicResource mw_restore}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -227,4 +222,4 @@
|
|||||||
<controls:PopupHost x:Name="PopupHost"/>
|
<controls:PopupHost x:Name="PopupHost"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -8,21 +8,20 @@ namespace WandEnhancer.View.MainWindow
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Interaction logic for MainWindow.xaml
|
/// Interaction logic for MainWindow.xaml
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class MainWindow
|
public partial class MainWindow : IShellView
|
||||||
{
|
{
|
||||||
public static MainWindow Instance;
|
public static MainWindow Instance;
|
||||||
public readonly MainWindowVm ViewModel;
|
public readonly MainWindowVm ViewModel;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
this.ViewModel = new MainWindowVm(this);
|
this.ViewModel = new MainWindowVm(this, new WindowsFileDialogs());
|
||||||
this.DataContext = ViewModel;
|
this.DataContext = ViewModel;
|
||||||
VersionLabel.Text = Constants.Version.ToString();
|
VersionLabel.Text = Constants.Version.ToString();
|
||||||
Instance = this;
|
Instance = this;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OpenPopup(FrameworkElement content, string title = null)
|
public void OpenPopup(FrameworkElement content, string title = null)
|
||||||
{
|
{
|
||||||
this.PopupHost.PopupContent = content;
|
this.PopupHost.PopupContent = content;
|
||||||
@@ -30,6 +29,11 @@ namespace WandEnhancer.View.MainWindow
|
|||||||
PopupHost.IsOpen = true;
|
PopupHost.IsOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ScrollLogIntoView(LogEntry entry)
|
||||||
|
{
|
||||||
|
this.LogList.ScrollIntoView(entry);
|
||||||
|
}
|
||||||
|
|
||||||
private void OnDragMove(object sender, MouseButtonEventArgs e)
|
private void OnDragMove(object sender, MouseButtonEventArgs e)
|
||||||
{
|
{
|
||||||
this.DragMove();
|
this.DragMove();
|
||||||
@@ -47,7 +51,15 @@ namespace WandEnhancer.View.MainWindow
|
|||||||
|
|
||||||
private void OpenSourceClicked(object sender, MouseButtonEventArgs e)
|
private void OpenSourceClicked(object sender, MouseButtonEventArgs e)
|
||||||
{
|
{
|
||||||
System.Diagnostics.Process.Start(Constants.RepositoryUrl);
|
// No browser association, or the shell refuses the URL: not worth killing the app.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.Diagnostics.Process.Start(Constants.RepositoryUrl);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
ViewModel.ReportRepositoryLinkFailure(Constants.RepositoryUrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Reflection;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Windows.Forms;
|
|
||||||
using WandEnhancer.Core;
|
using WandEnhancer.Core;
|
||||||
|
using WandEnhancer.Core.Services;
|
||||||
using WandEnhancer.Models;
|
using WandEnhancer.Models;
|
||||||
using WandEnhancer.ReactiveUICore;
|
using WandEnhancer.ReactiveUICore;
|
||||||
using WandEnhancer.Utils;
|
using WandEnhancer.Utils;
|
||||||
@@ -16,33 +15,33 @@ namespace WandEnhancer.View.MainWindow
|
|||||||
{
|
{
|
||||||
public class MainWindowVm : ObservableObject
|
public class MainWindowVm : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly MainWindow _view;
|
private const string LogExportFilter = "Text files (*.txt)|*.txt|All files (*.*)|*.*";
|
||||||
public ObservableCollection<LogEntry> LogList { get; set; } = new ObservableCollection<LogEntry>();
|
|
||||||
private static Updater _updater = new Updater();
|
|
||||||
|
|
||||||
|
private readonly IShellView _shell;
|
||||||
|
private readonly IFileDialogs _dialogs;
|
||||||
|
public ObservableCollection<LogEntry> LogList { get; } = new ObservableCollection<LogEntry>();
|
||||||
private WeModConfig _weModConfig;
|
private WeModConfig _weModConfig;
|
||||||
|
|
||||||
public WeModConfig WeModInfo
|
public WeModConfig WeModInfo
|
||||||
{
|
{
|
||||||
get => _weModConfig;
|
get => _weModConfig;
|
||||||
set
|
set => SetProperty(ref _weModConfig, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UseInstall(WeModConfig config)
|
||||||
|
{
|
||||||
|
WeModInfo = config;
|
||||||
|
if (config == null)
|
||||||
{
|
{
|
||||||
SetProperty(ref _weModConfig, value);
|
return;
|
||||||
if (value == null) return;
|
|
||||||
|
|
||||||
Log($"WeMod directory found at '{_weModConfig}' ({_weModConfig.ExecutableName})", ELogType.Success);
|
|
||||||
if (File.Exists(Path.Combine(_weModConfig.RootDirectory, "resources", "app.asar.backup")))
|
|
||||||
{
|
|
||||||
Log("WeMod already patched. If you want to patch again, please restore the backup first.",
|
|
||||||
ELogType.Warn);
|
|
||||||
IsPatchEnabled = false;
|
|
||||||
AlreadyPatched = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Log("Ready for patching.", ELogType.Info);
|
|
||||||
IsPatchEnabled = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log(LocalizationManager.Format("log_install_found", config, config.ExecutableName), ELogType.Success);
|
||||||
|
AlreadyPatched = Enhancer.IsPatched(config.RootDirectory);
|
||||||
|
IsPatchEnabled = !AlreadyPatched;
|
||||||
|
|
||||||
|
Log(LocalizationManager.Get(AlreadyPatched ? "log_already_patched" : "log_ready"),
|
||||||
|
AlreadyPatched ? ELogType.Warn : ELogType.Info);
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool _isPatchEnabled;
|
private bool _isPatchEnabled;
|
||||||
@@ -61,98 +60,96 @@ namespace WandEnhancer.View.MainWindow
|
|||||||
set => SetProperty(ref _alreadyPatched, value);
|
set => SetProperty(ref _alreadyPatched, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool _isUpdateAvailable;
|
private bool _isBusy;
|
||||||
|
|
||||||
public bool IsUpdateAvailable
|
/// <summary>True while a patch or restore runs; both are long file operations.</summary>
|
||||||
|
public bool IsBusy
|
||||||
{
|
{
|
||||||
get => _isUpdateAvailable;
|
get => _isBusy;
|
||||||
set => SetProperty(ref _isUpdateAvailable, value);
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isBusy, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsIdle));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Bound by buttons that must not be clickable a second time mid-run.</summary>
|
||||||
|
public bool IsIdle => !_isBusy;
|
||||||
|
|
||||||
public RelayCommand SetFolderPathCommand { get; }
|
public RelayCommand SetFolderPathCommand { get; }
|
||||||
public RelayCommand ApplyPatchCommand { get; }
|
public RelayCommand ApplyPatchCommand { get; }
|
||||||
public RelayCommand RestoreBackupCommand { get; }
|
public RelayCommand RestoreBackupCommand { get; }
|
||||||
public RelayCommand UpdateCommand { get; }
|
|
||||||
public RelayCommand OpenSettingsCommand { get; }
|
public RelayCommand OpenSettingsCommand { get; }
|
||||||
public RelayCommand CopyLogsCommand { get; }
|
public RelayCommand CopyLogsCommand { get; }
|
||||||
public RelayCommand ExportLogsCommand { get; }
|
public RelayCommand ExportLogsCommand { get; }
|
||||||
|
|
||||||
private void OnFolderPathSelection(object obj)
|
private void OnFolderPathSelection(object obj)
|
||||||
{
|
{
|
||||||
using (var dialog = new FolderBrowserDialog())
|
string selectedPath = _dialogs.PickFolder(
|
||||||
|
LocalizationManager.Get("dialog_pick_install"),
|
||||||
|
Environment.GetEnvironmentVariable("LOCALAPPDATA"));
|
||||||
|
if (selectedPath == null)
|
||||||
{
|
{
|
||||||
dialog.SelectedPath = Environment.GetEnvironmentVariable("LOCALAPPDATA");
|
return;
|
||||||
dialog.Description = "Select the WeMod directory";
|
|
||||||
dialog.ShowNewFolderButton = false;
|
|
||||||
|
|
||||||
if (dialog.ShowDialog() != DialogResult.OK) return;
|
|
||||||
string selectedPath = dialog.SelectedPath;
|
|
||||||
string fileName = Path.GetFileName(selectedPath);
|
|
||||||
|
|
||||||
var info = Extensions.CheckWeModPath(selectedPath);
|
|
||||||
|
|
||||||
if (info != null)
|
|
||||||
{
|
|
||||||
WeModInfo = info;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
LogList.Add(new LogEntry
|
|
||||||
{
|
|
||||||
LogType = ELogType.Error,
|
|
||||||
Message = $"The selected folder '{fileName}' is not a valid WeMod directory."
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var info = WeModInstalls.CheckWeModPath(selectedPath);
|
||||||
|
if (info == null)
|
||||||
|
{
|
||||||
|
Log(LocalizationManager.Format("log_invalid_directory", Path.GetFileName(selectedPath)), ELogType.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UseInstall(info);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnBackupRestoring(object param)
|
// Restore does the same heavy file IO as Patch, so it runs off the UI thread too.
|
||||||
|
private async void OnBackupRestoring(object param)
|
||||||
{
|
{
|
||||||
var backupPath = Path.Combine(WeModInfo.RootDirectory, "resources", "app.asar.backup");
|
if (WeModInfo == null)
|
||||||
if (!File.Exists(backupPath))
|
|
||||||
{
|
{
|
||||||
Log("Backup not found. Please dont delete it manually", ELogType.Error);
|
Log(LocalizationManager.Get("log_no_directory"), ELogType.Warn);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
IsBusy = true;
|
||||||
|
bool restored = await Task.Run(() =>
|
||||||
{
|
{
|
||||||
// Try to lock the file to see if it's in use
|
try
|
||||||
using (File.Open(backupPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
|
|
||||||
{
|
{
|
||||||
|
new Enhancer(WeModInfo, Log).Restore();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
catch (Exception e)
|
||||||
var proxyDllPath = Path.Combine(WeModInfo.RootDirectory, "version.dll");
|
|
||||||
|
|
||||||
if(File.Exists(proxyDllPath))
|
|
||||||
{
|
{
|
||||||
File.Delete(proxyDllPath);
|
Log(LocalizationManager.Format("log_restore_failed", e.Message), ELogType.Error);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
catch
|
|
||||||
{
|
|
||||||
Log("Backup file is locked. Please close the WeMod and try again.", ELogType.Error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
File.Copy(backupPath, Path.Combine(WeModInfo.RootDirectory, "resources", "app.asar"), true);
|
IsBusy = false;
|
||||||
File.Delete(backupPath);
|
if (restored)
|
||||||
Log("Backup restored successfully.", ELogType.Success);
|
{
|
||||||
AlreadyPatched = false;
|
AlreadyPatched = false;
|
||||||
IsPatchEnabled = true;
|
IsPatchEnabled = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnPatching(object param)
|
private void OnPatching(object param)
|
||||||
{
|
{
|
||||||
if (WeModInfo == null)
|
if (WeModInfo == null)
|
||||||
{
|
{
|
||||||
Log("Can't be done. Please specify the directory first.", ELogType.Warn);
|
Log(LocalizationManager.Get("log_no_directory"), ELogType.Warn);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
MainWindow.Instance.OpenPopup(new PatchVectorsPopup(async config =>
|
_shell.OpenPopup(new PatchVectorsPopup(async config =>
|
||||||
{
|
{
|
||||||
MainWindow.Instance.ClosePopup();
|
_shell.ClosePopup();
|
||||||
IsPatchEnabled = false;
|
IsPatchEnabled = false;
|
||||||
|
IsBusy = true;
|
||||||
await Task.Run(() =>
|
await Task.Run(() =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -162,62 +159,34 @@ namespace WandEnhancer.View.MainWindow
|
|||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
Log($"Failed to patch: {e.Message}", ELogType.Error);
|
Log(LocalizationManager.Format("log_patch_failed", e.Message), ELogType.Error);
|
||||||
IsPatchEnabled = true;
|
IsPatchEnabled = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}), Application.Current.FindResource("pv_popup_title") as string);
|
IsBusy = false;
|
||||||
|
}), LocalizationManager.Get("pv_popup_title"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Log(string message, ELogType logType)
|
private void Log(string message, ELogType logType)
|
||||||
{
|
{
|
||||||
Application.Current.Dispatcher.Invoke(() =>
|
Application.Current.Dispatcher.Invoke(() =>
|
||||||
{
|
{
|
||||||
message = $"[{logType.ToString().ToUpper()}] {message}";
|
|
||||||
|
|
||||||
var entry = new LogEntry
|
var entry = new LogEntry
|
||||||
{
|
{
|
||||||
LogType = logType,
|
LogType = logType,
|
||||||
Message = message
|
Message = $"[{logType.ToString().ToUpper()}] {message}"
|
||||||
};
|
};
|
||||||
LogList.Add(entry);
|
LogList.Add(entry);
|
||||||
_view.LogList.ScrollIntoView(entry);
|
_shell.ScrollLogIntoView(entry);
|
||||||
|
// The log commands are disabled while the list is empty, and appending a line
|
||||||
|
// is not user input, so nothing else would re-evaluate CanExecute.
|
||||||
|
System.Windows.Input.CommandManager.InvalidateRequerySuggested();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnUpdate(object param)
|
|
||||||
{
|
|
||||||
var updateInfo = await _updater.GetUpdateInfoAsync();
|
|
||||||
if (updateInfo == null)
|
|
||||||
{
|
|
||||||
Log("No update details are available right now.", ELogType.Warn);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
MainWindow.Instance.OpenPopup(new UpdatePopup(Constants.Version.ToString(), updateInfo.Version,
|
|
||||||
updateInfo.LatestNotes, () =>
|
|
||||||
{
|
|
||||||
MainWindow.Instance.ClosePopup();
|
|
||||||
Task.Run(async () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _updater.Update();
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
Log($"Failed to update: {e.Message}", ELogType.Error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Log("WandEnhancer updated successfully. Restarting...", ELogType.Success);
|
|
||||||
});
|
|
||||||
}, () => _updater.GetFullChangelogAsync()), Application.Current.FindResource("up_popup_title") as string);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnOpenSettings(object param)
|
private void OnOpenSettings(object param)
|
||||||
{
|
{
|
||||||
MainWindow.Instance.OpenPopup(new SettingsPopup(), Application.Current.FindResource("settings_title") as string);
|
_shell.OpenPopup(new SettingsPopup(), LocalizationManager.Get("settings_title"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private string BuildLogReport()
|
private string BuildLogReport()
|
||||||
@@ -232,73 +201,68 @@ namespace WandEnhancer.View.MainWindow
|
|||||||
|
|
||||||
private void OnCopyLogs(object param)
|
private void OnCopyLogs(object param)
|
||||||
{
|
{
|
||||||
if (LogList.Count == 0)
|
try
|
||||||
|
{
|
||||||
|
System.Windows.Clipboard.SetText(BuildLogReport());
|
||||||
|
Log(LocalizationManager.Get("log_copied"), ELogType.Success);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Log(LocalizationManager.Format("log_copy_failed", e.Message), ELogType.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnExportLogs(object param)
|
||||||
|
{
|
||||||
|
string path = _dialogs.PickSaveFile(
|
||||||
|
LogExportFilter,
|
||||||
|
$"wand-enhancer-log-{DateTime.Now:yyyyMMdd-HHmmss}.txt");
|
||||||
|
if (path == null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
System.Windows.Clipboard.SetText(BuildLogReport());
|
File.WriteAllText(path, BuildLogReport());
|
||||||
Log("Logs copied to clipboard.", ELogType.Success);
|
Log(LocalizationManager.Format("log_exported", path), ELogType.Success);
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
Log($"Failed to copy logs: {e.Message}", ELogType.Error);
|
Log(LocalizationManager.Format("log_export_failed", e.Message), ELogType.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnExportLogs(object param)
|
private bool HasLogs(object param) => LogList.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>The shell could not hand the repository URL to a browser; show it instead.</summary>
|
||||||
|
public void ReportRepositoryLinkFailure(string url)
|
||||||
{
|
{
|
||||||
if (LogList.Count == 0)
|
Log(LocalizationManager.Format("log_open_link_failed", url), ELogType.Warn);
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
using (var dialog = new SaveFileDialog
|
|
||||||
{
|
|
||||||
Filter = "Text files (*.txt)|*.txt|All files (*.*)|*.*",
|
|
||||||
FileName = $"wand-enhancer-log-{DateTime.Now:yyyyMMdd-HHmmss}.txt"
|
|
||||||
})
|
|
||||||
{
|
|
||||||
if (dialog.ShowDialog() != DialogResult.OK)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
File.WriteAllText(dialog.FileName, BuildLogReport());
|
|
||||||
Log($"Logs exported to '{dialog.FileName}'.", ELogType.Success);
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
Log($"Failed to export logs: {e.Message}", ELogType.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public MainWindowVm(MainWindow view)
|
public MainWindowVm(IShellView shell, IFileDialogs dialogs)
|
||||||
{
|
{
|
||||||
Task.Run(async () =>
|
_shell = shell;
|
||||||
{
|
_dialogs = dialogs;
|
||||||
var isUpdateAvailable = await _updater.CheckForUpdates();
|
|
||||||
Application.Current.Dispatcher.Invoke(() => IsUpdateAvailable = isUpdateAvailable);
|
|
||||||
});
|
|
||||||
_view = view;
|
|
||||||
SetFolderPathCommand = new RelayCommand(OnFolderPathSelection);
|
SetFolderPathCommand = new RelayCommand(OnFolderPathSelection);
|
||||||
ApplyPatchCommand = new RelayCommand(OnPatching);
|
ApplyPatchCommand = new RelayCommand(OnPatching);
|
||||||
RestoreBackupCommand = new RelayCommand(OnBackupRestoring);
|
RestoreBackupCommand = new RelayCommand(OnBackupRestoring);
|
||||||
UpdateCommand = new RelayCommand(OnUpdate);
|
|
||||||
OpenSettingsCommand = new RelayCommand(OnOpenSettings);
|
OpenSettingsCommand = new RelayCommand(OnOpenSettings);
|
||||||
CopyLogsCommand = new RelayCommand(OnCopyLogs);
|
CopyLogsCommand = new RelayCommand(OnCopyLogs, HasLogs);
|
||||||
ExportLogsCommand = new RelayCommand(OnExportLogs);
|
ExportLogsCommand = new RelayCommand(OnExportLogs, HasLogs);
|
||||||
|
|
||||||
WeModInfo = Extensions.FindWeMod();
|
UseInstall(WeModInstalls.FindWeMod());
|
||||||
if (WeModInfo == null)
|
if (WeModInfo == null)
|
||||||
{
|
{
|
||||||
Log("WeMod directory not found.", ELogType.Error);
|
Log(LocalizationManager.Get("log_install_not_found"), ELogType.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var entry in Program.StartupLog)
|
||||||
|
{
|
||||||
|
Log(entry.Key, entry.Value);
|
||||||
|
}
|
||||||
|
Program.StartupLog.Clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace WandEnhancer.View.MainWindow
|
||||||
|
{
|
||||||
|
internal sealed class WindowsFileDialogs : IFileDialogs
|
||||||
|
{
|
||||||
|
public string PickFolder(string description, string initialPath)
|
||||||
|
{
|
||||||
|
using (var dialog = new FolderBrowserDialog
|
||||||
|
{
|
||||||
|
SelectedPath = initialPath,
|
||||||
|
Description = description,
|
||||||
|
ShowNewFolderButton = false,
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return dialog.ShowDialog() == DialogResult.OK ? dialog.SelectedPath : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string PickSaveFile(string filter, string suggestedFileName)
|
||||||
|
{
|
||||||
|
using (var dialog = new SaveFileDialog { Filter = filter, FileName = suggestedFileName })
|
||||||
|
{
|
||||||
|
return dialog.ShowDialog() == DialogResult.OK ? dialog.FileName : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
<RowDefinition Height="27" />
|
<RowDefinition Height="27" />
|
||||||
<RowDefinition Height="27" />
|
<RowDefinition Height="27" />
|
||||||
<RowDefinition Height="27" />
|
<RowDefinition Height="27" />
|
||||||
|
<RowDefinition Height="27" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
@@ -39,7 +40,11 @@
|
|||||||
<TextBlock Grid.Row="3" Grid.Column="0" VerticalAlignment="Center" Text="{DynamicResource pv_remote_web_panel_preview}" />
|
<TextBlock Grid.Row="3" Grid.Column="0" VerticalAlignment="Center" Text="{DynamicResource pv_remote_web_panel_preview}" />
|
||||||
<CheckBox Grid.Row="3" Grid.Column="1" x:Name="RemoteWebPanelPreviewBox" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
<CheckBox Grid.Row="3" Grid.Column="1" x:Name="RemoteWebPanelPreviewBox" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||||
|
|
||||||
<Border Grid.Row="4" Grid.ColumnSpan="2" Margin="0 14 0 0" Padding="10"
|
<TextBlock Grid.Row="4" Grid.Column="0" VerticalAlignment="Center" Text="{DynamicResource pv_auto_apply}" />
|
||||||
|
<CheckBox Grid.Row="4" Grid.Column="1" x:Name="AutoApplyBox" HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||||
|
IsChecked="True" />
|
||||||
|
|
||||||
|
<Border Grid.Row="5" Grid.ColumnSpan="2" Margin="0 14 0 0" Padding="10"
|
||||||
BorderBrush="{DynamicResource Border}" BorderThickness="1" CornerRadius="4"
|
BorderBrush="{DynamicResource Border}" BorderThickness="1" CornerRadius="4"
|
||||||
Background="{DynamicResource Muted}">
|
Background="{DynamicResource Muted}">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
@@ -91,7 +96,7 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Button Grid.Row="5" Grid.ColumnSpan="2" Padding="0 5 0 5" Margin="0 15 0 0" Content="{DynamicResource pv_start}"
|
<Button Grid.Row="6" Grid.ColumnSpan="2" Padding="0 5 0 5" Margin="0 15 0 0" Content="{DynamicResource pv_start}"
|
||||||
Click="OnPatchButtonClick" />
|
Click="OnPatchButtonClick" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ using System.Windows;
|
|||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
using WandEnhancer.Models;
|
using WandEnhancer.Models;
|
||||||
|
using WandEnhancer.Utils;
|
||||||
|
|
||||||
namespace WandEnhancer.View.Popups
|
namespace WandEnhancer.View.Popups
|
||||||
{
|
{
|
||||||
public partial class PatchVectorsPopup : UserControl
|
public partial class PatchVectorsPopup : UserControl
|
||||||
{
|
{
|
||||||
private const string JavaScriptDialogFilter = "JavaScript files (*.js)|*.js";
|
private const string JavaScriptDialogFilter = "JavaScript files (*.js)|*.js";
|
||||||
private const string JavaScriptFileExtension = ".js";
|
|
||||||
|
|
||||||
private readonly Action<PatchConfig> _onApply;
|
private readonly Action<PatchConfig> _onApply;
|
||||||
private readonly ObservableCollection<SelectedScript> _selectedScripts = new ObservableCollection<SelectedScript>();
|
private readonly ObservableCollection<SelectedScript> _selectedScripts = new ObservableCollection<SelectedScript>();
|
||||||
@@ -40,7 +40,7 @@ namespace WandEnhancer.View.Popups
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var path in dialog.FileNames.Where(IsJavaScriptFile))
|
foreach (var path in dialog.FileNames.Where(WeModInstalls.IsJavaScriptFile))
|
||||||
{
|
{
|
||||||
AddScript(path);
|
AddScript(path);
|
||||||
}
|
}
|
||||||
@@ -99,7 +99,7 @@ namespace WandEnhancer.View.Popups
|
|||||||
{
|
{
|
||||||
PatchTypes = result,
|
PatchTypes = result,
|
||||||
CustomScriptPaths = _selectedScripts.Select(script => script.FullPath).ToList(),
|
CustomScriptPaths = _selectedScripts.Select(script => script.FullPath).ToList(),
|
||||||
AutoApplyPatches = false
|
AutoApplyAfterUpdate = AutoApplyBox.IsChecked == true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,11 +114,6 @@ namespace WandEnhancer.View.Popups
|
|||||||
_selectedScripts.Add(new SelectedScript(fullPath));
|
_selectedScripts.Add(new SelectedScript(fullPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsJavaScriptFile(string path)
|
|
||||||
{
|
|
||||||
return File.Exists(path) && string.Equals(Path.GetExtension(path), JavaScriptFileExtension, StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateScriptsEmptyState()
|
private void UpdateScriptsEmptyState()
|
||||||
{
|
{
|
||||||
NoScriptsText.Visibility = _selectedScripts.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
|
NoScriptsText.Visibility = _selectedScripts.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ namespace WandEnhancer.View.Popups
|
|||||||
LocalizationManager.CurrentLanguage = _selectedLanguage;
|
LocalizationManager.CurrentLanguage = _selectedLanguage;
|
||||||
}
|
}
|
||||||
|
|
||||||
MainWindow.MainWindow.Instance.ClosePopup();
|
MainWindow.MainWindow.Instance?.ClosePopup();
|
||||||
}
|
}
|
||||||
|
|
||||||
private class LanguageItem
|
private class LanguageItem
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
<UserControl x:Class="WandEnhancer.View.Popups.UpdatePopup"
|
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
|
||||||
mc:Ignorable="d"
|
|
||||||
d:DesignHeight="Auto" d:DesignWidth="Auto"
|
|
||||||
Background="{DynamicResource Background}"
|
|
||||||
Foreground="{DynamicResource MutedForeground}"
|
|
||||||
FontWeight="Medium"
|
|
||||||
FontSize="13">
|
|
||||||
<Grid MinWidth="500" MaxWidth="620">
|
|
||||||
<Grid.Resources>
|
|
||||||
<Style x:Key="UpdateActionButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
|
|
||||||
<Setter Property="Background" Value="{DynamicResource Primary}" />
|
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource Primary}" />
|
|
||||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryForeground}" />
|
|
||||||
<Style.Triggers>
|
|
||||||
<Trigger Property="IsMouseOver" Value="True">
|
|
||||||
<Setter Property="Background" Value="{DynamicResource Primary}" />
|
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource Primary}" />
|
|
||||||
</Trigger>
|
|
||||||
</Style.Triggers>
|
|
||||||
</Style>
|
|
||||||
</Grid.Resources>
|
|
||||||
|
|
||||||
<Grid.RowDefinitions>
|
|
||||||
<RowDefinition Height="Auto" />
|
|
||||||
<RowDefinition Height="Auto" />
|
|
||||||
<RowDefinition Height="Auto" />
|
|
||||||
<RowDefinition Height="*" />
|
|
||||||
<RowDefinition Height="Auto" />
|
|
||||||
</Grid.RowDefinitions>
|
|
||||||
|
|
||||||
<Border Grid.Row="0" HorizontalAlignment="Left" MaxWidth="560"
|
|
||||||
Padding="9 4" CornerRadius="4"
|
|
||||||
Background="{DynamicResource Muted}">
|
|
||||||
<DockPanel LastChildFill="True">
|
|
||||||
<Ellipse Width="7" Height="7" Fill="{DynamicResource Destructive}"
|
|
||||||
Margin="0 0 8 0" VerticalAlignment="Center" />
|
|
||||||
<TextBlock Foreground="{DynamicResource Foreground}" FontSize="11.5"
|
|
||||||
Text="{DynamicResource up_warning}" TextWrapping="Wrap" />
|
|
||||||
</DockPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<WrapPanel Grid.Row="1" Margin="0 10 0 0" Orientation="Horizontal">
|
|
||||||
<Border Padding="8 4" Margin="0 0 8 0" CornerRadius="4"
|
|
||||||
Background="{DynamicResource Muted}" BorderBrush="{DynamicResource Border}" BorderThickness="1">
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<TextBlock Margin="0 0 7 0" VerticalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource MutedForeground}" FontSize="10.5"
|
|
||||||
Text="{DynamicResource up_current_version}" />
|
|
||||||
<TextBlock x:Name="CurrentVersionValue" VerticalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource Foreground}" FontSize="12.5" FontWeight="Bold" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<Border Padding="8 4" CornerRadius="4"
|
|
||||||
Background="{DynamicResource Primary}" BorderThickness="1">
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<TextBlock Margin="0 0 7 0" VerticalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource PrimaryForeground}" FontSize="10.5"
|
|
||||||
Text="{DynamicResource up_latest_version}" />
|
|
||||||
<TextBlock x:Name="LatestVersionValue" VerticalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource PrimaryForeground}" FontSize="12.5" FontWeight="Bold" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
</WrapPanel>
|
|
||||||
|
|
||||||
<TextBlock Grid.Row="2" Margin="0 12 0 6" Foreground="{DynamicResource Foreground}"
|
|
||||||
FontSize="14" FontWeight="Bold" Text="{DynamicResource up_release_notes}" />
|
|
||||||
|
|
||||||
<Border Grid.Row="3"
|
|
||||||
Background="{DynamicResource Muted}" BorderBrush="{DynamicResource Border}" BorderThickness="1" CornerRadius="4">
|
|
||||||
<ScrollViewer x:Name="NotesScrollViewer"
|
|
||||||
VerticalScrollBarVisibility="Hidden"
|
|
||||||
HorizontalScrollBarVisibility="Disabled"
|
|
||||||
CanContentScroll="False">
|
|
||||||
<TextBox x:Name="NotesTextBlock" Background="Transparent"
|
|
||||||
BorderBrush="Transparent"
|
|
||||||
Padding="12"
|
|
||||||
BorderThickness="0" IsReadOnly="True"
|
|
||||||
Foreground="{DynamicResource Foreground}"
|
|
||||||
TextWrapping="Wrap" />
|
|
||||||
</ScrollViewer>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<Grid Grid.Row="4" Margin="0 12 0 0">
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="*" />
|
|
||||||
<ColumnDefinition Width="Auto" />
|
|
||||||
<ColumnDefinition Width="Auto" />
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
|
|
||||||
<Button x:Name="ShowMoreButton" Grid.Column="1" Margin="0 0 10 0"
|
|
||||||
Padding="12 5" Content="{DynamicResource up_show_more}"
|
|
||||||
Click="OnShowMoreClick" />
|
|
||||||
|
|
||||||
<Button Grid.Column="2" Padding="18 5" Style="{StaticResource UpdateActionButton}"
|
|
||||||
Content="{DynamicResource up_update_now}"
|
|
||||||
Click="OnUpdateClick" />
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</UserControl>
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using System.Windows;
|
|
||||||
using System.Windows.Controls;
|
|
||||||
|
|
||||||
namespace WandEnhancer.View.Popups
|
|
||||||
{
|
|
||||||
public partial class UpdatePopup : UserControl
|
|
||||||
{
|
|
||||||
private readonly Action _onUpdate;
|
|
||||||
private readonly Func<Task<string>> _loadFullChangelog;
|
|
||||||
private readonly string _latestNotes;
|
|
||||||
private string _fullChangelog;
|
|
||||||
private bool _showingFullChangelog;
|
|
||||||
|
|
||||||
public UpdatePopup(string currentVersion, string latestVersion, string latestNotes, Action onUpdate,
|
|
||||||
Func<Task<string>> loadFullChangelog)
|
|
||||||
{
|
|
||||||
_onUpdate = onUpdate;
|
|
||||||
_loadFullChangelog = loadFullChangelog;
|
|
||||||
InitializeComponent();
|
|
||||||
|
|
||||||
CurrentVersionValue.Text = currentVersion;
|
|
||||||
LatestVersionValue.Text = latestVersion;
|
|
||||||
_latestNotes = string.IsNullOrWhiteSpace(latestNotes)
|
|
||||||
? GetResourceText("up_release_notes_unavailable")
|
|
||||||
: latestNotes;
|
|
||||||
|
|
||||||
SetNotesText(_latestNotes);
|
|
||||||
ShowMoreButton.Visibility = loadFullChangelog == null ? Visibility.Collapsed : Visibility.Visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnUpdateClick(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
_onUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnShowMoreClick(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (_loadFullChangelog == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_showingFullChangelog)
|
|
||||||
{
|
|
||||||
SetNotesText(_latestNotes);
|
|
||||||
ShowMoreButton.Content = GetResourceText("up_show_more");
|
|
||||||
_showingFullChangelog = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(_fullChangelog))
|
|
||||||
{
|
|
||||||
ShowMoreButton.IsEnabled = false;
|
|
||||||
ShowMoreButton.Content = GetResourceText("up_loading_changelog");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_fullChangelog = await _loadFullChangelog();
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
ShowMoreButton.IsEnabled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(_fullChangelog))
|
|
||||||
{
|
|
||||||
ShowMoreButton.Content = GetResourceText("up_show_more");
|
|
||||||
SetNotesText(string.Concat(
|
|
||||||
_latestNotes,
|
|
||||||
Environment.NewLine,
|
|
||||||
Environment.NewLine,
|
|
||||||
GetResourceText("up_changelog_failed")));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
SetNotesText(_fullChangelog);
|
|
||||||
ShowMoreButton.Content = GetResourceText("up_show_less");
|
|
||||||
_showingFullChangelog = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetNotesText(string text)
|
|
||||||
{
|
|
||||||
NotesTextBlock.Text = text ?? string.Empty;
|
|
||||||
NotesScrollViewer.ScrollToTop();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetResourceText(string key)
|
|
||||||
{
|
|
||||||
return Application.Current.TryFindResource(key) as string ?? string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -39,12 +39,6 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<StartupObject>WandEnhancer.Program</StartupObject>
|
<StartupObject>WandEnhancer.Program</StartupObject>
|
||||||
<CMakeSourceDir>..\tools\asar-fuses-bypass</CMakeSourceDir>
|
|
||||||
<NativeBuildRoot>..\.tmp\cmake</NativeBuildRoot>
|
|
||||||
<NativeBuildConfiguration Condition="'$(Configuration)' == 'Debug'">Debug</NativeBuildConfiguration>
|
|
||||||
<NativeBuildConfiguration Condition="'$(NativeBuildConfiguration)' == ''">Release</NativeBuildConfiguration>
|
|
||||||
<CMakeBuildDir>$(NativeBuildRoot)\asar-fuses-bypass</CMakeBuildDir>
|
|
||||||
<ProxyDllPath>$(CMakeBuildDir)\$(NativeBuildConfiguration)\version.dll</ProxyDllPath>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Reference Include="Newtonsoft.Json, Version=13.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
|
<Reference Include="Newtonsoft.Json, Version=13.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
|
||||||
@@ -55,7 +49,6 @@
|
|||||||
<Reference Include="System.Data" />
|
<Reference Include="System.Data" />
|
||||||
<Reference Include="System.Windows.Forms" />
|
<Reference Include="System.Windows.Forms" />
|
||||||
<Reference Include="System.Xml" />
|
<Reference Include="System.Xml" />
|
||||||
<Reference Include="System.Net.Http" />
|
|
||||||
<Reference Include="System.Xaml">
|
<Reference Include="System.Xaml">
|
||||||
<RequiredTargetFramework>4.0</RequiredTargetFramework>
|
<RequiredTargetFramework>4.0</RequiredTargetFramework>
|
||||||
</Reference>
|
</Reference>
|
||||||
@@ -73,25 +66,29 @@
|
|||||||
<Compile Include="Converters\ToVisibilityConverter.cs" />
|
<Compile Include="Converters\ToVisibilityConverter.cs" />
|
||||||
<Compile Include="Core\Enhancer.cs" />
|
<Compile Include="Core\Enhancer.cs" />
|
||||||
<Compile Include="Core\EnhancerConfig.cs" />
|
<Compile Include="Core\EnhancerConfig.cs" />
|
||||||
|
<Compile Include="Core\ElectronFuse.cs" />
|
||||||
|
<Compile Include="Core\FuseLauncher.cs" />
|
||||||
|
<Compile Include="Core\LauncherLog.cs" />
|
||||||
|
<Compile Include="Core\JavaScriptPatchApplier.cs" />
|
||||||
|
<Compile Include="Core\Js\JsCursor.cs" />
|
||||||
|
<Compile Include="Core\Js\JsFunction.cs" />
|
||||||
|
<Compile Include="Core\Js\PatchPayload.cs" />
|
||||||
<Compile Include="Core\Services\LocalizationManager.cs" />
|
<Compile Include="Core\Services\LocalizationManager.cs" />
|
||||||
<Compile Include="Core\Services\SettingsManager.cs" />
|
<Compile Include="Core\Services\SettingsManager.cs" />
|
||||||
<Compile Include="Models\WeModConfig.cs" />
|
<Compile Include="Models\WeModConfig.cs" />
|
||||||
<Compile Include="Models\PatchConfig.cs" />
|
<Compile Include="Models\PatchConfig.cs" />
|
||||||
<Compile Include="Models\Signature.cs" />
|
|
||||||
<Compile Include="Program.cs" />
|
<Compile Include="Program.cs" />
|
||||||
<Compile Include="ReactiveUICore\AsyncRelayCommand.cs" />
|
|
||||||
<Compile Include="ReactiveUICore\ObservableObject.cs" />
|
<Compile Include="ReactiveUICore\ObservableObject.cs" />
|
||||||
<Compile Include="ReactiveUICore\RelayCommand.cs" />
|
<Compile Include="ReactiveUICore\RelayCommand.cs" />
|
||||||
<Compile Include="Utils\Common.cs" />
|
<Compile Include="Utils\ProcessTerminator.cs" />
|
||||||
<Compile Include="Utils\Extensions.cs" />
|
<Compile Include="Utils\WeModInstalls.cs" />
|
||||||
<Compile Include="Utils\Updater.cs" />
|
<Compile Include="View\Controls\PopupHost.xaml.cs">
|
||||||
<Compile Include="Utils\Win32\Shortcut.cs" />
|
<DependentUpon>PopupHost.xaml</DependentUpon>
|
||||||
<Compile Include="View\Controls\InfoItem.xaml.cs">
|
|
||||||
<DependentUpon>InfoItem.xaml</DependentUpon>
|
|
||||||
</Compile>
|
</Compile>
|
||||||
<Compile Include="View\Controls\PopupHost.xaml.cs" />
|
|
||||||
<Compile Include="View\MainWindow\Logs.cs" />
|
<Compile Include="View\MainWindow\Logs.cs" />
|
||||||
<Compile Include="View\MainWindow\MainWindow.xaml.cs" />
|
<Compile Include="View\MainWindow\MainWindow.xaml.cs" />
|
||||||
|
<Compile Include="View\MainWindow\IShellView.cs" />
|
||||||
|
<Compile Include="View\MainWindow\WindowsFileDialogs.cs" />
|
||||||
<Compile Include="View\MainWindow\MainWindowVm.cs" />
|
<Compile Include="View\MainWindow\MainWindowVm.cs" />
|
||||||
<Compile Include="View\Popups\PatchVectorsPopup.xaml.cs">
|
<Compile Include="View\Popups\PatchVectorsPopup.xaml.cs">
|
||||||
<DependentUpon>PatchVectorsPopup.xaml</DependentUpon>
|
<DependentUpon>PatchVectorsPopup.xaml</DependentUpon>
|
||||||
@@ -103,9 +100,6 @@
|
|||||||
<DependentUpon>App.xaml</DependentUpon>
|
<DependentUpon>App.xaml</DependentUpon>
|
||||||
<SubType>Code</SubType>
|
<SubType>Code</SubType>
|
||||||
</Compile>
|
</Compile>
|
||||||
<Compile Include="View\Popups\UpdatePopup.xaml.cs">
|
|
||||||
<DependentUpon>UpdatePopup.xaml</DependentUpon>
|
|
||||||
</Compile>
|
|
||||||
<Page Include="Locale\lang.en-US.xaml" />
|
<Page Include="Locale\lang.en-US.xaml" />
|
||||||
<Page Include="Locale\lang.zh-CN.xaml" />
|
<Page Include="Locale\lang.zh-CN.xaml" />
|
||||||
<Page Include="Locale\lang.de-DE.xaml" />
|
<Page Include="Locale\lang.de-DE.xaml" />
|
||||||
@@ -121,12 +115,10 @@
|
|||||||
<Page Include="Style\ColorScheme.xaml" />
|
<Page Include="Style\ColorScheme.xaml" />
|
||||||
<Page Include="Style\Icons.xaml" />
|
<Page Include="Style\Icons.xaml" />
|
||||||
<Page Include="Style\Styles.xaml" />
|
<Page Include="Style\Styles.xaml" />
|
||||||
<Page Include="View\Controls\InfoItem.xaml" />
|
|
||||||
<Page Include="View\Controls\PopupHost.xaml" />
|
<Page Include="View\Controls\PopupHost.xaml" />
|
||||||
<Page Include="View\MainWindow\MainWindow.xaml" />
|
<Page Include="View\MainWindow\MainWindow.xaml" />
|
||||||
<Page Include="View\Popups\PatchVectorsPopup.xaml" />
|
<Page Include="View\Popups\PatchVectorsPopup.xaml" />
|
||||||
<Page Include="View\Popups\SettingsPopup.xaml" />
|
<Page Include="View\Popups\SettingsPopup.xaml" />
|
||||||
<Page Include="View\Popups\UpdatePopup.xaml" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="Properties\AssemblyInfo.cs">
|
<Compile Include="Properties\AssemblyInfo.cs">
|
||||||
@@ -158,16 +150,16 @@
|
|||||||
<Name>AsarSharp</Name>
|
<Name>AsarSharp</Name>
|
||||||
</ProjectReference>
|
</ProjectReference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
|
||||||
<EmbeddedResource Include="$(ProxyDllPath)">
|
|
||||||
<LogicalName>proxydll</LogicalName>
|
|
||||||
</EmbeddedResource>
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<EmbeddedResource Include="..\web-panel\dist\**\*.*" Condition="Exists('..\web-panel\dist\index.html')">
|
<EmbeddedResource Include="..\web-panel\dist\**\*.*" Condition="Exists('..\web-panel\dist\index.html')">
|
||||||
<LogicalName>remote-panel/dist/%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
<LogicalName>remote-panel/dist/%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
||||||
</EmbeddedResource>
|
</EmbeddedResource>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="Patches\*.js">
|
||||||
|
<LogicalName>patches/%(Filename)%(Extension)</LogicalName>
|
||||||
|
</EmbeddedResource>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||||
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
|
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
|
||||||
@@ -177,14 +169,6 @@
|
|||||||
<Error Condition="!Exists('..\packages\ILRepack.2.0.41\build\ILRepack.props')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\ILRepack.2.0.41\build\ILRepack.props'))" />
|
<Error Condition="!Exists('..\packages\ILRepack.2.0.41\build\ILRepack.props')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\ILRepack.2.0.41\build\ILRepack.props'))" />
|
||||||
</Target>
|
</Target>
|
||||||
|
|
||||||
<Target Name="ValidateNativeArtifacts" BeforeTargets="BeforeBuild">
|
|
||||||
<Error Text="Proxy DLL not found: $(ProxyDllPath)"
|
|
||||||
Condition="!Exists('$(ProxyDllPath)')" />
|
|
||||||
|
|
||||||
<Message Text="Embedding Proxy DLL as resource from $(ProxyDllPath)"
|
|
||||||
Importance="high" />
|
|
||||||
</Target>
|
|
||||||
|
|
||||||
<Target Name="ILRepack" AfterTargets="Build" Condition="'$(Configuration)' == 'Release'">
|
<Target Name="ILRepack" AfterTargets="Build" Condition="'$(Configuration)' == 'Release'">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<ILRepackExe>..\packages\ILRepack.2.0.41\tools\ILRepack.exe</ILRepackExe>
|
<ILRepackExe>..\packages\ILRepack.2.0.41\tools\ILRepack.exe</ILRepackExe>
|
||||||
@@ -203,4 +187,4 @@
|
|||||||
<Exec Command=""$(ILRepackExe)" /allowMultiple /copyattrs /out:"$(OutputPath)$(AssemblyName).exe" "$(MainAssembly)" $(DllList)" />
|
<Exec Command=""$(ILRepackExe)" /allowMultiple /copyattrs /out:"$(OutputPath)$(AssemblyName).exe" "$(MainAssembly)" $(DllList)" />
|
||||||
<Delete Files="@(AssemblyList)" ContinueOnError="true" />
|
<Delete Files="@(AssemblyList)" ContinueOnError="true" />
|
||||||
</Target>
|
</Target>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Binary file not shown.
@@ -7,9 +7,6 @@ $ErrorActionPreference = 'Stop'
|
|||||||
|
|
||||||
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
$webPanelDir = Join-Path $repoRoot 'web-panel'
|
$webPanelDir = Join-Path $repoRoot 'web-panel'
|
||||||
$nativeBuildRoot = Join-Path $repoRoot '.tmp/cmake'
|
|
||||||
$asarFusesSourceDir = Join-Path $repoRoot 'tools/asar-fuses-bypass'
|
|
||||||
$asarFusesBuildDir = Join-Path $nativeBuildRoot 'asar-fuses-bypass'
|
|
||||||
$solutionPath = Join-Path $repoRoot 'Wand-Enhancer.sln'
|
$solutionPath = Join-Path $repoRoot 'Wand-Enhancer.sln'
|
||||||
|
|
||||||
function Resolve-CommandPath {
|
function Resolve-CommandPath {
|
||||||
@@ -23,38 +20,24 @@ function Resolve-CommandPath {
|
|||||||
return $command.Source
|
return $command.Source
|
||||||
}
|
}
|
||||||
|
|
||||||
function Resolve-NuGetPath {
|
function Resolve-VisualStudioPath {
|
||||||
$nugetCommand = Get-Command 'nuget.exe' -ErrorAction SilentlyContinue
|
|
||||||
if (-not $nugetCommand) {
|
|
||||||
$nugetCommand = Get-Command 'nuget' -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($nugetCommand) {
|
|
||||||
return $nugetCommand.Source
|
|
||||||
}
|
|
||||||
|
|
||||||
$toolsDir = Join-Path $repoRoot '.tmp/tools'
|
|
||||||
$nugetPath = Join-Path $toolsDir 'nuget.exe'
|
|
||||||
if (-not (Test-Path $nugetPath)) {
|
|
||||||
New-Item -ItemType Directory -Path $toolsDir -Force | Out-Null
|
|
||||||
Invoke-WebRequest -Uri 'https://dist.nuget.org/win-x86-commandline/latest/nuget.exe' -OutFile $nugetPath
|
|
||||||
}
|
|
||||||
|
|
||||||
return $nugetPath
|
|
||||||
}
|
|
||||||
|
|
||||||
function Resolve-MSBuildPath {
|
|
||||||
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
|
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
|
||||||
if (-not (Test-Path $vswhere)) {
|
if (-not (Test-Path $vswhere)) {
|
||||||
throw "vswhere.exe not found: $vswhere"
|
throw "vswhere.exe not found: $vswhere"
|
||||||
}
|
}
|
||||||
|
|
||||||
$installationPath = & $vswhere -latest -version '[17.0,18.0)' -requires Microsoft.Component.MSBuild -property installationPath
|
$installationPath = & $vswhere -latest -prerelease -products '*' -requires Microsoft.Component.MSBuild -property installationPath
|
||||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($installationPath)) {
|
if ([string]::IsNullOrWhiteSpace($installationPath)) {
|
||||||
throw 'Visual Studio 2022 with MSBuild was not found.'
|
throw 'Visual Studio with MSBuild was not found.'
|
||||||
}
|
}
|
||||||
|
|
||||||
$msbuildPath = Join-Path $installationPath 'MSBuild\Current\Bin\MSBuild.exe'
|
return $installationPath
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-MSBuildPath {
|
||||||
|
param([string]$VisualStudioPath)
|
||||||
|
|
||||||
|
$msbuildPath = Join-Path $VisualStudioPath 'MSBuild\Current\Bin\MSBuild.exe'
|
||||||
if (-not (Test-Path $msbuildPath)) {
|
if (-not (Test-Path $msbuildPath)) {
|
||||||
throw "MSBuild.exe not found: $msbuildPath"
|
throw "MSBuild.exe not found: $msbuildPath"
|
||||||
}
|
}
|
||||||
@@ -75,35 +58,48 @@ function Invoke-Step {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$cmake = Resolve-CommandPath 'cmake'
|
function Resolve-TargetFrameworkRoot {
|
||||||
$nuget = Resolve-NuGetPath
|
# Some environments do not register the v4.8 targeting pack for MSBuild to find on its own.
|
||||||
|
# Point at it explicitly when present; skip on CI where default resolution already works.
|
||||||
|
$root = Join-Path ${env:ProgramFiles(x86)} 'Reference Assemblies\Microsoft\Framework'
|
||||||
|
$frameworkList = Join-Path $root '.NETFramework\v4.8\RedistList\FrameworkList.xml'
|
||||||
|
if (Test-Path $frameworkList) {
|
||||||
|
return $root
|
||||||
|
}
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
$pnpm = Resolve-CommandPath 'pnpm'
|
$pnpm = Resolve-CommandPath 'pnpm'
|
||||||
$msbuild = Resolve-MSBuildPath
|
$visualStudio = Resolve-VisualStudioPath
|
||||||
$generator = 'Visual Studio 17 2022'
|
$msbuild = Resolve-MSBuildPath $visualStudio
|
||||||
|
$targetFrameworkRoot = Resolve-TargetFrameworkRoot
|
||||||
|
|
||||||
|
$buildArgs = @('/m', "/p:Configuration=$Configuration", '/p:Platform=Any CPU')
|
||||||
|
if ($targetFrameworkRoot) {
|
||||||
|
$buildArgs += "/p:TargetFrameworkRootPath=$targetFrameworkRoot"
|
||||||
|
}
|
||||||
|
|
||||||
Invoke-Step 'Install web-panel dependencies' {
|
Invoke-Step 'Install web-panel dependencies' {
|
||||||
& $pnpm --dir $webPanelDir install --frozen-lockfile
|
& $pnpm --dir $webPanelDir install --frozen-lockfile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Invoke-Step 'Lint web-panel' {
|
||||||
|
& $pnpm --dir $webPanelDir run lint
|
||||||
|
}
|
||||||
|
|
||||||
|
# Runs type-check (web + bridge), Vite, the bridge bundle, then the dist invariant check.
|
||||||
Invoke-Step 'Build web-panel' {
|
Invoke-Step 'Build web-panel' {
|
||||||
& $pnpm --dir $webPanelDir run build
|
& $pnpm --dir $webPanelDir run build
|
||||||
}
|
}
|
||||||
|
|
||||||
Invoke-Step 'Configure asar-fuses-bypass' {
|
|
||||||
& $cmake -S $asarFusesSourceDir -B $asarFusesBuildDir -G $generator -A x64
|
|
||||||
}
|
|
||||||
|
|
||||||
Invoke-Step 'Build asar-fuses-bypass' {
|
|
||||||
& $cmake --build $asarFusesBuildDir --config $Configuration
|
|
||||||
}
|
|
||||||
|
|
||||||
Invoke-Step 'Restore NuGet packages' {
|
Invoke-Step 'Restore NuGet packages' {
|
||||||
& $nuget restore $solutionPath -NonInteractive
|
& $msbuild $solutionPath /m /t:Restore /p:RestorePackagesConfig=true
|
||||||
}
|
}
|
||||||
|
|
||||||
Invoke-Step 'Build solution' {
|
Invoke-Step 'Build solution' {
|
||||||
& $msbuild $solutionPath /m /p:Configuration=$Configuration '/p:Platform=Any CPU' /t:Build
|
& $msbuild $solutionPath @buildArgs /t:Build
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host "Build completed successfully ($Configuration)." -ForegroundColor Green
|
Write-Host "Build completed successfully ($Configuration)." -ForegroundColor Green
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ function Normalize-Version {
|
|||||||
throw 'Version value cannot be empty.'
|
throw 'Version value cannot be empty.'
|
||||||
}
|
}
|
||||||
|
|
||||||
return $Value.Trim().TrimStart('v', 'V')
|
# A pre-release tag (1.1.0.0-rc.1) reads the notes of its base version.
|
||||||
|
return ($Value.Trim().TrimStart('v', 'V') -replace '-.*$', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-ChangelogSection {
|
function Get-ChangelogSection {
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ function Normalize-Version {
|
|||||||
throw 'Version value cannot be empty.'
|
throw 'Version value cannot be empty.'
|
||||||
}
|
}
|
||||||
|
|
||||||
return $Value.Trim().TrimStart('v', 'V')
|
# AssemblyVersion holds four numbers only, so a pre-release tag such as
|
||||||
|
# 1.1.0.0-rc.1 must compare and look up its notes as 1.1.0.0.
|
||||||
|
return ($Value.Trim().TrimStart('v', 'V') -replace '-.*$', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-ChangelogSection {
|
function Get-ChangelogSection {
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
# Build directories
|
|
||||||
/build/
|
|
||||||
/build-debug/
|
|
||||||
/build-release/
|
|
||||||
/out/
|
|
||||||
|
|
||||||
# CMake generated files
|
|
||||||
CMakeCache.txt
|
|
||||||
CMakeFiles/
|
|
||||||
cmake_install.cmake
|
|
||||||
CTestTestfile.cmake
|
|
||||||
Makefile
|
|
||||||
install_manifest.txt
|
|
||||||
|
|
||||||
# Compiled binaries
|
|
||||||
*.o
|
|
||||||
*.obj
|
|
||||||
*.lo
|
|
||||||
*.la
|
|
||||||
*.a
|
|
||||||
*.so
|
|
||||||
*.so.*
|
|
||||||
*.dylib
|
|
||||||
*.dll
|
|
||||||
*.exe
|
|
||||||
*.out
|
|
||||||
*.app
|
|
||||||
|
|
||||||
# Debug files
|
|
||||||
*.pch
|
|
||||||
*.pdb
|
|
||||||
*.mod
|
|
||||||
*.map
|
|
||||||
|
|
||||||
# Generated configuration headers
|
|
||||||
config.h
|
|
||||||
config.hpp
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# IDE files
|
|
||||||
# VS Code
|
|
||||||
.vscode/
|
|
||||||
*.code-workspace
|
|
||||||
|
|
||||||
# CLion
|
|
||||||
.idea/
|
|
||||||
|
|
||||||
# Visual Studio
|
|
||||||
*.user
|
|
||||||
*.suo
|
|
||||||
*.vcxproj.user
|
|
||||||
*.vcxproj.*
|
|
||||||
*.sln
|
|
||||||
|
|
||||||
# Xcode
|
|
||||||
*.pbxuser
|
|
||||||
*.mode1v3
|
|
||||||
*.mode2v3
|
|
||||||
*.perspectivev3
|
|
||||||
*.xcworkspace/
|
|
||||||
xcuserdata/
|
|
||||||
|
|
||||||
# OS junk
|
|
||||||
# macOS
|
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
# Windows
|
|
||||||
Thumbs.db
|
|
||||||
ehthumbs.db
|
|
||||||
Desktop.ini
|
|
||||||
$RECYCLE.BIN/
|
|
||||||
|
|
||||||
# Backup files
|
|
||||||
*~
|
|
||||||
*.swp
|
|
||||||
*.tmp
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
cmake_minimum_required(VERSION 3.16)
|
|
||||||
project(asar_fuses_bypass C)
|
|
||||||
|
|
||||||
set(CMAKE_C_STANDARD 11)
|
|
||||||
|
|
||||||
#[[
|
|
||||||
add_executable(asar_fuses_bypass main.c)
|
|
||||||
]]
|
|
||||||
|
|
||||||
set(CMAKE_SHARED_LIBRARY_PREFIX "")
|
|
||||||
set(CMAKE_STATIC_LIBRARY_PREFIX "")
|
|
||||||
|
|
||||||
if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
|
|
||||||
add_link_options(-static -static-libgcc -static-libstdc++)
|
|
||||||
endif()
|
|
||||||
|
|
||||||
add_library(version SHARED library.c library.def fuses.c)
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
//
|
|
||||||
// Created by kitbyte on 30.11.2025.
|
|
||||||
//
|
|
||||||
|
|
||||||
#include <Windows.h>
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <time.h>
|
|
||||||
|
|
||||||
#define ENABLE_LOGGING 0
|
|
||||||
|
|
||||||
#ifndef _DEBUG
|
|
||||||
#undef ENABLE_LOGGING
|
|
||||||
#define ENABLE_LOGGING 0
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#define FUSE_SENTINEL_LENGTH 32
|
|
||||||
#define FUSE_VERSION_SUPPORTED 1
|
|
||||||
#define FUSE_MIN_WIRE_LENGTH 5
|
|
||||||
|
|
||||||
#define ALIGN8(ptr, mod) ((((ULONG_PTR)(ptr) + 7) & ~7) + ((mod) * 8))
|
|
||||||
|
|
||||||
#if defined(_WIN64)
|
|
||||||
#define SENTINEL_PART1 0x6E64474B70374C64ULL
|
|
||||||
#define SENTINEL_PART2 0x6262503639377A4EULL
|
|
||||||
#define SENTINEL_PART3 0x58486D4B4E57516AULL
|
|
||||||
#define SENTINEL_PART4 0x5873743942615A42ULL
|
|
||||||
#else
|
|
||||||
static const DWORD SENTINEL_PARTS[8] = {
|
|
||||||
0x70374C64, 0x6E64474B,
|
|
||||||
0x39377A4E, 0x62625036,
|
|
||||||
0x4E57516A, 0x58486D4B,
|
|
||||||
0x42615A42, 0x58737439
|
|
||||||
};
|
|
||||||
#endif
|
|
||||||
|
|
||||||
typedef enum {
|
|
||||||
FUSE_RUN_AS_NODE = 0,
|
|
||||||
FUSE_COOKIE_ENCRYPTION = 1,
|
|
||||||
FUSE_NODE_OPTIONS = 2,
|
|
||||||
FUSE_NODE_CLI_INSPECT = 3,
|
|
||||||
FUSE_ASAR_INTEGRITY_VALIDATION = 4,
|
|
||||||
FUSE_ONLY_LOAD_APP_FROM_ASAR = 5,
|
|
||||||
FUSE_LOAD_BROWSER_V8_SNAPSHOT = 6,
|
|
||||||
FUSE_GRANT_FILE_PROTOCOL = 7
|
|
||||||
} ElectronFuseIndex;
|
|
||||||
|
|
||||||
typedef enum {
|
|
||||||
FUSE_STATE_DISABLED = '0',
|
|
||||||
FUSE_STATE_ENABLED = '1',
|
|
||||||
FUSE_STATE_REMOVED = 'r'
|
|
||||||
} FuseState;
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
char sentinel[FUSE_SENTINEL_LENGTH];
|
|
||||||
unsigned char version;
|
|
||||||
unsigned char wire_length;
|
|
||||||
unsigned char fuses[];
|
|
||||||
} FuseWire;
|
|
||||||
|
|
||||||
#if ENABLE_LOGGING
|
|
||||||
|
|
||||||
static FILE* g_logFile = NULL;
|
|
||||||
|
|
||||||
static void log_init(void) {
|
|
||||||
char path[MAX_PATH];
|
|
||||||
GetModuleFileNameA(NULL, path, MAX_PATH);
|
|
||||||
char* dot = strrchr(path, '.');
|
|
||||||
if (dot) strcpy(dot, ".log");
|
|
||||||
else strcat(path, ". log");
|
|
||||||
|
|
||||||
g_logFile = fopen(path, "a");
|
|
||||||
if (g_logFile) {
|
|
||||||
time_t now = time(NULL);
|
|
||||||
fprintf(g_logFile, "\n=== Session: %s", ctime(&now));
|
|
||||||
fflush(g_logFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static void log_close(void) {
|
|
||||||
if (g_logFile) {
|
|
||||||
fclose(g_logFile);
|
|
||||||
g_logFile = NULL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static void log_msg(const char* fmt, .. .) {
|
|
||||||
if (!g_logFile) return;
|
|
||||||
va_list args;
|
|
||||||
va_start(args, fmt);
|
|
||||||
vfprintf(g_logFile, fmt, args);
|
|
||||||
va_end(args);
|
|
||||||
fflush(g_logFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
#else
|
|
||||||
#define log_init() ((void)0)
|
|
||||||
#define log_close() ((void)0)
|
|
||||||
#define log_msg(...) ((void)0)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
static FuseWire* find_fuse_wire(int offset) {
|
|
||||||
char* base = (char*)GetModuleHandleA(NULL);
|
|
||||||
if (!base) return NULL;
|
|
||||||
|
|
||||||
IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)base;
|
|
||||||
if (dos->e_magic != IMAGE_DOS_SIGNATURE) return NULL;
|
|
||||||
|
|
||||||
IMAGE_NT_HEADERS* nt = (IMAGE_NT_HEADERS*)(base + dos->e_lfanew);
|
|
||||||
if (nt->Signature != IMAGE_NT_SIGNATURE) return NULL;
|
|
||||||
|
|
||||||
DWORD size = nt->OptionalHeader.SizeOfImage;
|
|
||||||
char* start = (char*)ALIGN8(base, 1) + offset;
|
|
||||||
char* end = (char*)ALIGN8(base + size - FUSE_SENTINEL_LENGTH, -1) - offset;
|
|
||||||
|
|
||||||
#if defined(_WIN64)
|
|
||||||
for (DWORD64* p = (DWORD64*)start; p < (DWORD64*)end; p++) {
|
|
||||||
if (p[0] == SENTINEL_PART1 && p[1] == SENTINEL_PART2 &&
|
|
||||||
p[2] == SENTINEL_PART3 && p[3] == SENTINEL_PART4) {
|
|
||||||
log_msg("[+] Sentinel at: %p\n", p);
|
|
||||||
return (FuseWire*)p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
for (DWORD* p = (DWORD*)start; p < (DWORD*)end; p += 2) {
|
|
||||||
if (p[0] == SENTINEL_PARTS[0] && p[1] == SENTINEL_PARTS[1] &&
|
|
||||||
p[2] == SENTINEL_PARTS[2] && p[3] == SENTINEL_PARTS[3] &&
|
|
||||||
p[4] == SENTINEL_PARTS[4] && p[5] == SENTINEL_PARTS[5] &&
|
|
||||||
p[6] == SENTINEL_PARTS[6] && p[7] == SENTINEL_PARTS[7]) {
|
|
||||||
log_msg("[+] Sentinel at: %p\n", p);
|
|
||||||
return (FuseWire*)p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
return NULL;
|
|
||||||
}
|
|
||||||
|
|
||||||
static BOOL patch_fuse(unsigned char* fuse) {
|
|
||||||
DWORD prot;
|
|
||||||
if (!VirtualProtect(fuse, 1, PAGE_READWRITE, &prot)) {
|
|
||||||
log_msg("[-] VirtualProtect failed: %lu\n", GetLastError());
|
|
||||||
return FALSE;
|
|
||||||
}
|
|
||||||
*fuse = FUSE_STATE_REMOVED;
|
|
||||||
VirtualProtect(fuse, 1, prot, &prot);
|
|
||||||
return TRUE;
|
|
||||||
}
|
|
||||||
|
|
||||||
BOOL disable_asar_integrity(void) {
|
|
||||||
log_init();
|
|
||||||
|
|
||||||
FuseWire* wire = find_fuse_wire(0);
|
|
||||||
if (! wire) wire = find_fuse_wire(4);
|
|
||||||
|
|
||||||
if (! wire) {
|
|
||||||
log_msg("[-] Fuse wire not found\n");
|
|
||||||
log_close();
|
|
||||||
return FALSE;
|
|
||||||
}
|
|
||||||
|
|
||||||
log_msg("[+] Wire at %p, ver=%d, len=%d\n", wire, wire->version, wire->wire_length);
|
|
||||||
|
|
||||||
if (wire->version != FUSE_VERSION_SUPPORTED) {
|
|
||||||
log_msg("[-] Unsupported version: %d\n", wire->version);
|
|
||||||
log_close();
|
|
||||||
return FALSE;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wire->wire_length < FUSE_MIN_WIRE_LENGTH) {
|
|
||||||
log_msg("[*] Wire too short, skip\n");
|
|
||||||
log_close();
|
|
||||||
return TRUE;
|
|
||||||
}
|
|
||||||
|
|
||||||
unsigned char* target = &wire->fuses[FUSE_ASAR_INTEGRITY_VALIDATION];
|
|
||||||
|
|
||||||
if (*target == FUSE_STATE_REMOVED) {
|
|
||||||
log_msg("[*] Already patched\n");
|
|
||||||
log_close();
|
|
||||||
return TRUE;
|
|
||||||
}
|
|
||||||
|
|
||||||
log_msg("[*] Patching fuse[%d]: 0x%02X -> 0x%02X\n",
|
|
||||||
FUSE_ASAR_INTEGRITY_VALIDATION, *target, FUSE_STATE_REMOVED);
|
|
||||||
|
|
||||||
BOOL result = patch_fuse(target);
|
|
||||||
log_msg(result ? "[+] Success\n" : "[-] Failed\n");
|
|
||||||
|
|
||||||
log_close();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
//
|
|
||||||
// Created by kitbyte on 30.11.2025.
|
|
||||||
//
|
|
||||||
#include <Windows.h>
|
|
||||||
#include <winver.h>
|
|
||||||
|
|
||||||
extern BOOL disable_asar_integrity(void);
|
|
||||||
|
|
||||||
static HMODULE g_originalVersionDll;
|
|
||||||
|
|
||||||
#define FOR_EACH_VERSION_FORWARDER(X) \
|
|
||||||
X(GetFileVersionInfoA, BOOL, FALSE, \
|
|
||||||
(LPCSTR filename, DWORD handle, DWORD length, LPVOID data), \
|
|
||||||
(filename, handle, length, data)) \
|
|
||||||
X(GetFileVersionInfoExA, BOOL, FALSE, \
|
|
||||||
(DWORD flags, LPCSTR filename, DWORD handle, DWORD length, LPVOID data), \
|
|
||||||
(flags, filename, handle, length, data)) \
|
|
||||||
X(GetFileVersionInfoExW, BOOL, FALSE, \
|
|
||||||
(DWORD flags, LPCWSTR filename, DWORD handle, DWORD length, LPVOID data), \
|
|
||||||
(flags, filename, handle, length, data)) \
|
|
||||||
X(GetFileVersionInfoSizeA, DWORD, 0, \
|
|
||||||
(LPCSTR filename, LPDWORD handle), \
|
|
||||||
(filename, handle)) \
|
|
||||||
X(GetFileVersionInfoSizeExA, DWORD, 0, \
|
|
||||||
(DWORD flags, LPCSTR filename, LPDWORD handle), \
|
|
||||||
(flags, filename, handle)) \
|
|
||||||
X(GetFileVersionInfoSizeExW, DWORD, 0, \
|
|
||||||
(DWORD flags, LPCWSTR filename, LPDWORD handle), \
|
|
||||||
(flags, filename, handle)) \
|
|
||||||
X(GetFileVersionInfoSizeW, DWORD, 0, \
|
|
||||||
(LPCWSTR filename, LPDWORD handle), \
|
|
||||||
(filename, handle)) \
|
|
||||||
X(GetFileVersionInfoW, BOOL, FALSE, \
|
|
||||||
(LPCWSTR filename, DWORD handle, DWORD length, LPVOID data), \
|
|
||||||
(filename, handle, length, data)) \
|
|
||||||
X(VerFindFileA, DWORD, 0, \
|
|
||||||
(DWORD flags, LPCSTR fileName, LPCSTR winDir, LPCSTR appDir, LPSTR curDir, PUINT curDirLen, LPSTR destDir, PUINT destDirLen), \
|
|
||||||
(flags, fileName, winDir, appDir, curDir, curDirLen, destDir, destDirLen)) \
|
|
||||||
X(VerFindFileW, DWORD, 0, \
|
|
||||||
(DWORD flags, LPCWSTR fileName, LPCWSTR winDir, LPCWSTR appDir, LPWSTR curDir, PUINT curDirLen, LPWSTR destDir, PUINT destDirLen), \
|
|
||||||
(flags, fileName, winDir, appDir, curDir, curDirLen, destDir, destDirLen)) \
|
|
||||||
X(VerInstallFileA, DWORD, 0, \
|
|
||||||
(DWORD flags, LPCSTR srcFileName, LPCSTR destFileName, LPCSTR srcDir, LPCSTR destDir, LPCSTR curDir, LPSTR tempFile, PUINT tempFileLen), \
|
|
||||||
(flags, srcFileName, destFileName, srcDir, destDir, curDir, tempFile, tempFileLen)) \
|
|
||||||
X(VerInstallFileW, DWORD, 0, \
|
|
||||||
(DWORD flags, LPCWSTR srcFileName, LPCWSTR destFileName, LPCWSTR srcDir, LPCWSTR destDir, LPCWSTR curDir, LPWSTR tempFile, PUINT tempFileLen), \
|
|
||||||
(flags, srcFileName, destFileName, srcDir, destDir, curDir, tempFile, tempFileLen)) \
|
|
||||||
X(VerLanguageNameA, DWORD, 0, \
|
|
||||||
(DWORD language, LPSTR buffer, DWORD bufferLength), \
|
|
||||||
(language, buffer, bufferLength)) \
|
|
||||||
X(VerLanguageNameW, DWORD, 0, \
|
|
||||||
(DWORD language, LPWSTR buffer, DWORD bufferLength), \
|
|
||||||
(language, buffer, bufferLength)) \
|
|
||||||
X(VerQueryValueA, BOOL, FALSE, \
|
|
||||||
(LPCVOID block, LPCSTR subBlock, LPVOID* buffer, PUINT bufferLength), \
|
|
||||||
(block, subBlock, buffer, bufferLength)) \
|
|
||||||
X(VerQueryValueW, BOOL, FALSE, \
|
|
||||||
(LPCVOID block, LPCWSTR subBlock, LPVOID* buffer, PUINT bufferLength), \
|
|
||||||
(block, subBlock, buffer, bufferLength))
|
|
||||||
|
|
||||||
#if defined(_MSC_VER) && !defined(_WIN64)
|
|
||||||
|
|
||||||
#define DECLARE_FORWARDER(name, return_type, default_value, params, args) \
|
|
||||||
static FARPROC s_##name; \
|
|
||||||
__declspec(naked) return_type WINAPI name params \
|
|
||||||
{ \
|
|
||||||
__asm \
|
|
||||||
{ \
|
|
||||||
jmp dword ptr [s_##name] \
|
|
||||||
} \
|
|
||||||
}
|
|
||||||
|
|
||||||
#define LOAD_FORWARDER(name, return_type, default_value, params, args) \
|
|
||||||
s_##name = GetProcAddress(g_originalVersionDll, #name);
|
|
||||||
|
|
||||||
#else
|
|
||||||
|
|
||||||
#define DECLARE_FORWARDER(name, return_type, default_value, params, args) \
|
|
||||||
typedef return_type (WINAPI *name##_fn) params; \
|
|
||||||
static name##_fn s_##name; \
|
|
||||||
return_type WINAPI name params \
|
|
||||||
{ \
|
|
||||||
if (s_##name == NULL) \
|
|
||||||
{ \
|
|
||||||
SetLastError(ERROR_PROC_NOT_FOUND); \
|
|
||||||
return default_value; \
|
|
||||||
} \
|
|
||||||
return s_##name args; \
|
|
||||||
}
|
|
||||||
|
|
||||||
#define LOAD_FORWARDER(name, return_type, default_value, params, args) \
|
|
||||||
s_##name = (name##_fn)GetProcAddress(g_originalVersionDll, #name);
|
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|
||||||
FOR_EACH_VERSION_FORWARDER(DECLARE_FORWARDER)
|
|
||||||
|
|
||||||
BOOL WINAPI GetFileVersionInfoByHandle(void)
|
|
||||||
{
|
|
||||||
SetLastError(ERROR_CALL_NOT_IMPLEMENTED);
|
|
||||||
return FALSE;
|
|
||||||
}
|
|
||||||
|
|
||||||
static BOOL SourceInit(void)
|
|
||||||
{
|
|
||||||
WCHAR source[MAX_PATH];
|
|
||||||
UINT sourceLength = GetSystemDirectoryW(source, MAX_PATH);
|
|
||||||
|
|
||||||
if (sourceLength == 0 || sourceLength >= MAX_PATH)
|
|
||||||
{
|
|
||||||
return FALSE;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wcscat_s(source, MAX_PATH, L"\\version.dll") != 0)
|
|
||||||
{
|
|
||||||
return FALSE;
|
|
||||||
}
|
|
||||||
|
|
||||||
g_originalVersionDll = LoadLibraryW(source);
|
|
||||||
if (!g_originalVersionDll)
|
|
||||||
{
|
|
||||||
return FALSE;
|
|
||||||
}
|
|
||||||
|
|
||||||
FOR_EACH_VERSION_FORWARDER(LOAD_FORWARDER);
|
|
||||||
|
|
||||||
return TRUE;
|
|
||||||
}
|
|
||||||
|
|
||||||
BOOL WINAPI DllMain(HMODULE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
|
|
||||||
{
|
|
||||||
(void)lpvReserved;
|
|
||||||
|
|
||||||
if (fdwReason == DLL_PROCESS_ATTACH)
|
|
||||||
{
|
|
||||||
DisableThreadLibraryCalls(hinstDLL);
|
|
||||||
|
|
||||||
if (!SourceInit())
|
|
||||||
{
|
|
||||||
return FALSE;
|
|
||||||
}
|
|
||||||
|
|
||||||
disable_asar_integrity();
|
|
||||||
}
|
|
||||||
|
|
||||||
return TRUE;
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
LIBRARY "VERSION"
|
|
||||||
EXPORTS
|
|
||||||
|
|
||||||
GetFileVersionInfoA
|
|
||||||
GetFileVersionInfoByHandle
|
|
||||||
GetFileVersionInfoExA
|
|
||||||
GetFileVersionInfoExW
|
|
||||||
GetFileVersionInfoSizeA
|
|
||||||
GetFileVersionInfoSizeExA
|
|
||||||
GetFileVersionInfoSizeExW
|
|
||||||
GetFileVersionInfoSizeW
|
|
||||||
GetFileVersionInfoW
|
|
||||||
VerFindFileA
|
|
||||||
VerFindFileW
|
|
||||||
VerInstallFileA
|
|
||||||
VerInstallFileW
|
|
||||||
VerLanguageNameA
|
|
||||||
VerLanguageNameW
|
|
||||||
VerQueryValueA
|
|
||||||
VerQueryValueW
|
|
||||||
Vendored
+4
@@ -22,3 +22,7 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# Compiled lingui catalogs — generated from .po by `lingui compile`; the app
|
||||||
|
# loads .po directly via @lingui/vite-plugin, so these are build artifacts.
|
||||||
|
src/locales/**/*.js
|
||||||
|
|||||||
Vendored
-7
@@ -1,7 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
coverage/
|
|
||||||
.pnpm-store/
|
|
||||||
pnpm-lock.yaml
|
|
||||||
package-lock.json
|
|
||||||
pnpm-lock.yaml
|
|
||||||
yarn.lock
|
|
||||||
Vendored
-11
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"endOfLine": "lf",
|
|
||||||
"semi": false,
|
|
||||||
"singleQuote": false,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"printWidth": 80,
|
|
||||||
"plugins": ["prettier-plugin-tailwindcss"],
|
|
||||||
"tailwindStylesheet": "src/index.css",
|
|
||||||
"tailwindFunctions": ["cn", "cva"]
|
|
||||||
}
|
|
||||||
Vendored
+254
@@ -0,0 +1,254 @@
|
|||||||
|
Use these rules as defaults, not as a reason to add ceremonial folders or wrapper layers.
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
- Organize code around product capabilities, not framework vocabulary.
|
||||||
|
- Keep related UI, state, rules, and data access close until a real boundary justifies moving
|
||||||
|
them apart.
|
||||||
|
- Dependencies point from composition and UI toward stable rules and narrow capabilities.
|
||||||
|
- Protect rendering code from business, state-management, and infrastructure complexity.
|
||||||
|
- Keep one source of truth and derive everything else.
|
||||||
|
- Apply KISS, YAGNI, and DRY together. Remove duplicated knowledge, not merely similar syntax.
|
||||||
|
- Prefer explicit, readable flow over clever abstractions and hidden behavior.
|
||||||
|
|
||||||
|
## Screaming Architecture
|
||||||
|
|
||||||
|
The repository structure and public APIs should reveal what the product does.
|
||||||
|
|
||||||
|
Prefer:
|
||||||
|
|
||||||
|
```text
|
||||||
|
features/
|
||||||
|
checkout/
|
||||||
|
search/
|
||||||
|
account-security/
|
||||||
|
```
|
||||||
|
|
||||||
|
Avoid making the application read primarily as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
components/
|
||||||
|
hooks/
|
||||||
|
services/
|
||||||
|
stores/
|
||||||
|
utils/
|
||||||
|
```
|
||||||
|
|
||||||
|
Technical folders are useful inside a capability, where their owner is clear. Generic top-level
|
||||||
|
folders easily become dependency magnets with unclear ownership.
|
||||||
|
|
||||||
|
Names should use product language. Prefer `useCheckoutSummary`, `reserveStock`, and
|
||||||
|
`AccountSecurityPanel` over `useData`, `processItems`, and `GenericPanel`.
|
||||||
|
|
||||||
|
## Suggested Structure
|
||||||
|
|
||||||
|
Start with the smallest structure that makes ownership obvious:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
app/ startup, providers, router, global composition
|
||||||
|
pages/ route-level composition
|
||||||
|
features/
|
||||||
|
<capability>/
|
||||||
|
index.ts optional public API
|
||||||
|
ui/ optional rendering components
|
||||||
|
model/ optional state, view models, decisions
|
||||||
|
api/ optional external data access
|
||||||
|
lib/ optional feature-local pure helpers
|
||||||
|
domains/ optional shared product rules and types
|
||||||
|
shared/
|
||||||
|
ui/ domain-free visual primitives
|
||||||
|
api/ generic transport/query infrastructure
|
||||||
|
lib/ genuinely generic pure helpers
|
||||||
|
```
|
||||||
|
|
||||||
|
Folders are created when they contain a real responsibility. A small feature may be one cohesive
|
||||||
|
file. Do not create empty layers in anticipation of future complexity.
|
||||||
|
|
||||||
|
## Dependency Direction
|
||||||
|
|
||||||
|
- `app` installs providers, constructs dependencies, and composes the application.
|
||||||
|
- `pages` compose capabilities for a route. They do not own business rules or data protocols.
|
||||||
|
- A feature owns one user-recognizable capability end to end.
|
||||||
|
- Feature UI consumes its own model/view-model API, not raw infrastructure.
|
||||||
|
- Shared domain code contains reusable product rules and stays independent of React and I/O.
|
||||||
|
- `shared` contains only domain-free code. Product-specific code is not shared merely because
|
||||||
|
two files use it.
|
||||||
|
- Avoid feature-to-feature imports. Compose features in a page, promote truly shared rules to a
|
||||||
|
domain module, or introduce a named workflow when coordination is the actual responsibility.
|
||||||
|
- Cyclic imports are an architecture problem, not something to solve with a tooling workaround.
|
||||||
|
|
||||||
|
For a simple feature, direct `ui -> model -> api` dependencies are sufficient. Introduce ports,
|
||||||
|
facades, dependency injection, or workflows only when they hide real complexity, enable
|
||||||
|
important tests, or separate unstable infrastructure.
|
||||||
|
|
||||||
|
## Make Composition Read Like The Product
|
||||||
|
|
||||||
|
Pages and other composition boundaries should use capability-level APIs.
|
||||||
|
|
||||||
|
Prefer:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<CheckoutSummary />
|
||||||
|
<PlaceOrderButton />
|
||||||
|
```
|
||||||
|
|
||||||
|
Over:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card>
|
||||||
|
<Select options={paymentOptions} onChange={handlePaymentChange} />
|
||||||
|
<Button onClick={handleSubmit}>Submit</Button>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
The second version makes the page understand checkout behavior and low-level UI configuration.
|
||||||
|
That knowledge belongs to the checkout capability.
|
||||||
|
|
||||||
|
This does not mean wrapping every native element or design-system primitive. Semantic HTML and
|
||||||
|
visual primitives are correct inside feature UI. Create a capability component when it hides
|
||||||
|
product behavior or gives composition code a clearer product-level API.
|
||||||
|
|
||||||
|
Avoid "raw components" whose consumers must know internal options, state transitions, query
|
||||||
|
shapes, or protocol details. Avoid generic configuration-driven components that combine
|
||||||
|
unrelated product modes behind dozens of props.
|
||||||
|
|
||||||
|
## UI Boundary
|
||||||
|
|
||||||
|
- Components render data and translate DOM events into named user intents.
|
||||||
|
- Keep business decisions, data mapping, persistence, protocol handling, and multi-step async
|
||||||
|
flows outside rendering components.
|
||||||
|
- UI receives render-ready values. It should not reconstruct domain meaning from raw DTOs.
|
||||||
|
- Prefer intent props and commands such as `onApprove`, `renameProject`, or `submitOrder` over
|
||||||
|
generic `onChange`, `setState`, or `patch` APIs at capability boundaries.
|
||||||
|
- Keep ephemeral visual state local: focus, hover, open/closed, and uncommitted input usually
|
||||||
|
belong in the component.
|
||||||
|
- Split components by responsibility and API clarity, not by arbitrary line limits.
|
||||||
|
- Prefer slots and composition over components with many layout modes and boolean props.
|
||||||
|
- Use semantic HTML and preserve accessibility behavior.
|
||||||
|
|
||||||
|
A view-model hook is useful when it protects UI from state shape, async coordination, or business
|
||||||
|
decisions. Do not create a pass-through hook that only renames one value to satisfy a diagram.
|
||||||
|
|
||||||
|
## State Ownership
|
||||||
|
|
||||||
|
Choose the smallest correct owner:
|
||||||
|
|
||||||
|
| State | Preferred owner |
|
||||||
|
| --- | --- |
|
||||||
|
| Ephemeral visual state | local component state |
|
||||||
|
| Uncommitted form state | the form or feature |
|
||||||
|
| URL/shareable navigation state | the router/URL |
|
||||||
|
| Remote server resource and cache | a query/cache layer |
|
||||||
|
| Shared capability state | that feature's model/store |
|
||||||
|
| Cross-capability process | a named workflow or app-level model |
|
||||||
|
|
||||||
|
- A store is not a bucket for every value used by several components.
|
||||||
|
- Split state by capability and lifecycle, not by data type.
|
||||||
|
- Expose narrow selectors, hooks, or commands. Do not expose a complete mutable store to all UI.
|
||||||
|
- Store transitions should express user or domain intent, not generic object mutation.
|
||||||
|
- Derive values instead of storing synchronized copies.
|
||||||
|
- Do not use effects to keep two pieces of application state synchronized.
|
||||||
|
- React Context is suitable for dependency injection or stable scoped state. Avoid one broad
|
||||||
|
app context whose every update rerenders unrelated consumers.
|
||||||
|
|
||||||
|
State-library choice is an implementation detail. Architecture should survive replacing it
|
||||||
|
without rewriting pages and rendering components.
|
||||||
|
|
||||||
|
## Effects And Async Work
|
||||||
|
|
||||||
|
- Use effects to synchronize with external systems, not to calculate render data or handle user
|
||||||
|
events.
|
||||||
|
- Start event-driven work from the event or model command that owns it.
|
||||||
|
- Every subscription, timer, listener, or in-flight operation must have a clear owner and
|
||||||
|
cleanup path.
|
||||||
|
- The owning feature/model defines pending, success, empty, error, retry, and cancellation
|
||||||
|
semantics.
|
||||||
|
- Prevent stale async results and race conditions where users can trigger overlapping work.
|
||||||
|
- Do not hide failures with broad `catch` blocks or silently convert errors into empty data.
|
||||||
|
|
||||||
|
## Data And Infrastructure
|
||||||
|
|
||||||
|
- Treat network responses, storage, URL input, files, and third-party SDK output as untrusted.
|
||||||
|
- Validate and normalize data at the boundary where it enters the application.
|
||||||
|
- Map transport DTOs and external errors into product-oriented values before they reach UI.
|
||||||
|
- Keep raw `fetch`, storage APIs, SDK calls, and protocol details out of rendering components.
|
||||||
|
- Keep a feature-specific API adapter inside the feature until it has a real shared consumer.
|
||||||
|
- Introduce a client, repository, gateway, service, or facade only when its responsibility is
|
||||||
|
distinct and useful.
|
||||||
|
- Avoid wrapper chains that only forward calls. One clear adapter is better than
|
||||||
|
`Client -> Service -> Facade` without separate responsibilities.
|
||||||
|
- Inject infrastructure when tests, multiple implementations, lifecycle, or unstable external
|
||||||
|
APIs justify it. Do not introduce dependency injection for every pure helper.
|
||||||
|
|
||||||
|
## Component And Hook APIs
|
||||||
|
|
||||||
|
- Component and hook APIs describe product intent, not internal implementation.
|
||||||
|
- Avoid boolean prop combinations that create unclear or invalid modes. Prefer explicit variants
|
||||||
|
or separate components.
|
||||||
|
- Avoid passing raw query results, stores, SDK clients, or large configuration objects through
|
||||||
|
component trees.
|
||||||
|
- Keep public props small and cohesive. A component that needs unrelated groups of props likely
|
||||||
|
owns too many responsibilities.
|
||||||
|
- Custom hooks encapsulate React state, lifecycle, or reusable reactive behavior. Pure
|
||||||
|
calculations remain plain functions.
|
||||||
|
- Do not use `useEffect`, `useMemo`, `useCallback`, or `memo` by habit. Use them for correctness
|
||||||
|
or measured performance needs.
|
||||||
|
- Do not duplicate server or domain state into component state merely to make it editable.
|
||||||
|
Create an explicit draft only when the UX requires commit/cancel semantics.
|
||||||
|
|
||||||
|
## Public Boundaries
|
||||||
|
|
||||||
|
- Export the smallest useful public surface of a feature.
|
||||||
|
- Consumers should use a feature's public components, hooks, commands, and types, not deep
|
||||||
|
internal paths.
|
||||||
|
- Keep implementation-only state, DTOs, adapters, and helpers private.
|
||||||
|
- Do not create barrel files everywhere. Use a public entry point only where a real boundary
|
||||||
|
exists.
|
||||||
|
- A reusable abstraction should have a clear owner and at least one current reason to exist.
|
||||||
|
- Avoid generic `core`, `common`, `helpers`, `services`, or `utils` modules that collect
|
||||||
|
unrelated responsibilities.
|
||||||
|
|
||||||
|
## Growing The Architecture
|
||||||
|
|
||||||
|
Start local and promote code only after pressure appears:
|
||||||
|
|
||||||
|
- A second consumer may justify shared domain code, but similar code is not automatically the
|
||||||
|
same knowledge.
|
||||||
|
- Repeated external integration logic may justify a shared adapter.
|
||||||
|
- A process coordinating several capabilities may justify a named workflow.
|
||||||
|
- A large feature may split into smaller capabilities when they have distinct responsibilities
|
||||||
|
and lifecycles.
|
||||||
|
- Separate packages are useful when an enforceable boundary, independent reuse, or independent
|
||||||
|
lifecycle outweighs their maintenance cost.
|
||||||
|
|
||||||
|
Do not begin a small application with every possible layer, package, provider, repository,
|
||||||
|
facade, and design pattern. Strong architecture makes growth cheaper; it does not predict every
|
||||||
|
future requirement.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Test product behavior and public contracts, not implementation trivia.
|
||||||
|
- Test pure rules with unit tests.
|
||||||
|
- Test feature models and async transitions without rendering where practical.
|
||||||
|
- Test components through accessible user behavior.
|
||||||
|
- Test infrastructure mapping and validation at external boundaries.
|
||||||
|
- Keep end-to-end tests for critical user journeys.
|
||||||
|
- Mock external systems and unstable boundaries, not every internal function.
|
||||||
|
- Add tests proportional to risk, especially for validation, permissions, races, retries,
|
||||||
|
cancellation, and regressions.
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
|
||||||
|
Before finishing a change, ask:
|
||||||
|
|
||||||
|
- Does the file location make its owner obvious?
|
||||||
|
- Does composition code read in product language?
|
||||||
|
- Is UI protected from raw state, DTOs, infrastructure, and business decisions?
|
||||||
|
- Is there one source of truth?
|
||||||
|
- Are effects only synchronizing external systems?
|
||||||
|
- Is new shared code genuinely domain-free or genuinely shared?
|
||||||
|
- Does every abstraction remove current complexity?
|
||||||
|
- Can important behavior be tested without rendering the whole app?
|
||||||
|
- Did the change preserve accessibility, error handling, and cleanup?
|
||||||
|
- Is this the least code that clearly solves the current problem?
|
||||||
Vendored
+6
-11
@@ -1,17 +1,18 @@
|
|||||||
# Wand Web Panel
|
# Wand Web Panel
|
||||||
|
|
||||||
Local mobile-friendly web panel scaffold for Wand.
|
Local mobile-friendly web panel for Wand.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run dev
|
pnpm dev
|
||||||
|
pnpm bridge:demo
|
||||||
```
|
```
|
||||||
|
|
||||||
Hosted access on the local machine:
|
Hosted access on the local machine:
|
||||||
|
|
||||||
- `http://localhost:4173/?mock=1`
|
- `http://localhost:4173/`
|
||||||
|
|
||||||
Hosted access on the LAN:
|
Hosted access on the LAN:
|
||||||
|
|
||||||
@@ -21,11 +22,5 @@ pnpm run dev:host
|
|||||||
|
|
||||||
Then open the machine IP on port `4173`.
|
Then open the machine IP on port `4173`.
|
||||||
|
|
||||||
## Modes
|
Use `?ws=ws://host:port/remote/ws` to override the bridge URL. The fixture bridge is dev-only;
|
||||||
|
production is bundled to `dist/bridge.cjs`.
|
||||||
- `?mock=1`
|
|
||||||
- dev server only; loads the demo trainer and values through a debug-only import
|
|
||||||
- `?ws=ws://host:port/remote/ws`
|
|
||||||
- connects to a real bridge once the desktop layer exists
|
|
||||||
|
|
||||||
Production builds exclude the debug route and demo JSON from the shipped bundle.
|
|
||||||
|
|||||||
Vendored
+106
@@ -0,0 +1,106 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"includes": [
|
||||||
|
"src/**",
|
||||||
|
"protocol/**",
|
||||||
|
"bridge/**",
|
||||||
|
"scripts/**",
|
||||||
|
"*.ts",
|
||||||
|
"*.js",
|
||||||
|
"*.json",
|
||||||
|
"*.html",
|
||||||
|
"*.css",
|
||||||
|
"!**/dist",
|
||||||
|
"!**/node_modules",
|
||||||
|
"!src/locales",
|
||||||
|
"!fixtures"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"formatWithErrors": false,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 4,
|
||||||
|
"lineEnding": "lf",
|
||||||
|
"lineWidth": 100
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"jsxQuoteStyle": "double",
|
||||||
|
"quoteProperties": "asNeeded",
|
||||||
|
"quoteStyle": "single",
|
||||||
|
"semicolons": "always",
|
||||||
|
"trailingCommas": "all",
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"json": {
|
||||||
|
"formatter": {
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"css": {
|
||||||
|
"parser": {
|
||||||
|
"tailwindDirectives": true
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"preset": "recommended",
|
||||||
|
"correctness": {
|
||||||
|
"noUnusedVariables": "warn"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"noNonNullAssertion": "off",
|
||||||
|
"noDescendingSpecificity": "off"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noExplicitAny": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assist": {
|
||||||
|
"enabled": true,
|
||||||
|
"actions": {
|
||||||
|
"source": {
|
||||||
|
"organizeImports": "on"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"includes": ["bridge/scripts/**", "scripts/**"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"correctness": {
|
||||||
|
"noUnusedVariables": "off",
|
||||||
|
"noUnusedFunctionParameters": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"includes": ["bridge/src/**"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"suspicious": {
|
||||||
|
"noExplicitAny": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
-35
@@ -1,35 +0,0 @@
|
|||||||
const fs = require('node:fs');
|
|
||||||
const os = require('node:os');
|
|
||||||
const path = require('node:path');
|
|
||||||
|
|
||||||
const { BRIDGE_LOG_FILE_NAME } = require('./constants.cjs');
|
|
||||||
|
|
||||||
function writeLogLine(logFile, level, message, error) {
|
|
||||||
const method = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'info';
|
|
||||||
const tag = `[wand-remote-bridge] ${message}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
console[method](tag, error || '');
|
|
||||||
} catch { }
|
|
||||||
|
|
||||||
try {
|
|
||||||
const detail = error ? ` :: ${error && error.stack ? error.stack : String(error)}` : '';
|
|
||||||
fs.appendFileSync(logFile, `[${new Date().toISOString()}] [${level}] ${message}${detail}\n`);
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
function createBridgeLogger(options = {}) {
|
|
||||||
const logFile = options.logFile || path.join(os.tmpdir(), BRIDGE_LOG_FILE_NAME);
|
|
||||||
const log = (level, message, error) => writeLogLine(logFile, level, message, error);
|
|
||||||
log.file = logFile;
|
|
||||||
return log;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeInstallLog(level, message, error) {
|
|
||||||
writeLogLine(path.join(os.tmpdir(), BRIDGE_LOG_FILE_NAME), level, message, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
createBridgeLogger,
|
|
||||||
writeInstallLog,
|
|
||||||
};
|
|
||||||
-483
@@ -1,483 +0,0 @@
|
|||||||
const { KNOWN_CHEAT_TYPES } = require('./constants.cjs');
|
|
||||||
const { cloneValue, firstString, isRecord, safeString, toStringId } = require('./utils.cjs');
|
|
||||||
|
|
||||||
function normalizeOption(option) {
|
|
||||||
if (typeof option === 'string' || typeof option === 'number') {
|
|
||||||
return {
|
|
||||||
label: String(option),
|
|
||||||
value: option,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isRecord(option)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = option.value;
|
|
||||||
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: safeString(option.label, String(value)),
|
|
||||||
value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeArgs(args) {
|
|
||||||
if (!isRecord(args)) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = {};
|
|
||||||
if (typeof args.min === 'number') next.min = args.min;
|
|
||||||
if (typeof args.max === 'number') next.max = args.max;
|
|
||||||
if (typeof args.step === 'number') next.step = args.step;
|
|
||||||
if (typeof args.postfix === 'string') next.postfix = args.postfix;
|
|
||||||
if (typeof args.default === 'string' || typeof args.default === 'number' || typeof args.default === 'boolean') {
|
|
||||||
next.default = args.default;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(args.options)) {
|
|
||||||
next.options = args.options.map(normalizeOption).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof args.button === 'string' || typeof args.button === 'boolean') {
|
|
||||||
next.button = args.button;
|
|
||||||
}
|
|
||||||
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCheat(cheat, index) {
|
|
||||||
if (!isRecord(cheat)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = safeString(cheat.target);
|
|
||||||
const type = safeString(cheat.type);
|
|
||||||
if (!target || !KNOWN_CHEAT_TYPES.has(type)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = {
|
|
||||||
uuid: safeString(cheat.uuid, `${target}-${index}`),
|
|
||||||
target,
|
|
||||||
type,
|
|
||||||
name: safeString(cheat.name, target),
|
|
||||||
description: typeof cheat.description === 'string' ? cheat.description : null,
|
|
||||||
instructions: typeof cheat.instructions === 'string' ? cheat.instructions : null,
|
|
||||||
category: safeString(cheat.category, 'general'),
|
|
||||||
parent: typeof cheat.parent === 'string' ? cheat.parent : null,
|
|
||||||
args: normalizeArgs(cheat.args),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof cheat.flags === 'number') {
|
|
||||||
normalized.flags = cheat.flags;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(cheat.hotkeys)) {
|
|
||||||
normalized.hotkeys = cheat.hotkeys.filter(Array.isArray).map((group) => group.map((item) => String(item)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeImageUrl(...values) {
|
|
||||||
const value = firstString(...values);
|
|
||||||
return value || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRawInstalledApps(rawSnapshot) {
|
|
||||||
if (Array.isArray(rawSnapshot)) {
|
|
||||||
return rawSnapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRecord(rawSnapshot) && Array.isArray(rawSnapshot.apps)) {
|
|
||||||
return rawSnapshot.apps;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRecord(rawSnapshot) && Array.isArray(rawSnapshot.installedApps)) {
|
|
||||||
return rawSnapshot.installedApps;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeInstalledApp(app) {
|
|
||||||
if (!isRecord(app)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const platform = safeString(app.platform);
|
|
||||||
const sku = safeString(app.sku);
|
|
||||||
if (!platform || !sku) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const location = typeof app.location === 'string' ? app.location : '';
|
|
||||||
const alternateLocations = Array.isArray(app.alternateLocations)
|
|
||||||
? app.alternateLocations.filter((entry) => typeof entry === 'string' && entry.trim()).map((entry) => entry.trim())
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
platform,
|
|
||||||
sku,
|
|
||||||
correlationId: `${platform}:${sku}`,
|
|
||||||
displayName: firstString(
|
|
||||||
app.displayName,
|
|
||||||
app.titleName,
|
|
||||||
app.gameName,
|
|
||||||
app.name,
|
|
||||||
location.replaceAll('\\', '/').split('/').filter(Boolean).pop() || '',
|
|
||||||
`${platform}:${sku}`
|
|
||||||
),
|
|
||||||
gameId: toStringId(app.gameId),
|
|
||||||
titleId: toStringId(app.titleId),
|
|
||||||
location,
|
|
||||||
alternateLocations,
|
|
||||||
imageUrl: normalizeImageUrl(app.imageUrl, app.iconUrl, app.coverUrl, app.thumbnailUrl, app.logoUrl, app.headerImageUrl),
|
|
||||||
platformLastPlayedTimestamp: typeof app.platformLastPlayedTimestamp === 'number' ? app.platformLastPlayedTimestamp : null,
|
|
||||||
platformTotalPlaytimeMinutes: typeof app.platformTotalPlaytimeMinutes === 'number' ? app.platformTotalPlaytimeMinutes : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeInstalledAppsSnapshot(rawSnapshot) {
|
|
||||||
const rawApps = getRawInstalledApps(rawSnapshot);
|
|
||||||
if (!rawApps) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const apps = rawApps.map(normalizeInstalledApp).filter(Boolean).sort(compareInstalledApps);
|
|
||||||
const diagnostics = isRecord(rawSnapshot) && isRecord(rawSnapshot.diagnostics)
|
|
||||||
? cloneValue(rawSnapshot.diagnostics)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
instanceId: isRecord(rawSnapshot) ? safeString(rawSnapshot.instanceId, 'wand-installed-apps') : 'wand-installed-apps',
|
|
||||||
updatedAt: isRecord(rawSnapshot) && typeof rawSnapshot.updatedAt === 'string' ? rawSnapshot.updatedAt : new Date().toISOString(),
|
|
||||||
apps,
|
|
||||||
diagnostics,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeGameStatusSnapshot(rawSnapshot) {
|
|
||||||
if (!isRecord(rawSnapshot)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawSession = isRecord(rawSnapshot.session) ? rawSnapshot.session : {};
|
|
||||||
const rawTrainer = isRecord(rawSnapshot.trainer) ? rawSnapshot.trainer : {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
instanceId: safeString(rawSnapshot.instanceId, 'wand-game-status'),
|
|
||||||
updatedAt: typeof rawSnapshot.updatedAt === 'string' ? rawSnapshot.updatedAt : new Date().toISOString(),
|
|
||||||
session: {
|
|
||||||
state: rawSession.state === 'running' ? 'running' : 'idle',
|
|
||||||
event: safeString(rawSession.event, 'snapshot'),
|
|
||||||
processId: typeof rawSession.processId === 'number' ? rawSession.processId : null,
|
|
||||||
gameId: toStringId(rawSession.gameId),
|
|
||||||
titleId: toStringId(rawSession.titleId),
|
|
||||||
titleName: typeof rawSession.titleName === 'string' ? rawSession.titleName : null,
|
|
||||||
sessionDurationSeconds: typeof rawSession.sessionDurationSeconds === 'number' ? rawSession.sessionDurationSeconds : null,
|
|
||||||
startedAt: typeof rawSession.startedAt === 'string' ? rawSession.startedAt : null,
|
|
||||||
endedAt: typeof rawSession.endedAt === 'string' ? rawSession.endedAt : null,
|
|
||||||
},
|
|
||||||
trainer: {
|
|
||||||
state: rawTrainer.state === 'running' ? 'running' : 'idle',
|
|
||||||
event: safeString(rawTrainer.event, 'snapshot'),
|
|
||||||
trainerId: toStringId(rawTrainer.trainerId),
|
|
||||||
displayName: typeof rawTrainer.displayName === 'string' ? rawTrainer.displayName : null,
|
|
||||||
gameId: toStringId(rawTrainer.gameId),
|
|
||||||
titleId: toStringId(rawTrainer.titleId),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRemoteCommandAction(value) {
|
|
||||||
if (value === 'launch' || value === 'stop') {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRemoteCommandResult(rawResult, fallback) {
|
|
||||||
const action = normalizeRemoteCommandAction(isRecord(rawResult) ? rawResult.action : null) || fallback.action;
|
|
||||||
const gameId = isRecord(rawResult) ? toStringId(rawResult.gameId) || fallback.gameId || null : fallback.gameId || null;
|
|
||||||
const titleId = isRecord(rawResult) ? toStringId(rawResult.titleId) || fallback.titleId || null : fallback.titleId || null;
|
|
||||||
const ok = rawResult === true || Boolean(isRecord(rawResult) && rawResult.ok === true);
|
|
||||||
const payload = {
|
|
||||||
ok,
|
|
||||||
action,
|
|
||||||
gameId,
|
|
||||||
titleId,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (ok) {
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isRecord(rawResult) || !isRecord(rawResult.error)) {
|
|
||||||
return {
|
|
||||||
...payload,
|
|
||||||
error: {
|
|
||||||
code: 'command_rejected',
|
|
||||||
message: 'The renderer rejected the remote command.',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...payload,
|
|
||||||
error: {
|
|
||||||
code: safeString(rawResult.error.code, 'command_rejected'),
|
|
||||||
message: safeString(rawResult.error.message, 'The renderer rejected the remote command.'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeInstalledAppsSource(rawSnapshot) {
|
|
||||||
if (!isRecord(rawSnapshot) || !isRecord(rawSnapshot.diagnostics)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = [];
|
|
||||||
for (const key of ['rawInstalledApps', 'catalogGames', 'catalogTitles']) {
|
|
||||||
const value = rawSnapshot.diagnostics[key];
|
|
||||||
if (typeof value === 'number') {
|
|
||||||
parts.push(`${key}=${value}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function installedAppsSignature(snapshot) {
|
|
||||||
return snapshot.apps
|
|
||||||
.map((app) => [
|
|
||||||
app.platform,
|
|
||||||
app.sku,
|
|
||||||
app.displayName,
|
|
||||||
app.gameId || '',
|
|
||||||
app.titleId || '',
|
|
||||||
app.location,
|
|
||||||
app.imageUrl || '',
|
|
||||||
app.platformLastPlayedTimestamp || '',
|
|
||||||
app.platformTotalPlaytimeMinutes || '',
|
|
||||||
].join('|'))
|
|
||||||
.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function gameStatusSignature(snapshot) {
|
|
||||||
return [
|
|
||||||
snapshot.session.state,
|
|
||||||
snapshot.session.event,
|
|
||||||
snapshot.session.processId || '',
|
|
||||||
snapshot.session.gameId || '',
|
|
||||||
snapshot.session.titleId || '',
|
|
||||||
snapshot.session.titleName || '',
|
|
||||||
snapshot.session.sessionDurationSeconds || '',
|
|
||||||
snapshot.session.startedAt || '',
|
|
||||||
snapshot.session.endedAt || '',
|
|
||||||
snapshot.trainer.state,
|
|
||||||
snapshot.trainer.event,
|
|
||||||
snapshot.trainer.trainerId || '',
|
|
||||||
snapshot.trainer.displayName || '',
|
|
||||||
snapshot.trainer.gameId || '',
|
|
||||||
snapshot.trainer.titleId || '',
|
|
||||||
].join('|');
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildInstalledAppsDebugPayload(snapshot) {
|
|
||||||
if (!snapshot) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
instanceId: null,
|
|
||||||
updatedAt: null,
|
|
||||||
counts: {
|
|
||||||
myGamesEntries: 0,
|
|
||||||
rawInstallEntries: 0,
|
|
||||||
groupedTitles: 0,
|
|
||||||
uniqueTitleIds: 0,
|
|
||||||
uniqueGameIds: 0,
|
|
||||||
},
|
|
||||||
diagnostics: null,
|
|
||||||
byPlatform: {},
|
|
||||||
titles: [],
|
|
||||||
apps: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const diagnostics = isRecord(snapshot.diagnostics) ? snapshot.diagnostics : null;
|
|
||||||
const byPlatform = {};
|
|
||||||
const uniqueTitleIds = new Set();
|
|
||||||
const uniqueGameIds = new Set();
|
|
||||||
const titleGroups = new Map();
|
|
||||||
|
|
||||||
for (const app of snapshot.apps) {
|
|
||||||
byPlatform[app.platform] = (byPlatform[app.platform] || 0) + 1;
|
|
||||||
|
|
||||||
if (app.titleId) {
|
|
||||||
uniqueTitleIds.add(app.titleId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (app.gameId) {
|
|
||||||
uniqueGameIds.add(app.gameId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupKey = resolveInstalledAppGroupKey(app);
|
|
||||||
let group = titleGroups.get(groupKey);
|
|
||||||
if (!group) {
|
|
||||||
group = {
|
|
||||||
key: groupKey,
|
|
||||||
titleId: app.titleId,
|
|
||||||
displayName: app.displayName,
|
|
||||||
gameIds: new Set(),
|
|
||||||
platforms: new Set(),
|
|
||||||
apps: [],
|
|
||||||
};
|
|
||||||
titleGroups.set(groupKey, group);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (app.gameId) {
|
|
||||||
group.gameIds.add(app.gameId);
|
|
||||||
}
|
|
||||||
|
|
||||||
group.platforms.add(app.platform);
|
|
||||||
group.apps.push(app);
|
|
||||||
}
|
|
||||||
|
|
||||||
const titles = Array.from(titleGroups.values())
|
|
||||||
.map((group) => ({
|
|
||||||
key: group.key,
|
|
||||||
titleId: group.titleId,
|
|
||||||
displayName: group.displayName,
|
|
||||||
gameIds: Array.from(group.gameIds).sort(),
|
|
||||||
platforms: Array.from(group.platforms).sort(),
|
|
||||||
appEntries: group.apps.length,
|
|
||||||
apps: group.apps,
|
|
||||||
}))
|
|
||||||
.sort((left, right) => left.displayName.localeCompare(right.displayName));
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
instanceId: snapshot.instanceId,
|
|
||||||
updatedAt: snapshot.updatedAt,
|
|
||||||
counts: {
|
|
||||||
myGamesEntries: snapshot.apps.length,
|
|
||||||
rawInstallEntries: typeof diagnostics?.rawInstalledApps === 'number' ? diagnostics.rawInstalledApps : snapshot.apps.length,
|
|
||||||
groupedTitles: titles.length,
|
|
||||||
uniqueTitleIds: uniqueTitleIds.size,
|
|
||||||
uniqueGameIds: uniqueGameIds.size,
|
|
||||||
},
|
|
||||||
diagnostics,
|
|
||||||
byPlatform,
|
|
||||||
titles,
|
|
||||||
apps: snapshot.apps,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSnapshot(rawSnapshot) {
|
|
||||||
if (!isRecord(rawSnapshot) || !isRecord(rawSnapshot.metadata) || !isRecord(rawSnapshot.metadata.info)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const info = rawSnapshot.metadata.info;
|
|
||||||
const blueprint = isRecord(info.blueprint) ? info.blueprint : {};
|
|
||||||
const rawCheats = Array.isArray(blueprint.cheats) ? blueprint.cheats : [];
|
|
||||||
const cheats = rawCheats.map(normalizeCheat).filter(Boolean);
|
|
||||||
const categories = Array.from(new Set(cheats.map((entry) => entry.category)));
|
|
||||||
const trainerId = safeString(rawSnapshot.trainerId || rawSnapshot.trainerInfo?.trainerId);
|
|
||||||
const displayName = firstString(
|
|
||||||
rawSnapshot.trainerInfo?.displayName,
|
|
||||||
rawSnapshot.trainerInfo?.gameName,
|
|
||||||
rawSnapshot.trainerInfo?.titleName,
|
|
||||||
rawSnapshot.trainerInfo?.title,
|
|
||||||
rawSnapshot.trainerInfo?.name,
|
|
||||||
info.displayName,
|
|
||||||
info.gameName,
|
|
||||||
info.titleName,
|
|
||||||
info.title,
|
|
||||||
info.name,
|
|
||||||
info.game?.displayName,
|
|
||||||
info.game?.name,
|
|
||||||
info.game?.title
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!trainerId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trainerMeta = {
|
|
||||||
session: {
|
|
||||||
instanceId: safeString(rawSnapshot.instanceId, 'wand-session'),
|
|
||||||
},
|
|
||||||
trainer: {
|
|
||||||
trainerId,
|
|
||||||
gameId: safeString(rawSnapshot.trainerInfo?.gameId || info.gameId),
|
|
||||||
displayName: displayName || safeString(rawSnapshot.trainerInfo?.gameId || info.gameId, trainerId),
|
|
||||||
titleId: typeof info.titleId === 'string' ? info.titleId : null,
|
|
||||||
gameVersion: typeof rawSnapshot.gameVersion === 'string' ? rawSnapshot.gameVersion : null,
|
|
||||||
trainerLoading: rawSnapshot.trainerLoading === true,
|
|
||||||
gameInstalled: rawSnapshot.gameInstalled !== false,
|
|
||||||
needsCompatibilityWarning: rawSnapshot.needsCompatibilityWarning === true,
|
|
||||||
language: safeString(rawSnapshot.language, 'en-US'),
|
|
||||||
themeId: safeString(rawSnapshot.themeId, 'default'),
|
|
||||||
isTimeLimitExpired: rawSnapshot.isTimeLimitExpired === true,
|
|
||||||
notesReadHash: typeof rawSnapshot.notesReadHash === 'string' ? rawSnapshot.notesReadHash : null,
|
|
||||||
},
|
|
||||||
schema: {
|
|
||||||
categories,
|
|
||||||
cheats,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const trainerValues = {
|
|
||||||
trainerId,
|
|
||||||
values: isRecord(rawSnapshot.values) ? cloneValue(rawSnapshot.values) : {},
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
trainerMeta,
|
|
||||||
trainerValues,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareInstalledApps(left, right) {
|
|
||||||
const displayNameDiff = left.displayName.localeCompare(right.displayName);
|
|
||||||
if (displayNameDiff !== 0) {
|
|
||||||
return displayNameDiff;
|
|
||||||
}
|
|
||||||
|
|
||||||
const platformDiff = left.platform.localeCompare(right.platform);
|
|
||||||
if (platformDiff !== 0) {
|
|
||||||
return platformDiff;
|
|
||||||
}
|
|
||||||
|
|
||||||
return left.sku.localeCompare(right.sku);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveInstalledAppGroupKey(app) {
|
|
||||||
if (app.titleId) {
|
|
||||||
return `title:${app.titleId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (app.gameId) {
|
|
||||||
return `game:${app.gameId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `app:${app.correlationId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
buildInstalledAppsDebugPayload,
|
|
||||||
gameStatusSignature,
|
|
||||||
installedAppsSignature,
|
|
||||||
normalizeGameStatusSnapshot,
|
|
||||||
normalizeInstalledAppsSnapshot,
|
|
||||||
normalizeRemoteCommandAction,
|
|
||||||
normalizeRemoteCommandResult,
|
|
||||||
normalizeSnapshot,
|
|
||||||
summarizeInstalledAppsSource,
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user