diff --git a/Directory.Packages.props b/Directory.Packages.props index bb225235..7de50230 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,7 +23,7 @@ - + \ No newline at end of file diff --git a/Knossos.NET.Desktop/Assets/utils/innoextract.license b/Knossos.NET.Android/natives/3rdparty/innoextract/LICENSE similarity index 100% rename from Knossos.NET.Desktop/Assets/utils/innoextract.license rename to Knossos.NET.Android/natives/3rdparty/innoextract/LICENSE diff --git a/Knossos.NET.Android/natives/arm64-v8a/libinnowrap.so b/Knossos.NET.Android/natives/arm64-v8a/libinnowrap.so new file mode 100644 index 00000000..4f3c438e Binary files /dev/null and b/Knossos.NET.Android/natives/arm64-v8a/libinnowrap.so differ diff --git a/Knossos.NET.Android/natives/armeabi-v7a/libinnowrap.so b/Knossos.NET.Android/natives/armeabi-v7a/libinnowrap.so new file mode 100644 index 00000000..a65786b1 Binary files /dev/null and b/Knossos.NET.Android/natives/armeabi-v7a/libinnowrap.so differ diff --git a/Knossos.NET.Android/natives/x86/libinnowrap.so b/Knossos.NET.Android/natives/x86/libinnowrap.so new file mode 100644 index 00000000..e4dad008 Binary files /dev/null and b/Knossos.NET.Android/natives/x86/libinnowrap.so differ diff --git a/Knossos.NET.Android/natives/x86_64/libinnowrap.so b/Knossos.NET.Android/natives/x86_64/libinnowrap.so new file mode 100644 index 00000000..d6660e10 Binary files /dev/null and b/Knossos.NET.Android/natives/x86_64/libinnowrap.so differ diff --git a/Knossos.NET.Desktop/Assets/utils/linux-arm64/innoextract.arm64 b/Knossos.NET.Desktop/Assets/utils/linux-arm64/innoextract.arm64 deleted file mode 100644 index 03168575..00000000 Binary files a/Knossos.NET.Desktop/Assets/utils/linux-arm64/innoextract.arm64 and /dev/null differ diff --git a/Knossos.NET.Desktop/Assets/utils/linux-riscv64/innoextract b/Knossos.NET.Desktop/Assets/utils/linux-riscv64/innoextract deleted file mode 100644 index 6f90a44d..00000000 Binary files a/Knossos.NET.Desktop/Assets/utils/linux-riscv64/innoextract and /dev/null differ diff --git a/Knossos.NET.Desktop/Assets/utils/linux-x64/innoextract.x64 b/Knossos.NET.Desktop/Assets/utils/linux-x64/innoextract.x64 deleted file mode 100644 index a8443f0a..00000000 Binary files a/Knossos.NET.Desktop/Assets/utils/linux-x64/innoextract.x64 and /dev/null differ diff --git a/Knossos.NET.Desktop/Assets/utils/osx/innoextract.mac64 b/Knossos.NET.Desktop/Assets/utils/osx/innoextract.mac64 deleted file mode 100644 index 3d9237d7..00000000 Binary files a/Knossos.NET.Desktop/Assets/utils/osx/innoextract.mac64 and /dev/null differ diff --git a/Knossos.NET.Desktop/Assets/utils/win/innoextract.exe b/Knossos.NET.Desktop/Assets/utils/win/innoextract.exe deleted file mode 100644 index 22c33dc1..00000000 Binary files a/Knossos.NET.Desktop/Assets/utils/win/innoextract.exe and /dev/null differ diff --git a/Knossos.NET.Desktop/Knossos.NET.Desktop.csproj b/Knossos.NET.Desktop/Knossos.NET.Desktop.csproj index f5f5578f..dac32dfb 100644 --- a/Knossos.NET.Desktop/Knossos.NET.Desktop.csproj +++ b/Knossos.NET.Desktop/Knossos.NET.Desktop.csproj @@ -11,6 +11,7 @@ copyused true true + AnyCPU;x64 @@ -27,27 +28,109 @@ - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + <_IsWin Condition="$(RuntimeIdentifier.StartsWith('win')) Or + ($(RuntimeIdentifier) == '' And $([MSBuild]::IsOSPlatform('Windows')))">true + <_IsLinux Condition="$(RuntimeIdentifier.StartsWith('linux')) Or + ($(RuntimeIdentifier) == '' And $([MSBuild]::IsOSPlatform('Linux')))">true + <_IsOsx Condition="$(RuntimeIdentifier.StartsWith('osx')) Or + ($(RuntimeIdentifier) == '' And $([MSBuild]::IsOSPlatform('OSX')))">true + + + <_IsX64 Condition="$(RuntimeIdentifier.Contains('x64')) Or + ('$(RuntimeIdentifier)' == '' And '$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'X64')">true + <_IsX86 Condition="$(RuntimeIdentifier.Contains('x86')) Or + ('$(RuntimeIdentifier)' == '' And '$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'X86')">true + <_IsArm64 Condition="$(RuntimeIdentifier.Contains('arm64')) Or + ('$(RuntimeIdentifier)' == '' And '$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)' == 'Arm64')">true + <_IsRiscV64 Condition="$(RuntimeIdentifier.Contains('riscv64'))">true + + + + + + PreserveNewest + innowrap.dll + + + + + PreserveNewest + innowrap.dll + + + + + PreserveNewest + innowrap.dll + + + + + + + PreserveNewest + libinnowrap.so + + + + + + + PreserveNewest + libinnowrap.so + + + + + + + PreserveNewest + libinnowrap.so + + + + + + + PreserveNewest + libinnowrap.dylib + + + + diff --git a/Knossos.NET.Desktop/precompiled/3rdparty/innoextract/LICENSE b/Knossos.NET.Desktop/precompiled/3rdparty/innoextract/LICENSE new file mode 100644 index 00000000..446f0c78 --- /dev/null +++ b/Knossos.NET.Desktop/precompiled/3rdparty/innoextract/LICENSE @@ -0,0 +1,18 @@ + +Copyright (C) 2011-2020 Daniel Scharrer + +This software is provided 'as-is', without any express or implied +warranty. In no event will the author(s) be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. diff --git a/Knossos.NET.Desktop/precompiled/linux/arm64/libinnowrap.so b/Knossos.NET.Desktop/precompiled/linux/arm64/libinnowrap.so new file mode 100644 index 00000000..cc0ec872 Binary files /dev/null and b/Knossos.NET.Desktop/precompiled/linux/arm64/libinnowrap.so differ diff --git a/Knossos.NET.Desktop/precompiled/linux/riscv64/libinnowrap.so b/Knossos.NET.Desktop/precompiled/linux/riscv64/libinnowrap.so new file mode 100644 index 00000000..c63711a3 Binary files /dev/null and b/Knossos.NET.Desktop/precompiled/linux/riscv64/libinnowrap.so differ diff --git a/Knossos.NET.Desktop/precompiled/linux/x64/libinnowrap.so b/Knossos.NET.Desktop/precompiled/linux/x64/libinnowrap.so new file mode 100644 index 00000000..f3062a24 Binary files /dev/null and b/Knossos.NET.Desktop/precompiled/linux/x64/libinnowrap.so differ diff --git a/Knossos.NET.Desktop/precompiled/osx/libinnowrap.dylib b/Knossos.NET.Desktop/precompiled/osx/libinnowrap.dylib new file mode 100644 index 00000000..aae2aa2b Binary files /dev/null and b/Knossos.NET.Desktop/precompiled/osx/libinnowrap.dylib differ diff --git a/Knossos.NET.Desktop/precompiled/windows/arm64/innowrap.dll b/Knossos.NET.Desktop/precompiled/windows/arm64/innowrap.dll new file mode 100644 index 00000000..a704bd78 Binary files /dev/null and b/Knossos.NET.Desktop/precompiled/windows/arm64/innowrap.dll differ diff --git a/Knossos.NET.Desktop/precompiled/windows/x64/innowrap.dll b/Knossos.NET.Desktop/precompiled/windows/x64/innowrap.dll new file mode 100644 index 00000000..e362ac55 Binary files /dev/null and b/Knossos.NET.Desktop/precompiled/windows/x64/innowrap.dll differ diff --git a/Knossos.NET.Desktop/precompiled/windows/x86/innowrap.dll b/Knossos.NET.Desktop/precompiled/windows/x86/innowrap.dll new file mode 100644 index 00000000..e682207c Binary files /dev/null and b/Knossos.NET.Desktop/precompiled/windows/x86/innowrap.dll differ diff --git a/Knossos.NET/Classes/Inno/InnoArchive.cs b/Knossos.NET/Classes/Inno/InnoArchive.cs new file mode 100644 index 00000000..9a54dc61 --- /dev/null +++ b/Knossos.NET/Classes/Inno/InnoArchive.cs @@ -0,0 +1,1128 @@ +// InnoArchive.cs +// +// Managed wrapper around the native "innowrap" library (a parser-only build of +// innoextract). The native side ONLY parses the Inno Setup headers and returns +// per-file metadata; this class owns the installer stream, feeds bytes to the +// parser through callbacks, and performs ALL decompression with SharpCompress. +// +// Dependencies: SharpCompress (https://github.com/adamhathcock/sharpcompress). +// +// Native library name resolves to: innowrap.dll (Windows) / libinnowrap.so +// (Linux/Android) / libinnowrap.dylib (macOS), via NativeLibrary search rules. +// +// Typical use: +// using var archive = new InnoArchive(File.OpenRead(gogSetupExe)); +// foreach (var f in archive.Files) Console.WriteLine(f.Name); +// var vp = archive.FindFile("root_fs2.vp"); +// using var outFs = File.Create(Path.Combine(dest, "root_fs2.vp")); +// archive.ExtractTo(vp, outFs); + +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using SharpCompress.Compressors; +using SharpCompress.Compressors.Deflate; + +namespace Knossos.NET.Classes.Inno +{ + public sealed class InnoExtractException : Exception + { + public InnoExtractException(string message) : base(message) { } + } + + /// Compression of a chunk's payload (matches innoextract's enum). + public enum InnoCompression { Stored = 0, Zlib = 1, BZip2 = 2, Lzma1 = 3, Lzma2 = 4, Unknown = 5 } + + /// Per-file pre-compression transform (matches innoextract's enum). + public enum InnoFilter { None = 0, Instruction4108 = 1, Instruction5200 = 2, Instruction5309 = 3, Zlib = 4 } + + public enum InnoChecksumType { None = 0, Adler32 = 1, Crc32 = 2, Md5 = 3, Sha1 = 4, Sha256 = 5 } + + /// Metadata describing one file inside the installer. + public sealed class InnoFile + { + /// Lowercased basename, e.g. "root_fs2.vp". Convenient for matching. + public string Name { get; init; } = ""; + /// Full install destination path as stored, e.g. "{app}\\data\\root_fs2.vp". + public string Path { get; init; } = ""; + /// Final (post-filter) uncompressed size in bytes. + public ulong Size { get; init; } + + /// Whole-file checksum (over the concatenation of all parts' output). + internal InnoChecksumType ChecksumType; + internal byte[] Checksum = Array.Empty(); + + /// + /// One or more parts to extract and concatenate. Non-GOG files have exactly one + /// part; GOG Galaxy files are split into many deflated parts. + /// + internal List Parts = new(); + + public int PartCount => Parts.Count; + + public override string ToString() => $"{Name} ({Size} bytes, {Parts.Count} part(s))"; + } + + // One part = one chunk in the .exe (embedded) or a .bin slice (external). + internal sealed class InnoPart + { + public uint FirstSlice; + public uint LastSlice; + public ulong ChunkOffset; + public ulong ChunkSize; + public InnoCompression Compression; + public int Encryption; + public ulong FileOffset; // offset within the decompressed chunk + public ulong FileSize; // pre-filter size within the decompressed chunk + public InnoFilter Filter; + } + + public sealed class InnoArchive : IDisposable + { + // ------------------------------------------------------------------ // + // Native interop + // ------------------------------------------------------------------ // + private const string Lib = "innowrap"; + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate long ReadFn(IntPtr user, ulong offset, IntPtr buf, long len); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate long InflateFn(IntPtr user, int op, long handle, int method, IntPtr inPtr, long inLen, IntPtr outPtr, long outCap); + + // Field order MUST match struct innowrap_file in innowrap.cpp exactly. + // Pointer-free + naturally aligned, so the layout is identical on 32- and 64-bit. + [StructLayout(LayoutKind.Sequential)] + private struct NativeFile + { + public ulong size; + public int part_count; + public int checksum_type; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)] + public byte[] checksum; + } + + // Field order MUST match struct innowrap_part in innowrap.cpp exactly. + [StructLayout(LayoutKind.Sequential)] + private struct NativePart + { + public ulong chunk_offset; + public ulong chunk_size; + public ulong file_offset; + public ulong file_size; + public uint first_slice; + public uint last_slice; + public int compression; + public int encryption; + public int filter; + public int reserved; + } + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr innowrap_version(); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr innowrap_open(ReadFn readCb, InflateFn inflateCb, IntPtr user, ulong totalSize, out int err); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + private static extern int innowrap_file_count(IntPtr ctx); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + private static extern ulong innowrap_data_offset(IntPtr ctx); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + private static extern uint innowrap_slices_per_disk(IntPtr ctx); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr innowrap_base_filename(IntPtr ctx); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + private static extern int innowrap_get_file(IntPtr ctx, int index, out NativeFile outFile); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + private static extern int innowrap_get_part(IntPtr ctx, int fileIndex, int partIndex, out NativePart outPart); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr innowrap_file_name(IntPtr ctx, int index); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr innowrap_file_path(IntPtr ctx, int index); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl)] + private static extern void innowrap_close(IntPtr ctx); + + // ------------------------------------------------------------------ // + // State + // ------------------------------------------------------------------ // + private readonly Stream _stream; + private readonly bool _leaveOpen; + private readonly object _io = new(); + private IntPtr _ctx; + private ulong _dataOffset; + + // External-slice (.bin) support. Slices are opened through a SliceOpener so the + // caller controls HOW (File.OpenRead on desktop, Avalonia IStorageFile on mobile). + private SliceOpener? _sliceOpener; + private string _baseName = ""; // installer stem, for .bin naming + private uint _slicesPerDisk = 1; + private string _baseFilename = ""; // header fallback base name + private SliceSource? _slices; + + // Keep delegates rooted so the GC doesn't collect them while native code holds pointers. + private readonly ReadFn _readCb; + private readonly InflateFn _inflateCb; + + // Reusable scratch for the read callback + live header decoders for the inflate callback. + private byte[] _readScratch = new byte[1 << 16]; + private readonly Dictionary _inflaters = new(); + private long _nextInflateHandle; + + private List _files = new(); + public IReadOnlyList Files => _files; + public ulong DataOffset => _dataOffset; + /// True when file data lives in external .bin slices rather than inside the .exe. + public bool IsExternal => _dataOffset == 0; + + public static string Version => Marshal.PtrToStringUTF8(innowrap_version()) ?? ""; + + /// + /// Opens a slice (.bin) by index and expected filename. Return a readable stream + /// (seekable preferred; a forward-only stream works with ), + /// or null if not available. + /// + public delegate Stream? SliceOpener(int sliceIndex, string expectedFileName); + + /// + /// Open an installer by path (desktop). Locates sibling .bin slices with File I/O. + /// + public InnoArchive(string installerPath) + : this(OpenSeekable(File.OpenRead(installerPath)), false, + Path.GetFileNameWithoutExtension(installerPath), + DefaultFileOpener(Path.GetDirectoryName(Path.GetFullPath(installerPath)) ?? ".")) + { } + + /// + /// Open an installer from streams (mobile / sandboxed). The .exe is read via + /// ; external .bin slices are obtained through + /// . is the .exe file + /// name (e.g. "setup_freespace_2_..._(33372).exe"), used to derive .bin names. + /// The .exe stream need not be seekable (it is small and buffered if necessary). + /// + public InnoArchive(Stream exeStream, string installerName, SliceOpener openSlice, bool leaveOpen = false) + : this(OpenSeekable(exeStream), leaveOpen, + Path.GetFileNameWithoutExtension(installerName), openSlice) + { } + + /// Embedded single-.exe installers only (no external .bin access). + public InnoArchive(Stream stream, bool leaveOpen = false) + : this(OpenSeekable(stream), leaveOpen, "", null) { } + + private InnoArchive(Stream seekableExe, bool leaveOpen, string baseName, SliceOpener? openSlice) + { + _stream = seekableExe; + _leaveOpen = leaveOpen; + _baseName = baseName; + _sliceOpener = openSlice; + _readCb = ReadCallback; + _inflateCb = InflateCallback; + + ulong total = (ulong)_stream.Length; + _ctx = innowrap_open(_readCb, _inflateCb, IntPtr.Zero, total, out int err); + if (_ctx == IntPtr.Zero || err != 0) + throw new InnoExtractException("innowrap_open failed (not an Inno Setup installer, or parse error)."); + + _dataOffset = innowrap_data_offset(_ctx); + _slicesPerDisk = Math.Max(1u, innowrap_slices_per_disk(_ctx)); + _baseFilename = Marshal.PtrToStringUTF8(innowrap_base_filename(_ctx)) ?? ""; + LoadFileList(); + } + + // The .exe is small; if the provided stream can't seek (e.g. an Android content + // stream), buffer it into a MemoryStream so the parser can seek freely. + private static Stream OpenSeekable(Stream s) + { + if (s == null) throw new ArgumentNullException(nameof(s)); + if (!s.CanRead) throw new ArgumentException("Stream must be readable."); + if (s.CanSeek) return s; + var ms = new MemoryStream(); + s.CopyTo(ms); + s.Dispose(); + ms.Position = 0; + return ms; + } + + // Desktop default: open .bin siblings from a directory by filename. + private static SliceOpener DefaultFileOpener(string dir) => + (idx, name) => + { + string p = Path.Combine(dir, name); + if (File.Exists(p)) return File.Open(p, FileMode.Open, FileAccess.Read, FileShare.Read); + if (Directory.Exists(dir)) + foreach (var f in Directory.EnumerateFiles(dir)) + if (string.Equals(Path.GetFileName(f), name, StringComparison.OrdinalIgnoreCase)) + return File.Open(f, FileMode.Open, FileAccess.Read, FileShare.Read); + return null; + }; + + private void LoadFileList() + { + int n = innowrap_file_count(_ctx); + var list = new List(n); + for (int i = 0; i < n; i++) + { + if (innowrap_get_file(_ctx, i, out NativeFile nf) != 0) continue; + + var parts = new List(nf.part_count); + for (int p = 0; p < nf.part_count; p++) + { + if (innowrap_get_part(_ctx, i, p, out NativePart np) != 0) continue; + parts.Add(new InnoPart + { + FirstSlice = np.first_slice, + LastSlice = np.last_slice, + ChunkOffset = np.chunk_offset, + ChunkSize = np.chunk_size, + Compression = (InnoCompression)np.compression, + Encryption = np.encryption, + FileOffset = np.file_offset, + FileSize = np.file_size, + Filter = (InnoFilter)np.filter, + }); + } + + list.Add(new InnoFile + { + Name = Marshal.PtrToStringUTF8(innowrap_file_name(_ctx, i)) ?? "", + Path = Marshal.PtrToStringUTF8(innowrap_file_path(_ctx, i)) ?? "", + Size = nf.size, + ChecksumType = (InnoChecksumType)nf.checksum_type, + Checksum = nf.checksum ?? Array.Empty(), + Parts = parts, + }); + } + _files = list; + } + + /// Find a file by lowercased basename (e.g. "root_fs2.vp"). Null if absent. + public InnoFile? FindFile(string nameLower) + { + foreach (var f in _files) + if (string.Equals(f.Name, nameLower, StringComparison.OrdinalIgnoreCase)) return f; + return null; + } + + // ------------------------------------------------------------------ // + // Native callbacks + // ------------------------------------------------------------------ // + private long ReadCallback(IntPtr user, ulong offset, IntPtr buf, long len) + { + try + { + if (len <= 0) return 0; + lock (_io) + { + if (_readScratch.Length < len) _readScratch = new byte[len]; + _stream.Seek((long)offset, SeekOrigin.Begin); + int total = 0; + while (total < len) + { + int got = _stream.Read(_readScratch, total, (int)(len - total)); + if (got <= 0) break; + total += got; + } + Marshal.Copy(_readScratch, 0, buf, total); + return total; + } + } + catch { return -1; } + } + + // Streaming header-block decompression. + // op 0 OPEN : method 1 = zlib, 2 = inno LZMA1; `in` is the full block. Returns a handle. + // op 1 READ : produce up to outCap bytes into outPtr from the decoder `handle`. + // op 2 CLOSE : dispose decoder `handle`. + // Streaming (rather than decode-all) is required because inno LZMA1 streams carry no + // end-of-stream marker; we decode only what the parser pulls, exactly like innoextract. + private long InflateCallback(IntPtr user, int op, long handle, int method, IntPtr inPtr, long inLen, IntPtr outPtr, long outCap) + { + try + { + switch (op) + { + case 0: // OPEN + { + var input = new byte[inLen]; + if (inLen > 0) Marshal.Copy(inPtr, input, 0, (int)inLen); + Stream dec = method switch + { + 1 => new ZlibStream(new MemoryStream(input), CompressionMode.Decompress), + 2 => OpenInnoLzma1(input), + _ => throw new InnoExtractException($"unexpected header compression method {method}") + }; + long h = _nextInflateHandle++; + _inflaters[h] = dec; + return h; + } + + case 1: // READ + { + if (!_inflaters.TryGetValue(handle, out var dec)) return -1; + if (outCap <= 0) return 0; + if (_readScratch.Length < outCap) _readScratch = new byte[outCap]; + int got; + try { got = dec.Read(_readScratch, 0, (int)outCap); } + catch { got = 0; } // tolerate a decode-end overrun: report EOF + if (got <= 0) return 0; + Marshal.Copy(_readScratch, 0, outPtr, got); + return got; + } + + case 2: // CLOSE + { + if (_inflaters.TryGetValue(handle, out var dec)) + { + _inflaters.Remove(handle); + dec.Dispose(); + } + return 0; + } + + default: + return -1; + } + } + catch { return -1; } + } + + // Header-block LZMA1: 5-byte properties header + raw LZMA1, decoded on demand + // (outputSize = -1; we never force it to EOF, so the missing end marker is harmless). + private static Stream OpenInnoLzma1(byte[] input) + { + if (input.Length < 5) throw new InnoExtractException("inno LZMA1 header too short."); + var props = new byte[5]; + Array.Copy(input, 0, props, 0, 5); + var ms = new MemoryStream(input, 5, input.Length - 5); + return InnoSharpCompat.Lzma1(props, ms, input.Length - 5, -1); + } + + // ------------------------------------------------------------------ // + // Extraction + // ------------------------------------------------------------------ // + private static readonly byte[] ChunkMagic = { 0x7a, 0x6c, 0x62, 0x1a }; // "zlb\x1a" + + /// + /// Decompress one file out of the installer and write it to . + /// For GOG Galaxy installers the file is reassembled from many deflated parts. + /// All decompression happens here in managed code via SharpCompress. + /// + public void ExtractTo(InnoFile file, Stream output) + { + if (file == null) throw new ArgumentNullException(nameof(file)); + if (file.Parts.Count == 0) + throw new InnoExtractException($"{file.Name} has no data parts."); + + var hasher = new RunningChecksum(file.ChecksumType); + + foreach (InnoPart part in file.Parts) + { + if (part.Encryption != 0) + throw new InnoExtractException("Encrypted installers are not supported by this build."); + ExtractPart(part, output, hasher); + } + + // Verify the whole-file checksum over the concatenation of all parts. + if (file.ChecksumType != InnoChecksumType.None) + { + byte[] actual = hasher.Final(); + byte[] expected = file.Checksum; + for (int i = 0; i < actual.Length; i++) + if (actual[i] != expected[i]) + throw new InnoExtractException($"Checksum mismatch for {file.Name} ({file.ChecksumType})."); + } + } + + /// + /// Extract several files in a SINGLE forward pass over each .bin. This is the + /// recommended path on sandboxed platforms (e.g. Android) where the .bin stream may + /// not be seekable: each slice is read once start-to-finish, so a forward-only + /// stream works and no temporary copy of the 1.4 GB .bin is needed. The output + /// stream for each file is obtained from and is written + /// (and its whole-file checksum verified) as its parts are encountered. + /// + public void ExtractFiles(IReadOnlyCollection files, Func outputFor, + Action? onFileExtracted = null) + { + if (files == null) throw new ArgumentNullException(nameof(files)); + + // Embedded (.exe) data is seekable (buffered if needed): per-file is fine. + if (!IsExternal) + { + foreach (var f in files) + { + using (var outp = outputFor(f)) + ExtractTo(f, outp); + onFileExtracted?.Invoke(f); + } + return; + } + + if (_sliceOpener == null) + throw new InnoExtractException("External installer requires a SliceOpener (or path constructor)."); + + // Global work list, sorted for a strict forward pass over the .bin(s). + var work = new List<(InnoFile file, InnoPart part)>(); + foreach (var f in files) + foreach (var p in f.Parts) + { + if (p.Encryption != 0) throw new InnoExtractException("Encrypted installers are not supported."); + if (p.FirstSlice != p.LastSlice) + throw new InnoExtractException( + "A part spans multiple .bin slices; use the seekable path for this installer."); + work.Add((f, p)); + } + work.Sort((a, b) => + { + int c = a.part.FirstSlice.CompareTo(b.part.FirstSlice); + return c != 0 ? c : a.part.ChunkOffset.CompareTo(b.part.ChunkOffset); + }); + + // Per-file streaming state. + var outStream = new Dictionary(); + var hashers = new Dictionary(); + var remaining = new Dictionary(); + foreach (var f in files) + { + outStream[f] = outputFor(f); + hashers[f] = new RunningChecksum(f.ChecksumType); + remaining[f] = f.Parts.Count; + } + + try + { + using var reader = new ForwardBinReader(_sliceOpener, _baseName, _baseFilename, _slicesPerDisk); + foreach (var (f, part) in work) + { + long span = checked((long)part.ChunkSize + 4); + using (Stream w = reader.Window((int)part.FirstSlice, (long)part.ChunkOffset, span)) + DecodePart(w, part, outStream[f], hashers[f]); + + if (--remaining[f] == 0) + { + if (f.ChecksumType != InnoChecksumType.None) + { + byte[] actual = hashers[f].Final(); + byte[] expected = f.Checksum; + for (int i = 0; i < actual.Length; i++) + if (actual[i] != expected[i]) + throw new InnoExtractException($"Checksum mismatch for {f.Name} ({f.ChecksumType})."); + } + onFileExtracted?.Invoke(f); + } + } + } + finally + { + foreach (var s in outStream.Values) { try { s.Dispose(); } catch { } } + } + } + + // Extract a single part via random access (seekable sources). + private void ExtractPart(InnoPart part, Stream output, RunningChecksum hasher) + { + long span = checked((long)part.ChunkSize + 4); // 4-byte magic + payload + Stream chunkStream = OpenChunkStream(part, span); + try { DecodePart(chunkStream, part, output, hasher); } + finally { chunkStream.Dispose(); } + } + + // Decode one part from a stream positioned at the chunk magic (spanning 4 + ChunkSize + // bytes): verify magic, decompress the chunk, skip to FileOffset, read FileSize bytes, + // apply the part filter, stream to `output` while feeding the running checksum. + private void DecodePart(Stream chunkStream, InnoPart part, Stream output, RunningChecksum hasher) + { + Span magic = stackalloc byte[4]; + ReadExact(chunkStream, magic); + if (!magic.SequenceEqual(ChunkMagic)) + throw new InnoExtractException("Chunk magic mismatch (\"zlb\\x1a\" expected)."); + + long needed = checked((long)(part.FileOffset + part.FileSize)); + Stream decompressed = part.Compression switch + { + InnoCompression.Stored => chunkStream, + InnoCompression.Zlib => new ZlibStream(chunkStream, CompressionMode.Decompress), + InnoCompression.BZip2 => InnoSharpCompat.BZip2(chunkStream), + InnoCompression.Lzma1 => MakeInnoLzma1Stream(chunkStream, needed), + InnoCompression.Lzma2 => MakeInnoLzma2Stream(chunkStream, needed), + _ => throw new InnoExtractException($"Unsupported chunk compression {part.Compression}.") + }; + try + { + SkipExact(decompressed, (long)part.FileOffset); + using var payload = new BoundedStream(decompressed, (long)part.FileSize); + + switch (part.Filter) + { + case InnoFilter.None: + PumpToOutput(payload, output, hasher); + break; + case InnoFilter.Zlib: + // GOG Galaxy parts are deflated; inflate then write. The whole-file + // checksum is over the INFLATED bytes. + using (var zs = new ZlibStream(payload, CompressionMode.Decompress)) + PumpToOutput(zs, output, hasher); + break; + case InnoFilter.Instruction4108: + WriteFiltered(payload, output, hasher, b => InnoExeFilters.Decode4108(b)); + break; + case InnoFilter.Instruction5200: + WriteFiltered(payload, output, hasher, b => InnoExeFilters.Decode5200(b, false)); + break; + case InnoFilter.Instruction5309: + WriteFiltered(payload, output, hasher, b => InnoExeFilters.Decode5200(b, true)); + break; + default: + throw new InnoExtractException($"Unsupported file filter {part.Filter}."); + } + } + finally + { + if (!ReferenceEquals(decompressed, chunkStream)) decompressed.Dispose(); + } + } + + // Copy a stream to output while updating the running checksum (no full buffering). + private static void PumpToOutput(Stream src, Stream output, RunningChecksum hasher) + { + var buf = new byte[1 << 16]; + int got; + while ((got = src.Read(buf, 0, buf.Length)) > 0) + { + hasher.Update(buf.AsSpan(0, got)); + output.Write(buf, 0, got); + } + } + + // Instruction (exe) filters need the whole part in memory; these parts are small. + private static void WriteFiltered(Stream payload, Stream output, RunningChecksum hasher, + Func decode) + { + byte[] raw = ReadAll(payload); + byte[] decoded = decode(raw); + hasher.Update(decoded); + output.Write(decoded, 0, decoded.Length); + } + + private SliceSource EnsureSlices() + { + if (_sliceOpener == null) + throw new InnoExtractException( + "This installer stores its data in external .bin slices. Open it with the " + + "path constructor, or the (Stream, name, SliceOpener) constructor on mobile."); + return _slices ??= new SliceSource(_sliceOpener, _baseName, _baseFilename, _slicesPerDisk); + } + + // Random-access chunk stream at the magic, bounded to `span` bytes. + private Stream OpenChunkStream(InnoPart part, long span) + { + if (!IsExternal) + { + long start = checked((long)(_dataOffset + part.ChunkOffset)); + // SharpCompress's LZMA decoder pulls compressed input one byte at a time. + // Buffer the bounded window so Android content streams do not perform a + // managed-to-Java read + seek round trip for every compressed byte. + return new BufferedStream(new WindowStream(_stream, start, span, _io), 1 << 16); + } + return new SliceStream(EnsureSlices(), part.FirstSlice, (long)part.ChunkOffset, span); + } + + // ------------------------------------------------------------------ // + // SharpCompress helpers + // ------------------------------------------------------------------ // + // Chunk LZMA1: identical 5-byte header, but we know exactly how many output + // bytes we need (outputSize), so the decoder stops cleanly. + private static Stream MakeInnoLzma1Stream(Stream input, long outputSize) + { + var props = new byte[5]; + ReadExact(input, props); + return InnoSharpCompat.Lzma1(props, input, -1, outputSize); + } + + // Chunk LZMA2: single dict-size property byte, then raw LZMA2. + private static Stream MakeInnoLzma2Stream(Stream input, long outputSize) + { + int b = input.ReadByte(); + if (b < 0) throw new InnoExtractException("inno LZMA2 header missing."); + var props = new byte[] { (byte)b }; + return InnoSharpCompat.Lzma2(props, input, -1, outputSize); + } + + // ------------------------------------------------------------------ // + // Checksums (incremental, so multi-hundred-MB files aren't buffered) + // ------------------------------------------------------------------ // + private sealed class RunningChecksum + { + private readonly InnoChecksumType _type; + private readonly IncrementalHash? _ih; + private uint _crc = 0xFFFFFFFF; + private uint _adlerA = 1, _adlerB = 0; + + public RunningChecksum(InnoChecksumType type) + { + _type = type; + _ih = type switch + { + InnoChecksumType.Md5 => IncrementalHash.CreateHash(HashAlgorithmName.MD5), + InnoChecksumType.Sha1 => IncrementalHash.CreateHash(HashAlgorithmName.SHA1), + InnoChecksumType.Sha256 => IncrementalHash.CreateHash(HashAlgorithmName.SHA256), + _ => null + }; + } + + public void Update(ReadOnlySpan data) + { + switch (_type) + { + case InnoChecksumType.Md5: + case InnoChecksumType.Sha1: + case InnoChecksumType.Sha256: + _ih!.AppendData(data); + break; + case InnoChecksumType.Crc32: + foreach (byte by in data) _crc = CrcTable[(_crc ^ by) & 0xFF] ^ (_crc >> 8); + break; + case InnoChecksumType.Adler32: + const uint MOD = 65521; + foreach (byte by in data) { _adlerA = (_adlerA + by) % MOD; _adlerB = (_adlerB + _adlerA) % MOD; } + break; + } + } + + public byte[] Final() => _type switch + { + InnoChecksumType.Md5 or InnoChecksumType.Sha1 or InnoChecksumType.Sha256 => _ih!.GetHashAndReset(), + InnoChecksumType.Crc32 => Le32(_crc ^ 0xFFFFFFFF), + InnoChecksumType.Adler32 => Le32((_adlerB << 16) | _adlerA), + _ => Array.Empty() + }; + } + + private static byte[] Le32(uint v) => new[] { (byte)v, (byte)(v >> 8), (byte)(v >> 16), (byte)(v >> 24) }; + + private static readonly uint[] CrcTable = BuildCrcTable(); + private static uint[] BuildCrcTable() + { + var t = new uint[256]; + for (uint i = 0; i < 256; i++) + { + uint c = i; + for (int k = 0; k < 8; k++) c = (c & 1) != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1; + t[i] = c; + } + return t; + } + + // ------------------------------------------------------------------ // + // Stream utilities + // ------------------------------------------------------------------ // + private static void ReadExact(Stream s, Span buf) + { + int off = 0; + while (off < buf.Length) + { + int got = s.Read(buf.Slice(off)); + if (got <= 0) throw new EndOfStreamException(); + off += got; + } + } + + private static void SkipExact(Stream s, long count) + { + if (count <= 0) return; + var buf = new byte[Math.Min(count, 1 << 16)]; + long left = count; + while (left > 0) + { + int want = (int)Math.Min(left, buf.Length); + int got = s.Read(buf, 0, want); + if (got <= 0) throw new EndOfStreamException(); + left -= got; + } + } + + private static byte[] ReadCount(Stream s, long count) + { + var outBuf = new byte[count]; + long off = 0; + while (off < count) + { + int got = s.Read(outBuf, (int)off, (int)Math.Min(count - off, int.MaxValue)); + if (got <= 0) throw new EndOfStreamException(); + off += got; + } + return outBuf; + } + + private static byte[] ReadAll(Stream s) + { + using var ms = new MemoryStream(); + var buf = new byte[1 << 16]; + int got; + while ((got = s.Read(buf, 0, buf.Length)) > 0) ms.Write(buf, 0, got); + return ms.ToArray(); + } + + public void Dispose() + { + if (_ctx != IntPtr.Zero) { innowrap_close(_ctx); _ctx = IntPtr.Zero; } + foreach (var d in _inflaters.Values) { try { d.Dispose(); } catch { } } + _inflaters.Clear(); + _slices?.Dispose(); + if (!_leaveOpen) _stream.Dispose(); + GC.SuppressFinalize(this); + } + + ~InnoArchive() { if (_ctx != IntPtr.Zero) innowrap_close(_ctx); } + + // ------------------------------------------------------------------ // + // External slice (.bin) reading + // ------------------------------------------------------------------ // + // Opens and caches .bin slices through a SliceOpener. Each slice is + // [8-byte magic "idska16/32\x1a"][4-byte LE size][data...]; `size` is the whole file. + private sealed class SliceSource : IDisposable + { + private static readonly byte[][] Magics = + { + new byte[] { (byte)'i',(byte)'d',(byte)'s',(byte)'k',(byte)'a',(byte)'1',(byte)'6',0x1a }, + new byte[] { (byte)'i',(byte)'d',(byte)'s',(byte)'k',(byte)'a',(byte)'3',(byte)'2',0x1a }, + }; + public const int HeaderSize = 12; // 8 magic + 4 size + + private readonly SliceOpener _open; + private readonly string _base, _base2; + private readonly uint _slicesPerDisk; + private readonly Dictionary _cache = new(); + + public SliceSource(SliceOpener opener, string baseName, string baseName2, uint slicesPerDisk) + { _open = opener; _base = baseName; _base2 = baseName2 ?? ""; _slicesPerDisk = Math.Max(1u, slicesPerDisk); } + + // {base}-{slice+1}.bin, or {base}-{major}{letter}.bin when slicesPerDisk > 1. + public static string SliceFileName(string baseName, long slice, uint slicesPerDisk) + { + if (slicesPerDisk == 1) return $"{baseName}-{slice + 1}.bin"; + long major = slice / slicesPerDisk + 1; + char minor = (char)('a' + (int)(slice % slicesPerDisk)); + return $"{baseName}-{major}{minor}.bin"; + } + + // A seekable stream over slice `slice`, plus its declared size. Cached. + public (Stream s, long size) Get(long slice) + { + if (_cache.TryGetValue(slice, out var e)) return e; + + Stream s = OpenAnyName((int)slice) ?? throw new InnoExtractException( + $"Could not open slice {slice}: {SliceFileName(_base, slice, _slicesPerDisk)}" + + (string.IsNullOrEmpty(_base2) ? "" : $" or {SliceFileName(_base2, slice, _slicesPerDisk)}")); + + if (!s.CanSeek) + { + s.Dispose(); + throw new InnoExtractException( + "The .bin slice stream is not seekable. On sandboxed platforms (e.g. Android) " + + "use ExtractFiles(...) which does a single forward pass, or provide a seekable stream."); + } + + var hdr = new byte[HeaderSize]; + s.Position = 0; + int off = 0; while (off < HeaderSize) { int g = s.Read(hdr, off, HeaderSize - off); if (g <= 0) break; off += g; } + if (off < HeaderSize || !MagicOk(hdr)) + throw new InnoExtractException("Bad slice magic (not a valid .bin)."); + long size = (uint)(hdr[8] | (hdr[9] << 8) | (hdr[10] << 16) | (hdr[11] << 24)); + if (size < HeaderSize) throw new InnoExtractException("Bad slice size."); + e = (s, size); + _cache[slice] = e; + return e; + } + + private Stream? OpenAnyName(int slice) + { + foreach (var bn in new[] { _base, _base2 }) + { + if (string.IsNullOrEmpty(bn)) continue; + string name = SliceFileName(bn, slice, _slicesPerDisk); + Stream? s = _open(slice, name); + if (s != null) return s; + } + return null; + } + + private static bool MagicOk(byte[] hdr) + { + foreach (var m in Magics) + { + bool ok = true; + for (int i = 0; i < 8; i++) if (hdr[i] != m[i]) { ok = false; break; } + if (ok) return true; + } + return false; + } + + public void Dispose() + { + foreach (var e in _cache.Values) { try { e.s.Dispose(); } catch { } } + _cache.Clear(); + } + } + + // Forward-only reader over a chunk that starts at an absolute offset in slice `firstSlice` + // and may continue into subsequent slices (resuming after each slice's 12-byte header). + private sealed class SliceStream : Stream + { + private readonly SliceSource _src; + private long _slice; + private long _filePos; // absolute position within the current .bin + private long _sliceSize; // declared size of the current .bin + private Stream _fs; + private long _left; // bytes still to serve for this chunk + + public SliceStream(SliceSource src, long firstSlice, long chunkOffset, long length) + { + _src = src; _slice = firstSlice; _left = length; + var (s, size) = _src.Get(_slice); + _fs = s; _sliceSize = size; _filePos = chunkOffset; + } + + public override int Read(byte[] buffer, int offset, int count) + { + if (_left <= 0 || count <= 0) return 0; + + long avail = _sliceSize - _filePos; + if (avail <= 0) + { + // Continue in the next slice, right after its 12-byte header. + _slice++; + var (s, size) = _src.Get(_slice); + _fs = s; _sliceSize = size; _filePos = SliceSource.HeaderSize; + avail = _sliceSize - _filePos; + if (avail <= 0) return 0; + } + + int want = (int)Math.Min(Math.Min((long)count, avail), _left); + _fs.Seek(_filePos, SeekOrigin.Begin); + int got = _fs.Read(buffer, offset, want); + if (got <= 0) return 0; + _filePos += got; _left -= got; + return got; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() { } + public override long Seek(long o, SeekOrigin r) => throw new NotSupportedException(); + public override void SetLength(long v) => throw new NotSupportedException(); + public override void Write(byte[] b, int o, int c) => throw new NotSupportedException(); + // The underlying FileStreams are owned by SliceSource; don't dispose them here. + } + + // Reads .bin slices strictly forward (no seeking), for ExtractFiles. Opens one + // stream per slice via the SliceOpener; skips forward to each chunk. Tolerates + // forward-only streams (e.g. Android content streams). + private sealed class ForwardBinReader : IDisposable + { + private readonly SliceOpener _open; + private readonly string _base, _base2; + private readonly uint _spd; + private int _slice = -1; + private Stream? _s; + private long _pos; // absolute position within the current .bin + + public ForwardBinReader(SliceOpener open, string b, string b2, uint spd) + { _open = open; _base = b; _base2 = b2 ?? ""; _spd = spd; } + + private void Ensure(int slice) + { + if (slice == _slice && _s != null) return; + _s?.Dispose(); _s = null; + foreach (var bn in new[] { _base, _base2 }) + { + if (string.IsNullOrEmpty(bn)) continue; + _s = _open(slice, SliceSource.SliceFileName(bn, slice, _spd)); + if (_s != null) break; + } + if (_s == null) throw new InnoExtractException($"Could not open slice {slice}."); + + // Consume+validate the 12-byte slice header. + var hdr = new byte[SliceSource.HeaderSize]; + int o = 0; while (o < hdr.Length) { int g = _s.Read(hdr, o, hdr.Length - o); if (g <= 0) break; o += g; } + if (o < hdr.Length || !(hdr[0] == (byte)'i' && hdr[1] == (byte)'d' && hdr[2] == (byte)'s' && hdr[3] == (byte)'k' && hdr[4] == (byte)'a')) + throw new InnoExtractException("Bad slice magic (not a valid .bin)."); + _slice = slice; _pos = SliceSource.HeaderSize; + } + + // A bounded forward view of `len` bytes starting at absolute `absOffset` in `slice`. + public Stream Window(int slice, long absOffset, long len) + { + Ensure(slice); + if (absOffset < _pos) + throw new InnoExtractException( + "Non-forward .bin access. Extract files in a single ExtractFiles() call " + + "(offset order is handled internally), or use a seekable stream."); + SkipForward(absOffset - _pos); + return new FwdWindow(this, len); + } + + private void SkipForward(long n) + { + if (n <= 0) return; + var buf = new byte[(int)Math.Min(n, 1 << 16)]; + while (n > 0) + { + int want = (int)Math.Min(n, buf.Length); + int g = _s!.Read(buf, 0, want); + if (g <= 0) throw new EndOfStreamException("Unexpected end of .bin while skipping."); + n -= g; _pos += g; + } + } + + private int ReadInner(byte[] b, int o, int c) + { + int g = _s!.Read(b, o, c); + if (g > 0) _pos += g; + return g; + } + + public void Dispose() { _s?.Dispose(); _s = null; } + + // Forward window; on Dispose it drains any bytes the decoder didn't consume so + // the parent position lands exactly at the end of this chunk span. + private sealed class FwdWindow : Stream + { + private readonly ForwardBinReader _r; + private long _left; + public FwdWindow(ForwardBinReader r, long len) { _r = r; _left = len; } + public override int Read(byte[] buffer, int offset, int count) + { + if (_left <= 0 || count <= 0) return 0; + int want = (int)Math.Min(count, _left); + int got = _r.ReadInner(buffer, offset, want); + if (got > 0) _left -= got; + return got; + } + protected override void Dispose(bool disposing) + { + if (disposing && _left > 0) { _r.SkipForward(_left); _left = 0; } + base.Dispose(disposing); + } + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() { } + public override long Seek(long o, SeekOrigin r) => throw new NotSupportedException(); + public override void SetLength(long v) => throw new NotSupportedException(); + public override void Write(byte[] b, int o, int c) => throw new NotSupportedException(); + } + } + + // Test seam: build a cross-slice chunk reader directly (used by unit tests). + internal static Stream OpenSliceStreamForTest(SliceOpener opener, string baseName, string baseName2, + uint slicesPerDisk, long firstSlice, long chunkOffset, long length) + => new SliceStream(new SliceSource(opener, baseName, baseName2, slicesPerDisk), firstSlice, chunkOffset, length); + + internal static string SliceFileNameForTest(string baseName, long slice, uint slicesPerDisk) + => SliceSource.SliceFileName(baseName, slice, slicesPerDisk); + + // Test seam: an embedded (in-.exe) archive over `data`, bypassing native open. + private InnoArchive(Stream data, ulong dataOffset) + { + _stream = data; _leaveOpen = true; + _readCb = ReadCallback; _inflateCb = InflateCallback; + _dataOffset = dataOffset; _files = new List(); + } + internal static InnoArchive ForEmbeddedTest(Stream data, ulong dataOffset) => new InnoArchive(data, dataOffset); + internal void ExtractForTest(InnoFile f, Stream output) => ExtractTo(f, output); + + // Test seam: an external archive over a SliceOpener, bypassing native open (for ExtractFiles). + private InnoArchive(SliceOpener opener, string baseName, uint slicesPerDisk) + { + _stream = new MemoryStream(); _leaveOpen = true; + _readCb = ReadCallback; _inflateCb = InflateCallback; + _dataOffset = 0; _sliceOpener = opener; _baseName = baseName; + _slicesPerDisk = slicesPerDisk; _files = new List(); + } + internal static InnoArchive ForExternalTest(SliceOpener opener, string baseName, uint slicesPerDisk) + => new InnoArchive(opener, baseName, slicesPerDisk); + internal void ExtractFilesForTest(IReadOnlyCollection files, Func outputFor) + => ExtractFiles(files, outputFor); + internal static InnoFile MakeFileForTest(ulong size, InnoChecksumType ct, byte[] cksum, List parts) + => new InnoFile { Size = size, ChecksumType = ct, Checksum = cksum, Parts = parts }; + internal static InnoPart MakePartForTest(ulong chunkOffset, ulong chunkSize, ulong fileSize, InnoCompression comp, InnoFilter filter, uint slice = 0) + => new InnoPart { ChunkOffset = chunkOffset, ChunkSize = chunkSize, FileOffset = 0, FileSize = fileSize, Compression = comp, Filter = filter, FirstSlice = slice, LastSlice = slice }; + + // Reads at most `limit` bytes from an underlying (forward-only) stream, then EOF. + // Does not dispose the underlying stream. + private sealed class BoundedStream : Stream + { + private readonly Stream _base; + private long _left; + public BoundedStream(Stream baseStream, long limit) { _base = baseStream; _left = limit; } + public override int Read(byte[] buffer, int offset, int count) + { + if (_left <= 0 || count <= 0) return 0; + int want = (int)Math.Min(count, _left); + int got = _base.Read(buffer, offset, want); + if (got > 0) _left -= got; + return got; + } + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() { } + public override long Seek(long o, SeekOrigin r) => throw new NotSupportedException(); + public override void SetLength(long v) => throw new NotSupportedException(); + public override void Write(byte[] b, int o, int c) => throw new NotSupportedException(); + } + + /// A read-only view over [start, start+length) of a shared, seekable base stream. + private sealed class WindowStream : Stream + { + private readonly Stream _base; + private readonly long _start, _length; + private readonly object _io; + private long _pos; + + public WindowStream(Stream baseStream, long start, long length, object io) + { _base = baseStream; _start = start; _length = length; _io = io; } + + public override int Read(byte[] buffer, int offset, int count) + { + if (_pos >= _length) return 0; + int want = (int)Math.Min(count, _length - _pos); + lock (_io) + { + _base.Seek(_start + _pos, SeekOrigin.Begin); + int got = _base.Read(buffer, offset, want); + _pos += got; + return got; + } + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => _length; + public override long Position { get => _pos; set => throw new NotSupportedException(); } + public override void Flush() { } + public override long Seek(long o, SeekOrigin r) => throw new NotSupportedException(); + public override void SetLength(long v) => throw new NotSupportedException(); + public override void Write(byte[] b, int o, int c) => throw new NotSupportedException(); + } + } +} diff --git a/Knossos.NET/Classes/Inno/InnoExeFilters.cs b/Knossos.NET/Classes/Inno/InnoExeFilters.cs new file mode 100644 index 00000000..1ffe53a7 --- /dev/null +++ b/Knossos.NET/Classes/Inno/InnoExeFilters.cs @@ -0,0 +1,106 @@ +// InnoExeFilters.cs +// +// Managed port of innoextract's stream/exefilter.hpp. Inno Setup rewrites the +// addresses of x86 CALL/JMP instructions in stored executables to make them +// more compressible; these decoders reverse that transform. They operate on a +// fully-buffered payload and always return a buffer of the SAME length as the +// input (the transform is byte-for-byte). +// +// .vp data files use no filter, so for FreeSpace 2 these are rarely exercised, +// but they are provided for completeness and correctness on any installer. + +using System; +using System.IO; + +namespace Knossos.NET.Classes.Inno +{ + internal static class InnoExeFilters + { + /// Decoder for executables stored by Inno Setup < 5.2.0. + public static byte[] Decode4108(byte[] input) + { + var output = new byte[input.Length]; + uint addr = 0; + int addrBytesLeft = 0; + uint addrOffset = 5; // matches the filter's initial state + + for (int i = 0; i < input.Length; i++, addrOffset++) + { + int b = input[i]; + if (addrBytesLeft == 0) + { + if (b == 0xe8 || b == 0xe9) + { + unchecked { addr = ~addrOffset + 1; } // == -addrOffset + addrBytesLeft = 4; + } + } + else + { + unchecked { addr += (byte)b; } + b = (byte)addr; + addr >>= 8; + addrBytesLeft--; + } + output[i] = (byte)b; + } + return output; + } + + /// + /// Decoder for executables stored by Inno Setup >= 5.2.0. + /// selects the 5.3.9+ variant. + /// + public static byte[] Decode5200(byte[] input, bool flipHighByte) + { + const int blockSize = 0x10000; + using var ms = new MemoryStream(input.Length); + uint offset = 0; // total bytes consumed from input + int i = 0; + int n = input.Length; + + while (i < n) + { + int b = input[i++]; + ms.WriteByte((byte)b); + offset++; + + if (b != 0xe8 && b != 0xe9) continue; + + int blockSizeLeft = (int)(blockSize - ((offset - 1) % blockSize)); + if (blockSizeLeft < 5) continue; // instruction spans a block boundary + + if (i + 4 > n) + { + // EOF before the full address: emit the remaining bytes unchanged. + while (i < n) ms.WriteByte(input[i++]); + break; + } + + byte b0 = input[i], b1 = input[i + 1], b2 = input[i + 2], b3 = input[i + 3]; + i += 4; + offset += 4; + + if (b3 == 0x00 || b3 == 0xff) + { + uint addr = offset & 0xffffff; + uint rel = (uint)(b0 | (b1 << 8) | (b2 << 16)); + unchecked { rel -= addr; } + byte n0 = (byte)rel; + byte n1 = (byte)(rel >> 8); + byte n2 = (byte)(rel >> 16); + byte n3 = b3; + if (flipHighByte && (rel & 0x800000) != 0) n3 = (byte)~b3; + ms.WriteByte(n0); ms.WriteByte(n1); ms.WriteByte(n2); ms.WriteByte(n3); + } + else + { + // Most likely not a real CALL/JMP: leave the operand untouched. + ms.WriteByte(b0); ms.WriteByte(b1); ms.WriteByte(b2); ms.WriteByte(b3); + } + } + + return ms.ToArray(); + } + } +} diff --git a/Knossos.NET/Classes/Inno/InnoSharpCompat.cs b/Knossos.NET/Classes/Inno/InnoSharpCompat.cs new file mode 100644 index 00000000..8cf5f01f --- /dev/null +++ b/Knossos.NET/Classes/Inno/InnoSharpCompat.cs @@ -0,0 +1,136 @@ +// InnoSharpCompat.cs +// +// SharpCompress changed its API: newer versions made the public LzmaStream / +// BZip2Stream constructors private and replaced them with static Create(...) +// factory methods. Older versions only have the constructors. +// +// This shim resolves, once, whichever form is available (factory preferred, +// constructor as fallback) so the wrapper compiles and runs against any +// SharpCompress version. ZlibStream kept its public constructor across versions, +// so it is used directly in InnoArchive and not handled here. + +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using SharpCompress.Compressors; // CompressionMode (stable public enum) + +namespace Knossos.NET.Classes.Inno +{ + internal static class InnoSharpCompat + { + private static readonly Func LzmaFactory = BuildLzma(isLzma2: false); + private static readonly Func Lzma2Factory = BuildLzma(isLzma2: true); + private static readonly Func BZip2Factory = BuildBZip2(); + + /// Raw LZMA1: 5-byte properties + data. outputSize may be -1 (unknown). + public static Stream Lzma1(byte[] props, Stream input, long inputSize, long outputSize) + => LzmaFactory(props, input, inputSize, outputSize); + + /// Raw LZMA2: single dict-size property byte + data. + public static Stream Lzma2(byte[] props, Stream input, long inputSize, long outputSize) + => Lzma2Factory(props, input, inputSize, outputSize); + + /// BZip2 decompressor (decompressConcatenated = false). + public static Stream BZip2(Stream input) => BZip2Factory(input); + + // ------------------------------------------------------------------ // + private static Type Resolve(string fullName) + => Type.GetType($"{fullName}, SharpCompress") + ?? AppDomain.CurrentDomain.GetAssemblies() + .Select(a => a.GetType(fullName)) + .FirstOrDefault(t => t != null) + ?? throw new InnoExtractException($"SharpCompress type not found: {fullName}"); + + // LzmaStream: prefer Create(byte[],Stream,long,long[,Stream,bool,...]), + // fall back to the matching public constructor. + private static Func BuildLzma(bool isLzma2) + { + Type t = Resolve("SharpCompress.Compressors.LZMA.LzmaStream"); + + if (!isLzma2) + { + // 4-arg form: (properties, inputStream, inputSize, outputSize[, optional...]) + var fourTypes = new[] { typeof(byte[]), typeof(Stream), typeof(long), typeof(long) }; + + var create = t.GetMethods(BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(m => m.Name == "Create" && ParamsStartWith(m, fourTypes)); + if (create != null) + { + var pars = create.GetParameters(); + return (p, s, isz, osz) => + { + var args = new object?[pars.Length]; + args[0] = p; args[1] = s; args[2] = isz; args[3] = osz; + for (int i = 4; i < pars.Length; i++) args[i] = pars[i].HasDefaultValue ? pars[i].DefaultValue : false; + return (Stream)create.Invoke(null, args)!; + }; + } + + var ctor = t.GetConstructor(fourTypes) + ?? throw new InnoExtractException("No usable LzmaStream (4-arg) API found in this SharpCompress version."); + return (p, s, isz, osz) => (Stream)ctor.Invoke(new object[] { p, s, isz, osz }); + } + else + { + // 6-arg form: (properties, inputStream, inputSize, outputSize, presetDictionary, isLzma2) + var sixTypes = new[] { typeof(byte[]), typeof(Stream), typeof(long), typeof(long), typeof(Stream), typeof(bool) }; + + // Newer Create has an extra optional leaveOpen; match by prefix. + var create = t.GetMethods(BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(m => m.Name == "Create" && ParamsStartWith(m, sixTypes)); + if (create != null) + { + var pars = create.GetParameters(); + return (p, s, isz, osz) => + { + var args = new object?[pars.Length]; + args[0] = p; args[1] = s; args[2] = isz; args[3] = osz; args[4] = null; args[5] = true; + for (int i = 6; i < pars.Length; i++) args[i] = pars[i].HasDefaultValue ? pars[i].DefaultValue : false; + return (Stream)create.Invoke(null, args)!; + }; + } + + var ctor = t.GetConstructor(sixTypes) + ?? throw new InnoExtractException("No usable LzmaStream (LZMA2) API found in this SharpCompress version."); + return (p, s, isz, osz) => (Stream)ctor.Invoke(new object?[] { p, s, isz, osz, null, true }); + } + } + + // BZip2Stream: prefer Create(Stream,CompressionMode,bool[,...]), else the ctor. + private static Func BuildBZip2() + { + Type t = Resolve("SharpCompress.Compressors.BZip2.BZip2Stream"); + object decompress = Enum.Parse(typeof(CompressionMode), "Decompress"); + var baseTypes = new[] { typeof(Stream), typeof(CompressionMode), typeof(bool) }; + + var create = t.GetMethods(BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(m => m.Name == "Create" && ParamsStartWith(m, baseTypes)); + if (create != null) + { + var pars = create.GetParameters(); + return s => + { + var args = new object?[pars.Length]; + args[0] = s; args[1] = decompress; args[2] = false; + for (int i = 3; i < pars.Length; i++) args[i] = pars[i].HasDefaultValue ? pars[i].DefaultValue : false; + return (Stream)create.Invoke(null, args)!; + }; + } + + var ctor = t.GetConstructor(baseTypes) + ?? throw new InnoExtractException("No usable BZip2Stream API found in this SharpCompress version."); + return s => (Stream)ctor.Invoke(new object?[] { s, decompress, false }); + } + + private static bool ParamsStartWith(MethodInfo m, Type[] types) + { + var p = m.GetParameters(); + if (p.Length < types.Length) return false; + for (int i = 0; i < types.Length; i++) if (p[i].ParameterType != types[i]) return false; + // remaining parameters (if any) must be optional + for (int i = types.Length; i < p.Length; i++) if (!p[i].HasDefaultValue) return false; + return true; + } + } +} diff --git a/Knossos.NET/ViewModels/Windows/Fs2InstallerViewModel.cs b/Knossos.NET/ViewModels/Windows/Fs2InstallerViewModel.cs index 2e019d6d..224fa46e 100644 --- a/Knossos.NET/ViewModels/Windows/Fs2InstallerViewModel.cs +++ b/Knossos.NET/ViewModels/Windows/Fs2InstallerViewModel.cs @@ -1,17 +1,19 @@ -using Avalonia.Platform; +using Avalonia.Controls; +using Avalonia.Platform; +using Avalonia.Platform.Storage; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; +using Knossos.NET.Classes.Inno; using Knossos.NET.Models; using Knossos.NET.Views; +using SharpCompress.Archives; using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Linq; using System.Text; using System.Threading.Tasks; -using System.IO; -using Avalonia.Platform.Storage; -using Avalonia.Controls; namespace Knossos.NET.ViewModels { @@ -52,26 +54,21 @@ public partial class Fs2InstallerViewModel : ViewModelBase internal int progressCurrent = 0; [ObservableProperty] internal string installText = string.Empty; - [ObservableProperty] - internal bool innoExtractIsAvailable = false; private string? gogExe = null; private KnossosWindow? window; private int reqFilesFound = 0; + // GOG installer sources. Desktop: a real path. Sandboxed (Android/iOS): storage + // handles for the .exe and its .bin slice(s), accessed as streams (no file paths). + private IStorageFile? gogExeFile = null; + private readonly Dictionary gogBins = + new Dictionary(StringComparer.OrdinalIgnoreCase); public Fs2InstallerViewModel() { - if(KnUtils.IsWindows || KnUtils.IsMacOS || KnUtils.IsLinux && ( KnUtils.CpuArch == "X64" || KnUtils.CpuArch == "X86" || KnUtils.CpuArch == "Arm64" || KnUtils.CpuArch == "RiscV64")) - { - InnoExtractIsAvailable = true; - } } public Fs2InstallerViewModel(KnossosWindow window) { - if (KnUtils.IsWindows || KnUtils.IsMacOS || KnUtils.IsLinux && (KnUtils.CpuArch == "X64" || KnUtils.CpuArch == "X86" || KnUtils.CpuArch == "Arm64" || KnUtils.CpuArch == "RiscV64")) - { - InnoExtractIsAvailable = true; - } this.window = window; } @@ -82,138 +79,79 @@ internal async void InstallFS2Command() { if(Knossos.GetKnossosLibraryPath() == null) { - await MessageBox.Show(MainWindow.instance!, "The KnossosNET library path is not set, first set the library path in the settings tab before installing FS2 Retail.", "Library path is null", MessageBox.MessageBoxButtons.OK); + await MessageBox.Show(MainWindow.instance, "The KnossosNET library path is not set, first set the library path in the settings tab before installing FS2 Retail.", "Library path is null", MessageBox.MessageBoxButtons.OK); return; } - //If gog exe first extract to a temp folder and process it first - if(gogExe != null) - { - InstallText = "Running innoextract"; - IsInstalling = true; + await Task.Run(async () => { try { - string innoPath = KnUtils.GetKnossosDataFolderPath() + Path.DirectorySeparatorChar; - if (KnUtils.IsWindows) - { - innoPath += "innoextract.exe"; - } - else + IsInstalling = true; + Dispatcher.UIThread.Invoke(new Action(() => { ProgressCurrent = 0; })); + var fs2Path = Path.Combine(Knossos.GetKnossosLibraryPath()!, "FS2"); + var moviesPath = Path.Combine(fs2Path, "data", "movies"); + var playersPath = Path.Combine(fs2Path, "data", "players"); + Directory.CreateDirectory(fs2Path); + Directory.CreateDirectory(moviesPath); + Directory.CreateDirectory(playersPath); + + //GoG (single .exe or .exe + .bin, GOG Galaxy multi-part) + if (gogExe != null || gogExeFile != null) { - if(KnUtils.IsLinux) + var archive = OpenGogArchive(out var cleanup); + try { - if(KnUtils.CpuArch == "X64") - { - innoPath += "innoextract.x64"; - } - if (KnUtils.CpuArch == "X86") - { - innoPath += "innoextract.x86"; - } - if (KnUtils.CpuArch == "Arm64") - { - innoPath += "innoextract.arm64"; - } - if (KnUtils.CpuArch == "RiscV64") + // Gather the files we want (required + any present optional) and + // their destination paths, using the same routing as before. + var wanted = new List(); + var destPath = new Dictionary(); + foreach (var name in required.Concat(optional)) { - innoPath += "innoextract.riscv64"; + var f = archive.FindFile(name); + if (f == null) + continue; + var dir = RouteDir(name, fs2Path, playersPath, moviesPath); + if (dir == "") + continue; + wanted.Add(f); + destPath[f] = Path.Combine(dir, name); } + + Dispatcher.UIThread.Invoke(new Action(() => { InstallText = "Extracting FreeSpace 2 data..."; })); + + // Single forward pass over the .bin (works on Android forward-only + // streams). ExtractFiles verifies each file's checksum internally. + archive.ExtractFiles( + wanted, + file => File.Create(destPath[file]), + file => + { + Dispatcher.UIThread.Invoke(new Action(() => + { + InstallText = $"Extracted: {file.Name}"; + ProgressCurrent++; + })); + }); } - else + finally { - if(KnUtils.IsMacOS) - { - innoPath += "innoextract.mac64"; - } + cleanup(); + archive.Dispose(); } } - await Task.Run(() => + //Folder Copy + if (filePaths.Any()) { - var cmd = new Process(); - Directory.CreateDirectory(KnUtils.GetKnossosDataFolderPath() + Path.DirectorySeparatorChar + "gog"); - cmd.StartInfo.FileName = innoPath; - cmd.StartInfo.Arguments = gogExe + " -L -g -d \"" + KnUtils.GetKnossosDataFolderPath() + Path.DirectorySeparatorChar + "gog\""; - cmd.StartInfo.UseShellExecute = false; - cmd.StartInfo.CreateNoWindow = true; - cmd.StartInfo.RedirectStandardOutput = true; - cmd.StartInfo.StandardOutputEncoding = new UTF8Encoding(false); - cmd.Start(); - string? output; - while ((output = cmd.StandardOutput.ReadLine()) != null) + foreach (var file in filePaths) { - Dispatcher.UIThread.InvokeAsync(() => - { - InstallText = "Running innoextract" + output; - ProgressCurrent++; - }); - } - cmd.WaitForExit(); - cmd.Dispose(); - }); - /* - there is an older gog installer that had all the data inside an /app folder, current version it just on the root - ProccessFolder need to be pointed to the folder with all the vps and the datas folder - */ - var sp = KnUtils.GetTopLevel().StorageProvider; - if (File.Exists(KnUtils.GetKnossosDataFolderPath() + Path.DirectorySeparatorChar + "gog" + Path.DirectorySeparatorChar + "root_fs2.vp")) - { - ProcessFolder( await sp.TryGetFolderFromPathAsync(Path.Combine(KnUtils.GetKnossosDataFolderPath(),"gog")) ); - } - else - { - ProcessFolder(await sp.TryGetFolderFromPathAsync(Path.Combine(KnUtils.GetKnossosDataFolderPath(), "gog", "app"))); - } - } - catch(Exception ex) - { - Log.Add(Log.LogSeverity.Error, "Fs2InstallerViewModel.InstallFS2Command()", ex); - return; - } - } - - if (!filePaths.Any()) - { - await MessageBox.Show(MainWindow.instance!, "Filepaths list is empty, something happened, if you are reading a gog exe it may be because its internal folder structure is different than the expected.", "Error", MessageBox.MessageBoxButtons.OK); - if (gogExe != null) - { - try - { - Directory.Delete(KnUtils.GetKnossosDataFolderPath() + Path.DirectorySeparatorChar + "gog", true); - } - catch { } - } - return; - } - - await Task.Run(async () => { - try - { - IsInstalling = true; - ProgressMax += filePaths.Count(); - ProgressCurrent = ProgressMax - filePaths.Count(); - Directory.CreateDirectory(Knossos.GetKnossosLibraryPath() + Path.DirectorySeparatorChar + "FS2" + Path.DirectorySeparatorChar + "data" + Path.DirectorySeparatorChar + "movies"); - Directory.CreateDirectory(Knossos.GetKnossosLibraryPath() + Path.DirectorySeparatorChar + "FS2" + Path.DirectorySeparatorChar + "data" + Path.DirectorySeparatorChar + "players"); - foreach (var file in filePaths) - { - InstallText = "Copying " + file.Name; - if (file.Name.ToLower().Contains(".vp")) - { - using (var streamOrg = await file.OpenReadAsync()) - { - using (var streamDst = new FileStream(Path.Combine(Knossos.GetKnossosLibraryPath()!, "FS2", file.Name), FileMode.Create, FileAccess.Write)) - { - await streamOrg.CopyToAsync(streamDst); - } - } - } - else - { - if (file.Name.ToLower().Contains(".hcf")) + Dispatcher.UIThread.Invoke(new Action(() => { InstallText = $"Copying: {file.Name}"; })); + /* VPs */ + if (file.Name.ToLower().Contains(".vp")) { using (var streamOrg = await file.OpenReadAsync()) { - using (var streamDst = new FileStream(Path.Combine(Knossos.GetKnossosLibraryPath()!, "FS2", "data", "players", file.Name), FileMode.Create, FileAccess.Write)) + using (var streamDst = new FileStream(Path.Combine(fs2Path, file.Name), FileMode.Create, FileAccess.Write)) { await streamOrg.CopyToAsync(streamDst); } @@ -221,20 +159,37 @@ await Task.Run(async () => { } else { - using (var streamOrg = await file.OpenReadAsync()) + /* Player Profiles */ + if (file.Name.ToLower().Contains(".hcf")) { - using (var streamDst = new FileStream(Path.Combine(Knossos.GetKnossosLibraryPath()!, "FS2", "data", "movies", file.Name), FileMode.Create, FileAccess.Write)) + using (var streamOrg = await file.OpenReadAsync()) { - await streamOrg.CopyToAsync(streamDst); + using (var streamDst = new FileStream(Path.Combine(playersPath, file.Name), FileMode.Create, FileAccess.Write)) + { + await streamOrg.CopyToAsync(streamDst); + } + } + } + else + { + /* Movies */ + using (var streamOrg = await file.OpenReadAsync()) + { + using (var streamDst = new FileStream(Path.Combine(moviesPath, file.Name), FileMode.Create, FileAccess.Write)) + { + await streamOrg.CopyToAsync(streamDst); + } } } } + Dispatcher.UIThread.Invoke(new Action(() => { ProgressCurrent++; })); } - ProgressCurrent++; } - InstallText = "Finishing tasks..."; + + /* FINISH */ + Dispatcher.UIThread.Invoke(new Action(() => { ProgressCurrent = ProgressMax; InstallText = "Finishing tasks..."; })); var fs2Mod = new Mod(); - fs2Mod.fullPath = Knossos.GetKnossosLibraryPath() + Path.DirectorySeparatorChar + "FS2"; + fs2Mod.fullPath = fs2Path; fs2Mod.folderName = "FS2"; fs2Mod.installed = true; fs2Mod.id = "FS2"; @@ -339,146 +294,181 @@ await Task.Run(async () => { } } catch { } - InstallText = "Install Complete!, KnossosNET is reloading the library..."; + Dispatcher.UIThread.Invoke(new Action(() => { InstallText = "Install Complete!, KnossosNET is reloading the library..."; })); Knossos.ResetBasePath(); - if(gogExe != null) - { - try - { - Directory.Delete(KnUtils.GetKnossosDataFolderPath() + Path.DirectorySeparatorChar + "gog",true); - } - catch { } - } + await Task.Delay(3000); + Dispatcher.UIThread.Invoke(new Action(() => { window?.Close(); })); } catch(Exception ex) { Log.Add(Log.LogSeverity.Error, "Fs2InstallerViewModel.InstallFS2Command()",ex); + Dispatcher.UIThread.Invoke(new Action(() => { MessageBox.Show(MainWindow.instance, $"An error has ocurred during file copy: {ex.Message}.", "An error has ocurred", MessageBox.MessageBoxButtons.OK); })); } }); } /// /// Open file dialog to select a gog exe file, it checks that this is a valid FS2 install file + /// by counting the number of requiered and optional files present in it. /// internal async void LoadGoGExeCommand() { - FilePickerOpenOptions options = new FilePickerOpenOptions(); - options.AllowMultiple = false; - options.Title = "Select your Freespace 2 gog .exe installer file"; - - var topmostWindow = KnUtils.GetTopLevel(); - var result = await topmostWindow.StorageProvider.OpenFilePickerAsync(options); - - if (result != null && result.Count > 0) + var top = KnUtils.GetTopLevel(); + // Sandboxed platforms (Android/Browser) have no usable file paths, so we + // pick the FOLDER (which grants access to both the .exe and its .bin siblings). + // Desktop keeps the familiar .exe file picker (path-based). + bool sandboxed = KnUtils.IsAndroid || KnUtils.IsBrowser; + try { CanInstall = false; gogExe = null; - try + gogExeFile = null; + gogBins.Clear(); + + if (!sandboxed) { - string innoPath = KnUtils.GetKnossosDataFolderPath() + Path.DirectorySeparatorChar; + var options = new FilePickerOpenOptions(); + options.AllowMultiple = false; + options.Title = "Select your Freespace 2 gog .exe installer file"; + var result = await top.StorageProvider.OpenFilePickerAsync(options); + if (result == null || result.Count == 0) + return; + // If the platform gives us a real path, use it; otherwise fall back to folder mode. + var localPath = result[0].TryGetLocalPath(); + if (localPath != null) + gogExe = localPath; + else + sandboxed = true; + } - /*Copy Innoextract License file*/ - using (var fileStream = File.Create(innoPath + Path.DirectorySeparatorChar + "innoextract.license")) + if (sandboxed && gogExe == null) + { + var fopts = new FolderPickerOpenOptions(); + fopts.AllowMultiple = false; + fopts.Title = "Select the folder with your Freespace 2 gog installer (.exe + .bin)"; + var folders = await top.StorageProvider.OpenFolderPickerAsync(fopts); + if (folders == null || folders.Count == 0) + return; + await FindGogFilesInFolder(folders[0]); + if (gogExeFile == null) { - AssetLoader.Open(new Uri("avares://Knossos.NET.Desktop/Assets/utils/innoextract.license")).CopyTo(fileStream); - fileStream.Close(); + await MessageBox.Show(MainWindow.instance, "No .exe installer was found in that folder.", "Installer not found", MessageBox.MessageBoxButtons.OK); + return; } + } - if (KnUtils.IsWindows) + // Validate on a background thread: opening storage streams blocks, and doing + // that on the UI thread can deadlock on Android. + int total = 0; + bool allRequired = false; + await Task.Run(() => + { + var archive = OpenGogArchive(out var cleanup); + try { - innoPath += "innoextract.exe"; - using (var fileStream = File.Create(innoPath)) - { - AssetLoader.Open(new Uri("avares://Knossos.NET.Desktop/Assets/utils/win/innoextract.exe")).CopyTo(fileStream); - fileStream.Close(); - } + int req = required.Count(r => archive.FindFile(r) != null); + allRequired = req == required.Length; + total = req + optional.Count(o => archive.FindFile(o) != null); } - else + finally { - if (KnUtils.IsLinux) - { - if (KnUtils.CpuArch == "X64") - { - innoPath += "innoextract.x64"; - using (var fileStream = File.Create(innoPath)) - { - AssetLoader.Open(new Uri("avares://Knossos.NET.Desktop/Assets/utils/linux-x64/innoextract.x64")).CopyTo(fileStream); - fileStream.Close(); - KnUtils.Chmod(innoPath,"+x"); - } - } - if (KnUtils.CpuArch == "Arm64") - { - innoPath += "innoextract.arm64"; - using (var fileStream = File.Create(innoPath)) - { - AssetLoader.Open(new Uri("avares://Knossos.NET.Desktop/Assets/utils/linux-arm64/innoextract.arm64")).CopyTo(fileStream); - fileStream.Close(); - KnUtils.Chmod(innoPath, "+x"); - } - } - if (KnUtils.CpuArch == "RiscV64") - { - innoPath += "innoextract.riscv64"; - using (var fileStream = File.Create(innoPath)) - { - AssetLoader.Open(new Uri("avares://Knossos.NET.Desktop/Assets/utils/linux-riscv64/innoextract.riscv64")).CopyTo(fileStream); - fileStream.Close(); - KnUtils.Chmod(innoPath, "+x"); - } - } - } - else - { - if(KnUtils.IsMacOS) - { - innoPath += "innoextract.mac64"; - using (var fileStream = File.Create(innoPath)) - { - AssetLoader.Open(new Uri("avares://Knossos.NET.Desktop/Assets/utils/osx/innoextract.mac64")).CopyTo(fileStream); - fileStream.Close(); - KnUtils.Chmod(innoPath, "+x"); - } - } - } + cleanup(); + archive.Dispose(); } + }); - var cmd = new Process(); - var file = new FileInfo(result[0].Path.LocalPath.ToString()); - gogExe = "\"" + file.FullName + "\""; - cmd.StartInfo.FileName = innoPath; - cmd.StartInfo.Arguments = gogExe + " -l -g"; - cmd.StartInfo.UseShellExecute = false; - cmd.StartInfo.CreateNoWindow = true; - cmd.StartInfo.RedirectStandardOutput = true; - cmd.StartInfo.RedirectStandardInput = true; - cmd.StartInfo.StandardOutputEncoding = new UTF8Encoding(false); - cmd.Start(); - var innoOutput = cmd.StandardOutput.ReadToEnd().ToLower(); - cmd.WaitForExit(); - cmd.Dispose(); - int count = 0; - foreach (var reqFileName in required) - { - if (innoOutput.Contains(reqFileName)) - count++; - } + if (!allRequired) + { + gogExe = null; + gogExeFile = null; + gogBins.Clear(); + await MessageBox.Show(MainWindow.instance, "Unable to find all the required Freespace 2 files in gog installer.", "Files not found", MessageBox.MessageBoxButtons.OK); + return; + } + ProgressMax = total; + CanInstall = true; + } + catch (Exception ex) + { + Log.Add(Log.LogSeverity.Error, "Fs2InstallerViewModel.LoadGoGExeCommand()", ex); + gogExe = null; + gogExeFile = null; + gogBins.Clear(); + } + } - if (count != required.Count()) - { - //Missing files - gogExe = null; - await MessageBox.Show(MainWindow.instance!, "Unable to find all the required Freespace 2 files in gog exe.", "Files not found", MessageBox.MessageBoxButtons.OK); - return; - } - ProgressMax = innoOutput.Split('\n').Length-2; - ProgressMax += required.Count() + optional.Count(); - CanInstall = true; - }catch(Exception ex) + /// + /// Finds the GOG installer .exe and its .bin slice(s) inside a picked folder, + /// storing them as storage handles (streams are opened later, at install time). + /// + private async Task FindGogFilesInFolder(IStorageFolder folder) + { + IStorageFile? anyExe = null; + await foreach (var item in folder.GetItemsAsync()) + { + if (item is not IStorageFile f) + continue; + var lname = f.Name.ToLowerInvariant(); + if (lname.EndsWith(".exe")) { - Log.Add(Log.LogSeverity.Error, "Fs2InstallerViewModel.LoadGoGExeCommand()", ex); + anyExe ??= f; + if (lname.Contains("setup")) + gogExeFile ??= f; // prefer a setup*.exe + } + else if (lname.EndsWith(".bin")) + { + gogBins[f.Name] = f; } } + gogExeFile ??= anyExe; + } + + /// + /// Opens the selected GOG installer as an InnoArchive, from a path (desktop) or from + /// storage streams (sandboxed). The returned cleanup action releases opened streams. + /// + private InnoArchive OpenGogArchive(out Action cleanup) + { + if (gogExe != null) + { + cleanup = () => { }; + return new InnoArchive(gogExe); + } + if (gogExeFile != null) + { + var opened = new List(); + InnoArchive.SliceOpener opener = (idx, expectedName) => + { + if (gogBins.TryGetValue(expectedName, out var bf)) + { + var bs = bf.OpenReadAsync().GetAwaiter().GetResult(); + opened.Add(bs); + return bs; + } + return null; + }; + var exeStream = gogExeFile.OpenReadAsync().GetAwaiter().GetResult(); + var archive = new InnoArchive(exeStream, gogExeFile.Name, opener, leaveOpen: false); + cleanup = () => { foreach (var st in opened) { try { st.Dispose(); } catch { } } }; + return archive; + } + throw new InvalidOperationException("No GOG installer selected."); + } + + /// + /// Destination directory for a file name (same routing as the folder-copy path). + /// + private static string RouteDir(string name, string fs2Path, string playersPath, string moviesPath) + { + switch (Path.GetExtension(name).ToLowerInvariant()) + { + case ".vp": + case ".vpc": return fs2Path; + case ".hcf": return playersPath; + case ".ogg": + case ".mve": return moviesPath; + default: return ""; + } } /// @@ -544,12 +534,14 @@ private async void ProcessFolder(IStorageFolder? path, bool topLevel = true) if (reqFilesFound < 9) { //Missing files - await MessageBox.Show(MainWindow.instance!, "Unable to find all the required Freespace 2 files in this directory.", "Files not found", MessageBox.MessageBoxButtons.OK); + await MessageBox.Show(MainWindow.instance, "Unable to find all the required Freespace 2 files in this directory.", "Files not found", MessageBox.MessageBoxButtons.OK); return; } CanInstall = true; + ProgressMax = filePaths.Count(); } - }catch (Exception ex) + } + catch (Exception ex) { Log.Add(Log.LogSeverity.Error, "Fs2InstallerViewModel.ProcessFolder()", ex); } diff --git a/Knossos.NET/Views/Windows/Fs2InstallerView.axaml b/Knossos.NET/Views/Windows/Fs2InstallerView.axaml index 1b03e0f6..4cc4908f 100644 --- a/Knossos.NET/Views/Windows/Fs2InstallerView.axaml +++ b/Knossos.NET/Views/Windows/Fs2InstallerView.axaml @@ -32,7 +32,7 @@ - +