mirror of
https://github.com/AssetRipper/AssetRipper.NativeDialogs.git
synced 2026-11-13 18:06:41 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49e71fc0a5 | ||
|
|
0ca1fdb1dc | ||
|
|
63278e1a51 | ||
|
|
43573a623e | ||
|
|
da64c1a6d0 | ||
|
|
76e1f42c15 | ||
|
|
1e60e65ae4 | ||
|
|
efd1956ab9 | ||
|
|
6e7a80aa80 | ||
|
|
c4569ae1b3 | ||
|
|
0431d0b536 | ||
|
|
801b203fca | ||
|
|
5d207fbdf7 | ||
|
|
7f3ac48095 | ||
|
|
e4515bb59a |
No files matched your search
@@ -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": "*"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text;
|
||||
|
||||
namespace AssetRipper.NativeDialogs;
|
||||
|
||||
@@ -31,16 +33,40 @@ 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)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(command);
|
||||
if (!File.Exists("/bin/bash"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
string? result = await TryRun("/bin/bash", "-c", $"command -v {EscapeString(command)}");
|
||||
return !string.IsNullOrEmpty(result);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.1</VersionPrefix>
|
||||
<Copyright>Copyright (c) 2026 ds5678</Copyright>
|
||||
<Authors>ds5678</Authors>
|
||||
<Company>AssetRipper</Company>
|
||||
<Description>Cross platform library for native file dialogs.</Description>
|
||||
|
||||
@@ -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 `.csproj` file:
|
||||
|
||||
```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
|
||||
Reference in new issue
Block a user