12 Commits
Author SHA1 Message Date
ds5678 43573a623e Bump version to 1.1.0 and update copyright year
Updated the project version from 1.0.1 to 1.1.0 and changed the copyright year to 2026 in Directory.Build.props.
2026-01-03 15:17:50 -08:00
ds5678 da64c1a6d0 Replace .sln with .slnx XML-based solution format
Replaced the traditional Visual Studio .sln file with a new .slnx file using an XML-based format. The new solution file includes all projects and solution items, modernizing the solution structure and improving compatibility with alternative tooling and editors.
2026-01-03 15:14:50 -08:00
dependabot[bot] 76e1f42c15 Bump actions/upload-artifact from 4 to 6 (#14)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-03 15:11:38 -08:00
dependabot[bot] 1e60e65ae4 Bump actions/checkout from 4 to 6 (#13)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-03 15:11:28 -08:00
Marceli Antosik efd1956ab9 Require CETCompat to be false and other improvements (#15)
* Disable CETCompat

Solves issue described here:
https://github.com/AssetRipper/AssetRipper/issues/2047

* .NET 10 and Readme Update

* Update .NET version in Directory.Build.props

* TerraFX 10.0.26100.6 and publish.yaml fix

- Changed TerraFX version from 10.0.26100.2 -> 10.0.26100.6
- Changed net9.0 path to net10.0 in publish.yaml
- Removed TargetFramework property from .csproj
- Added links to similar issues in README.md

* All Files Filter name changed in Save Dialog

To stay consistent with the standard convention and keep the behavior uniform across dialogs, I also changed All Files to All Files (*.*) in the Save File dialog.
2026-01-03 15:10:59 -08:00
ds5678 6e7a80aa80 Bump version prefix to 1.0.1
Updated the `VersionPrefix` in Directory.Build.props from
`1.0.0` to `1.0.1` to reflect a minor version increment.
2025-08-10 00:42:37 -07:00
ds5678 c4569ae1b3 Move BeginOutputReadLine and BeginErrorReadLine after Start 2025-08-10 00:32:30 -07:00
ds5678 0431d0b536 Replace gtk with zenity and kdialog 2025-08-10 00:19:59 -07:00
ds5678 801b203fca Call gtk quit 2025-08-05 23:25:47 -07:00
ds5678 5d207fbdf7 Rename web api extension methods 2025-08-05 23:11:33 -07:00
ds5678 7f3ac48095 Add web api project 2025-08-05 22:22:47 -07:00
ds5678 e4515bb59a Open Folders support for Windows 2025-08-05 13:13:50 -07:00
21 changed files with 536 additions and 238 deletions

No files matched your search

+62 -8
View File
@@ -8,7 +8,7 @@ on:
workflow_dispatch:
jobs:
publish:
publish_example:
runs-on: ${{ matrix.config.os }}
strategy:
@@ -22,11 +22,11 @@ jobs:
- { name: mac_arm64, os: macos-latest, runtime: osx-arm64, executable: AssetRipper.NativeDialogs.Example }
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
dotnet-version: 10.0.x
# https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/cross-compile#linux
- name: Install Arm64 Dependencies
@@ -52,12 +52,66 @@ jobs:
run: ls -R ./AssetRipper.NativeDialogs.Example/bin/Release/
- name: Upload
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: Example_${{ matrix.config.name }}
path: |
./AssetRipper.NativeDialogs.Example/bin/Release/net9.0/${{ matrix.config.runtime }}/publish/${{ matrix.config.executable }}
./AssetRipper.NativeDialogs.Example/bin/Release/net9.0/${{ matrix.config.runtime }}/publish/*.dll
./AssetRipper.NativeDialogs.Example/bin/Release/net9.0/${{ matrix.config.runtime }}/publish/*.so
./AssetRipper.NativeDialogs.Example/bin/Release/net9.0/${{ matrix.config.runtime }}/publish/*.dylib
./AssetRipper.NativeDialogs.Example/bin/Release/net10.0/${{ matrix.config.runtime }}/publish/${{ matrix.config.executable }}
./AssetRipper.NativeDialogs.Example/bin/Release/net10.0/${{ matrix.config.runtime }}/publish/*.dll
./AssetRipper.NativeDialogs.Example/bin/Release/net10.0/${{ matrix.config.runtime }}/publish/*.so
./AssetRipper.NativeDialogs.Example/bin/Release/net10.0/${{ matrix.config.runtime }}/publish/*.dylib
if-no-files-found: error
publish_web_api:
runs-on: ${{ matrix.config.os }}
strategy:
matrix:
config:
- { name: win_x64, os: windows-latest, runtime: win-x64, executable: AssetRipper.NativeDialogs.WebApi.exe }
- { name: win_arm64, os: windows-latest, runtime: win-arm64, executable: AssetRipper.NativeDialogs.WebApi.exe }
- { name: linux_x64, os: ubuntu-22.04, runtime: linux-x64, executable: AssetRipper.NativeDialogs.WebApi }
- { name: linux_arm64, os: ubuntu-22.04, runtime: linux-arm64, executable: AssetRipper.NativeDialogs.WebApi }
- { name: mac_x64, os: macos-latest, runtime: osx-x64, executable: AssetRipper.NativeDialogs.WebApi }
- { name: mac_arm64, os: macos-latest, runtime: osx-arm64, executable: AssetRipper.NativeDialogs.WebApi }
steps:
- uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
# https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/cross-compile#linux
- name: Install Arm64 Dependencies
if: matrix.config.runtime == 'linux-arm64'
run: |
sudo dpkg --add-architecture arm64
sudo bash -c 'cat > /etc/apt/sources.list.d/arm64.list <<EOF
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ jammy main restricted
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ jammy-updates main restricted
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports/ jammy-backports main restricted universe multiverse
EOF'
sudo sed -i -e 's/deb http/deb [arch=amd64] http/g' /etc/apt/sources.list
sudo sed -i -e 's/deb mirror/deb [arch=amd64] mirror/g' /etc/apt/sources.list
sudo apt update
sudo apt install -y clang llvm binutils-aarch64-linux-gnu gcc-aarch64-linux-gnu zlib1g-dev:arm64
- name: Publish
run: dotnet publish -c Release -r ${{ matrix.config.runtime }}
working-directory: ./AssetRipper.NativeDialogs.WebApi/
- name: List Files
shell: bash
run: ls -R ./AssetRipper.NativeDialogs.WebApi/bin/Release/
- name: Upload
uses: actions/upload-artifact@v6
with:
name: WebApi_${{ matrix.config.name }}
path: |
./AssetRipper.NativeDialogs.WebApi/bin/Release/net10.0/${{ matrix.config.runtime }}/publish/${{ matrix.config.executable }}
./AssetRipper.NativeDialogs.WebApi/bin/Release/net10.0/${{ matrix.config.runtime }}/publish/*.dll
./AssetRipper.NativeDialogs.WebApi/bin/Release/net10.0/${{ matrix.config.runtime }}/publish/*.so
./AssetRipper.NativeDialogs.WebApi/bin/Release/net10.0/${{ matrix.config.runtime }}/publish/*.dylib
if-no-files-found: error
@@ -3,6 +3,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<PublishAot>true</PublishAot>
<CETCompat>false</CETCompat>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
@@ -0,0 +1,8 @@
using System.Text.Json.Serialization;
namespace AssetRipper.NativeDialogs.WebApi;
[JsonSerializable(typeof(string[]))]
internal partial class AppJsonSerializerContext : JsonSerializerContext
{
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<PublishAot>true</PublishAot>
<CETCompat>false</CETCompat>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\AssetRipper.NativeDialogs\AssetRipper.NativeDialogs.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,77 @@
using System.Diagnostics.CodeAnalysis;
namespace AssetRipper.NativeDialogs.WebApi;
public static class Program
{
public static void Main(string[] args)
{
WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args);
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
});
WebApplication app = builder.Build();
app.MapGetFunction("/", () => Task.FromResult<string?>("Welcome to the AssetRipper Native Dialogs Web API!"));
app.MapGetFunction("/open-file", OpenFileDialog.OpenFile);
app.MapGetFunction("/open-files", OpenFileDialog.OpenFiles);
app.MapGetFunction("/open-folder", OpenFolderDialog.OpenFolder);
app.MapGetFunction("/open-folders", OpenFolderDialog.OpenFolders);
app.MapGetFunction("/save-file", SaveFileDialog.SaveFile);
app.MapGetFunction("/confirm", static async Task<string?> () =>
{
bool? result = await ConfirmationDialog.Confirm(new() { Message = "Do you acknowledge?", Type = ConfirmationDialog.Type.YesNo });
return result switch
{
true => "User acknowledged.",
false => "User did not acknowledge.",
null => "User canceled the dialog."
};
});
app.MapGetFunction("/message", static async Task<string?> () =>
{
await MessageDialog.Message("Hello, World!");
return "Message dialog shown.";
});
app.Run();
}
private static void DisableCaching(this HttpResponse response)
{
response.Headers.CacheControl = "no-store, max-age=0";
}
private static void MapGetFunction(this WebApplication app, [StringSyntax("Route")] string path, Func<Task<string?>> func)
{
app.MapGet(path, async (context) =>
{
context.Response.DisableCaching();
await JsonResult(await func.Invoke()).ExecuteAsync(context);
});
}
private static void MapGetFunction(this WebApplication app, [StringSyntax("Route")] string path, Func<Task<string[]?>> func)
{
app.MapGet(path, async (context) =>
{
context.Response.DisableCaching();
await JsonResult(await func.Invoke()).ExecuteAsync(context);
});
}
private static IResult JsonResult<T>(T? value) where T : class
{
if (value is null)
{
return Results.Text("null", "text/json");
}
else
{
return Results.Json(value, AppJsonSerializerContext.Default);
}
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
-42
View File
@@ -1,42 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.36301.6
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AssetRipper.NativeDialogs.Example", "AssetRipper.NativeDialogs.Example\AssetRipper.NativeDialogs.Example.csproj", "{637F54CA-046C-41C3-BC47-488F6CAB4A58}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AssetRipper.NativeDialogs", "AssetRipper.NativeDialogs\AssetRipper.NativeDialogs.csproj", "{B666C286-5649-456B-A356-FB91DC0DE0C3}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}"
ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig
.gitignore = .gitignore
.github\dependabot.yml = .github\dependabot.yml
Directory.Build.props = Directory.Build.props
LICENSE = LICENSE
.github\workflows\publish.yml = .github\workflows\publish.yml
README.md = README.md
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{637F54CA-046C-41C3-BC47-488F6CAB4A58}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{637F54CA-046C-41C3-BC47-488F6CAB4A58}.Debug|Any CPU.Build.0 = Debug|Any CPU
{637F54CA-046C-41C3-BC47-488F6CAB4A58}.Release|Any CPU.ActiveCfg = Release|Any CPU
{637F54CA-046C-41C3-BC47-488F6CAB4A58}.Release|Any CPU.Build.0 = Release|Any CPU
{B666C286-5649-456B-A356-FB91DC0DE0C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B666C286-5649-456B-A356-FB91DC0DE0C3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B666C286-5649-456B-A356-FB91DC0DE0C3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B666C286-5649-456B-A356-FB91DC0DE0C3}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {1D6CFC53-7372-4EF6-B954-364EC2218A9B}
EndGlobalSection
EndGlobal
+14
View File
@@ -0,0 +1,14 @@
<Solution>
<Folder Name="/Solution Items/">
<File Path=".editorconfig" />
<File Path=".github/dependabot.yml" />
<File Path=".github/workflows/publish.yml" />
<File Path=".gitignore" />
<File Path="Directory.Build.props" />
<File Path="LICENSE" />
<File Path="README.md" />
</Folder>
<Project Path="AssetRipper.NativeDialogs.Example/AssetRipper.NativeDialogs.Example.csproj" />
<Project Path="AssetRipper.NativeDialogs.WebApi/AssetRipper.NativeDialogs.WebApi.csproj" />
<Project Path="AssetRipper.NativeDialogs/AssetRipper.NativeDialogs.csproj" />
</Solution>
@@ -2,6 +2,7 @@
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<CETCompat>false</CETCompat>
<PackageId>AssetRipper.NativeDialogs</PackageId>
<PackageTags>C# file</PackageTags>
@@ -13,8 +14,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="GtkSharp" Version="3.24.24.117-develop" />
<PackageReference Include="TerraFX.Interop.Windows" Version="10.0.26100.2" />
<PackageReference Include="TerraFX.Interop.Windows" Version="10.0.26100.6" />
</ItemGroup>
<ItemGroup>
+74 -20
View File
@@ -1,4 +1,5 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.Versioning;
using TerraFX.Interop.Windows;
@@ -111,35 +112,88 @@ public static class ConfirmationDialog
}
[SupportedOSPlatform("linux")]
private static Task<bool?> ConfirmLinux(Options options)
private static async Task<bool?> ConfirmLinux(Options options)
{
if (Gtk.Global.IsSupported)
string escapedMessage = ProcessExecutor.EscapeString(options.Message);
if (await LinuxHelper.HasZenity())
{
bool? result;
GtkHelper.EnsureInitialized();
using Gtk.MessageDialog md = new(
null,
Gtk.DialogFlags.Modal,
Gtk.MessageType.Info,
options.Type == Type.OkCancel ? Gtk.ButtonsType.OkCancel : Gtk.ButtonsType.YesNo,
options.Message
);
int response = md.Run();
result = response switch
Process process = new()
{
(int)Gtk.ResponseType.Ok or (int)Gtk.ResponseType.Yes => true,
(int)Gtk.ResponseType.Cancel or (int)Gtk.ResponseType.No => false,
_ => throw new($"Unexpected response type: {response}"),
StartInfo = new()
{
FileName = "zenity",
RedirectStandardOutput = true,
RedirectStandardError = true,
},
};
return Task.FromResult(result);
process.StartInfo.ArgumentList.Add("--question");
process.StartInfo.ArgumentList.Add("--text");
process.StartInfo.ArgumentList.Add(escapedMessage);
if (options.Type != Type.YesNo)
{
string escapedTrueLabel = ProcessExecutor.EscapeString(options.TrueLabel);
string escapedFalseLabel = ProcessExecutor.EscapeString(options.FalseLabel);
process.StartInfo.ArgumentList.Add("--ok-label");
process.StartInfo.ArgumentList.Add(escapedTrueLabel);
process.StartInfo.ArgumentList.Add("--cancel-label");
process.StartInfo.ArgumentList.Add(escapedFalseLabel);
}
if (!process.Start())
{
return null; // Failed to start the process
}
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await process.WaitForExitAsync();
return process.ExitCode switch
{
0 => true, // User clicked OK or Yes
1 => false, // User clicked Cancel or No
_ => null, // An error occurred
};
}
else if (await LinuxHelper.HasKDialog())
{
Process process = new()
{
StartInfo = new()
{
FileName = "kdialog",
RedirectStandardOutput = true,
RedirectStandardError = true,
},
};
process.StartInfo.ArgumentList.Add(options.Type is Type.YesNo ? "--yesno" : "--okcancel");
process.StartInfo.ArgumentList.Add(escapedMessage);
if (!process.Start())
{
return null; // Failed to start the process
}
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await process.WaitForExitAsync();
return process.ExitCode switch
{
0 => true, // User clicked OK or Yes
1 => false, // User clicked Cancel or No
_ => null, // An error occurred
};
}
else
{
// Fallback
return Task.FromResult<bool?>(null);
return null;
}
}
}
-15
View File
@@ -1,15 +0,0 @@
namespace AssetRipper.NativeDialogs;
internal static class GtkHelper
{
private static bool isInitialized;
public static void EnsureInitialized()
{
if (!isInitialized)
{
Gtk.Application.Init();
isInitialized = true;
}
}
}
+33
View File
@@ -0,0 +1,33 @@
using System.Runtime.Versioning;
namespace AssetRipper.NativeDialogs;
[SupportedOSPlatform("linux")]
internal static class LinuxHelper
{
private static bool? hasZenity;
private static bool? hasKDialog;
public static async Task<bool> HasSupportedBackend()
{
if (hasZenity is null)
{
await HasZenity();
}
if (hasKDialog is null)
{
await HasKDialog();
}
return hasZenity == true || hasKDialog == true;
}
public static async Task<bool> HasZenity()
{
return hasZenity ??= await ProcessExecutor.HasCommand("zenity");
}
public static async Task<bool> HasKDialog()
{
return hasKDialog ??= await ProcessExecutor.HasCommand("kdialog");
}
}
+9 -16
View File
@@ -79,27 +79,20 @@ public static class MessageDialog
}
[SupportedOSPlatform("linux")]
private static Task MessageLinux(Options options)
private static async Task MessageLinux(Options options)
{
if (Gtk.Global.IsSupported)
string escapedMessage = ProcessExecutor.EscapeString(options.Message);
if (await LinuxHelper.HasZenity())
{
GtkHelper.EnsureInitialized();
using Gtk.MessageDialog md = new(
null,
Gtk.DialogFlags.Modal,
Gtk.MessageType.Info,
Gtk.ButtonsType.Ok,
options.Message
);
md.Run();
return Task.CompletedTask;
await ProcessExecutor.TryRun("zenity", "--info", "--text", escapedMessage);
}
else if (await LinuxHelper.HasKDialog())
{
await ProcessExecutor.TryRun("kdialog", "--msgbox", escapedMessage);
}
else
{
// Fallback
return Task.CompletedTask;
// Fallback: do nothing
}
}
}
+8 -4
View File
@@ -2,8 +2,12 @@
public static class NativeDialog
{
public static bool Supported =>
OperatingSystem.IsWindows() ||
OperatingSystem.IsMacOS() ||
(OperatingSystem.IsLinux() && Gtk.Global.IsSupported);
private static bool SupportedLinux { get; } = OperatingSystem.IsLinux() && LinuxHelper.HasSupportedBackend().WaitForResult();
public static bool Supported => OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() || (OperatingSystem.IsLinux() && SupportedLinux);
private static T WaitForResult<T>(this Task<T> task)
{
task.Wait();
return task.Result;
}
}
+25 -41
View File
@@ -36,7 +36,7 @@ public static class OpenFileDialog
new Span<char>(buffer).Clear();
fixed (char* bufferPtr = buffer)
fixed (char* filterPtr = "All Files\0*.*\0")
fixed (char* filterPtr = "All Files (*.*)\0*.*\0\0")
{
OPENFILENAMEW ofn = default;
ofn.lStructSize = (uint)Unsafe.SizeOf<OPENFILENAMEW>();
@@ -45,7 +45,7 @@ public static class OpenFileDialog
ofn.nMaxFile = (uint)buffer.Length;
ofn.lpstrFilter = filterPtr;
ofn.nFilterIndex = 1; // The first pair of strings has an index value of 1.
ofn.Flags = OFN.OFN_PATHMUSTEXIST | OFN.OFN_FILEMUSTEXIST;
ofn.Flags = OFN.OFN_PATHMUSTEXIST | OFN.OFN_FILEMUSTEXIST | OFN.OFN_EXPLORER;
if (Windows.GetOpenFileNameW(&ofn))
{
int length = Array.IndexOf(buffer, '\0');
@@ -69,32 +69,20 @@ public static class OpenFileDialog
}
[SupportedOSPlatform("linux")]
private static Task<string?> OpenFileLinux()
private static async Task<string?> OpenFileLinux()
{
if (Gtk.Global.IsSupported)
if (await LinuxHelper.HasZenity())
{
string? result;
GtkHelper.EnsureInitialized();
using Gtk.FileChooserNative dlg = new(
"Open a file", null,
Gtk.FileChooserAction.Open, "Open", "Cancel");
if (dlg.Run() == (int)Gtk.ResponseType.Accept)
{
result = dlg.Filename;
}
else
{
result = null; // User canceled the dialog
}
return Task.FromResult(result);
return await ProcessExecutor.TryRun("zenity", "--file-selection");
}
else if (await LinuxHelper.HasKDialog())
{
return await ProcessExecutor.TryRun("kdialog", "--getopenfilename");
}
else
{
// Fallback
return Task.FromResult<string?>(null);
return null;
}
}
@@ -127,7 +115,7 @@ public static class OpenFileDialog
new Span<char>(buffer).Clear();
fixed (char* bufferPtr = buffer)
fixed (char* filterPtr = "All Files\0*.*\0")
fixed (char* filterPtr = "All Files (*.*)\0*.*\0\0")
{
OPENFILENAMEW ofn = default;
ofn.lStructSize = (uint)Unsafe.SizeOf<OPENFILENAMEW>();
@@ -192,34 +180,30 @@ public static class OpenFileDialog
}
[SupportedOSPlatform("linux")]
private static Task<string[]?> OpenFilesLinux()
private static async Task<string[]?> OpenFilesLinux()
{
if (Gtk.Global.IsSupported)
if (await LinuxHelper.HasZenity())
{
string[]? result;
GtkHelper.EnsureInitialized();
using Gtk.FileChooserNative dlg = new(
"Open files", null,
Gtk.FileChooserAction.Open, "Open", "Cancel");
dlg.SelectMultiple = true; // Allow multiple folder selection
if (dlg.Run() == (int)Gtk.ResponseType.Accept)
string? output = await ProcessExecutor.TryRun("zenity", "--file-selection", "--multiple");
if (string.IsNullOrEmpty(output))
{
result = dlg.Filenames;
return null; // User canceled the dialog
}
else
return output.Split('|');
}
else if (await LinuxHelper.HasKDialog())
{
string? output = await ProcessExecutor.TryRun("kdialog", "--getopenfilenames");
if (string.IsNullOrEmpty(output))
{
result = null; // User canceled the dialog
return null; // User canceled the dialog
}
return Task.FromResult(result);
return output.Split('\n');
}
else
{
// Fallback
return Task.FromResult<string[]?>(null);
return null;
}
}
}
+137 -64
View File
@@ -80,33 +80,35 @@ public static class OpenFolderDialog
// Create the FileOpenDialog object.
hr = Windows.CoCreateInstance(&CLSID_FileOpenDialog, null, (uint)CLSCTX.CLSCTX_INPROC_SERVER, &IID_IFileOpenDialog, (void**)&pFileDialog);
if (!Windows.SUCCEEDED(hr))
{
return null;
}
// Set the options on the dialog.
uint dwOptions;
pFileDialog->GetOptions(&dwOptions);
pFileDialog->SetOptions(dwOptions | (uint)FILEOPENDIALOGOPTIONS.FOS_PICKFOLDERS | (uint)FILEOPENDIALOGOPTIONS.FOS_FORCEFILESYSTEM);
// Show the dialog
hr = pFileDialog->Show(default);
if (Windows.SUCCEEDED(hr))
{
// Set the options on the dialog.
uint dwOptions;
pFileDialog->GetOptions(&dwOptions);
pFileDialog->SetOptions(dwOptions | (uint)FILEOPENDIALOGOPTIONS.FOS_PICKFOLDERS | (uint)FILEOPENDIALOGOPTIONS.FOS_FORCEFILESYSTEM);
// Show the dialog
hr = pFileDialog->Show(default);
IShellItem* pItem;
hr = pFileDialog->GetResult(&pItem);
if (Windows.SUCCEEDED(hr))
{
IShellItem* pItem;
hr = pFileDialog->GetResult(&pItem);
char* pszFilePath = null;
hr = pItem->GetDisplayName(SIGDN.SIGDN_FILESYSPATH, &pszFilePath);
if (Windows.SUCCEEDED(hr))
{
char* pszFilePath = null;
hr = pItem->GetDisplayName(SIGDN.SIGDN_FILESYSPATH, &pszFilePath);
if (Windows.SUCCEEDED(hr))
{
result = new string(pszFilePath);
Windows.CoTaskMemFree(pszFilePath);
}
pItem->Release();
result = new string(pszFilePath);
Windows.CoTaskMemFree(pszFilePath);
}
pItem->Release();
}
pFileDialog->Release();
}
pFileDialog->Release();
return result;
}
@@ -118,32 +120,20 @@ public static class OpenFolderDialog
}
[SupportedOSPlatform("linux")]
private static Task<string?> OpenFolderLinux()
private static async Task<string?> OpenFolderLinux()
{
if (Gtk.Global.IsSupported)
if (await LinuxHelper.HasZenity())
{
string? result;
GtkHelper.EnsureInitialized();
using Gtk.FileChooserNative dlg = new(
"Open a folder", null,
Gtk.FileChooserAction.SelectFolder, "Open", "Cancel");
if (dlg.Run() == (int)Gtk.ResponseType.Accept)
{
result = dlg.Filename;
}
else
{
result = null; // User canceled the dialog
}
return Task.FromResult(result);
return await ProcessExecutor.TryRun("zenity", "--file-selection", "--directory");
}
else if (await LinuxHelper.HasKDialog())
{
return await ProcessExecutor.TryRun("kdialog", "--getexistingdirectory");
}
else
{
// Fallback
return Task.FromResult<string?>(null);
return null;
}
}
@@ -168,15 +158,105 @@ public static class OpenFolderDialog
}
[SupportedOSPlatform("windows")]
private static async Task<string[]?> OpenFoldersWindows()
private static Task<string[]?> OpenFoldersWindows()
{
// Todo: proper Windows implementation
string? path = await OpenFolder();
if (string.IsNullOrEmpty(path))
TaskCompletionSource<string[]?> tcs = new();
Thread thread = new(() =>
{
return null; // User canceled the dialog
try
{
// Run the STA work
Debug.Assert(OperatingSystem.IsWindows());
string[]? result = OpenFoldersWindowsInternal();
// Mark task complete
tcs.SetResult(result);
}
catch (Exception ex)
{
tcs.SetException(ex);
}
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
return tcs.Task;
}
[SupportedOSPlatform("windows")]
private unsafe static string[]? OpenFoldersWindowsInternal()
{
HRESULT hr = Windows.CoInitializeEx(null, (uint)COINIT.COINIT_APARTMENTTHREADED);
switch (hr.Value)
{
case S.S_OK:
Windows.CoUninitialize();
throw new InvalidOperationException("CoInitializeEx failed with S_OK, which should never happen because .NET is supposed to initialize the thread.");
case S.S_FALSE:
// The thread is already initialized, which is expected.
break;
case RPC.RPC_E_CHANGED_MODE:
// The thread is already initialized with a different mode, which is unexpected.
throw new InvalidOperationException("CoInitializeEx failed with RPC_E_CHANGED_MODE, which should never happen because we only call this method in STA threads.");
}
return [path];
IFileOpenDialog* pFileDialog = null;
// Assign the CLSID and IID for the FileOpenDialog.
Guid CLSID_FileOpenDialog = new("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7");
Guid IID_IFileOpenDialog = new("D57C7288-D4AD-4768-BE02-9D969532D960");
// Create the FileOpenDialog object.
hr = Windows.CoCreateInstance(&CLSID_FileOpenDialog, null, (uint)CLSCTX.CLSCTX_INPROC_SERVER, &IID_IFileOpenDialog, (void**)&pFileDialog);
if (!Windows.SUCCEEDED(hr))
{
return null;
}
// Set the options on the dialog.
uint dwOptions = default;
pFileDialog->GetOptions(&dwOptions);
pFileDialog->SetOptions(dwOptions | (uint)FILEOPENDIALOGOPTIONS.FOS_PICKFOLDERS | (uint)FILEOPENDIALOGOPTIONS.FOS_ALLOWMULTISELECT | (uint)FILEOPENDIALOGOPTIONS.FOS_FORCEFILESYSTEM);
string[]? result = null;
// Show the dialog
hr = pFileDialog->Show(default);
if (Windows.SUCCEEDED(hr))
{
IShellItemArray* pItemArray;
hr = pFileDialog->GetResults(&pItemArray);
if (Windows.SUCCEEDED(hr))
{
uint itemCount = default;
pItemArray->GetCount(&itemCount);
if (itemCount > 0)
{
result = new string[itemCount];
for (uint i = 0; i < itemCount; i++)
{
IShellItem* pItem;
hr = pItemArray->GetItemAt(i, &pItem);
if (Windows.SUCCEEDED(hr))
{
char* pszFilePath = null;
hr = pItem->GetDisplayName(SIGDN.SIGDN_FILESYSPATH, &pszFilePath);
if (Windows.SUCCEEDED(hr))
{
result[i] = new string(pszFilePath);
Windows.CoTaskMemFree(pszFilePath);
}
pItem->Release();
}
}
}
pItemArray->Release();
}
}
pFileDialog->Release();
return result;
}
[SupportedOSPlatform("macos")]
@@ -201,34 +281,27 @@ public static class OpenFolderDialog
}
[SupportedOSPlatform("linux")]
private static Task<string[]?> OpenFoldersLinux()
private static async Task<string[]?> OpenFoldersLinux()
{
if (Gtk.Global.IsSupported)
if (await LinuxHelper.HasZenity())
{
string[]? result;
GtkHelper.EnsureInitialized();
using Gtk.FileChooserNative dlg = new(
"Open folders", null,
Gtk.FileChooserAction.SelectFolder, "Open", "Cancel");
dlg.SelectMultiple = true; // Allow multiple folder selection
if (dlg.Run() == (int)Gtk.ResponseType.Accept)
string? output = await ProcessExecutor.TryRun("zenity", "--file-selection", "--directory", "--multiple");
if (string.IsNullOrEmpty(output))
{
result = dlg.Filenames;
return null; // User canceled the dialog
}
else
{
result = null; // User canceled the dialog
}
return Task.FromResult(result);
return output.Split('|');
}
else if (await LinuxHelper.HasKDialog())
{
// KDialog does not support selecting multiple directories, so we can only select one.
string? path = await OpenFolderLinux();
return string.IsNullOrEmpty(path) ? null : [path];
}
else
{
// Fallback
return Task.FromResult<string[]?>(null);
return null;
}
}
}
+23 -2
View File
@@ -1,4 +1,6 @@
using System.Diagnostics;
using System.Runtime.Versioning;
using System.Text;
namespace AssetRipper.NativeDialogs;
@@ -31,16 +33,35 @@ internal static class ProcessExecutor
static async Task<string?> TryRunProcess(Process process)
{
StringBuilder output = new();
process.OutputDataReceived += (sender, e) =>
{
if (e.Data != null)
{
output.AppendLine(e.Data);
}
};
if (!process.Start())
{
return null;
}
string? path = process.StandardOutput.ReadLine();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await process.WaitForExitAsync();
return process.ExitCode == 0 ? path : null;
string result = output.ToString().Trim();
return process.ExitCode == 0 && result.Length > 0 ? result : null;
}
}
[SupportedOSPlatform("linux")]
public static async Task<bool> HasCommand(string command)
{
string? result = await TryRun("/bin/bash", "-c", $"command -v {EscapeString(command)}");
return !string.IsNullOrEmpty(result);
}
}
+9 -21
View File
@@ -35,7 +35,7 @@ public static class SaveFileDialog
char[] buffer = ArrayPool<char>.Shared.Rent(ushort.MaxValue + 1); // Should be enough for the overwhelming majority of cases.
new Span<char>(buffer).Clear();
string filter = "All Files\0*.*\0\0";
string filter = "All Files (*.*)\0*.*\0\0";
fixed (char* bufferPtr = buffer)
fixed (char* filterPtr = filter)
@@ -71,32 +71,20 @@ public static class SaveFileDialog
}
[SupportedOSPlatform("linux")]
private static Task<string?> SaveFileLinux()
private static async Task<string?> SaveFileLinux()
{
if (Gtk.Global.IsSupported)
if (await LinuxHelper.HasZenity())
{
string? result;
GtkHelper.EnsureInitialized();
using Gtk.FileChooserNative dlg = new(
"Save a file", null,
Gtk.FileChooserAction.Save, "Save", "Cancel");
if (dlg.Run() == (int)Gtk.ResponseType.Accept)
{
result = dlg.Filename;
}
else
{
result = null; // User canceled the dialog
}
return Task.FromResult(result);
return await ProcessExecutor.TryRun("zenity", "--file-selection", "--save");
}
else if (await LinuxHelper.HasKDialog())
{
return await ProcessExecutor.TryRun("kdialog", "--getsavefilename");
}
else
{
// Fallback
return Task.FromResult<string?>(null);
return null;
}
}
}
+3 -3
View File
@@ -1,13 +1,13 @@
<Project>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>Latest</LangVersion>
<Nullable>enable</Nullable>
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
<VersionPrefix>1.0.0</VersionPrefix>
<Copyright>Copyright (c) 2025 ds5678</Copyright>
<VersionPrefix>1.1.0</VersionPrefix>
<Copyright>Copyright (c) 2026 ds5678</Copyright>
<Authors>ds5678</Authors>
<Company>AssetRipper</Company>
<Description>Cross platform library for native file dialogs.</Description>
+19
View File
@@ -1,3 +1,22 @@
# AssetRipper.NativeDialogs
A cross-platform library for picking files and folders in .NET applications.
## CETCompat requirement (Windows)
On Windows, downstream projects must disable `CETCompat`. Leaving it enabled may cause crashes when opening native file dialogs.
Add the following to the consuming project’s `.csproj`:
```xml
<PropertyGroup>
<CETCompat>false</CETCompat>
</PropertyGroup>
```
### Why?
Native file dialogs can load .NET Framework components that do not support CET mitigations. When CETCompat is enabled, this may lead to shadow stack violations and runtime crashes. CET affects the entire process, so it must be disabled by the application that uses this library.
### Similar issues in other repositories
- https://github.com/dotnet/wpf/issues/10483#issuecomment-2669897861
- https://github.com/microsoft/PowerToys/issues/37254#issue-2826006174