7 Commits
Author SHA1 Message Date
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
18 changed files with 494 additions and 182 deletions

No files matched your search

+55 -1
View File
@@ -8,7 +8,7 @@ on:
workflow_dispatch:
jobs:
publish:
publish_example:
runs-on: ${{ matrix.config.os }}
strategy:
@@ -61,3 +61,57 @@ jobs:
./AssetRipper.NativeDialogs.Example/bin/Release/net9.0/${{ matrix.config.runtime }}/publish/*.so
./AssetRipper.NativeDialogs.Example/bin/Release/net9.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@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.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@v4
with:
name: WebApi_${{ matrix.config.name }}
path: |
./AssetRipper.NativeDialogs.WebApi/bin/Release/net9.0/${{ matrix.config.runtime }}/publish/${{ matrix.config.executable }}
./AssetRipper.NativeDialogs.WebApi/bin/Release/net9.0/${{ matrix.config.runtime }}/publish/*.dll
./AssetRipper.NativeDialogs.WebApi/bin/Release/net9.0/${{ matrix.config.runtime }}/publish/*.so
./AssetRipper.NativeDialogs.WebApi/bin/Release/net9.0/${{ matrix.config.runtime }}/publish/*.dylib
if-no-files-found: error
@@ -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>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<PublishAot>true</PublishAot>
</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": "*"
}
+6
View File
@@ -18,6 +18,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
README.md = README.md
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AssetRipper.NativeDialogs.WebApi", "AssetRipper.NativeDialogs.WebApi\AssetRipper.NativeDialogs.WebApi.csproj", "{18204EF5-F8B0-4314-BF6C-3FBB2AD6FB12}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -32,6 +34,10 @@ Global
{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
{18204EF5-F8B0-4314-BF6C-3FBB2AD6FB12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{18204EF5-F8B0-4314-BF6C-3FBB2AD6FB12}.Debug|Any CPU.Build.0 = Debug|Any CPU
{18204EF5-F8B0-4314-BF6C-3FBB2AD6FB12}.Release|Any CPU.ActiveCfg = Release|Any CPU
{18204EF5-F8B0-4314-BF6C-3FBB2AD6FB12}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -13,7 +13,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="GtkSharp" Version="3.24.24.117-develop" />
<PackageReference Include="TerraFX.Interop.Windows" Version="10.0.26100.2" />
</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;
}
}
+23 -38
View File
@@ -1,4 +1,5 @@
using System.Buffers;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Runtime.Versioning;
using TerraFX.Interop.Windows;
@@ -69,32 +70,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;
}
}
@@ -192,34 +181,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);
}
}
+8 -20
View File
@@ -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 -1
View File
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
<VersionPrefix>1.0.0</VersionPrefix>
<VersionPrefix>1.0.1</VersionPrefix>
<Copyright>Copyright (c) 2025 ds5678</Copyright>
<Authors>ds5678</Authors>
<Company>AssetRipper</Company>