diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..8aad221 --- /dev/null +++ b/.githooks/pre-commit @@ -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" \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b4b621a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,54 @@ +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@v4 + with: + fetch-depth: 0 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + 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: | + WandEnhancer/bin/Release/WandEnhancer.exe + CHANGELOG.md + fail_on_unmatched_files: true \ No newline at end of file diff --git a/.github/workflows/validate-release-metadata.yml b/.github/workflows/validate-release-metadata.yml new file mode 100644 index 0000000..9799acc --- /dev/null +++ b/.github/workflows/validate-release-metadata.yml @@ -0,0 +1,18 @@ +name: Validate Release Metadata + +on: + push: + branches: [ "master", "main" ] + pull_request: + branches: [ "master", "main" ] + +jobs: + validate-release-metadata: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Validate version and changelog sync + shell: pwsh + run: ./scripts/validate-release-metadata.ps1 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8e518d7..f782a44 100644 --- a/.gitignore +++ b/.gitignore @@ -142,4 +142,7 @@ packages # App settings (user preferences) appsettings.json -*DotSettings.user \ No newline at end of file +*DotSettings.user +.tmp +.source +web-panel/bridge/wand-remote-bridge.cjs \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 1c1de59..e80cc7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,22 +9,37 @@ This repository patches the Wand Electron app from a .NET Framework WPF desktop - The default local remote port is `3223`. Keep C# and frontend constants aligned. - The embedded panel must stay small because the desktop patcher embeds it and then injects it into Wand's `app.asar`. - Production builds must not include mock data, debug routes, sourcemaps, local fonts, heavy icon libraries, or runtime class helper packages. +- The Electron bridge is authored as modular CommonJS source under `web-panel/bridge/source.cjs` and `web-panel/bridge/bridge-modules/`, but production runtime must be bundled/minified into `web-panel/dist/bridge.cjs` by `pnpm run build:bridge`. Do not copy `bridge-modules` into Wand or embed them as ASAR resources. - 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 shadcn-style local primitives under `web-panel/src/components/ui/`. -- Default renderer scripts live in `web-panel/scripts/default/` and are embedded. Custom user scripts are selected in the WPF patch modal and copied from `PatchConfig.CustomScriptPaths`; only existing `.js` files are accepted. A local `renderer-scripts/` folder next to the patcher exe is still copied as an advanced fallback. +- Default renderer script sources live in `web-panel/bridge/scripts/default/` and are bundled/minified into `web-panel/dist/renderer-scripts/` by `pnpm run build:bridge`. Custom user scripts are selected in the WPF patch modal and copied from `PatchConfig.CustomScriptPaths`; only existing `.js` files are accepted. A local `renderer-scripts/` folder next to the patcher exe is still copied as an advanced fallback. +- `web-panel/bridge/scripts/default/installed-apps-sync.js` resolves Wand's renderer services/store and publishes `My Games` snapshots through the `wand-remote-installed-apps` IPC channel. The synced list must mirror Wand's `my_games` source criteria: catalog games come from `installedGameVersions`, and extra installed unsupported titles come from `correlatedUnavailableTitles` whose `games[].correlationIds` match `installedApps`. +- 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//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. ## ASAR Patch Pipeline - Preserve and restore both `resources/app.asar` and `resources/app.asar.unpacked` backups. -- Inject `web-panel/dist` as `remote-panel/`, `web-panel/bridge/wand-remote-bridge.cjs` as `remote-panel/bridge.cjs`, and default/selected/local renderer scripts under `remote-panel/renderer-scripts`. -- Do not commit extracted `.sources/` output. Recreate it only for reverse-engineering sessions. +- 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 `.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:`). 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:`. Presets capture persistent cheat values only; do not include `button` one-shot cheats in saved presets. +- All `localStorage` access in `web-panel/src/features/remote-panel/` MUST go through the shared helpers in `storage.ts` (`loadJson` / `saveJson` / `loadStringSet` / `saveStringSet`). Do not reintroduce per-module `try/catch` + `JSON.parse` duplication in `pinned-storage`, `preset-storage`, or `game-pin-storage`. Trainer/game storage IDs are derived through the shared `getTrainerStorageId(trainer)` helper in `storage.ts`; do not re-implement the `gameId → titleId → trainerId → 'global'` precedence inline. +- All shared bridge port/path/IPC channel/WS-opcode/protocol-version constants live in `web-panel/bridge/bridge-modules/constants.cjs` (exports `IPC_CHANNEL`, `WS_OPCODE`, `BRIDGE_PROTOCOL_VERSION`, `BRIDGE_SERVER_VERSION`, `RENDERER_INJECTION_DELAYS_MS`). Do not redeclare `3223`, `/remote/*`, IPC channel strings, raw WS opcode numbers (1/8/9/10), or the 500/2000 ms injection delays inline. The renderer-script-side equivalents (e.g. `vO`/`TRAINER_LAUNCH_REQUEST_EXPORT_KEY`, snapshot key prefixes, bootstrap log throttle) live in `web-panel/bridge/scripts/default/installed-apps-sync/constants.js`. +- UI string-union types follow the `E*` enum convention from `.claude/rules/frontend-conventions.md` (currently `ECheatType` in `protocol.ts`, `EConnectionStatus` in `state.ts`); the wire string values must remain on the right-hand side of the enum members. Reducer `PanelAction` `type` tags stay as discriminated-union string literals (the union itself provides the discrimination — converting it to an enum loses pattern matching). +- Cheat input controls live one-per-file under `web-panel/src/features/remote-panel/controls/` (`ToggleControl`, `SliderControl`, `ScalarControl`, `NumberControl`, `ActionButton`, `SelectionControl`, `IncrementalControl`); shared `SliderTrack` / `StepButton` / `ControlInternalProps` are in `controls/shared.tsx` and number formatting helpers in `controls/format-number.ts`. `controls/CheatControl.tsx` is a thin dispatcher map keyed by `ECheatType` — do not inline new control bodies into it. +- 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`. -- Bridge syntax checks: `node --check web-panel/bridge/wand-remote-bridge.cjs` and `node --check web-panel/scripts/default/remote-popup-cleanup.js`. +- 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`. \ No newline at end of file diff --git a/AsarSharp/AsarCreator.cs b/AsarSharp/AsarCreator.cs index 64f9fb1..a4bea28 100644 --- a/AsarSharp/AsarCreator.cs +++ b/AsarSharp/AsarCreator.cs @@ -1,14 +1,13 @@ -using System; +using System; using System.Collections.Generic; using System.IO; -using System.Linq; using System.Text.RegularExpressions; using AsarSharp.AsarFileSystem; +using AsarSharp.Integrity; using AsarSharp.Utils; namespace AsarSharp { - public class CreateOptions { public Regex Unpack { get; set; } @@ -28,10 +27,10 @@ namespace AsarSharp _destPath = destPath ?? throw new ArgumentNullException(nameof(destPath)); _options = options; } - + public void CreatePackageWithOptions() { - var result = FileSystemCrawler.CrawlFileSystem(_folderPath); + var result = FileSystemCrawler.CrawlFileSystem(_folderPath); _filenames = result.filenames; _metadata = result.metadata; CreatePackageFromFiles(); @@ -41,28 +40,25 @@ namespace AsarSharp public void CreatePackageFromFiles() { var filesystem = new Filesystem(_folderPath); - var files = new List(); - - var filenamesSorted = _filenames.ToList(); - - foreach (var filename in filenamesSorted) + var files = new List(_filenames.Count); + + foreach (var filename in _filenames) { HandleFile(filesystem, filename, files); } InsertsDone(filesystem, files); } - - - + + private void HandleFile(Filesystem filesystem, string filename, List files) { - if (!_metadata.ContainsKey(filename)) + if (!_metadata.TryGetValue(filename, out var file)) { - var fileType = FileSystemCrawler.DetermineFileType(filename); - _metadata[filename] = fileType ?? throw new Exception("Unknown file type for file: " + filename); + file = FileSystemCrawler.DetermineFileType(filename) + ?? throw new Exception("Unknown file type for file: " + filename); + _metadata[filename] = file; } - var file = _metadata[filename]; switch (file.Type) { @@ -70,9 +66,18 @@ namespace AsarSharp filesystem.InsertDirectory(filename, false); break; case FileType.File: - var shouldUnpack = ShouldUnpackPath(Extensions.GetRelativePath(_folderPath, Path.GetDirectoryName(filename))); + string parentDir = Path.GetDirectoryName(filename) ?? string.Empty; + string relParent = Extensions.GetRelativePath(_folderPath, parentDir); + bool shouldUnpack = ShouldUnpackPath(relParent); files.Add(new Disk.BasicFileInfo { Filename = filename, Unpack = shouldUnpack }); - filesystem.InsertFile(filename, shouldUnpack, file); + + // Build a placeholder integrity record up front. Real + // SHA-256 hashes are filled in by Disk.WriteFileSystem + // during the streamed write — eliminates the second pass + // over each file (open → hash → close → open → copy → close). + long size = (file.Stat is FileInfo fi) ? fi.Length : 0; + var placeholder = IntegrityHelper.CreatePlaceholder(size); + filesystem.InsertFile(filename, shouldUnpack, file, placeholder); break; case FileType.Link: throw new NotImplementedException(); @@ -81,14 +86,16 @@ namespace AsarSharp private bool ShouldUnpackPath(string relativePath) { - return _options.Unpack?.IsMatch(relativePath) == true; + return _options?.Unpack?.IsMatch(relativePath) == true; } private void InsertsDone(Filesystem filesystem, List files) { - Directory.CreateDirectory(Path.GetDirectoryName(_destPath) ?? throw new InvalidOperationException()); - Disk.WriteFileSystem(_destPath, filesystem, new Disk.FilesystemFilesAndLinks { Files = files, Links = null }, _metadata); + Directory.CreateDirectory( + Path.GetDirectoryName(_destPath) + ?? throw new InvalidOperationException()); + Disk.WriteFileSystem(_destPath, filesystem, + new Disk.FilesystemFilesAndLinks { Files = files, Links = null }, _metadata); } - } -} \ No newline at end of file +} diff --git a/AsarSharp/AsarExtractor.cs b/AsarSharp/AsarExtractor.cs index 422b8a5..6a9475e 100644 --- a/AsarSharp/AsarExtractor.cs +++ b/AsarSharp/AsarExtractor.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -10,126 +10,62 @@ 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(); - // under windows just extract links as regular files + // On Windows, links are extracted as plain files. bool followLinks = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - // create destination directory Directory.CreateDirectory(dest); + byte[] ioBuffer = new byte[IO_BUFFER_SIZE]; + var dirCache = new HashSet(StringComparer.OrdinalIgnoreCase) { Path.GetFullPath(dest) }; var extractionErrors = new List(); - foreach (var fullPath in filenames) + 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)) { - try + foreach (var fullPath in filenames) { - // Remove leading slash - var filename = fullPath.Substring(1); - var destFilename = Path.Combine(dest, filename); - var file = filesystem.GetFile(filename, followLinks); - - // Check that the file is not written outside the specified destination folder - string relativePath = Extensions.GetRelativePath(dest, destFilename); - if (relativePath.StartsWith("..")) + try { - throw new InvalidOperationException($"{fullPath}: file \"{destFilename}\" writes out of the package"); - } + var filename = fullPath.Substring(1); + var destFilename = Path.Combine(dest, filename); + var file = filesystem.GetFile(filename, followLinks); - if (file.IsDirectory) - { - // it's a directory, create it and continue with the next entry - Directory.CreateDirectory(destFilename); - } - // TODO (LINK NOT SUPPORTED) - else if (file.IsLink) - { - // it's a symlink, create a symlink - var linkSrcPath = Extensions.GetDirectoryName(Path.Combine(dest, file.Link)); - var linkDestPath = Extensions.GetDirectoryName(destFilename); - var relativeLinkPath = Extensions.GetRelativePath(linkDestPath, linkSrcPath); - - // try to delete output file, because we can't overwrite a link - try - { - File.Delete(destFilename); - } - catch { - // Ignore errors during file link deletion - } - - var linkTo = Path.Combine(relativeLinkPath, Path.GetFileName(file.Link)); - - if (Extensions.GetRelativePath(dest, linkSrcPath).StartsWith("..")) + // Path-traversal guard. + string relativePath = Extensions.GetRelativePath(dest, destFilename); + if (relativePath.StartsWith("..")) { throw new InvalidOperationException( - $"{fullPath}: file \"{file.Link}\" links out of the package to \"{linkSrcPath}\""); + $"{fullPath}: file \"{destFilename}\" writes out of the package"); } - // On Windows, creating symlinks requires additional permissions or enabling Developer Mode, - // so just copy the contents of the file - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + if (file.IsDirectory) { - var targetPath = Path.Combine(linkSrcPath, Path.GetFileName(file.Link)); - if (Directory.Exists(targetPath)) - { - Directory.CreateDirectory(destFilename); - Extensions.CopyDirectory(targetPath, destFilename); - } - else if (File.Exists(targetPath)) - { - Directory.CreateDirectory(Extensions.GetDirectoryName(destFilename)); - File.Copy(targetPath, destFilename, true); - } + EnsureDirectory(destFilename, dirCache); + continue; } - else + + if (file.IsLink) { - // On Unix systems we use symlinks - Directory.CreateDirectory(Extensions.GetDirectoryName(destFilename)); - Extensions.CreateSymbolicLink(linkTo, destFilename); + ExtractLink(dest, fullPath, destFilename, file, dirCache); + continue; } - } - else if (file.IsFile) - { - // it's a file, try to extract it + + if (!file.IsFile) continue; + try { - // Unpacked entries already live on disk next to the archive in - // ".unpacked". When the caller extracts INTO that same - // directory (e.g. re-extracting in place to repack later) reading + - // writing the file is a self-copy that needlessly fails when the - // file is locked by another process (TrainerLib_x64.dll) or has been - // removed from disk by an installer (auxiliary/GameLauncher.exe). - if (file.Unpacked == true) - { - string unpackedSourcePath = Path.GetFullPath( - Path.Combine($"{filesystem.GetRootPath()}.unpacked", filename)); - string unpackedDestPath = Path.GetFullPath(destFilename); - - if (string.Equals(unpackedSourcePath, unpackedDestPath, StringComparison.OrdinalIgnoreCase)) - { - // Nothing to do – the file is already at the destination. - continue; - } - - if (!File.Exists(unpackedSourcePath)) - { - // The header references an unpacked file that no longer - // exists on disk; skip it instead of aborting the whole - // extraction so the rest of the asar can still be repacked. - continue; - } - - Directory.CreateDirectory(Extensions.GetDirectoryName(destFilename)); - File.Copy(unpackedSourcePath, destFilename, true); - } - else - { - byte[] content = Disk.ReadFileSync(filesystem, filename, file); - File.WriteAllBytes(destFilename, content); - } + ExtractFile(archive, dataOffset, rootPath, filename, destFilename, file, ioBuffer, dirCache); if (file.Executable == true && !RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { @@ -141,10 +77,10 @@ namespace AsarSharp extractionErrors.Add(e); } } - } - catch (Exception ex) - { - extractionErrors.Add(ex); + catch (Exception ex) + { + extractionErrors.Add(ex); + } } } @@ -156,5 +92,103 @@ namespace AsarSharp extractionErrors); } } + + private static void EnsureDirectory(string path, HashSet 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 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 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 dirCache) + { + var linkSrcPath = Extensions.GetDirectoryName(Path.Combine(dest, file.Link)); + var linkDestPath = Extensions.GetDirectoryName(destFilename); + var relativeLinkPath = Extensions.GetRelativePath(linkDestPath, linkSrcPath); + + try { File.Delete(destFilename); } + catch { /* ignore — failing to remove an existing link is non-fatal */ } + + var linkTo = Path.Combine(relativeLinkPath, Path.GetFileName(file.Link)); + + if (Extensions.GetRelativePath(dest, linkSrcPath).StartsWith("..")) + { + throw new InvalidOperationException( + $"{fullPath}: file \"{file.Link}\" links out of the package to \"{linkSrcPath}\""); + } + + 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 + { + EnsureParentDir(destFilename, dirCache); + Extensions.CreateSymbolicLink(linkTo, destFilename); + } + } } -} \ No newline at end of file +} diff --git a/AsarSharp/AsarFileSystem/Disk.cs b/AsarSharp/AsarFileSystem/Disk.cs index 7d26d91..16e3f18 100644 --- a/AsarSharp/AsarFileSystem/Disk.cs +++ b/AsarSharp/AsarFileSystem/Disk.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using AsarSharp.PickleTools; @@ -9,6 +9,7 @@ namespace AsarSharp.AsarFileSystem { public static class Disk { + private const int StreamBufferSize = 1024 * 1024; private static Dictionary _filesystemCache = new Dictionary(); public class ArchiveHeader @@ -35,7 +36,7 @@ namespace AsarSharp.AsarFileSystem public static ArchiveHeader ReadArchiveHeaderSync(string archivePath) { - using (FileStream fs = File.OpenRead(archivePath)) + using (var fs = new FileStream(archivePath, FileMode.Open, FileAccess.Read, FileShare.Read, StreamBufferSize, FileOptions.SequentialScan)) { // read the size of the header (8 bytes) byte[] sizeBuf = new byte[8]; @@ -103,7 +104,7 @@ namespace AsarSharp.AsarFileSystem } // Read from the ASAR archive - using (FileStream fs = File.OpenRead(filesystem.GetRootPath())) + using (var fs = new FileStream(filesystem.GetRootPath(), FileMode.Open, FileAccess.Read, FileShare.Read, StreamBufferSize, FileOptions.SequentialScan)) { // Important: the offset must take into account the size of the Pickle header (8 bytes) // and the size of the header itself @@ -145,19 +146,29 @@ namespace AsarSharp.AsarFileSystem if(dest == null || rootPath == null || filename == null) throw new ArgumentNullException(); - if (dest == rootPath) + 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.Combine(rootPath, filename); - string destPath = Path.Combine(dest, filename); + 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 sourceStream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read)) - using (var destinationStream = new FileStream(destPath, FileMode.Create, FileAccess.Write)) + using (var sourceStream = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, StreamBufferSize, FileOptions.SequentialScan)) + using (var destinationStream = new FileStream(destPath, FileMode.Create, FileAccess.Write, FileShare.None, StreamBufferSize, FileOptions.SequentialScan)) { - sourceStream.CopyTo(destinationStream); + sourceStream.CopyTo(destinationStream, StreamBufferSize); } } @@ -179,7 +190,7 @@ namespace AsarSharp.AsarFileSystem sizePickle.WriteUInt32((uint)headerBuf.Length); var sizeBuf = sizePickle.ToBuffer(); - using (FileStream fs = File.Create(dest)) + using (var fs = new FileStream(dest, FileMode.Create, FileAccess.Write, FileShare.None, StreamBufferSize, FileOptions.SequentialScan)) { fs.Write(sizeBuf, 0, sizeBuf.Length); fs.Write(headerBuf, 0, headerBuf.Length); @@ -192,9 +203,9 @@ namespace AsarSharp.AsarFileSystem CopyFile($"{dest}.unpacked", fileSystem.GetRootPath(), filename); continue; } - using (var transformedFileStream = new FileStream(file.Filename, FileMode.Open, FileAccess.Read)) + using (var transformedFileStream = new FileStream(file.Filename, FileMode.Open, FileAccess.Read, FileShare.Read, StreamBufferSize, FileOptions.SequentialScan)) { - transformedFileStream.CopyTo(fs); + transformedFileStream.CopyTo(fs, StreamBufferSize); } } } diff --git a/AsarSharp/AsarFileSystem/FileSystem.cs b/AsarSharp/AsarFileSystem/FileSystem.cs index dc7a4df..e8cc673 100644 --- a/AsarSharp/AsarFileSystem/FileSystem.cs +++ b/AsarSharp/AsarFileSystem/FileSystem.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using AsarSharp.Integrity; @@ -150,7 +150,6 @@ namespace AsarSharp.AsarFileSystem public static string ReadLink(string path) { throw new NotImplementedException(); - return Path.GetFileName(path); // TODO , NOT IMPLEMENTED } diff --git a/AsarSharp/AsarFileSystem/FileSystemCrawler.cs b/AsarSharp/AsarFileSystem/FileSystemCrawler.cs index 12d3c1d..6460ec8 100644 --- a/AsarSharp/AsarFileSystem/FileSystemCrawler.cs +++ b/AsarSharp/AsarFileSystem/FileSystemCrawler.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -28,32 +28,37 @@ namespace AsarSharp.AsarFileSystem public static class FileSystemCrawler { - - public static CrawledFileType DetermineFileType(string filename) { - var fileInfo = new FileInfo(filename); - if (fileInfo.Exists) + FileAttributes attributes; + try { - return new CrawledFileType { Type = FileType.File, Stat = fileInfo }; + attributes = File.GetAttributes(filename); } - - var directoryInfo = new DirectoryInfo(filename); - if (directoryInfo.Exists) + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) { - return new CrawledFileType { Type = FileType.Directory, Stat = directoryInfo }; + return null; } - - var linkInfo = new FileInfo(filename); - if (linkInfo.Exists && (linkInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint) + + 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 = linkInfo }; + return new CrawledFileType { Type = FileType.Link, Stat = info }; } - - return null; + + if (isDirectory) + { + return new CrawledFileType { Type = FileType.Directory, Stat = info }; + } + + return new CrawledFileType { Type = FileType.File, Stat = info }; } - - + public static (List filenames, Dictionary metadata) CrawlFileSystem(string dir) { var metadata = new Dictionary(); @@ -73,7 +78,12 @@ namespace AsarSharp.AsarFileSystem filenames.Add(result.filename); } - var filteredFilenames = new List(); + if (links.Count == 0) + { + return (filenames, metadata); + } + + var filteredFilenames = new List(filenames.Count); foreach (var filename in filenames) { @@ -107,7 +117,6 @@ namespace AsarSharp.AsarFileSystem return (filteredFilenames, metadata); } - // (File order is not important!!!) public static List CrawlIterative(string dir) { diff --git a/AsarSharp/Integrity/IntegrityHelper.cs b/AsarSharp/Integrity/IntegrityHelper.cs index be07b8b..0085c35 100644 --- a/AsarSharp/Integrity/IntegrityHelper.cs +++ b/AsarSharp/Integrity/IntegrityHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Security.Cryptography; @@ -11,6 +11,7 @@ namespace AsarSharp.Integrity private const string ALGORITHM = "SHA256"; // 4MB default block size private const int BLOCK_SIZE = 4 * 1024 * 1024; + private static readonly char[] HexDigits = "0123456789abcdef".ToCharArray(); public class FileIntegrity { @@ -29,20 +30,21 @@ namespace AsarSharp.Integrity public static FileIntegrity GetFileIntegrity(string path) { - using (var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, BLOCK_SIZE, FileOptions.SequentialScan)) using(var fileHash = SHA256.Create()) + using (var blockHash = SHA256.Create()) { - - var blockHashes = new List(); + int estimatedBlockCount = fileStream.Length > 0 + ? (int)((fileStream.Length + BLOCK_SIZE - 1) / BLOCK_SIZE) + : 0; + var blockHashes = new List(estimatedBlockCount); var buffer = new byte[BLOCK_SIZE]; int bytesRead; - while ((bytesRead = fileStream.Read(buffer, 0, BLOCK_SIZE)) > 0) + while ((bytesRead = fileStream.Read(buffer, 0, buffer.Length)) > 0) { - var block = new byte[bytesRead]; - Array.Copy(buffer, block, bytesRead); - blockHashes.Add(HashBlock(block)); - fileHash.TransformBlock(block, 0, block.Length, null, 0); + blockHashes.Add(HashBlock(blockHash, buffer, bytesRead)); + fileHash.TransformBlock(buffer, 0, bytesRead, null, 0); } fileHash.TransformFinalBlock(Array.Empty(), 0, 0); @@ -50,20 +52,34 @@ namespace AsarSharp.Integrity return new FileIntegrity { Algorithm = ALGORITHM, - Hash = BitConverter.ToString(fileHash.Hash).Replace("-", "").ToLowerInvariant(), + Hash = ToLowerHex(fileHash.Hash), BlockSize = BLOCK_SIZE, Blocks = blockHashes, }; } } - private static string HashBlock(byte[] block) + private static string HashBlock(HashAlgorithm hashAlgorithm, byte[] buffer, int bytesRead) { - using (var sha256 = SHA256.Create()) + return ToLowerHex(hashAlgorithm.ComputeHash(buffer, 0, bytesRead)); + } + + private static string ToLowerHex(byte[] bytes) + { + if (bytes == null || bytes.Length == 0) { - var hash = sha256.ComputeHash(block); - return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + return string.Empty; } + + var chars = new char[bytes.Length * 2]; + for (int index = 0; index < bytes.Length; index++) + { + byte value = bytes[index]; + chars[index * 2] = HexDigits[value >> 4]; + chars[index * 2 + 1] = HexDigits[value & 0x0F]; + } + + return new string(chars); } } } \ No newline at end of file diff --git a/AsarSharp/PickleTools/Pickle.cs b/AsarSharp/PickleTools/Pickle.cs index ce7759d..169d15d 100644 --- a/AsarSharp/PickleTools/Pickle.cs +++ b/AsarSharp/PickleTools/Pickle.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.IO; using System.Text; namespace AsarSharp.PickleTools @@ -12,10 +13,10 @@ namespace AsarSharp.PickleTools public const int SIZE_FLOAT = 4; public const int SIZE_DOUBLE = 8; - // Size of memory allocation unit for payload - public const int PAYLOAD_UNIT = 64; + // Initial payload allocation. Bumped from 64 — large headers used to + // realloc many times when growing geometrically from 64. + public const int PAYLOAD_UNIT = 4096; - // Maximum value for read-only public const long CAPACITY_READ_ONLY = 9007199254740992; private byte[] _header; @@ -57,55 +58,40 @@ namespace AsarSharp.PickleTools SetPayloadSize(0); } } - - public static Pickle CreateEmpty() - { - return new Pickle(); - } - - public static Pickle CreateFromBuffer(byte[] buffer) - { - return new Pickle(buffer); - } - public byte[] GetHeader() - { - return _header; - } + public static Pickle CreateEmpty() => new Pickle(); + public static Pickle CreateFromBuffer(byte[] buffer) => new Pickle(buffer); - public int GetHeaderSize() - { - return _headerSize; - } - - public PickleIterator CreateIterator() - { - return new PickleIterator(this); - } + public byte[] GetHeader() => _header; + public int GetHeaderSize() => _headerSize; - /// - /// Converts Pickle to a byte array - /// + public PickleIterator CreateIterator() => new PickleIterator(this); + + /// Total byte length of the serialised pickle (header + payload). + public int GetTotalSize() => _headerSize + GetPayloadSize(); + + /// Materialise the pickle into a fresh byte array (allocates). public byte[] ToBuffer() { - int resultSize = _headerSize + GetPayloadSize(); + int resultSize = GetTotalSize(); byte[] result = new byte[resultSize]; - Array.Copy(_header, 0, result, 0, resultSize); + Buffer.BlockCopy(_header, 0, result, 0, resultSize); return result; } - - public bool WriteBool(bool value) + /// Write the serialised pickle straight to — no extra copy. + public void WriteTo(Stream stream) { - return WriteInt(value ? 1 : 0); + stream.Write(_header, 0, GetTotalSize()); } - + + + public bool WriteBool(bool value) => WriteInt(value ? 1 : 0); + public bool WriteInt(int value) { - EnsureCapacity(SIZE_INT32); - - var dataLength = AlignInt(SIZE_INT32, SIZE_UINT32); - var newSize = _writeOffset + dataLength; + const int dataLength = SIZE_INT32; // already 4-byte aligned + int newSize = _writeOffset + dataLength; if (newSize > _capacityAfterHeader) { @@ -113,13 +99,6 @@ namespace AsarSharp.PickleTools } WriteInt32LE(value, _headerSize + _writeOffset); - - var endOffset = _headerSize + _writeOffset + SIZE_INT32; - for (int i = endOffset; i < endOffset + dataLength - SIZE_INT32; i++) - { - _header[i] = 0; - } - SetPayloadSize(newSize); _writeOffset = newSize; return true; @@ -128,10 +107,8 @@ namespace AsarSharp.PickleTools public bool WriteUInt32(uint value) { - EnsureCapacity(SIZE_UINT32); - - var dataLength = AlignInt(SIZE_UINT32, SIZE_UINT32); - var newSize = _writeOffset + dataLength; + const int dataLength = SIZE_UINT32; + int newSize = _writeOffset + dataLength; if (newSize > _capacityAfterHeader) { @@ -139,24 +116,15 @@ namespace AsarSharp.PickleTools } WriteUInt32LE(value, _headerSize + _writeOffset); - - var endOffset = _headerSize + _writeOffset + SIZE_UINT32; - for (int i = endOffset; i < endOffset + dataLength - SIZE_UINT32; i++) - { - _header[i] = 0; - } - SetPayloadSize(newSize); _writeOffset = newSize; return true; } - + public bool WriteInt64(long value) { - EnsureCapacity(SIZE_INT64); - - var dataLength = AlignInt(SIZE_INT64, SIZE_UINT32); - var newSize = _writeOffset + dataLength; + const int dataLength = SIZE_INT64; + int newSize = _writeOffset + dataLength; if (newSize > _capacityAfterHeader) { @@ -164,13 +132,6 @@ namespace AsarSharp.PickleTools } WriteInt64LE(value, _headerSize + _writeOffset); - - var endOffset = _headerSize + _writeOffset + SIZE_INT64; - for (int i = endOffset; i < endOffset + dataLength - SIZE_INT64; i++) - { - _header[i] = 0; - } - SetPayloadSize(newSize); _writeOffset = newSize; return true; @@ -179,10 +140,8 @@ namespace AsarSharp.PickleTools public bool WriteUInt64(ulong value) { - EnsureCapacity(SIZE_UINT64); - - var dataLength = AlignInt(SIZE_UINT64, SIZE_UINT32); - var newSize = _writeOffset + dataLength; + const int dataLength = SIZE_UINT64; + int newSize = _writeOffset + dataLength; if (newSize > _capacityAfterHeader) { @@ -190,102 +149,69 @@ namespace AsarSharp.PickleTools } WriteUInt64LE(value, _headerSize + _writeOffset); - - var endOffset = _headerSize + _writeOffset + SIZE_UINT64; - for (int i = endOffset; i < endOffset + dataLength - SIZE_UINT64; i++) - { - _header[i] = 0; - } - SetPayloadSize(newSize); _writeOffset = newSize; return true; } - + public bool WriteFloat(float value) { - EnsureCapacity(SIZE_FLOAT); - - var dataLength = AlignInt(SIZE_FLOAT, SIZE_UINT32); - var newSize = _writeOffset + dataLength; + const int dataLength = SIZE_FLOAT; + int newSize = _writeOffset + dataLength; if (newSize > _capacityAfterHeader) { Resize(Math.Max((int)_capacityAfterHeader * 2, newSize)); } - byte[] bytes = BitConverter.GetBytes(value); - if (!BitConverter.IsLittleEndian) - { - Array.Reverse(bytes); - } - - Array.Copy(bytes, 0, _header, _headerSize + _writeOffset, SIZE_FLOAT); - - var endOffset = _headerSize + _writeOffset + SIZE_FLOAT; - for (int i = endOffset; i < endOffset + dataLength - SIZE_FLOAT; i++) - { - _header[i] = 0; - } + int bits = BitConverter.ToInt32(BitConverter.GetBytes(value), 0); + WriteInt32LE(bits, _headerSize + _writeOffset); SetPayloadSize(newSize); _writeOffset = newSize; return true; } - + public bool WriteDouble(double value) { - EnsureCapacity(SIZE_DOUBLE); - - var dataLength = AlignInt(SIZE_DOUBLE, SIZE_UINT32); - var newSize = _writeOffset + dataLength; + const int dataLength = SIZE_DOUBLE; + int newSize = _writeOffset + dataLength; if (newSize > _capacityAfterHeader) { Resize(Math.Max((int)_capacityAfterHeader * 2, newSize)); } - byte[] bytes = BitConverter.GetBytes(value); - if (!BitConverter.IsLittleEndian) - { - Array.Reverse(bytes); - } - - Array.Copy(bytes, 0, _header, _headerSize + _writeOffset, SIZE_DOUBLE); - - var endOffset = _headerSize + _writeOffset + SIZE_DOUBLE; - for (int i = endOffset; i < endOffset + dataLength - SIZE_DOUBLE; i++) - { - _header[i] = 0; - } + long bits = BitConverter.DoubleToInt64Bits(value); + WriteInt64LE(bits, _headerSize + _writeOffset); SetPayloadSize(newSize); _writeOffset = newSize; return true; } - + public bool WriteString(string value) { - byte[] strBytes = Encoding.UTF8.GetBytes(value); - int length = strBytes.Length; + int byteLen = Encoding.UTF8.GetByteCount(value); - if (!WriteInt(length)) + if (!WriteInt(byteLen)) { return false; } - var dataLength = AlignInt(length, SIZE_UINT32); - var newSize = _writeOffset + dataLength; + int aligned = AlignInt(byteLen, SIZE_UINT32); + int newSize = _writeOffset + aligned; if (newSize > _capacityAfterHeader) { Resize(Math.Max((int)_capacityAfterHeader * 2, newSize)); } - Array.Copy(strBytes, 0, _header, _headerSize + _writeOffset, length); + int writeStart = _headerSize + _writeOffset; + Encoding.UTF8.GetBytes(value, 0, value.Length, _header, writeStart); - var endOffset = _headerSize + _writeOffset + length; - for (int i = endOffset; i < endOffset + dataLength - length; i++) + // zero alignment padding + for (int i = writeStart + byteLen; i < writeStart + aligned; i++) { _header[i] = 0; } @@ -294,132 +220,80 @@ namespace AsarSharp.PickleTools _writeOffset = newSize; return true; } - + public void SetPayloadSize(int payloadSize) { WriteUInt32LE((uint)payloadSize, 0); } - - public int GetPayloadSize() - { - return (int)ReadUInt32LE(0); - } - + + public int GetPayloadSize() => (int)ReadUInt32LE(0); + private void Resize(int newCapacity) { newCapacity = AlignInt(newCapacity, PAYLOAD_UNIT); byte[] newHeader = new byte[_header.Length + newCapacity]; - Array.Copy(_header, 0, newHeader, 0, _header.Length); + Buffer.BlockCopy(_header, 0, newHeader, 0, _header.Length); _header = newHeader; _capacityAfterHeader = newCapacity; } - + public static int AlignInt(int i, int alignment) { return i + ((alignment - (i % alignment)) % alignment); } - - private void EnsureCapacity(int additionalSize) - { - var dataLength = AlignInt(additionalSize, SIZE_UINT32); - var newSize = _writeOffset + dataLength; - - if (newSize > _capacityAfterHeader) - { - Resize(Math.Max((int)_capacityAfterHeader * 2, newSize)); - } - } #region Auxiliary methods for reading/writing values in Little Endian private uint ReadUInt32LE(int offset) { - if (BitConverter.IsLittleEndian) - { - return BitConverter.ToUInt32(_header, offset); - } - else - { - return (uint)(_header[offset] | - (_header[offset + 1] << 8) | - (_header[offset + 2] << 16) | - (_header[offset + 3] << 24)); - } + // _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) { - if (BitConverter.IsLittleEndian) - { - byte[] bytes = BitConverter.GetBytes(value); - Array.Copy(bytes, 0, _header, offset, 4); - } - else - { - _header[offset] = (byte)value; - _header[offset + 1] = (byte)(value >> 8); - _header[offset + 2] = (byte)(value >> 16); - _header[offset + 3] = (byte)(value >> 24); - } + _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) { - if (BitConverter.IsLittleEndian) - { - byte[] bytes = BitConverter.GetBytes(value); - Array.Copy(bytes, 0, _header, offset, 4); - } - else - { - _header[offset] = (byte)value; - _header[offset + 1] = (byte)(value >> 8); - _header[offset + 2] = (byte)(value >> 16); - _header[offset + 3] = (byte)(value >> 24); - } + _header[offset] = (byte)value; + _header[offset + 1] = (byte)(value >> 8); + _header[offset + 2] = (byte)(value >> 16); + _header[offset + 3] = (byte)(value >> 24); } private void WriteInt64LE(long value, int offset) { - if (BitConverter.IsLittleEndian) - { - byte[] bytes = BitConverter.GetBytes(value); - Array.Copy(bytes, 0, _header, offset, 8); - } - else - { - _header[offset] = (byte)value; - _header[offset + 1] = (byte)(value >> 8); - _header[offset + 2] = (byte)(value >> 16); - _header[offset + 3] = (byte)(value >> 24); - _header[offset + 4] = (byte)(value >> 32); - _header[offset + 5] = (byte)(value >> 40); - _header[offset + 6] = (byte)(value >> 48); - _header[offset + 7] = (byte)(value >> 56); - } - } - - private void WriteUInt64LE(ulong value, int offset) - { - if (BitConverter.IsLittleEndian) - { - byte[] bytes = BitConverter.GetBytes(value); - Array.Copy(bytes, 0, _header, offset, 8); - } - else - { - _header[offset] = (byte)value; - _header[offset + 1] = (byte)(value >> 8); - _header[offset + 2] = (byte)(value >> 16); - _header[offset + 3] = (byte)(value >> 24); - _header[offset + 4] = (byte)(value >> 32); - _header[offset + 5] = (byte)(value >> 40); - _header[offset + 6] = (byte)(value >> 48); - _header[offset + 7] = (byte)(value >> 56); - } + _header[offset] = (byte)value; + _header[offset + 1] = (byte)(value >> 8); + _header[offset + 2] = (byte)(value >> 16); + _header[offset + 3] = (byte)(value >> 24); + _header[offset + 4] = (byte)(value >> 32); + _header[offset + 5] = (byte)(value >> 40); + _header[offset + 6] = (byte)(value >> 48); + _header[offset + 7] = (byte)(value >> 56); } - + private void WriteUInt64LE(ulong value, int offset) + { + _header[offset] = (byte)value; + _header[offset + 1] = (byte)(value >> 8); + _header[offset + 2] = (byte)(value >> 16); + _header[offset + 3] = (byte)(value >> 24); + _header[offset + 4] = (byte)(value >> 32); + _header[offset + 5] = (byte)(value >> 40); + _header[offset + 6] = (byte)(value >> 48); + _header[offset + 7] = (byte)(value >> 56); + } + + #endregion } -} \ No newline at end of file +} diff --git a/AsarSharp/Utils/Extensions.cs b/AsarSharp/Utils/Extensions.cs index e629f77..feeb7e0 100644 --- a/AsarSharp/Utils/Extensions.cs +++ b/AsarSharp/Utils/Extensions.cs @@ -1,11 +1,19 @@ -using System; +using System; using System.IO; using System.Runtime.InteropServices; +using System.Text; namespace AsarSharp.Utils { internal static class Extensions { + /// + /// Compute path relative to . + /// Fast common-case (path is inside relativeTo): plain prefix-strip. + /// Falls back to + manual relativisation + /// when paths must be normalised or '..' segments are required. + /// Replaces previous URI-based implementation which was a large hot-path cost. + /// public static string GetRelativePath(string relativeTo, string path) { if (string.IsNullOrEmpty(relativeTo)) @@ -13,84 +21,134 @@ namespace AsarSharp.Utils if (string.IsNullOrEmpty(path)) throw new ArgumentNullException(nameof(path)); - var fullRelativeTo = Path.GetFullPath(relativeTo); - var fullPath = Path.GetFullPath(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(fullRelativeTo, fullPath, StringComparison.OrdinalIgnoreCase)) - return ""; + if (string.Equals(baseFast, pathFast, StringComparison.OrdinalIgnoreCase)) + return string.Empty; - var relativeToUri = new Uri(fullRelativeTo.EndsWith(Path.DirectorySeparatorChar.ToString()) - ? fullRelativeTo - : fullRelativeTo + Path.DirectorySeparatorChar); - var pathUri = new Uri(fullPath.EndsWith(Path.DirectorySeparatorChar.ToString()) && !File.Exists(fullPath) - ? fullPath - : fullPath + (Directory.Exists(fullPath) ? Path.DirectorySeparatorChar.ToString() : "")); + if (pathFast.Length > baseFast.Length && + pathFast.StartsWith(baseFast, StringComparison.OrdinalIgnoreCase) && + IsSeparator(pathFast[baseFast.Length])) + { + return pathFast.Substring(baseFast.Length + 1); + } - var relativeUri = relativeToUri.MakeRelativeUri(pathUri); - var relativePath = Uri.UnescapeDataString(relativeUri.ToString()) - .Replace('/', Path.DirectorySeparatorChar); - - return relativePath.TrimEnd(Path.DirectorySeparatorChar); + // 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 == '\\'; + public static string GetDirectoryName(string path) { if (string.IsNullOrEmpty(path)) return "."; string result = Path.GetDirectoryName(path); - - // If the result is an empty string, return “.” as in Node.js + if (string.IsNullOrEmpty(result)) return "."; - + return result; } - + public static void CopyDirectory(string sourceDir, string destinationDir) { - // Create the destination directory Directory.CreateDirectory(destinationDir); - // Get all files in the source directory foreach (var file in Directory.GetFiles(sourceDir)) { var destFile = Path.Combine(destinationDir, Path.GetFileName(file)); File.Copy(file, destFile, true); } - // Recursively copy all subdirectories foreach (var dir in Directory.GetDirectories(sourceDir)) { var destDir = Path.Combine(destinationDir, Path.GetFileName(dir)); CopyDirectory(dir, destDir); } } - + public static string GetBasePath(string dir) { - // Look for the last path delimiter before any pattern 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; - // Use chmod var process = new System.Diagnostics.Process { StartInfo = new System.Diagnostics.ProcessStartInfo @@ -105,14 +163,12 @@ namespace AsarSharp.Utils process.Start(); process.WaitForExit(); } - - + + public static void CreateSymbolicLink(string linkTarget, string linkPath) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - // On Windows, creating symlinks requires special privileges, - // so on many systems it simply won't work without administrator privileges NativeMethods.CreateSymbolicLink(linkPath, linkTarget, Directory.Exists(linkTarget) ? NativeMethods.SymLinkFlag.Directory @@ -120,7 +176,6 @@ namespace AsarSharp.Utils return; } - // In Unix systems we use the corresponding system call var process = new System.Diagnostics.Process { StartInfo = new System.Diagnostics.ProcessStartInfo @@ -135,11 +190,11 @@ namespace AsarSharp.Utils process.Start(); process.WaitForExit(); } - - + + public static bool IsWindowsPlatform() { return Environment.OSVersion.Platform == PlatformID.Win32NT; } } -} \ No newline at end of file +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b52ed79 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,115 @@ +# Changelog + +This file is the source of truth for release notes. +The newest entry must match the version in `WandEnhancer/Properties/AssemblyInfo.cs`. + +## [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. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d33e28a..574cd5f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,6 +8,7 @@ Thank you for your interest in the WandEnhancer project! This document provides - [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) @@ -82,6 +83,22 @@ Suggestions for new features or improvements are welcome! Create an Issue descri 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 the release automatically. + ## Code Style - Use C# naming conventions: diff --git a/README.md b/README.md index 816a45c..32fff18 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,11 @@ ![logo](./assets/icon.svg) ---- # WandEnhancer + +[![GitLab Mirror](https://img.shields.io/badge/GitLab-mirror-fc6d26?logo=gitlab)](https://gitlab.com/kitbyte/wand-enhancer) +[![VirusTotal](https://img.shields.io/badge/VirusTotal-0/72-brightgreen?logo=virustotal)](https://www.virustotal.com/gui/file/f6897cf583e9f8ea11e0ee4c3fb99b86c50336b28de706e3e0b9181b4e3cf223) +

An open-source interoperability tool designed to extend local client-side configurations and improve the UX of the Wand application.

@@ -74,4 +77,4 @@ This project is licensed under the Apache-2.0 - see the [LICENSE](LICENSE.md) fi --- -[![Star History Chart](https://api.star-history.com/svg?repos=k1tbyte/Wand-Enhancer&type=Date)](https://www.star-history.com/#k1tbyte/Wand-Enhancer&Date) +[![Star History Chart](https://api.star-history.com/svg?repos=k1tbyte/Wand-Enhancer&type=Date)](https://www.star-history.com/#k1tbyte/Wand-Enhancer&Date) \ No newline at end of file diff --git a/WandEnhancer/Core/Enhancer.cs b/WandEnhancer/Core/Enhancer.cs index 9a330df..8da3726 100644 --- a/WandEnhancer/Core/Enhancer.cs +++ b/WandEnhancer/Core/Enhancer.cs @@ -20,17 +20,14 @@ namespace WandEnhancer.Core private const string AppAsarUnpackedBackupDirectoryName = "app.asar.unpacked.backup"; private const string WebPanelDirectoryName = "web-panel"; private const string WebPanelDistDirectoryName = "dist"; - private const string WebPanelBridgeDirectoryName = "bridge"; - private const string WebPanelScriptsDirectoryName = "scripts"; - private const string DefaultScriptsDirectoryName = "default"; private const string LocalCustomScriptsDirectoryName = "renderer-scripts"; private const string RemotePanelDirectoryName = "remote-panel"; - private const string RemoteBridgeSourceFileName = "wand-remote-bridge.cjs"; private const string RemoteBridgeTargetFileName = "bridge.cjs"; private const string RemoteRendererScriptsDirectoryName = "renderer-scripts"; private const string EmbeddedRemotePanelDistPrefix = "remote-panel/dist/"; - private const string EmbeddedRemotePanelBridgeResourceName = "remote-panel/bridge.cjs"; - private const string EmbeddedRemotePanelDefaultScriptsPrefix = "remote-panel/renderer-scripts/"; + private const string AppBundleFilePrefix = "app-"; + private const string AppBundleFileSuffix = ".bundle.js"; + private const string IndexBundleFileName = "index.js"; private const string JavaScriptFileExtension = ".js"; private const string JavaScriptFileSearchPattern = "*.js"; private const string DuplicateScriptSuffix = ".custom"; @@ -56,52 +53,63 @@ namespace WandEnhancer.Core _unpackedBackupPath = Path.Combine(weModConfig.RootDirectory, ResourcesDirectoryName, AppAsarUnpackedBackupDirectoryName); } - private string ApplyJsPatch(string fileName, string js, EnhancerConfig.PatchEntry patch, EPatchType patchType) + private string ApplyJsPatch(string fileName, string js, EnhancerConfig.PatchEntry patch, EPatchType patchType, out bool patchApplied) { + patchApplied = false; + if (patch.Applied) { return js; } + + if (!CanSearchPatchInFile(fileName, patch) || !ContainsSearchHint(js, patch.SearchHints)) + { + return js; + } - var matches = patch.Target.Matches(js); - if (matches.Count == 0) + var match = patch.Target.Match(js); + if (!match.Success) { return js; } var prefix = $"[ENHANCER] [{patchType} -> {patch.Name}]"; - if(matches.Count > 1 && patch.SingleMatch) + if(patch.SingleMatch && match.NextMatch().Success) { throw new Exception( $"{prefix} Patch failed. Multiple target functions found. Looks like the version is not supported"); } + string patchSource = patch.Patch; + if (patch.Resolver != null) { - string resolvedField = patch.Resolver.Handler(matches[0].Value); + string resolvedField = patch.Resolver.Handler(match.Value); if (string.IsNullOrEmpty(resolvedField)) { throw new Exception($"{prefix} Resolver failed to find field name"); } - patch.Patch = patch.Patch.Replace(patch.Resolver.Placeholder, resolvedField); + patchSource = patchSource.Replace(patch.Resolver.Placeholder, resolvedField); } _logger($"{prefix} Found target function in: " + Path.GetFileName(fileName), ELogType.Info); - string newJs = patch.Target.Replace(js, patch.Patch); - File.WriteAllText(fileName, newJs); + string newJs = patch.SingleMatch + ? patch.Target.Replace(js, patchSource, 1) + : patch.Target.Replace(js, patchSource); _logger($"{prefix} Patch applied", ELogType.Success); patch.Applied = true; + patchApplied = true; return newJs; } private void PatchAsar() { - var items = Directory.EnumerateFiles(_unpackedPath) - .Where(file => !Directory.Exists(file) && Regex.IsMatch(Path.GetFileName(file), @"^app-\w+|index\.js")) + var items = Directory.EnumerateFiles(_unpackedPath, $"*{JavaScriptFileExtension}", SearchOption.TopDirectoryOnly) + .Where(IsCandidateBundleFile) .ToList(); if (!items.Any()) @@ -118,15 +126,23 @@ namespace WandEnhancer.Core { 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) { - data = ApplyJsPatch(item, data, patchEntry, entry); + bool patchApplied; + data = ApplyJsPatch(item, data, patchEntry, entry, out patchApplied); + fileChanged = fileChanged || patchApplied; } if (entries.All(x => x.Applied)) @@ -134,6 +150,11 @@ namespace WandEnhancer.Core remainingPatches.Remove(entry); } } + + if (fileChanged) + { + File.WriteAllText(item, data); + } } if(remainingPatches.Count > 0) @@ -143,6 +164,56 @@ namespace WandEnhancer.Core } } + 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 remainingPatches, Dictionary enhancerConfig) + { + foreach (var patchType in remainingPatches) + { + foreach (var patchEntry in enhancerConfig[patchType]) + { + if (patchEntry.Applied) + { + continue; + } + + if (CanSearchPatchInFile(filePath, patchEntry)) + { + return true; + } + } + } + + return false; + } + + private static bool CanSearchPatchInFile(string filePath, EnhancerConfig.PatchEntry patch) + { + if (patch.CandidateFileNames == null || patch.CandidateFileNames.Length == 0) + { + return true; + } + + string fileName = Path.GetFileName(filePath); + return patch.CandidateFileNames.Any(candidate => fileName.Equals(candidate, StringComparison.OrdinalIgnoreCase)); + } + + private static bool ContainsSearchHint(string source, string[] searchHints) + { + if (searchHints == null || searchHints.Length == 0) + { + return true; + } + + return searchHints.Any(searchHint => source.IndexOf(searchHint, StringComparison.Ordinal) >= 0); + } + private static string FindWorkspacePath(params string[] segments) { string current = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); @@ -257,26 +328,6 @@ namespace WandEnhancer.Core return resourceNames.Count; } - private static bool CopyEmbeddedFile(string resourceName, string destinationPath) - { - var assembly = Assembly.GetExecutingAssembly(); - using (var resource = assembly.GetManifestResourceStream(resourceName)) - { - if (resource == null) - { - return false; - } - - Directory.CreateDirectory(Path.GetDirectoryName(destinationPath) ?? "."); - using (var output = File.Create(destinationPath)) - { - resource.CopyTo(output); - } - } - - return true; - } - private static string FindLocalCustomScriptsPath() { string executableDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); @@ -335,15 +386,17 @@ namespace WandEnhancer.Core CopyDirectory(FindWorkspacePath(WebPanelDirectoryName, WebPanelDistDirectoryName), targetRoot); } - if (!CopyEmbeddedFile(EmbeddedRemotePanelBridgeResourceName, targetBridgePath)) + if (!File.Exists(targetBridgePath)) { - File.Copy(FindWorkspacePath(WebPanelDirectoryName, WebPanelBridgeDirectoryName, RemoteBridgeSourceFileName), targetBridgePath, true); + throw new FileNotFoundException("[ENHANCER] Remote bridge artifact is missing. Run `cd web-panel && pnpm run build` before patching.", targetBridgePath); } - int defaultScriptCount = CopyEmbeddedDirectory(EmbeddedRemotePanelDefaultScriptsPrefix, targetScriptsRoot); + int defaultScriptCount = Directory.Exists(targetScriptsRoot) + ? Directory.GetFiles(targetScriptsRoot, JavaScriptFileSearchPattern, SearchOption.TopDirectoryOnly).Length + : 0; if (defaultScriptCount == 0) { - defaultScriptCount = CopyJavaScriptFiles(FindWorkspacePath(WebPanelDirectoryName, WebPanelScriptsDirectoryName, DefaultScriptsDirectoryName), targetScriptsRoot); + 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); diff --git a/WandEnhancer/Core/EnhancerConfig.cs b/WandEnhancer/Core/EnhancerConfig.cs index 92f6ddb..3627496 100644 --- a/WandEnhancer/Core/EnhancerConfig.cs +++ b/WandEnhancer/Core/EnhancerConfig.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text.RegularExpressions; using WandEnhancer.Models; @@ -23,6 +23,8 @@ namespace WandEnhancer.Core public string Name { get; set; } public bool Applied { get; set; } public bool SingleMatch { get; set; } = true; + public string[] CandidateFileNames { get; set; } + public string[] SearchHints { get; set; } public ResolveContext Resolver { get; set; } } @@ -36,6 +38,7 @@ namespace WandEnhancer.Core { new PatchEntry { + SearchHints = new[] { "getUserAccount()", "/v3/account" }, Resolver = new ResolveContext { Handler = (targetFunction) => @@ -53,6 +56,7 @@ namespace WandEnhancer.Core }, new PatchEntry { + SearchHints = new[] { "setAccountWandBrandExperience()", "/v3/account/brand_experience_wand" }, Resolver = new ResolveContext { Handler = (targetFunction) => @@ -64,7 +68,7 @@ namespace WandEnhancer.Core }, Name = "setAccountWandBrandExperience", Target = new Regex( - @"setAccountWandBrandExperience\(\){.*?return\s+this\.#\w+\.post\(""/v3/account/brand_experience_wand""\)}", + @"setAccountWandBrandExperience\(\)\{.*?return\s+this\.#\w+\.post\(""/v3/account/brand_experience_wand""\)\}", RegexOptions.Singleline), Patch = "setAccountWandBrandExperience(){return this.#.post(\"/v3/account/brand_experience_wand\").then(response=>{response.subscription={period:\"yearly\",state:\"active\"};return response;})}" @@ -77,6 +81,8 @@ namespace WandEnhancer.Core { new PatchEntry { + CandidateFileNames = new[] { "index.js" }, + SearchHints = new[] { "ACTION_CHECK_FOR_UPDATE" }, Target = new Regex(@"registerHandler\(""ACTION_CHECK_FOR_UPDATE"".*?\)\)\)\)", RegexOptions.Singleline), Patch = "registerHandler(\"ACTION_CHECK_FOR_UPDATE\",(e=>expectUpdateFeedUrl(e,(e=>null)))" @@ -90,6 +96,8 @@ namespace WandEnhancer.Core new PatchEntry { Name = "devToolsBeforeInputEvent", + CandidateFileNames = new[] { "index.js" }, + SearchHints = new[] { "whenReady().then(" }, // Anchor on the Electron main-process `.whenReady().then(` // call. This site is far more stable than the minified renderer // keydown listener that previously held the F12 -> ACTION_OPEN_DEV_TOOLS @@ -109,42 +117,50 @@ namespace WandEnhancer.Core new PatchEntry { Name = "remoteBridgeMainBoot", + CandidateFileNames = new[] { "index.js" }, + SearchHints = new[] { "whenReady().then(run)" }, Target = new Regex(@"(?\w+)\.whenReady\(\)\.then\(run\)"), Patch = "${app}.whenReady().then(()=>{try{const p=require(\"node:path\");require(p.join(__dirname,\"remote-panel\",\"bridge.cjs\")).installWandRuntime(require(\"electron\"));}catch(e){try{const fs=require(\"node:fs\"),os=require(\"node:os\"),p=require(\"node:path\");fs.appendFileSync(p.join(os.tmpdir(),\"wand-remote-bridge.log\"),\"[\"+new Date().toISOString()+\"] [boot-error] \"+(e&&e.stack||e)+\"\\n\");}catch(_){}}return run()})" }, new PatchEntry { Name = "remoteBridgeReset", + SearchHints = new[] { "client-state" }, Target = new Regex(@"#Je\(\)\{this\.#Oe&&\(this\.#Oe\.dispose\(\),this\.#Oe=null\),this\.#Pe=Date\.now\(\)\.toString\(\),this\.#ke=null,this\.#_e=\[],this\.#Ee=null\}"), Patch = "#Je(){this.#Oe&&(this.#Oe.dispose(),this.#Oe=null),this.#Pe=Date.now().toString(),this.#ke=null,this.#_e=[],this.#Ee=null,this.__wandRemoteTrainerInfo=null,this.__wandRemoteBridge?.sync(null)}" }, new PatchEntry { Name = "remoteBridgeSyncSnapshot", + SearchHints = new[] { "client-state" }, Target = new Regex(@"#Be\(\)\{if\(this\.status===i\.Connected\)\{let e,t=!1,s=this\.#Ee\?\.getMetadata\(h\.vO\)\?\.gameVersion\?\?null,i=!1;const n=this\.#Ve\[this\.#ke\?\?""""\]\|\|null;this\.#Re&&\(e=this\.#Ae\.getPreferredInstallationInfo\(this\.#Re\),e\.app&&\(t=!0,s\?\?=e\.version\?\?null,i=""number""==typeof e\.version&&!this\.#_e\.includes\(e\.version\)\)\),this\.#Me\?\.send\(""client-state"",\{instanceId:this\.#Pe,trainerId:this\.#ke,trainerLoading:this\.#Ee\?\.isLoading\(\),gameInstalled:t,gameVersion:s,needsCompatibilityWarning:i,values:this\.#Ke\(\),themeId:this\.#We,settings:R\(this\.settings\),language:this\.#Ne,accountUuid:this\.account\.uuid,notesReadHash:n,isTimeLimitExpired:""expired""===this\.#Fe\.timerState\}\)\}\}"), Patch = "#Be(){let e,t=!1,s=this.#Ee?.getMetadata(h.vO)?.gameVersion??null,o=!1;const n=this.#Ve[this.#ke??\"\"]||null;this.#Re&&(e=this.#Ae.getPreferredInstallationInfo(this.#Re),e.app&&(t=!0,s??=e.version??null,o=\"number\"==typeof e.version&&!this.#_e.includes(e.version)));this.status===i.Connected&&this.#Me?.send(\"client-state\",{instanceId:this.#Pe,trainerId:this.#ke,trainerLoading:this.#Ee?.isLoading(),gameInstalled:t,gameVersion:s,needsCompatibilityWarning:o,values:this.#Ke(),themeId:this.#We,settings:R(this.settings),language:this.#Ne,accountUuid:this.account.uuid,notesReadHash:n,isTimeLimitExpired:\"expired\"===this.#Fe.timerState});this.__wandRemoteBridge?.sync({instanceId:this.#Pe,trainerId:this.#ke,trainerInfo:this.__wandRemoteTrainerInfo??null,metadata:this.#Ee?.getMetadata(h.vO)??null,trainerLoading:this.#Ee?.isLoading()??false,gameInstalled:t,gameVersion:s,needsCompatibilityWarning:o,language:this.#Ne,themeId:this.#We,notesReadHash:n,isTimeLimitExpired:\"expired\"===this.#Fe.timerState,values:this.#Ke()})}" }, new PatchEntry { Name = "remoteBridgeBindHandler", + SearchHints = new[] { "client-state" }, Target = new Regex(@"setCurrentTrainer\(e,t=null\)\{const s=e\?\.trainerId\|\|null,i=\(s\?e\?\.gameId:null\)\|\|null,n=\(s\?e\?\.supportedVersions:null\)\|\|\[];if\(s===this\.#ke&&t===this\.#Ee\)return;"), Patch = "setCurrentTrainer(e,t=null){this.__wandRemoteBridge||(this.__wandRemoteBridge=(()=>{try{const r=globalThis.require||require;const{ipcRenderer:c}=r(\"electron\");try{c.invoke(\"wand-remote-url\").then((u=>{u&&(globalThis.__wandRemoteBridgeUrl=u)}))}catch(e){}const send=(ch,p)=>{try{return c.invoke(ch,p&&JSON.parse(JSON.stringify(p)))}catch(e){}};return{sync:(s)=>send(\"wand-remote-sync\",s),valueChanged:(s)=>send(\"wand-remote-value-changed\",s),setHandler:(h)=>{if(this.__wandRemoteBridgeBound)return;this.__wandRemoteBridgeBound=true;try{c.invoke(\"wand-remote-set-handler-bind\")}catch(e){}c.on(\"wand-remote-set-value\",(_e,req)=>{try{h(req)}catch(e){}})}}}catch(e){try{const r=globalThis.require||require,fs=r(\"node:fs\"),os=r(\"node:os\"),p=r(\"node:path\");fs.appendFileSync(p.join(os.tmpdir(),\"wand-remote-bridge.log\"),\"[\"+new Date().toISOString()+\"] [renderer-bind-error] \"+(e&&e.stack||e)+\"\\n\");}catch(_){}return null}})());this.__wandRemoteBridge?.setHandler((e=>{if(!this.#Ee||!e?.target)return!1;return this.#Ee.isActive()?this.#Ee.setValue(e.target,e.value,g.kL.Remote,e.cheatId):!1}));this.__wandRemoteTrainerInfo=e??null;const s=e?.trainerId||null,i=(s?e?.gameId:null)||null,n=(s?e?.supportedVersions:null)||[];if(s===this.#ke&&t===this.#Ee)return;" }, new PatchEntry { Name = "remoteBridgeValueDelta", + SearchHints = new[] { "client-value-changed" }, Target = new Regex(@"#ct\(e,t\)\{t\.push\(e\.onValueSet\(e=>\{this\.status===i\.Connected&&e\.source!==g\.kL\.Remote&&this\.#Me\?\.send\(""client-value-changed"",\{instanceId:this\.#Pe,name:e\.name,value:e\.value,cheatId:e\.cheatId\}\)\}\)\),this\.#Be\(\)\}"), Patch = "#ct(e,t){t.push(e.onValueSet(e=>{this.status===i.Connected&&e.source!==g.kL.Remote&&this.#Me?.send(\"client-value-changed\",{instanceId:this.#Pe,name:e.name,value:e.value,cheatId:e.cheatId}),this.__wandRemoteBridge?.valueChanged({trainerId:this.#ke,target:e.name,value:e.value,oldValue:e.oldValue,source:String(e.source??\"desktop\"),cheatId:e.cheatId})})),this.#Be()}" }, new PatchEntry { Name = "remoteTooltipPreviewUrl", + SearchHints = new[] { "remote_tooltip.scan_the_qr_code_or_visit_the_site", "remote_tooltip.connect_to_wand_remote" }, Target = new Regex(@"remoteUrl=""wemodwebsite://remote"""), Patch = "remoteUrl=globalThis.__wandRemoteBridgeUrl||\"" + RemoteWebPanelFallbackUrl + "\"" }, new PatchEntry { Name = "remoteQrPreviewUrl", + SearchHints = new[] { "resources/elements/remote-qr-code" }, Resolver = new ResolveContext { Handler = (matchContent) => @@ -162,4 +178,4 @@ namespace WandEnhancer.Core }; } } -} \ No newline at end of file +} diff --git a/WandEnhancer/Locale/lang.de-DE.xaml b/WandEnhancer/Locale/lang.de-DE.xaml index 6094781..c20e639 100644 --- a/WandEnhancer/Locale/lang.de-DE.xaml +++ b/WandEnhancer/Locale/lang.de-DE.xaml @@ -41,6 +41,14 @@ Vor dem Update wird dringend empfohlen, Änderungen rückgängig zu machen, falls sie angewendet wurden + Aktuelle Version + Neueste Version + Versionshinweise + Für diese Version sind keine Versionshinweise verfügbar. + Gesamtes Changelog anzeigen + Nur aktuelle Hinweise anzeigen + Changelog wird geladen... + Das vollständige Changelog konnte nicht geladen werden. Stattdessen werden die aktuellen Hinweise angezeigt. Jetzt aktualisieren Update verfügbar! diff --git a/WandEnhancer/Locale/lang.en-US.xaml b/WandEnhancer/Locale/lang.en-US.xaml index 288fb55..2f093c8 100644 --- a/WandEnhancer/Locale/lang.en-US.xaml +++ b/WandEnhancer/Locale/lang.en-US.xaml @@ -41,6 +41,14 @@ Before updating, it is strongly recommended to roll back modifications if they have been applied + Current version + Latest version + Release notes + Release notes are unavailable for this release. + Show full changelog + Show latest notes + Loading changelog... + Failed to load the full changelog. The latest notes are shown instead. Update now Update available! diff --git a/WandEnhancer/Locale/lang.es-ES.xaml b/WandEnhancer/Locale/lang.es-ES.xaml index 2c54806..fa688a8 100644 --- a/WandEnhancer/Locale/lang.es-ES.xaml +++ b/WandEnhancer/Locale/lang.es-ES.xaml @@ -41,6 +41,14 @@ Antes de actualizar, se recomienda encarecidamente revertir las modificaciones si se han aplicado + Versión actual + Última versión + Notas de la versión + Las notas de la versión no están disponibles para esta versión. + Mostrar changelog completo + Mostrar solo las notas actuales + Cargando changelog... + No se pudo cargar el changelog completo. Se muestran las notas actuales. Actualizar ahora ¡Actualización disponible! diff --git a/WandEnhancer/Locale/lang.fr-FR.xaml b/WandEnhancer/Locale/lang.fr-FR.xaml index 2df2dd8..39a57c0 100644 --- a/WandEnhancer/Locale/lang.fr-FR.xaml +++ b/WandEnhancer/Locale/lang.fr-FR.xaml @@ -41,6 +41,14 @@ Avant la mise à jour, il est fortement recommandé d'annuler les modifications si elles ont été appliquées + Version actuelle + Dernière version + Notes de version + Les notes de version ne sont pas disponibles pour cette version. + Afficher le changelog complet + Afficher uniquement les notes actuelles + Chargement du changelog... + Impossible de charger le changelog complet. Les notes actuelles sont affichées à la place. Mettre à jour maintenant Mise à jour disponible ! diff --git a/WandEnhancer/Locale/lang.it-IT.xaml b/WandEnhancer/Locale/lang.it-IT.xaml index 6abda7d..604c9c5 100644 --- a/WandEnhancer/Locale/lang.it-IT.xaml +++ b/WandEnhancer/Locale/lang.it-IT.xaml @@ -41,6 +41,14 @@ Prima dell'aggiornamento, si consiglia vivamente di annullare le modifiche se sono state applicate + Versione corrente + Ultima versione + Note di rilascio + Le note di rilascio non sono disponibili per questa versione. + Mostra il changelog completo + Mostra solo le note correnti + Caricamento del changelog... + Impossibile caricare il changelog completo. Vengono mostrate solo le note correnti. Aggiorna ora Aggiornamento disponibile! diff --git a/WandEnhancer/Locale/lang.ja-JP.xaml b/WandEnhancer/Locale/lang.ja-JP.xaml index 4b07eca..4f0352a 100644 --- a/WandEnhancer/Locale/lang.ja-JP.xaml +++ b/WandEnhancer/Locale/lang.ja-JP.xaml @@ -41,6 +41,14 @@ アップデート前に、変更が適用されている場合はロールバックすることを強くお勧めします + 現在のバージョン + 最新バージョン + リリースノート + このリリースのリリースノートは利用できません。 + 完全な変更履歴を表示 + 最新のリリースノートのみ表示 + 変更履歴を読み込み中... + 完全な変更履歴を読み込めませんでした。代わりに最新のリリースノートを表示しています。 今すぐ更新 アップデート利用可能! diff --git a/WandEnhancer/Locale/lang.pl-PL.xaml b/WandEnhancer/Locale/lang.pl-PL.xaml index 1fd6847..014f151 100644 --- a/WandEnhancer/Locale/lang.pl-PL.xaml +++ b/WandEnhancer/Locale/lang.pl-PL.xaml @@ -41,6 +41,14 @@ Przed aktualizacją zdecydowanie zaleca się cofnięcie zmian, jeśli zostały zastosowane + Aktualna wersja + Najnowsza wersja + Informacje o wydaniu + Informacje o wydaniu są niedostępne dla tej wersji. + Pokaż cały changelog + Pokaż tylko bieżące zmiany + Ładowanie changeloga... + Nie udało się załadować pełnego changeloga. Zamiast tego wyświetlono bieżące zmiany. Aktualizuj teraz Dostępna aktualizacja! diff --git a/WandEnhancer/Locale/lang.pt-BR.xaml b/WandEnhancer/Locale/lang.pt-BR.xaml index ddff038..346378d 100644 --- a/WandEnhancer/Locale/lang.pt-BR.xaml +++ b/WandEnhancer/Locale/lang.pt-BR.xaml @@ -41,6 +41,14 @@ Antes de atualizar, é altamente recomendável reverter as modificações se elas foram aplicadas + Versão atual + Versão mais recente + Notas da versão + As notas da versão não estão disponíveis para esta versão. + Mostrar changelog completo + Mostrar apenas as notas atuais + Carregando changelog... + Falha ao carregar o changelog completo. As notas atuais estão sendo exibidas. Atualizar agora Atualização disponível! diff --git a/WandEnhancer/Locale/lang.ru-RU.xaml b/WandEnhancer/Locale/lang.ru-RU.xaml index 9668494..73eb65b 100644 --- a/WandEnhancer/Locale/lang.ru-RU.xaml +++ b/WandEnhancer/Locale/lang.ru-RU.xaml @@ -41,6 +41,14 @@ Перед обновлением настоятельно рекомендуется откатить изменения, если они были применены + Текущая версия + Новая версия + Что нового + Для этого релиза патчноуты недоступны. + Показать весь changelog + Показать только актуальные изменения + Загрузка changelog... + Не удалось загрузить полный changelog. Показаны только актуальные изменения. Обновить сейчас Доступно обновление! diff --git a/WandEnhancer/Locale/lang.tr-TR.xaml b/WandEnhancer/Locale/lang.tr-TR.xaml index 3bc5b5b..8cb571d 100644 --- a/WandEnhancer/Locale/lang.tr-TR.xaml +++ b/WandEnhancer/Locale/lang.tr-TR.xaml @@ -41,6 +41,14 @@ Güncellemeden önce, değişiklikler uygulandıysa geri almak şiddetle tavsiye edilir + Geçerli sürüm + En son sürüm + Sürüm notları + Bu sürüm için sürüm notları kullanılamıyor. + Tüm changelog'u göster + Yalnızca güncel notları göster + Changelog yükleniyor... + Tam changelog yüklenemedi. Bunun yerine güncel notlar gösteriliyor. Şimdi güncelle Güncelleme mevcut! diff --git a/WandEnhancer/Locale/lang.uk-UA.xaml b/WandEnhancer/Locale/lang.uk-UA.xaml index 1096452..00eeed6 100644 --- a/WandEnhancer/Locale/lang.uk-UA.xaml +++ b/WandEnhancer/Locale/lang.uk-UA.xaml @@ -41,6 +41,14 @@ Перед оновленням наполегливо рекомендується відкотити зміни, якщо вони були застосовані + Поточна версія + Остання версія + Нотатки до релізу + Нотатки до цього релізу недоступні. + Показати весь список змін + Показати лише актуальні зміни + Завантаження списку змін... + Не вдалося завантажити повний список змін. Натомість показано лише актуальні зміни. Оновити зараз Доступне оновлення! diff --git a/WandEnhancer/Locale/lang.zh-CN.xaml b/WandEnhancer/Locale/lang.zh-CN.xaml index b87cb71..27d346c 100644 --- a/WandEnhancer/Locale/lang.zh-CN.xaml +++ b/WandEnhancer/Locale/lang.zh-CN.xaml @@ -41,6 +41,14 @@ 在更新之前,强烈建议回滚已应用的修改 + 当前版本 + 最新版本 + 更新说明 + 此版本的更新说明不可用。 + 显示完整更新日志 + 仅显示当前说明 + 正在加载更新日志... + 无法加载完整更新日志。当前仅显示本次说明。 立即更新 有更新可用! diff --git a/WandEnhancer/Properties/AssemblyInfo.cs b/WandEnhancer/Properties/AssemblyInfo.cs index 829ffc4..6ac6d30 100644 --- a/WandEnhancer/Properties/AssemblyInfo.cs +++ b/WandEnhancer/Properties/AssemblyInfo.cs @@ -51,5 +51,5 @@ using System.Windows; // 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.7.0")] -[assembly: AssemblyFileVersion("1.0.7.0")] \ No newline at end of file +[assembly: AssemblyVersion("1.0.8.0")] +[assembly: AssemblyFileVersion("1.0.8.0")] \ No newline at end of file diff --git a/WandEnhancer/Utils/Updater.cs b/WandEnhancer/Utils/Updater.cs index 7d0e29f..1d38f01 100644 --- a/WandEnhancer/Utils/Updater.cs +++ b/WandEnhancer/Utils/Updater.cs @@ -1,15 +1,23 @@ using System; +using System.Globalization; using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; +using System.Text; using System.Threading.Tasks; using System.Net.Http; -using System.Windows; using Newtonsoft.Json; namespace WandEnhancer.Utils { + public class UpdateReleaseInfo + { + public string Version { get; set; } + + public string LatestNotes { get; set; } + } + public class GitHubRelease { public class AssetsType @@ -26,11 +34,19 @@ namespace WandEnhancer.Utils [JsonProperty("assets")] public AssetsType[] Assets { get; set; } + [JsonProperty("body")] + public string Body { get; set; } + + [JsonProperty("published_at")] + public DateTimeOffset PublishedAt { get; set; } + } public class Updater { private GitHubRelease _release = null; + private UpdateReleaseInfo _updateInfo = null; + private string _fullChangelog = null; private static readonly HttpClient _httpClient = new HttpClient() { DefaultRequestHeaders = @@ -40,6 +56,7 @@ namespace WandEnhancer.Utils }; private static readonly string ApiUrl = $"https://api.github.com/repos/{Constants.Owner}/{Constants.RepoName}/releases/latest"; + private static readonly string ReleasesApiUrl = $"https://api.github.com/repos/{Constants.Owner}/{Constants.RepoName}/releases?per_page=20"; public async Task CheckForUpdates() { try @@ -48,22 +65,59 @@ namespace WandEnhancer.Utils var response = await _httpClient.GetAsync(ApiUrl); response.EnsureSuccessStatusCode(); _release = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + _updateInfo = null; + _fullChangelog = null; if (_release == null) { return false; } - var latestVersion = new Version(_release.TagName); + var latestVersion = ParseVersion(_release.TagName); + + if (latestVersion <= currentVersion) + { + return false; + } + + _updateInfo = new UpdateReleaseInfo + { + Version = NormalizeVersion(_release.TagName), + LatestNotes = NormalizeText(_release.Body) + }; - return latestVersion > currentVersion; + return true; } - catch (Exception e) + catch (Exception) { return false; } } + public async Task GetUpdateInfoAsync() + { + if (_updateInfo != null) + { + return _updateInfo; + } + + return await CheckForUpdates() + ? _updateInfo + : null; + } + + public async Task GetFullChangelogAsync() + { + if (!string.IsNullOrWhiteSpace(_fullChangelog)) + { + return NormalizeText(_fullChangelog); + } + + _fullChangelog = await TryLoadFullChangelogAsync(); + + return NormalizeText(_fullChangelog); + } + public async Task Update() { if (_release == null) @@ -125,6 +179,103 @@ namespace WandEnhancer.Utils throw new Exception($"Update failed: {ex.Message}"); } } + + private static Version ParseVersion(string versionTag) + { + return new Version(NormalizeVersion(versionTag)); + } + + private static string NormalizeVersion(string versionTag) + { + if (string.IsNullOrWhiteSpace(versionTag)) + { + throw new ArgumentException("Version tag cannot be empty.", nameof(versionTag)); + } + + return versionTag.Trim().TrimStart('v', 'V'); + } + + private static string NormalizeText(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + return NormalizeLineEndings(text).Trim(); + } + + private static string NormalizeLineEndings(string text) + { + return text + .Replace("\r\n", "\n") + .Replace('\r', '\n'); + } + + private static async Task TryLoadFullChangelogAsync() + { + return await TryBuildReleaseHistoryAsync(); + } + + private static async Task TryBuildReleaseHistoryAsync() + { + try + { + var response = await _httpClient.GetAsync(ReleasesApiUrl); + if (!response.IsSuccessStatusCode) + { + return null; + } + + var releases = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + if (releases == null || releases.Length == 0) + { + return null; + } + + return BuildReleaseHistory(releases); + } + catch + { + return null; + } + } + + private static string BuildReleaseHistory(GitHubRelease[] releases) + { + var builder = new StringBuilder(); + + foreach (var release in releases.Where(item => !string.IsNullOrWhiteSpace(item?.TagName))) + { + if (builder.Length > 0) + { + builder.AppendLine(); + builder.AppendLine(); + } + + builder.Append("## [") + .Append(NormalizeVersion(release.TagName)) + .Append("]"); + + if (release.PublishedAt != default(DateTimeOffset)) + { + builder.Append(" - ") + .Append(release.PublishedAt.UtcDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + } + + var notes = NormalizeText(release.Body); + if (string.IsNullOrWhiteSpace(notes)) + { + continue; + } + + builder.AppendLine(); + builder.AppendLine(); + builder.Append(notes); + } + + return NormalizeText(builder.ToString()); + } } } \ No newline at end of file diff --git a/WandEnhancer/View/MainWindow/MainWindowVm.cs b/WandEnhancer/View/MainWindow/MainWindowVm.cs index a2dde4f..6ba9f93 100644 --- a/WandEnhancer/View/MainWindow/MainWindowVm.cs +++ b/WandEnhancer/View/MainWindow/MainWindowVm.cs @@ -185,10 +185,19 @@ namespace WandEnhancer.View.MainWindow }); } - private void OnUpdate(object param) + private async void OnUpdate(object param) { - MainWindow.Instance.OpenPopup(new UpdatePopup(() => + var updateInfo = await _updater.GetUpdateInfoAsync(); + if (updateInfo == null) { + Log("No update details are available right now.", ELogType.Warn); + return; + } + + MainWindow.Instance.OpenPopup(new UpdatePopup(Constants.Version.ToString(), updateInfo.Version, + updateInfo.LatestNotes, () => + { + MainWindow.Instance.ClosePopup(); Task.Run(async () => { try @@ -203,7 +212,7 @@ namespace WandEnhancer.View.MainWindow Log("WandEnhancer updated successfully. Restarting...", ELogType.Success); }); - }), Application.Current.FindResource("up_popup_title") as string); + }, () => _updater.GetFullChangelogAsync()), Application.Current.FindResource("up_popup_title") as string); } private void OnOpenSettings(object param) @@ -271,7 +280,11 @@ namespace WandEnhancer.View.MainWindow public MainWindowVm(MainWindow view) { - Task.Run(async () => IsUpdateAvailable = await _updater.CheckForUpdates()); + Task.Run(async () => + { + var isUpdateAvailable = await _updater.CheckForUpdates(); + Application.Current.Dispatcher.Invoke(() => IsUpdateAvailable = isUpdateAvailable); + }); _view = view; SetFolderPathCommand = new RelayCommand(OnFolderPathSelection); ApplyPatchCommand = new RelayCommand(OnPatching); diff --git a/WandEnhancer/View/Popups/UpdatePopup.xaml b/WandEnhancer/View/Popups/UpdatePopup.xaml index 1ac52e2..dfb0d33 100644 --- a/WandEnhancer/View/Popups/UpdatePopup.xaml +++ b/WandEnhancer/View/Popups/UpdatePopup.xaml @@ -3,18 +3,102 @@ 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" mc:Ignorable="d" d:DesignHeight="Auto" d:DesignWidth="Auto" Background="{DynamicResource Background}" Foreground="{DynamicResource MutedForeground}" FontWeight="Medium" FontSize="13"> - - - - - - ) : null} - -
+
+
+
+
+
+
+ setLeftOpen(true)} /> +
+ {!connected ? ( + setLeftOpen(true)} /> + ) : !activeTrainer ? ( + setRightOpen(true)} /> + ) : ( + <> + currentGame && handleToggleGamePin(currentGame)} /> + +
+ +
{filteredPinnedGroup ? ( ) : null} - {filteredGroups.map((group) => ( + {filteredGroups.map((group, index) => ( ))} - {searchQuery && filteredGroups.length === 0 && !filteredPinnedGroup ? ( -

- No cheats match "{searchQuery}". -

- ) : null} -
- - ) : ( - - )} -
-
+ {cheatQuery && totalVisibleCheats === 0 ?

No mods match "{cheatQuery}"

: null} +
+ {cheatQuery ? `${totalVisibleCheats} matches` : `END · ${state.trainerMeta?.schema.cheats.length ?? 0} MODS`} +
+ + )} +
+ + +