diff --git a/.gitignore b/.gitignore index c6bba59..77452a3 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,10 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* + + +./WeModPatcher/obj/ +./WeModPatcher/bin/ +./AsarSharp/obj/ +./AsarSharp/bin/ +.idea \ No newline at end of file diff --git a/AsarSharp/AsarCreator.cs b/AsarSharp/AsarCreator.cs new file mode 100644 index 0000000..64f9fb1 --- /dev/null +++ b/AsarSharp/AsarCreator.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using AsarSharp.AsarFileSystem; +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 _filenames = new List(); + private Dictionary _metadata = new Dictionary(); + + 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(); + + var filenamesSorted = _filenames.ToList(); + + foreach (var filename in filenamesSorted) + { + HandleFile(filesystem, filename, files); + } + + InsertsDone(filesystem, files); + } + + + + private void HandleFile(Filesystem filesystem, string filename, List files) + { + if (!_metadata.ContainsKey(filename)) + { + var fileType = FileSystemCrawler.DetermineFileType(filename); + _metadata[filename] = fileType ?? throw new Exception("Unknown file type for file: " + filename); + } + var file = _metadata[filename]; + + switch (file.Type) + { + case FileType.Directory: + filesystem.InsertDirectory(filename, false); + break; + case FileType.File: + var shouldUnpack = ShouldUnpackPath(Extensions.GetRelativePath(_folderPath, Path.GetDirectoryName(filename))); + files.Add(new Disk.BasicFileInfo { Filename = filename, Unpack = shouldUnpack }); + filesystem.InsertFile(filename, shouldUnpack, file); + break; + case FileType.Link: + throw new NotImplementedException(); + } + } + + private bool ShouldUnpackPath(string relativePath) + { + 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); + } + + } +} \ No newline at end of file diff --git a/AsarSharp/AsarExtractor.cs b/AsarSharp/AsarExtractor.cs new file mode 100644 index 0000000..0dc1833 --- /dev/null +++ b/AsarSharp/AsarExtractor.cs @@ -0,0 +1,131 @@ +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 + { + 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 + bool followLinks = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + // create destination directory + Directory.CreateDirectory(dest); + + var extractionErrors = new List(); + foreach (var fullPath in filenames) + { + try + { + // 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("..")) + { + throw new InvalidOperationException($"{fullPath}: file \"{destFilename}\" writes out of the package"); + } + + 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("..")) + { + throw new InvalidOperationException( + $"{fullPath}: file \"{file.Link}\" links out of the package to \"{linkSrcPath}\""); + } + + // On Windows, creating symlinks requires additional permissions or enabling Developer Mode, + // so just copy the contents of the file + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + 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); + } + } + else + { + // On Unix systems we use symlinks + Directory.CreateDirectory(Extensions.GetDirectoryName(destFilename)); + Extensions.CreateSymbolicLink(linkTo, destFilename); + } + } + else if (file.IsFile) + { + // it's a file, try to extract it + try + { + byte[] content; + + content = Disk.ReadFileSync(filesystem, filename, file); + + File.WriteAllBytes(destFilename, content); + + 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); + } + } + } +} \ No newline at end of file diff --git a/AsarSharp/AsarFileSystem/Disk.cs b/AsarSharp/AsarFileSystem/Disk.cs new file mode 100644 index 0000000..7d26d91 --- /dev/null +++ b/AsarSharp/AsarFileSystem/Disk.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.IO; +using AsarSharp.PickleTools; +using AsarSharp.Utils; +using Newtonsoft.Json; + +namespace AsarSharp.AsarFileSystem +{ + public static class Disk + { + private static Dictionary _filesystemCache = new Dictionary(); + + public class ArchiveHeader + { + public FilesystemEntry Header { get; set; } + public string HeaderString { get; set; } + public int HeaderSize { get; set; } + } + + public class FilesystemFilesAndLinks + { + public List Files { get; set; } = new List(); + public List Links { get; set; } = new List(); + } + + public class BasicFileInfo + { + public string Filename { get; set; } + public bool Unpack { get; set; } + } + + + #region Reading + + public static ArchiveHeader ReadArchiveHeaderSync(string archivePath) + { + using (FileStream fs = File.OpenRead(archivePath)) + { + // read the size of the header (8 bytes) + byte[] sizeBuf = new byte[8]; + if (fs.Read(sizeBuf, 0, 8) != 8) + { + throw new Exception("Unable to read header size"); + } + + var sizePickle = Pickle.CreateFromBuffer(sizeBuf); + var size = sizePickle.CreateIterator().ReadUInt32(); + + // Read the header of the specified size + var headerBuf = new byte[size]; + if(fs.Read(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(header); + + return new ArchiveHeader + { + Header = headerObj, + HeaderString = header, + HeaderSize = (int)size + }; + } + } + public static Filesystem ReadFilesystemSync(string archivePath) + { + if (!_filesystemCache.ContainsKey(archivePath) || _filesystemCache[archivePath] == null) + { + ArchiveHeader header = ReadArchiveHeaderSync(archivePath); + Filesystem filesystem = new Filesystem(archivePath); + filesystem.SetHeader(header.Header, header.HeaderSize); + _filesystemCache[archivePath] = filesystem; + } + + return _filesystemCache[archivePath]; + } + + public static byte[] ReadFileSync(Filesystem filesystem, string filename, FilesystemEntry info) + { + if (!info.IsFile || !info.Size.HasValue) + { + throw new ArgumentException("Entry is not a file", nameof(info)); + } + + long size = info.Size.Value; + byte[] buffer = new byte[size]; + + if (size <= 0) + { + return buffer; + } + + if (info.Unpacked == true) + { + // It's an unpacked file, read it directly + string filePath = Path.Combine($"{filesystem.GetRootPath()}.unpacked", filename); + return File.ReadAllBytes(filePath); + } + + // Read from the ASAR archive + using (FileStream fs = File.OpenRead(filesystem.GetRootPath())) + { + // Important: the offset must take into account the size of the Pickle header (8 bytes) + // and the size of the header itself + long offset = 8 + filesystem.GetHeaderSize() + long.Parse(info.Offset); + fs.Position = offset; + + // Read the whole file at once + int bytesRead = fs.Read(buffer, 0, (int)size); + if (bytesRead != size) + { + throw new Exception($"Failed to read entire file, got {bytesRead} bytes instead of {size}"); + } + } + + + return buffer; + } + + #endregion + + public static bool UncacheFilesystem(string archivePath) + { + if (_filesystemCache.ContainsKey(archivePath)) + { + _filesystemCache.Remove(archivePath); + return true; + } + + return false; + } + + public static void UncacheAll() + { + _filesystemCache.Clear(); + } + + public static void CopyFile(string dest, string rootPath, string filename) + { + if(dest == null || rootPath == null || filename == null) + throw new ArgumentNullException(); + + if (dest == rootPath) + { + return; + } + + string sourcePath = Path.Combine(rootPath, filename); + string destPath = Path.Combine(dest, filename); + + 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)) + { + sourceStream.CopyTo(destinationStream); + } + } + + + public static void WriteFileSystem(string dest, Filesystem fileSystem, + FilesystemFilesAndLinks lists, + Dictionary metadata) + { + var fsHeader = fileSystem.GetHeader(); + var headerPickle = Pickle.CreateEmpty(); + var serializerSettings = new JsonSerializerSettings() + { NullValueHandling = NullValueHandling.Ignore, DefaultValueHandling = DefaultValueHandling.Ignore } ; + + var headerJson = JsonConvert.SerializeObject(fsHeader,serializerSettings); + headerPickle.WriteString(headerJson); + var headerBuf = headerPickle.ToBuffer(); + + var sizePickle = Pickle.CreateEmpty(); + sizePickle.WriteUInt32((uint)headerBuf.Length); + var sizeBuf = sizePickle.ToBuffer(); + + using (FileStream fs = File.Create(dest)) + { + fs.Write(sizeBuf, 0, sizeBuf.Length); + fs.Write(headerBuf, 0, headerBuf.Length); + + foreach (var file in lists.Files) + { + if (file.Unpack) + { + var filename = Extensions.GetRelativePath(fileSystem.GetRootPath(), file.Filename); + CopyFile($"{dest}.unpacked", fileSystem.GetRootPath(), filename); + continue; + } + using (var transformedFileStream = new FileStream(file.Filename, FileMode.Open, FileAccess.Read)) + { + transformedFileStream.CopyTo(fs); + } + } + } + } + } +} \ No newline at end of file diff --git a/AsarSharp/AsarFileSystem/FileSystem.cs b/AsarSharp/AsarFileSystem/FileSystem.cs new file mode 100644 index 0000000..dc7a4df --- /dev/null +++ b/AsarSharp/AsarFileSystem/FileSystem.cs @@ -0,0 +1,235 @@ +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; // 2^32 - 1 + + public Filesystem(string src) + { + _src = Path.GetFullPath(src); + _header = new FilesystemEntry { Files = new Dictionary(StringComparer.Ordinal) }; + _headerSize = 0; + _offset = 0; + } + + public string GetRootPath() + { + return _src; + } + + public FilesystemEntry GetHeader() + { + return _header; + } + + public int GetHeaderSize() + { + return _headerSize; + } + + public void SetHeader(FilesystemEntry header, int headerSize) + { + _header = header; + _headerSize = headerSize; + } + + public FilesystemEntry SearchNodeFromDirectory(string p) + { + FilesystemEntry json = _header; + + // Normalize path delimiters to system delimiters + p = p.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar); + + string[] dirs = p.Split(Path.DirectorySeparatorChar); + + foreach (string dir in dirs) + { + if (dir == "." || string.IsNullOrEmpty(dir)) continue; + + if (json.IsDirectory) + { + if (!json.Files.ContainsKey(dir)) + { + json.Files[dir] = new FilesystemEntry { Files = new Dictionary(StringComparer.Ordinal) }; + } + json = json.Files[dir]; + } + else + { + throw new Exception($"Unexpected directory state while traversing: {p}"); + } + } + + return json; + } + + public List ListFiles(bool isPack = false) + { + var files = new List(); + + FillFilesFromMetadata("/", _header); + return files; + + void FillFilesFromMetadata(string basePath, FilesystemEntry metadata) + { + if (!metadata.IsDirectory) + { + return; + } + + foreach (var entry in metadata.Files) + { + string childPath = entry.Key; + FilesystemEntry childMetadata = entry.Value; + string fullPath = Path.Combine(basePath, childPath).Replace('\\', '/'); + + string packState = + childMetadata.Unpacked == true ? "unpack" : "pack "; + + files.Add(isPack ? $"{packState} : {fullPath}" : fullPath); + FillFilesFromMetadata(fullPath, childMetadata); + } + } + } + + public FilesystemEntry GetNode(string p, bool followLinks = true) + { + // Normalize path delimiters + p = p.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar); + + FilesystemEntry node = SearchNodeFromDirectory(Extensions.GetDirectoryName(p)); + string name = Path.GetFileName(p); + + // Process symbolic links + if (node.IsLink && followLinks) + { + return GetNode(Path.Combine(node.Link, name)); + } + + if (!string.IsNullOrEmpty(name)) + { + if (node.IsDirectory && node.Files.TryGetValue(name, out var entry)) + { + return entry; + } + return null; + } + + return node; + } + + public FilesystemEntry GetFile(string p, bool followLinks = true) + { + FilesystemEntry info = GetNode(p, followLinks); + + if (info == null) + { + throw new Exception($"\"{p}\" was not found in this archive"); + } + + // If followLinks=false, do not allow symbolic links (TODO) + if (info.IsLink && followLinks) + { + return GetFile(info.Link, followLinks); + } + + return info; + } + + public static string ReadLink(string path) + { + throw new NotImplementedException(); + return Path.GetFileName(path); + // TODO , NOT IMPLEMENTED + } + + + + #region Writing + + public FilesystemEntry SearchNodeFromPath(string p) + { + p = Extensions.GetRelativePath(_src, p); + + if (string.IsNullOrEmpty(p)) + { + return _header; + } + + var name = Path.GetFileName(p); + var node = SearchNodeFromDirectory(Extensions.GetDirectoryName(p)); + + if (node.Files == null) + { + node.Files = new Dictionary(); + } + + if (!node.Files.ContainsKey(name)) + { + node.Files[name] = new FilesystemEntry(); + } + + return node.Files[name]; + } + + public void InsertDirectory(string p, bool unpack) + { + FilesystemEntry node = SearchNodeFromPath(p); + node.Files = node.Files ?? new Dictionary(); + node.Unpacked = unpack; + } + + public void InsertFile(string path, bool shouldUnpack, CrawledFileType file) + { + var dirName = Path.GetDirectoryName(path); + var dirNode = SearchNodeFromPath(dirName); + var node = SearchNodeFromPath(path); + + long size = 0; + if (file.Stat is FileInfo fileInfo) + { + size = fileInfo.Length; + } + else + { + throw new Exception($"{path}: stat is not a file"); + } + + if (shouldUnpack || dirNode.Unpacked == true) + { + node.Size = size; + node.Unpacked = true; + node.Integrity = IntegrityHelper.GetFileIntegrity(path); + return; + } + + // Check that the file size does not exceed UINT32_MAX + 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 = IntegrityHelper.GetFileIntegrity(path); + if (!Extensions.IsWindowsPlatform() && (file.Stat.Attributes & FileAttributes.Hidden) != 0) + { + node.Executable = true; + } + _offset += size; + } + + #endregion + } +} \ No newline at end of file diff --git a/AsarSharp/AsarFileSystem/FileSystemCrawler.cs b/AsarSharp/AsarFileSystem/FileSystemCrawler.cs new file mode 100644 index 0000000..12d3c1d --- /dev/null +++ b/AsarSharp/AsarFileSystem/FileSystemCrawler.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using AsarSharp.Utils; + +namespace AsarSharp.AsarFileSystem +{ + public class CrawledFileType + { + public FileType Type { get; set; } + public FileSystemInfo Stat { get; set; } + public TransformedFile Transformed { get; set; } + } + + public class TransformedFile + { + public string Path { get; set; } + public FileSystemInfo Stat { get; set; } + } + + public enum FileType + { + File, + Directory, + Link + } + + public static class FileSystemCrawler + { + + + public static CrawledFileType DetermineFileType(string filename) + { + var fileInfo = new FileInfo(filename); + if (fileInfo.Exists) + { + return new CrawledFileType { Type = FileType.File, Stat = fileInfo }; + } + + var directoryInfo = new DirectoryInfo(filename); + if (directoryInfo.Exists) + { + return new CrawledFileType { Type = FileType.Directory, Stat = directoryInfo }; + } + + var linkInfo = new FileInfo(filename); + if (linkInfo.Exists && (linkInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint) + { + return new CrawledFileType { Type = FileType.Link, Stat = linkInfo }; + } + + return null; + } + + + public static (List filenames, Dictionary metadata) CrawlFileSystem(string dir) + { + var metadata = new Dictionary(); + var crawled = CrawlIterative(dir); + var results = crawled.Select(filename => new { filename, type = DetermineFileType(filename) }).ToList(); + + var links = new List(); + var filenames = new List(); + + foreach (var result in results.Where(result => result.type != null)) + { + metadata[result.filename] = result.type; + if (result.type.Type == FileType.Link) + { + links.Add(result.filename); + } + filenames.Add(result.filename); + } + + var filteredFilenames = new List(); + + foreach (var filename in filenames) + { + var exactLinkIndex = links.FindIndex(link => filename == link); + var isValid = true; + + for (var i = 0; i < links.Count; i++) + { + if (i == exactLinkIndex) + { + continue; + } + + var link = links[i]; + var isFileWithinSymlinkDir = filename.StartsWith(link, StringComparison.OrdinalIgnoreCase); + var relativePath = Extensions.GetRelativePath(link, Path.GetDirectoryName(filename) ?? string.Empty); + + if (isFileWithinSymlinkDir && !relativePath.StartsWith("..", StringComparison.Ordinal)) + { + isValid = false; + break; + } + } + + if (isValid) + { + filteredFilenames.Add(filename); + } + } + + return (filteredFilenames, metadata); + } + + + // (File order is not important!!!) + public static List CrawlIterative(string dir) + { + var result = new List(); + var stack = new Stack(); + + + string basePath = Extensions.GetBasePath(dir); + + if (!Directory.Exists(basePath)) + return result; + + // Add only the base directory to the stack, but not to the result + stack.Push(basePath); + + while (stack.Count > 0) + { + string currentDir = stack.Pop(); + + try + { + // Add all files from the current directory + result.AddRange(Directory.GetFiles(currentDir, "*", SearchOption.TopDirectoryOnly)); + + // Add subdirectories to the results and to the stack + foreach (var directory in Directory.GetDirectories(currentDir, "*", + SearchOption.TopDirectoryOnly)) + { + // Add subdirectories to the result + if (directory != basePath) // Do not add a base directory + { + result.Add(directory); + } + + // Add to the stack for processing + stack.Push(directory); + } + } + catch (UnauthorizedAccessException) + { + // Skip directories to which there is no access + continue; + } + } + + + return result; + } + } +} \ No newline at end of file diff --git a/AsarSharp/AsarFileSystem/FilesystemEntry.cs b/AsarSharp/AsarFileSystem/FilesystemEntry.cs new file mode 100644 index 0000000..4404e06 --- /dev/null +++ b/AsarSharp/AsarFileSystem/FilesystemEntry.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using AsarSharp.Integrity; +using Newtonsoft.Json; + +namespace AsarSharp.AsarFileSystem +{ + public class FilesystemEntry + { + [JsonProperty("files")] + public Dictionary 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; + } + } +} \ No newline at end of file diff --git a/AsarSharp/AsarSharp.csproj b/AsarSharp/AsarSharp.csproj new file mode 100644 index 0000000..0794a2a --- /dev/null +++ b/AsarSharp/AsarSharp.csproj @@ -0,0 +1,70 @@ + + + + + Debug + AnyCPU + {BEAA604A-402A-4387-8903-A53FC913A26E} + Library + Properties + AsarSharp + AsarSharp + v4.8 + 512 + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + ..\packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AsarSharp/Integrity/IntegrityHelper.cs b/AsarSharp/Integrity/IntegrityHelper.cs new file mode 100644 index 0000000..be07b8b --- /dev/null +++ b/AsarSharp/Integrity/IntegrityHelper.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using Newtonsoft.Json; + +namespace AsarSharp.Integrity +{ + public static class IntegrityHelper + { + private const string ALGORITHM = "SHA256"; + // 4MB default block size + private const int BLOCK_SIZE = 4 * 1024 * 1024; + + 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 Blocks { get; set; } + } + + public static FileIntegrity GetFileIntegrity(string path) + { + using (var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + using(var fileHash = SHA256.Create()) + { + + var blockHashes = new List(); + var buffer = new byte[BLOCK_SIZE]; + int bytesRead; + + while ((bytesRead = fileStream.Read(buffer, 0, BLOCK_SIZE)) > 0) + { + var block = new byte[bytesRead]; + Array.Copy(buffer, block, bytesRead); + blockHashes.Add(HashBlock(block)); + fileHash.TransformBlock(block, 0, block.Length, null, 0); + } + + fileHash.TransformFinalBlock(Array.Empty(), 0, 0); + + return new FileIntegrity + { + Algorithm = ALGORITHM, + Hash = BitConverter.ToString(fileHash.Hash).Replace("-", "").ToLowerInvariant(), + BlockSize = BLOCK_SIZE, + Blocks = blockHashes, + }; + } + } + + private static string HashBlock(byte[] block) + { + using (var sha256 = SHA256.Create()) + { + var hash = sha256.ComputeHash(block); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + } + } +} \ No newline at end of file diff --git a/AsarSharp/PickleTools/Pickle.cs b/AsarSharp/PickleTools/Pickle.cs new file mode 100644 index 0000000..ce7759d --- /dev/null +++ b/AsarSharp/PickleTools/Pickle.cs @@ -0,0 +1,425 @@ +using System; +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; + + // Size of memory allocation unit for payload + public const int PAYLOAD_UNIT = 64; + + // Maximum value for read-only + 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) + { + _header = buffer; + _headerSize = buffer.Length - GetPayloadSize(); + _capacityAfterHeader = CAPACITY_READ_ONLY; + _writeOffset = 0; + + if (_headerSize > buffer.Length) + { + _headerSize = 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() + { + return new Pickle(); + } + + public static Pickle CreateFromBuffer(byte[] buffer) + { + return new Pickle(buffer); + } + + public byte[] GetHeader() + { + return _header; + } + + public int GetHeaderSize() + { + return _headerSize; + } + + public PickleIterator CreateIterator() + { + return new PickleIterator(this); + } + + /// + /// Converts Pickle to a byte array + /// + public byte[] ToBuffer() + { + int resultSize = _headerSize + GetPayloadSize(); + byte[] result = new byte[resultSize]; + Array.Copy(_header, 0, result, 0, resultSize); + return result; + } + + + public bool WriteBool(bool value) + { + return WriteInt(value ? 1 : 0); + } + + public bool WriteInt(int value) + { + EnsureCapacity(SIZE_INT32); + + var dataLength = AlignInt(SIZE_INT32, SIZE_UINT32); + var newSize = _writeOffset + dataLength; + + if (newSize > _capacityAfterHeader) + { + Resize(Math.Max((int)_capacityAfterHeader * 2, newSize)); + } + + 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; + } + + + public bool WriteUInt32(uint value) + { + EnsureCapacity(SIZE_UINT32); + + var dataLength = AlignInt(SIZE_UINT32, SIZE_UINT32); + var newSize = _writeOffset + dataLength; + + if (newSize > _capacityAfterHeader) + { + Resize(Math.Max((int)_capacityAfterHeader * 2, newSize)); + } + + 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; + + if (newSize > _capacityAfterHeader) + { + Resize(Math.Max((int)_capacityAfterHeader * 2, newSize)); + } + + 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; + } + + + public bool WriteUInt64(ulong value) + { + EnsureCapacity(SIZE_UINT64); + + var dataLength = AlignInt(SIZE_UINT64, SIZE_UINT32); + var newSize = _writeOffset + dataLength; + + if (newSize > _capacityAfterHeader) + { + Resize(Math.Max((int)_capacityAfterHeader * 2, newSize)); + } + + 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; + + 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; + } + + 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; + + 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; + } + + SetPayloadSize(newSize); + _writeOffset = newSize; + return true; + } + + public bool WriteString(string value) + { + byte[] strBytes = Encoding.UTF8.GetBytes(value); + int length = strBytes.Length; + + if (!WriteInt(length)) + { + return false; + } + + var dataLength = AlignInt(length, SIZE_UINT32); + var newSize = _writeOffset + dataLength; + + if (newSize > _capacityAfterHeader) + { + Resize(Math.Max((int)_capacityAfterHeader * 2, newSize)); + } + + Array.Copy(strBytes, 0, _header, _headerSize + _writeOffset, length); + + var endOffset = _headerSize + _writeOffset + length; + for (int i = endOffset; i < endOffset + dataLength - length; i++) + { + _header[i] = 0; + } + + SetPayloadSize(newSize); + _writeOffset = newSize; + return true; + } + + public void SetPayloadSize(int payloadSize) + { + WriteUInt32LE((uint)payloadSize, 0); + } + + public int GetPayloadSize() + { + return (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); + _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)); + } + } + + 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); + } + } + + 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); + } + } + + 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); + } + } + + + #endregion + } +} \ No newline at end of file diff --git a/AsarSharp/PickleTools/PickleIterator.cs b/AsarSharp/PickleTools/PickleIterator.cs new file mode 100644 index 0000000..11d069a --- /dev/null +++ b/AsarSharp/PickleTools/PickleIterator.cs @@ -0,0 +1,101 @@ +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 bool ReadBool() + { + return ReadInt() != 0; + } + + public int ReadInt() + { + return ReadBytes(Pickle.SIZE_INT32, BitConverter.ToInt32); + } + + public uint ReadUInt32() + { + return ReadBytes(Pickle.SIZE_UINT32, BitConverter.ToUInt32); + } + + public long ReadInt64() + { + return ReadBytes(Pickle.SIZE_INT64, BitConverter.ToInt64); + } + + public ulong ReadUInt64() + { + return ReadBytes(Pickle.SIZE_UINT64, BitConverter.ToUInt64); + } + + public float ReadFloat() + { + return ReadBytes(Pickle.SIZE_FLOAT, BitConverter.ToSingle); + } + + public double ReadDouble() + { + return ReadBytes(Pickle.SIZE_DOUBLE, BitConverter.ToDouble); + } + + public string ReadString() + { + int length = ReadInt(); + return Encoding.UTF8.GetString(ReadBytes(length)); + } + + private T ReadBytes(int length, Func 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 > _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; + } + } + } +} \ No newline at end of file diff --git a/AsarSharp/Properties/AssemblyInfo.cs b/AsarSharp/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..345b6d6 --- /dev/null +++ b/AsarSharp/Properties/AssemblyInfo.cs @@ -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")] \ No newline at end of file diff --git a/AsarSharp/Utils/Extensions.cs b/AsarSharp/Utils/Extensions.cs new file mode 100644 index 0000000..e629f77 --- /dev/null +++ b/AsarSharp/Utils/Extensions.cs @@ -0,0 +1,145 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; + +namespace AsarSharp.Utils +{ + internal static class Extensions + { + 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)); + + var fullRelativeTo = Path.GetFullPath(relativeTo); + var fullPath = Path.GetFullPath(path); + + if (string.Equals(fullRelativeTo, fullPath, StringComparison.OrdinalIgnoreCase)) + return ""; + + 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() : "")); + + var relativeUri = relativeToUri.MakeRelativeUri(pathUri); + var relativePath = Uri.UnescapeDataString(relativeUri.ToString()) + .Replace('/', Path.DirectorySeparatorChar); + + return relativePath.TrimEnd(Path.DirectorySeparatorChar); + } + + 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 + { + FileName = "chmod", + Arguments = $"{permission} \"{filePath}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true + } + }; + 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 + : NativeMethods.SymLinkFlag.File); + return; + } + + // In Unix systems we use the corresponding system call + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "ln", + Arguments = $"-s \"{linkTarget}\" \"{linkPath}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true + } + }; + process.Start(); + process.WaitForExit(); + } + + + public static bool IsWindowsPlatform() + { + return Environment.OSVersion.Platform == PlatformID.Win32NT; + } + } +} \ No newline at end of file diff --git a/AsarSharp/Utils/NativeMethods.cs b/AsarSharp/Utils/NativeMethods.cs new file mode 100644 index 0000000..6c054b9 --- /dev/null +++ b/AsarSharp/Utils/NativeMethods.cs @@ -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 + } + } +} \ No newline at end of file diff --git a/AsarSharp/packages.config b/AsarSharp/packages.config new file mode 100644 index 0000000..0b14af3 --- /dev/null +++ b/AsarSharp/packages.config @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/WeModPatcher/App.config b/WeModPatcher/App.config new file mode 100644 index 0000000..193aecc --- /dev/null +++ b/WeModPatcher/App.config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/WeModPatcher/App.xaml b/WeModPatcher/App.xaml new file mode 100644 index 0000000..c901bd6 --- /dev/null +++ b/WeModPatcher/App.xaml @@ -0,0 +1,21 @@ + + + + + + + + + + pack://application:,,,/Style/#Inter 18pt 18pt + + + + + + \ No newline at end of file diff --git a/WeModPatcher/App.xaml.cs b/WeModPatcher/App.xaml.cs new file mode 100644 index 0000000..e8aa294 --- /dev/null +++ b/WeModPatcher/App.xaml.cs @@ -0,0 +1,9 @@ +namespace WeModPatcher +{ + /// + /// Interaction logic for App.xaml + /// + public partial class App + { + } +} \ No newline at end of file diff --git a/WeModPatcher/Constants.cs b/WeModPatcher/Constants.cs new file mode 100644 index 0000000..d8fabbe --- /dev/null +++ b/WeModPatcher/Constants.cs @@ -0,0 +1,7 @@ +namespace WeModPatcher +{ + public static class Constants + { + public const string RepositoryUrl = "https://github.com/k1tbyte/Wemod-Patcher"; + } +} \ No newline at end of file diff --git a/WeModPatcher/Converters/BaseBooleanConverter.cs b/WeModPatcher/Converters/BaseBooleanConverter.cs new file mode 100644 index 0000000..55a5866 --- /dev/null +++ b/WeModPatcher/Converters/BaseBooleanConverter.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Windows.Data; + +namespace WeModPatcher.Converters +{ + public abstract class BaseBooleanConverter : 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) + { + switch (value) + { + case null: + return False; + case bool booleanValue: + return booleanValue ? True : False; + } + + if (!(value is int intValue)) + { + return True; + } + + switch (parameter) + { + case null: + return intValue == 0 ? False : True; + case int param: + return intValue > param ? True : False; + default: + //Because object not null + return True; + } + } + + public virtual object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + return value is T t && EqualityComparer.Default.Equals(t, True); + } + } +} \ No newline at end of file diff --git a/WeModPatcher/Converters/ToVisibilityConverter.cs b/WeModPatcher/Converters/ToVisibilityConverter.cs new file mode 100644 index 0000000..a0e8620 --- /dev/null +++ b/WeModPatcher/Converters/ToVisibilityConverter.cs @@ -0,0 +1,18 @@ +using System.Windows; + +namespace WeModPatcher.Converters +{ + internal sealed class ToVisibilityConverter : BaseBooleanConverter + { + public ToVisibilityConverter() : + base(Visibility.Visible, Visibility.Collapsed) + { } + } + + internal sealed class ToVisibilityInvertedConverter : BaseBooleanConverter + { + public ToVisibilityInvertedConverter() : + base(Visibility.Collapsed, Visibility.Visible) + { } + } +} \ No newline at end of file diff --git a/WeModPatcher/MainWindow.xaml b/WeModPatcher/MainWindow.xaml new file mode 100644 index 0000000..acf9872 --- /dev/null +++ b/WeModPatcher/MainWindow.xaml @@ -0,0 +1,12 @@ + + + + + diff --git a/WeModPatcher/MainWindow.xaml.cs b/WeModPatcher/MainWindow.xaml.cs new file mode 100644 index 0000000..b5a23dd --- /dev/null +++ b/WeModPatcher/MainWindow.xaml.cs @@ -0,0 +1,13 @@ +namespace WeModPatcher +{ + /// + /// Interaction logic for MainWindow.xaml + /// + public partial class MainWindow + { + public MainWindow() + { + InitializeComponent(); + } + } +} \ No newline at end of file diff --git a/WeModPatcher/Models/PatchConfig.cs b/WeModPatcher/Models/PatchConfig.cs new file mode 100644 index 0000000..deedf84 --- /dev/null +++ b/WeModPatcher/Models/PatchConfig.cs @@ -0,0 +1,10 @@ +namespace WeModPatcher.Models +{ + + public enum EPatchType + { + ActivatePro = 1, + DisableUpdates = 2, + DisableTelemetry = 4 + } +} \ No newline at end of file diff --git a/WeModPatcher/Properties/AssemblyInfo.cs b/WeModPatcher/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..48deafc --- /dev/null +++ b/WeModPatcher/Properties/AssemblyInfo.cs @@ -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("WeModPatcher")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("WeModPatcher")] +[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 +//CultureYouAreCodingWith in your .csproj file +//inside a . For example, if you are using US english +//in your source files, set the 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("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] \ No newline at end of file diff --git a/WeModPatcher/Properties/Resources.Designer.cs b/WeModPatcher/Properties/Resources.Designer.cs new file mode 100644 index 0000000..8aebc3c --- /dev/null +++ b/WeModPatcher/Properties/Resources.Designer.cs @@ -0,0 +1,69 @@ +//------------------------------------------------------------------------------ +// +// 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. +// +//------------------------------------------------------------------------------ + +namespace WeModPatcher.Properties +{ + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // 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() + { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [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("WeModPatcher.Properties.Resources", + typeof(Resources).Assembly); + resourceMan = temp; + } + + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState + .Advanced)] + internal static global::System.Globalization.CultureInfo Culture + { + get { return resourceCulture; } + set { resourceCulture = value; } + } + } +} \ No newline at end of file diff --git a/WeModPatcher/Properties/Resources.resx b/WeModPatcher/Properties/Resources.resx new file mode 100644 index 0000000..af7dbeb --- /dev/null +++ b/WeModPatcher/Properties/Resources.resx @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/WeModPatcher/ReactiveCore/AsyncRelayCommand.cs b/WeModPatcher/ReactiveCore/AsyncRelayCommand.cs new file mode 100644 index 0000000..e351090 --- /dev/null +++ b/WeModPatcher/ReactiveCore/AsyncRelayCommand.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Input; + +namespace WeModPatcher.ReactiveCore +{ + public sealed class AsyncRelayCommand : ICommand + { + private readonly Func _execute; + private readonly Func _canExecute; + + private long _isExecuting; + + public AsyncRelayCommand(Func execute, Func canExecute = null) + { + this._execute = execute; + this._canExecute = canExecute ?? (o => true); + } + + public event EventHandler CanExecuteChanged + { + add => CommandManager.RequerySuggested += value; + remove => CommandManager.RequerySuggested -= value; + } + + private static void RaiseCanExecuteChanged() => CommandManager.InvalidateRequerySuggested(); + + public bool CanExecute(object parameter) => Interlocked.Read(ref _isExecuting) == 0 && _canExecute(parameter); + + public async void Execute(object parameter) + { + Interlocked.Exchange(ref _isExecuting, 1); + RaiseCanExecuteChanged(); + + try + { + await _execute(parameter); + } + finally + { + Interlocked.Exchange(ref _isExecuting, 0); + RaiseCanExecuteChanged(); + } + } + } +} \ No newline at end of file diff --git a/WeModPatcher/ReactiveCore/ObservableObject.cs b/WeModPatcher/ReactiveCore/ObservableObject.cs new file mode 100644 index 0000000..7e3c129 --- /dev/null +++ b/WeModPatcher/ReactiveCore/ObservableObject.cs @@ -0,0 +1,20 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace WeModPatcher.ReactiveCore +{ + 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(ref T field, T value, [CallerMemberName] string propertyName = null) + { + if (Equals(field, value)) return false; + field = value; + OnPropertyChanged(propertyName); + return true; + } + } +} \ No newline at end of file diff --git a/WeModPatcher/ReactiveCore/RelayCommand.cs b/WeModPatcher/ReactiveCore/RelayCommand.cs new file mode 100644 index 0000000..5592ed2 --- /dev/null +++ b/WeModPatcher/ReactiveCore/RelayCommand.cs @@ -0,0 +1,26 @@ +using System; +using System.Windows.Input; + +namespace WeModPatcher.ReactiveCore +{ + public sealed class RelayCommand : ICommand + { + private readonly Action _execute; + private readonly Func _canExecute; + + public event EventHandler CanExecuteChanged + { + add => CommandManager.RequerySuggested += value; + remove => CommandManager.RequerySuggested -= value; + } + + public RelayCommand(Action execute, Func canExecute = null) + { + _execute = execute; + _canExecute = canExecute; + } + + public bool CanExecute(object parameter) => _canExecute == null || _canExecute(parameter); + public void Execute(object parameter) => _execute(parameter); + } +} \ No newline at end of file diff --git a/WeModPatcher/Style/ColorScheme.xaml b/WeModPatcher/Style/ColorScheme.xaml new file mode 100644 index 0000000..d7b0383 --- /dev/null +++ b/WeModPatcher/Style/ColorScheme.xaml @@ -0,0 +1,22 @@ + + #09090b + #FAFAFA + #27272A + #FAFAFA + #09090b + #FAFAFA + #FAFAFA + #18181B + #27272A + #FAFAFA + #18181a + #A1A1AA + #27272A + #FAFAFA + Red + #FAFAFA + #27272A + #27272A + #D4D4D8 + \ No newline at end of file diff --git a/WeModPatcher/Style/Icons.xaml b/WeModPatcher/Style/Icons.xaml new file mode 100644 index 0000000..7e3af1a --- /dev/null +++ b/WeModPatcher/Style/Icons.xaml @@ -0,0 +1,18 @@ + + + M13.46,12L19,17.54V19H17.54L12,13.46L6.46,19H5V17.54L10.54,12L5,6.46V5H6.46L12,10.54L17.54,5H19V6.46L13.46,12Z + + + + 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 + + + + 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 + + + + 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 + + \ No newline at end of file diff --git a/WeModPatcher/Style/Inter_18pt-Regular.ttf b/WeModPatcher/Style/Inter_18pt-Regular.ttf new file mode 100644 index 0000000..ce097c8 Binary files /dev/null and b/WeModPatcher/Style/Inter_18pt-Regular.ttf differ diff --git a/WeModPatcher/Style/Styles.xaml b/WeModPatcher/Style/Styles.xaml new file mode 100644 index 0000000..0433452 --- /dev/null +++ b/WeModPatcher/Style/Styles.xaml @@ -0,0 +1,245 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WeModPatcher/Utils/Patcher.cs b/WeModPatcher/Utils/Patcher.cs new file mode 100644 index 0000000..43fec64 --- /dev/null +++ b/WeModPatcher/Utils/Patcher.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using AsarSharp; +using WeModPatcher.Models; +using WeModPatcher.View.MainWindow; + +namespace WeModPatcher.Utils +{ + public class Patcher + { + private class PatchEntry + { + public Regex Target { get; set; } + public string Patch { get; set; } + public bool Applied { get; set; } + public bool SingleMatch { get; set; } = true; + public bool DynamicFieldResolve { get; set; } + } + + private static readonly Dictionary Patches = new Dictionary() + { + { + EPatchType.ActivatePro, + new PatchEntry + { + DynamicFieldResolve = true, + Target = new Regex(@"getUserAccount\(\)\{.*?return\s+this\.#\w+\.fetch\(\{.*?\}\)\}", RegexOptions.Singleline), + Patch = "getUserAccount(){return this.#.fetch({endpoint:\"/v3/account\",method:\"GET\",name:\"/v3/account\",collectMetrics:0}).then(response=>{response.subscription={period:\"yearly\",state:\"active\"};response.flags=78;return response;})}" + } + }, + { + EPatchType.DisableUpdates, + new PatchEntry + { + Target = new Regex(@"registerHandler\(""ACTION_CHECK_FOR_UPDATE"".*?\)\)\)\)", RegexOptions.Singleline), + Patch = "registerHandler(\"ACTION_CHECK_FOR_UPDATE\",(e=>expectUpdateFeedUrl(e,(e=>null)))" + } + } + }; + + // ... + // test eax, eax (0x85 for r/m16/32/64) + // jnz short loc_1403A4DD2 (Integrity check failed) + // call near ptr funk_1445527E0 + // ... + private const string PatchSignature = "E8 ?? ?? ?? ?? ?? C0 75 ?? F6 C3 01 74 ?? 48 89 F9 E8 ?? ?? ?? ??"; + private static readonly byte[] PatchBytes = { 0x31 }; + private const int PatchOffset = 0x5; + + private readonly string _weModRootFolder; + private readonly Action _logger; + private readonly HashSet _config; + private readonly string _asarPath; + private readonly string _backupPath; + private readonly string _unpackedPath; + private int _sumOfPatches = 0; + + public Patcher(string weModRootFolder, Action logger, HashSet config) + { + _weModRootFolder = weModRootFolder; + _logger = logger; + _config = config; + + _asarPath = Path.Combine(weModRootFolder, "resources", "app.asar"); + _unpackedPath = Path.Combine(weModRootFolder, "resources", "app.asar.unpacked"); + _backupPath = Path.Combine(weModRootFolder, "resources", "app.asar.backup"); + } + + private static string GetFetchFieldName(string targetFunction) + { + var fetchMatch = Regex.Match(targetFunction, @"return\s+this\.#(\w+)\.fetch"); + return fetchMatch.Success ? fetchMatch.Groups[1].Value : null; + } + + private void ApplyJsPatch(string fileName, string js, PatchEntry patch, EPatchType patchType) + { + if (patch.Applied) + { + return; + } + + var matches = patch.Target.Matches(js); + if (matches.Count == 0) + { + return; + } + + if(matches.Count > 1 && patch.SingleMatch) + { + throw new Exception( + $"[PATCHER] [{patchType}] Patch failed. Multiple target functions found. Looks like the version is not supported"); + } + + if (patch.DynamicFieldResolve) + { + string fetchFieldName = GetFetchFieldName(matches[0].Value); + if (string.IsNullOrEmpty(fetchFieldName)) + { + throw new Exception($"[PATCHER] [{patchType}] Fetch field name not found"); + } + + patch.Patch = patch.Patch.Replace("", fetchFieldName); + } + + _logger($"[PATCHER] [{patchType}] Found target function in: " + Path.GetFileName(fileName), ELogType.Info); + + + File.WriteAllText(fileName, patch.Target.Replace(js, patch.Patch)); + _logger($"[PATCHER] [{patchType}] Patch applied", ELogType.Success); + patch.Applied = true; + _sumOfPatches -= (int)patchType; + } + + private void PatchAsar() + { + var items = Directory.EnumerateFiles(_unpackedPath) + .Where(file => !Directory.Exists(file) && Regex.IsMatch(Path.GetFileName(file), @"^app-\w+|index\.js")) + .ToList(); + + if (!items.Any()) + { + throw new Exception("[PATCHER] No app bundle found"); + } + + var requestedPatches = _config.ToList(); + requestedPatches.ForEach(patch => _sumOfPatches += (int)patch); + foreach (var item in items) + { + if (_sumOfPatches <= 0) + { + break; + } + + string data = File.ReadAllText(item); + foreach (var entry in requestedPatches) + { + ApplyJsPatch(item, data, Patches[entry], entry); + } + } + } + + private async Task PatchPE() + { + _logger("[PATCHER] Patching PE...", ELogType.Info); + var pePath = Path.Combine(_weModRootFolder, "WeMod.exe"); + var patchResult = await PatternScanner.PatchBySignature(pePath, PatchSignature, PatchBytes, PatchOffset); + if(patchResult == -1) + { + _logger("[PATCHER] Failed to patch PE", ELogType.Error); + return; + } + _logger(patchResult == 0 ? "[PATCHER] PE already patched!" : "[PATCHER] PE patched successfully!", ELogType.Success); + } + + public async Task Patch() + { + if (!File.Exists(_backupPath)) + { + _logger("[PATCHER] Creating backup...", ELogType.Info); + File.Copy(_asarPath, _backupPath); + } + else + { + _logger("[PATCHER] Backup already exists", ELogType.Warn); + } + + if(!File.Exists(_asarPath)) + { + _logger("[PATCHER] app.asar not found!", ELogType.Error); + return; + } + + try + { + _logger("[PATCHER] Extracting app.asar...", ELogType.Info); + AsarExtractor.ExtractAll(_asarPath, _unpackedPath); + } + catch (Exception e) + { + _logger($"[PATCHER] Failed to unpack app.asar: {e.Message}", ELogType.Error); + return; + } + + PatchAsar(); + + try + { + new AsarCreator(_unpackedPath, _asarPath, new CreateOptions + { + Unpack = new Regex(@"^static\\unpacked.*$") + }).CreatePackageWithOptions(); + } + catch (Exception e) + { + _logger($"[PATCHER] Failed to pack app.asar: {e.Message}", ELogType.Error); + return; + } + + await PatchPE(); + + _logger("[PATCHER] Done!", ELogType.Success); + } + } +} \ No newline at end of file diff --git a/WeModPatcher/Utils/PatternScanner.cs b/WeModPatcher/Utils/PatternScanner.cs new file mode 100644 index 0000000..05fb04d --- /dev/null +++ b/WeModPatcher/Utils/PatternScanner.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace WeModPatcher.Utils +{ +public class PatternScanner +{ + public static int FindPatternInBuffer(byte[] buffer, int bytesRead, byte[] signature, string mask) + { + int bufferLength = bytesRead + signature.Length - 1; + + for (int i = 0; i <= bytesRead - signature.Length; i++) + { + if (IsMatch(buffer, signature, mask, i)) + return i; + } + + return -1; + } + + private static bool IsMatch(byte[] buffer, byte[] signature, string mask, int offset) + { + for (int i = 0; i < signature.Length; i++) + { + if (mask[i] == 'x' && buffer[offset + i] != signature[i]) + return false; + } + return true; + } + + public static (byte[] signature, string mask) ParseSignature(string signature) + { + var signatureBytes = new List(); + var mask = new StringBuilder(); + + var tokens = signature.Split(' '); + foreach (var token in tokens) + { + if (token == "??" || token == "?") + { + signatureBytes.Add(0); + mask.Append('?'); + } + else + { + signatureBytes.Add(Convert.ToByte(token, 16)); + mask.Append('x'); + } + } + + return (signatureBytes.ToArray(), mask.ToString()); + } + + public static async Task PatchBySignature(string filePath, string functionSignature, byte[] patchBytes, int patchOffset) + { + var (signature, mask) = ParseSignature(functionSignature); + const int bufferSize = 8192; + var buffer = new byte[bufferSize + signature.Length - 1]; + + using (var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite)) + { + int filePosition = 0; + while (true) + { + int bytesRead = await fileStream.ReadAsync(buffer, 0, bufferSize); + if (bytesRead == 0) break; + + int matchIndex = FindPatternInBuffer(buffer, bytesRead, signature, mask); + if (matchIndex != -1) + { + int functionStartPosition = filePosition + matchIndex; + + var checkBuffer = new byte[patchBytes.Length]; + fileStream.Seek(functionStartPosition + patchOffset, SeekOrigin.Begin); + await fileStream.ReadAsync(checkBuffer, 0, patchBytes.Length); + + if (checkBuffer.SequenceEqual(patchBytes)) + { + return 0; // Memory already patched + } + + // Go to patch position + fileStream.Seek(functionStartPosition + patchOffset, SeekOrigin.Begin); + await fileStream.WriteAsync(patchBytes, 0, patchBytes.Length); + + return functionStartPosition; // Return the address of the function start by signature + } + + filePosition += bytesRead; + Array.Copy(buffer, bufferSize, buffer, 0, signature.Length - 1); + } + } + + return -1; + } +} +} \ No newline at end of file diff --git a/WeModPatcher/View/Controls/PopupHost.xaml b/WeModPatcher/View/Controls/PopupHost.xaml new file mode 100644 index 0000000..d4d8906 --- /dev/null +++ b/WeModPatcher/View/Controls/PopupHost.xaml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + diff --git a/WeModPatcher/View/Controls/PopupHost.xaml.cs b/WeModPatcher/View/Controls/PopupHost.xaml.cs new file mode 100644 index 0000000..372ffad --- /dev/null +++ b/WeModPatcher/View/Controls/PopupHost.xaml.cs @@ -0,0 +1,100 @@ +using System; +using System.Threading; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media.Animation; + +namespace WeModPatcher.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(); + 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; + } + } +} \ No newline at end of file diff --git a/WeModPatcher/View/MainWindow/Logs.cs b/WeModPatcher/View/MainWindow/Logs.cs new file mode 100644 index 0000000..0b4dcfb --- /dev/null +++ b/WeModPatcher/View/MainWindow/Logs.cs @@ -0,0 +1,15 @@ +namespace WeModPatcher.View.MainWindow +{ + public enum ELogType + { + Info, + Warn, + Error, + Success + } + public class LogEntry + { + public ELogType LogType { get; set; } + public string Message { get; set; } + } +} \ No newline at end of file diff --git a/WeModPatcher/View/MainWindow/MainWindow.xaml b/WeModPatcher/View/MainWindow/MainWindow.xaml new file mode 100644 index 0000000..303982c --- /dev/null +++ b/WeModPatcher/View/MainWindow/MainWindow.xaml @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + WeMod Patcher + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - -
-
Waiting for action...
-
- -
-
- - -
- - - - -
- - - - - \ No newline at end of file diff --git a/index.js b/index.js deleted file mode 100644 index 9061c19..0000000 --- a/index.js +++ /dev/null @@ -1,134 +0,0 @@ -const { app, BrowserWindow, ipcMain, dialog } = require('electron'); -const path = require('path'); -const fs = require("fs"); -const Unlocker = require("./unlocker"); -const GitHubUpdater = require("./updater"); - -const packageJsonPath = path.join(__dirname, 'package.json'); -const packageData = require(packageJsonPath) -const updater = new GitHubUpdater(packageData.author, packageData.name); - - -function createWindow() { - const win = new BrowserWindow({ - width: 800, - height: 600, - resizable: false, - autoHideMenuBar: true, - webPreferences: { - nodeIntegration: true, - contextIsolation: false - } - }); - - win.loadFile('index.html'); -} - -const checkWeModPath = (root) => fs.existsSync(path.join(root, 'WeMod.exe')) && - fs.existsSync(path.join(root, 'resources/app.asar')) - -function log(message, type = 'info') { - BrowserWindow.getAllWindows().forEach(win => { - win.webContents.send('log', { message, type }); - }); -} - -app.whenReady().then(createWindow); - -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit(); - } -}); - -ipcMain.handle('select-file', async () => { - const result = await dialog.showOpenDialog({ - properties: ["openDirectory"], - defaultPath: process.env.LOCALAPPDATA || path.join(process.env.HOME || process.env.USERPROFILE, 'AppData', 'Local') - }); - - if (!result.canceled && result.filePaths.length > 0) { - return { - filePath: result.filePaths[0], - fileName: path.basename(result.filePaths[0]), - valid: checkWeModPath(result.filePaths[0]) - }; - } - return null; -}); - -ipcMain.handle('apply-patch', async (event, path) => { - const unlocker = new Unlocker(path, (e) => log(e)) - await unlocker.start() -}) - -ipcMain.handle('resolve-default-path', async () => { - const defaultDir = path.join(process.env.LOCALAPPDATA || path.join(process.env.HOME || process.env.USERPROFILE, 'AppData', 'Local'), 'WeMod'); - - if (!fs.existsSync(defaultDir)) { - return null; - } - - const items = fs.readdirSync(defaultDir, {withFileTypes: true}); - const appFolders = items - .filter(item => item.isDirectory() && /^app-\w+/.test(item.name)) - .map(item => { - const folderPath = path.join(defaultDir, item.name); - const stats = fs.statSync(folderPath); - return { - name: item.name, - path: folderPath, - mtime: stats.mtime, - }; - }); - - let appDir = null; - appFolders.sort((a, b) => b.mtime - a.mtime); - for (const folder of appFolders) { - if (checkWeModPath(folder.path)) { - appDir = folder.path; - break; - } - } - - return appDir; -}) - -ipcMain.handle('start-patch', async (event, filePath) => { - try { - // stub - return { - success: true, - message: 'Patch completed successfully!' - }; - } catch (error) { - return { - success: false, - message: error.message - }; - } -}); - -ipcMain.handle("get-current-version", () => { - return packageData.version -}) - - -ipcMain.handle("check-updates", async () => { - return await updater.checkForUpdates() -}) - -ipcMain.on('open-link', () => { - require('electron').shell.openExternal("https://github.com/k1tbyte/Wemod-Patcher") -}) - -ipcMain.on("apply-update", async (event, source) => { - try { - log("Downloading update ...") - const path = await updater.downloadUpdate(source) - log("Preparation") - updater.applyUpdate(path) - } catch (err) { - log(err, "error") - } -}) \ No newline at end of file diff --git a/memoryScanner.js b/memoryScanner.js deleted file mode 100644 index 0f25fed..0000000 --- a/memoryScanner.js +++ /dev/null @@ -1,81 +0,0 @@ -const fs = require("fs"); - -function findPatternInBuffer(buffer, bytesRead, signature, mask) { - const bufferLength = bytesRead + signature.length - 1; - - for (let i = 0; i <= bytesRead - signature.length; i++) { - if (isMatch(buffer, signature, mask, i)) return i; - } - - return -1; -} - - -function isMatch(buffer, signature, mask, offset) { - for (let i = 0; i < signature.length; i++) { - if (mask[i] === "x" && buffer[offset + i] !== signature[i]) return false; - } - return true; -} - -function parseSignature(signature) { - const signatureBytes = []; - let mask = ""; - - const tokens = signature.split(" "); - tokens.forEach((token) => { - if (token === "??" || token === "?") { - signatureBytes.push(0); - mask += "?"; - } else { - signatureBytes.push(parseInt(token, 16)); - mask += "x"; - } - }); - - return { signature: Buffer.from(signatureBytes), mask }; -} - - -async function patchBySignature(filePath, functionSignature, patchBytes, patchOffset) { - const { signature, mask } = parseSignature(functionSignature); - - const bufferSize = 8192; - const buffer = Buffer.alloc(bufferSize + signature.length - 1); - - const fileHandle = await fs.promises.open(filePath, "r+"); - - let filePosition = 0; - try { - while (true) { - const { bytesRead } = await fileHandle.read(buffer, 0, bufferSize, filePosition); - if (bytesRead === 0) break; - - const matchIndex = findPatternInBuffer(buffer, bytesRead, signature, mask); - if (matchIndex !== -1) { - const functionStartPosition = filePosition + matchIndex; - - const checkBuffer = Buffer.alloc(patchBytes.length); - await fileHandle.read(checkBuffer, 0, patchBytes.length, functionStartPosition + patchOffset); - - if (Buffer.compare(checkBuffer, Buffer.from(patchBytes)) === 0) { - return 0; // Memory already patched - } - - // Go to patch position - await fileHandle.write(Buffer.from(patchBytes), 0, patchBytes.length, functionStartPosition + patchOffset); - - return functionStartPosition; // Return the address of the function start by signature - } - - filePosition += bytesRead; - buffer.copy(buffer, 0, bufferSize, bufferSize + signature.length - 1); - } - } finally { - await fileHandle.close(); - } - - return -1; -} - -module.exports = patchBySignature; \ No newline at end of file diff --git a/package.json b/package.json deleted file mode 100644 index 456a807..0000000 --- a/package.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "name": "wemod-patcher", - "version": "0.0.1", - "main": "index.js", - "scripts": { - "start": "electron .", - "build": "electron-builder --win portable" - }, - "keywords": [], - "author": "k1tbyte", - "license": "Apache-2.0", - "description": "", - "dependencies": { - "asar": "^3.2.0" - }, - "devDependencies": { - "electron": "^33.2.1", - "electron-builder": "^25.1.8" - }, - "build": { - "appId": "kitbyte.wemod-patcher", - "productName": "WeMod Patcher", - "directories": { - "output": "dist" - }, - "win": { - "icon": "./appicon.ico", - "target": [{ - "target": "portable", - "arch": ["x64"] - }], - "artifactName": "${productName}.exe" - }, - "nsis": { - "oneClick": false, - "perMachine": false, - "allowToChangeInstallationDirectory": true, - "installerIcon": "./appicon.ico", - "uninstallerIcon": "./appicon.ico", - "installerHeaderIcon": "./appicon.ico" - }, - "compression": "maximum", - "removePackageScripts": true, - "removePackageKeywords": true, - "extraResources": false, - "electronLanguages": ["en-US"] - } -} diff --git a/renderer.js b/renderer.js deleted file mode 100644 index 5e3aed4..0000000 --- a/renderer.js +++ /dev/null @@ -1,112 +0,0 @@ -const { ipcRenderer } = require('electron'); - -class PatcherUI { - constructor() { - this.selectedPath = ''; - this.initializeElements(); - this.bindEvents(); - - ipcRenderer.on('log', (event, { message, type }) => { - this.addLog(message, type); - }); - } - - initializeElements() { - this.filePathInput = document.getElementById('file-path'); - this.browseBtn = document.getElementById('browse-btn'); - this.patchBtn = document.getElementById('patch-btn'); - this.updateBtn = document.getElementById('updateBtn'); - this.versionLabel = document.getElementById('version-label'); - this.logSection = document.querySelector('.log-section'); - document.getElementById(`sourceBtn`).addEventListener('click', () => { - ipcRenderer.send("open-link") - }) - } - - bindEvents() { - this.browseBtn.addEventListener('click', () => this.selectFile()); - this.patchBtn.addEventListener('click', () => this.startPatch()); - } - - setPath(path) { - this.filePathInput.value = this.selectedPath = path - this.patchBtn.disabled = !path; - } - - async selectFile() { - try { - const result = await ipcRenderer.invoke('select-file'); - if (!result?.filePath) { - return; - } - - if(!result.valid) { - this.addLog(`The folder “${result.filePath}” is not recognized as a Wemod directory`, 'error') - return; - } - - this.setPath(result.filePath) - this.addLog('Folder selected: ' + result.fileName, 'info'); - } catch (error) { - this.addLog('Error selecting file: ' + error.message, 'error'); - } - } - - addLog(message, type = 'info') { - const entry = document.createElement('div'); - entry.className = `log-entry ${type}`; - entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`; - this.logSection.appendChild(entry); - this.logSection.scrollTop = this.logSection.scrollHeight; - } - - async startPatch() { - if (!this.selectedPath) return; - - this.patchBtn.disabled = true; - this.addLog('Starting patch process...', 'info'); - - try { - await ipcRenderer.invoke( - 'apply-patch', - this.selectedPath - ); - this.addLog("Success", 'success'); - } catch (error) { - this.addLog('Patch failed: ' + error.message, 'error'); - } finally { - this.patchBtn.disabled = false; - } - } - - resolveDefault() { - - ipcRenderer.invoke("get-current-version").then((v) => - this.versionLabel.textContent = `Current version: ${v}`); - - ipcRenderer.invoke("resolve-default-path").then(path => { - this.addLog(path ? 'The WeMod folder has been found!' : - "WeMod folder was not found. You need to specify the path manually", path ? "success" : "warning" - ); - this.setPath(path) - }) - - ipcRenderer.invoke("check-updates").then(result => { - if(!result) { - return; - } - - this.updateBtn.className = "" - this.updateBtn.textContent = `Update to ${result.version}` - this.updateBtn.addEventListener('click', () => { - this.updateBtn.className = "hidden" - ipcRenderer.send("apply-update", result); - }); - }) - } -} - -window.addEventListener('DOMContentLoaded', async() => { - const ui = new PatcherUI(); - await ui.resolveDefault() -}); \ No newline at end of file diff --git a/styles.css b/styles.css deleted file mode 100644 index 71d997a..0000000 --- a/styles.css +++ /dev/null @@ -1,188 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; -} - -body { - background: #191a33; - color: #beb2b2; - padding: 0 50px 0 50px; - height: 100vh; - display: flex; -} - -.container { - background: #21223d; - padding: 24px 24px 15px; - border: 1px solid #1e2039; - height: 100%; - width: 100%; - max-width: 1000px; - margin: auto; - gap: 24px; -} - -.main-section { - display: flex; - height: 100%; - justify-content: center; - flex-direction: column; -} - -.header { - display: flex; - flex-direction: column; -} - -.header h1 { - font-size: 26px; - margin-bottom: 8px; - color: #6d67fd; - background: #3a3881; - border-radius: 10px; - padding: 5px 10px; - width: fit-content; -} - -.header p { - color: #beb2b2; - font-size: 13px; -} - -.path-section { - background: #26254b; - padding: 16px; - margin-top: 20px; - border-radius: 6px; - border: 1px solid #1e2039; -} - -.path-section label { - display: block; - margin-bottom: 8px; - font-size: 13px; - color: #f8f6f6; -} - -.path-input-container { - display: flex; - gap: 8px; -} - -.path-input { - flex: 1; - background: #21223d; - color: #beb2b2; - padding: 8px 12px; - border: 1px solid #6d67fd; - border-radius: 4px; - font-size: 14px; -} - -.browse-btn { - background: #6d67fd; - border: none; - padding: 8px 16px; - border-radius: 4px; - cursor: pointer; - font-size: 14px; - color: #f8f6f6; - transition: background 0.2s; -} - -.browse-btn:hover { - background: #6d67fd; -} - -.patch-btn { - margin: 15px 0; - background: #6d67fd; - color: #f8f6f6; - border: none; - padding: 12px 24px; - border-radius: 4px; - font-size: 16px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; -} - -.patch-btn:hover:enabled { - scale: 105%; -} - -.patch-btn:disabled { - background: #3a3881; - cursor: not-allowed; - opacity: 0.7; -} - -.log-section { - background: #26254b; - color: #beb2b2; - padding: 16px; - border-radius: 6px; - height: 100%; - font-family: monospace; - font-size: 14px; - overflow-y: auto; - border: 1px solid #1e2039; -} - -.log-entry { - margin-bottom: 8px; - line-height: 1.4; -} - -.success { - color: #08fc81; -} - -.error { - color: #f04343; -} - -.warning { - color: #facc15; -} - -.info { - color: #6d67fd; -} - -#sourceBtn { - cursor: pointer; -} - -.footer { - margin-top: 10px; - display: flex; - align-items: center; - justify-content: space-between; -} - -.footer span { - font-size: 12px; -} - -.hidden { - display: none; -} - -#updateBtn { - background: springgreen; - color: #1e2039; - font-weight: bold; - border: none; - cursor: pointer; - border-radius: 10px; - margin-left: 10px; - padding: 10px 25px; - transition: all 0.1s; -} - -#updateBtn:hover { - scale: 105% -} \ No newline at end of file diff --git a/unlocker.js b/unlocker.js deleted file mode 100644 index dc01156..0000000 --- a/unlocker.js +++ /dev/null @@ -1,112 +0,0 @@ -const path = require('path'); -const fs = require('fs'); -const asar = require('asar'); -const { execSync } = require("child_process"); -const patchBySignature = require("./memoryScanner"); - - -const regex = /getUserAccount\(\)\{.*?return\s+this\.#\w+\.fetch\(\{.*?}\).*?}/g; -const asarPatch = "getUserAccount(){return this.#.fetch({endpoint:\"/v3/account\",method:\"GET\",name:\"/v3/account\",collectMetrics:0}).then(response=>{response.subscription={period:\"yearly\",state:\"active\"};response.flags=78;return response;})}" -const signature = "E8 ?? ?? ?? ?? ?? C0 75 ?? F6 C3 01 74 ?? 48 89 F9 E8 ?? ?? ?? ??" -const patchBytes = [0x31] -const patchOffset = 0x5 -// ... -// test eax, eax (0x85 for r/m16/32/64) -// jnz short loc_1403A4DD2 (Integrity check failed) -// call near ptr funk_1445527E0 -// ... - -class Unlocker { - - constructor(appDir, logger) { - this.appDir = appDir; - this.logger = logger; - } - - #getFetchFieldName(code) { - const match = code.match(/this\.#([a-zA-Z_$][0-9a-zA-Z_$]*)\.fetch/); - return match ? match[1] : null; - } - - #patchAsar (unpackedPath) { - let items = fs.readdirSync(unpackedPath,{ withFileTypes: true }) - items = items.filter(item => !item.isDirectory() && /^app-\w+/.test(item.name)); - if(items.length === 0) { - throw new Error(" - No app bundle found"); - } - - let asarPatchApplied = false; - for (const item of items) { - const data = fs.readFileSync(path.join(unpackedPath, item.name), { encoding: 'utf8'}) - - const matches = data.match(regex) - if(!matches) { - continue; - } - if(matches.length > 1) { - throw new Error(" - Multiple target functions found. Looks like the version is not supported"); - } - - const fetchFieldName = this.#getFetchFieldName(matches[0]); - if(!fetchFieldName) { - throw new Error(" - Fetch field name not found"); - } - - const patch = asarPatch.replace(//g, fetchFieldName) - - this.logger(" - Found target function in: " + item.name) - this.logger(" - Patching asar...") - fs.writeFileSync(path.join(unpackedPath, item.name), data.replace(regex, patch), {encoding: 'utf8'}) - this.logger(" - Patch applied") - asarPatchApplied = true; - break; - } - - if(!asarPatchApplied) { - throw new Error("Failed to apply patch"); - } - } - - - async #patchPE () { - this.logger(" - Patching PE...") - const pePath = path.join(this.appDir, 'WeMod.exe') - const procStart = await patchBySignature(pePath, signature, patchBytes, patchOffset) - if(procStart === -1) { - throw new Error(" - Signature not found or already patched") - } - - this.logger(procStart === 0 ? " - PE already patched" : " - Patch saved") - } - - async start () { - this.logger(" - Extracting asar...") - const asarPath = path.join(this.appDir, 'resources', 'app.asar') - const unpackedPath = path.join(this.appDir, 'resources', 'app.asar.unpacked') - const backupPath = path.join(this.appDir, 'resources', 'app.asar.backup') - - if(fs.existsSync(backupPath)) { - this.logger(" - Backup already exists") - } else { - execSync(`copy "${asarPath}" "${backupPath}"`, { encoding: "utf-8" }); - this.logger(" - Backup saved") - } - - try { - asar.extractAll(asarPath, unpackedPath) - } catch(e) { - throw new Error("Failed to extract asar: " + e) - } - - this.#patchAsar(unpackedPath) - - await asar.createPackageWithOptions(unpackedPath, asarPath, - { unpack: path.join(unpackedPath,"static/unpacked/**") }) - this.logger(" - Patch saved") - - await this.#patchPE() - } -} - -module.exports = Unlocker; - diff --git a/updater.js b/updater.js deleted file mode 100644 index 87a380f..0000000 --- a/updater.js +++ /dev/null @@ -1,105 +0,0 @@ -const { app } = require("electron"); -const fs = require("fs"); -const path = require("path"); -const { exec } = require("child_process"); - -class GitHubUpdater { - constructor(owner, repo, currentVersion) { - this.owner = owner; - this.repo = repo; - this.currentVersion = currentVersion || app.getVersion(); - this.apiUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`; - this.downloadDir = path.join(app.getPath("temp"), "update"); - } - - async checkForUpdates() { - const response = await fetch(this.apiUrl, { - headers: { "User-Agent": "GitHub-Updater" }, - }); - if (!response.ok) { - throw new Error(`An error occurred while checking for an update: ${response.statusText}`); - } - - const release = await response.json(); - const latestVersion = release.tag_name; - const assets = release.assets; - - if (this.currentVersion === latestVersion) { - return null; - } - - const asset = assets.find(a => a.name.endsWith(".exe")); - if (!asset) { - throw new Error("Unable to find files to update"); - } - - return { - version: latestVersion, - url: asset.browser_download_url, - name: asset.name, - }; - } - - async downloadUpdate(updateInfo) { - const response = await fetch(updateInfo.url); - if (!response.ok) { - throw new Error(`Error downloading file: ${response.statusText}`); - } - - if (!fs.existsSync(this.downloadDir)) { - fs.mkdirSync(this.downloadDir, { recursive: true }); - } - - const filePath = path.join(this.downloadDir, updateInfo.name); - const fileStream = fs.createWriteStream(filePath); - - await new Promise((resolve, reject) => { - const downloadStream = response.body.getReader(); - - const pump = async () => { - try { - while (true) { - const { done, value } = await downloadStream.read(); - if (done) { - fileStream.end(); - resolve(); - break; - } - fileStream.write(value); - } - } catch (error) { - reject(error); - } - }; - - fileStream.on("error", (error) => { - reject(new Error(`File write error: ${error.message}`)); - }); - - pump(); - }); - - return filePath; - } - - async applyUpdate(filePath) { - try { - const currentExecutable = process.env.PORTABLE_EXECUTABLE_FILE; - const updateScript = `Start-Sleep -Seconds 3; Copy-Item -Path '${filePath}' -Destination '${currentExecutable}' -Force; Remove-Item -Path '${filePath}' -Force; Start-Sleep -Seconds 2; Start-Process -FilePath '${currentExecutable}';`; - - exec(`start /b "" powershell -WindowStyle Hidden -Command "${updateScript}"`, { - windowsHide: true, - stdio: 'ignore' - }); - - setTimeout(() => { - app.quit(); - }, 1000); - - } catch (error) { - throw new Error(`Update failed: ${error.message}`); - } - } -} - -module.exports = GitHubUpdater;