diff --git a/Extensions/TkSharp.Extensions.LibHac/Extensions/FileSystemExtensions.cs b/Extensions/TkSharp.Extensions.LibHac/Extensions/FileSystemExtensions.cs index 5a2ca5c..30a30e3 100644 --- a/Extensions/TkSharp.Extensions.LibHac/Extensions/FileSystemExtensions.cs +++ b/Extensions/TkSharp.Extensions.LibHac/Extensions/FileSystemExtensions.cs @@ -17,6 +17,10 @@ public static class FileSystemExtensions { public static SwitchFs GetSwitchFs(this IStorage storage, string filePath, KeySet keys) { + if (storage is ConcatenationStorage) { + return IsXci(storage) ? OpenXci(keys, storage) : OpenNsp(keys, storage); + } + ReadOnlySpan extension = Path.GetExtension(filePath.AsSpan()); return extension switch { @@ -25,7 +29,14 @@ public static SwitchFs GetSwitchFs(this IStorage storage, string filePath, KeySe _ => throw new ArgumentException($"Unsupported file extension: '{extension}'", nameof(filePath)), }; } - + + private static bool IsXci(IStorage storage) + { + Span buffer = stackalloc byte[4]; + storage.Read(0x100, buffer).ThrowIfFailure(); + return buffer.SequenceEqual("HEAD"u8); + } + private static SwitchFs OpenNsp(KeySet keys, IStorage storage) { SharedRef storageShared = new(storage); diff --git a/Extensions/TkSharp.Extensions.LibHac/Helpers/FileRomHelper.cs b/Extensions/TkSharp.Extensions.LibHac/Helpers/FileRomHelper.cs new file mode 100644 index 0000000..94c4b26 --- /dev/null +++ b/Extensions/TkSharp.Extensions.LibHac/Helpers/FileRomHelper.cs @@ -0,0 +1,25 @@ +using LibHac.Common.Keys; +using LibHac.Fs; +using LibHac.FsSystem; +using LibHac.Tools.Fs; +using TkSharp.Extensions.LibHac.Extensions; + +namespace TkSharp.Extensions.LibHac.Helpers +{ + public class FileRomHelper : ILibHacRomHelper + { + private IStorage? _storage; + + public SwitchFs Initialize(string filePath, KeySet keys) + { + _storage = new LocalStorage(filePath, FileAccess.Read); + return _storage.GetSwitchFs(filePath, keys); + } + + public void Dispose() + { + _storage?.Dispose(); + GC.SuppressFinalize(this); + } + } +} \ No newline at end of file diff --git a/Extensions/TkSharp.Extensions.LibHac/Helpers/ILibHacRomHelper.cs b/Extensions/TkSharp.Extensions.LibHac/Helpers/ILibHacRomHelper.cs new file mode 100644 index 0000000..3effb5f --- /dev/null +++ b/Extensions/TkSharp.Extensions.LibHac/Helpers/ILibHacRomHelper.cs @@ -0,0 +1,9 @@ +using LibHac.Common.Keys; +using LibHac.Tools.Fs; + +namespace TkSharp.Extensions.LibHac.Helpers; + +public interface ILibHacRomHelper : IDisposable +{ + SwitchFs Initialize(string path, KeySet keys); +} \ No newline at end of file diff --git a/Extensions/TkSharp.Extensions.LibHac/Helpers/SdRomHelper.cs b/Extensions/TkSharp.Extensions.LibHac/Helpers/SdRomHelper.cs new file mode 100644 index 0000000..69e8853 --- /dev/null +++ b/Extensions/TkSharp.Extensions.LibHac/Helpers/SdRomHelper.cs @@ -0,0 +1,37 @@ +using LibHac.Common; +using LibHac.Common.Keys; +using LibHac.Fs; +using LibHac.Fs.Fsa; +using LibHac.FsSystem; +using LibHac.Tools.Fs; +using LibHac.Tools.FsSystem; + +namespace TkSharp.Extensions.LibHac.Helpers; + +public class SdRomHelper : ILibHacRomHelper +{ + private UniqueRef _localFsRef; + + public SwitchFs Initialize(string sdCardPath, KeySet keys) + { + LocalFileSystem.Create(out LocalFileSystem? localFs, sdCardPath).ThrowIfFailure(); + _localFsRef = new UniqueRef(localFs); + + var concatFs = new ConcatenationFileSystem(ref _localFsRef); + + using var contentDirPath = new global::LibHac.Fs.Path(); + PathFunctions.SetUpFixedPath(ref contentDirPath.Ref(), "/Nintendo/Contents"u8).ThrowIfFailure(); + + var contentDirFs = new SubdirectoryFileSystem(concatFs); + contentDirFs.Initialize(in contentDirPath).ThrowIfFailure(); + + var encFs = new AesXtsFileSystem(contentDirFs, keys.SdCardEncryptionKeys[1].DataRo.ToArray(), 0x4000); + return new SwitchFs(keys, encFs, null); + } + + public void Dispose() + { + _localFsRef.Destroy(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/Extensions/TkSharp.Extensions.LibHac/Helpers/SplitRomHelper.cs b/Extensions/TkSharp.Extensions.LibHac/Helpers/SplitRomHelper.cs new file mode 100644 index 0000000..1757de2 --- /dev/null +++ b/Extensions/TkSharp.Extensions.LibHac/Helpers/SplitRomHelper.cs @@ -0,0 +1,30 @@ +using LibHac.Common.Keys; +using LibHac.Fs; +using LibHac.FsSystem; +using LibHac.Tools.FsSystem; +using LibHac.Tools.Fs; +using TkSharp.Extensions.LibHac.Extensions; + +namespace TkSharp.Extensions.LibHac.Helpers +{ + public class SplitRomHelper : ILibHacRomHelper + { + private ConcatenationStorage? _storage; + + public SwitchFs Initialize(string splitDirectory, KeySet keys) + { + IList splitFiles = [.. Directory.EnumerateFiles(splitDirectory) + .OrderBy(f => f) + .Select(f => new LocalStorage(f, FileAccess.Read))]; + + _storage = new ConcatenationStorage(splitFiles, true); + return _storage.GetSwitchFs("rom", keys); + } + + public void Dispose() + { + _storage?.Dispose(); + GC.SuppressFinalize(this); + } + } +} \ No newline at end of file diff --git a/Extensions/TkSharp.Extensions.LibHac/LibHacRomProvider.cs b/Extensions/TkSharp.Extensions.LibHac/LibHacRomProvider.cs new file mode 100644 index 0000000..ef2e753 --- /dev/null +++ b/Extensions/TkSharp.Extensions.LibHac/LibHacRomProvider.cs @@ -0,0 +1,71 @@ +using LibHac.Common.Keys; +using LibHac.Fs.Fsa; +using LibHac.Tools.Fs; +using LibHac.Tools.FsSystem; +using LibHac.Tools.FsSystem.NcaUtils; +using TkSharp.Core; +using TkSharp.Extensions.LibHac.Helpers; + +namespace TkSharp.Extensions.LibHac; + +public class LibHacRomProvider : IDisposable +{ + public const ulong EX_KING_APP_ID = 0x0100F2C0115B6000; + + private SwitchFs? _baseFs; + private SwitchFs? _updateFs; + private IFileSystem? _fileSystem; + private ILibHacRomHelper? _helper; + + public TkRom CreateRom(TkChecksums checksums, KeySet keys, LibHacRomSourceType baseSourceType, string basePath, LibHacRomSourceType updateSourceType, string updatePath) + { + if (baseSourceType is LibHacRomSourceType.SdCard && updateSourceType is LibHacRomSourceType.SdCard && basePath == updatePath) { + _helper = new SdRomHelper(); + _baseFs = _helper.Initialize(basePath, keys); + _fileSystem = InitializeLayeredFs(_baseFs, _baseFs); + } + else { + _baseFs = CreateSwitchFs(baseSourceType, basePath, keys); + _updateFs = CreateSwitchFs(updateSourceType, updatePath, keys); + _fileSystem = InitializeLayeredFs(_baseFs, _updateFs); + } + + return new TkRom(checksums, _fileSystem); + } + + private SwitchFs CreateSwitchFs(LibHacRomSourceType sourceType, string path, KeySet keys) + { + _helper = sourceType switch { + LibHacRomSourceType.File => new FileRomHelper(), + LibHacRomSourceType.SdCard => new SdRomHelper(), + LibHacRomSourceType.SplitFiles => new SplitRomHelper(), + _ => throw new ArgumentException($"Invalid source: {sourceType}") + }; + + return _helper.Initialize(path, keys); + } + + private static IFileSystem InitializeLayeredFs(SwitchFs baseFs, SwitchFs updateFs) + { + return baseFs.Applications[EX_KING_APP_ID].Main.MainNca.Nca + .OpenFileSystemWithPatch(updateFs.Applications[EX_KING_APP_ID].Patch.MainNca.Nca, + NcaSectionType.Data, IntegrityCheckLevel.ErrorOnInvalid); + } + + public void Dispose() + { + _fileSystem?.Dispose(); + _baseFs?.Dispose(); + _updateFs?.Dispose(); + _helper?.Dispose(); + + GC.SuppressFinalize(this); + } +} + +public enum LibHacRomSourceType +{ + File, // XCI or NSP file + SdCard, // From SD card + SplitFiles // Split files in a directory +} diff --git a/Extensions/TkSharp.Extensions.LibHac/PackedTkRom.cs b/Extensions/TkSharp.Extensions.LibHac/TkRom.cs similarity index 50% rename from Extensions/TkSharp.Extensions.LibHac/PackedTkRom.cs rename to Extensions/TkSharp.Extensions.LibHac/TkRom.cs index 6062d58..c202176 100644 --- a/Extensions/TkSharp.Extensions.LibHac/PackedTkRom.cs +++ b/Extensions/TkSharp.Extensions.LibHac/TkRom.cs @@ -1,26 +1,15 @@ using LibHac.Common; -using LibHac.Common.Keys; using LibHac.Fs; using LibHac.Fs.Fsa; -using LibHac.FsSystem; -using LibHac.Tools.Fs; -using LibHac.Tools.FsSystem; -using LibHac.Tools.FsSystem.NcaUtils; using TkSharp.Core; using TkSharp.Core.IO.Buffers; using TkSharp.Core.IO.Parsers; using TkSharp.Extensions.LibHac.Extensions; -using Path = System.IO.Path; namespace TkSharp.Extensions.LibHac; -public sealed class PackedTkRom : ITkRom, IDisposable +public class TkRom : ITkRom, IDisposable { - public const ulong EX_KING_APP_ID = 0x0100F2C0115B6000; - - private readonly IStorage _baseStorage, _updateStorage; - private readonly SwitchFs _baseSwitchFs, _updateSwitchFs; - private readonly TkChecksums _checksums; private readonly IFileSystem _fileSystem; @@ -36,46 +25,33 @@ public sealed class PackedTkRom : ITkRom, IDisposable public Dictionary.AlternateLookup> EffectVersions { get; } - public PackedTkRom(TkChecksums checksums, KeySet keys, string baseGameFilePath, string gameUpdateFilePath) + public TkRom(TkChecksums checksums, IFileSystem fileSystem) { _checksums = checksums; + _fileSystem = fileSystem; - _baseStorage = new LocalStorage(baseGameFilePath, FileAccess.Read); - _baseSwitchFs = _baseStorage.GetSwitchFs(baseGameFilePath, keys); - - _updateStorage = new LocalStorage(gameUpdateFilePath, FileAccess.Read); - _updateSwitchFs = _updateStorage.GetSwitchFs(gameUpdateFilePath, keys); - - _fileSystem = _baseSwitchFs.Applications[EX_KING_APP_ID].Main.MainNca.Nca - .OpenFileSystemWithPatch(_updateSwitchFs.Applications[EX_KING_APP_ID].Patch.MainNca.Nca, NcaSectionType.Data, IntegrityCheckLevel.ErrorOnInvalid); - - { - using Stream regionLangMaskFs = _fileSystem.OpenFileStream("/System/RegionLangMask.txt"); - using RentedBuffer regionLangMask = RentedBuffer.Allocate(regionLangMaskFs); + using (Stream regionLangMaskFs = _fileSystem.OpenFileStream("/System/RegionLangMask.txt")) + using (RentedBuffer regionLangMask = RentedBuffer.Allocate(regionLangMaskFs)) { GameVersion = RegionLangMaskParser.ParseVersion(regionLangMask.Span, out string nsoBinaryId); NsoBinaryId = nsoBinaryId; } - { - using Stream zsDicFs = _fileSystem.OpenFileStream("/Pack/ZsDic.pack.zs"); + using (Stream zsDicFs = _fileSystem.OpenFileStream("/Pack/ZsDic.pack.zs")) { Zstd = new TkZstd(zsDicFs); } - { - using Stream addressTableFs = _fileSystem.OpenFileStream($"/System/AddressTable/Product.{GameVersion}.Nin_NX_NVN.atbl.byml.zs"); - using RentedBuffer addressTableBuffer = RentedBuffer.Allocate(addressTableFs); + using (Stream addressTableFs = _fileSystem.OpenFileStream($"/System/AddressTable/Product.{GameVersion}.Nin_NX_NVN.atbl.byml.zs")) + using (RentedBuffer addressTableBuffer = RentedBuffer.Allocate(addressTableFs)) { AddressTable = AddressTableParser.ParseAddressTable(addressTableBuffer.Span, Zstd); } - { - using Stream eventFlowFileEntryFs = _fileSystem.OpenFileStream($"/{AddressTable["Event/EventFlow/EventFlowFileEntry.Product.byml"]}.zs"); - using RentedBuffer eventFlowFileEntryBuffer = RentedBuffer.Allocate(eventFlowFileEntryFs); + using (Stream eventFlowFileEntryFs = _fileSystem.OpenFileStream($"/{AddressTable["Event/EventFlow/EventFlowFileEntry.Product.byml"]}.zs")) + using (RentedBuffer eventFlowFileEntryBuffer = RentedBuffer.Allocate(eventFlowFileEntryFs)) { EventFlowVersions = EventFlowFileEntryParser.ParseFileEntry(eventFlowFileEntryBuffer.Span, Zstd); } - { - using Stream effectInfoFs = _fileSystem.OpenFileStream($"/{AddressTable["Effect/EffectFileInfo.Product.Nin_NX_NVN.byml"]}.zs"); - using RentedBuffer effectInfoBuffer = RentedBuffer.Allocate(effectInfoFs); + using (Stream effectInfoFs = _fileSystem.OpenFileStream($"/{AddressTable["Effect/EffectFileInfo.Product.Nin_NX_NVN.byml"]}.zs")) + using (RentedBuffer effectInfoBuffer = RentedBuffer.Allocate(effectInfoFs)) { EffectVersions = EffectInfoParser.ParseFileEntry(effectInfoBuffer.Span, Zstd); } } @@ -87,7 +63,8 @@ public RentedBuffer GetVanilla(string relativeFilePath) UniqueRef file = new(); _fileSystem.OpenFile(ref file, relativeFilePath.ToU8Span(), OpenMode.Read); - if (!file.HasValue) { + if (!file.HasValue) + { return default; } @@ -97,16 +74,19 @@ public RentedBuffer GetVanilla(string relativeFilePath) file.Get.Read(out _, offset: 0, raw); file.Destroy(); - if (!TkZstd.IsCompressed(raw)) { + if (!TkZstd.IsCompressed(raw)) + { return rawBuffer; } - try { + try + { RentedBuffer decompressed = RentedBuffer.Allocate(TkZstd.GetDecompressedSize(raw)); Zstd.Decompress(raw, decompressed.Span); return decompressed; } - finally { + finally + { rawBuffer.Dispose(); } } @@ -118,10 +98,7 @@ public bool IsVanilla(ReadOnlySpan canonical, Span src, int fileVers public void Dispose() { - _baseStorage.Dispose(); - _updateStorage.Dispose(); - _baseSwitchFs.Dispose(); - _updateSwitchFs.Dispose(); _fileSystem.Dispose(); + GC.SuppressFinalize(this); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/TkSharp.Debug/DebugRomProvider.cs b/TkSharp.Debug/DebugRomProvider.cs index e80d50e..3d7a55c 100644 --- a/TkSharp.Debug/DebugRomProvider.cs +++ b/TkSharp.Debug/DebugRomProvider.cs @@ -1,22 +1,40 @@ +using LibHac.Common.Keys; +using LibHac.FsSystem; +using LibHac.Tools.FsSystem; using TkSharp.Core; using TkSharp.Core.Common; -using TkSharp.Core.IO; using TkSharp.Data.Embedded; +using TkSharp.Extensions.LibHac; namespace TkSharp.Debug; -public sealed class DebugRomProvider : Singleton, ITkRomProvider +public sealed class DebugRomProvider : Singleton, ITkRomProvider, IDisposable { + private LibHacRomProvider? _romProvider; + public ITkRom GetRom() { - return new ExtractedTkRom(@"F:\Games\RomFS\Totk\1.2.1", - TkChecksums.FromStream(TkEmbeddedDataSource.GetChecksumsBin()) - ); + // return new ExtractedTkRom(@"F:\Games\RomFS\Totk\1.2.1", + // TkChecksums.FromStream(TkEmbeddedDataSource.GetChecksumsBin()) + // ); // return new PackedTkRom( // TkChecksums.FromStream(TkEmbeddedDataSource.GetChecksumsBin()), // @"C:\Users\ArchLeaders\AppData\Roaming\Ryujinx\system", // @"D:\Games\Emulation\Packaged\Tears-of-the-Kingdom\TotK-1.0.0.xci", // @"D:\Games\Emulation\Packaged\Tears-of-the-Kingdom\TotK-1.2.1.nsp"); + + _romProvider = new LibHacRomProvider(); + return _romProvider.CreateRom( + TkChecksums.FromStream(TkEmbeddedDataSource.GetChecksumsBin()), + ExternalKeyReader.ReadKeyFile(@"F:\TOTK\SDCardTest\switch\prod.keys", @"F:\TOTK\SDCardTest\switch\title.keys"), + LibHacRomSourceType.SplitFiles, @"F:\TOTK\SDCardTest\TOTKSPLIT", + LibHacRomSourceType.SdCard, @"F:\TOTK\SDCardTest"); + } + + public void Dispose() + { + _romProvider?.Dispose(); + _romProvider = null; } -} \ No newline at end of file +} diff --git a/Tools/TkSharp.DevTools/Helpers/RomHelper.cs b/Tools/TkSharp.DevTools/Helpers/RomHelper.cs index a0d4fdc..d627901 100644 --- a/Tools/TkSharp.DevTools/Helpers/RomHelper.cs +++ b/Tools/TkSharp.DevTools/Helpers/RomHelper.cs @@ -1,5 +1,4 @@ using LibHac.Common.Keys; -using Microsoft.Extensions.Logging; using TkSharp.Core; using TkSharp.Core.IO; using TkSharp.Data.Embedded; @@ -10,46 +9,65 @@ namespace TkSharp.DevTools.Helpers; public class RomHelper : ITkRomProvider { - private static readonly TkChecksums _checksums = TkChecksums.FromStream( - TkEmbeddedDataSource.GetChecksumsBin()); + private static readonly TkChecksums _checksums = TkChecksums.FromStream(TkEmbeddedDataSource.GetChecksumsBin()); public static readonly RomHelper Instance = new(); - + public ITkRom GetRom() { if (Config.Shared.GameDumpFolderPath is string gamePath && Directory.Exists(gamePath)) { return new ExtractedTkRom(gamePath, _checksums); } - if (Config.Shared.KeysFolderPath is string keysFolderPath - && GetKeys(keysFolderPath) is KeySet keys - && Config.Shared.BaseGameFilePath is string baseGameFilePath - && Config.Shared.GameUpdateFilePath is string gameUpdateFilePath) { - return new PackedTkRom(_checksums, keys, baseGameFilePath, gameUpdateFilePath); + if (Config.Shared.KeysFolderPath is not string keysFolderPath) { + throw new InvalidOperationException("Keys folder path is required but not configured."); } - throw new InvalidOperationException("Invalid configuration."); - } - - public static KeySet? GetKeys(string keysFolder) - { - string prodKeysFilePath = Path.Combine(keysFolder, "prod.keys"); - if (!File.Exists(prodKeysFilePath)) { - TkLog.Instance.LogError("A 'prod.keys' file could not be found in '{KeysFolder}'", keysFolder); - return null; + string prodKeysPath = Path.Combine(keysFolderPath, "prod.keys"); + if (!File.Exists(prodKeysPath)) { + throw new FileNotFoundException($"A 'prod.keys' file could not be found in '{keysFolderPath}'"); } - - string titleKeysFilePath = Path.Combine(keysFolder, "title.keys"); - if (!File.Exists(titleKeysFilePath)) { - TkLog.Instance.LogError("A 'title.keys' file could not be found in '{KeysFolder}'", keysFolder); - return null; + + string titleKeysPath = Path.Combine(keysFolderPath, "title.keys"); + if (!File.Exists(titleKeysPath)) { + throw new FileNotFoundException($"A 'title.keys' file could not be found in '{keysFolderPath}'"); } - KeySet keys = new(); - ExternalKeyReader.ReadKeyFile(keys, - prodKeysFilename: prodKeysFilePath, - titleKeysFilename: titleKeysFilePath); + var keys = new KeySet(); + ExternalKeyReader.ReadKeyFile(keys, prodKeysFilename: prodKeysPath, titleKeysFilename: titleKeysPath); - return keys; + var (baseSource, basePath) = GetRomSource(); + var (updateSource, updatePath) = GetUpdateSource(); + + if (baseSource is null || basePath is null || updateSource is null || updatePath is null) { + throw new InvalidOperationException("Invalid configuration: ROM source or path is not set."); + } + + var romProvider = new LibHacRomProvider(); + return romProvider.CreateRom( + _checksums, + keys, + baseSource.Value, basePath, + updateSource.Value, updatePath); + } + + private static (LibHacRomSourceType? Source, string? Path) GetRomSource() + { + if (Config.Shared.BaseGameFilePath is string path && File.Exists(path)) + return (LibHacRomSourceType.File, path); + if (Config.Shared.SplitFilesPath is string splitPath && Directory.Exists(splitPath)) + return (LibHacRomSourceType.SplitFiles, splitPath); + if (Config.Shared.SdCardRootPath is string sdPath && Directory.Exists(sdPath)) + return (LibHacRomSourceType.SdCard, sdPath); + return (null, null); + } + + private static (LibHacRomSourceType? Source, string? Path) GetUpdateSource() + { + if (Config.Shared.GameUpdateFilePath is string path && File.Exists(path)) + return (LibHacRomSourceType.File, path); + if (Config.Shared.SdCardRootPath is string sdPath && Directory.Exists(sdPath)) + return (LibHacRomSourceType.SdCard, sdPath); + return (null, null); } } \ No newline at end of file diff --git a/Tools/TkSharp.DevTools/Helpers/Ryujinx/RyujinxHelper.cs b/Tools/TkSharp.DevTools/Helpers/Ryujinx/RyujinxHelper.cs index 03c9cc5..22d2119 100644 --- a/Tools/TkSharp.DevTools/Helpers/Ryujinx/RyujinxHelper.cs +++ b/Tools/TkSharp.DevTools/Helpers/Ryujinx/RyujinxHelper.cs @@ -53,7 +53,7 @@ public static class RyujinxHelper using LocalStorage storage = new(target, FileAccess.Read); using SwitchFs fs = storage.GetSwitchFs(target, keys); - if (fs.Applications.TryGetValue(PackedTkRom.EX_KING_APP_ID, out Application? app)) { + if (fs.Applications.TryGetValue(LibHacRomProvider.EX_KING_APP_ID, out Application? app)) { yield return (FilePath: target, app.DisplayVersion); } } diff --git a/Tools/TkSharp.DevTools/ViewModels/SettingsViewModel.cs b/Tools/TkSharp.DevTools/ViewModels/SettingsViewModel.cs index 60ace5c..03cd9af 100644 --- a/Tools/TkSharp.DevTools/ViewModels/SettingsViewModel.cs +++ b/Tools/TkSharp.DevTools/ViewModels/SettingsViewModel.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging; using TkSharp.Core; using TkSharp.DevTools.Helpers.Ryujinx; +using Avalonia.Platform.Storage; namespace TkSharp.DevTools.ViewModels; @@ -31,6 +32,12 @@ public partial class SettingsPageViewModel : ObservableObject [ObservableProperty] private string? _gameDumpFolderPath; + [ObservableProperty] + private string? _sdCardRootPath; + + [ObservableProperty] + private string? _splitFilesPath; + public static SettingsPageViewModel Load() { SettingsPageViewModel? result; diff --git a/Tools/TkSharp.DevTools/Views/SettingsPage.axaml b/Tools/TkSharp.DevTools/Views/SettingsPage.axaml index 0915690..8ad56d9 100644 --- a/Tools/TkSharp.DevTools/Views/SettingsPage.axaml +++ b/Tools/TkSharp.DevTools/Views/SettingsPage.axaml @@ -27,6 +27,12 @@ + +