diff --git a/Directory.Packages.props b/Directory.Packages.props index e03cb42..b795f3c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,4 +1,4 @@ - @@ -7,10 +7,16 @@ SPDX-License-Identifier: GPL-2.0-or-later true + + + + + + \ No newline at end of file diff --git a/SharpEmu.slnx b/SharpEmu.slnx index 9b2a924..3c45742 100644 --- a/SharpEmu.slnx +++ b/SharpEmu.slnx @@ -7,6 +7,7 @@ SPDX-License-Identifier: GPL-2.0-or-later + diff --git a/src/SharpEmu.CLI/Program.cs b/src/SharpEmu.CLI/Program.cs index 62e94c2..60ec8d2 100644 --- a/src/SharpEmu.CLI/Program.cs +++ b/src/SharpEmu.CLI/Program.cs @@ -3,6 +3,7 @@ using SharpEmu.Core.Runtime; using SharpEmu.Core.Cpu; +using SharpEmu.GUI; using SharpEmu.HLE; using SharpEmu.Logging; using System.Runtime.InteropServices; @@ -24,12 +25,32 @@ internal static partial class Program private const ulong PROCESS_CREATION_MITIGATION_POLICY2_CET_USER_SHADOW_STACKS_ALWAYS_OFF = 0x00000002UL << 28; private const ulong PROCESS_CREATION_MITIGATION_POLICY2_USER_CET_SET_CONTEXT_IP_VALIDATION_ALWAYS_OFF = 0x00000002UL << 32; private const ulong PROCESS_CREATION_MITIGATION_POLICY2_XTENDED_CONTROL_FLOW_GUARD_ALWAYS_OFF = 0x00000002UL << 40; + private const int ATTACH_PARENT_PROCESS = -1; + private const int STD_OUTPUT_HANDLE = -11; + private const int STD_ERROR_HANDLE = -12; + private const uint GENERIC_READ = 0x80000000; + private const uint GENERIC_WRITE = 0x40000000; + private const uint FILE_SHARE_READ = 0x00000001; + private const uint FILE_SHARE_WRITE = 0x00000002; + private const uint OPEN_EXISTING = 3; + [STAThread] private static int Main(string[] args) { + args = NormalizeInternalArguments(args, out var isMitigatedChild); + if (args.Length == 0 && !isMitigatedChild) + { + // No arguments: open the desktop frontend. Any argument selects + // the classic CLI behavior below. + return GuiLauncher.Run(); + } + + // The executable uses the GUI subsystem, so CLI mode has to connect + // itself to a console before the first write. + EnsureCliConsole(); + Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args"); - args = NormalizeInternalArguments(args, out var isMitigatedChild); if (!isMitigatedChild && TryRunMitigatedChild(args, out var childExitCode)) { return childExitCode; @@ -101,6 +122,58 @@ internal static partial class Program return result == OrbisGen2Result.ORBIS_GEN2_OK ? 0 : 4; } + private static void EnsureCliConsole() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + // Standard handles already provided (pipes or file redirection, e.g. + // when the GUI or a script launches us): use them as-is. + if (IsHandleValid(GetStdHandle(STD_OUTPUT_HANDLE)) && IsHandleValid(GetStdHandle(STD_ERROR_HANDLE))) + { + return; + } + + // Prefer the console of the parent process (interactive terminal); + // create one only when started with arguments but no terminal at all + // (e.g. a shortcut), so usage and errors remain visible. + if (!AttachConsole(ATTACH_PARENT_PROCESS) && GetConsoleWindow() == 0) + { + _ = AllocConsole(); + } + + RebindStdHandleToConsole(STD_OUTPUT_HANDLE); + RebindStdHandleToConsole(STD_ERROR_HANDLE); + } + + private static void RebindStdHandleToConsole(int stdHandle) + { + if (IsHandleValid(GetStdHandle(stdHandle)) || GetConsoleWindow() == 0) + { + return; + } + + var conOut = CreateFileW( + "CONOUT$", + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + 0, + OPEN_EXISTING, + 0, + 0); + if (IsHandleValid(conOut)) + { + _ = SetStdHandle(stdHandle, conOut); + } + } + + private static bool IsHandleValid(nint handle) + { + return handle != 0 && handle != -1; + } + private static string[] NormalizeInternalArguments(string[] args, out bool isMitigatedChild) { isMitigatedChild = false; @@ -670,4 +743,32 @@ internal static partial class Program [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool CloseHandle(nint handle); + + [DllImport("kernel32.dll")] + private static extern nint GetConsoleWindow(); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AttachConsole(int processId); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AllocConsole(); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern nint GetStdHandle(int stdHandle); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetStdHandle(int stdHandle, nint handle); + + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern nint CreateFileW( + string fileName, + uint desiredAccess, + uint shareMode, + nint securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + nint templateFile); } diff --git a/src/SharpEmu.CLI/SharpEmu.CLI.csproj b/src/SharpEmu.CLI/SharpEmu.CLI.csproj index 18d24da..67e373c 100644 --- a/src/SharpEmu.CLI/SharpEmu.CLI.csproj +++ b/src/SharpEmu.CLI/SharpEmu.CLI.csproj @@ -7,16 +7,19 @@ SPDX-License-Identifier: GPL-2.0-or-later + - Exe + + WinExe SharpEmu win-x64;linux-x64;osx-arm64 true true - + true true enable true @@ -43,4 +46,17 @@ SPDX-License-Identifier: GPL-2.0-or-later + + + + <_GlfwPublishFiles Include="@(ResolvedFileToPublish)" + Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw'))" /> + + + true + + + + diff --git a/src/SharpEmu.CLI/packages.lock.json b/src/SharpEmu.CLI/packages.lock.json index 4f4620a..1020cc1 100644 --- a/src/SharpEmu.CLI/packages.lock.json +++ b/src/SharpEmu.CLI/packages.lock.json @@ -8,6 +8,105 @@ "resolved": "10.0.3", "contentHash": "0B6nZyCHWXnvmlB559oduOspVdNOnpNXPjhpWVMovLPAsDVG7A4jJR9rzECf67JUzxP8/ee/wA8clwIzJcWNFA==" }, + "Avalonia.Angle.Windows.Natives": { + "type": "Transitive", + "resolved": "2.1.25547.20250602", + "contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A==" + }, + "Avalonia.BuildServices": { + "type": "Transitive", + "resolved": "11.3.2", + "contentHash": "qHDToxto1e3hci5YqbG9n0Ty8mlp3zBUN5wT66wKqaDVzXyQ0do3EnRILd4Ke9jpvsktaPpgE0YjEk7hornryQ==" + }, + "Avalonia.FreeDesktop": { + "type": "Transitive", + "resolved": "11.3.18", + "contentHash": "aUwv8BNruRUOaUfMu4U3uibIUS60/rSHgGOhd8zBkLkpxY3JFJvgRbeq5ZzHIyKXCuKi18PO00YHAgCarp3wdw==", + "dependencies": { + "Avalonia": "11.3.18", + "Tmds.DBus.Protocol": "0.21.3" + } + }, + "Avalonia.Native": { + "type": "Transitive", + "resolved": "11.3.18", + "contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==", + "dependencies": { + "Avalonia": "11.3.18" + } + }, + "Avalonia.Remote.Protocol": { + "type": "Transitive", + "resolved": "11.3.18", + "contentHash": "vw+6ZfgTuu72dA9aVWn6u56t2nrBd5MoMU0wo/qI9XJAl/c0oYYphIvwLvJP1JorubQY4UE3d0ac8ULBhrGBiA==" + }, + "Avalonia.Skia": { + "type": "Transitive", + "resolved": "11.3.18", + "contentHash": "/B4aXmNRNjG8I5U/a1xJI+bIi0XO6DDzS3mBrIKlVnJRY2CyZiUeESRQXLnIU77Z9TvqkUROs+D47s085YjFtA==", + "dependencies": { + "Avalonia": "11.3.18", + "HarfBuzzSharp": "8.3.1.1", + "HarfBuzzSharp.NativeAssets.Linux": "8.3.1.1", + "HarfBuzzSharp.NativeAssets.WebAssembly": "8.3.1.1", + "SkiaSharp": "2.88.9", + "SkiaSharp.NativeAssets.Linux": "2.88.9", + "SkiaSharp.NativeAssets.WebAssembly": "2.88.9" + } + }, + "Avalonia.Win32": { + "type": "Transitive", + "resolved": "11.3.18", + "contentHash": "eioUHkM2PeLPETd1aEks3rvb9plbba6buIrNdrqCpwE/qgHKUjvRNBd5mUQfAbGgTLiAes524gB8uUMDhrsJVQ==", + "dependencies": { + "Avalonia": "11.3.18", + "Avalonia.Angle.Windows.Natives": "2.1.25547.20250602" + } + }, + "Avalonia.X11": { + "type": "Transitive", + "resolved": "11.3.18", + "contentHash": "m4Ki/G5Dovnq+6QzfS0iGbK8V77Q6oTjToMLOB0CxPCCrl3Oxywh6kIjuGJDPaN6kopMmjxlNShyQf+vPYL+JA==", + "dependencies": { + "Avalonia": "11.3.18", + "Avalonia.FreeDesktop": "11.3.18", + "Avalonia.Skia": "11.3.18" + } + }, + "HarfBuzzSharp": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "tLZN66oe/uiRPTZfrCU4i8ScVGwqHNh5MHrXj0yVf4l7Mz0FhTGnQ71RGySROTmdognAs0JtluHkL41pIabWuQ==", + "dependencies": { + "HarfBuzzSharp.NativeAssets.Win32": "8.3.1.1", + "HarfBuzzSharp.NativeAssets.macOS": "8.3.1.1" + } + }, + "HarfBuzzSharp.NativeAssets.Linux": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg==" + }, + "HarfBuzzSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ==" + }, + "HarfBuzzSharp.NativeAssets.WebAssembly": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "loJweK2u/mH/3C2zBa0ggJlITIszOkK64HLAZB7FUT670dTg965whLFYHDQo69NmC4+d9UN0icLC9VHidXaVCA==" + }, + "HarfBuzzSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA==" + }, + "MicroCom.Runtime": { + "type": "Transitive", + "resolved": "0.11.0", + "contentHash": "MEnrZ3UIiH40hjzMDsxrTyi8dtqB5ziv3iBeeU4bXsL/7NLSal9F1lZKpK+tfBRnUoDSdtcW3KufE4yhATOMCA==" + }, "Microsoft.DotNet.PlatformAbstractions": { "type": "Transitive", "resolved": "3.1.6", @@ -59,6 +158,38 @@ "Silk.NET.Windowing.Common": "2.23.0" } }, + "SkiaSharp": { + "type": "Transitive", + "resolved": "2.88.9", + "contentHash": "3MD5VHjXXieSHCleRLuaTXmL2pD0mB7CcOB1x2kA1I4bhptf4e3R27iM93264ZYuAq6mkUyX5XbcxnZvMJYc1Q==", + "dependencies": { + "SkiaSharp.NativeAssets.Win32": "2.88.9", + "SkiaSharp.NativeAssets.macOS": "2.88.9" + } + }, + "SkiaSharp.NativeAssets.Linux": { + "type": "Transitive", + "resolved": "2.88.9", + "contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==", + "dependencies": { + "SkiaSharp": "2.88.9" + } + }, + "SkiaSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "2.88.9", + "contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA==" + }, + "SkiaSharp.NativeAssets.WebAssembly": { + "type": "Transitive", + "resolved": "2.88.9", + "contentHash": "kt06RccBHSnAs2wDYdBSfsjIDbY3EpsOVqnlDgKdgvyuRA8ZFDaHRdWNx1VHjGgYzmnFCGiTJBnXFl5BqGwGnA==" + }, + "SkiaSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "2.88.9", + "contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w==" + }, "Ultz.Native.GLFW": { "type": "Transitive", "resolved": "3.4.0", @@ -73,6 +204,16 @@ "SharpEmu.Logging": "[1.0.0, )" } }, + "sharpemu.gui": { + "type": "Project", + "dependencies": { + "Avalonia": "[11.3.18, )", + "Avalonia.Desktop": "[11.3.18, )", + "Avalonia.Fonts.Inter": "[11.3.18, )", + "Avalonia.Themes.Fluent": "[11.3.18, )", + "Tmds.DBus.Protocol": "[0.21.3, )" + } + }, "sharpemu.hle": { "type": "Project" }, @@ -89,6 +230,48 @@ "sharpemu.logging": { "type": "Project" }, + "Avalonia": { + "type": "CentralTransitive", + "requested": "[11.3.18, )", + "resolved": "11.3.18", + "contentHash": "2C4UxhWUObWGgYKWic1x5BMMWGJP6SElb91WeOxs+X/iR26rtkqpxFFwwo50FXS9AyYnHfk8QKXDEfe7oT/kZA==", + "dependencies": { + "Avalonia.BuildServices": "11.3.2", + "Avalonia.Remote.Protocol": "11.3.18", + "MicroCom.Runtime": "0.11.0" + } + }, + "Avalonia.Desktop": { + "type": "CentralTransitive", + "requested": "[11.3.18, )", + "resolved": "11.3.18", + "contentHash": "bilMPa5vYiis6fbNovb6esKytBnOCEGojBa1XFegLCRHCP6g6PvZwS0XF/YOAGkENRlHG8dI7lohOpQ9bIkq1g==", + "dependencies": { + "Avalonia": "11.3.18", + "Avalonia.Native": "11.3.18", + "Avalonia.Skia": "11.3.18", + "Avalonia.Win32": "11.3.18", + "Avalonia.X11": "11.3.18" + } + }, + "Avalonia.Fonts.Inter": { + "type": "CentralTransitive", + "requested": "[11.3.18, )", + "resolved": "11.3.18", + "contentHash": "27u6hB3Y2Ue586yjfeVakberY73VNQXtuKwe/P927XG1QPlhsfmOyifLHDDpSHG85Zl1x/Xv9IZ3+tk9FnjcZQ==", + "dependencies": { + "Avalonia": "11.3.18" + } + }, + "Avalonia.Themes.Fluent": { + "type": "CentralTransitive", + "requested": "[11.3.18, )", + "resolved": "11.3.18", + "contentHash": "+Q/TJoynD0zNuu5w2gD+xcTl7GNKJFxlPYAndRLs/mTDrNbbsvv/271WyIysbMPsXSjCyBDp7RCZzQkpD6x5Bg==", + "dependencies": { + "Avalonia": "11.3.18" + } + }, "Iced": { "type": "CentralTransitive", "requested": "[1.21.0, )", @@ -133,9 +316,61 @@ "Silk.NET.Windowing.Common": "2.23.0", "Silk.NET.Windowing.Glfw": "2.23.0" } + }, + "Tmds.DBus.Protocol": { + "type": "CentralTransitive", + "requested": "[0.21.3, )", + "resolved": "0.21.3", + "contentHash": "hDwB8WsQoyALQKqIbwzS68UKdlnafDm4T/DkO/JrA/YIneP/rKv96SxYPVXeh3FP4i/SXfShrYftKLtciJAIlw==" } }, "net10.0/linux-x64": { + "Avalonia.Angle.Windows.Natives": { + "type": "Transitive", + "resolved": "2.1.25547.20250602", + "contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A==" + }, + "Avalonia.Native": { + "type": "Transitive", + "resolved": "11.3.18", + "contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==", + "dependencies": { + "Avalonia": "11.3.18" + } + }, + "HarfBuzzSharp.NativeAssets.Linux": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg==" + }, + "HarfBuzzSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ==" + }, + "HarfBuzzSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA==" + }, + "SkiaSharp.NativeAssets.Linux": { + "type": "Transitive", + "resolved": "2.88.9", + "contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==", + "dependencies": { + "SkiaSharp": "2.88.9" + } + }, + "SkiaSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "2.88.9", + "contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA==" + }, + "SkiaSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "2.88.9", + "contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w==" + }, "Ultz.Native.GLFW": { "type": "Transitive", "resolved": "3.4.0", @@ -143,6 +378,52 @@ } }, "net10.0/osx-arm64": { + "Avalonia.Angle.Windows.Natives": { + "type": "Transitive", + "resolved": "2.1.25547.20250602", + "contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A==" + }, + "Avalonia.Native": { + "type": "Transitive", + "resolved": "11.3.18", + "contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==", + "dependencies": { + "Avalonia": "11.3.18" + } + }, + "HarfBuzzSharp.NativeAssets.Linux": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg==" + }, + "HarfBuzzSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ==" + }, + "HarfBuzzSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA==" + }, + "SkiaSharp.NativeAssets.Linux": { + "type": "Transitive", + "resolved": "2.88.9", + "contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==", + "dependencies": { + "SkiaSharp": "2.88.9" + } + }, + "SkiaSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "2.88.9", + "contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA==" + }, + "SkiaSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "2.88.9", + "contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w==" + }, "Ultz.Native.GLFW": { "type": "Transitive", "resolved": "3.4.0", @@ -150,6 +431,52 @@ } }, "net10.0/win-x64": { + "Avalonia.Angle.Windows.Natives": { + "type": "Transitive", + "resolved": "2.1.25547.20250602", + "contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A==" + }, + "Avalonia.Native": { + "type": "Transitive", + "resolved": "11.3.18", + "contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==", + "dependencies": { + "Avalonia": "11.3.18" + } + }, + "HarfBuzzSharp.NativeAssets.Linux": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg==" + }, + "HarfBuzzSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ==" + }, + "HarfBuzzSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA==" + }, + "SkiaSharp.NativeAssets.Linux": { + "type": "Transitive", + "resolved": "2.88.9", + "contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==", + "dependencies": { + "SkiaSharp": "2.88.9" + } + }, + "SkiaSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "2.88.9", + "contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA==" + }, + "SkiaSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "2.88.9", + "contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w==" + }, "Ultz.Native.GLFW": { "type": "Transitive", "resolved": "3.4.0", diff --git a/src/SharpEmu.GUI/App.axaml b/src/SharpEmu.GUI/App.axaml new file mode 100644 index 0000000..1638b9d --- /dev/null +++ b/src/SharpEmu.GUI/App.axaml @@ -0,0 +1,118 @@ + + + + + + #7C5CFC + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SharpEmu.GUI/App.axaml.cs b/src/SharpEmu.GUI/App.axaml.cs new file mode 100644 index 0000000..111ef48 --- /dev/null +++ b/src/SharpEmu.GUI/App.axaml.cs @@ -0,0 +1,26 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; + +namespace SharpEmu.GUI; + +public partial class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow(); + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/src/SharpEmu.GUI/EmulatorProcess.cs b/src/SharpEmu.GUI/EmulatorProcess.cs new file mode 100644 index 0000000..f7b120d --- /dev/null +++ b/src/SharpEmu.GUI/EmulatorProcess.cs @@ -0,0 +1,645 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace SharpEmu.GUI; + +/// +/// Launches the SharpEmu CLI as a child process with the same CET/CFG mitigation +/// opt-outs the CLI would apply to its own relaunched child, while capturing +/// stdout/stderr through pipes. The CLI's internal relaunch is suppressed via +/// SHARPEMU_DISABLE_MITIGATION_RELAUNCH so output is not lost to a detached +/// console. A kill-on-close job object ties the emulator's lifetime to the GUI. +/// +internal sealed class EmulatorProcess : IDisposable +{ + private const uint EXTENDED_STARTUPINFO_PRESENT = 0x00080000; + private const uint CREATE_NO_WINDOW = 0x08000000; + private const int STARTF_USESTDHANDLES = 0x00000100; + private const uint HANDLE_FLAG_INHERIT = 0x00000001; + private const uint INFINITE = 0xFFFFFFFF; + private const int PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY = 0x00020007; + private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; + private const int JobObjectExtendedLimitInformation = 9; + private const ulong PROCESS_CREATION_MITIGATION_POLICY_CONTROL_FLOW_GUARD_ALWAYS_OFF = 0x00000002UL << 40; + private const ulong PROCESS_CREATION_MITIGATION_POLICY2_CET_USER_SHADOW_STACKS_ALWAYS_OFF = 0x00000002UL << 28; + private const ulong PROCESS_CREATION_MITIGATION_POLICY2_USER_CET_SET_CONTEXT_IP_VALIDATION_ALWAYS_OFF = 0x00000002UL << 32; + private const ulong PROCESS_CREATION_MITIGATION_POLICY2_XTENDED_CONTROL_FLOW_GUARD_ALWAYS_OFF = 0x00000002UL << 40; + + private readonly object _sync = new(); + private nint _processHandle; + private nint _jobHandle; + private Process? _fallbackProcess; + private bool _running; + private bool _disposed; + + public event Action? OutputReceived; + + public event Action? Exited; + + public bool IsRunning + { + get + { + lock (_sync) + { + return _running; + } + } + } + + public void Start(string exePath, IReadOnlyList arguments, string? workingDirectory) + { + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_running) + { + throw new InvalidOperationException("The emulator process is already running."); + } + + if (OperatingSystem.IsWindows()) + { + StartWindows(exePath, arguments, workingDirectory); + } + else + { + StartFallback(exePath, arguments, workingDirectory); + } + + _running = true; + } + } + + public void Stop() + { + lock (_sync) + { + if (!_running) + { + return; + } + + if (_processHandle != 0) + { + _ = TerminateProcess(_processHandle, 1); + } + + try + { + _fallbackProcess?.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + // Already exited. + } + } + } + + public void Dispose() + { + lock (_sync) + { + if (_disposed) + { + return; + } + + _disposed = true; + } + + Stop(); + } + + private void StartWindows(string exePath, IReadOnlyList arguments, string? workingDirectory) + { + // The CLI would otherwise relaunch itself into a mitigated child whose + // console output cannot flow through our pipes. + Environment.SetEnvironmentVariable("SHARPEMU_DISABLE_MITIGATION_RELAUNCH", "1"); + + var securityAttributes = new SECURITY_ATTRIBUTES + { + nLength = Marshal.SizeOf(), + bInheritHandle = 1, + }; + + if (!CreatePipe(out var stdoutRead, out var stdoutWrite, ref securityAttributes, 0) || + !CreatePipe(out var stderrRead, out var stderrWrite, ref securityAttributes, 0)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to create output pipes."); + } + + _ = SetHandleInformation(stdoutRead, HANDLE_FLAG_INHERIT, 0); + _ = SetHandleInformation(stderrRead, HANDLE_FLAG_INHERIT, 0); + + var startupInfoEx = new STARTUPINFOEX(); + startupInfoEx.StartupInfo.cb = Marshal.SizeOf(); + startupInfoEx.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startupInfoEx.StartupInfo.hStdOutput = stdoutWrite; + startupInfoEx.StartupInfo.hStdError = stderrWrite; + + nint attributeList = 0; + nint mitigationPolicies = 0; + try + { + nuint attributeListSize = 0; + _ = InitializeProcThreadAttributeList(0, 1, 0, ref attributeListSize); + attributeList = Marshal.AllocHGlobal((nint)attributeListSize); + if (!InitializeProcThreadAttributeList(attributeList, 1, 0, ref attributeListSize)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to initialize the process attribute list."); + } + + startupInfoEx.lpAttributeList = attributeList; + + var policy1 = PROCESS_CREATION_MITIGATION_POLICY_CONTROL_FLOW_GUARD_ALWAYS_OFF; + var policy2 = + PROCESS_CREATION_MITIGATION_POLICY2_CET_USER_SHADOW_STACKS_ALWAYS_OFF | + PROCESS_CREATION_MITIGATION_POLICY2_USER_CET_SET_CONTEXT_IP_VALIDATION_ALWAYS_OFF | + PROCESS_CREATION_MITIGATION_POLICY2_XTENDED_CONTROL_FLOW_GUARD_ALWAYS_OFF; + + mitigationPolicies = Marshal.AllocHGlobal(sizeof(ulong) * 2); + Marshal.WriteInt64(mitigationPolicies, unchecked((long)policy1)); + Marshal.WriteInt64(nint.Add(mitigationPolicies, sizeof(long)), unchecked((long)policy2)); + + if (!UpdateProcThreadAttribute( + attributeList, + 0, + PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY, + mitigationPolicies, + (nuint)(sizeof(ulong) * 2), + 0, + 0)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to apply the mitigation policy."); + } + + var currentDirectory = workingDirectory ?? Environment.CurrentDirectory; + var created = CreateProcessW( + exePath, + new StringBuilder(BuildCommandLine(exePath, arguments)), + 0, + 0, + true, + EXTENDED_STARTUPINFO_PRESENT | CREATE_NO_WINDOW, + 0, + currentDirectory, + ref startupInfoEx, + out var processInfo); + + if (!created) + { + // Some mitigation policy bits (e.g. XFG) are not supported on + // older Windows builds. Mirror the CLI's behavior and fall back + // to launching without the mitigation attribute list. + startupInfoEx.lpAttributeList = 0; + created = CreateProcessW( + exePath, + new StringBuilder(BuildCommandLine(exePath, arguments)), + 0, + 0, + true, + CREATE_NO_WINDOW, + 0, + currentDirectory, + ref startupInfoEx, + out processInfo); + } + + if (!created) + { + var error = Marshal.GetLastWin32Error(); + throw new Win32Exception(error, $"Failed to start '{exePath}' (Win32 error {error}: {new Win32Exception(error).Message})."); + } + + CloseHandle(processInfo.hThread); + _processHandle = processInfo.hProcess; + + _jobHandle = CreateJobObjectW(0, null); + if (_jobHandle != 0 && + (!TryEnableKillOnJobClose(_jobHandle) || !AssignProcessToJobObject(_jobHandle, processInfo.hProcess))) + { + CloseHandle(_jobHandle); + _jobHandle = 0; + } + + StartReaderThread(stdoutRead, isError: false); + StartReaderThread(stderrRead, isError: true); + StartExitWatcherThread(); + } + catch + { + CloseHandle(stdoutRead); + CloseHandle(stderrRead); + throw; + } + finally + { + // The child owns duplicated pipe write ends; closing ours lets the + // readers observe EOF when the child exits. + CloseHandle(stdoutWrite); + CloseHandle(stderrWrite); + + if (attributeList != 0) + { + DeleteProcThreadAttributeList(attributeList); + Marshal.FreeHGlobal(attributeList); + } + + if (mitigationPolicies != 0) + { + Marshal.FreeHGlobal(mitigationPolicies); + } + } + } + + private void StartFallback(string exePath, IReadOnlyList arguments, string? workingDirectory) + { + var startInfo = new ProcessStartInfo + { + FileName = exePath, + WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; + process.OutputDataReceived += (_, e) => + { + if (e.Data is not null) + { + OutputReceived?.Invoke(e.Data, false); + } + }; + process.ErrorDataReceived += (_, e) => + { + if (e.Data is not null) + { + OutputReceived?.Invoke(e.Data, true); + } + }; + process.Exited += (_, _) => + { + int exitCode; + try + { + exitCode = process.ExitCode; + } + catch (InvalidOperationException) + { + exitCode = -1; + } + + OnExited(exitCode); + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + _fallbackProcess = process; + } + + private void StartReaderThread(nint readHandle, bool isError) + { + var thread = new Thread(() => + { + using var stream = new FileStream(new SafeFileHandle(readHandle, ownsHandle: true), FileAccess.Read); + using var reader = new StreamReader(stream, Encoding.UTF8); + try + { + while (reader.ReadLine() is { } line) + { + OutputReceived?.Invoke(line, isError); + } + } + catch (IOException) + { + // Pipe broken on process teardown. + } + }) + { + IsBackground = true, + Name = isError ? "SharpEmu stderr reader" : "SharpEmu stdout reader", + }; + thread.Start(); + } + + private void StartExitWatcherThread() + { + var processHandle = _processHandle; + var thread = new Thread(() => + { + _ = WaitForSingleObject(processHandle, INFINITE); + var exitCode = GetExitCodeProcess(processHandle, out var rawExitCode) + ? unchecked((int)rawExitCode) + : -1; + OnExited(exitCode); + }) + { + IsBackground = true, + Name = "SharpEmu exit watcher", + }; + thread.Start(); + } + + private void OnExited(int exitCode) + { + lock (_sync) + { + if (!_running) + { + return; + } + + _running = false; + if (_processHandle != 0) + { + CloseHandle(_processHandle); + _processHandle = 0; + } + + if (_jobHandle != 0) + { + CloseHandle(_jobHandle); + _jobHandle = 0; + } + + _fallbackProcess?.Dispose(); + _fallbackProcess = null; + } + + Exited?.Invoke(exitCode); + } + + private static bool TryEnableKillOnJobClose(nint jobHandle) + { + var extendedLimitInfo = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + BasicLimitInformation = new JOBOBJECT_BASIC_LIMIT_INFORMATION + { + LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }, + }; + + var size = Marshal.SizeOf(); + var memory = Marshal.AllocHGlobal(size); + try + { + Marshal.StructureToPtr(extendedLimitInfo, memory, false); + return SetInformationJobObject( + jobHandle, + JobObjectExtendedLimitInformation, + memory, + unchecked((uint)size)); + } + finally + { + Marshal.FreeHGlobal(memory); + } + } + + private static string BuildCommandLine(string processPath, IReadOnlyList args) + { + var builder = new StringBuilder(); + builder.Append(QuoteArgument(processPath)); + for (var i = 0; i < args.Count; i++) + { + builder.Append(' '); + builder.Append(QuoteArgument(args[i])); + } + + return builder.ToString(); + } + + private static string QuoteArgument(string argument) + { + if (argument.Length == 0) + { + return "\"\""; + } + + var needsQuotes = false; + foreach (var c in argument) + { + if (char.IsWhiteSpace(c) || c == '"') + { + needsQuotes = true; + break; + } + } + + if (!needsQuotes) + { + return argument; + } + + var builder = new StringBuilder(argument.Length + 2); + builder.Append('"'); + + var backslashCount = 0; + foreach (var c in argument) + { + if (c == '\\') + { + backslashCount++; + continue; + } + + if (c == '"') + { + builder.Append('\\', (backslashCount * 2) + 1); + builder.Append('"'); + backslashCount = 0; + continue; + } + + if (backslashCount > 0) + { + builder.Append('\\', backslashCount); + backslashCount = 0; + } + + builder.Append(c); + } + + if (backslashCount > 0) + { + builder.Append('\\', backslashCount * 2); + } + + builder.Append('"'); + return builder.ToString(); + } + + [StructLayout(LayoutKind.Sequential)] + private struct SECURITY_ATTRIBUTES + { + public int nLength; + public nint lpSecurityDescriptor; + public int bInheritHandle; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct STARTUPINFO + { + public int cb; + public nint lpReserved; + public nint lpDesktop; + public nint lpTitle; + public int dwX; + public int dwY; + public int dwXSize; + public int dwYSize; + public int dwXCountChars; + public int dwYCountChars; + public int dwFillAttribute; + public int dwFlags; + public short wShowWindow; + public short cbReserved2; + public nint lpReserved2; + public nint hStdInput; + public nint hStdOutput; + public nint hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct STARTUPINFOEX + { + public STARTUPINFO StartupInfo; + public nint lpAttributeList; + } + + [StructLayout(LayoutKind.Sequential)] + private struct PROCESS_INFORMATION + { + public nint hProcess; + public nint hThread; + public int dwProcessId; + public int dwThreadId; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_LIMIT_INFORMATION + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public nuint MinimumWorkingSetSize; + public nuint MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public nint Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IO_COUNTERS + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public nuint ProcessMemoryLimit; + public nuint JobMemoryLimit; + public nuint PeakProcessMemoryUsed; + public nuint PeakJobMemoryUsed; + } + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreatePipe( + out nint hReadPipe, + out nint hWritePipe, + ref SECURITY_ATTRIBUTES lpPipeAttributes, + uint nSize); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetHandleInformation(nint hObject, uint dwMask, uint dwFlags); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool InitializeProcThreadAttributeList( + nint lpAttributeList, + int dwAttributeCount, + int dwFlags, + ref nuint lpSize); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool UpdateProcThreadAttribute( + nint lpAttributeList, + uint dwFlags, + nint attribute, + nint lpValue, + nuint cbSize, + nint lpPreviousValue, + nint lpReturnSize); + + [DllImport("kernel32.dll")] + private static extern void DeleteProcThreadAttributeList(nint lpAttributeList); + + [DllImport("kernel32.dll", EntryPoint = "CreateJobObjectW", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern nint CreateJobObjectW(nint lpJobAttributes, string? lpName); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetInformationJobObject( + nint hJob, + int jobObjectInfoClass, + nint lpJobObjectInfo, + uint cbJobObjectInfoLength); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AssignProcessToJobObject(nint hJob, nint hProcess); + + [DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateProcessW( + string applicationName, + StringBuilder commandLine, + nint processAttributes, + nint threadAttributes, + [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, + uint creationFlags, + nint environment, + string currentDirectory, + ref STARTUPINFOEX startupInfo, + out PROCESS_INFORMATION processInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint WaitForSingleObject(nint handle, uint milliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetExitCodeProcess(nint process, out uint exitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool TerminateProcess(nint process, uint exitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(nint handle); +} diff --git a/src/SharpEmu.GUI/GameEntry.cs b/src/SharpEmu.GUI/GameEntry.cs new file mode 100644 index 0000000..ea27d3b --- /dev/null +++ b/src/SharpEmu.GUI/GameEntry.cs @@ -0,0 +1,22 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.GUI; + +public sealed record GameEntry(string Name, string? TitleId, string Path, long SizeBytes) +{ + public string Detail => TitleId is not null + ? $"{TitleId} • {FormatSize(SizeBytes)}" + : $"{FormatSize(SizeBytes)} • {Path}"; + + private static string FormatSize(long bytes) + { + return bytes switch + { + >= 1L << 30 => $"{bytes / (double)(1L << 30):0.0} GiB", + >= 1L << 20 => $"{bytes / (double)(1L << 20):0.0} MiB", + >= 1L << 10 => $"{bytes / (double)(1L << 10):0.0} KiB", + _ => $"{bytes} B", + }; + } +} diff --git a/src/SharpEmu.GUI/GuiLauncher.cs b/src/SharpEmu.GUI/GuiLauncher.cs new file mode 100644 index 0000000..34bf9ec --- /dev/null +++ b/src/SharpEmu.GUI/GuiLauncher.cs @@ -0,0 +1,47 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using Avalonia; + +namespace SharpEmu.GUI; + +/// +/// Entry point for the desktop frontend, hosted by the SharpEmu executable +/// when it is started without command-line arguments. +/// +public static class GuiLauncher +{ + public static int Run() + { + try + { + BuildAvaloniaApp().StartWithClassicDesktopLifetime(Array.Empty()); + return 0; + } + catch (Exception ex) + { + WriteCrashLog(ex); + throw; + } + } + + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); + + private static void WriteCrashLog(Exception ex) + { + try + { + File.AppendAllText( + Path.Combine(AppContext.BaseDirectory, "gui-crash.log"), + $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {ex}{Environment.NewLine}{Environment.NewLine}"); + } + catch (Exception) + { + // Crash logging is best-effort. + } + } +} diff --git a/src/SharpEmu.GUI/GuiSettings.cs b/src/SharpEmu.GUI/GuiSettings.cs new file mode 100644 index 0000000..bc31717 --- /dev/null +++ b/src/SharpEmu.GUI/GuiSettings.cs @@ -0,0 +1,64 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Text.Json; + +namespace SharpEmu.GUI; + +public sealed class GuiSettings +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = true, + }; + + public List GameFolders { get; set; } = new(); + + public string LogLevel { get; set; } = "Info"; + + public int ImportTraceLimit { get; set; } + + public bool StrictDynlibResolution { get; set; } + + public string? EmulatorPath { get; set; } + + // The emulator is portable and keeps its data next to the executable; + // the GUI follows the same convention. + public static string SettingsPath => Path.Combine(AppContext.BaseDirectory, "gui-settings.json"); + + public static GuiSettings Load() + { + try + { + if (File.Exists(SettingsPath)) + { + var json = File.ReadAllText(SettingsPath); + return JsonSerializer.Deserialize(json, SerializerOptions) ?? new GuiSettings(); + } + } + catch (Exception) + { + // Corrupt or unreadable settings fall back to defaults. + } + + return new GuiSettings(); + } + + public void Save() + { + try + { + var directory = Path.GetDirectoryName(SettingsPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(SettingsPath, JsonSerializer.Serialize(this, SerializerOptions)); + } + catch (Exception) + { + // Settings persistence is best-effort. + } + } +} diff --git a/src/SharpEmu.GUI/LogLine.cs b/src/SharpEmu.GUI/LogLine.cs new file mode 100644 index 0000000..452b319 --- /dev/null +++ b/src/SharpEmu.GUI/LogLine.cs @@ -0,0 +1,8 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using Avalonia.Media; + +namespace SharpEmu.GUI; + +public sealed record LogLine(string Text, IBrush Brush); diff --git a/src/SharpEmu.GUI/MainWindow.axaml b/src/SharpEmu.GUI/MainWindow.axaml new file mode 100644 index 0000000..2ebd422 --- /dev/null +++ b/src/SharpEmu.GUI/MainWindow.axaml @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +