mirror of
https://github.com/k1tbyte/Wand-Enhancer.git
synced 2026-08-29 17:01:19 +00:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| a4f3a57f97 | |||
| 1f5ba9fc95 | |||
| 710d014e6d | |||
| a810e5b549 | |||
| 13759b1db6 | |||
| 3b2f373946 | |||
| f78f9609b5 | |||
| 633984ed3b | |||
| fa753b4a34 | |||
| 5273930eef | |||
| 9a5d3799bf | |||
| d02b84a414 | |||
| a943b91f5f | |||
| a5583fd4f7 | |||
| 13e26c3a70 | |||
| d8d0abd448 | |||
| 54fe538ed9 | |||
| 8f14ee2939 | |||
| 9ba60a09a7 | |||
| 108feeafa8 | |||
| b283c63fd7 | |||
| 9ed3d270e3 | |||
| e534c08f88 | |||
| fcf47673af | |||
| d3ff13a528 | |||
| 123307aed7 | |||
| 68cbbee275 | |||
| 4049ade4b1 |
@@ -0,0 +1,4 @@
|
||||
# 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,12 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
repo_root=$(dirname "$script_dir")
|
||||
script_path="$repo_root/scripts/validate-release-metadata.ps1"
|
||||
|
||||
if command -v cygpath >/dev/null 2>&1; then
|
||||
script_path=$(cygpath -w "$script_path")
|
||||
fi
|
||||
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$script_path"
|
||||
+8
-1
@@ -1 +1,8 @@
|
||||
ko_fi: kitbyte
|
||||
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"
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
name: Bug report
|
||||
description: Create a report to help us improve
|
||||
title: '[Bug]: '
|
||||
labels: ["bug"]
|
||||
assignees:
|
||||
- k1tbyte
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**STOP BEFORE YOU POST: THIS PROJECT DOES NOT PUBLISH OFFICIAL EXECUTABLE DOWNLOADS.**
|
||||
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
|
||||
id: build_source_check
|
||||
attributes:
|
||||
label: Build Source Confirmation (REQUIRED)
|
||||
description: You must check this box to proceed.
|
||||
options:
|
||||
- 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
|
||||
|
||||
- type: input
|
||||
id: client_version
|
||||
attributes:
|
||||
label: Client (Wand) version
|
||||
description: What version of the client are you using? (e.g., 9.0.0)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: enhancer_version
|
||||
attributes:
|
||||
label: Enhancer version
|
||||
description: What version of this tool are you using?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: bug_description
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: To Reproduce
|
||||
description: Steps to reproduce the behavior.
|
||||
value: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
|
||||
- type: textarea
|
||||
id: additional_context
|
||||
attributes:
|
||||
label: Screenshots & Additional context
|
||||
description: Drag and drop screenshots here, or add any other context about the problem.
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -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
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Mirror to GitLab
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master", "main" ]
|
||||
tags:
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
if: github.repository == 'k1tbyte/Wand-Enhancer'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Push to GitLab
|
||||
env:
|
||||
GITLAB_USERNAME: kitbyte
|
||||
GITLAB_REPO: Wand-Enhancer
|
||||
run: |
|
||||
git remote add gitlab https://oauth2:${{ secrets.GITLAB_TOKEN }}@gitlab.com/$GITLAB_USERNAME/$GITLAB_REPO.git
|
||||
git push gitlab --all --force
|
||||
git push gitlab --tags --force
|
||||
@@ -0,0 +1,56 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
publish-release:
|
||||
if: github.repository == 'k1tbyte/Wand-Enhancer'
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
RELEASE_VERSION: ${{ github.ref_name }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: web-panel/pnpm-lock.yaml
|
||||
|
||||
- name: Validate release metadata
|
||||
shell: pwsh
|
||||
run: ./scripts/validate-release-metadata.ps1 -ExpectedVersion $env:RELEASE_VERSION
|
||||
|
||||
- name: Extract release notes from changelog
|
||||
shell: pwsh
|
||||
run: ./scripts/get-changelog-section.ps1 -Version $env:RELEASE_VERSION -OutputPath release-notes.md
|
||||
|
||||
- name: Build release
|
||||
shell: pwsh
|
||||
run: ./build.ps1 -Configuration Release
|
||||
|
||||
- name: Publish GitHub release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: ${{ github.ref_name }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
body_path: release-notes.md
|
||||
files: CHANGELOG.md
|
||||
fail_on_unmatched_files: true
|
||||
# A tag carrying a suffix (1.1.0.0-rc.1) publishes as a pre-release and
|
||||
# does not become the "Latest release" on the repository page.
|
||||
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||
make_latest: ${{ !contains(github.ref_name, '-') }}
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Validate Release Metadata
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master", "main" ]
|
||||
pull_request:
|
||||
branches: [ "master", "main" ]
|
||||
|
||||
jobs:
|
||||
validate-release-metadata:
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Validate version and changelog sync
|
||||
shell: pwsh
|
||||
run: ./scripts/validate-release-metadata.ps1
|
||||
+18
@@ -128,3 +128,21 @@ dist
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
|
||||
./WandEnhancer/obj/
|
||||
./WandEnhancer/bin/
|
||||
./AsarSharp/obj/
|
||||
./AsarSharp/bin/
|
||||
.idea
|
||||
packages
|
||||
*/bin/
|
||||
*/obj/
|
||||
*/obj/.nuget/
|
||||
|
||||
# App settings (user preferences)
|
||||
appsettings.json
|
||||
*DotSettings.user
|
||||
.tmp
|
||||
.source
|
||||
web-panel/bridge/wand-remote-bridge.cjs
|
||||
@@ -0,0 +1,58 @@
|
||||
INFO ./docs/*
|
||||
|
||||
# Wand Enhancer Agent Notes
|
||||
|
||||
This repository patches the Wand Electron app from a .NET Framework WPF desktop tool. Keep changes narrow and preserve the patch pipeline invariants.
|
||||
|
||||
## Remote Web Panel
|
||||
|
||||
- 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`.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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 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.
|
||||
- `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.
|
||||
- Installed app snapshots should include game artwork in `imageUrl` when possible. `installed-apps-sync.js` must prefer Wand's own client icon CDN shape `https://api-cdn.wemod.com/steam_community/<steamAppId>/client_icon/96.webp` whenever the matched title/game/version metadata contains a Steam AppID, regardless of install platform. Do not assume `steamAppId` is a flat property; search nested `steam*` metadata before falling back to installed Steam `sku`. If metadata still does not expose the icon, fall back to the rendered Wand sidebar DOM (`.sidebar-game-row-image` background-image) keyed by `titleId` parsed from `data-tooltip-trigger-for`. The web panel `GameCover` must tolerate broken artwork URLs and fall back to its text cover.
|
||||
- The same renderer sync script also forwards lifecycle state through `wand-remote-game-status`: `game-launched` / `game-ended` come from Wand's launch monitor service, and trainer runtime comes from the running-trainer visibility service. The web panel consumes this as the `game_status` websocket message.
|
||||
- When Wand does not emit a `game-launched` event but a trainer is already active, `wand-remote-game-status` must synthesize a running session from the running-trainer visibility payload so the remote panel does not show an idle game session next to a running trainer.
|
||||
- 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 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
|
||||
|
||||
- Preserve and restore both `resources/app.asar` and `resources/app.asar.unpacked` backups.
|
||||
- Inject `web-panel/dist` as `remote-panel/`; it must already contain `bridge.cjs` and generated default renderer scripts under `renderer-scripts/`. Selected/local custom renderer scripts are then copied under `remote-panel/renderer-scripts`.
|
||||
- Do not commit extracted `.source/` or `.sources/` output. Recreate it only for reverse-engineering sessions.
|
||||
- `AsarSharp.AsarExtractor.ExtractAll` must skip unpacked entries when their source path equals the destination (in-place extraction is a self-copy that fails on locked files like `TrainerLib_x64.dll`) and silently skip unpacked entries whose source is missing on disk (e.g. `auxiliary/GameLauncher.exe` removed by an installer). Do not reintroduce hard failure on either case.
|
||||
- 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.
|
||||
- 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/` 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.
|
||||
- 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/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/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.
|
||||
|
||||
## Validation
|
||||
|
||||
- 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`.
|
||||
- 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`.
|
||||
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using AsarSharp.AsarFileSystem;
|
||||
using AsarSharp.Integrity;
|
||||
using AsarSharp.Utils;
|
||||
|
||||
namespace AsarSharp
|
||||
{
|
||||
public class CreateOptions
|
||||
{
|
||||
public Regex Unpack { get; set; }
|
||||
}
|
||||
|
||||
public class AsarCreator
|
||||
{
|
||||
private readonly string _folderPath;
|
||||
private readonly string _destPath;
|
||||
private readonly CreateOptions _options;
|
||||
private List<string> _filenames = new List<string>();
|
||||
private Dictionary<string, CrawledFileType> _metadata = new Dictionary<string, CrawledFileType>();
|
||||
|
||||
public AsarCreator(string folderPath, string destPath, CreateOptions options)
|
||||
{
|
||||
_folderPath = folderPath ?? throw new ArgumentNullException(nameof(folderPath));
|
||||
_destPath = destPath ?? throw new ArgumentNullException(nameof(destPath));
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public void CreatePackageWithOptions()
|
||||
{
|
||||
var result = FileSystemCrawler.CrawlFileSystem(_folderPath);
|
||||
_filenames = result.filenames;
|
||||
_metadata = result.metadata;
|
||||
CreatePackageFromFiles();
|
||||
}
|
||||
|
||||
public void CreatePackageFromFiles()
|
||||
{
|
||||
var filesystem = new Filesystem(_folderPath);
|
||||
var files = new List<Disk.BasicFileInfo>(_filenames.Count);
|
||||
|
||||
foreach (var filename in _filenames)
|
||||
{
|
||||
HandleFile(filesystem, filename, files);
|
||||
}
|
||||
|
||||
InsertsDone(filesystem, files);
|
||||
}
|
||||
|
||||
private void HandleFile(Filesystem filesystem, string filename, List<Disk.BasicFileInfo> files)
|
||||
{
|
||||
if (!_metadata.TryGetValue(filename, out var file))
|
||||
{
|
||||
file = FileSystemCrawler.DetermineFileType(filename)
|
||||
?? throw new Exception("Unknown file type for file: " + filename);
|
||||
_metadata[filename] = file;
|
||||
}
|
||||
|
||||
switch (file.Type)
|
||||
{
|
||||
case FileType.Directory:
|
||||
filesystem.InsertDirectory(filename, false);
|
||||
break;
|
||||
case FileType.File:
|
||||
string parentDir = Path.GetDirectoryName(filename) ?? string.Empty;
|
||||
string relParent = Extensions.GetRelativePath(_folderPath, parentDir);
|
||||
bool shouldUnpack = ShouldUnpackPath(relParent);
|
||||
long fileSize = file.Stat is FileInfo fi ? fi.Length : 0;
|
||||
var placeholder = IntegrityHelper.CreatePlaceholder(fileSize);
|
||||
files.Add(new Disk.BasicFileInfo { Filename = filename, Unpack = shouldUnpack });
|
||||
filesystem.InsertFile(filename, shouldUnpack, file, placeholder);
|
||||
break;
|
||||
case FileType.Link:
|
||||
throw new NotSupportedException($"Packing symlinks is not supported: '{filename}'");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches the directory path (relative to the archive root) against the unpack regex.
|
||||
/// </summary>
|
||||
private bool ShouldUnpackPath(string relativeParentPath)
|
||||
{
|
||||
return _options?.Unpack?.IsMatch(relativeParentPath) == true;
|
||||
}
|
||||
|
||||
private void InsertsDone(Filesystem filesystem, List<Disk.BasicFileInfo> files)
|
||||
{
|
||||
string dir = Path.GetDirectoryName(_destPath);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
Disk.WriteFileSystem(_destPath, filesystem,
|
||||
new Disk.FilesystemFilesAndLinks { Files = files }, _metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using AsarSharp.AsarFileSystem;
|
||||
using AsarSharp.Utils;
|
||||
|
||||
namespace AsarSharp
|
||||
{
|
||||
public class AsarExtractor
|
||||
{
|
||||
private const int IO_BUFFER_SIZE = 1024 * 1024;
|
||||
private const int FS_INTERNAL_BUFFER = 4096;
|
||||
|
||||
public static void ExtractAll(string archivePath, string dest)
|
||||
{
|
||||
var filesystem = Disk.ReadFilesystemSync(archivePath);
|
||||
var filenames = filesystem.ListFiles();
|
||||
|
||||
// On Windows, links are extracted as plain files.
|
||||
bool followLinks = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
|
||||
|
||||
Directory.CreateDirectory(dest);
|
||||
|
||||
byte[] ioBuffer = new byte[IO_BUFFER_SIZE];
|
||||
var dirCache = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { Path.GetFullPath(dest) };
|
||||
var extractionErrors = new List<Exception>();
|
||||
string rootPath = filesystem.GetRootPath();
|
||||
long dataOffset = 8 + filesystem.GetHeaderSize();
|
||||
|
||||
// One archive handle for all reads — old code opened it per file.
|
||||
using (var archive = new FileStream(rootPath, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
FS_INTERNAL_BUFFER, FileOptions.RandomAccess))
|
||||
{
|
||||
foreach (var fullPath in filenames)
|
||||
{
|
||||
try
|
||||
{
|
||||
var filename = fullPath.Substring(1);
|
||||
var destFilename = Path.Combine(dest, filename);
|
||||
var file = filesystem.GetFile(filename, followLinks);
|
||||
|
||||
// Path-traversal (zip-slip) guard. Uses the normalising
|
||||
// containment check: GetRelativePath's fast path strips the
|
||||
// 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(
|
||||
$"{fullPath}: file \"{destFilename}\" writes out of the package");
|
||||
}
|
||||
|
||||
if (file.IsDirectory)
|
||||
{
|
||||
EnsureDirectory(destFilename, dirCache);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.IsLink)
|
||||
{
|
||||
ExtractLink(dest, fullPath, destFilename, file, dirCache);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!file.IsFile) continue;
|
||||
|
||||
try
|
||||
{
|
||||
ExtractFile(archive, dataOffset, rootPath, filename, destFilename, file, ioBuffer, dirCache);
|
||||
|
||||
if (file.Executable == true && !RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
Extensions.SetUnixFilePermission(destFilename, "755");
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
extractionErrors.Add(e);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
extractionErrors.Add(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (extractionErrors.Count > 0)
|
||||
{
|
||||
throw new AggregateException(
|
||||
"Unable to extract some files:\n\n" +
|
||||
string.Join("\n\n", extractionErrors.Select(e => e.ToString())),
|
||||
extractionErrors);
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureDirectory(string path, HashSet<string> cache)
|
||||
{
|
||||
string full = Path.GetFullPath(path);
|
||||
if (cache.Contains(full)) return;
|
||||
Directory.CreateDirectory(full);
|
||||
// Mark every ancestor too so siblings skip the syscall.
|
||||
string p = full;
|
||||
while (!string.IsNullOrEmpty(p) && cache.Add(p))
|
||||
{
|
||||
p = Path.GetDirectoryName(p);
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureParentDir(string filePath, HashSet<string> cache)
|
||||
{
|
||||
string parent = Path.GetDirectoryName(filePath);
|
||||
if (string.IsNullOrEmpty(parent)) return;
|
||||
EnsureDirectory(parent, cache);
|
||||
}
|
||||
|
||||
private static void ExtractFile(FileStream archive, long dataOffset, string rootPath,
|
||||
string filename, string destFilename, FilesystemEntry file, byte[] buffer,
|
||||
HashSet<string> dirCache)
|
||||
{
|
||||
EnsureParentDir(destFilename, dirCache);
|
||||
|
||||
if (file.Unpacked == true)
|
||||
{
|
||||
string unpackedSourcePath = Path.GetFullPath(Path.Combine($"{rootPath}.unpacked", filename));
|
||||
string unpackedDestPath = Path.GetFullPath(destFilename);
|
||||
|
||||
if (string.Equals(unpackedSourcePath, unpackedDestPath, StringComparison.OrdinalIgnoreCase))
|
||||
return; // self-copy
|
||||
if (!File.Exists(unpackedSourcePath))
|
||||
return; // header references a missing unpacked file — skip rather than abort
|
||||
|
||||
File.Copy(unpackedSourcePath, destFilename, true);
|
||||
return;
|
||||
}
|
||||
|
||||
long size = file.Size ?? 0;
|
||||
using (var dst = new FileStream(destFilename, FileMode.Create, FileAccess.Write, FileShare.None,
|
||||
FS_INTERNAL_BUFFER, FileOptions.SequentialScan))
|
||||
{
|
||||
if (size <= 0) return;
|
||||
|
||||
archive.Position = dataOffset + long.Parse(file.Offset);
|
||||
long remaining = size;
|
||||
while (remaining > 0)
|
||||
{
|
||||
int toRead = remaining > buffer.Length ? buffer.Length : (int)remaining;
|
||||
int got = archive.Read(buffer, 0, toRead);
|
||||
if (got <= 0) throw new EndOfStreamException("Archive truncated");
|
||||
dst.Write(buffer, 0, got);
|
||||
remaining -= got;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExtractLink(string dest, string fullPath, string destFilename,
|
||||
FilesystemEntry file, HashSet<string> dirCache)
|
||||
{
|
||||
var linkSrcPath = Extensions.GetDirectoryName(Path.Combine(dest, file.Link));
|
||||
|
||||
if (!Extensions.IsPathInside(dest, linkSrcPath))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{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))
|
||||
{
|
||||
var targetPath = Path.Combine(linkSrcPath, Path.GetFileName(file.Link));
|
||||
if (Directory.Exists(targetPath))
|
||||
{
|
||||
EnsureDirectory(destFilename, dirCache);
|
||||
Extensions.CopyDirectory(targetPath, destFilename);
|
||||
}
|
||||
else if (File.Exists(targetPath))
|
||||
{
|
||||
EnsureParentDir(destFilename, dirCache);
|
||||
File.Copy(targetPath, destFilename, true);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var linkDestPath = Extensions.GetDirectoryName(destFilename);
|
||||
var relativeLinkPath = Extensions.GetRelativePath(linkDestPath, linkSrcPath);
|
||||
EnsureParentDir(destFilename, dirCache);
|
||||
Extensions.CreateSymbolicLink(Path.Combine(relativeLinkPath, Path.GetFileName(file.Link)), destFilename);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AsarSharp.Integrity;
|
||||
using AsarSharp.PickleTools;
|
||||
using AsarSharp.Utils;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AsarSharp.AsarFileSystem
|
||||
{
|
||||
public static class Disk
|
||||
{
|
||||
private const int StreamBufferSize = 1024 * 1024;
|
||||
|
||||
public class ArchiveHeader
|
||||
{
|
||||
public FilesystemEntry Header { get; set; }
|
||||
public string HeaderString { get; set; }
|
||||
public int HeaderSize { get; set; }
|
||||
}
|
||||
|
||||
public class FilesystemFilesAndLinks
|
||||
{
|
||||
public List<BasicFileInfo> Files { get; set; } = new List<BasicFileInfo>();
|
||||
}
|
||||
|
||||
public class BasicFileInfo
|
||||
{
|
||||
public string Filename { get; set; }
|
||||
public bool Unpack { get; set; }
|
||||
}
|
||||
|
||||
#region Reading
|
||||
|
||||
public static ArchiveHeader ReadArchiveHeaderSync(string archivePath)
|
||||
{
|
||||
using (var fs = new FileStream(archivePath, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
65536, FileOptions.SequentialScan))
|
||||
{
|
||||
byte[] sizeBuf = new byte[8];
|
||||
if (fs.ReadFull(sizeBuf, 0, 8) != 8)
|
||||
throw new Exception("Unable to read header size");
|
||||
|
||||
var sizePickle = Pickle.CreateFromBuffer(sizeBuf);
|
||||
var size = sizePickle.CreateIterator().ReadUInt32();
|
||||
|
||||
var headerBuf = new byte[size];
|
||||
if (fs.ReadFull(headerBuf, 0, (int)size) != size)
|
||||
throw new Exception("Unable to read header");
|
||||
|
||||
var headerPickle = Pickle.CreateFromBuffer(headerBuf);
|
||||
var header = headerPickle.CreateIterator().ReadString();
|
||||
var headerObj = JsonConvert.DeserializeObject<FilesystemEntry>(header);
|
||||
|
||||
return new ArchiveHeader
|
||||
{
|
||||
Header = headerObj,
|
||||
HeaderString = header,
|
||||
HeaderSize = (int)size
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
var header = ReadArchiveHeaderSync(archivePath);
|
||||
var filesystem = new Filesystem(archivePath);
|
||||
filesystem.SetHeader(header.Header, header.HeaderSize);
|
||||
return filesystem;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public static void CopyFile(string dest, string rootPath, string filename)
|
||||
{
|
||||
if (dest == null)
|
||||
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)
|
||||
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
string normalizedRootPath = Path.GetFullPath(rootPath)
|
||||
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
|
||||
if (string.Equals(normalizedDestRoot, normalizedRootPath, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
string sourcePath = Path.GetFullPath(Path.Combine(rootPath, filename));
|
||||
string destPath = Path.GetFullPath(Path.Combine(dest, filename));
|
||||
|
||||
if (string.Equals(sourcePath, destPath, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destPath) ?? throw new InvalidOperationException());
|
||||
using (var src = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, StreamBufferSize, FileOptions.SequentialScan))
|
||||
using (var dst = new FileStream(destPath, FileMode.Create, FileAccess.Write, FileShare.None, StreamBufferSize, FileOptions.SequentialScan))
|
||||
{
|
||||
src.CopyTo(dst, StreamBufferSize);
|
||||
}
|
||||
}
|
||||
|
||||
public static void WriteFileSystem(string dest, Filesystem fileSystem,
|
||||
FilesystemFilesAndLinks lists, Dictionary<string, CrawledFileType> metadata)
|
||||
{
|
||||
var serializerSettings = new JsonSerializerSettings
|
||||
{
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
DefaultValueHandling = DefaultValueHandling.Ignore
|
||||
};
|
||||
|
||||
// --- Phase 1: write placeholder header ---
|
||||
string headerJson = JsonConvert.SerializeObject(fileSystem.GetHeader(), serializerSettings);
|
||||
var headerPickle = Pickle.CreateEmpty();
|
||||
headerPickle.WriteString(headerJson);
|
||||
|
||||
var sizePickle = Pickle.CreateEmpty();
|
||||
sizePickle.WriteUInt32((uint)headerPickle.GetTotalSize());
|
||||
int sizePickleSize = sizePickle.GetTotalSize();
|
||||
|
||||
var buf = new byte[StreamBufferSize];
|
||||
var blockBuf = new byte[4 * 1024 * 1024]; // shared across all files — avoids 4MB alloc per file
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
headerPickle.WriteTo(fs);
|
||||
|
||||
// --- Phase 2: stream files, hash in one pass, patch nodes in-memory ---
|
||||
foreach (var file in lists.Files)
|
||||
{
|
||||
if (file.Unpack)
|
||||
{
|
||||
var relName = Extensions.GetRelativePath(fileSystem.GetRootPath(), file.Filename);
|
||||
CopyFile($"{dest}.unpacked", fileSystem.GetRootPath(), relName);
|
||||
CopyAndHash(file.Filename, null, buf, blockBuf, fileSystem);
|
||||
continue;
|
||||
}
|
||||
|
||||
CopyAndHash(file.Filename, fs, buf, blockBuf, fileSystem);
|
||||
}
|
||||
|
||||
// --- Phase 3: re-serialize header with real hashes, seek back, overwrite ---
|
||||
string patchedJson = JsonConvert.SerializeObject(fileSystem.GetHeader(), serializerSettings);
|
||||
var patchedPickle = Pickle.CreateEmpty();
|
||||
patchedPickle.WriteString(patchedJson);
|
||||
|
||||
var patchedSizePickle = Pickle.CreateEmpty();
|
||||
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;
|
||||
patchedSizePickle.WriteTo(fs);
|
||||
patchedPickle.WriteTo(fs);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyAndHash(string srcPath, Stream dest, byte[] buf, byte[] blockBuf, Filesystem fs)
|
||||
{
|
||||
string relPath = Extensions.GetRelativePath(fs.GetRootPath(), srcPath);
|
||||
var node = fs.GetNode(relPath, followLinks: false);
|
||||
|
||||
long fileSize = node?.Size ?? 0;
|
||||
int estimatedBlocks = fileSize > 0 ? (int)((fileSize + 4 * 1024 * 1024 - 1) / (4 * 1024 * 1024)) : 0;
|
||||
|
||||
using (var hasher = new IntegrityHelper.StreamingHasher(estimatedBlocks, blockBuf))
|
||||
using (var src = new FileStream(srcPath, FileMode.Open, FileAccess.Read, FileShare.Read, StreamBufferSize, FileOptions.SequentialScan))
|
||||
{
|
||||
int read;
|
||||
while ((read = src.Read(buf, 0, buf.Length)) > 0)
|
||||
{
|
||||
hasher.Append(buf, 0, read);
|
||||
dest?.Write(buf, 0, read);
|
||||
}
|
||||
|
||||
if (node != null)
|
||||
node.Integrity = hasher.Finalise();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AsarSharp.Integrity;
|
||||
using AsarSharp.Utils;
|
||||
|
||||
namespace AsarSharp.AsarFileSystem
|
||||
{
|
||||
public class Filesystem
|
||||
{
|
||||
private readonly string _src;
|
||||
private FilesystemEntry _header;
|
||||
private int _headerSize;
|
||||
private long _offset;
|
||||
|
||||
private const uint UINT32_MAX = 0xFFFFFFFF;
|
||||
|
||||
public Filesystem(string src)
|
||||
{
|
||||
_src = Path.GetFullPath(src);
|
||||
_header = new FilesystemEntry { Files = new Dictionary<string, FilesystemEntry>(StringComparer.Ordinal) };
|
||||
_headerSize = 0;
|
||||
_offset = 0;
|
||||
}
|
||||
|
||||
public string GetRootPath() => _src;
|
||||
public FilesystemEntry GetHeader() => _header;
|
||||
public int GetHeaderSize() => _headerSize;
|
||||
|
||||
public void SetHeader(FilesystemEntry header, int headerSize)
|
||||
{
|
||||
_header = header;
|
||||
_headerSize = headerSize;
|
||||
}
|
||||
|
||||
public FilesystemEntry SearchNodeFromDirectory(string p, bool create = true)
|
||||
{
|
||||
FilesystemEntry json = _header;
|
||||
|
||||
int len = p.Length;
|
||||
int start = 0;
|
||||
|
||||
// skip leading separators
|
||||
while (start < len && (p[start] == '/' || p[start] == '\\')) start++;
|
||||
|
||||
while (start < len)
|
||||
{
|
||||
// find next separator
|
||||
int end = start;
|
||||
while (end < len && p[end] != '/' && p[end] != '\\') end++;
|
||||
|
||||
int segLen = end - start;
|
||||
if (segLen == 0 || (segLen == 1 && p[start] == '.'))
|
||||
{
|
||||
start = end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
string seg = p.Substring(start, segLen);
|
||||
|
||||
if (!json.IsDirectory)
|
||||
{
|
||||
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 (create)
|
||||
{
|
||||
child = new FilesystemEntry { Files = new Dictionary<string, FilesystemEntry>(StringComparer.Ordinal) };
|
||||
json.Files[seg] = child;
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
json = child;
|
||||
start = end + 1;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
public (FilesystemEntry parent, string name) SearchNodeFromPathWithParent(string p)
|
||||
{
|
||||
string rel = Extensions.GetRelativePath(_src, p);
|
||||
if (string.IsNullOrEmpty(rel))
|
||||
return (_header, string.Empty);
|
||||
|
||||
string name = Path.GetFileName(rel);
|
||||
string dir = Extensions.GetDirectoryName(rel);
|
||||
var parent = SearchNodeFromDirectory(dir, true);
|
||||
|
||||
if (parent.Files == null)
|
||||
parent.Files = new Dictionary<string, FilesystemEntry>(StringComparer.Ordinal);
|
||||
|
||||
if (!parent.Files.ContainsKey(name))
|
||||
parent.Files[name] = new FilesystemEntry();
|
||||
|
||||
return (parent, name);
|
||||
}
|
||||
|
||||
public List<string> ListFiles(bool isPack = false)
|
||||
{
|
||||
var files = new List<string>();
|
||||
FillFilesFromMetadata("/", _header);
|
||||
return files;
|
||||
|
||||
void FillFilesFromMetadata(string basePath, FilesystemEntry metadata)
|
||||
{
|
||||
if (!metadata.IsDirectory) return;
|
||||
foreach (var entry in metadata.Files)
|
||||
{
|
||||
string fullPath = Path.Combine(basePath, entry.Key).Replace('\\', '/');
|
||||
string packState = entry.Value.Unpacked == true ? "unpack" : "pack ";
|
||||
files.Add(isPack ? $"{packState} : {fullPath}" : fullPath);
|
||||
FillFilesFromMetadata(fullPath, entry.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
FilesystemEntry node = SearchNodeFromDirectory(Extensions.GetDirectoryName(p), false);
|
||||
if (node == null)
|
||||
return null;
|
||||
string name = Path.GetFileName(p);
|
||||
|
||||
if (node.IsLink && followLinks)
|
||||
return GetNode(Path.Combine(node.Link, name), followLinks, linkDepth + 1);
|
||||
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
if (node.IsDirectory && node.Files != null && node.Files.TryGetValue(name, out var entry))
|
||||
return entry;
|
||||
return null;
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
public FilesystemEntry GetFile(string p, bool followLinks = true, int linkDepth = 0)
|
||||
{
|
||||
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.IsLink && followLinks) return GetFile(info.Link, followLinks, linkDepth + 1);
|
||||
return info;
|
||||
}
|
||||
|
||||
#region Writing
|
||||
|
||||
public FilesystemEntry SearchNodeFromPath(string p)
|
||||
{
|
||||
var (parent, name) = SearchNodeFromPathWithParent(p);
|
||||
if (string.IsNullOrEmpty(name)) return _header;
|
||||
return parent.Files[name];
|
||||
}
|
||||
|
||||
public void InsertDirectory(string p, bool unpack)
|
||||
{
|
||||
FilesystemEntry node = SearchNodeFromPath(p);
|
||||
node.Files = node.Files ?? new Dictionary<string, FilesystemEntry>(StringComparer.Ordinal);
|
||||
node.Unpacked = unpack;
|
||||
}
|
||||
|
||||
public void InsertFile(string path, bool shouldUnpack, CrawledFileType file,
|
||||
IntegrityHelper.FileIntegrity precomputedIntegrity = null)
|
||||
{
|
||||
var (dirNode, _) = SearchNodeFromPathWithParent(path);
|
||||
var node = SearchNodeFromPath(path);
|
||||
|
||||
long size;
|
||||
if (file.Stat is FileInfo fi)
|
||||
size = fi.Length;
|
||||
else
|
||||
throw new Exception($"{path}: stat is not a file");
|
||||
|
||||
if (shouldUnpack || dirNode.Unpacked == true)
|
||||
{
|
||||
node.Size = size;
|
||||
node.Unpacked = true;
|
||||
node.Integrity = precomputedIntegrity ?? IntegrityHelper.GetFileIntegrity(path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (size > UINT32_MAX)
|
||||
throw new Exception($"{path}: file size cannot be larger than 4.2GB");
|
||||
|
||||
node.Size = size;
|
||||
node.Offset = _offset.ToString();
|
||||
node.Integrity = precomputedIntegrity ?? IntegrityHelper.GetFileIntegrity(path);
|
||||
|
||||
if (!Extensions.IsWindowsPlatform() && (file.Stat.Attributes & FileAttributes.Hidden) != 0)
|
||||
node.Executable = true;
|
||||
|
||||
_offset += size;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AsarSharp.Utils;
|
||||
|
||||
namespace AsarSharp.AsarFileSystem
|
||||
{
|
||||
public class CrawledFileType
|
||||
{
|
||||
public FileType Type { get; set; }
|
||||
public FileSystemInfo Stat { get; set; }
|
||||
}
|
||||
|
||||
public enum FileType
|
||||
{
|
||||
File,
|
||||
Directory,
|
||||
Link
|
||||
}
|
||||
|
||||
public static class FileSystemCrawler
|
||||
{
|
||||
public static CrawledFileType DetermineFileType(string filename)
|
||||
{
|
||||
FileAttributes attributes;
|
||||
try
|
||||
{
|
||||
attributes = File.GetAttributes(filename);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
|
||||
{
|
||||
throw new IOException($"Failed to read attributes for '{filename}'", ex);
|
||||
}
|
||||
|
||||
bool isDirectory = (attributes & FileAttributes.Directory) == FileAttributes.Directory;
|
||||
bool isLink = (attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint;
|
||||
FileSystemInfo info = isDirectory
|
||||
? (FileSystemInfo)new DirectoryInfo(filename)
|
||||
: new FileInfo(filename);
|
||||
|
||||
if (isLink) return new CrawledFileType { Type = FileType.Link, Stat = info };
|
||||
if (isDirectory) return new CrawledFileType { Type = FileType.Directory, Stat = info };
|
||||
return new CrawledFileType { Type = FileType.File, Stat = info };
|
||||
}
|
||||
|
||||
public static (List<string> filenames, Dictionary<string, CrawledFileType> metadata) CrawlFileSystem(string dir)
|
||||
{
|
||||
var metadata = new Dictionary<string, CrawledFileType>();
|
||||
var filenames = new List<string>();
|
||||
var links = new List<string>();
|
||||
|
||||
foreach (var fullPath in CrawlIterative(dir))
|
||||
{
|
||||
var type = DetermineFileType(fullPath);
|
||||
metadata[fullPath] = type;
|
||||
if (type.Type == FileType.Link) links.Add(fullPath);
|
||||
filenames.Add(fullPath);
|
||||
}
|
||||
|
||||
if (links.Count == 0) return (filenames, metadata);
|
||||
|
||||
var filtered = new List<string>(filenames.Count);
|
||||
foreach (var filename in filenames)
|
||||
{
|
||||
bool isValid = true;
|
||||
string fileDir = Path.GetDirectoryName(filename) ?? string.Empty;
|
||||
|
||||
foreach (var link in links)
|
||||
{
|
||||
if (string.Equals(filename, link, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
|
||||
// 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);
|
||||
if (!rel.StartsWith("..", StringComparison.Ordinal))
|
||||
{
|
||||
isValid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isValid) filtered.Add(filename);
|
||||
}
|
||||
|
||||
return (filtered, metadata);
|
||||
}
|
||||
|
||||
public static List<string> CrawlIterative(string dir)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var stack = new Stack<DirectoryInfo>();
|
||||
|
||||
string basePath = Extensions.GetBasePath(dir);
|
||||
if (!Directory.Exists(basePath)) return result;
|
||||
|
||||
stack.Push(new DirectoryInfo(basePath));
|
||||
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
var current = stack.Pop();
|
||||
FileSystemInfo[] entries;
|
||||
try
|
||||
{
|
||||
entries = current.GetFileSystemInfos();
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
result.Add(entry.FullName);
|
||||
if (entry is DirectoryInfo subDir && (subDir.Attributes & FileAttributes.ReparsePoint) == 0)
|
||||
stack.Push(subDir);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Collections.Generic;
|
||||
using AsarSharp.Integrity;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AsarSharp.AsarFileSystem
|
||||
{
|
||||
public class FilesystemEntry
|
||||
{
|
||||
[JsonProperty("files")]
|
||||
public Dictionary<string, FilesystemEntry> Files { get; set; }
|
||||
|
||||
|
||||
[JsonProperty("executable")]
|
||||
public bool? Executable { get; set; }
|
||||
|
||||
[JsonProperty("size")]
|
||||
public long? Size { get; set; }
|
||||
|
||||
[JsonProperty("offset")]
|
||||
public string Offset { get; set; }
|
||||
|
||||
[JsonProperty("unpacked")]
|
||||
public bool? Unpacked { get; set; }
|
||||
|
||||
[JsonProperty("integrity")]
|
||||
public IntegrityHelper.FileIntegrity Integrity { get; set; }
|
||||
|
||||
[JsonProperty("link")]
|
||||
public string Link { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public bool IsDirectory => Files != null;
|
||||
[JsonIgnore]
|
||||
public bool IsFile => Size.HasValue;
|
||||
|
||||
[JsonIgnore]
|
||||
public bool IsLink => Link != null;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"Offset: {Offset}, Size: {Size} Unpacked: {Unpacked}";
|
||||
}
|
||||
|
||||
public bool ShouldSerializeUnpacked()
|
||||
{
|
||||
return Unpacked == true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props"
|
||||
Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')"/>
|
||||
<PropertyGroup>
|
||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
||||
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
||||
<ProjectGuid>{BEAA604A-402A-4387-8903-A53FC913A26E}</ProjectGuid>
|
||||
<OutputType>Library</OutputType>
|
||||
<AppDesignerFolder>Properties</AppDesignerFolder>
|
||||
<RootNamespace>AsarSharp</RootNamespace>
|
||||
<AssemblyName>AsarSharp</AssemblyName>
|
||||
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
|
||||
<FileAlignment>512</FileAlignment>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<DebugType>full</DebugType>
|
||||
<Optimize>false</Optimize>
|
||||
<OutputPath>bin\Debug\</OutputPath>
|
||||
<DefineConstants>DEBUG;TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<DebugType>pdbonly</DebugType>
|
||||
<Optimize>true</Optimize>
|
||||
<OutputPath>bin\Release\</OutputPath>
|
||||
<DefineConstants>TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="Newtonsoft.Json, Version=13.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="System"/>
|
||||
<Reference Include="System.Core"/>
|
||||
<Reference Include="System.Data"/>
|
||||
<Reference Include="System.Xml"/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="AsarCreator.cs" />
|
||||
<Compile Include="AsarExtractor.cs" />
|
||||
<Compile Include="AsarFileSystem\Disk.cs" />
|
||||
<Compile Include="AsarFileSystem\FileSystem.cs" />
|
||||
<Compile Include="AsarFileSystem\FileSystemCrawler.cs" />
|
||||
<Compile Include="AsarFileSystem\FilesystemEntry.cs" />
|
||||
<Compile Include="Integrity\IntegrityHelper.cs" />
|
||||
<Compile Include="PickleTools\Pickle.cs" />
|
||||
<Compile Include="PickleTools\PickleIterator.cs" />
|
||||
<Compile Include="Properties\AssemblyInfo.cs"/>
|
||||
<Compile Include="Utils\Extensions.cs" />
|
||||
<Compile Include="Utils\NativeMethods.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="packages.config" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets"/>
|
||||
<!-- To modify your build process, add your task inside one of the targets below and uncomment it.
|
||||
Other similar extension points exist, see Microsoft.Common.targets.
|
||||
<Target Name="BeforeBuild">
|
||||
</Target>
|
||||
<Target Name="AfterBuild">
|
||||
</Target>
|
||||
-->
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,159 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using AsarSharp.Utils;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AsarSharp.Integrity
|
||||
{
|
||||
public static class IntegrityHelper
|
||||
{
|
||||
private const string ALGORITHM = "SHA256";
|
||||
private const int BLOCK_SIZE = 4 * 1024 * 1024;
|
||||
public const string PLACEHOLDER_HASH = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
private static readonly char[] HexDigits = "0123456789abcdef".ToCharArray();
|
||||
|
||||
public class FileIntegrity
|
||||
{
|
||||
[JsonProperty("algorithm")]
|
||||
public string Algorithm { get; set; }
|
||||
|
||||
[JsonProperty("hash")]
|
||||
public string Hash { get; set; }
|
||||
|
||||
[JsonProperty("blockSize")]
|
||||
public int BlockSize { get; set; }
|
||||
|
||||
[JsonProperty("blocks")]
|
||||
public List<string> Blocks { get; set; }
|
||||
}
|
||||
|
||||
public static FileIntegrity CreatePlaceholder(long fileSize)
|
||||
{
|
||||
int blockCount = fileSize > 0 ? (int)((fileSize + BLOCK_SIZE - 1) / BLOCK_SIZE) : 0;
|
||||
var blocks = new List<string>(blockCount);
|
||||
for (int i = 0; i < blockCount; i++)
|
||||
blocks.Add(PLACEHOLDER_HASH);
|
||||
|
||||
return new FileIntegrity
|
||||
{
|
||||
Algorithm = ALGORITHM,
|
||||
Hash = PLACEHOLDER_HASH,
|
||||
BlockSize = BLOCK_SIZE,
|
||||
Blocks = blocks,
|
||||
};
|
||||
}
|
||||
|
||||
public static FileIntegrity GetFileIntegrity(string path, byte[] reusableBuffer = null)
|
||||
{
|
||||
bool ownBuffer = reusableBuffer == null;
|
||||
if (ownBuffer) reusableBuffer = new byte[BLOCK_SIZE];
|
||||
|
||||
using (var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
65536, FileOptions.SequentialScan))
|
||||
using (var fileHash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256))
|
||||
using (var blockHash = SHA256.Create())
|
||||
{
|
||||
int estimatedBlockCount = fileStream.Length > 0
|
||||
? (int)((fileStream.Length + BLOCK_SIZE - 1) / BLOCK_SIZE)
|
||||
: 0;
|
||||
var blockHashes = new List<string>(estimatedBlockCount);
|
||||
int bytesRead;
|
||||
|
||||
// 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)));
|
||||
fileHash.AppendData(reusableBuffer, 0, bytesRead);
|
||||
}
|
||||
|
||||
return new FileIntegrity
|
||||
{
|
||||
Algorithm = ALGORITHM,
|
||||
Hash = ToLowerHex(fileHash.GetHashAndReset()),
|
||||
BlockSize = BLOCK_SIZE,
|
||||
Blocks = blockHashes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StreamingHasher : IDisposable
|
||||
{
|
||||
private readonly IncrementalHash _fileHash;
|
||||
private readonly SHA256 _blockHash;
|
||||
private readonly byte[] _blockBuf;
|
||||
private int _blockFill;
|
||||
private readonly List<string> _blockHashes;
|
||||
|
||||
public StreamingHasher(int estimatedBlocks = 0, byte[] sharedBlockBuffer = null)
|
||||
{
|
||||
_fileHash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
_blockHash = SHA256.Create();
|
||||
_blockBuf = sharedBlockBuffer ?? new byte[BLOCK_SIZE];
|
||||
_blockFill = 0;
|
||||
_blockHashes = new List<string>(estimatedBlocks);
|
||||
}
|
||||
|
||||
public void Append(byte[] data, int offset, int count)
|
||||
{
|
||||
_fileHash.AppendData(data, offset, count);
|
||||
|
||||
int remaining = count;
|
||||
int src = offset;
|
||||
while (remaining > 0)
|
||||
{
|
||||
int space = BLOCK_SIZE - _blockFill;
|
||||
int copy = Math.Min(space, remaining);
|
||||
Buffer.BlockCopy(data, src, _blockBuf, _blockFill, copy);
|
||||
_blockFill += copy;
|
||||
src += copy;
|
||||
remaining -= copy;
|
||||
|
||||
if (_blockFill == BLOCK_SIZE)
|
||||
{
|
||||
_blockHashes.Add(ToLowerHex(_blockHash.ComputeHash(_blockBuf, 0, BLOCK_SIZE)));
|
||||
_blockFill = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public FileIntegrity Finalise()
|
||||
{
|
||||
if (_blockFill > 0)
|
||||
{
|
||||
_blockHashes.Add(ToLowerHex(_blockHash.ComputeHash(_blockBuf, 0, _blockFill)));
|
||||
_blockFill = 0;
|
||||
}
|
||||
|
||||
return new FileIntegrity
|
||||
{
|
||||
Algorithm = ALGORITHM,
|
||||
Hash = ToLowerHex(_fileHash.GetHashAndReset()),
|
||||
BlockSize = BLOCK_SIZE,
|
||||
Blocks = _blockHashes,
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_fileHash.Dispose();
|
||||
_blockHash.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public static string ToLowerHex(byte[] bytes)
|
||||
{
|
||||
if (bytes == null || bytes.Length == 0) return string.Empty;
|
||||
var chars = new char[bytes.Length * 2];
|
||||
for (int i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
byte v = bytes[i];
|
||||
chars[i * 2] = HexDigits[v >> 4];
|
||||
chars[i * 2 + 1] = HexDigits[v & 0x0F];
|
||||
}
|
||||
return new string(chars);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace AsarSharp.PickleTools
|
||||
{
|
||||
public class Pickle
|
||||
{
|
||||
public const int SIZE_INT32 = 4;
|
||||
public const int SIZE_UINT32 = 4;
|
||||
public const int SIZE_INT64 = 8;
|
||||
public const int SIZE_UINT64 = 8;
|
||||
public const int SIZE_FLOAT = 4;
|
||||
public const int SIZE_DOUBLE = 8;
|
||||
|
||||
// Initial payload allocation. Bumped from 64 — large headers used to
|
||||
// realloc many times when growing geometrically from 64.
|
||||
public const int PAYLOAD_UNIT = 4096;
|
||||
|
||||
public const long CAPACITY_READ_ONLY = 9007199254740992;
|
||||
|
||||
private byte[] _header;
|
||||
private int _headerSize;
|
||||
private long _capacityAfterHeader;
|
||||
private int _writeOffset;
|
||||
|
||||
private Pickle(byte[] buffer = null)
|
||||
{
|
||||
if (buffer != null)
|
||||
{
|
||||
if (buffer.Length < SIZE_UINT32)
|
||||
throw new ArgumentException("Buffer is too small.", nameof(buffer));
|
||||
|
||||
_header = buffer;
|
||||
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;
|
||||
_writeOffset = 0;
|
||||
|
||||
if (_headerSize != AlignInt(_headerSize, SIZE_UINT32))
|
||||
{
|
||||
_headerSize = 0;
|
||||
}
|
||||
|
||||
if (_headerSize == 0)
|
||||
{
|
||||
_header = new byte[0];
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_header = new byte[0];
|
||||
_headerSize = SIZE_UINT32;
|
||||
_capacityAfterHeader = 0;
|
||||
_writeOffset = 0;
|
||||
Resize(PAYLOAD_UNIT);
|
||||
SetPayloadSize(0);
|
||||
}
|
||||
}
|
||||
|
||||
public static Pickle CreateEmpty() => new Pickle();
|
||||
public static Pickle CreateFromBuffer(byte[] buffer) => new Pickle(buffer);
|
||||
|
||||
public byte[] GetHeader() => _header;
|
||||
public int GetHeaderSize() => _headerSize;
|
||||
|
||||
public PickleIterator CreateIterator() => new PickleIterator(this);
|
||||
|
||||
/// <summary>Total byte length of the serialised pickle (header + payload).</summary>
|
||||
public int GetTotalSize() => _headerSize + GetPayloadSize();
|
||||
|
||||
/// <summary>Materialise the pickle into a fresh byte array (allocates).</summary>
|
||||
public byte[] ToBuffer()
|
||||
{
|
||||
int resultSize = GetTotalSize();
|
||||
byte[] result = new byte[resultSize];
|
||||
Buffer.BlockCopy(_header, 0, result, 0, resultSize);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Write the serialised pickle straight to <paramref name="stream"/> — no extra copy.</summary>
|
||||
public void WriteTo(Stream stream)
|
||||
{
|
||||
stream.Write(_header, 0, GetTotalSize());
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public bool WriteInt(int value)
|
||||
{
|
||||
const int dataLength = SIZE_INT32; // already 4-byte aligned
|
||||
int newSize = _writeOffset + dataLength;
|
||||
|
||||
if (newSize > _capacityAfterHeader)
|
||||
{
|
||||
Resize(Math.Max((int)_capacityAfterHeader * 2, newSize));
|
||||
}
|
||||
|
||||
WriteInt32LE(value, _headerSize + _writeOffset);
|
||||
SetPayloadSize(newSize);
|
||||
_writeOffset = newSize;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
public bool WriteUInt32(uint value)
|
||||
{
|
||||
const int dataLength = SIZE_UINT32;
|
||||
int newSize = _writeOffset + dataLength;
|
||||
|
||||
if (newSize > _capacityAfterHeader)
|
||||
{
|
||||
Resize(Math.Max((int)_capacityAfterHeader * 2, newSize));
|
||||
}
|
||||
|
||||
WriteUInt32LE(value, _headerSize + _writeOffset);
|
||||
SetPayloadSize(newSize);
|
||||
_writeOffset = newSize;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public bool WriteString(string value)
|
||||
{
|
||||
int byteLen = Encoding.UTF8.GetByteCount(value);
|
||||
|
||||
if (!WriteInt(byteLen))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int aligned = AlignInt(byteLen, SIZE_UINT32);
|
||||
int newSize = _writeOffset + aligned;
|
||||
|
||||
if (newSize > _capacityAfterHeader)
|
||||
{
|
||||
Resize(Math.Max((int)_capacityAfterHeader * 2, newSize));
|
||||
}
|
||||
|
||||
int writeStart = _headerSize + _writeOffset;
|
||||
Encoding.UTF8.GetBytes(value, 0, value.Length, _header, writeStart);
|
||||
|
||||
// zero alignment padding
|
||||
for (int i = writeStart + byteLen; i < writeStart + aligned; i++)
|
||||
{
|
||||
_header[i] = 0;
|
||||
}
|
||||
|
||||
SetPayloadSize(newSize);
|
||||
_writeOffset = newSize;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void SetPayloadSize(int payloadSize)
|
||||
{
|
||||
WriteUInt32LE((uint)payloadSize, 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)
|
||||
{
|
||||
newCapacity = AlignInt(newCapacity, PAYLOAD_UNIT);
|
||||
// The backing array must hold the header plus the full advertised
|
||||
// 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;
|
||||
_capacityAfterHeader = newCapacity;
|
||||
}
|
||||
|
||||
public static int AlignInt(int i, int alignment)
|
||||
{
|
||||
return i + ((alignment - (i % alignment)) % alignment);
|
||||
}
|
||||
|
||||
#region Auxiliary methods for reading/writing values in Little Endian
|
||||
|
||||
private uint ReadUInt32LE(int offset)
|
||||
{
|
||||
// _header is allocated by us so always little-endian-friendly when on LE host.
|
||||
return (uint)(_header[offset] |
|
||||
(_header[offset + 1] << 8) |
|
||||
(_header[offset + 2] << 16) |
|
||||
(_header[offset + 3] << 24));
|
||||
}
|
||||
|
||||
private void WriteInt32LE(int 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);
|
||||
}
|
||||
|
||||
private void WriteUInt32LE(uint 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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
|
||||
namespace AsarSharp.PickleTools
|
||||
{
|
||||
public class PickleIterator
|
||||
{
|
||||
private readonly byte[] _payload;
|
||||
private readonly int _payloadOffset;
|
||||
private int _readIndex;
|
||||
private readonly int _endIndex;
|
||||
|
||||
public PickleIterator(Pickle pickle)
|
||||
{
|
||||
_payload = pickle.GetHeader();
|
||||
_payloadOffset = pickle.GetHeaderSize();
|
||||
_readIndex = 0;
|
||||
_endIndex = pickle.GetPayloadSize();
|
||||
}
|
||||
|
||||
|
||||
|
||||
public int ReadInt()
|
||||
{
|
||||
return ReadBytes(Pickle.SIZE_INT32, BitConverter.ToInt32);
|
||||
}
|
||||
|
||||
public uint ReadUInt32()
|
||||
{
|
||||
return ReadBytes(Pickle.SIZE_UINT32, BitConverter.ToUInt32);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public string ReadString()
|
||||
{
|
||||
int length = ReadInt();
|
||||
return Encoding.UTF8.GetString(ReadBytes(length));
|
||||
}
|
||||
|
||||
private T ReadBytes<T>(int length, Func<byte[], int, T> converter)
|
||||
{
|
||||
int readPayloadOffset = GetReadPayloadOffsetAndAdvance(length);
|
||||
return converter(_payload, readPayloadOffset);
|
||||
}
|
||||
|
||||
private byte[] ReadBytes(int length)
|
||||
{
|
||||
int readPayloadOffset = GetReadPayloadOffsetAndAdvance(length);
|
||||
byte[] result = new byte[length];
|
||||
Array.Copy(_payload, readPayloadOffset, result, 0, length);
|
||||
return result;
|
||||
}
|
||||
|
||||
private int GetReadPayloadOffsetAndAdvance(int length)
|
||||
{
|
||||
if (length < 0 || length > _endIndex - _readIndex)
|
||||
{
|
||||
_readIndex = _endIndex;
|
||||
throw new InvalidOperationException($"Failed to read data with length of {length}");
|
||||
}
|
||||
int readPayloadOffset = _payloadOffset + _readIndex;
|
||||
Advance(length);
|
||||
return readPayloadOffset;
|
||||
}
|
||||
|
||||
private void Advance(int size)
|
||||
{
|
||||
int alignedSize = Pickle.AlignInt(size, Pickle.SIZE_UINT32);
|
||||
if (_endIndex - _readIndex < alignedSize)
|
||||
{
|
||||
_readIndex = _endIndex;
|
||||
}
|
||||
else
|
||||
{
|
||||
_readIndex += alignedSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
// General Information about an assembly is controlled through the following
|
||||
// set of attributes. Change these attribute values to modify the information
|
||||
// associated with an assembly.
|
||||
[assembly: AssemblyTitle("AsarSharp")]
|
||||
[assembly: AssemblyDescription("")]
|
||||
[assembly: AssemblyConfiguration("")]
|
||||
[assembly: AssemblyCompany("")]
|
||||
[assembly: AssemblyProduct("AsarSharp")]
|
||||
[assembly: AssemblyCopyright("Copyright © 2025")]
|
||||
[assembly: AssemblyTrademark("")]
|
||||
[assembly: AssemblyCulture("")]
|
||||
|
||||
// Setting ComVisible to false makes the types in this assembly not visible
|
||||
// to COM components. If you need to access a type in this assembly from
|
||||
// COM, set the ComVisible attribute to true on that type.
|
||||
[assembly: ComVisible(false)]
|
||||
|
||||
// The following GUID is for the ID of the typelib if this project is exposed to COM
|
||||
[assembly: Guid("BEAA604A-402A-4387-8903-A53FC913A26E")]
|
||||
|
||||
// Version information for an assembly consists of the following four values:
|
||||
//
|
||||
// Major Version
|
||||
// Minor Version
|
||||
// Build Number
|
||||
// Revision
|
||||
//
|
||||
// You can specify all the values or you can default the Build and Revision Numbers
|
||||
// by using the '*' as shown below:
|
||||
// [assembly: AssemblyVersion("1.0.*")]
|
||||
[assembly: AssemblyVersion("1.0.0.0")]
|
||||
[assembly: AssemblyFileVersion("1.0.0.0")]
|
||||
@@ -0,0 +1,240 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace AsarSharp.Utils
|
||||
{
|
||||
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>
|
||||
/// Compute path relative to <paramref name="relativeTo"/>.
|
||||
/// Fast common-case (path is inside relativeTo): plain prefix-strip.
|
||||
/// Falls back to <see cref="Path.GetFullPath"/> + manual relativisation
|
||||
/// when paths must be normalised or '..' segments are required.
|
||||
/// Replaces previous URI-based implementation which was a large hot-path cost.
|
||||
/// </summary>
|
||||
public static string GetRelativePath(string relativeTo, string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(relativeTo))
|
||||
throw new ArgumentNullException(nameof(relativeTo));
|
||||
if (string.IsNullOrEmpty(path))
|
||||
throw new ArgumentNullException(nameof(path));
|
||||
|
||||
// Fast path: literal prefix match (no normalisation). Covers ~all
|
||||
// intra-archive callers where both inputs already come from the
|
||||
// same crawl pass.
|
||||
string baseFast = TrimTrailingSeparators(relativeTo);
|
||||
string pathFast = TrimTrailingSeparators(path);
|
||||
|
||||
if (string.Equals(baseFast, pathFast, StringComparison.OrdinalIgnoreCase))
|
||||
return string.Empty;
|
||||
|
||||
if (pathFast.Length > baseFast.Length &&
|
||||
pathFast.StartsWith(baseFast, StringComparison.OrdinalIgnoreCase) &&
|
||||
IsSeparator(pathFast[baseFast.Length]))
|
||||
{
|
||||
return pathFast.Substring(baseFast.Length + 1);
|
||||
}
|
||||
|
||||
// Slow path: normalise both sides and compute relative — used for
|
||||
// security checks (out-of-tree symlink/destination guards) and the
|
||||
// rare "go up" case.
|
||||
return GetRelativePathNormalised(relativeTo, path);
|
||||
}
|
||||
|
||||
private static string GetRelativePathNormalised(string relativeTo, string path)
|
||||
{
|
||||
string fullBase = Path.GetFullPath(relativeTo);
|
||||
string fullPath = Path.GetFullPath(path);
|
||||
|
||||
fullBase = TrimTrailingSeparators(fullBase);
|
||||
fullPath = TrimTrailingSeparators(fullPath);
|
||||
|
||||
if (string.Equals(fullBase, fullPath, StringComparison.OrdinalIgnoreCase))
|
||||
return string.Empty;
|
||||
|
||||
if (fullPath.Length > fullBase.Length &&
|
||||
fullPath.StartsWith(fullBase, StringComparison.OrdinalIgnoreCase) &&
|
||||
IsSeparator(fullPath[fullBase.Length]))
|
||||
{
|
||||
return fullPath.Substring(fullBase.Length + 1);
|
||||
}
|
||||
|
||||
// Need to walk up the common ancestor.
|
||||
char sep = Path.DirectorySeparatorChar;
|
||||
string[] baseParts = fullBase.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
string[] pathParts = fullPath.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
int common = 0;
|
||||
int max = Math.Min(baseParts.Length, pathParts.Length);
|
||||
while (common < max &&
|
||||
string.Equals(baseParts[common], pathParts[common], StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
common++;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
for (int i = common; i < baseParts.Length; i++)
|
||||
{
|
||||
if (sb.Length > 0) sb.Append(sep);
|
||||
sb.Append("..");
|
||||
}
|
||||
for (int i = common; i < pathParts.Length; i++)
|
||||
{
|
||||
if (sb.Length > 0) sb.Append(sep);
|
||||
sb.Append(pathParts[i]);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string TrimTrailingSeparators(string s)
|
||||
{
|
||||
int end = s.Length;
|
||||
while (end > 0 && IsSeparator(s[end - 1])) end--;
|
||||
return end == s.Length ? s : s.Substring(0, end);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
return ".";
|
||||
|
||||
string result = Path.GetDirectoryName(path);
|
||||
|
||||
if (string.IsNullOrEmpty(result))
|
||||
return ".";
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static void CopyDirectory(string sourceDir, string destinationDir)
|
||||
{
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
|
||||
foreach (var file in Directory.GetFiles(sourceDir))
|
||||
{
|
||||
var destFile = Path.Combine(destinationDir, Path.GetFileName(file));
|
||||
File.Copy(file, destFile, true);
|
||||
}
|
||||
|
||||
foreach (var dir in Directory.GetDirectories(sourceDir))
|
||||
{
|
||||
var destDir = Path.Combine(destinationDir, Path.GetFileName(dir));
|
||||
CopyDirectory(dir, destDir);
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetBasePath(string dir)
|
||||
{
|
||||
int wildcardIndex = dir.IndexOfAny(new[] { '*', '?' });
|
||||
if (wildcardIndex == -1)
|
||||
{
|
||||
return dir;
|
||||
}
|
||||
|
||||
int lastSeparatorIndex = dir.LastIndexOf(Path.DirectorySeparatorChar, wildcardIndex);
|
||||
if (lastSeparatorIndex == -1)
|
||||
{
|
||||
return ".";
|
||||
}
|
||||
|
||||
return dir.Substring(0, lastSeparatorIndex);
|
||||
}
|
||||
|
||||
public static void SetUnixFilePermission(string filePath, string permission)
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return;
|
||||
|
||||
RunTool("chmod", $"{permission} \"{filePath}\"");
|
||||
}
|
||||
|
||||
|
||||
public static void CreateSymbolicLink(string linkTarget, string linkPath)
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
bool success = NativeMethods.CreateSymbolicLink(linkPath, linkTarget,
|
||||
Directory.Exists(linkTarget)
|
||||
? NativeMethods.SymLinkFlag.Directory
|
||||
: NativeMethods.SymLinkFlag.File);
|
||||
if (!success)
|
||||
throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
|
||||
return;
|
||||
}
|
||||
|
||||
RunTool("ln", $"-s \"{linkTarget}\" \"{linkPath}\"");
|
||||
}
|
||||
|
||||
public static bool IsWindowsPlatform()
|
||||
{
|
||||
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}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AsarSharp.Utils
|
||||
{
|
||||
internal static class NativeMethods
|
||||
{
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool CreateSymbolicLink(string lpSymlinkFileName, string lpTargetFileName, SymLinkFlag dwFlags);
|
||||
|
||||
public enum SymLinkFlag
|
||||
{
|
||||
File = 0,
|
||||
Directory = 1,
|
||||
AllowUnprivilegedCreate = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<packages>
|
||||
<package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" />
|
||||
</packages>
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
# Changelog
|
||||
|
||||
This file is the source of truth for release notes.
|
||||
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 starts Wand as a child process, apply patches in every process Electron spawns, and detaches once startup settles. 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 "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 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
|
||||
|
||||
- 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
|
||||
|
||||
### Fixes
|
||||
|
||||
- Rolled back an incorrect Disable Updates patch fix that introduced a `SyntaxError` preventing Wand from launching.
|
||||
|
||||
## [1.0.8.1] - 2026-05-15
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed a syntax error in the Disable Updates patch that prevented Wand from launching. #70
|
||||
- Fixed an issue where the Remote Web Panel WebSocket connection wouldn't automatically reconnect when turning returning to the app or turning on the screen.
|
||||
- Reduced battery consumption and device heating on mobile device by optimizing heavy UI blur effects and eliminating unnecessary React re-renders in the Remote Web Panel. #67
|
||||
|
||||
## [1.0.8.0] - 2026-05-06
|
||||
|
||||
### Features
|
||||
|
||||
- Added the My Games list to the Remote Panel with remote start and stop actions.
|
||||
- Improved the Remote Panel with new UI and overall UX.
|
||||
- Added an update dialog with release notes and access to full patch notes.
|
||||
|
||||
### Improvements
|
||||
|
||||
- Optimized and sped up patching and ASAR unpack/pack operations.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed in-place handling of unpacked `app.asar.unpacked` assets during packing to avoid locked-file failures.
|
||||
- Fixed local network IP detection for QR-based Remote Panel pairing, so the app no longer picks Cloudflare, VMware, and similar non-LAN adapters by mistake.
|
||||
|
||||
## [1.0.7.0] - 2026-05-01
|
||||
|
||||
### Features
|
||||
|
||||
- New Remote Web Panel: control local app features from a phone or another PC over the local network via QR code connection. #37
|
||||
- Custom Script Loader: inject and execute custom user `.js` scripts directly into the Wand renderer process via the patch modal.
|
||||
- Added the ability to export and copy application logs from the UI.
|
||||
- Stabilized the DevTools on `F12` patch.
|
||||
- Added a repository mirror on GitLab. #47
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed ASAR unpacking failures on locked files or missing entries. #63 #57
|
||||
|
||||
## [1.0.6.0] - 2025-12-14
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed issues related to Wand `12.5.1`. #35
|
||||
- Fixed a bug where the patch could not be reapplied after restoring without restarting the patcher.
|
||||
- Removed the redundant telemetry removal option from patch settings.
|
||||
|
||||
### Features
|
||||
|
||||
- Added localization support.
|
||||
- Added the patch option to open Wand DevTools with `F12`.
|
||||
|
||||
## [1.0.5.0] - 2025-11-30
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed the issue where games detected the debugger. #33 #23 #19 #13
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Removed patch methods.
|
||||
- Removed shortcut launch.
|
||||
- Removed automatic patching for new versions because it is not compatible with the current patch method.
|
||||
|
||||
### Notes
|
||||
|
||||
- This version is incompatible with previous versions of the patcher. Before updating, previous patches must be rolled back.
|
||||
- With the current method, the patcher only needs to be run once to apply the patch.
|
||||
- Thanks to issue #12 for sharing the patching method used here.
|
||||
|
||||
## [1.0.4.0] - 2025-11-05
|
||||
|
||||
### Features
|
||||
|
||||
- Added backward compatibility for older WeMod versions so both legacy WeMod and the newer Wand builds can be patched.
|
||||
- Added manual version management for patches, including separate patches and shortcuts for individual WeMod or Wand versions.
|
||||
|
||||
## [1.0.3.0] - 2025-11-03
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed issues related to the WeMod to Wand rebrand. #24
|
||||
|
||||
## [1.0.2.0] - 2025-04-09
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed a performance issue when a process with an applied patch was scanned again.
|
||||
- Fixed exception propagation into the WeMod process. #11
|
||||
|
||||
## [1.0.1.0] - 2025-04-01
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed WeMod overlay breakage when using the runtime patch.
|
||||
|
||||
## [1.0.0.0] - 2025-03-24
|
||||
|
||||
### Changes
|
||||
|
||||
- Replaced Electron with WPF.
|
||||
- Reduced the `.exe` size by more than 70x.
|
||||
- Updated the UI.
|
||||
- Added two types of patching.
|
||||
- Fixed hotkeys breaking after patching.
|
||||
- Added patch recovery.
|
||||
- Removed external dependencies such as Electron and ASAR tooling from runtime.
|
||||
- Added a patch option to disable WeMod updates.
|
||||
|
||||
### Notes
|
||||
|
||||
- VirusTotal detection increased with the new patching method.
|
||||
|
||||
## [0.0.1] - 2025-01-04
|
||||
|
||||
### Changes
|
||||
|
||||
- Basic ElectronJS wrapper over the original script.
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
# Contributing to WandEnhancer
|
||||
|
||||
Thank you for your interest in the WandEnhancer project! This document provides guidelines for contributing to the project.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Development Environment Setup](#development-environment-setup)
|
||||
- [Bug Reports](#bug-reports)
|
||||
- [Feature Suggestions](#feature-suggestions)
|
||||
- [Creating a Pull Request](#creating-a-pull-request)
|
||||
- [Release Process](#release-process)
|
||||
- [Code Style](#code-style)
|
||||
- [Testing](#testing)
|
||||
- [License](#license)
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
By participating in this project, you commit to maintaining respectful interactions with all community members. Any form of insults, harassment, or other unacceptable behavior will not be tolerated.
|
||||
|
||||
## Project Structure
|
||||
|
||||
The project consists of the following main components:
|
||||
|
||||
- **WandEnhancer** - Main project containing the enhancement logic and user interface
|
||||
- **AsarSharp** - Library for working with ASAR archives (used for unpacking and modifying WeMod files)
|
||||
- **Core** - Core of the enhancement flow, including static and dynamic modifications
|
||||
- **Models** - Data models used in the project
|
||||
- **View** - User interface components
|
||||
|
||||
## Development Environment Setup
|
||||
|
||||
1. Clone the repository:
|
||||
```
|
||||
git clone https://github.com/k1tbyte/Wand-Enhancer.git
|
||||
```
|
||||
|
||||
2. Open the solution `Wand-Enhancer.sln` in Visual Studio or JetBrains Rider.
|
||||
|
||||
3. Restore NuGet packages.
|
||||
|
||||
4. Build the project.
|
||||
|
||||
## Bug Reports
|
||||
|
||||
If you've found a bug, please create an Issue with a detailed description:
|
||||
|
||||
- WandEnhancer version
|
||||
- WeMod version where the problem occurred
|
||||
- Detailed steps to reproduce the bug
|
||||
- Expected and actual behavior
|
||||
- Screenshots or error logs (if available)
|
||||
|
||||
## Feature Suggestions
|
||||
|
||||
Suggestions for new features or improvements are welcome! Create an Issue describing your idea, explaining:
|
||||
|
||||
- What problem the proposed improvement solves
|
||||
- How you envision implementing this feature
|
||||
- Potential alternatives you've considered
|
||||
|
||||
## Creating a Pull Request
|
||||
|
||||
1. Fork the repository.
|
||||
2. Create a branch with a descriptive name:
|
||||
```
|
||||
git checkout -b feature/feature-name
|
||||
```
|
||||
or
|
||||
```
|
||||
git checkout -b fix/fix-name
|
||||
```
|
||||
|
||||
3. Make the necessary changes and commit with clear, descriptive messages.
|
||||
|
||||
4. Ensure your code follows the project's style.
|
||||
|
||||
5. Push the branch to your fork:
|
||||
```
|
||||
git push origin your-branch-name
|
||||
```
|
||||
|
||||
6. Create a Pull Request to the main repository.
|
||||
|
||||
7. In the Pull Request description, explain the changes made and why they're necessary.
|
||||
|
||||
## Release Process
|
||||
|
||||
1. Update `WandEnhancer/Properties/AssemblyInfo.cs`.
|
||||
2. Add a new top section with the same version to `CHANGELOG.md`.
|
||||
3. Configure local hooks once:
|
||||
```
|
||||
git config core.hooksPath .githooks
|
||||
```
|
||||
4. Commit and push the version/changelog changes.
|
||||
5. Create and push a tag matching the same version exactly, for example:
|
||||
```
|
||||
git tag 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 a notes-only release automatically. Official releases do not attach compiled binaries.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Use C# naming conventions:
|
||||
- PascalCase for class, method, and property names
|
||||
- camelCase for local variables and parameters
|
||||
- _camelCase for private fields
|
||||
|
||||
- Add comments for complex code sections or patching methods
|
||||
|
||||
- Follow SOLID and DRY principles
|
||||
|
||||
## Testing
|
||||
|
||||
Before submitting a Pull Request, ensure that:
|
||||
|
||||
1. Your code compiles without errors
|
||||
2. You've manually tested the functionality
|
||||
3. The patch works with the current version of WeMod
|
||||
4. Changes don't break existing functionality
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the [Apache License 2.0](LICENSE.md).
|
||||
|
||||
---
|
||||
|
||||
Thank you for contributing to the WandEnhancer project!
|
||||
@@ -1,48 +1,162 @@
|
||||
<div align="center">
|
||||
<h1>WeMod Patcher</h1>
|
||||
|
||||

|
||||
|
||||
# WandEnhancer
|
||||
|
||||
[](https://gitlab.com/kitbyte/wand-enhancer)
|
||||
|
||||
</div>
|
||||
|
||||
<h4>WeMod patcher allows you to get some WeMod Pro features absolutely free. This script patches WeMod, thereby removing the daily usage limit (2h), etc.</h4>
|
||||
<h4>An open-source interoperability tool designed to extend local client-side configurations and improve the UX of the Wand application.</h4>
|
||||
|
||||
## 👾 No malware? Is it safe for me?
|
||||
**🚨 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. 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.**
|
||||
|
||||
Yes, this is safe. This script is open-source, and you can check the code yourself. I have no intention of harming you or your computer. The script does absolutely nothing with your computer and does not require Internet access. It only affects WeMod files.
|
||||
## 👾 What does it access?
|
||||
|
||||
## 💻 Does this script only work with older versions of WeMod like other unlockers?
|
||||
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.
|
||||
|
||||
With this patch you will be able to use the latest version together with Pro.
|
||||
## 💫 What features are improved?
|
||||
|
||||
## 💫 What features will be available?
|
||||
✅ Local environment configuration management <br/>
|
||||
✅ Automated compatibility adjustments for new client versions <br/>
|
||||
✅ Advanced layout and theme customization (Client-side only) <br/>
|
||||
✅ AI Features <br/>
|
||||
✅ Remote web panel (Remote Connect on mobile) <br/>
|
||||
|
||||
✅ Unlimited usage time <br/>
|
||||
✅ No ads <br/>
|
||||
✅ AI Game guides <br/>
|
||||
✅ Saving mods <br/>
|
||||
❌ Connect phone <br/>
|
||||
❌ Hotkeys (hotkey functionality breaks after patch for unknown reason)
|
||||
## 🌐 Remote Web Panel
|
||||
WandEnhancer includes a built-in **Remote Web Panel** allowing you to control app features directly from your phone.
|
||||
|
||||
### Quick Start:
|
||||
1. Ensure both your PC and phone are on the **same Wi-Fi network**.
|
||||
2. Hover over the **Connect** button in the top bar of WandEnhancer.
|
||||
3. Scan the displayed **QR code** with your phone's camera.
|
||||
|
||||
### 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.
|
||||
- **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?
|
||||
|
||||
1. Go to [Releases](https://github.com/k1tbyte/Wemod-Patcher/releases) page.
|
||||
2. Download latest version
|
||||
3. Run and click the patch
|
||||
This repository does not publish official compiled binaries. Build your own executable from your own fork using GitHub Actions.
|
||||
|
||||
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
|
||||
|
||||
Building from source on Windows requires a local development environment.
|
||||
|
||||
### Requirements
|
||||
|
||||
- `Node.js` and `pnpm`
|
||||
- `Visual Studio 2022` or `Build Tools for Visual Studio 2022` with `MSBuild`
|
||||
- .NET Framework 4.8 desktop build tools / targeting pack
|
||||
|
||||
### Build steps
|
||||
|
||||
1. Clone this repository.
|
||||
2. Install the requirements above and make sure `pnpm` and `MSBuild` are available.
|
||||
3. Run `build.cmd` from Command Prompt or PowerShell.
|
||||
|
||||
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
|
||||
|
||||
- I applied the patch but when I inject I get stuck on 'Loading mods...'.
|
||||
- Just close WeMod and try again
|
||||
- During the game, some hacks are enabled without my input
|
||||
- This is a bug after the patch, you have to turn off hotkeys in WeMod settings
|
||||
- Why is the patch executable file size so large? It seems to me that you want to harm my system.
|
||||
- The thing is that the application is written in Electron, so it also puts chromium, nodejs and some libraries in the exe. Maybe Electron is a temporary solution and in the future I will consider another option
|
||||
- **Why is there no `.exe` in GitHub Releases?**
|
||||
- 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?**
|
||||
- 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
|
||||

|
||||
<div align='center'>
|
||||
|
||||

|
||||
</div>
|
||||
|
||||
|
||||
## 📜 License
|
||||
This project is licensed under the Apache-2.0 - see the [LICENSE](LICENSE.txt) file for details.
|
||||
This project is licensed under the Apache-2.0 - see the [LICENSE](LICENSE.md) file for details.
|
||||
|
||||
|
||||
## ❤️ Support
|
||||
|
||||
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)
|
||||
|
||||
|
||||
---
|
||||
|
||||
> **Legal Disclaimer:**
|
||||
> 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.
|
||||
|
||||
---
|
||||
## ❤️ Support
|
||||
[](https://ko-fi.com/kitbyte)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WandEnhancer", "WandEnhancer\WandEnhancer.csproj", "{106D3E44-ECBB-4EF3-84B2-5FC6BCF77727}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AsarSharp", "AsarSharp\AsarSharp.csproj", "{BEAA604A-402A-4387-8903-A53FC913A26E}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{106D3E44-ECBB-4EF3-84B2-5FC6BCF77727}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{106D3E44-ECBB-4EF3-84B2-5FC6BCF77727}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{106D3E44-ECBB-4EF3-84B2-5FC6BCF77727}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{106D3E44-ECBB-4EF3-84B2-5FC6BCF77727}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BEAA604A-402A-4387-8903-A53FC913A26E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BEAA604A-402A-4387-8903-A53FC913A26E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BEAA604A-402A-4387-8903-A53FC913A26E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BEAA604A-402A-4387-8903-A53FC913A26E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<configuration>
|
||||
<startup>
|
||||
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
|
||||
</startup>
|
||||
</configuration>
|
||||
@@ -0,0 +1,20 @@
|
||||
<Application x:Class="WandEnhancer.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:WandEnhancer"
|
||||
xmlns:converters="clr-namespace:WandEnhancer.Converters">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="Locale/lang.en-US.xaml"/>
|
||||
<ResourceDictionary Source="Style/ColorScheme.xaml"/>
|
||||
<ResourceDictionary Source="Style/Styles.xaml"/>
|
||||
<ResourceDictionary Source="Style/Icons.xaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
|
||||
<FontFamily x:Key="Inter" >pack://application:,,,/Style/#Inter 18pt 18pt</FontFamily>
|
||||
|
||||
<converters:ToVisibilityConverter x:Key="ToVisibilityConverter"/>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using WandEnhancer.Core;
|
||||
using WandEnhancer.Core.Services;
|
||||
using WandEnhancer.View.MainWindow;
|
||||
using MessageBox = System.Windows.Forms.MessageBox;
|
||||
|
||||
namespace WandEnhancer
|
||||
{
|
||||
/// <summary>
|
||||
/// Interaction logic for App.xaml
|
||||
/// </summary>
|
||||
public partial class App
|
||||
{
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
LocalizationManager.Initialize();
|
||||
this.MainWindow.Show();
|
||||
}
|
||||
|
||||
public new static void Shutdown()
|
||||
{
|
||||
Current.Dispatcher.Invoke(() => Current.Shutdown());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
|
||||
namespace WandEnhancer
|
||||
{
|
||||
public static class Constants
|
||||
{
|
||||
public const string RepoName = "Wand-Enhancer";
|
||||
public const string Owner = "k1tbyte";
|
||||
public static readonly string RepositoryUrl = $"https://github.com/{Owner}/{RepoName}";
|
||||
public static readonly Version Version;
|
||||
public static readonly string[] WeModBrandNames = { "Wand", "WeMod" };
|
||||
public const string AppSettingsFileName = "appsettings.json";
|
||||
public const string AutoPatchConfigFileName = "enhancer.json";
|
||||
|
||||
static Constants()
|
||||
{
|
||||
Version = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace WandEnhancer.Converters
|
||||
{
|
||||
public abstract class BaseBooleanConverter<T> : IValueConverter
|
||||
{
|
||||
protected BaseBooleanConverter(T trueValue, T falseValue)
|
||||
{
|
||||
True = trueValue;
|
||||
False = falseValue;
|
||||
}
|
||||
|
||||
protected T True { get; set; }
|
||||
protected T False { get; set; }
|
||||
|
||||
public virtual object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
return value is bool booleanValue && booleanValue ? True : False;
|
||||
}
|
||||
|
||||
public virtual object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
return value is T t && EqualityComparer<T>.Default.Equals(t, True);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace WandEnhancer.Converters
|
||||
{
|
||||
internal sealed class ToVisibilityConverter : BaseBooleanConverter<Visibility>
|
||||
{
|
||||
public ToVisibilityConverter() :
|
||||
base(Visibility.Visible, Visibility.Collapsed)
|
||||
{ }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using AsarSharp;
|
||||
using WandEnhancer.Models;
|
||||
using WandEnhancer.Utils;
|
||||
using WandEnhancer.View.MainWindow;
|
||||
|
||||
namespace WandEnhancer.Core
|
||||
{
|
||||
public class Enhancer
|
||||
{
|
||||
private const string ResourcesDirectoryName = "resources";
|
||||
private const string AppAsarFileName = "app.asar";
|
||||
private const string AppAsarUnpackedDirectoryName = "app.asar.unpacked";
|
||||
private const string AppAsarBackupFileName = "app.asar.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 WebPanelDistDirectoryName = "dist";
|
||||
private const string LocalCustomScriptsDirectoryName = "renderer-scripts";
|
||||
private const string RemotePanelDirectoryName = "remote-panel";
|
||||
private const string RemoteBridgeTargetFileName = "bridge.cjs";
|
||||
private const string RemoteRendererScriptsDirectoryName = "renderer-scripts";
|
||||
private const string EmbeddedRemotePanelDistPrefix = "remote-panel/dist/";
|
||||
private const string AppBundleFilePrefix = "app-";
|
||||
private const string AppBundleFileSuffix = ".bundle.js";
|
||||
private const string IndexBundleFileName = "index.js";
|
||||
private const string JavaScriptFileSearchPattern = "*.js";
|
||||
private const string DuplicateScriptSuffix = ".custom";
|
||||
private const int FirstDuplicateScriptIndex = 1;
|
||||
|
||||
private readonly WeModConfig _weModConfig;
|
||||
private readonly Action<string, ELogType> _logger;
|
||||
private readonly PatchConfig _config;
|
||||
private readonly JavaScriptPatchApplier _jsPatchApplier;
|
||||
private readonly string _asarPath;
|
||||
private readonly string _backupPath;
|
||||
private readonly string _unpackedPath;
|
||||
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)
|
||||
{
|
||||
_weModConfig = weModConfig;
|
||||
_logger = logger;
|
||||
_config = config;
|
||||
_jsPatchApplier = new JavaScriptPatchApplier(logger);
|
||||
|
||||
_asarPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarFileName);
|
||||
_unpackedPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarUnpackedDirectoryName);
|
||||
_backupPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarBackupFileName);
|
||||
_unpackedBackupPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarUnpackedBackupDirectoryName);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
var resources = Path.Combine(rootDirectory, ResourcesDirectoryName);
|
||||
return File.Exists(Path.Combine(resources, AppAsarBackupFileName))
|
||||
&& Directory.Exists(Path.Combine(resources, AppAsarUnpackedBackupDirectoryName));
|
||||
}
|
||||
|
||||
private void PatchAsar()
|
||||
{
|
||||
var items = Directory.EnumerateFiles(_unpackedPath, JavaScriptFileSearchPattern, SearchOption.TopDirectoryOnly)
|
||||
.Where(IsCandidateBundleFile)
|
||||
.ToList();
|
||||
|
||||
if (!items.Any())
|
||||
{
|
||||
throw new Exception("[ENHANCER] No app bundle found");
|
||||
}
|
||||
|
||||
var remainingPatches = new HashSet<EPatchType>(_config.PatchTypes);
|
||||
var enhancerConfig = EnhancerConfig.GetInstance();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (remainingPatches.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!CouldFileContainRemainingPatch(item, remainingPatches, enhancerConfig))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string data = File.ReadAllText(item);
|
||||
bool fileChanged = false;
|
||||
|
||||
foreach (var entry in remainingPatches.ToList())
|
||||
{
|
||||
var entries = enhancerConfig[entry];
|
||||
foreach (var patchEntry in entries)
|
||||
{
|
||||
bool patchApplied;
|
||||
data = _jsPatchApplier.Apply(item, data, patchEntry, entry, out 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))
|
||||
{
|
||||
remainingPatches.Remove(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (fileChanged)
|
||||
{
|
||||
File.WriteAllText(item, data);
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
{
|
||||
_logger($"[ENHANCER] [{skipped.Label}] Capability not present, skipping", ELogType.Info);
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCandidateBundleFile(string filePath)
|
||||
{
|
||||
string fileName = Path.GetFileName(filePath);
|
||||
return fileName.Equals(IndexBundleFileName, StringComparison.OrdinalIgnoreCase)
|
||||
|| (fileName.StartsWith(AppBundleFilePrefix, StringComparison.OrdinalIgnoreCase)
|
||||
&& fileName.EndsWith(AppBundleFileSuffix, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool CouldFileContainRemainingPatch(string filePath, IEnumerable<EPatchType> remainingPatches, Dictionary<EPatchType, EnhancerConfig.PatchEntry[]> enhancerConfig)
|
||||
{
|
||||
return remainingPatches
|
||||
.SelectMany(patchType => enhancerConfig[patchType])
|
||||
.Any(patchEntry => !patchEntry.Applied && JavaScriptPatchApplier.CanSearchFile(filePath, patchEntry));
|
||||
}
|
||||
|
||||
private static string FindWorkspacePath(params string[] segments)
|
||||
{
|
||||
string current = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
|
||||
while (!string.IsNullOrEmpty(current))
|
||||
{
|
||||
string candidate = Path.Combine(new[] { current }.Concat(segments).ToArray());
|
||||
if (Directory.Exists(candidate) || File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = Directory.GetParent(current)?.FullName;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException($"Required workspace artifact not found: {Path.Combine(segments)}");
|
||||
}
|
||||
|
||||
private static int CopyJavaScriptFiles(string sourceDir, string destinationDir)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sourceDir) || !Directory.Exists(sourceDir))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return CopySelectedJavaScriptFiles(
|
||||
Directory.GetFiles(sourceDir, JavaScriptFileSearchPattern, SearchOption.TopDirectoryOnly),
|
||||
destinationDir);
|
||||
}
|
||||
|
||||
private static string GetAvailableScriptPath(string destinationDir, string fileName)
|
||||
{
|
||||
string destinationPath = Path.Combine(destinationDir, fileName);
|
||||
if (!File.Exists(destinationPath))
|
||||
{
|
||||
return destinationPath;
|
||||
}
|
||||
|
||||
string name = Path.GetFileNameWithoutExtension(fileName);
|
||||
string extension = Path.GetExtension(fileName);
|
||||
for (int index = FirstDuplicateScriptIndex; ; index++)
|
||||
{
|
||||
destinationPath = Path.Combine(destinationDir, $"{name}{DuplicateScriptSuffix}{index}{extension}");
|
||||
if (!File.Exists(destinationPath))
|
||||
{
|
||||
return destinationPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int CopyEmbeddedDirectory(string resourcePrefix, string destinationDir)
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var resourceNames = assembly.GetManifestResourceNames()
|
||||
.Where(name => name.StartsWith(resourcePrefix, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
|
||||
if (resourceNames.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
|
||||
foreach (var resourceName in resourceNames)
|
||||
{
|
||||
var relativePath = resourceName.Substring(resourcePrefix.Length)
|
||||
.Replace('/', Path.DirectorySeparatorChar)
|
||||
.Replace('\\', Path.DirectorySeparatorChar);
|
||||
var destinationPath = Path.Combine(destinationDir, relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath) ?? destinationDir);
|
||||
|
||||
using (var resource = assembly.GetManifestResourceStream(resourceName))
|
||||
{
|
||||
if (resource == null)
|
||||
{
|
||||
throw new FileNotFoundException($"Embedded resource not found: {resourceName}");
|
||||
}
|
||||
|
||||
using (var output = File.Create(destinationPath))
|
||||
{
|
||||
resource.CopyTo(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resourceNames.Count;
|
||||
}
|
||||
|
||||
private static string FindLocalCustomScriptsPath()
|
||||
{
|
||||
string executableDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
|
||||
if (string.IsNullOrEmpty(executableDirectory))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string localScripts = Path.Combine(executableDirectory, LocalCustomScriptsDirectoryName);
|
||||
return Directory.Exists(localScripts) ? localScripts : null;
|
||||
}
|
||||
|
||||
private static int CopySelectedJavaScriptFiles(IEnumerable<string> files, string destinationDir)
|
||||
{
|
||||
if (files == null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
|
||||
int copied = 0;
|
||||
foreach (var file in files.Where(WeModInstalls.IsJavaScriptFile).Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Copy(file, GetAvailableScriptPath(destinationDir, Path.GetFileName(file)));
|
||||
copied++;
|
||||
}
|
||||
|
||||
return copied;
|
||||
}
|
||||
|
||||
private void InjectRemotePanelFiles()
|
||||
{
|
||||
if (!_config.PatchTypes.Contains(EPatchType.RemoteWebPanelPreview))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string localCustomScriptsRoot = FindLocalCustomScriptsPath();
|
||||
string targetRoot = Path.Combine(_unpackedPath, RemotePanelDirectoryName);
|
||||
string targetScriptsRoot = Path.Combine(targetRoot, RemoteRendererScriptsDirectoryName);
|
||||
string targetBridgePath = Path.Combine(targetRoot, RemoteBridgeTargetFileName);
|
||||
|
||||
if (Directory.Exists(targetRoot))
|
||||
{
|
||||
Directory.Delete(targetRoot, true);
|
||||
}
|
||||
|
||||
if (CopyEmbeddedDirectory(EmbeddedRemotePanelDistPrefix, targetRoot) == 0)
|
||||
{
|
||||
AsarSharp.Utils.Extensions.CopyDirectory(FindWorkspacePath(WebPanelDirectoryName, WebPanelDistDirectoryName), targetRoot);
|
||||
}
|
||||
|
||||
if (!File.Exists(targetBridgePath))
|
||||
{
|
||||
throw new FileNotFoundException("[ENHANCER] Remote bridge artifact is missing. Run `cd web-panel && pnpm run build` before patching.", targetBridgePath);
|
||||
}
|
||||
|
||||
int defaultScriptCount = Directory.Exists(targetScriptsRoot)
|
||||
? Directory.GetFiles(targetScriptsRoot, JavaScriptFileSearchPattern, SearchOption.TopDirectoryOnly).Length
|
||||
: 0;
|
||||
if (defaultScriptCount == 0)
|
||||
{
|
||||
throw new FileNotFoundException("[ENHANCER] Remote renderer script artifacts are missing. Run `cd web-panel && pnpm run build` before patching.", targetScriptsRoot);
|
||||
}
|
||||
|
||||
int selectedScriptCount = CopySelectedJavaScriptFiles(_config.CustomScriptPaths, targetScriptsRoot);
|
||||
int localScriptCount = CopyJavaScriptFiles(localCustomScriptsRoot, targetScriptsRoot);
|
||||
|
||||
_logger($"[ENHANCER] Injected remote panel assets and renderer scripts into app.asar (default: {defaultScriptCount}, selected: {selectedScriptCount}, local: {localScriptCount})", ELogType.Info);
|
||||
}
|
||||
|
||||
private string SquirrelRoot
|
||||
{
|
||||
get
|
||||
{
|
||||
string root = Directory.GetParent(_weModConfig.RootDirectory)?.FullName;
|
||||
if (string.IsNullOrEmpty(root))
|
||||
{
|
||||
throw new Exception("[ENHANCER] Cannot determine Squirrel root directory");
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (File.Exists(stubPath) && !File.Exists(stubBackup))
|
||||
{
|
||||
File.Copy(stubPath, stubBackup);
|
||||
}
|
||||
|
||||
File.Copy(self, stubPath, true);
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
||||
public void Patch()
|
||||
{
|
||||
ProcessTerminator.TryKillProcess(_weModConfig.BrandName);
|
||||
if (!File.Exists(_backupPath))
|
||||
{
|
||||
_logger("[ENHANCER] Creating backup...", ELogType.Info);
|
||||
File.Copy(_asarPath, _backupPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger("[ENHANCER] Backup found, restoring pristine app.asar before patching...", ELogType.Info);
|
||||
File.Copy(_backupPath, _asarPath, true);
|
||||
}
|
||||
|
||||
if (!Directory.Exists(_unpackedBackupPath) && Directory.Exists(_unpackedPath))
|
||||
{
|
||||
_logger("[ENHANCER] Creating backup of app.asar.unpacked...", ELogType.Info);
|
||||
AsarSharp.Utils.Extensions.CopyDirectory(_unpackedPath, _unpackedBackupPath);
|
||||
}
|
||||
else if (Directory.Exists(_unpackedBackupPath))
|
||||
{
|
||||
_logger("[ENHANCER] Restoring pristine app.asar.unpacked before patching...", ELogType.Info);
|
||||
if (Directory.Exists(_unpackedPath))
|
||||
{
|
||||
Directory.Delete(_unpackedPath, true);
|
||||
}
|
||||
|
||||
AsarSharp.Utils.Extensions.CopyDirectory(_unpackedBackupPath, _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.");
|
||||
}
|
||||
|
||||
if (!File.Exists(_asarPath))
|
||||
{
|
||||
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
|
||||
{
|
||||
_logger("[ENHANCER] Extracting app.asar...", ELogType.Info);
|
||||
AsarExtractor.ExtractAll(_asarPath, _unpackedPath);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new Exception($"[ENHANCER] Failed to unpack app.asar: {e.Message}", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void PackSources()
|
||||
{
|
||||
try
|
||||
{
|
||||
new AsarCreator(_unpackedPath, _asarPath, new CreateOptions
|
||||
{
|
||||
Unpack = new Regex(@"^static\\unpacked.*$")
|
||||
}).CreatePackageWithOptions();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new Exception($"[ENHANCER] Failed to pack app.asar: {e.Message}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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))
|
||||
{
|
||||
File.Copy(_backupPath, _asarPath, true);
|
||||
}
|
||||
|
||||
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);
|
||||
File.Copy(_backupPath, _asarPath, true);
|
||||
|
||||
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))
|
||||
{
|
||||
File.Copy(stubBackup, stubPath, true);
|
||||
File.Delete(stubBackup);
|
||||
}
|
||||
|
||||
string autoPatchConfig = Path.Combine(squirrelRoot, Constants.AutoPatchConfigFileName);
|
||||
if (File.Exists(autoPatchConfig))
|
||||
{
|
||||
File.Delete(autoPatchConfig);
|
||||
}
|
||||
|
||||
File.Delete(_backupPath);
|
||||
Directory.Delete(_unpackedBackupPath, true);
|
||||
_logger("[ENHANCER] Backup restored successfully.", ELogType.Success);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using WandEnhancer.Core.Js;
|
||||
using WandEnhancer.Models;
|
||||
|
||||
namespace WandEnhancer.Core
|
||||
{
|
||||
/// <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
|
||||
{
|
||||
/// <summary>Locates the edits a patch must make, or null when the anchor is absent from this file.</summary>
|
||||
public delegate JsEdit[] PatchLocator(JsCursor js);
|
||||
|
||||
public sealed class PatchEntry
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public PatchLocator Locate { get; set; }
|
||||
public string[] CandidateFileNames { get; set; }
|
||||
public string[] SearchHints { 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()
|
||||
{
|
||||
return new Dictionary<EPatchType, PatchEntry[]>
|
||||
{
|
||||
{
|
||||
EPatchType.ActivatePro,
|
||||
new[]
|
||||
{
|
||||
new PatchEntry
|
||||
{
|
||||
Name = "getUserAccount",
|
||||
SearchHints = new[] { "getUserAccount(" },
|
||||
Locate = js => ForceProSubscription(js, "getUserAccount")
|
||||
},
|
||||
new PatchEntry
|
||||
{
|
||||
Name = "setAccountWandBrandExperience",
|
||||
SearchHints = new[] { "setAccountWandBrandExperience(" },
|
||||
CapabilityHints = new[] { "/v3/account/brand_experience_wand" },
|
||||
Locate = js => ForceProSubscription(js, "setAccountWandBrandExperience")
|
||||
},
|
||||
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")))
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
EPatchType.DisableUpdates,
|
||||
new[]
|
||||
{
|
||||
new PatchEntry
|
||||
{
|
||||
Name = "disableUpdateCheck",
|
||||
CandidateFileNames = new[] { "index.js" },
|
||||
SearchHints = new[] { "ACTION_CHECK_FOR_UPDATE" },
|
||||
Locate = LocateUpdateHandler
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
EPatchType.DevToolsOnF12,
|
||||
new[]
|
||||
{
|
||||
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",
|
||||
CandidateFileNames = new[] { "index.js" },
|
||||
SearchHints = new[] { "whenReady().then(" },
|
||||
Locate = LocateDevToolsHook
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
EPatchType.RemoteWebPanelPreview,
|
||||
new[]
|
||||
{
|
||||
new PatchEntry
|
||||
{
|
||||
Name = "remoteBridgeMainBoot",
|
||||
CandidateFileNames = new[] { "index.js" },
|
||||
SearchHints = new[] { "whenReady().then(run)" },
|
||||
Locate = LocateBridgeBoot
|
||||
},
|
||||
new PatchEntry
|
||||
{
|
||||
Name = "remoteBridgeReset",
|
||||
SearchHints = new[] { "client-state" },
|
||||
Locate = LocateBridgeReset
|
||||
},
|
||||
new PatchEntry
|
||||
{
|
||||
Name = "remoteBridgeSyncSnapshot",
|
||||
SearchHints = new[] { "client-state" },
|
||||
Locate = LocateBridgeSync
|
||||
},
|
||||
new PatchEntry
|
||||
{
|
||||
Name = "remoteBridgeBindHandler",
|
||||
SearchHints = new[] { "setCurrentTrainer(" },
|
||||
Locate = LocateBridgeBindHandler
|
||||
},
|
||||
new PatchEntry
|
||||
{
|
||||
Name = "remoteBridgeValueDelta",
|
||||
SearchHints = new[] { "client-value-changed" },
|
||||
Locate = LocateBridgeValueDelta
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <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,291 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace WandEnhancer.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Launches Electron under a startup-only debugger and clears the ASAR integrity
|
||||
/// fuse in every process it spawns (main, renderer, gpu, utility). Electron respawns
|
||||
/// children from its own on-disk exe where the fuse is still enabled, so patching only
|
||||
/// the main process leaves renderers crashing with -36861. The debugger stops each child
|
||||
/// at creation, so there is no race, and memory patching is immune to Chromium's sandbox
|
||||
/// DLL-signature mitigations. We detach once the window is up - long before any game
|
||||
/// launch - so game anti-debug/DRM is never exposed to a debugger.
|
||||
/// </summary>
|
||||
internal static class FuseLauncher
|
||||
{
|
||||
private const int FuseAsarIntegrity = 4;
|
||||
private const byte FuseStateRemoved = (byte)'r';
|
||||
private const int SentinelLength = 32;
|
||||
private const int ScanChunkSize = 0x100000;
|
||||
|
||||
// Electron's fuse wire follows the sentinel: [version][fuseCount][state per fuse].
|
||||
private const int FuseWireVersionOffset = 0;
|
||||
private const int FuseWireCountOffset = 1;
|
||||
private const int FuseWireStatesOffset = 2;
|
||||
private const byte FuseWireSupportedVersion = 1;
|
||||
private const int FuseWireMinCount = 5;
|
||||
// Longest tail read past a sentinel hit: version + count + the fuse we edit.
|
||||
private const int FuseWireTailBytes = FuseWireStatesOffset + FuseAsarIntegrity + 1;
|
||||
|
||||
// x64 DEBUG_EVENT: dwDebugEventCode, dwProcessId, dwThreadId, 4 bytes padding,
|
||||
// then the union. CREATE_PROCESS_DEBUG_INFO starts with hFile, hProcess, hThread,
|
||||
// lpBaseOfImage; EXCEPTION_DEBUG_INFO starts with the exception code.
|
||||
private const int DebugEventSize = 192;
|
||||
private const int OffsetDebugEventCode = 0;
|
||||
private const int OffsetProcessId = 4;
|
||||
private const int OffsetThreadId = 8;
|
||||
private const int OffsetUnion = 16;
|
||||
private const int OffsetExceptionCode = OffsetUnion;
|
||||
private const int OffsetCreateProcessFile = OffsetUnion;
|
||||
private const int OffsetCreateProcessHandle = OffsetUnion + 8;
|
||||
private const int OffsetCreateProcessImageBase = OffsetUnion + 24;
|
||||
|
||||
// Detach after the startup process burst settles (all children spawned and patched),
|
||||
// capped hard so we never linger into gameplay.
|
||||
private const long MinDebugMs = 3000;
|
||||
private const long QuietMs = 1500;
|
||||
private const long MaxDebugMs = 9000;
|
||||
|
||||
private static readonly byte[] Sentinel =
|
||||
Encoding.ASCII.GetBytes("dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX");
|
||||
|
||||
public static bool Launch(string exePath, string args, Action<string> log = null)
|
||||
{
|
||||
var si = new STARTUPINFO { cb = Marshal.SizeOf<STARTUPINFO>() };
|
||||
var cmdLine = new StringBuilder(
|
||||
string.IsNullOrEmpty(args) ? $"\"{exePath}\"" : $"\"{exePath}\" {args}");
|
||||
|
||||
if (!CreateProcessW(null, cmdLine, IntPtr.Zero, IntPtr.Zero,
|
||||
false, DEBUG_PROCESS, IntPtr.Zero,
|
||||
Path.GetDirectoryName(exePath), ref si, out var pi))
|
||||
{
|
||||
log?.Invoke($"Could not start Wand under the fuse patcher (win32 error {Marshal.GetLastWin32Error()}).");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Debugged processes must survive after we detach and exit.
|
||||
DebugSetProcessKillOnExit(false);
|
||||
CloseHandle(pi.hThread);
|
||||
CloseHandle(pi.hProcess);
|
||||
|
||||
DrivePatchingDebugLoop(pi.dwProcessId, log);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void DrivePatchingDebugLoop(int mainPid, Action<string> log)
|
||||
{
|
||||
var pids = new List<int>();
|
||||
var brokeIn = new HashSet<int>();
|
||||
var evt = new byte[DebugEventSize];
|
||||
// Stopwatch, not TickCount: TickCount is a 32-bit millisecond counter that wraps
|
||||
// every ~25 days, and a negative elapsed would keep the debugger attached forever.
|
||||
var clock = Stopwatch.StartNew();
|
||||
long lastCreate = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
long now = clock.ElapsedMilliseconds;
|
||||
|
||||
if (!WaitForDebugEvent(evt, 200))
|
||||
{
|
||||
if (ShouldDetach(now, now - lastCreate)) break;
|
||||
continue;
|
||||
}
|
||||
|
||||
int code = BitConverter.ToInt32(evt, OffsetDebugEventCode);
|
||||
int pid = BitConverter.ToInt32(evt, OffsetProcessId);
|
||||
int tid = BitConverter.ToInt32(evt, OffsetThreadId);
|
||||
uint status = DBG_CONTINUE;
|
||||
|
||||
switch (code)
|
||||
{
|
||||
case CREATE_PROCESS_DEBUG_EVENT:
|
||||
var hFile = (IntPtr)BitConverter.ToInt64(evt, OffsetCreateProcessFile);
|
||||
var hProc = (IntPtr)BitConverter.ToInt64(evt, OffsetCreateProcessHandle);
|
||||
var baseImg = (IntPtr)BitConverter.ToInt64(evt, OffsetCreateProcessImageBase);
|
||||
if (!pids.Contains(pid)) pids.Add(pid);
|
||||
if (!PatchFuse(hProc, baseImg))
|
||||
log?.Invoke($"Fuse not cleared in pid {pid}; renderers may fail with -36861.");
|
||||
// The debugger owns the image handle the kernel hands over with this event.
|
||||
if (hFile != IntPtr.Zero) CloseHandle(hFile);
|
||||
lastCreate = now;
|
||||
break;
|
||||
|
||||
case EXCEPTION_DEBUG_EVENT:
|
||||
int exCode = BitConverter.ToInt32(evt, OffsetExceptionCode);
|
||||
// Pass the one-shot startup breakpoint, let the app own the rest.
|
||||
status = (exCode == EXCEPTION_BREAKPOINT && brokeIn.Add(pid))
|
||||
? DBG_CONTINUE
|
||||
: DBG_EXCEPTION_NOT_HANDLED;
|
||||
break;
|
||||
|
||||
case EXIT_PROCESS_DEBUG_EVENT:
|
||||
pids.Remove(pid);
|
||||
if (pid == mainPid)
|
||||
{
|
||||
ContinueDebugEvent(pid, tid, status);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
ContinueDebugEvent(pid, tid, status);
|
||||
|
||||
now = clock.ElapsedMilliseconds;
|
||||
if (ShouldDetach(now, now - lastCreate))
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var pid in pids)
|
||||
DebugActiveProcessStop(pid);
|
||||
}
|
||||
|
||||
private static bool ShouldDetach(long elapsed, long sinceLastCreate)
|
||||
{
|
||||
if (elapsed > MaxDebugMs) return true;
|
||||
return elapsed > MinDebugMs && sinceLastCreate > QuietMs;
|
||||
}
|
||||
|
||||
private static bool PatchFuse(IntPtr hProcess, IntPtr imageBase)
|
||||
{
|
||||
if (imageBase == IntPtr.Zero) return false;
|
||||
|
||||
int sizeOfImage = ReadSizeOfImage(hProcess, imageBase);
|
||||
if (sizeOfImage == 0) return false;
|
||||
|
||||
const int overlap = 64;
|
||||
var buffer = new byte[ScanChunkSize + overlap];
|
||||
|
||||
for (long offset = 0; offset < sizeOfImage; offset += ScanChunkSize)
|
||||
{
|
||||
int toRead = (int)Math.Min(ScanChunkSize + overlap, sizeOfImage - offset);
|
||||
if (toRead < SentinelLength + FuseWireTailBytes) break;
|
||||
|
||||
var addr = new IntPtr(imageBase.ToInt64() + offset);
|
||||
if (!ReadProcessMemory(hProcess, addr, buffer, toRead, out int bytesRead))
|
||||
continue;
|
||||
if (bytesRead < SentinelLength + FuseWireTailBytes) continue;
|
||||
|
||||
int limit = bytesRead - SentinelLength - FuseWireTailBytes;
|
||||
// Byte-by-byte: the linker is free to place the sentinel at any alignment,
|
||||
// and a miss means every renderer dies with -36861.
|
||||
for (int i = 0; i <= limit; i++)
|
||||
{
|
||||
if (buffer[i] != Sentinel[0] || !MatchesSentinel(buffer, i)) continue;
|
||||
|
||||
int wireOffset = i + SentinelLength;
|
||||
if (buffer[wireOffset + FuseWireVersionOffset] != FuseWireSupportedVersion ||
|
||||
buffer[wireOffset + FuseWireCountOffset] < FuseWireMinCount) continue;
|
||||
|
||||
int fusePos = wireOffset + FuseWireStatesOffset + FuseAsarIntegrity;
|
||||
if (buffer[fusePos] == FuseStateRemoved) return true;
|
||||
|
||||
var target = new IntPtr(imageBase.ToInt64() + offset + fusePos);
|
||||
VirtualProtectEx(hProcess, target, (UIntPtr)1, PAGE_READWRITE, out uint oldProt);
|
||||
bool ok = WriteProcessMemory(hProcess, target, new[] { FuseStateRemoved }, 1, out _);
|
||||
VirtualProtectEx(hProcess, target, (UIntPtr)1, oldProt, out _);
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool MatchesSentinel(byte[] buffer, int offset)
|
||||
{
|
||||
for (int j = 1; j < SentinelLength; j++)
|
||||
if (buffer[offset + j] != Sentinel[j]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int ReadSizeOfImage(IntPtr hProcess, IntPtr imageBase)
|
||||
{
|
||||
var dosHeader = new byte[64];
|
||||
if (!ReadProcessMemory(hProcess, imageBase, dosHeader, 64, out _))
|
||||
return 0;
|
||||
|
||||
int peOffset = BitConverter.ToInt32(dosHeader, 0x3C);
|
||||
var buf = new byte[4];
|
||||
// SizeOfImage sits at optional-header offset 56 (PE signature + COFF header = 24).
|
||||
var addr = new IntPtr(imageBase.ToInt64() + peOffset + 80);
|
||||
if (!ReadProcessMemory(hProcess, addr, buf, 4, out _))
|
||||
return 0;
|
||||
|
||||
return BitConverter.ToInt32(buf, 0);
|
||||
}
|
||||
|
||||
#region P/Invoke
|
||||
|
||||
private const uint DEBUG_PROCESS = 0x1;
|
||||
private const uint PAGE_READWRITE = 0x04;
|
||||
private const uint DBG_CONTINUE = 0x00010002;
|
||||
private const uint DBG_EXCEPTION_NOT_HANDLED = 0x80010001;
|
||||
private const int EXCEPTION_DEBUG_EVENT = 1;
|
||||
private const int CREATE_PROCESS_DEBUG_EVENT = 3;
|
||||
private const int EXIT_PROCESS_DEBUG_EVENT = 5;
|
||||
private const int EXCEPTION_BREAKPOINT = unchecked((int)0x80000003);
|
||||
|
||||
[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;
|
||||
}
|
||||
|
||||
[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 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);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool WaitForDebugEvent(byte[] lpDebugEvent, int dwMilliseconds);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool ContinueDebugEvent(int dwProcessId, int dwThreadId, uint dwContinueStatus);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool DebugActiveProcessStop(int dwProcessId);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool DebugSetProcessKillOnExit(bool KillOnExit);
|
||||
|
||||
[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,165 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Windows;
|
||||
|
||||
namespace WandEnhancer.Core.Services
|
||||
{
|
||||
public static class LocalizationManager
|
||||
{
|
||||
public static readonly List<CultureInfo> SupportedLanguages = new List<CultureInfo>
|
||||
{
|
||||
new CultureInfo("en-US"),
|
||||
new CultureInfo("zh-CN"),
|
||||
new CultureInfo("de-DE"),
|
||||
new CultureInfo("fr-FR"),
|
||||
new CultureInfo("es-ES"),
|
||||
new CultureInfo("it-IT"),
|
||||
new CultureInfo("pt-BR"),
|
||||
new CultureInfo("pl-PL"),
|
||||
new CultureInfo("ru-RU"),
|
||||
new CultureInfo("uk-UA"),
|
||||
new CultureInfo("ja-JP"),
|
||||
new CultureInfo("tr-TR")
|
||||
};
|
||||
|
||||
private static CultureInfo _currentLanguage;
|
||||
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
|
||||
{
|
||||
get => _currentLanguage;
|
||||
set => SetLanguage(value);
|
||||
}
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
// Load English as the base fallback dictionary
|
||||
_englishBaseDictionary = new ResourceDictionary
|
||||
{
|
||||
Source = new Uri("Locale/lang.en-US.xaml", UriKind.Relative)
|
||||
};
|
||||
|
||||
// Try to load saved language preference
|
||||
var savedLanguage = SettingsManager.LoadSettings()?.Language;
|
||||
CultureInfo targetCulture = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(savedLanguage))
|
||||
{
|
||||
targetCulture = SupportedLanguages.FirstOrDefault(c => c.Name == savedLanguage);
|
||||
}
|
||||
|
||||
if (targetCulture == null)
|
||||
{
|
||||
// Fall back to system culture detection
|
||||
var systemCulture = Thread.CurrentThread.CurrentUICulture;
|
||||
targetCulture = SupportedLanguages.FirstOrDefault(c =>
|
||||
c.Name == systemCulture.Name ||
|
||||
c.TwoLetterISOLanguageName == systemCulture.TwoLetterISOLanguageName);
|
||||
}
|
||||
|
||||
SetLanguage(targetCulture ?? SupportedLanguages[0], saveSettings: false);
|
||||
}
|
||||
|
||||
private static void SetLanguage(CultureInfo culture, bool saveSettings = true)
|
||||
{
|
||||
if (culture == null)
|
||||
throw new ArgumentNullException(nameof(culture));
|
||||
|
||||
if (Equals(culture, _currentLanguage))
|
||||
return;
|
||||
|
||||
var supportedCulture = SupportedLanguages.FirstOrDefault(c => c.Name == culture.Name);
|
||||
if (supportedCulture == null)
|
||||
{
|
||||
supportedCulture = SupportedLanguages[0]; // Default to English
|
||||
}
|
||||
|
||||
_currentLanguage = supportedCulture;
|
||||
Thread.CurrentThread.CurrentUICulture = supportedCulture;
|
||||
|
||||
// Create the locale dictionary with English as base for fallback
|
||||
var localeDict = new ResourceDictionary();
|
||||
|
||||
// First, add English base dictionary for fallback
|
||||
if (_englishBaseDictionary != null && supportedCulture.Name != SupportedLanguages[0].Name)
|
||||
{
|
||||
foreach (var key in _englishBaseDictionary.Keys)
|
||||
{
|
||||
localeDict[key] = _englishBaseDictionary[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Then overlay with the selected language (will override English keys)
|
||||
var targetDict = new ResourceDictionary
|
||||
{
|
||||
Source = new Uri($"Locale/lang.{supportedCulture.Name}.xaml", UriKind.Relative)
|
||||
};
|
||||
|
||||
foreach (DictionaryEntry entry in targetDict)
|
||||
{
|
||||
localeDict[entry.Key] = targetDict[entry.Key];
|
||||
}
|
||||
|
||||
// Track the dictionary we injected: it is built by merging entries, so its Source is
|
||||
// null and a Source-based lookup never finds it - every switch used to append another.
|
||||
var merged = Application.Current.Resources.MergedDictionaries;
|
||||
if (_activeLocaleDictionary != null && merged.Contains(_activeLocaleDictionary))
|
||||
{
|
||||
merged[merged.IndexOf(_activeLocaleDictionary)] = localeDict;
|
||||
}
|
||||
else
|
||||
{
|
||||
merged.Add(localeDict);
|
||||
}
|
||||
|
||||
_activeLocaleDictionary = localeDict;
|
||||
|
||||
if (saveSettings)
|
||||
{
|
||||
SettingsManager.SaveSettings(new AppSettings { Language = supportedCulture.Name });
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetLanguageDisplayName(CultureInfo culture)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dict = new ResourceDictionary
|
||||
{
|
||||
Source = new Uri($"Locale/lang.{culture.Name}.xaml", UriKind.Relative)
|
||||
};
|
||||
|
||||
if (dict.Contains("language_display_name"))
|
||||
{
|
||||
return dict["language_display_name"] as string ?? culture.NativeName;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback to native name if loading fails
|
||||
}
|
||||
|
||||
return culture.NativeName;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace WandEnhancer.Core.Services
|
||||
{
|
||||
public class AppSettings
|
||||
{
|
||||
public string Language { get; set; }
|
||||
}
|
||||
|
||||
public static class SettingsManager
|
||||
{
|
||||
private static readonly string SettingsPath = Path.Combine(
|
||||
AppDomain.CurrentDomain.BaseDirectory,
|
||||
Constants.AppSettingsFileName);
|
||||
|
||||
public static AppSettings LoadSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(SettingsPath))
|
||||
{
|
||||
var json = File.ReadAllText(SettingsPath);
|
||||
return JsonConvert.DeserializeObject<AppSettings>(json);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Unreadable or corrupt settings must not block startup; defaults apply.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static void SaveSettings(AppSettings settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonConvert.SerializeObject(settings, Formatting.Indented);
|
||||
File.WriteAllText(SettingsPath, json);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// A read-only install directory must not break the app; the choice is lost, not fatal.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:s="clr-namespace:System;assembly=mscorlib">
|
||||
|
||||
<!--#region Language Metadata -->
|
||||
<s:String x:Key="language_display_name">Deutsch</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<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_patch">Anwenden</s:String>
|
||||
<s:String x:Key="mw_restore">Wiederherstellen</s:String>
|
||||
<s:String x:Key="mw_source_code">Quellcode</s:String>
|
||||
<s:String x:Key="mw_made_by">Mit ❤️ von k1tbyte erstellt</s:String>
|
||||
<s:String x:Key="mw_star_hint">Gib einen Stern, wenn dir das geholfen hat ;)</s:String>
|
||||
<s:String x:Key="mw_copy_logs">Logs in die Zwischenablage kopieren</s:String>
|
||||
<s:String x:Key="mw_export_logs">Logs in Datei exportieren</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Settings -->
|
||||
<s:String x:Key="settings_title">Einstellungen</s:String>
|
||||
<s:String x:Key="settings_language">Sprache</s:String>
|
||||
<s:String x:Key="settings_save">Speichern</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region EnhanceVectorsPopup -->
|
||||
<s:String x:Key="pv_activate_pro">WeMod Pro aktivieren</s:String>
|
||||
<s:String x:Key="pv_devtools">DevTools mit F12</s:String>
|
||||
<s:String x:Key="pv_disable_updates">Updates deaktivieren</s:String>
|
||||
<s:String x:Key="pv_remote_web_panel_preview">Remote-Zugriff aktivieren (Beta)</s:String>
|
||||
<s:String x:Key="pv_custom_scripts">Benutzerdefinierte Skripte</s:String>
|
||||
<s:String x:Key="pv_add_js_scripts">.js hinzufügen</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_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>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Runtime log -->
|
||||
<s:String x:Key="log_install_found">WeMod-Verzeichnis unter {0} ({1}) gefunden</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="log_ready">Bereit zum Patchen.</s:String>
|
||||
<s:String x:Key="log_install_not_found">WeMod-Verzeichnis nicht gefunden.</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="log_invalid_directory">Der ausgewählte Ordner {0} ist kein gültiges WeMod-Verzeichnis.</s:String>
|
||||
<s:String x:Key="log_restore_failed">Fehler beim Wiederherstellen des Backups: {0}</s:String>
|
||||
<s:String x:Key="log_patch_failed">Fehler beim Patchen: {0}</s:String>
|
||||
<s:String x:Key="log_copied">Protokolle in die Zwischenablage kopiert.</s:String>
|
||||
<s:String x:Key="log_copy_failed">Fehler beim Kopieren der Protokolle: {0}</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 -->
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,58 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:s="clr-namespace:System;assembly=mscorlib">
|
||||
|
||||
<!--#region Language Metadata -->
|
||||
<s:String x:Key="language_display_name">English</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<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_patch">Enhance</s:String>
|
||||
<s:String x:Key="mw_restore">Restore</s:String>
|
||||
<s:String x:Key="mw_source_code">Source code</s:String>
|
||||
<s:String x:Key="mw_made_by">Made with ❤️ by k1tbyte</s:String>
|
||||
<s:String x:Key="mw_star_hint">Put a star if you found this helpful ;)</s:String>
|
||||
<s:String x:Key="mw_copy_logs">Copy logs to clipboard</s:String>
|
||||
<s:String x:Key="mw_export_logs">Export logs to file</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Settings -->
|
||||
<s:String x:Key="settings_title">Settings</s:String>
|
||||
<s:String x:Key="settings_language">Language</s:String>
|
||||
<s:String x:Key="settings_save">Save</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region EnhanceVectorsPopup -->
|
||||
<s:String x:Key="pv_activate_pro">Activate WeMod Pro</s:String>
|
||||
<s:String x:Key="pv_devtools">DevTools on F12</s:String>
|
||||
<s:String x:Key="pv_disable_updates">Disable updates</s:String>
|
||||
<s:String x:Key="pv_remote_web_panel_preview">Enable remote access (beta)</s:String>
|
||||
<s:String x:Key="pv_custom_scripts">Custom scripts</s:String>
|
||||
<s:String x:Key="pv_add_js_scripts">Add .js</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_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>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Runtime log -->
|
||||
<s:String x:Key="log_install_found">WeMod directory found at {0} ({1})</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="log_ready">Ready for patching.</s:String>
|
||||
<s:String x:Key="log_install_not_found">WeMod directory not found.</s:String>
|
||||
<s:String x:Key="log_no_directory">Cant be done. Please specify the directory first.</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="log_restore_failed">Failed to restore backup: {0}</s:String>
|
||||
<s:String x:Key="log_patch_failed">Failed to patch: {0}</s:String>
|
||||
<s:String x:Key="log_copied">Logs copied to clipboard.</s:String>
|
||||
<s:String x:Key="log_copy_failed">Failed to copy logs: {0}</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 -->
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,58 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:s="clr-namespace:System;assembly=mscorlib">
|
||||
|
||||
<!--#region Language Metadata -->
|
||||
<s:String x:Key="language_display_name">Español</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<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_patch">Aplicar</s:String>
|
||||
<s:String x:Key="mw_restore">Restaurar</s:String>
|
||||
<s:String x:Key="mw_source_code">Código fuente</s:String>
|
||||
<s:String x:Key="mw_made_by">Hecho con ❤️ por k1tbyte</s:String>
|
||||
<s:String x:Key="mw_star_hint">Pon una estrella si te fue útil ;)</s:String>
|
||||
<s:String x:Key="mw_copy_logs">Copiar registros al portapapeles</s:String>
|
||||
<s:String x:Key="mw_export_logs">Exportar registros a un archivo</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Settings -->
|
||||
<s:String x:Key="settings_title">Configuración</s:String>
|
||||
<s:String x:Key="settings_language">Idioma</s:String>
|
||||
<s:String x:Key="settings_save">Guardar</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region EnhanceVectorsPopup -->
|
||||
<s:String x:Key="pv_activate_pro">Activar WeMod Pro</s:String>
|
||||
<s:String x:Key="pv_devtools">DevTools en F12</s:String>
|
||||
<s:String x:Key="pv_disable_updates">Desactivar actualizaciones</s:String>
|
||||
<s:String x:Key="pv_remote_web_panel_preview">Habilitar acceso remoto (beta)</s:String>
|
||||
<s:String x:Key="pv_custom_scripts">Scripts personalizados</s:String>
|
||||
<s:String x:Key="pv_add_js_scripts">Agregar .js</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_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>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Runtime log -->
|
||||
<s:String x:Key="log_install_found">Directorio de WeMod encontrado en {0} ({1})</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="log_ready">Listo para parchear.</s:String>
|
||||
<s:String x:Key="log_install_not_found">Directorio de WeMod no encontrado.</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="log_invalid_directory">La carpeta seleccionada {0} no es un directorio de WeMod válido.</s:String>
|
||||
<s:String x:Key="log_restore_failed">Error al restaurar la copia de seguridad: {0}</s:String>
|
||||
<s:String x:Key="log_patch_failed">Error al parchear: {0}</s:String>
|
||||
<s:String x:Key="log_copied">Registros copiados al portapapeles.</s:String>
|
||||
<s:String x:Key="log_copy_failed">Error al copiar los registros: {0}</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 -->
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,58 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:s="clr-namespace:System;assembly=mscorlib">
|
||||
|
||||
<!--#region Language Metadata -->
|
||||
<s:String x:Key="language_display_name">Français</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<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_patch">Appliquer</s:String>
|
||||
<s:String x:Key="mw_restore">Restaurer</s:String>
|
||||
<s:String x:Key="mw_source_code">Code source</s:String>
|
||||
<s:String x:Key="mw_made_by">Fait avec ❤️ par k1tbyte</s:String>
|
||||
<s:String x:Key="mw_star_hint">Mettez une étoile si cela vous a aidé ;)</s:String>
|
||||
<s:String x:Key="mw_copy_logs">Copier les logs dans le presse-papiers</s:String>
|
||||
<s:String x:Key="mw_export_logs">Exporter les logs dans un fichier</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Settings -->
|
||||
<s:String x:Key="settings_title">Paramètres</s:String>
|
||||
<s:String x:Key="settings_language">Langue</s:String>
|
||||
<s:String x:Key="settings_save">Enregistrer</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region EnhanceVectorsPopup -->
|
||||
<s:String x:Key="pv_activate_pro">Activer WeMod Pro</s:String>
|
||||
<s:String x:Key="pv_devtools">DevTools sur F12</s:String>
|
||||
<s:String x:Key="pv_disable_updates">Désactiver les mises à jour</s:String>
|
||||
<s:String x:Key="pv_remote_web_panel_preview">Activer l'accès à distance (bêta)</s:String>
|
||||
<s:String x:Key="pv_custom_scripts">Scripts personnalisés</s:String>
|
||||
<s:String x:Key="pv_add_js_scripts">Ajouter .js</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_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>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Runtime log -->
|
||||
<s:String x:Key="log_install_found">Répertoire WeMod trouvé à {0} ({1})</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="log_ready">Prêt pour le patch.</s:String>
|
||||
<s:String x:Key="log_install_not_found">Répertoire WeMod introuvable.</s:String>
|
||||
<s:String x:Key="log_no_directory">Impossible. Veuillez d'abord spécifier le répertoire.</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="log_restore_failed">Échec de la restauration de la sauvegarde : {0}</s:String>
|
||||
<s:String x:Key="log_patch_failed">Échec du patch : {0}</s:String>
|
||||
<s:String x:Key="log_copied">Journaux copiés dans le presse-papiers.</s:String>
|
||||
<s:String x:Key="log_copy_failed">Échec de la copie des journaux : {0}</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 -->
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,58 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:s="clr-namespace:System;assembly=mscorlib">
|
||||
|
||||
<!--#region Language Metadata -->
|
||||
<s:String x:Key="language_display_name">Italiano</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<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_patch">Applica</s:String>
|
||||
<s:String x:Key="mw_restore">Ripristina</s:String>
|
||||
<s:String x:Key="mw_source_code">Codice sorgente</s:String>
|
||||
<s:String x:Key="mw_made_by">Creato con ❤️ da k1tbyte</s:String>
|
||||
<s:String x:Key="mw_star_hint">Metti una stella se ti è stato utile ;)</s:String>
|
||||
<s:String x:Key="mw_copy_logs">Copia i log negli appunti</s:String>
|
||||
<s:String x:Key="mw_export_logs">Esporta i log su file</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Settings -->
|
||||
<s:String x:Key="settings_title">Impostazioni</s:String>
|
||||
<s:String x:Key="settings_language">Lingua</s:String>
|
||||
<s:String x:Key="settings_save">Salva</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region EnhanceVectorsPopup -->
|
||||
<s:String x:Key="pv_activate_pro">Attiva WeMod Pro</s:String>
|
||||
<s:String x:Key="pv_devtools">DevTools su F12</s:String>
|
||||
<s:String x:Key="pv_disable_updates">Disattiva aggiornamenti</s:String>
|
||||
<s:String x:Key="pv_remote_web_panel_preview">Abilita accesso remoto (beta)</s:String>
|
||||
<s:String x:Key="pv_custom_scripts">Script personalizzati</s:String>
|
||||
<s:String x:Key="pv_add_js_scripts">Aggiungi .js</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_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>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Runtime log -->
|
||||
<s:String x:Key="log_install_found">Directory di WeMod trovata in {0} ({1})</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="log_ready">Pronto per il patching.</s:String>
|
||||
<s:String x:Key="log_install_not_found">Directory di WeMod non trovata.</s:String>
|
||||
<s:String x:Key="log_no_directory">Impossibile procedere. Specifica prima la directory.</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="log_restore_failed">Impossibile ripristinare il backup: {0}</s:String>
|
||||
<s:String x:Key="log_patch_failed">Impossibile eseguire il patch: {0}</s:String>
|
||||
<s:String x:Key="log_copied">Log copiati negli appunti.</s:String>
|
||||
<s:String x:Key="log_copy_failed">Impossibile copiare i log: {0}</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 -->
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,58 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:s="clr-namespace:System;assembly=mscorlib">
|
||||
|
||||
<!--#region Language Metadata -->
|
||||
<s:String x:Key="language_display_name">日本語</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_folder_path">フォルダパス</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_restore">復元</s:String>
|
||||
<s:String x:Key="mw_source_code">ソースコード</s:String>
|
||||
<s:String x:Key="mw_made_by">k1tbyte が ❤️ を込めて作成</s:String>
|
||||
<s:String x:Key="mw_star_hint">役に立ったらスターをつけてください ;)</s:String>
|
||||
<s:String x:Key="mw_copy_logs">ログをクリップボードにコピー</s:String>
|
||||
<s:String x:Key="mw_export_logs">ログをファイルにエクスポート</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Settings -->
|
||||
<s:String x:Key="settings_title">設定</s:String>
|
||||
<s:String x:Key="settings_language">言語</s:String>
|
||||
<s:String x:Key="settings_save">保存</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region EnhanceVectorsPopup -->
|
||||
<s:String x:Key="pv_activate_pro">WeMod Pro を有効化</s:String>
|
||||
<s:String x:Key="pv_devtools">F12でDevTools</s:String>
|
||||
<s:String x:Key="pv_disable_updates">アップデートを無効化</s:String>
|
||||
<s:String x:Key="pv_remote_web_panel_preview">リモートアクセスを有効化(ベータ)</s:String>
|
||||
<s:String x:Key="pv_custom_scripts">カスタムスクリプト</s:String>
|
||||
<s:String x:Key="pv_add_js_scripts">.js を追加</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_start">開始</s:String>
|
||||
<s:String x:Key="pv_auto_apply">更新後に自動適用</s:String>
|
||||
<s:String x:Key="pv_popup_title">何を改善しますか?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Runtime log -->
|
||||
<s:String x:Key="log_install_found">WeModディレクトリが {0} ({1}) に見つかりました</s:String>
|
||||
<s:String x:Key="log_already_patched">WeModは既にパッチが適用されています。もう一度パッチを適用する場合は、まずバックアップを復元してください。</s:String>
|
||||
<s:String x:Key="log_ready">パッチ適用の準備ができました。</s:String>
|
||||
<s:String x:Key="log_install_not_found">WeModディレクトリが見つかりません。</s:String>
|
||||
<s:String x:Key="log_no_directory">実行できません。先にディレクトリを指定してください。</s:String>
|
||||
<s:String x:Key="log_invalid_directory">選択したフォルダ {0} は有効なWeModディレクトリではありません。</s:String>
|
||||
<s:String x:Key="log_restore_failed">バックアップの復元に失敗しました: {0}</s:String>
|
||||
<s:String x:Key="log_patch_failed">パッチの適用に失敗しました: {0}</s:String>
|
||||
<s:String x:Key="log_copied">ログをクリップボードにコピーしました。</s:String>
|
||||
<s:String x:Key="log_copy_failed">ログのコピーに失敗しました: {0}</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 -->
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,58 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:s="clr-namespace:System;assembly=mscorlib">
|
||||
|
||||
<!--#region Language Metadata -->
|
||||
<s:String x:Key="language_display_name">Polski</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<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_patch">Zastosuj</s:String>
|
||||
<s:String x:Key="mw_restore">Przywróć</s:String>
|
||||
<s:String x:Key="mw_source_code">Kod źródłowy</s:String>
|
||||
<s:String x:Key="mw_made_by">Wykonane z ❤️ przez k1tbyte</s:String>
|
||||
<s:String x:Key="mw_star_hint">Daj gwiazdkę, jeśli ci pomogło ;)</s:String>
|
||||
<s:String x:Key="mw_copy_logs">Skopiuj logi do schowka</s:String>
|
||||
<s:String x:Key="mw_export_logs">Eksportuj logi do pliku</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Settings -->
|
||||
<s:String x:Key="settings_title">Ustawienia</s:String>
|
||||
<s:String x:Key="settings_language">Język</s:String>
|
||||
<s:String x:Key="settings_save">Zapisz</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region EnhanceVectorsPopup -->
|
||||
<s:String x:Key="pv_activate_pro">Aktywuj WeMod Pro</s:String>
|
||||
<s:String x:Key="pv_devtools">DevTools na F12</s:String>
|
||||
<s:String x:Key="pv_disable_updates">Wyłącz aktualizacje</s:String>
|
||||
<s:String x:Key="pv_remote_web_panel_preview">Włącz zdalny dostęp (beta)</s:String>
|
||||
<s:String x:Key="pv_custom_scripts">Skrypty niestandardowe</s:String>
|
||||
<s:String x:Key="pv_add_js_scripts">Dodaj .js</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_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>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Runtime log -->
|
||||
<s:String x:Key="log_install_found">Katalog WeMod znaleziony w {0} ({1})</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="log_ready">Gotowy do aktualizacji (patchowania).</s:String>
|
||||
<s:String x:Key="log_install_not_found">Nie znaleziono katalogu WeMod.</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="log_invalid_directory">Wybrany folder {0} nie jest prawidłowym katalogiem WeMod.</s:String>
|
||||
<s:String x:Key="log_restore_failed">Nie udało się przywrócić kopii zapasowej: {0}</s:String>
|
||||
<s:String x:Key="log_patch_failed">Nie udało się zaktualizować: {0}</s:String>
|
||||
<s:String x:Key="log_copied">Logi skopiowane do schowka.</s:String>
|
||||
<s:String x:Key="log_copy_failed">Nie udało się skopiować logów: {0}</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 -->
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,58 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:s="clr-namespace:System;assembly=mscorlib">
|
||||
|
||||
<!--#region Language Metadata -->
|
||||
<s:String x:Key="language_display_name">Português</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<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_patch">Aplicar</s:String>
|
||||
<s:String x:Key="mw_restore">Restaurar</s:String>
|
||||
<s:String x:Key="mw_source_code">Código fonte</s:String>
|
||||
<s:String x:Key="mw_made_by">Feito com ❤️ por k1tbyte</s:String>
|
||||
<s:String x:Key="mw_star_hint">Dê uma estrela se isso te ajudou ;)</s:String>
|
||||
<s:String x:Key="mw_copy_logs">Copiar logs para a área de transferência</s:String>
|
||||
<s:String x:Key="mw_export_logs">Exportar logs para arquivo</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Settings -->
|
||||
<s:String x:Key="settings_title">Configurações</s:String>
|
||||
<s:String x:Key="settings_language">Idioma</s:String>
|
||||
<s:String x:Key="settings_save">Salvar</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region EnhanceVectorsPopup -->
|
||||
<s:String x:Key="pv_activate_pro">Ativar WeMod Pro</s:String>
|
||||
<s:String x:Key="pv_devtools">DevTools no F12</s:String>
|
||||
<s:String x:Key="pv_disable_updates">Desativar atualizações</s:String>
|
||||
<s:String x:Key="pv_remote_web_panel_preview">Ativar acesso remoto (beta)</s:String>
|
||||
<s:String x:Key="pv_custom_scripts">Scripts personalizados</s:String>
|
||||
<s:String x:Key="pv_add_js_scripts">Adicionar .js</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_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>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Runtime log -->
|
||||
<s:String x:Key="log_install_found">Diretório do WeMod encontrado em {0} ({1})</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="log_ready">Pronto para modificar.</s:String>
|
||||
<s:String x:Key="log_install_not_found">Diretório do WeMod não encontrado.</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="log_invalid_directory">A pasta selecionada {0} não é um diretório válido do WeMod.</s:String>
|
||||
<s:String x:Key="log_restore_failed">Falha ao restaurar o backup: {0}</s:String>
|
||||
<s:String x:Key="log_patch_failed">Falha ao modificar: {0}</s:String>
|
||||
<s:String x:Key="log_copied">Logs copiados para a área de transferência.</s:String>
|
||||
<s:String x:Key="log_copy_failed">Falha ao copiar logs: {0}</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 -->
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,58 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:s="clr-namespace:System;assembly=mscorlib">
|
||||
|
||||
<!--#region Language Metadata -->
|
||||
<s:String x:Key="language_display_name">Русский</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_folder_path">Путь к папке</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_restore">Восстановить</s:String>
|
||||
<s:String x:Key="mw_source_code">Исходный код</s:String>
|
||||
<s:String x:Key="mw_made_by">Сделано с ❤️ by k1tbyte</s:String>
|
||||
<s:String x:Key="mw_star_hint">Поставьте звезду, если это было полезно ;)</s:String>
|
||||
<s:String x:Key="mw_copy_logs">Скопировать логи в буфер обмена</s:String>
|
||||
<s:String x:Key="mw_export_logs">Экспортировать логи в файл</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Settings -->
|
||||
<s:String x:Key="settings_title">Настройки</s:String>
|
||||
<s:String x:Key="settings_language">Язык</s:String>
|
||||
<s:String x:Key="settings_save">Сохранить</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region EnhanceVectorsPopup -->
|
||||
<s:String x:Key="pv_activate_pro">Активировать WeMod Pro</s:String>
|
||||
<s:String x:Key="pv_devtools">DevTools на F12</s:String>
|
||||
<s:String x:Key="pv_disable_updates">Отключить обновления</s:String>
|
||||
<s:String x:Key="pv_remote_web_panel_preview">Remote-доступ (beta)</s:String>
|
||||
<s:String x:Key="pv_custom_scripts">Свои скрипты</s:String>
|
||||
<s:String x:Key="pv_add_js_scripts">Добавить .js</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_start">Начать</s:String>
|
||||
<s:String x:Key="pv_auto_apply">Авто-патч после обновлений</s:String>
|
||||
<s:String x:Key="pv_popup_title">Что будем улучшать?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Runtime log -->
|
||||
<s:String x:Key="log_install_found">Директория WeMod найдена в {0} ({1})</s:String>
|
||||
<s:String x:Key="log_already_patched">WeMod уже пропатчен. Если вы хотите пропатчить снова, сначала восстановите резервную копию.</s:String>
|
||||
<s:String x:Key="log_ready">Готово к патчингу.</s:String>
|
||||
<s:String x:Key="log_install_not_found">Директория WeMod не найдена.</s:String>
|
||||
<s:String x:Key="log_no_directory">Невозможно выполнить. Пожалуйста, сначала укажите директорию.</s:String>
|
||||
<s:String x:Key="log_invalid_directory">Выбранная папка {0} не является допустимой директорией WeMod.</s:String>
|
||||
<s:String x:Key="log_restore_failed">Не удалось восстановить резервную копию: {0}</s:String>
|
||||
<s:String x:Key="log_patch_failed">Не удалось пропатчить: {0}</s:String>
|
||||
<s:String x:Key="log_copied">Логи скопированы в буфер обмена.</s:String>
|
||||
<s:String x:Key="log_copy_failed">Не удалось скопировать логи: {0}</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 -->
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,58 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:s="clr-namespace:System;assembly=mscorlib">
|
||||
|
||||
<!--#region Language Metadata -->
|
||||
<s:String x:Key="language_display_name">Türkçe</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<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_patch">Uygula</s:String>
|
||||
<s:String x:Key="mw_restore">Geri Yükle</s:String>
|
||||
<s:String x:Key="mw_source_code">Kaynak kodu</s:String>
|
||||
<s:String x:Key="mw_made_by">k1tbyte tarafından ❤️ ile yapıldı</s:String>
|
||||
<s:String x:Key="mw_star_hint">Yardımcı olduysa yıldız verin ;)</s:String>
|
||||
<s:String x:Key="mw_copy_logs">Günlükleri panoya kopyala</s:String>
|
||||
<s:String x:Key="mw_export_logs">Günlükleri dosyaya aktar</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Settings -->
|
||||
<s:String x:Key="settings_title">Ayarlar</s:String>
|
||||
<s:String x:Key="settings_language">Dil</s:String>
|
||||
<s:String x:Key="settings_save">Kaydet</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region EnhanceVectorsPopup -->
|
||||
<s:String x:Key="pv_activate_pro">WeMod Pro'yu Etkinleştir</s:String>
|
||||
<s:String x:Key="pv_devtools">F12 ile DevTools</s:String>
|
||||
<s:String x:Key="pv_disable_updates">Güncellemeleri devre dışı bırak</s:String>
|
||||
<s:String x:Key="pv_remote_web_panel_preview">Uzaktan erişimi etkinleştir (beta)</s:String>
|
||||
<s:String x:Key="pv_custom_scripts">Özel betikler</s:String>
|
||||
<s:String x:Key="pv_add_js_scripts">.js ekle</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_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>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Runtime log -->
|
||||
<s:String x:Key="log_install_found">WeMod dizini {0} konumunda bulundu ({1})</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="log_ready">Yama işlemi için hazır.</s:String>
|
||||
<s:String x:Key="log_install_not_found">WeMod dizini bulunamadı.</s:String>
|
||||
<s:String x:Key="log_no_directory">İşlem yapılamıyor. Lütfen önce dizini belirtin.</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="log_restore_failed">Yedek geri yüklenemedi: {0}</s:String>
|
||||
<s:String x:Key="log_patch_failed">Yama yapılamadı: {0}</s:String>
|
||||
<s:String x:Key="log_copied">Günlükler panoya kopyalandı.</s:String>
|
||||
<s:String x:Key="log_copy_failed">Günlükler kopyalanamadı: {0}</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 -->
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,58 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:s="clr-namespace:System;assembly=mscorlib">
|
||||
|
||||
<!--#region Language Metadata -->
|
||||
<s:String x:Key="language_display_name">Українська</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_folder_path">Шлях до папки</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_restore">Відновити</s:String>
|
||||
<s:String x:Key="mw_source_code">Вихідний код</s:String>
|
||||
<s:String x:Key="mw_made_by">Зроблено з ❤️ by k1tbyte</s:String>
|
||||
<s:String x:Key="mw_star_hint">Поставте зірку, якщо це було корисно ;)</s:String>
|
||||
<s:String x:Key="mw_copy_logs">Скопіювати логи до буфера обміну</s:String>
|
||||
<s:String x:Key="mw_export_logs">Експортувати логи у файл</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Settings -->
|
||||
<s:String x:Key="settings_title">Налаштування</s:String>
|
||||
<s:String x:Key="settings_language">Мова</s:String>
|
||||
<s:String x:Key="settings_save">Зберегти</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region EnhanceVectorsPopup -->
|
||||
<s:String x:Key="pv_activate_pro">Активувати WeMod Pro</s:String>
|
||||
<s:String x:Key="pv_devtools">DevTools на F12</s:String>
|
||||
<s:String x:Key="pv_disable_updates">Вимкнути оновлення</s:String>
|
||||
<s:String x:Key="pv_remote_web_panel_preview">Увімкнути віддалений доступ (бета)</s:String>
|
||||
<s:String x:Key="pv_custom_scripts">Користувацькі скрипти</s:String>
|
||||
<s:String x:Key="pv_add_js_scripts">Додати .js</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_start">Почати</s:String>
|
||||
<s:String x:Key="pv_auto_apply">Автоматично застосовувати після оновлень</s:String>
|
||||
<s:String x:Key="pv_popup_title">Що будемо покращувати?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Runtime log -->
|
||||
<s:String x:Key="log_install_found">Директорію WeMod знайдено в {0} ({1})</s:String>
|
||||
<s:String x:Key="log_already_patched">WeMod вже пропатчено. Якщо ви хочете пропатчити знову, спершу відновіть резервну копію.</s:String>
|
||||
<s:String x:Key="log_ready">Готово до патчингу.</s:String>
|
||||
<s:String x:Key="log_install_not_found">Директорію WeMod не знайдено.</s:String>
|
||||
<s:String x:Key="log_no_directory">Не вдається виконати. Будь ласка, спочатку вкажіть директорію.</s:String>
|
||||
<s:String x:Key="log_invalid_directory">Вибрана папка {0} не є дійсною директорією WeMod.</s:String>
|
||||
<s:String x:Key="log_restore_failed">Не вдалося відновити резервну копію: {0}</s:String>
|
||||
<s:String x:Key="log_patch_failed">Не вдалося пропатчити: {0}</s:String>
|
||||
<s:String x:Key="log_copied">Логи скопійовано в буфер обміну.</s:String>
|
||||
<s:String x:Key="log_copy_failed">Не вдалося скопіювати логи: {0}</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 -->
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,58 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:s="clr-namespace:System;assembly=mscorlib">
|
||||
|
||||
<!--#region Language Metadata -->
|
||||
<s:String x:Key="language_display_name">简体中文</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region MainWindow -->
|
||||
<s:String x:Key="mw_folder_path">文件夹路径</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_restore">恢复</s:String>
|
||||
<s:String x:Key="mw_source_code">源代码</s:String>
|
||||
<s:String x:Key="mw_made_by">由 k1tbyte 用 ❤️ 制作</s:String>
|
||||
<s:String x:Key="mw_star_hint">如果这对您有帮助,请给个星标 ;)</s:String>
|
||||
<s:String x:Key="mw_copy_logs">复制日志到剪贴板</s:String>
|
||||
<s:String x:Key="mw_export_logs">将日志导出到文件</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Settings -->
|
||||
<s:String x:Key="settings_title">设置</s:String>
|
||||
<s:String x:Key="settings_language">语言</s:String>
|
||||
<s:String x:Key="settings_save">保存</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region EnhanceVectorsPopup -->
|
||||
<s:String x:Key="pv_activate_pro">激活 WeMod Pro</s:String>
|
||||
<s:String x:Key="pv_devtools">按 F12 打开开发者工具</s:String>
|
||||
<s:String x:Key="pv_disable_updates">禁用更新</s:String>
|
||||
<s:String x:Key="pv_remote_web_panel_preview">启用远程访问(beta)</s:String>
|
||||
<s:String x:Key="pv_custom_scripts">自定义脚本</s:String>
|
||||
<s:String x:Key="pv_add_js_scripts">添加 .js</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_start">开始</s:String>
|
||||
<s:String x:Key="pv_auto_apply">更新后自动应用</s:String>
|
||||
<s:String x:Key="pv_popup_title">我们要增强什么?</s:String>
|
||||
<!--#endregion -->
|
||||
|
||||
<!--#region Runtime log -->
|
||||
<s:String x:Key="log_install_found">在 {0} ({1}) 找到 WeMod 目录</s:String>
|
||||
<s:String x:Key="log_already_patched">WeMod 已经修补过。如果想再次修补,请先恢复备份。</s:String>
|
||||
<s:String x:Key="log_ready">准备修补。</s:String>
|
||||
<s:String x:Key="log_install_not_found">未找到 WeMod 目录。</s:String>
|
||||
<s:String x:Key="log_no_directory">无法执行。请先指定目录。</s:String>
|
||||
<s:String x:Key="log_invalid_directory">选择的文件夹 {0} 不是有效的 WeMod 目录。</s:String>
|
||||
<s:String x:Key="log_restore_failed">恢复备份失败: {0}</s:String>
|
||||
<s:String x:Key="log_patch_failed">修补失败: {0}</s:String>
|
||||
<s:String x:Key="log_copied">日志已复制到剪贴板。</s:String>
|
||||
<s:String x:Key="log_copy_failed">复制日志失败: {0}</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 -->
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace WandEnhancer.Models
|
||||
{
|
||||
public enum EPatchType
|
||||
{
|
||||
ActivatePro = 1,
|
||||
DisableUpdates = 2,
|
||||
DevToolsOnF12 = 8,
|
||||
RemoteWebPanelPreview = 16
|
||||
}
|
||||
|
||||
public sealed class PatchConfig
|
||||
{
|
||||
public HashSet<EPatchType> PatchTypes { get; set; }
|
||||
|
||||
public List<string> CustomScriptPaths { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>When set, the patch selection is saved so the launcher re-applies it after a Wand update.</summary>
|
||||
public bool AutoApplyAfterUpdate { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace WandEnhancer.Models
|
||||
{
|
||||
public class WeModConfig
|
||||
{
|
||||
public string BrandName { get; set; }
|
||||
public string ExecutableName { get; set; }
|
||||
public string RootDirectory { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public string ExecutablePath => System.IO.Path.Combine(RootDirectory, ExecutableName);
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return RootDirectory;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
@@ -0,0 +1,160 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using WandEnhancer.Core;
|
||||
using WandEnhancer.Models;
|
||||
using WandEnhancer.Utils;
|
||||
using WandEnhancer.View.MainWindow;
|
||||
|
||||
namespace WandEnhancer
|
||||
{
|
||||
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]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
if (TryLaunchMode(args))
|
||||
return;
|
||||
|
||||
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
|
||||
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
|
||||
|
||||
var application = new App();
|
||||
application.InitializeComponent();
|
||||
application.MainWindow = new MainWindow();
|
||||
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);
|
||||
|
||||
if (args.Length > 0 &&
|
||||
args[0].StartsWith("--squirrel", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string updateExe = Path.Combine(myDir, "Update.exe");
|
||||
if (File.Exists(updateExe))
|
||||
Process.Start(updateExe, QuoteArguments(args));
|
||||
return true;
|
||||
}
|
||||
|
||||
var config = WeModInstalls.FindLatestWeMod(myDir);
|
||||
if (config == null)
|
||||
return false;
|
||||
|
||||
// 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 (!Enhancer.IsPatched(config.RootDirectory) && !TryAutoPatch(config, myDir))
|
||||
return false;
|
||||
|
||||
string forwardedArgs = args.Length > 0 ? QuoteArguments(args) : null;
|
||||
FuseLauncher.Launch(config.ExecutablePath, forwardedArgs,
|
||||
message => RecordStartupLog(message, ELogType.Warn));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-quotes argv for a command line. Squirrel hands us paths with spaces
|
||||
/// (`--squirrel-install "C:\Users\Some Name\..."`); re-joining on spaces splits them.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private static void RecordStartupLog(string message, ELogType type)
|
||||
{
|
||||
StartupLog.Add(new KeyValuePair<string, ELogType>(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)
|
||||
{
|
||||
e.SetObserved();
|
||||
RecordStartupLog($"Background task failed: {e.Exception.GetBaseException().Message}", ELogType.Error);
|
||||
}
|
||||
|
||||
private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
|
||||
{
|
||||
var error = e.ExceptionObject as Exception;
|
||||
MessageBox.Show(
|
||||
error?.Message ?? e.ExceptionObject?.ToString() ?? "Unknown error",
|
||||
Constants.RepoName,
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Error);
|
||||
Environment.Exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Reflection;
|
||||
using System.Resources;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
|
||||
// General Information about an assembly is controlled through the following
|
||||
// set of attributes. Change these attribute values to modify the information
|
||||
// associated with an assembly.
|
||||
[assembly: AssemblyTitle("WandEnhancer")]
|
||||
[assembly: AssemblyDescription("")]
|
||||
[assembly: AssemblyConfiguration("")]
|
||||
[assembly: AssemblyCompany("")]
|
||||
[assembly: AssemblyProduct("WandEnhancer")]
|
||||
[assembly: AssemblyCopyright("Copyright © 2025")]
|
||||
[assembly: AssemblyTrademark("")]
|
||||
[assembly: AssemblyCulture("")]
|
||||
|
||||
// Setting ComVisible to false makes the types in this assembly not visible
|
||||
// to COM components. If you need to access a type in this assembly from
|
||||
// COM, set the ComVisible attribute to true on that type.
|
||||
[assembly: ComVisible(false)]
|
||||
|
||||
//In order to begin building localizable applications, set
|
||||
//<UICulture>CultureYouAreCodingWith</UICulture> in your .csproj file
|
||||
//inside a <PropertyGroup>. For example, if you are using US english
|
||||
//in your source files, set the <UICulture> to en-US. Then uncomment
|
||||
//the NeutralResourceLanguage attribute below. Update the "en-US" in
|
||||
//the line below to match the UICulture setting in the project file.
|
||||
|
||||
//[assembly: NeutralResourcesLanguage("en-US", UltimateResourceFallbackLocation.Satellite)]
|
||||
|
||||
|
||||
[assembly: ThemeInfo(
|
||||
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
|
||||
//(used if a resource is not found in the page,
|
||||
// or application resource dictionaries)
|
||||
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
|
||||
//(used if a resource is not found in the page,
|
||||
// app, or any theme specific resource dictionaries)
|
||||
)]
|
||||
|
||||
|
||||
// Version information for an assembly consists of the following four values:
|
||||
//
|
||||
// Major Version
|
||||
// Minor Version
|
||||
// Build Number
|
||||
// Revision
|
||||
//
|
||||
// You can specify all the values or you can default the Build and Revision Numbers
|
||||
// by using the '*' as shown below:
|
||||
// [assembly: AssemblyVersion("1.0.*")]
|
||||
[assembly: AssemblyVersion("2.0.0.0")]
|
||||
[assembly: AssemblyFileVersion("2.0.0.0")]
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
// Runtime Version:4.0.30319.42000
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace WandEnhancer.Properties
|
||||
{
|
||||
/// <summary>
|
||||
/// A strongly-typed resource class, for looking up localized strings, etc.
|
||||
/// </summary>
|
||||
// This class was auto-generated by the StronglyTypedResourceBuilder
|
||||
// class via a tool like ResGen or Visual Studio.
|
||||
// To add or remove a member, edit your .ResX file then rerun ResGen
|
||||
// with the /str option, or rebuild your VS project.
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder",
|
||||
"4.0.0.0")]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
internal class Resources
|
||||
{
|
||||
private static global::System.Resources.ResourceManager resourceMan;
|
||||
|
||||
private static global::System.Globalization.CultureInfo resourceCulture;
|
||||
|
||||
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance",
|
||||
"CA1811:AvoidUncalledPrivateCode")]
|
||||
internal Resources()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached ResourceManager instance used by this class.
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState
|
||||
.Advanced)]
|
||||
internal static global::System.Resources.ResourceManager ResourceManager
|
||||
{
|
||||
get
|
||||
{
|
||||
if ((resourceMan == null))
|
||||
{
|
||||
global::System.Resources.ResourceManager temp =
|
||||
new global::System.Resources.ResourceManager("WandEnhancer.Properties.Resources",
|
||||
typeof(Resources).Assembly);
|
||||
resourceMan = temp;
|
||||
}
|
||||
|
||||
return resourceMan;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the current thread's CurrentUICulture property for all
|
||||
/// resource lookups using this strongly typed resource class.
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState
|
||||
.Advanced)]
|
||||
internal static global::System.Globalization.CultureInfo Culture
|
||||
{
|
||||
get { return resourceCulture; }
|
||||
set { resourceCulture = value; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
</root>
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace WandEnhancer.ReactiveUICore
|
||||
{
|
||||
public class ObservableObject : INotifyPropertyChanged
|
||||
{
|
||||
public event PropertyChangedEventHandler PropertyChanged;
|
||||
|
||||
protected void OnPropertyChanged([CallerMemberName] string name = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
|
||||
protected virtual bool SetProperty<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
|
||||
{
|
||||
if (Equals(field, value)) return false;
|
||||
field = value;
|
||||
OnPropertyChanged(propertyName);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace WandEnhancer.ReactiveUICore
|
||||
{
|
||||
public sealed class RelayCommand : ICommand
|
||||
{
|
||||
private readonly Action<object> _execute;
|
||||
private readonly Func<object, bool> _canExecute;
|
||||
|
||||
public event EventHandler CanExecuteChanged
|
||||
{
|
||||
add => CommandManager.RequerySuggested += value;
|
||||
remove => CommandManager.RequerySuggested -= value;
|
||||
}
|
||||
|
||||
public RelayCommand(Action<object> execute, Func<object, bool> canExecute = null)
|
||||
{
|
||||
_execute = execute;
|
||||
_canExecute = canExecute;
|
||||
}
|
||||
|
||||
public bool CanExecute(object parameter) => _canExecute == null || _canExecute(parameter);
|
||||
public void Execute(object parameter) => _execute(parameter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Brush x:Key="Background">#09090b</Brush>
|
||||
<Brush x:Key="Foreground">#FAFAFA</Brush>
|
||||
<Brush x:Key="Card">#27272A</Brush>
|
||||
<Brush x:Key="CardForeground">#FAFAFA</Brush>
|
||||
<Brush x:Key="Popover">#09090b</Brush>
|
||||
<Brush x:Key="PopoverForeground">#FAFAFA</Brush>
|
||||
<Brush x:Key="Primary">#FAFAFA</Brush>
|
||||
<Brush x:Key="PrimaryForeground">#18181B</Brush>
|
||||
<Brush x:Key="Secondary">#27272A</Brush>
|
||||
<Brush x:Key="SecondaryForeground">#FAFAFA</Brush>
|
||||
<Brush x:Key="Muted">#18181a</Brush>
|
||||
<Brush x:Key="MutedForeground">#A1A1AA</Brush>
|
||||
<Brush x:Key="Accent">#27272A</Brush>
|
||||
<Brush x:Key="AccentForeground">#FAFAFA</Brush>
|
||||
<Brush x:Key="Destructive">Red</Brush>
|
||||
<Brush x:Key="DestructiveForeground">#FAFAFA</Brush>
|
||||
<Brush x:Key="Border">#27272A</Brush>
|
||||
<Brush x:Key="Input">#27272A</Brush>
|
||||
<Brush x:Key="Ring">#D4D4D8</Brush>
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,42 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Geometry x:Key="CloseIcon">
|
||||
M13.46,12L19,17.54V19H17.54L12,13.46L6.46,19H5V17.54L10.54,12L5,6.46V5H6.46L12,10.54L17.54,5H19V6.46L13.46,12Z
|
||||
</Geometry>
|
||||
|
||||
<Geometry x:Key="CogIcon">
|
||||
M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z
|
||||
</Geometry>
|
||||
|
||||
<Geometry x:Key="Logo">
|
||||
M47.845,22.185l-20.03,-20.03c-1.543,-1.543 -4.046,-1.553 -5.729,0.002l-19.931,20.028c-1.542,1.542 -1.554,4.045 0,5.727l19.934,19.934c0.772,0.772 1.785,1.16 2.816,1.16c1.026,0 2.07,-0.385 2.91,-1.16l19.933,-19.934c1.605,-1.605 1.648,-4.175 0.097,-5.727zM18,27c-1.105,0 -2,-0.895 -2,-2c0,-1.105 0.895,-2 2,-2c1.105,0 2,0.895 2,2c0,1.105 -0.895,2 -2,2zM25,34c-1.105,0 -2,-0.895 -2,-2c0,-1.105 0.895,-2 2,-2c1.105,0 2,0.895 2,2c0,1.105 -0.895,2 -2,2zM25,20c-1.105,0 -2,-0.895 -2,-2c0,-1.105 0.895,-2 2,-2c1.105,0 2,0.895 2,2c0,1.105 -0.895,2 -2,2zM32,27c-1.105,0 -2,-0.895 -2,-2c0,-1.105 0.895,-2 2,-2c1.105,0 2,0.895 2,2c0,1.105 -0.895,2 -2,2z
|
||||
</Geometry>
|
||||
|
||||
<Geometry x:Key="GitHub">
|
||||
M12 2A10 10 0 0122 12c0 4.42-2.86 8.16-6.83 9.5-.51.09-.67-.23-.67-.5 0-.32 0-1.4 0-2.74 0-.93-.33-1.54-.69-1.85 2.23-.25 4.57-1.09 4.57-4.91 0-1.11-.38-2-1.03-2.71.1-.25.45-1.29-.1-2.64 0 0-.84-.27-2.75 1.02-.79-.22-1.65-.33-2.5-.33s-1.71.11-2.5.33C7.59 5.88 6.75 6.15 6.75 6.15c-.55 1.35-.2 2.39-.1 2.64-.65.71-1.03 1.6-1.03 2.71 0 3.81 2.33 4.67 4.55 4.92-.28.25-.54.69-.63 1.34-.57.24-2.04.69-2.91-.83 0 0-.53-.96-1.53-1.03 0 0-.98-.02-.07.6 0 0 .65.31 1.11 1.47 0 0 .59 1.94 3.36 1.34 0 .83 0 1.46 0 1.69 0 .27-.16.58-.66.5C4.87 20.17 2 16.42 2 12A10 10 0 0112 2Z
|
||||
</Geometry>
|
||||
|
||||
<Geometry x:Key="CheckDecagram">
|
||||
M10 17l8-8-1.41-1.42L10 14.17 7.41 11.59 6 13l4 4Zm13-5-2.44 2.78.34 3.68-3.61.82-1.89 3.18L12 21 8.6 22.47 6.71 19.29 3.1 18.47l.34-3.69L1 12 3.44 9.21 3.1 5.53l3.61-.81L8.6 1.54 12 3l3.4-1.46 1.89 3.18 3.61.82-.34 3.68L23 12
|
||||
</Geometry>
|
||||
|
||||
<Geometry x:Key="AlertDecagram">
|
||||
M13 13V7H11v6h2Zm0 4V15H11v2h2m10-5-2.44 2.78.34 3.68-3.61.82-1.89 3.18L12 21 8.6 22.47 6.71 19.29 3.1 18.47l.34-3.69L1 12 3.44 9.21 3.1 5.53l3.61-.81L8.6 1.54 12 3l3.4-1.46 1.89 3.18 3.61.82-.34 3.68L23 12
|
||||
</Geometry>
|
||||
|
||||
<Geometry x:Key="ArrowLeft">
|
||||
M5.05 11.94l5-5v3.99H19l-.03 2.01H10.05v4Z
|
||||
</Geometry>
|
||||
|
||||
<Geometry x:Key="CopyIcon">
|
||||
M19 21H8V7h11m0-2H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2m-3-4H4a2 2 0 0 0-2 2v14h2V3h12V1Z
|
||||
</Geometry>
|
||||
|
||||
<Geometry x:Key="ExportIcon">
|
||||
M14 13h-3v3H9v-3H6v-2h3V8h2v3h3m-1-9H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9l-7-7M5 4h7v5h5v11H5V4Z
|
||||
</Geometry>
|
||||
|
||||
<!--<Geometry x:Key="">
|
||||
|
||||
</Geometry>-->
|
||||
</ResourceDictionary>
|
||||
Binary file not shown.
@@ -0,0 +1,354 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<CircleEase EasingMode="EaseInOut" x:Key="BaseAnimationFunction"/>
|
||||
|
||||
<Style TargetType="{x:Type Button}">
|
||||
<Style.Resources>
|
||||
<CornerRadius x:Key="CornerRadius">3 3 3 3</CornerRadius>
|
||||
</Style.Resources>
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Border}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource Foreground}"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border CornerRadius="{DynamicResource CornerRadius}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
Padding="{TemplateBinding Padding}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
Background="{TemplateBinding Background}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background" Value="{DynamicResource Secondary}"/>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="ColoredButton" TargetType="{x:Type Button}" BasedOn="{StaticResource {x:Type Button}}">
|
||||
<Setter Property="Background" Value="{DynamicResource Primary}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryForeground}"/>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Background" Value="{DynamicResource Muted}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource MutedForeground}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Margin" Value="0 2 0 2"/>
|
||||
<Setter Property="Background" Value="{DynamicResource Primary}"/>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="IconButton" TargetType="{x:Type Button}" BasedOn="{StaticResource {x:Type Button}}">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border CornerRadius="{DynamicResource CornerRadius}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
Padding="{TemplateBinding Padding}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
Background="{TemplateBinding Background}">
|
||||
<Viewbox HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<Path x:Name="Icon" Stretch="Fill" Data="{TemplateBinding Tag}" Fill="{TemplateBinding Foreground}"/>
|
||||
</Viewbox>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<CircleEase EasingMode="EaseIn" x:Key="DefaultAnimationFunction"/>
|
||||
|
||||
<Style TargetType="{x:Type ContextMenu}">
|
||||
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||
<Setter Property="OverridesDefaultStyle" Value="True"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="{x:Type ContextMenu}">
|
||||
<Border x:Name="Content" CornerRadius="5" Margin="5"
|
||||
Background="{StaticResource Background}"
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource Border}"
|
||||
Padding="4">
|
||||
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Cycle" />
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="5" ShadowDepth="0" Color="Black" Opacity="0.4"/>
|
||||
</Border.Effect>
|
||||
<Border.RenderTransform>
|
||||
<ScaleTransform ScaleX="0" ScaleY="0"/>
|
||||
</Border.RenderTransform>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<EventTrigger RoutedEvent="Loaded">
|
||||
<BeginStoryboard>
|
||||
<Storyboard>
|
||||
<DoubleAnimation Duration="0:0:0.15" Storyboard.TargetName="Content" EasingFunction="{StaticResource DefaultAnimationFunction}"
|
||||
Storyboard.TargetProperty="(Border.RenderTransform).(ScaleTransform.ScaleY)" From="0" To="1"/>
|
||||
<DoubleAnimation Duration="0:0:0.15" Storyboard.TargetName="Content" EasingFunction="{StaticResource DefaultAnimationFunction}"
|
||||
Storyboard.TargetProperty="(Border.RenderTransform).(ScaleTransform.ScaleX)" From="0" To="1"/>
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</EventTrigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="{x:Type MenuItem}">
|
||||
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||
<Setter Property="OverridesDefaultStyle" Value="True"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource Foreground}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Border}"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="FontWeight" Value="Medium"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="{x:Type MenuItem}">
|
||||
<Border Name="Root" Height="30" Background="Transparent" CornerRadius="4">
|
||||
<ContentPresenter Name="HeaderHost" Margin="10,0,10,0"
|
||||
ContentSource="Header" MinWidth="100"
|
||||
RecognizesAccessKey="True"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||
TextOptions.TextRenderingMode="ClearType" TextBlock.FontSize="12" TextBlock.FontWeight="{TemplateBinding FontWeight}" TextBlock.Foreground="{TemplateBinding Foreground}" TextOptions.TextFormattingMode="Display"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Foreground}"/>
|
||||
<Setter TargetName="Root" Property="Background" Value="{DynamicResource Accent}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Background}"/>
|
||||
<Setter TargetName="Root" Property="Background" Value="{DynamicResource Foreground}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="LabelCard" TargetType="{x:Type Border}">
|
||||
<Setter Property="Background" Value="{DynamicResource Card}"/>
|
||||
<Setter Property="Opacity" Value="0.9"/>
|
||||
<Setter Property="CornerRadius" Value="10"/>
|
||||
<Setter Property="Padding" Value="12 6"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Center"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="Label" TargetType="{x:Type TextBlock}">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Foreground}"/>
|
||||
<Setter Property="FontWeight" Value="Medium"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Center"/>
|
||||
<Setter Property="TextAlignment" Value="Center"/>
|
||||
</Style>
|
||||
|
||||
|
||||
<Style TargetType="CheckBox">
|
||||
<Setter Property="Cursor" Value="Hand"></Setter>
|
||||
<Setter Property="Content" Value=""/>
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="{x:Type CheckBox}">
|
||||
<Border x:Name="Border" Height="17" Width="17"
|
||||
CornerRadius="3"
|
||||
Background="{DynamicResource Foreground}" BorderBrush="{DynamicResource Border}"
|
||||
BorderThickness="0">
|
||||
<TextBlock x:Name="Text" VerticalAlignment="Center" HorizontalAlignment="Center"
|
||||
Foreground="{DynamicResource PrimaryForeground}"></TextBlock>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsChecked" Value="False">
|
||||
<Setter TargetName="Border"
|
||||
Property="Background" Value="Transparent"/>
|
||||
<Setter TargetName="Border"
|
||||
Property="BorderThickness" Value="1"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Text"
|
||||
Property="Text" Value="✓"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsChecked" Value="{x:Null}">
|
||||
<Setter TargetName="Text"
|
||||
Property="Text" Value="–"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
|
||||
<Style TargetType="TextBox" x:Key="TitledTextBox">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Height" Value="30" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Foreground}" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="CaretBrush" Value="{DynamicResource MutedForeground}" />
|
||||
<Setter Property="SelectionBrush" Value="{DynamicResource MutedForeground}" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="{x:Type TextBox}">
|
||||
<Border BorderBrush="{StaticResource Border}" Cursor="IBeam"
|
||||
BorderThickness="1" CornerRadius="3">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border BorderBrush="{DynamicResource Border}"
|
||||
BorderThickness="0 0 1 0" IsHitTestVisible="False">
|
||||
<TextBlock Text="{DynamicResource mw_folder_path}"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource MutedForeground}"
|
||||
Padding="10 0" />
|
||||
</Border>
|
||||
<ScrollViewer
|
||||
Grid.Column="1"
|
||||
Margin="5 0"
|
||||
VerticalAlignment="Center"
|
||||
x:Name="PART_ContentHost" />
|
||||
<TextBlock IsHitTestVisible="False"
|
||||
Grid.Column="1"
|
||||
Opacity="0.3"
|
||||
Text="{DynamicResource mw_folder_not_found}"
|
||||
Margin="7 0 5 1"
|
||||
VerticalAlignment="Center"
|
||||
Visibility="Collapsed"
|
||||
x:Name="Placeholder" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="Text" Value="">
|
||||
<Setter TargetName="Placeholder"
|
||||
Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="{x:Type ComboBox}">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Border}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource Foreground}"/>
|
||||
<Setter Property="Height" Value="30"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Padding" Value="10 0"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="{x:Type ComboBox}">
|
||||
<Grid>
|
||||
<ToggleButton x:Name="ToggleButton"
|
||||
Focusable="False"
|
||||
Background="Transparent"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
|
||||
ClickMode="Press">
|
||||
<ToggleButton.Template>
|
||||
<ControlTemplate TargetType="{x:Type ToggleButton}">
|
||||
<Border x:Name="Border" CornerRadius="3"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
Background="Transparent">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition/>
|
||||
<ColumnDefinition Width="20"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Grid.Column="1" BorderBrush="{DynamicResource Border}"
|
||||
BorderThickness="1 0 0 0" Margin="0 5"/>
|
||||
<Path x:Name="Arrow" Grid.Column="1"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Data="M0,0 L4,4 L8,0" Stroke="{DynamicResource MutedForeground}"
|
||||
StrokeThickness="1.5"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Border" Property="Background" Value="{DynamicResource Secondary}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</ToggleButton.Template>
|
||||
</ToggleButton>
|
||||
<ContentPresenter x:Name="ContentSite"
|
||||
IsHitTestVisible="False"
|
||||
Content="{TemplateBinding SelectionBoxItem}"
|
||||
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
|
||||
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}"
|
||||
Margin="10,0,25,0"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Left"/>
|
||||
<Popup x:Name="Popup"
|
||||
Placement="Bottom"
|
||||
IsOpen="{TemplateBinding IsDropDownOpen}"
|
||||
AllowsTransparency="True"
|
||||
Focusable="False"
|
||||
PopupAnimation="Slide">
|
||||
<Grid x:Name="DropDown"
|
||||
SnapsToDevicePixels="True"
|
||||
MinWidth="{TemplateBinding ActualWidth}"
|
||||
MaxHeight="{TemplateBinding MaxDropDownHeight}">
|
||||
<Border x:Name="DropDownBorder"
|
||||
CornerRadius="3"
|
||||
Margin="0 2 0 0"
|
||||
Background="{DynamicResource Background}"
|
||||
BorderBrush="{DynamicResource Border}"
|
||||
BorderThickness="1">
|
||||
<ScrollViewer Margin="4" SnapsToDevicePixels="True">
|
||||
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Contained"/>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Popup>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="{x:Type ComboBoxItem}">
|
||||
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource Foreground}"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Padding" Value="8 5"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="{x:Type ComboBoxItem}">
|
||||
<Border x:Name="Border" CornerRadius="3" Padding="{TemplateBinding Padding}"
|
||||
Background="Transparent">
|
||||
<ContentPresenter/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsHighlighted" Value="True">
|
||||
<Setter TargetName="Border" Property="Background" Value="{DynamicResource Secondary}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Border" Property="Background" Value="{DynamicResource Accent}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<Grid x:Class="WandEnhancer.View.Controls.PopupHost"
|
||||
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"
|
||||
Visibility="Collapsed">
|
||||
<Border x:Name="Splash" Background="Black" CornerRadius="7"
|
||||
Opacity="0.45"
|
||||
MouseLeftButtonDown="HidePopup"/>
|
||||
|
||||
<Border Background="{DynamicResource Background}" d:Margin="0"
|
||||
Margin="0 40 0 40" x:Name="PopupPresenter" Width="Auto" Height="Auto"
|
||||
BorderBrush="{DynamicResource Border}" BorderThickness="1" MinWidth="300"
|
||||
VerticalAlignment="Center" HorizontalAlignment="Center" CornerRadius="4" Padding="15 10 10 15">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition/>
|
||||
</Grid.RowDefinitions>
|
||||
<Button BorderThickness="0" BorderBrush="Transparent"
|
||||
Tag="{StaticResource CloseIcon}"
|
||||
Width="25" Height="25" Padding="8" Background="Transparent"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Top" Click="HidePopup"
|
||||
x:Name="cancel">
|
||||
<Button.Style>
|
||||
<Style BasedOn="{StaticResource IconButton}" TargetType="Button">
|
||||
<Setter Property="Foreground" Value="{DynamicResource MutedForeground}"/>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Foreground}"/>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Button.Style>
|
||||
</Button>
|
||||
|
||||
<StackPanel Grid.Row="0" x:Name="TitleContainer" Orientation="Horizontal">
|
||||
<TextBlock x:Name="Title" Foreground="{DynamicResource Foreground}"
|
||||
HorizontalAlignment="Left" FontWeight="Bold" FontSize="16"
|
||||
VerticalAlignment="Bottom"/>
|
||||
</StackPanel>
|
||||
|
||||
<ContentPresenter x:Name="Presenter" Margin="0 20 0 0"
|
||||
Content="{Binding PopupContent}" Grid.Row="2"/>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media.Animation;
|
||||
|
||||
namespace WandEnhancer.View.Controls
|
||||
{
|
||||
public partial class PopupHost : Grid
|
||||
{
|
||||
internal Action Closed;
|
||||
|
||||
public static readonly DependencyProperty PopupContentProperty =
|
||||
DependencyProperty.Register("PopupContent", typeof(object), typeof(PopupHost), new PropertyMetadata(null));
|
||||
|
||||
internal readonly SemaphoreSlim OpenedSemaphore = new SemaphoreSlim(1, 1);
|
||||
|
||||
private DoubleAnimation OpeningAnimation;
|
||||
private DoubleAnimation ClosingAnimation;
|
||||
|
||||
|
||||
public bool IsOpen
|
||||
{
|
||||
get => this.Visibility == Visibility.Visible;
|
||||
set
|
||||
{
|
||||
if (value)
|
||||
{
|
||||
if(OpenedSemaphore.CurrentCount == 0)
|
||||
return;
|
||||
|
||||
|
||||
Visibility = Visibility.Visible;
|
||||
cancel.Focus();
|
||||
PopupPresenter.BeginAnimation(OpacityProperty, OpeningAnimation);
|
||||
OpenedSemaphore.Wait();
|
||||
}
|
||||
else
|
||||
{
|
||||
PopupPresenter.BeginAnimation(OpacityProperty, ClosingAnimation);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public object PopupContent
|
||||
{
|
||||
get => GetValue(PopupContentProperty);
|
||||
set => SetValue(PopupContentProperty, value);
|
||||
}
|
||||
|
||||
private void HidePopup(object sender, EventArgs e)
|
||||
{
|
||||
if (OpenedSemaphore.CurrentCount == 1)
|
||||
return;
|
||||
|
||||
IsOpen = false;
|
||||
}
|
||||
|
||||
private void OnClosing(object sender, EventArgs e)
|
||||
{
|
||||
if (PopupContent == null)
|
||||
return;
|
||||
|
||||
Visibility = Visibility.Collapsed;
|
||||
Closed?.Invoke();
|
||||
if (PopupContent is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
PopupContent = null;
|
||||
Closed = null;
|
||||
OpenedSemaphore.Release();
|
||||
}
|
||||
|
||||
public PopupHost()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
PreviewKeyDown += (sender, e) =>
|
||||
{
|
||||
if (e.Key != Key.Escape)
|
||||
return;
|
||||
|
||||
HidePopup(null, null);
|
||||
e.Handled = true;
|
||||
};
|
||||
|
||||
OpeningAnimation = new DoubleAnimation(0, 1, new Duration(TimeSpan.FromSeconds(0.4)))
|
||||
{
|
||||
EasingFunction = App.Current.FindResource("BaseAnimationFunction") as IEasingFunction
|
||||
};
|
||||
OpeningAnimation.Freeze();
|
||||
|
||||
ClosingAnimation = new DoubleAnimation(1, 0, new Duration(TimeSpan.FromSeconds(0.2)));
|
||||
ClosingAnimation.Completed += OnClosing;
|
||||
ClosingAnimation.Freeze();
|
||||
|
||||
this.Splash.DataContext = this;
|
||||
this.PopupPresenter.DataContext = this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace WandEnhancer.View.MainWindow
|
||||
{
|
||||
public enum ELogType
|
||||
{
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
Success
|
||||
}
|
||||
public class LogEntry
|
||||
{
|
||||
public ELogType LogType { get; set; }
|
||||
public string Message { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
<Window x:Class="WandEnhancer.View.MainWindow.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:local="clr-namespace:WandEnhancer.View.MainWindow"
|
||||
xmlns:controls="clr-namespace:WandEnhancer.View.Controls"
|
||||
mc:Ignorable="d"
|
||||
d:DataContext="{d:DesignInstance local:MainWindowVm}"
|
||||
Title="WandEnhancer"
|
||||
Height="510" MaxHeight="510"
|
||||
Width="780" MaxWidth="780"
|
||||
Opacity="0.97"
|
||||
Background="Transparent"
|
||||
WindowStyle="None"
|
||||
FontFamily="{StaticResource Inter}"
|
||||
AllowsTransparency="True">
|
||||
<Border CornerRadius="7" Background="{DynamicResource Background}"
|
||||
BorderBrush="{DynamicResource Border}"
|
||||
BorderThickness="1" Margin="10">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect BlurRadius="15" Direction="-90"
|
||||
RenderingBias="Quality" ShadowDepth="2"/>
|
||||
</Border.Effect>
|
||||
|
||||
<Grid>
|
||||
<Grid Background="Transparent" VerticalAlignment="Top"
|
||||
MouseLeftButtonDown="OnDragMove" Height="55">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="25 0 0 0">
|
||||
<Viewbox VerticalAlignment="Center" Width="32" Height="32">
|
||||
<Path
|
||||
Fill="White" Data="{StaticResource Logo}"/>
|
||||
</Viewbox>
|
||||
<TextBlock Foreground="{DynamicResource Foreground}"
|
||||
FontWeight="SemiBold" Opacity="0.9"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="18" Margin="10 0 0 0">
|
||||
<Bold>
|
||||
WandEnhancer
|
||||
</Bold>
|
||||
</TextBlock>
|
||||
<TextBlock x:Name="VersionLabel" VerticalAlignment="Bottom"
|
||||
Opacity="0.7" FontSize="10" Margin="5 0 0 5"
|
||||
Foreground="{DynamicResource Foreground}">
|
||||
v 1.0.0
|
||||
</TextBlock>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
|
||||
<Button
|
||||
Margin="0 0 5 0"
|
||||
Width="25" Height="25" Padding="5.5"
|
||||
Style="{StaticResource IconButton}"
|
||||
Tag="{StaticResource CogIcon}"
|
||||
Command="{Binding OpenSettingsCommand}"
|
||||
/>
|
||||
<Button
|
||||
Margin="9 0 15 0"
|
||||
Tag="{StaticResource CloseIcon}"
|
||||
Width="25" Height="25" Padding="6.5"
|
||||
HorizontalAlignment="Right"
|
||||
Click="OnClosing"
|
||||
VerticalAlignment="Center">
|
||||
<Button.Resources>
|
||||
<CornerRadius x:Key="CornerRadius">5 5 5 5</CornerRadius>
|
||||
</Button.Resources>
|
||||
<Button.Style>
|
||||
<Style BasedOn="{StaticResource IconButton}" TargetType="Button">
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background" Value="{DynamicResource Secondary}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource Destructive}"/>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Button.Style>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
<Border Background="{DynamicResource Border}" Height="1" VerticalAlignment="Bottom"></Border>
|
||||
|
||||
</Grid>
|
||||
<Grid Margin="0 55 0 0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition/>
|
||||
<RowDefinition Height="50"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid Margin="10" Cursor="Hand" Background="Transparent">
|
||||
<TextBox Style="{StaticResource TitledTextBox}"
|
||||
IsReadOnly="True"
|
||||
Text="{Binding WeModInfo.RootDirectory, Mode=OneWay}"
|
||||
VerticalAlignment="Center">
|
||||
</TextBox>
|
||||
<Grid.InputBindings>
|
||||
<MouseBinding Gesture="LeftClick" Command="{Binding SetFolderPathCommand}" />
|
||||
</Grid.InputBindings>
|
||||
</Grid>
|
||||
|
||||
|
||||
<Border Grid.Row="1" BorderBrush="{DynamicResource Border}" BorderThickness="1"
|
||||
Margin="10 0 10 10"
|
||||
CornerRadius="5">
|
||||
<Grid>
|
||||
<ListBox ItemsSource="{Binding LogList}" SelectionMode="Single"
|
||||
BorderBrush="Transparent" BorderThickness="0"
|
||||
Background="Transparent"
|
||||
x:Name="LogList"
|
||||
Padding="6"
|
||||
ScrollViewer.VerticalScrollBarVisibility="Hidden"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||
ScrollViewer.CanContentScroll="False">
|
||||
<ListBox.ItemContainerStyle>
|
||||
<Style TargetType="ListBoxItem">
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
|
||||
<Setter Property="Margin" Value="0 0 0 5"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<Border x:Name="Card" HorizontalAlignment="Left"
|
||||
Background="#08fc81"
|
||||
BorderBrush="{DynamicResource Border}"
|
||||
Padding="5 3 5 3" CornerRadius="3">
|
||||
<TextBox Text="{Binding Message}"
|
||||
Cursor="IBeam" FontSize="13"
|
||||
Background="Transparent" TextWrapping="Wrap"
|
||||
BorderThickness="0"
|
||||
IsReadOnly="True"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding LogType}" Value="Error">
|
||||
<Setter TargetName="Card" Property="Background" Value="#f04343"></Setter>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding LogType}" Value="Info">
|
||||
<Setter TargetName="Card" Property="Background" Value="#FFF"></Setter>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding LogType}" Value="Warn">
|
||||
<Setter TargetName="Card" Property="Background" Value="#facc15"></Setter>
|
||||
</DataTrigger>
|
||||
</ControlTemplate.Triggers>
|
||||
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ListBox.ItemContainerStyle>
|
||||
</ListBox>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Top"
|
||||
Margin="0 4 4 0">
|
||||
<Button Width="22" Height="22" Padding="4"
|
||||
Style="{StaticResource IconButton}"
|
||||
Tag="{StaticResource CopyIcon}"
|
||||
ToolTip="{DynamicResource mw_copy_logs}"
|
||||
Command="{Binding CopyLogsCommand}"/>
|
||||
<Button Width="22" Height="22" Padding="4" Margin="4 0 0 0"
|
||||
Style="{StaticResource IconButton}"
|
||||
Tag="{StaticResource ExportIcon}"
|
||||
ToolTip="{DynamicResource mw_export_logs}"
|
||||
Command="{Binding ExportLogsCommand}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
|
||||
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal" Grid.Row="2" Margin="10 0 10 10">
|
||||
<Grid>
|
||||
<Grid HorizontalAlignment="Right" >
|
||||
<Grid.Style>
|
||||
<Style TargetType="{x:Type Grid}">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsPatchEnabled}" Value="False">
|
||||
<Setter Property="Cursor" Value="No"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Grid.Style>
|
||||
<Button Style="{StaticResource ColoredButton}"
|
||||
IsEnabled="{Binding IsPatchEnabled}"
|
||||
FontWeight="Bold" FontSize="16" Width="200"
|
||||
Command="{Binding ApplyPatchCommand}"
|
||||
Content="{DynamicResource mw_patch}"/>
|
||||
</Grid>
|
||||
<Button HorizontalAlignment="Right"
|
||||
Command="{Binding RestoreBackupCommand}"
|
||||
FontWeight="Bold" FontSize="16" Width="200"
|
||||
Style="{StaticResource ColoredButton}"
|
||||
IsEnabled="{Binding IsIdle}"
|
||||
Visibility="{Binding AlreadyPatched, Converter={StaticResource ToVisibilityConverter}}"
|
||||
Content="{DynamicResource mw_restore}"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
|
||||
|
||||
<DockPanel Grid.Row="2" Margin="10 0 10 10">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center"
|
||||
Cursor="Hand"
|
||||
HorizontalAlignment="Left"
|
||||
MouseDown="OpenSourceClicked"
|
||||
Background="Transparent">
|
||||
<Viewbox VerticalAlignment="Center" Width="32" Height="32">
|
||||
<Path
|
||||
Fill="White" Data="{StaticResource GitHub}"/>
|
||||
</Viewbox>
|
||||
<Grid>
|
||||
<TextBlock Margin="8 0 0 0" FontSize="10" Foreground="{DynamicResource AccentForeground}">
|
||||
<Hyperlink Foreground="{DynamicResource AccentForeground}"><Run Text="{DynamicResource mw_source_code}"/></Hyperlink>
|
||||
<LineBreak/>
|
||||
<Run Text="{DynamicResource mw_made_by}"/>
|
||||
|
||||
|
||||
<LineBreak/>
|
||||
<Run Foreground="{DynamicResource MutedForeground}" Text="{DynamicResource mw_star_hint}"/>
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
|
||||
<controls:PopupHost x:Name="PopupHost"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace WandEnhancer.View.MainWindow
|
||||
{
|
||||
/// <summary>
|
||||
/// Interaction logic for MainWindow.xaml
|
||||
/// </summary>
|
||||
public partial class MainWindow : IShellView
|
||||
{
|
||||
public static MainWindow Instance;
|
||||
public readonly MainWindowVm ViewModel;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
this.ViewModel = new MainWindowVm(this, new WindowsFileDialogs());
|
||||
this.DataContext = ViewModel;
|
||||
VersionLabel.Text = Constants.Version.ToString();
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
public void OpenPopup(FrameworkElement content, string title = null)
|
||||
{
|
||||
this.PopupHost.PopupContent = content;
|
||||
PopupHost.Title.Text = title;
|
||||
PopupHost.IsOpen = true;
|
||||
}
|
||||
|
||||
public void ScrollLogIntoView(LogEntry entry)
|
||||
{
|
||||
this.LogList.ScrollIntoView(entry);
|
||||
}
|
||||
|
||||
private void OnDragMove(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
this.DragMove();
|
||||
}
|
||||
|
||||
private void OnClosing(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Application.Current.Shutdown();
|
||||
}
|
||||
|
||||
public void ClosePopup()
|
||||
{
|
||||
PopupHost.IsOpen = false;
|
||||
}
|
||||
|
||||
private void OpenSourceClicked(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using WandEnhancer.Core;
|
||||
using WandEnhancer.Core.Services;
|
||||
using WandEnhancer.Models;
|
||||
using WandEnhancer.ReactiveUICore;
|
||||
using WandEnhancer.Utils;
|
||||
using WandEnhancer.View.Popups;
|
||||
using Application = System.Windows.Application;
|
||||
|
||||
namespace WandEnhancer.View.MainWindow
|
||||
{
|
||||
public class MainWindowVm : ObservableObject
|
||||
{
|
||||
private const string LogExportFilter = "Text files (*.txt)|*.txt|All files (*.*)|*.*";
|
||||
|
||||
private readonly IShellView _shell;
|
||||
private readonly IFileDialogs _dialogs;
|
||||
public ObservableCollection<LogEntry> LogList { get; } = new ObservableCollection<LogEntry>();
|
||||
private WeModConfig _weModConfig;
|
||||
|
||||
public WeModConfig WeModInfo
|
||||
{
|
||||
get => _weModConfig;
|
||||
set => SetProperty(ref _weModConfig, value);
|
||||
}
|
||||
|
||||
private void UseInstall(WeModConfig config)
|
||||
{
|
||||
WeModInfo = config;
|
||||
if (config == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
public bool IsPatchEnabled
|
||||
{
|
||||
get => _isPatchEnabled;
|
||||
set => SetProperty(ref _isPatchEnabled, value);
|
||||
}
|
||||
|
||||
private bool _alreadyPatched;
|
||||
|
||||
public bool AlreadyPatched
|
||||
{
|
||||
get => _alreadyPatched;
|
||||
set => SetProperty(ref _alreadyPatched, value);
|
||||
}
|
||||
|
||||
private bool _isBusy;
|
||||
|
||||
/// <summary>True while a patch or restore runs; both are long file operations.</summary>
|
||||
public bool IsBusy
|
||||
{
|
||||
get => _isBusy;
|
||||
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 ApplyPatchCommand { get; }
|
||||
public RelayCommand RestoreBackupCommand { get; }
|
||||
public RelayCommand OpenSettingsCommand { get; }
|
||||
public RelayCommand CopyLogsCommand { get; }
|
||||
public RelayCommand ExportLogsCommand { get; }
|
||||
|
||||
private void OnFolderPathSelection(object obj)
|
||||
{
|
||||
string selectedPath = _dialogs.PickFolder(
|
||||
LocalizationManager.Get("dialog_pick_install"),
|
||||
Environment.GetEnvironmentVariable("LOCALAPPDATA"));
|
||||
if (selectedPath == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var info = WeModInstalls.CheckWeModPath(selectedPath);
|
||||
if (info == null)
|
||||
{
|
||||
Log(LocalizationManager.Format("log_invalid_directory", Path.GetFileName(selectedPath)), ELogType.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
UseInstall(info);
|
||||
}
|
||||
|
||||
// Restore does the same heavy file IO as Patch, so it runs off the UI thread too.
|
||||
private async void OnBackupRestoring(object param)
|
||||
{
|
||||
if (WeModInfo == null)
|
||||
{
|
||||
Log(LocalizationManager.Get("log_no_directory"), ELogType.Warn);
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
bool restored = await Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
new Enhancer(WeModInfo, Log).Restore();
|
||||
return true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log(LocalizationManager.Format("log_restore_failed", e.Message), ELogType.Error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
IsBusy = false;
|
||||
if (restored)
|
||||
{
|
||||
AlreadyPatched = false;
|
||||
IsPatchEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPatching(object param)
|
||||
{
|
||||
if (WeModInfo == null)
|
||||
{
|
||||
Log(LocalizationManager.Get("log_no_directory"), ELogType.Warn);
|
||||
return;
|
||||
}
|
||||
|
||||
_shell.OpenPopup(new PatchVectorsPopup(async config =>
|
||||
{
|
||||
_shell.ClosePopup();
|
||||
IsPatchEnabled = false;
|
||||
IsBusy = true;
|
||||
await Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
new Enhancer(WeModInfo, Log, config).Patch();
|
||||
AlreadyPatched = true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log(LocalizationManager.Format("log_patch_failed", e.Message), ELogType.Error);
|
||||
IsPatchEnabled = true;
|
||||
}
|
||||
});
|
||||
IsBusy = false;
|
||||
}), LocalizationManager.Get("pv_popup_title"));
|
||||
}
|
||||
|
||||
private void Log(string message, ELogType logType)
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
var entry = new LogEntry
|
||||
{
|
||||
LogType = logType,
|
||||
Message = $"[{logType.ToString().ToUpper()}] {message}"
|
||||
};
|
||||
LogList.Add(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 void OnOpenSettings(object param)
|
||||
{
|
||||
_shell.OpenPopup(new SettingsPopup(), LocalizationManager.Get("settings_title"));
|
||||
}
|
||||
|
||||
private string BuildLogReport()
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
foreach (var entry in LogList)
|
||||
{
|
||||
builder.AppendLine(entry.Message);
|
||||
}
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private void OnCopyLogs(object param)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllText(path, BuildLogReport());
|
||||
Log(LocalizationManager.Format("log_exported", path), ELogType.Success);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log(LocalizationManager.Format("log_export_failed", e.Message), ELogType.Error);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
Log(LocalizationManager.Format("log_open_link_failed", url), ELogType.Warn);
|
||||
}
|
||||
|
||||
public MainWindowVm(IShellView shell, IFileDialogs dialogs)
|
||||
{
|
||||
_shell = shell;
|
||||
_dialogs = dialogs;
|
||||
SetFolderPathCommand = new RelayCommand(OnFolderPathSelection);
|
||||
ApplyPatchCommand = new RelayCommand(OnPatching);
|
||||
RestoreBackupCommand = new RelayCommand(OnBackupRestoring);
|
||||
OpenSettingsCommand = new RelayCommand(OnOpenSettings);
|
||||
CopyLogsCommand = new RelayCommand(OnCopyLogs, HasLogs);
|
||||
ExportLogsCommand = new RelayCommand(OnExportLogs, HasLogs);
|
||||
|
||||
UseInstall(WeModInstalls.FindWeMod());
|
||||
if (WeModInfo == null)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<UserControl x:Class="WandEnhancer.View.Popups.PatchVectorsPopup"
|
||||
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.Popups"
|
||||
xmlns:controls="clr-namespace:WandEnhancer.View.Controls"
|
||||
mc:Ignorable="d"
|
||||
d:DesignHeight="Auto" d:DesignWidth="Auto"
|
||||
Background="{DynamicResource Background}"
|
||||
Foreground="{DynamicResource MutedForeground}"
|
||||
FontWeight="Medium"
|
||||
FontSize="13">
|
||||
<Grid MinWidth="430">
|
||||
<Grid Visibility="Visible" Margin="0 0 5 0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="27" />
|
||||
<RowDefinition Height="27" />
|
||||
<RowDefinition Height="27" />
|
||||
<RowDefinition Height="27" />
|
||||
<RowDefinition Height="27" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" VerticalAlignment="Center" Text="{DynamicResource pv_activate_pro}" />
|
||||
<CheckBox Grid.Row="0" Grid.Column="1" x:Name="ActivateProBox" HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||
IsChecked="True" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" VerticalAlignment="Center" Text="{DynamicResource pv_devtools}" />
|
||||
<CheckBox Grid.Row="1" Grid.Column="1" x:Name="DevToolsHotkeyBox" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" VerticalAlignment="Center" Text="{DynamicResource pv_disable_updates}" />
|
||||
<CheckBox Grid.Row="2" Grid.Column="1" x:Name="DisableUpdateBox" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||
|
||||
<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" />
|
||||
|
||||
<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"
|
||||
Background="{DynamicResource Muted}">
|
||||
<StackPanel>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource Foreground}"
|
||||
Text="{DynamicResource pv_custom_scripts}" />
|
||||
<Button Grid.Column="1" Padding="10 4" Content="{DynamicResource pv_add_js_scripts}"
|
||||
Click="OnAddScriptClick" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Margin="0 7 0 0" FontSize="11" TextWrapping="Wrap"
|
||||
Opacity="0.8"
|
||||
Text="{DynamicResource pv_custom_scripts_hint}" />
|
||||
|
||||
<ItemsControl x:Name="ScriptList" Margin="0 2 0 0">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Margin="0 6 6 0" Padding="8 3"
|
||||
Background="{DynamicResource Card}"
|
||||
BorderBrush="{DynamicResource Border}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="3">
|
||||
<DockPanel LastChildFill="True">
|
||||
<Button DockPanel.Dock="Right" Tag="{Binding}" Content="x"
|
||||
BorderThickness="0" Padding="6 0" Margin="6 0 0 0"
|
||||
Click="OnRemoveScriptClick" />
|
||||
<TextBlock VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource Foreground}"
|
||||
Text="{Binding FileName}" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<TextBlock x:Name="NoScriptsText" Margin="0 7 0 0" FontSize="11"
|
||||
Opacity="0.7"
|
||||
Text="{DynamicResource pv_no_custom_scripts}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Button Grid.Row="6" Grid.ColumnSpan="2" Padding="0 5 0 5" Margin="0 15 0 0" Content="{DynamicResource pv_start}"
|
||||
Click="OnPatchButtonClick" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,135 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using Microsoft.Win32;
|
||||
using WandEnhancer.Models;
|
||||
using WandEnhancer.Utils;
|
||||
|
||||
namespace WandEnhancer.View.Popups
|
||||
{
|
||||
public partial class PatchVectorsPopup : UserControl
|
||||
{
|
||||
private const string JavaScriptDialogFilter = "JavaScript files (*.js)|*.js";
|
||||
|
||||
private readonly Action<PatchConfig> _onApply;
|
||||
private readonly ObservableCollection<SelectedScript> _selectedScripts = new ObservableCollection<SelectedScript>();
|
||||
|
||||
public PatchVectorsPopup(Action<PatchConfig> onApply)
|
||||
{
|
||||
_onApply = onApply;
|
||||
InitializeComponent();
|
||||
ScriptList.ItemsSource = _selectedScripts;
|
||||
UpdateScriptsEmptyState();
|
||||
}
|
||||
|
||||
private void OnAddScriptClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dialog = new OpenFileDialog
|
||||
{
|
||||
Filter = JavaScriptDialogFilter,
|
||||
Multiselect = true,
|
||||
CheckFileExists = true
|
||||
};
|
||||
|
||||
if (dialog.ShowDialog() != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var path in dialog.FileNames.Where(WeModInstalls.IsJavaScriptFile))
|
||||
{
|
||||
AddScript(path);
|
||||
}
|
||||
|
||||
if (_selectedScripts.Count > 0)
|
||||
{
|
||||
RemoteWebPanelPreviewBox.IsChecked = true;
|
||||
}
|
||||
|
||||
UpdateScriptsEmptyState();
|
||||
}
|
||||
|
||||
private void OnRemoveScriptClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var button = sender as Button;
|
||||
var script = button?.Tag as SelectedScript;
|
||||
if (script == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_selectedScripts.Remove(script);
|
||||
UpdateScriptsEmptyState();
|
||||
}
|
||||
|
||||
private void OnPatchButtonClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (ActivateProBox.IsChecked != true && DisableUpdateBox.IsChecked != true &&
|
||||
DevToolsHotkeyBox.IsChecked != true && RemoteWebPanelPreviewBox.IsChecked != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var result = new HashSet<EPatchType>();
|
||||
if (ActivateProBox.IsChecked == true)
|
||||
{
|
||||
result.Add(EPatchType.ActivatePro);
|
||||
}
|
||||
|
||||
if (DisableUpdateBox.IsChecked == true)
|
||||
{
|
||||
result.Add(EPatchType.DisableUpdates);
|
||||
}
|
||||
|
||||
if (DevToolsHotkeyBox.IsChecked == true)
|
||||
{
|
||||
result.Add(EPatchType.DevToolsOnF12);
|
||||
}
|
||||
|
||||
if (RemoteWebPanelPreviewBox.IsChecked == true)
|
||||
{
|
||||
result.Add(EPatchType.RemoteWebPanelPreview);
|
||||
}
|
||||
|
||||
_onApply(new PatchConfig
|
||||
{
|
||||
PatchTypes = result,
|
||||
CustomScriptPaths = _selectedScripts.Select(script => script.FullPath).ToList(),
|
||||
AutoApplyAfterUpdate = AutoApplyBox.IsChecked == true
|
||||
});
|
||||
}
|
||||
|
||||
private void AddScript(string path)
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
if (_selectedScripts.Any(script => string.Equals(script.FullPath, fullPath, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_selectedScripts.Add(new SelectedScript(fullPath));
|
||||
}
|
||||
|
||||
private void UpdateScriptsEmptyState()
|
||||
{
|
||||
NoScriptsText.Visibility = _selectedScripts.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
|
||||
private sealed class SelectedScript
|
||||
{
|
||||
public SelectedScript(string fullPath)
|
||||
{
|
||||
FullPath = fullPath;
|
||||
FileName = Path.GetFileName(fullPath);
|
||||
}
|
||||
|
||||
public string FullPath { get; }
|
||||
|
||||
public string FileName { get; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<UserControl x:Class="WandEnhancer.View.Popups.SettingsPopup"
|
||||
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="250">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid Grid.Row="0" Margin="0 0 0 15">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBlock Grid.Column="0" VerticalAlignment="Center"
|
||||
Text="{DynamicResource settings_language}" />
|
||||
<ComboBox Grid.Column="1" x:Name="LanguageComboBox"
|
||||
Width="130"
|
||||
SelectionChanged="OnLanguageSelectionChanged">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding DisplayName}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
|
||||
<Button Grid.Row="1" Padding="0 5 0 5" Margin="0 5 0 0"
|
||||
Content="{DynamicResource settings_save}"
|
||||
Click="OnSaveClick" />
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using WandEnhancer.Core;
|
||||
using WandEnhancer.Core.Services;
|
||||
using WandEnhancer.View.MainWindow;
|
||||
|
||||
namespace WandEnhancer.View.Popups
|
||||
{
|
||||
public partial class SettingsPopup : UserControl
|
||||
{
|
||||
private CultureInfo _selectedLanguage;
|
||||
|
||||
public SettingsPopup()
|
||||
{
|
||||
InitializeComponent();
|
||||
LoadLanguages();
|
||||
}
|
||||
|
||||
private void LoadLanguages()
|
||||
{
|
||||
var items = LocalizationManager.SupportedLanguages
|
||||
.Select(c => new LanguageItem
|
||||
{
|
||||
Culture = c,
|
||||
DisplayName = LocalizationManager.GetLanguageDisplayName(c)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
LanguageComboBox.ItemsSource = items;
|
||||
|
||||
var currentItem = items.FirstOrDefault(i => i.Culture.Name == LocalizationManager.CurrentLanguage?.Name);
|
||||
if (currentItem != null)
|
||||
{
|
||||
LanguageComboBox.SelectedItem = currentItem;
|
||||
}
|
||||
|
||||
_selectedLanguage = LocalizationManager.CurrentLanguage;
|
||||
}
|
||||
|
||||
private void OnLanguageSelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (LanguageComboBox.SelectedItem is LanguageItem item)
|
||||
{
|
||||
_selectedLanguage = item.Culture;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSaveClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_selectedLanguage != null &&
|
||||
(LocalizationManager.CurrentLanguage == null ||
|
||||
_selectedLanguage.Name != LocalizationManager.CurrentLanguage.Name))
|
||||
{
|
||||
LocalizationManager.CurrentLanguage = _selectedLanguage;
|
||||
}
|
||||
|
||||
MainWindow.MainWindow.Instance?.ClosePopup();
|
||||
}
|
||||
|
||||
private class LanguageItem
|
||||
{
|
||||
public CultureInfo Culture { get; set; }
|
||||
public string DisplayName { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<Import Project="..\packages\ILRepack.2.0.41\build\ILRepack.props" Condition="Exists('..\packages\ILRepack.2.0.41\build\ILRepack.props')" />
|
||||
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
|
||||
<PropertyGroup>
|
||||
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
||||
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
|
||||
<ProjectGuid>{106D3E44-ECBB-4EF3-84B2-5FC6BCF77727}</ProjectGuid>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<RootNamespace>WandEnhancer</RootNamespace>
|
||||
<AssemblyName>WandEnhancer</AssemblyName>
|
||||
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
|
||||
<FileAlignment>512</FileAlignment>
|
||||
<ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||
<ApplicationIcon>..\assets\appicon.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<DebugSymbols>true</DebugSymbols>
|
||||
<DebugType>full</DebugType>
|
||||
<Optimize>false</Optimize>
|
||||
<OutputPath>bin\Debug\</OutputPath>
|
||||
<DefineConstants>DEBUG;TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
<Prefer32bit>false</Prefer32bit>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<DebugType>none</DebugType>
|
||||
<Optimize>true</Optimize>
|
||||
<OutputPath>bin\Release\</OutputPath>
|
||||
<DefineConstants>TRACE</DefineConstants>
|
||||
<ErrorReport>prompt</ErrorReport>
|
||||
<WarningLevel>4</WarningLevel>
|
||||
<Prefer32bit>false</Prefer32bit>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<StartupObject>WandEnhancer.Program</StartupObject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="Newtonsoft.Json, Version=13.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
|
||||
<HintPath>..\packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="System" />
|
||||
<Reference Include="System.Core" />
|
||||
<Reference Include="System.Data" />
|
||||
<Reference Include="System.Windows.Forms" />
|
||||
<Reference Include="System.Xml" />
|
||||
<Reference Include="System.Xaml">
|
||||
<RequiredTargetFramework>4.0</RequiredTargetFramework>
|
||||
</Reference>
|
||||
<Reference Include="WindowsBase" />
|
||||
<Reference Include="PresentationCore" />
|
||||
<Reference Include="PresentationFramework" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ApplicationDefinition Include="App.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
<SubType>Designer</SubType>
|
||||
</ApplicationDefinition>
|
||||
<Compile Include="Constants.cs" />
|
||||
<Compile Include="Converters\BaseBooleanConverter.cs" />
|
||||
<Compile Include="Converters\ToVisibilityConverter.cs" />
|
||||
<Compile Include="Core\Enhancer.cs" />
|
||||
<Compile Include="Core\EnhancerConfig.cs" />
|
||||
<Compile Include="Core\FuseLauncher.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\SettingsManager.cs" />
|
||||
<Compile Include="Models\WeModConfig.cs" />
|
||||
<Compile Include="Models\PatchConfig.cs" />
|
||||
<Compile Include="Program.cs" />
|
||||
<Compile Include="ReactiveUICore\ObservableObject.cs" />
|
||||
<Compile Include="ReactiveUICore\RelayCommand.cs" />
|
||||
<Compile Include="Utils\ProcessTerminator.cs" />
|
||||
<Compile Include="Utils\WeModInstalls.cs" />
|
||||
<Compile Include="View\Controls\PopupHost.xaml.cs">
|
||||
<DependentUpon>PopupHost.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="View\MainWindow\Logs.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\Popups\PatchVectorsPopup.xaml.cs">
|
||||
<DependentUpon>PatchVectorsPopup.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="View\Popups\SettingsPopup.xaml.cs">
|
||||
<DependentUpon>SettingsPopup.xaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Include="App.xaml.cs">
|
||||
<DependentUpon>App.xaml</DependentUpon>
|
||||
<SubType>Code</SubType>
|
||||
</Compile>
|
||||
<Page Include="Locale\lang.en-US.xaml" />
|
||||
<Page Include="Locale\lang.zh-CN.xaml" />
|
||||
<Page Include="Locale\lang.de-DE.xaml" />
|
||||
<Page Include="Locale\lang.fr-FR.xaml" />
|
||||
<Page Include="Locale\lang.es-ES.xaml" />
|
||||
<Page Include="Locale\lang.it-IT.xaml" />
|
||||
<Page Include="Locale\lang.pt-BR.xaml" />
|
||||
<Page Include="Locale\lang.pl-PL.xaml" />
|
||||
<Page Include="Locale\lang.ru-RU.xaml" />
|
||||
<Page Include="Locale\lang.uk-UA.xaml" />
|
||||
<Page Include="Locale\lang.ja-JP.xaml" />
|
||||
<Page Include="Locale\lang.tr-TR.xaml" />
|
||||
<Page Include="Style\ColorScheme.xaml" />
|
||||
<Page Include="Style\Icons.xaml" />
|
||||
<Page Include="Style\Styles.xaml" />
|
||||
<Page Include="View\Controls\PopupHost.xaml" />
|
||||
<Page Include="View\MainWindow\MainWindow.xaml" />
|
||||
<Page Include="View\Popups\PatchVectorsPopup.xaml" />
|
||||
<Page Include="View\Popups\SettingsPopup.xaml" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="Properties\AssemblyInfo.cs">
|
||||
<SubType>Code</SubType>
|
||||
</Compile>
|
||||
<Compile Include="Properties\Resources.Designer.cs">
|
||||
<AutoGen>True</AutoGen>
|
||||
<DesignTime>True</DesignTime>
|
||||
<DependentUpon>Resources.resx</DependentUpon>
|
||||
</Compile>
|
||||
<EmbeddedResource Include="Properties\Resources.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="..\assets\appicon.ico">
|
||||
<Link>appicon.ico</Link>
|
||||
</None>
|
||||
<None Include="App.config" />
|
||||
<None Include="packages.config" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="Style\Inter_18pt-Regular.ttf" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AsarSharp\AsarSharp.csproj">
|
||||
<Project>{BEAA604A-402A-4387-8903-A53FC913A26E}</Project>
|
||||
<Name>AsarSharp</Name>
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="..\web-panel\dist\**\*.*" Condition="Exists('..\web-panel\dist\index.html')">
|
||||
<LogicalName>remote-panel/dist/%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Patches\*.js">
|
||||
<LogicalName>patches/%(Filename)%(Extension)</LogicalName>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
|
||||
<PropertyGroup>
|
||||
<ErrorText>This project references NuGet package(s) that are missing on this computer. Enable NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105.The missing file is {0}.</ErrorText>
|
||||
</PropertyGroup>
|
||||
<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 Name="ILRepack" AfterTargets="Build" Condition="'$(Configuration)' == 'Release'">
|
||||
<PropertyGroup>
|
||||
<ILRepackExe>..\packages\ILRepack.2.0.41\tools\ILRepack.exe</ILRepackExe>
|
||||
<MainAssembly>$(OutputPath)$(AssemblyName).exe</MainAssembly>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyList Include="$(OutputPath)*.dll" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<DllList>@(AssemblyList->'%(FullPath)', ' ')</DllList>
|
||||
</PropertyGroup>
|
||||
|
||||
<Delete Files="$(OutputPath)$(AssemblyName).pdb" ContinueOnError="true" />
|
||||
<Exec Command=""$(ILRepackExe)" /allowMultiple /copyattrs /out:"$(OutputPath)$(AssemblyName).exe" "$(MainAssembly)" $(DllList)" />
|
||||
<Delete Files="@(AssemblyList)" ContinueOnError="true" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<packages>
|
||||
<package id="ILRepack" version="2.0.41" targetFramework="net48" developmentDependency="true" />
|
||||
<package id="Microsoft.Build.Framework" version="15.9.20" targetFramework="net48" />
|
||||
<package id="Microsoft.Build.Utilities.Core" version="15.9.20" targetFramework="net48" />
|
||||
<package id="Microsoft.VisualStudio.Setup.Configuration.Interop" version="1.16.30" targetFramework="net48" developmentDependency="true" />
|
||||
<package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" />
|
||||
<package id="System.Collections.Immutable" version="1.5.0" targetFramework="net48" />
|
||||
<package id="System.Runtime.InteropServices.RuntimeInformation" version="4.3.0" targetFramework="net48" />
|
||||
</packages>
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0,0,256,256" width="170px" height="170px" fill-rule="nonzero"><g transform="translate(17.92,17.92) scale(0.86,0.86)"><g fill="#27272a" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><path d="M53.5814,276.83721c-41.10026,0 -74.4186,-33.31834 -74.4186,-74.4186v-148.83721c0,-41.10026 33.31834,-74.4186 74.4186,-74.4186h148.83721c41.10026,0 74.4186,33.31834 74.4186,74.4186v148.83721c0,41.10026 -33.31834,74.4186 -74.4186,74.4186z" id="shape"></path></g><g fill="#ffffff" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g transform="scale(5.12,5.12)"><path d="M47.845,22.185l-20.03,-20.03c-1.543,-1.543 -4.046,-1.553 -5.729,0.002l-19.931,20.028c-1.542,1.542 -1.554,4.045 0,5.727l19.934,19.934c0.772,0.772 1.785,1.16 2.816,1.16c1.026,0 2.07,-0.385 2.91,-1.16l19.933,-19.934c1.605,-1.605 1.648,-4.175 0.097,-5.727zM18,27c-1.105,0 -2,-0.895 -2,-2c0,-1.105 0.895,-2 2,-2c1.105,0 2,0.895 2,2c0,1.105 -0.895,2 -2,2zM25,34c-1.105,0 -2,-0.895 -2,-2c0,-1.105 0.895,-2 2,-2c1.105,0 2,0.895 2,2c0,1.105 -0.895,2 -2,2zM25,20c-1.105,0 -2,-0.895 -2,-2c0,-1.105 0.895,-2 2,-2c1.105,0 2,0.895 2,2c0,1.105 -0.895,2 -2,2zM32,27c-1.105,0 -2,-0.895 -2,-2c0,-1.105 0.895,-2 2,-2c1.105,0 2,0.895 2,2c0,1.105 -0.895,2 -2,2z"></path></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 177 KiB |
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user