diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..c8bcd5d
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,11 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(*)",
+ "Bash(mkdir -p \"C:/Projects/ImageResize/.claude/tmp-icon-work\")",
+ "Bash(magick \"C:/Projects/ImageResize/ImageResize.ContextMenu/Assets/icon.ico[8]\" \"C:/Projects/ImageResize/.claude/tmp-icon-work/existing-256.png\")",
+ "Bash(rm -rf \"C:/Projects/ImageResize/.claude/tmp-icon-work\")",
+ "Bash(magick identify *)"
+ ]
+ }
+}
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index b652d57..72eb508 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -42,7 +42,7 @@ jobs:
- name: Test
run: dotnet test ImageResize.Tests/ImageResize.Tests.csproj -c Release
- context-menu:
+ context-menu-windows:
name: ContextMenu (windows)
runs-on: windows-latest
steps:
@@ -60,3 +60,35 @@ jobs:
- name: Build
run: dotnet build ImageResize.ContextMenu/ImageResize.ContextMenu.csproj -c Release --no-restore
+
+ context-menu-macos:
+ name: ContextMenu (macos)
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Restore
+ run: dotnet restore ImageResize.ContextMenu/ImageResize.ContextMenu.csproj
+
+ - name: Build
+ run: dotnet build ImageResize.ContextMenu/ImageResize.ContextMenu.csproj -c Release --no-restore
+
+ - name: Install create-dmg
+ run: brew install create-dmg
+
+ - name: Build universal .app + .dmg
+ run: ./build-macos.sh Release
+
+ - name: Upload DMG artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: ImageResize-ContextMenu-macOS
+ path: publish/installer/*.dmg
+ if-no-files-found: error
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 5eb2d97..f632e31 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -23,6 +23,18 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ImageResize.ContextMenu/AboutWindow.xaml b/ImageResize.ContextMenu/AboutWindow.axaml
similarity index 55%
rename from ImageResize.ContextMenu/AboutWindow.xaml
rename to ImageResize.ContextMenu/AboutWindow.axaml
index b004e13..6128bc9 100644
--- a/ImageResize.ContextMenu/AboutWindow.xaml
+++ b/ImageResize.ContextMenu/AboutWindow.axaml
@@ -1,22 +1,13 @@
-
-
-
-
-
-
-
-
-
+ WindowStartupLocation="CenterOwner">
+
-
-
- github.com/YodasMyDad/ImageResize
-
-
+ Text="Minimal, cross-platform image-resize middleware for ASP.NET Core, plus a desktop companion app. Distributed under the MIT License." />
+
diff --git a/ImageResize.ContextMenu/AboutWindow.axaml.cs b/ImageResize.ContextMenu/AboutWindow.axaml.cs
new file mode 100644
index 0000000..5867447
--- /dev/null
+++ b/ImageResize.ContextMenu/AboutWindow.axaml.cs
@@ -0,0 +1,19 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+
+namespace ImageResize.ContextMenu;
+
+public partial class AboutWindow : Window
+{
+ public AboutWindow()
+ {
+ InitializeComponent();
+
+ AppVersionText.Text = $"Version {VersionInfo.AppVersion}";
+ CoreVersionText.Text = $"ImageResize.Core: {VersionInfo.CoreVersion}";
+ SkiaVersionText.Text = $"SkiaSharp: {VersionInfo.SkiaSharpVersion}";
+ RuntimeVersionText.Text = $"Runtime: {VersionInfo.Runtime}";
+ }
+
+ private void CloseButton_Click(object? sender, RoutedEventArgs e) => Close();
+}
diff --git a/ImageResize.ContextMenu/AboutWindow.xaml.cs b/ImageResize.ContextMenu/AboutWindow.xaml.cs
deleted file mode 100644
index c7a2be2..0000000
--- a/ImageResize.ContextMenu/AboutWindow.xaml.cs
+++ /dev/null
@@ -1,33 +0,0 @@
-using System.Diagnostics;
-using System.Windows;
-using System.Windows.Navigation;
-
-namespace ImageResize.ContextMenu;
-
-public partial class AboutWindow : Window
-{
- public AboutWindow()
- {
- InitializeComponent();
-
- AppVersionText.Text = $"Version {VersionInfo.AppVersion}";
- CoreVersionText.Text = $"ImageResize.Core: {VersionInfo.CoreVersion}";
- SkiaVersionText.Text = $"SkiaSharp: {VersionInfo.SkiaSharpVersion}";
- RuntimeVersionText.Text = $"Runtime: {VersionInfo.Runtime}";
- }
-
- private void CloseButton_Click(object sender, RoutedEventArgs e) => Close();
-
- private void Hyperlink_RequestNavigate(object sender, RequestNavigateEventArgs e)
- {
- try
- {
- Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true });
- }
- catch (System.ComponentModel.Win32Exception)
- {
- // User has no default browser; silently ignore.
- }
- e.Handled = true;
- }
-}
diff --git a/ImageResize.ContextMenu/App.axaml b/ImageResize.ContextMenu/App.axaml
new file mode 100644
index 0000000..5a1ec2f
--- /dev/null
+++ b/ImageResize.ContextMenu/App.axaml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/ImageResize.ContextMenu/App.axaml.cs b/ImageResize.ContextMenu/App.axaml.cs
new file mode 100644
index 0000000..8d35731
--- /dev/null
+++ b/ImageResize.ContextMenu/App.axaml.cs
@@ -0,0 +1,194 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using Avalonia.Threading;
+using ImageResize.Core.Codecs;
+using ImageResize.Core.Configuration;
+using ImageResize.Core.Interfaces;
+using ImageResize.Core.Services;
+using ImageResize.ContextMenu.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using MsBox.Avalonia;
+using MsBox.Avalonia.Enums;
+
+namespace ImageResize.ContextMenu;
+
+public partial class App : Application
+{
+ public static IServiceProvider Services { get; private set; } = null!;
+
+ private ISingleInstance? _singleInstance;
+ private CancellationTokenSource? _ipcCts;
+
+ public override void Initialize() => AvaloniaXamlLoader.Load(this);
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ ConfigureServices();
+ RegisterExceptionHandlers();
+
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ _singleInstance = SingleInstance.Create();
+
+ if (!_singleInstance.TryAcquire())
+ {
+ HandleSecondaryInstance(desktop);
+ }
+ else
+ {
+ HandlePrimaryInstance(desktop);
+ }
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+
+ private void HandleSecondaryInstance(IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ var args = Environment.GetCommandLineArgs().Skip(1).ToArray();
+ AppLog.LogStartupArgs("Secondary instance forwarding", args);
+
+ try
+ {
+ _singleInstance!.ForwardArgs(args);
+ }
+ finally
+ {
+ _singleInstance!.Dispose();
+ _singleInstance = null;
+ }
+
+ desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
+ Dispatcher.UIThread.Post(() => desktop.TryShutdown(0));
+ }
+
+ private void HandlePrimaryInstance(IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ var args = Environment.GetCommandLineArgs().Skip(1).ToArray();
+ AppLog.LogStartupArgs("Primary instance", args);
+
+ var mainWindow = Services.GetRequiredService();
+ desktop.MainWindow = mainWindow;
+
+ _ipcCts = new CancellationTokenSource();
+ _singleInstance!.StartServer(OnArgsReceivedFromSecondary, _ipcCts.Token);
+
+ desktop.Exit += OnDesktopExit;
+ }
+
+ private static void OnArgsReceivedFromSecondary(IReadOnlyList paths)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ if (Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) return;
+ if (desktop.MainWindow is not MainWindow mw) return;
+
+ mw.AddFiles(paths);
+ if (mw.WindowState == WindowState.Minimized)
+ mw.WindowState = WindowState.Normal;
+ mw.Activate();
+ mw.Focus();
+ });
+ }
+
+ private void OnDesktopExit(object? sender, ControlledApplicationLifetimeExitEventArgs e)
+ {
+ try { _ipcCts?.Cancel(); } catch (ObjectDisposedException) { }
+ _ipcCts?.Dispose();
+ _ipcCts = null;
+
+ _singleInstance?.Dispose();
+ _singleInstance = null;
+ }
+
+ private static void ConfigureServices()
+ {
+ var services = new ServiceCollection();
+
+ services.AddLogging(builder =>
+ {
+ builder.AddDebug();
+ builder.SetMinimumLevel(LogLevel.Information);
+ });
+
+ services.Configure(options =>
+ {
+ options.DefaultQuality = 99;
+ options.PngCompressionLevel = 6;
+ options.AllowUpscale = false;
+ options.Backend = ImageBackend.SkiaSharp;
+ options.WebRoot = string.Empty;
+ });
+
+ services.AddSingleton();
+ services.AddSingleton(sp =>
+ {
+ var options = sp.GetRequiredService>();
+ var codec = sp.GetRequiredService();
+ var logger = sp.GetRequiredService>();
+ var cache = new NullImageCache();
+ return new ImageResizerService(options, cache, codec, logger);
+ });
+
+ services.AddSingleton();
+ services.AddTransient();
+
+ Services = services.BuildServiceProvider();
+ }
+
+ private static void RegisterExceptionHandlers()
+ {
+ Dispatcher.UIThread.UnhandledException += OnDispatcherUnhandledException;
+ AppDomain.CurrentDomain.UnhandledException += OnAppDomainUnhandledException;
+ TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
+ }
+
+ private static void OnDispatcherUnhandledException(object? sender, DispatcherUnhandledExceptionEventArgs e)
+ {
+ AppLog.Write(e.Exception, "Dispatcher unhandled");
+
+ try
+ {
+ var owner = Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lt ? lt.MainWindow : null;
+ var box = MessageBoxManager.GetMessageBoxStandard(
+ "Resize Images",
+ $"An unexpected error occurred:{Environment.NewLine}{Environment.NewLine}{e.Exception.Message}{Environment.NewLine}{Environment.NewLine}See the log at:{Environment.NewLine}{AppPaths.GetLogFilePath()}",
+ ButtonEnum.Ok,
+ Icon.Error);
+
+ _ = owner is not null
+ ? box.ShowWindowDialogAsync(owner)
+ : box.ShowWindowAsync();
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write(ex, "Failed to show error dialog");
+ }
+
+ e.Handled = true;
+ }
+
+ private static void OnAppDomainUnhandledException(object sender, UnhandledExceptionEventArgs e)
+ {
+ if (e.ExceptionObject is Exception ex)
+ AppLog.Write(ex, $"AppDomain unhandled (terminating={e.IsTerminating})");
+ }
+
+ private static void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
+ {
+ AppLog.Write(e.Exception, "Unobserved task exception");
+ e.SetObserved();
+ }
+}
+
+internal sealed class NullImageCache : IImageCache
+{
+ public Task ExistsAsync(string path, CancellationToken ct = default) => Task.FromResult(false);
+ public string GetCachedFilePath(string relativePath, ImageResize.Models.ResizeOptions resizeOptions, string sourceSignature) => string.Empty;
+ public Task WriteAtomicallyAsync(string path, Stream data, CancellationToken ct = default) => Task.CompletedTask;
+ public Task OpenReadAsync(string path, CancellationToken ct = default) => Task.FromResult(Stream.Null);
+}
diff --git a/ImageResize.ContextMenu/App.xaml b/ImageResize.ContextMenu/App.xaml
deleted file mode 100644
index 33c3ab3..0000000
--- a/ImageResize.ContextMenu/App.xaml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
diff --git a/ImageResize.ContextMenu/App.xaml.cs b/ImageResize.ContextMenu/App.xaml.cs
deleted file mode 100644
index c961cdd..0000000
--- a/ImageResize.ContextMenu/App.xaml.cs
+++ /dev/null
@@ -1,303 +0,0 @@
-using System.IO;
-using System.IO.Pipes;
-using System.Security.Principal;
-using System.Text;
-using System.Windows;
-using System.Windows.Threading;
-using ImageResize.Core.Codecs;
-using ImageResize.Core.Configuration;
-using ImageResize.Core.Interfaces;
-using ImageResize.Core.Services;
-using ImageResize.ContextMenu.Services;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
-
-namespace ImageResize.ContextMenu;
-
-public partial class App : Application
-{
- private const string MutexName = @"Global\ImageResize.ContextMenu.SingleInstance";
- private const int ClientConnectTimeoutMs = 2000;
- private const int ClientRetryAttempts = 5;
- private const int ClientRetryDelayMs = 200;
-
- public static IServiceProvider Services { get; private set; } = null!;
-
- private Mutex? _singleInstanceMutex;
- private bool _ownsMutex;
- private CancellationTokenSource? _ipcCts;
-
- public App()
- {
- ConfigureServices();
-
- DispatcherUnhandledException += OnDispatcherUnhandledException;
- AppDomain.CurrentDomain.UnhandledException += OnAppDomainUnhandledException;
- TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
- }
-
- protected override void OnStartup(StartupEventArgs e)
- {
- base.OnStartup(e);
-
- _singleInstanceMutex = new Mutex(initiallyOwned: true, name: MutexName, createdNew: out var createdNew);
- _ownsMutex = createdNew;
-
- if (!createdNew)
- {
- ForwardArgsToRunningInstance(Environment.GetCommandLineArgs().Skip(1).ToArray());
- Shutdown();
- return;
- }
-
- StartIpcServer();
-
- var mainWindow = Services.GetRequiredService();
- var startupArgs = Environment.GetCommandLineArgs().Skip(1).ToArray();
- LogStartupArgs("Primary instance", startupArgs);
- mainWindow.Show();
- }
-
- protected override void OnExit(ExitEventArgs e)
- {
- base.OnExit(e);
- try { _ipcCts?.Cancel(); } catch (ObjectDisposedException) { }
- _ipcCts?.Dispose();
-
- if (_ownsMutex && _singleInstanceMutex is not null)
- {
- try { _singleInstanceMutex.ReleaseMutex(); }
- catch (ApplicationException) { /* not held on this thread */ }
- }
- _singleInstanceMutex?.Dispose();
- }
-
- private static string GetPipeName()
- {
- var sid = WindowsIdentity.GetCurrent()?.User?.Value ?? "Default";
- return $"ImageResize.ContextMenu.Pipe.{sid}";
- }
-
- private static string GetLogPath()
- {
- var baseDir = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
- "ImageResize", "ContextMenu");
- Directory.CreateDirectory(baseDir);
- return Path.Combine(baseDir, "log.txt");
- }
-
- private static void SafeLog(string message)
- {
- try
- {
- File.AppendAllText(
- GetLogPath(),
- $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}{Environment.NewLine}");
- }
- catch (IOException) { }
- catch (UnauthorizedAccessException) { }
- }
-
- private static void SafeLog(Exception ex, string context)
- => SafeLog($"{context}: {ex.GetType().Name}: {ex.Message}");
-
- private static void LogStartupArgs(string label, string[] args)
- {
- string cwd;
- try { cwd = Environment.CurrentDirectory; }
- catch (Exception ex) { cwd = $""; }
-
- SafeLog($"{label}. argc={args.Length} cwd='{cwd}'");
- for (var i = 0; i < args.Length; i++)
- {
- var a = args[i];
- bool exists;
- try { exists = !string.IsNullOrWhiteSpace(a) && File.Exists(a); }
- catch (Exception) { exists = false; }
- var ext = string.Empty;
- try { ext = Path.GetExtension(a ?? string.Empty); } catch (ArgumentException) { }
- SafeLog($" arg[{i}] len={(a?.Length ?? 0)} exists={exists} ext='{ext}' value='{a}'");
- }
- }
-
- private static void ForwardArgsToRunningInstance(string[] args)
- {
- LogStartupArgs("Secondary instance forwarding", args);
- if (args.Length == 0)
- return;
-
- var pipeName = GetPipeName();
-
- for (var attempt = 0; attempt < ClientRetryAttempts; attempt++)
- {
- try
- {
- using var client = new NamedPipeClientStream(
- ".", pipeName, PipeDirection.Out, PipeOptions.Asynchronous);
- client.Connect(ClientConnectTimeoutMs);
-
- using var writer = new StreamWriter(client, new UTF8Encoding(false)) { AutoFlush = true };
- foreach (var a in args)
- {
- if (File.Exists(a))
- {
- SafeLog($" -> send '{a}'");
- writer.WriteLine(a);
- }
- }
- writer.WriteLine();
- client.Flush();
- return;
- }
- catch (TimeoutException ex)
- {
- SafeLog(ex, $"IPC forward attempt {attempt + 1}: pipe busy");
- }
- catch (IOException ex)
- {
- SafeLog(ex, $"IPC forward attempt {attempt + 1}");
- }
- Thread.Sleep(ClientRetryDelayMs);
- }
-
- SafeLog("IPC forward failed after retries; giving up.");
- }
-
- private void ConfigureServices()
- {
- var services = new ServiceCollection();
-
- services.AddLogging(builder =>
- {
- builder.AddDebug();
- builder.SetMinimumLevel(LogLevel.Information);
- });
-
- services.Configure(options =>
- {
- options.DefaultQuality = 99;
- options.PngCompressionLevel = 6;
- options.AllowUpscale = false;
- options.Backend = ImageBackend.SkiaSharp;
- options.WebRoot = string.Empty;
- });
-
- services.AddSingleton();
- services.AddSingleton(sp =>
- {
- var options = sp.GetRequiredService>();
- var codec = sp.GetRequiredService();
- var logger = sp.GetRequiredService>();
- var cache = new NullImageCache();
- return new ImageResizerService(options, cache, codec, logger);
- });
-
- services.AddSingleton();
- services.AddTransient();
-
- Services = services.BuildServiceProvider();
- }
-
- private void StartIpcServer()
- {
- _ipcCts = new CancellationTokenSource();
- var ct = _ipcCts.Token;
- var pipeName = GetPipeName();
-
- _ = Task.Run(async () =>
- {
- while (!ct.IsCancellationRequested)
- {
- try
- {
- await using var server = new NamedPipeServerStream(
- pipeName,
- PipeDirection.In,
- NamedPipeServerStream.MaxAllowedServerInstances,
- PipeTransmissionMode.Byte,
- PipeOptions.Asynchronous);
-
- await server.WaitForConnectionAsync(ct).ConfigureAwait(false);
-
- using var reader = new StreamReader(server, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
- var received = new List();
- string? line;
- while ((line = await reader.ReadLineAsync(ct).ConfigureAwait(false)) != null)
- {
- if (string.IsNullOrWhiteSpace(line))
- break;
- if (File.Exists(line))
- received.Add(line);
- }
-
- if (received.Count > 0)
- {
- await Dispatcher.InvokeAsync(() =>
- {
- if (Current?.MainWindow is MainWindow mw)
- {
- SafeLog($"IPC received {received.Count} file(s). First='{received.FirstOrDefault()}'");
- mw.AddFiles(received);
- if (mw.WindowState == WindowState.Minimized)
- mw.WindowState = WindowState.Normal;
- mw.Activate();
- mw.Focus();
- }
- });
- }
- }
- catch (OperationCanceledException)
- {
- break;
- }
- catch (IOException ex)
- {
- SafeLog(ex, "IPC server I/O error");
- }
- catch (ObjectDisposedException)
- {
- break;
- }
- }
- }, ct);
- }
-
- private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
- {
- SafeLog(e.Exception, "Dispatcher unhandled");
- try
- {
- MessageBox.Show(
- MainWindow,
- $"An unexpected error occurred:\n\n{e.Exception.Message}\n\nSee the log in\n%LocalAppData%\\ImageResize\\ContextMenu\\log.txt",
- "Resize Images",
- MessageBoxButton.OK,
- MessageBoxImage.Error);
- }
- catch (InvalidOperationException) { /* app shutting down */ }
- e.Handled = true;
- }
-
- private static void OnAppDomainUnhandledException(object sender, UnhandledExceptionEventArgs e)
- {
- if (e.ExceptionObject is Exception ex)
- SafeLog(ex, $"AppDomain unhandled (terminating={e.IsTerminating})");
- }
-
- private static void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
- {
- SafeLog(e.Exception, "Unobserved task exception");
- e.SetObserved();
- }
-}
-
-// Minimal null cache implementation for desktop usage
-internal sealed class NullImageCache : IImageCache
-{
- public Task ExistsAsync(string path, CancellationToken ct = default) => Task.FromResult(false);
- public string GetCachedFilePath(string relativePath, ImageResize.Models.ResizeOptions resizeOptions, string sourceSignature) => string.Empty;
- public Task WriteAtomicallyAsync(string path, Stream data, CancellationToken ct = default) => Task.CompletedTask;
- public Task OpenReadAsync(string path, CancellationToken ct = default) => Task.FromResult(Stream.Null);
-}
diff --git a/ImageResize.ContextMenu/Assets/icon.ico b/ImageResize.ContextMenu/Assets/icon.ico
index d7696cb..f50a5e4 100644
Binary files a/ImageResize.ContextMenu/Assets/icon.ico and b/ImageResize.ContextMenu/Assets/icon.ico differ
diff --git a/ImageResize.ContextMenu/ImageResize.ContextMenu.csproj b/ImageResize.ContextMenu/ImageResize.ContextMenu.csproj
index 7935c91..7d5d0cb 100644
--- a/ImageResize.ContextMenu/ImageResize.ContextMenu.csproj
+++ b/ImageResize.ContextMenu/ImageResize.ContextMenu.csproj
@@ -1,13 +1,16 @@
WinExe
- net10.0-windows
+ net10.0
ImageResize.ContextMenu
- true
Assets\icon.ico
ImageResize.ContextMenu
ImageResize Context Menu
- $(NoWarn);CS1591;CA1848;CA1001;CA1822
+ true
+ app.manifest
+ true
+
+ $(NoWarn);CS1591;CA1848;CA1001;CA1822;AVLN3001
@@ -17,6 +20,14 @@
+
+
+
+
+
+
+
+
diff --git a/ImageResize.ContextMenu/Installer.iss b/ImageResize.ContextMenu/Installer.iss
index 1a3be85..a616540 100644
--- a/ImageResize.ContextMenu/Installer.iss
+++ b/ImageResize.ContextMenu/Installer.iss
@@ -11,7 +11,8 @@
#ifndef MyAppVersion
#define MyAppVersion GetFileVersion("..\publish\" + Platform + "\ImageResize.ContextMenu.exe")
#endif
-#define MyAppPublisher "ImageResize"
+; Must match the Authenticode certificate subject once signing is wired up (see plan).
+#define MyAppPublisher "Lee Messenger"
#define MyAppURL "https://github.com/YodasMyDad/ImageResize"
#define MyAppExeName "ImageResize.ContextMenu.exe"
@@ -31,7 +32,7 @@ LicenseFile=..\LICENSE.txt
PrivilegesRequired=admin
OutputDir=..\publish\installer
OutputBaseFilename=ImageResize-ContextMenu-Setup-{#MyAppVersion}-{#Platform}
-; SetupIconFile=Assets\icon.ico
+SetupIconFile=Assets\icon.ico
Compression=lzma
SolidCompression=yes
WizardStyle=modern
diff --git a/ImageResize.ContextMenu/MainWindow.xaml b/ImageResize.ContextMenu/MainWindow.axaml
similarity index 56%
rename from ImageResize.ContextMenu/MainWindow.xaml
rename to ImageResize.ContextMenu/MainWindow.axaml
index 4921c5f..a654e18 100644
--- a/ImageResize.ContextMenu/MainWindow.xaml
+++ b/ImageResize.ContextMenu/MainWindow.axaml
@@ -1,74 +1,18 @@
-
+ DragDrop.AllowDrop="True">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
+
-
-
-
-
+
+
+
+
+
-
-
-
-
-
+
-
-
-
-
-
-
-
+
+ TextInput="NumberValidationTextBox"/>
+ TextInput="NumberValidationTextBox"/>
@@ -174,11 +114,7 @@
-
-
-
-
-
+
-
-
-
-
-
-
+
+
-
+
-
+
@@ -257,6 +191,8 @@
Margin="0,0,12,0"
TabIndex="7"
IsCancel="True"
+ HorizontalContentAlignment="Center"
+ VerticalContentAlignment="Center"
Click="CancelButton_Click"/>
diff --git a/ImageResize.ContextMenu/MainWindow.xaml.cs b/ImageResize.ContextMenu/MainWindow.axaml.cs
similarity index 86%
rename from ImageResize.ContextMenu/MainWindow.xaml.cs
rename to ImageResize.ContextMenu/MainWindow.axaml.cs
index d0b3748..0337edf 100644
--- a/ImageResize.ContextMenu/MainWindow.xaml.cs
+++ b/ImageResize.ContextMenu/MainWindow.axaml.cs
@@ -1,10 +1,16 @@
using System.Collections;
+using System.Globalization;
using System.IO;
using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
using System.Text;
using System.Text.RegularExpressions;
-using System.Windows;
-using System.Windows.Input;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using Avalonia.Threading;
using ImageResize.ContextMenu.Models;
using ImageResize.ContextMenu.Services;
using Microsoft.CSharp.RuntimeBinder;
@@ -36,7 +42,20 @@ public MainWindow(ImageProcessor imageProcessor)
Title = $"Resize Images — v{VersionInfo.AppVersion}";
- Loaded += async (_, _) => await InitializeAsync().ConfigureAwait(false);
+ AddHandler(DragDrop.DragOverEvent, Window_DragOver);
+ AddHandler(DragDrop.DropEvent, Window_Drop);
+ KeyDown += OnKeyDown;
+
+ Opened += async (_, _) => await InitializeAsync().ConfigureAwait(false);
+ }
+
+ private void OnKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.F1)
+ {
+ ShowAbout();
+ e.Handled = true;
+ }
}
private async Task InitializeAsync()
@@ -55,15 +74,15 @@ private async Task InitializeAsync()
// No command-line args means Windows didn't pass the selection (typical for Desktop
// shell-verb invocations). Try the Desktop listview first — a stale selection in a
// background Explorer window would otherwise hijack the result.
- if (initial.Count == 0)
+ if (initial.Count == 0 && OperatingSystem.IsWindows())
initial = GetSelectedDesktopImagePaths();
- if (initial.Count == 0)
+ if (initial.Count == 0 && OperatingSystem.IsWindows())
initial = GetSelectedExplorerImagePaths();
if (initial.Count == 0)
{
- Dispatcher.Invoke(() =>
+ await Dispatcher.UIThread.InvokeAsync(() =>
{
ShowInfo("No valid image files were provided. Please select images and use the context menu, or drag images onto this window.");
ResizeButton.IsEnabled = false;
@@ -76,19 +95,19 @@ private async Task InitializeAsync()
var imageInfo = await _imageProcessor.GetImageInfoAsync(_imagePaths[0]).ConfigureAwait(false);
- Dispatcher.Invoke(() =>
+ await Dispatcher.UIThread.InvokeAsync(() =>
{
_originalWidth = imageInfo.Width;
_originalHeight = imageInfo.Height;
RefreshHeader();
- WidthBox.Text = _originalWidth.ToString(System.Globalization.CultureInfo.InvariantCulture);
- HeightBox.Text = _originalHeight.ToString(System.Globalization.CultureInfo.InvariantCulture);
+ WidthBox.Text = _originalWidth.ToString(CultureInfo.InvariantCulture);
+ HeightBox.Text = _originalHeight.ToString(CultureInfo.InvariantCulture);
ConfigureUiForMode();
});
}
catch (Exception ex)
{
- Dispatcher.Invoke(() =>
+ await Dispatcher.UIThread.InvokeAsync(() =>
{
ShowError($"Failed to load image: {ex.Message}");
ResizeButton.IsEnabled = false;
@@ -104,13 +123,13 @@ private void RefreshHeader()
OriginalDimensionsText.Text = _originalWidth > 0 && _originalHeight > 0
? $"Original: {_originalWidth} × {_originalHeight} px"
: string.Empty;
- OriginalDimensionsText.Visibility = Visibility.Visible;
+ OriginalDimensionsText.IsVisible = true;
}
else
{
FileCountText.Text = $"Resizing {_imagePaths.Count} images";
OriginalDimensionsText.Text = string.Empty;
- OriginalDimensionsText.Visibility = Visibility.Collapsed;
+ OriginalDimensionsText.IsVisible = false;
}
}
@@ -171,6 +190,7 @@ private static IEnumerable EnumerateDesktopRoots()
}
}
+ [SupportedOSPlatform("windows")]
private static List GetSelectedExplorerImagePaths()
{
var results = new List();
@@ -225,6 +245,7 @@ private static List GetSelectedExplorerImagePaths()
// Reads the Windows Desktop's current selection by talking to the Progman/WorkerW SysListView32
// directly. Needed because Shell.Application.Windows() does not expose the Desktop, and Windows
// does not pass %* command-line args to shell verbs invoked from the Desktop.
+ [SupportedOSPlatform("windows")]
private static List GetSelectedDesktopImagePaths()
{
var results = new List();
@@ -308,7 +329,6 @@ private static string ResolveDesktopItem(string name, IReadOnlyList root
var direct = Path.Combine(root, name);
if (File.Exists(direct)) return direct;
- // "Hide extensions of known file types" is on — try known image extensions.
if (string.IsNullOrEmpty(Path.GetExtension(name)))
{
foreach (var ext in ImageExtensions)
@@ -321,9 +341,9 @@ private static string ResolveDesktopItem(string name, IReadOnlyList root
return string.Empty;
}
+ [SupportedOSPlatform("windows")]
private static IntPtr FindDesktopListView()
{
- // Classic path: Progman > SHELLDLL_DefView > SysListView32
var hProgman = FindWindow("Progman", null);
if (hProgman != IntPtr.Zero)
{
@@ -335,8 +355,6 @@ private static IntPtr FindDesktopListView()
}
}
- // Wallpaper slideshow / some Win10+ configs reparent SHELLDLL_DefView into a WorkerW
- // sibling of Progman. Enumerate top-level WorkerW windows and probe each.
IntPtr found = IntPtr.Zero;
var classBuf = new char[32];
EnumWindows((hWnd, _) =>
@@ -443,20 +461,19 @@ private void ConfigureUiForMode()
{
if (_isMultipleImages)
{
- DimensionsPanel.Visibility = Visibility.Collapsed;
+ DimensionsPanel.IsVisible = false;
PercentageLabel.Text = "Size Percentage (applies to all images)";
OriginalDimensionsText.Text = string.Empty;
- OriginalDimensionsText.Visibility = Visibility.Collapsed;
+ OriginalDimensionsText.IsVisible = false;
}
else
{
- DimensionsPanel.Visibility = Visibility.Visible;
+ DimensionsPanel.IsVisible = true;
PercentageLabel.Text = "Size Percentage";
- OriginalDimensionsText.Visibility = Visibility.Visible;
+ OriginalDimensionsText.IsVisible = true;
}
}
- // Called by App when a secondary instance forwards more files
public void AddFiles(IEnumerable files)
{
if (_resizeInProgress) return;
@@ -481,15 +498,15 @@ public void AddFiles(IEnumerable files)
if (!_isMultipleImages && _originalWidth > 0 && _originalHeight > 0)
{
- WidthBox.Text = _originalWidth.ToString(System.Globalization.CultureInfo.InvariantCulture);
- HeightBox.Text = _originalHeight.ToString(System.Globalization.CultureInfo.InvariantCulture);
+ WidthBox.Text = _originalWidth.ToString(CultureInfo.InvariantCulture);
+ HeightBox.Text = _originalHeight.ToString(CultureInfo.InvariantCulture);
}
- ErrorBorder.Visibility = Visibility.Collapsed;
+ ErrorBorder.IsVisible = false;
ResizeButton.IsEnabled = true;
}
- private void PercentageSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e)
+ private void PercentageSlider_ValueChanged(object? sender, RangeBaseValueChangedEventArgs e)
{
if (_isUpdatingFromTextBox || _originalWidth == 0 || _originalHeight == 0)
return;
@@ -501,13 +518,13 @@ private void PercentageSlider_ValueChanged(object sender, RoutedPropertyChangedE
if (!_isMultipleImages)
{
var scale = percentage / 100.0;
- WidthBox.Text = ((int)Math.Round(_originalWidth * scale)).ToString(System.Globalization.CultureInfo.InvariantCulture);
- HeightBox.Text = ((int)Math.Round(_originalHeight * scale)).ToString(System.Globalization.CultureInfo.InvariantCulture);
+ WidthBox.Text = ((int)Math.Round(_originalWidth * scale)).ToString(CultureInfo.InvariantCulture);
+ HeightBox.Text = ((int)Math.Round(_originalHeight * scale)).ToString(CultureInfo.InvariantCulture);
}
_isUpdatingFromSlider = false;
}
- private void DimensionBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
+ private void DimensionBox_TextChanged(object? sender, TextChangedEventArgs e)
{
if (_isUpdatingFromSlider || _originalWidth == 0 || _originalHeight == 0 || _isMultipleImages)
return;
@@ -522,13 +539,13 @@ private void DimensionBox_TextChanged(object sender, System.Windows.Controls.Tex
_isUpdatingFromTextBox = false;
}
- private void QualitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e)
+ private void QualitySlider_ValueChanged(object? sender, RangeBaseValueChangedEventArgs e)
{
if (QualityText != null)
- QualityText.Text = ((int)e.NewValue).ToString(System.Globalization.CultureInfo.InvariantCulture);
+ QualityText.Text = ((int)e.NewValue).ToString(CultureInfo.InvariantCulture);
}
- private async void ResizeButton_Click(object sender, RoutedEventArgs e)
+ private async void ResizeButton_Click(object? sender, RoutedEventArgs e)
{
if (_imagePaths.Count == 0)
{
@@ -544,8 +561,8 @@ private async void ResizeButton_Click(object sender, RoutedEventArgs e)
{
ResizeButton.IsEnabled = false;
CancelButton.Content = "Stop";
- ProgressPanel.Visibility = Visibility.Visible;
- ErrorBorder.Visibility = Visibility.Collapsed;
+ ProgressPanel.IsVisible = true;
+ ErrorBorder.IsVisible = false;
ProgressBar.Value = 0;
ProgressText.Text = "Starting…";
ProgressEtaText.Text = string.Empty;
@@ -617,7 +634,7 @@ private void ResetPostRunUi()
{
ResizeButton.IsEnabled = true;
CancelButton.Content = "Cancel";
- ProgressPanel.Visibility = Visibility.Collapsed;
+ ProgressPanel.IsVisible = false;
}
private static string FormatDuration(TimeSpan ts)
@@ -639,7 +656,7 @@ private static string FormatBatchFailure(BatchResizeException ex)
return $"{header}{Environment.NewLine}{failures}";
}
- private void CancelButton_Click(object sender, RoutedEventArgs e)
+ private void CancelButton_Click(object? sender, RoutedEventArgs e)
{
if (_resizeInProgress)
{
@@ -653,57 +670,71 @@ private void CancelButton_Click(object sender, RoutedEventArgs e)
}
}
- private void AboutButton_Click(object sender, RoutedEventArgs e) => ShowAbout();
-
- private void Help_Executed(object sender, System.Windows.Input.ExecutedRoutedEventArgs e) => ShowAbout();
+ private void AboutButton_Click(object? sender, RoutedEventArgs e) => ShowAbout();
public void ShowAbout()
{
- var dlg = new AboutWindow { Owner = this };
- dlg.ShowDialog();
+ var dlg = new AboutWindow();
+ _ = dlg.ShowDialog(this);
}
- private void Window_DragOver(object sender, DragEventArgs e)
+ private void Window_DragOver(object? sender, DragEventArgs e)
{
- e.Effects = HasImageFiles(e.Data) ? DragDropEffects.Copy : DragDropEffects.None;
+ e.DragEffects = HasImageFiles(e.Data) ? DragDropEffects.Copy : DragDropEffects.None;
e.Handled = true;
}
- private void Window_Drop(object sender, DragEventArgs e)
+ private void Window_Drop(object? sender, DragEventArgs e)
{
if (_resizeInProgress) return;
- if (e.Data.GetData(DataFormats.FileDrop) is not string[] files) return;
+
+ var files = ExtractImageFilePaths(e.Data);
+ if (files.Count == 0) return;
AddFiles(files);
e.Handled = true;
}
private static bool HasImageFiles(IDataObject data)
+ => ExtractImageFilePaths(data).Count > 0;
+
+ private static List ExtractImageFilePaths(IDataObject data)
{
- if (data.GetData(DataFormats.FileDrop) is not string[] files)
- return false;
- return files.Any(IsImageFile);
+ var result = new List();
+ var files = data.GetFiles();
+ if (files == null) return result;
+
+ foreach (var item in files)
+ {
+ var path = item.TryGetLocalPath();
+ if (!string.IsNullOrEmpty(path) && IsImageFile(path))
+ result.Add(path);
+ }
+ return result;
}
private void ShowInfo(string message)
{
ErrorHeading.Text = "Info";
ErrorText.Text = message;
- ErrorBorder.Visibility = Visibility.Visible;
+ ErrorBorder.IsVisible = true;
}
private void ShowError(string message)
{
ErrorHeading.Text = "Error";
ErrorText.Text = message;
- ErrorBorder.Visibility = Visibility.Visible;
+ ErrorBorder.IsVisible = true;
}
private static bool IsImageFile(string path)
=> ImageExtensions.Contains(Path.GetExtension(path));
- private void NumberValidationTextBox(object sender, TextCompositionEventArgs e)
- => e.Handled = NumberOnly.IsMatch(e.Text);
+ private void NumberValidationTextBox(object? sender, TextInputEventArgs e)
+ {
+ if (!string.IsNullOrEmpty(e.Text) && NumberOnly.IsMatch(e.Text))
+ e.Handled = true;
+ }
protected override void OnClosed(EventArgs e)
{
diff --git a/ImageResize.ContextMenu/Program.cs b/ImageResize.ContextMenu/Program.cs
new file mode 100644
index 0000000..da33093
--- /dev/null
+++ b/ImageResize.ContextMenu/Program.cs
@@ -0,0 +1,16 @@
+using Avalonia;
+
+namespace ImageResize.ContextMenu;
+
+internal static class Program
+{
+ [STAThread]
+ public static int Main(string[] args)
+ => BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
+
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+}
diff --git a/ImageResize.ContextMenu/README.md b/ImageResize.ContextMenu/README.md
index 675fddc..5b5d1b2 100644
--- a/ImageResize.ContextMenu/README.md
+++ b/ImageResize.ContextMenu/README.md
@@ -1,6 +1,8 @@
# ImageResize Context Menu
-Windows 11 context menu integration for quick image resizing. Right-click any image (or multiple images) and resize them instantly.
+Desktop integration for quick image resizing on **Windows 11** (Explorer context menu) and **macOS** (Finder → Services). Right-click any image — or multiple images — and resize them instantly.
+
+Built on Avalonia UI, so the same binary (modulo per-OS packaging) runs natively on both platforms. No WPF, no Electron.
## Features
@@ -15,10 +17,10 @@ Windows 11 context menu integration for quick image resizing. Right-click any im
## Quick Start
-### For Users (Installing)
+### Windows
**Option 1: Installer (Recommended)**
-1. Download `ImageResize-ContextMenu-Setup-1.0.0.exe`
+1. Download `ImageResize-ContextMenu-Setup--x64.exe`
2. Double-click to install
3. Follow the wizard
4. Right-click any image → "Resize Images..."
@@ -27,9 +29,40 @@ Windows 11 context menu integration for quick image resizing. Right-click any im
```powershell
# From repository root
.\build-installer.ps1
-# Then run: publish\installer\ImageResize-ContextMenu-Setup-1.0.0.exe
+# Then run: publish\installer\ImageResize-ContextMenu-Setup--x64.exe
+```
+
+### macOS
+
+1. Download `ImageResize-ContextMenu-Setup--universal.dmg`
+2. Open the DMG, drag **ImageResize.app** into `Applications`
+3. Double-click **Resize Images.workflow** — macOS will offer to install it as a Finder Quick Action (copied to `~/Library/Services`)
+4. Right-click any image file in Finder → **Services → Resize Images**
+
+**First launch: Gatekeeper warning.** The app is not Authenticode-signed or notarised (no paid Apple Developer Program account), so Gatekeeper will refuse the first launch with _"ImageResize can't be opened because it is from an unidentified developer"_. Bypass it once:
+
+- In Finder: **right-click** `ImageResize.app` (or the Quick Action) → **Open**, then click **Open** again in the dialog. Subsequent launches are unprompted.
+- Alternative: **System Settings → Privacy & Security → Open Anyway** after the first blocked launch.
+- Or strip the quarantine flag from a terminal: `xattr -dr com.apple.quarantine /Applications/ImageResize.app`.
+
+**To build from source on macOS:**
+```bash
+./build-macos.sh # produces publish/installer/ImageResize-ContextMenu-Setup--universal.dmg
+brew install create-dmg # (optional) nicer DMG layout; falls back to hdiutil without it
```
+The build produces a universal binary (arm64 + x86_64 via `lipo`), so one DMG works on both Apple Silicon and Intel Macs.
+
+### First install on Windows: SmartScreen warning
+
+On first run you'll likely see a **"Windows protected your PC — Unknown publisher"** dialog. This is because the installer isn't yet Authenticode-signed (code signing is on the roadmap). The installer is safe to run; SmartScreen flags any unsigned installer from a publisher it doesn't recognise.
+
+To proceed, use any of:
+
+- Click **More info** → **Run anyway** in the SmartScreen dialog.
+- Right-click the downloaded `.exe` → **Properties** → tick **Unblock** → **OK**, then double-click.
+- In PowerShell, before running: `Unblock-File -Path .\ImageResize-ContextMenu-Setup-*.exe`
+
### Usage
**Single Image:**
@@ -55,10 +88,9 @@ Windows 11 context menu integration for quick image resizing. Right-click any im
### Prerequisites
-- Windows 11
-- .NET 9.0 SDK
-- Visual Studio 2022 (optional)
-- Inno Setup 6 (for building installer)
+- .NET 10 SDK
+- **Windows builds:** Windows 10 (1809)+ and Inno Setup 6 for the installer
+- **macOS builds:** macOS 11+ with Xcode command-line tools (`xcode-select --install`) for `lipo`; `brew install create-dmg` for prettier DMGs (optional)
## Creating the Windows Installer
@@ -147,11 +179,12 @@ All methods work correctly and remove:
### Dependencies
-- **ImageResize.Core**: NuGet package (v2.0.0) for image processing
-- **SkiaSharp**: High-performance image codec
-- **Microsoft.Extensions.DependencyInjection**: Service container
-- **Microsoft.Extensions.Logging**: Logging infrastructure
-- **.NET 9.0 Desktop Runtime**: Required for WPF
+- **ImageResize.Core**: image processing engine (project reference)
+- **Avalonia UI 11.2.x**: cross-platform XAML UI toolkit
+- **SkiaSharp**: high-performance image codec (pulled in by Core)
+- **MessageBox.Avalonia**: standard dialog primitives
+- **Microsoft.Extensions.DependencyInjection / Logging**: service container + logging
+- **.NET 10 runtime**: self-contained in Mac builds; required system-wide on Windows
### Registry Entries
diff --git a/ImageResize.ContextMenu/Services/AppLog.cs b/ImageResize.ContextMenu/Services/AppLog.cs
new file mode 100644
index 0000000..0568687
--- /dev/null
+++ b/ImageResize.ContextMenu/Services/AppLog.cs
@@ -0,0 +1,40 @@
+using System.IO;
+
+namespace ImageResize.ContextMenu.Services;
+
+internal static class AppLog
+{
+ public static void Write(string message)
+ {
+ try
+ {
+ File.AppendAllText(
+ AppPaths.GetLogFilePath(),
+ $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}{Environment.NewLine}");
+ }
+ catch (IOException) { }
+ catch (UnauthorizedAccessException) { }
+ }
+
+ public static void Write(Exception ex, string context)
+ => Write($"{context}: {ex.GetType().Name}: {ex.Message}");
+
+ public static void LogStartupArgs(string label, string[] args)
+ {
+ string cwd;
+ try { cwd = Environment.CurrentDirectory; }
+ catch (Exception ex) { cwd = $""; }
+
+ Write($"{label}. argc={args.Length} cwd='{cwd}'");
+ for (var i = 0; i < args.Length; i++)
+ {
+ var a = args[i];
+ bool exists;
+ try { exists = !string.IsNullOrWhiteSpace(a) && File.Exists(a); }
+ catch (Exception) { exists = false; }
+ var ext = string.Empty;
+ try { ext = Path.GetExtension(a ?? string.Empty); } catch (ArgumentException) { }
+ Write($" arg[{i}] len={(a?.Length ?? 0)} exists={exists} ext='{ext}' value='{a}'");
+ }
+ }
+}
diff --git a/ImageResize.ContextMenu/Services/AppPaths.cs b/ImageResize.ContextMenu/Services/AppPaths.cs
new file mode 100644
index 0000000..7a3aacd
--- /dev/null
+++ b/ImageResize.ContextMenu/Services/AppPaths.cs
@@ -0,0 +1,40 @@
+using System.IO;
+
+namespace ImageResize.ContextMenu.Services;
+
+internal static class AppPaths
+{
+ private const string AppFolder = "ImageResize";
+ private const string SubFolder = "ContextMenu";
+
+ public static string GetUserDataDir()
+ {
+ string root;
+ if (OperatingSystem.IsWindows())
+ {
+ root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ }
+ else if (OperatingSystem.IsMacOS())
+ {
+ var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ root = Path.Combine(home, "Library", "Application Support");
+ }
+ else
+ {
+ var xdg = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
+ root = !string.IsNullOrEmpty(xdg)
+ ? xdg
+ : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "share");
+ }
+
+ var dir = Path.Combine(root, AppFolder, SubFolder);
+ Directory.CreateDirectory(dir);
+ return dir;
+ }
+
+ public static string GetLogFilePath() => Path.Combine(GetUserDataDir(), "log.txt");
+
+ public static string GetLockFilePath() => Path.Combine(GetUserDataDir(), "instance.lock");
+
+ public static string GetUnixSocketPath() => Path.Combine(GetUserDataDir(), "instance.sock");
+}
diff --git a/ImageResize.ContextMenu/Services/ISingleInstance.cs b/ImageResize.ContextMenu/Services/ISingleInstance.cs
new file mode 100644
index 0000000..934c6e3
--- /dev/null
+++ b/ImageResize.ContextMenu/Services/ISingleInstance.cs
@@ -0,0 +1,25 @@
+namespace ImageResize.ContextMenu.Services;
+
+///
+/// Cross-platform single-instance coordinator. On startup, call :
+/// a true return means this process is primary and should continue running;
+/// a false return means another process is already primary — forward the command-line
+/// args with and exit.
+/// Primary instances call to receive forwarded args from secondaries.
+///
+internal interface ISingleInstance : IDisposable
+{
+ bool TryAcquire();
+
+ void ForwardArgs(IReadOnlyList args);
+
+ void StartServer(Action> onArgsReceived, CancellationToken ct);
+}
+
+internal static class SingleInstance
+{
+ public static ISingleInstance Create()
+ => OperatingSystem.IsWindows()
+ ? new WindowsSingleInstance()
+ : new UnixSingleInstance();
+}
diff --git a/ImageResize.ContextMenu/Services/UnixSingleInstance.cs b/ImageResize.ContextMenu/Services/UnixSingleInstance.cs
new file mode 100644
index 0000000..51d5371
--- /dev/null
+++ b/ImageResize.ContextMenu/Services/UnixSingleInstance.cs
@@ -0,0 +1,175 @@
+using System.IO;
+using System.Net.Sockets;
+using System.Text;
+
+namespace ImageResize.ContextMenu.Services;
+
+///
+/// Unix single-instance coordinator: an exclusive lockfile (held for the lifetime of the
+/// primary process) + a Unix domain socket that secondaries connect to when forwarding args.
+///
+internal sealed class UnixSingleInstance : ISingleInstance
+{
+ private const int ClientConnectTimeoutMs = 2000;
+ private const int ClientRetryAttempts = 5;
+ private const int ClientRetryDelayMs = 200;
+
+ private FileStream? _lock;
+ private Socket? _server;
+
+ public bool TryAcquire()
+ {
+ var path = AppPaths.GetLockFilePath();
+ try
+ {
+ _lock = new FileStream(
+ path,
+ FileMode.OpenOrCreate,
+ FileAccess.ReadWrite,
+ FileShare.None,
+ bufferSize: 1,
+ FileOptions.DeleteOnClose);
+ return true;
+ }
+ catch (IOException)
+ {
+ return false;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return false;
+ }
+ }
+
+ public void ForwardArgs(IReadOnlyList args)
+ {
+ if (args.Count == 0) return;
+
+ var socketPath = AppPaths.GetUnixSocketPath();
+ var endpoint = new UnixDomainSocketEndPoint(socketPath);
+
+ for (var attempt = 0; attempt < ClientRetryAttempts; attempt++)
+ {
+ Socket? client = null;
+ try
+ {
+ client = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
+ using var connectCts = new CancellationTokenSource(ClientConnectTimeoutMs);
+ try
+ {
+ client.ConnectAsync(endpoint, connectCts.Token).AsTask().GetAwaiter().GetResult();
+ }
+ catch (OperationCanceledException)
+ {
+ throw new TimeoutException("Unix socket connect timed out");
+ }
+
+ using var stream = new NetworkStream(client, ownsSocket: false);
+ using var writer = new StreamWriter(stream, new UTF8Encoding(false)) { AutoFlush = true };
+ foreach (var a in args)
+ {
+ if (File.Exists(a))
+ {
+ AppLog.Write($" -> send '{a}'");
+ writer.WriteLine(a);
+ }
+ }
+ writer.WriteLine();
+ return;
+ }
+ catch (TimeoutException ex)
+ {
+ AppLog.Write(ex, $"IPC forward attempt {attempt + 1}: socket busy");
+ }
+ catch (SocketException ex)
+ {
+ AppLog.Write(ex, $"IPC forward attempt {attempt + 1}");
+ }
+ catch (IOException ex)
+ {
+ AppLog.Write(ex, $"IPC forward attempt {attempt + 1}");
+ }
+ finally
+ {
+ try { client?.Dispose(); } catch (ObjectDisposedException) { }
+ }
+ Thread.Sleep(ClientRetryDelayMs);
+ }
+
+ AppLog.Write("IPC forward failed after retries; giving up.");
+ }
+
+ public void StartServer(Action> onArgsReceived, CancellationToken ct)
+ {
+ var socketPath = AppPaths.GetUnixSocketPath();
+
+ // A stale socket file from a previous crash would block bind() with EADDRINUSE — remove it.
+ // We hold the exclusive lockfile, so no live primary is using this path.
+ try { if (File.Exists(socketPath)) File.Delete(socketPath); }
+ catch (IOException ex) { AppLog.Write(ex, "Removing stale socket"); }
+
+ _server = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
+ _server.Bind(new UnixDomainSocketEndPoint(socketPath));
+ _server.Listen(backlog: 8);
+
+ ct.Register(() =>
+ {
+ try { _server?.Close(); } catch (ObjectDisposedException) { }
+ try { if (File.Exists(socketPath)) File.Delete(socketPath); } catch (IOException) { }
+ });
+
+ _ = Task.Run(async () =>
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ Socket? client = null;
+ try
+ {
+ client = await _server.AcceptAsync(ct).ConfigureAwait(false);
+ using var stream = new NetworkStream(client, ownsSocket: true);
+ using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
+
+ var received = new List();
+ string? line;
+ while ((line = await reader.ReadLineAsync(ct).ConfigureAwait(false)) != null)
+ {
+ if (string.IsNullOrWhiteSpace(line)) break;
+ if (File.Exists(line)) received.Add(line);
+ }
+
+ if (received.Count > 0)
+ {
+ AppLog.Write($"IPC received {received.Count} file(s). First='{received[0]}'");
+ onArgsReceived(received);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ catch (SocketException ex)
+ {
+ AppLog.Write(ex, "IPC server socket error");
+ }
+ catch (IOException ex)
+ {
+ AppLog.Write(ex, "IPC server I/O error");
+ }
+ catch (ObjectDisposedException)
+ {
+ break;
+ }
+ }
+ }, ct);
+ }
+
+ public void Dispose()
+ {
+ try { _server?.Close(); } catch (ObjectDisposedException) { }
+ _server = null;
+
+ // FileOptions.DeleteOnClose removes the lockfile; closing the stream releases the exclusive lock.
+ try { _lock?.Dispose(); } catch (IOException) { }
+ _lock = null;
+ }
+}
diff --git a/ImageResize.ContextMenu/Services/WindowsSingleInstance.cs b/ImageResize.ContextMenu/Services/WindowsSingleInstance.cs
new file mode 100644
index 0000000..d212ec7
--- /dev/null
+++ b/ImageResize.ContextMenu/Services/WindowsSingleInstance.cs
@@ -0,0 +1,133 @@
+using System.IO;
+using System.IO.Pipes;
+using System.Runtime.Versioning;
+using System.Security.Principal;
+using System.Text;
+
+namespace ImageResize.ContextMenu.Services;
+
+[SupportedOSPlatform("windows")]
+internal sealed class WindowsSingleInstance : ISingleInstance
+{
+ private const string MutexName = @"Global\ImageResize.ContextMenu.SingleInstance";
+ private const int ClientConnectTimeoutMs = 2000;
+ private const int ClientRetryAttempts = 5;
+ private const int ClientRetryDelayMs = 200;
+
+ private Mutex? _mutex;
+ private bool _ownsMutex;
+
+ public bool TryAcquire()
+ {
+ _mutex = new Mutex(initiallyOwned: true, name: MutexName, createdNew: out var createdNew);
+ _ownsMutex = createdNew;
+ return createdNew;
+ }
+
+ public void ForwardArgs(IReadOnlyList args)
+ {
+ if (args.Count == 0) return;
+
+ var pipeName = GetPipeName();
+ for (var attempt = 0; attempt < ClientRetryAttempts; attempt++)
+ {
+ try
+ {
+ using var client = new NamedPipeClientStream(
+ ".", pipeName, PipeDirection.Out, PipeOptions.Asynchronous);
+ client.Connect(ClientConnectTimeoutMs);
+
+ using var writer = new StreamWriter(client, new UTF8Encoding(false)) { AutoFlush = true };
+ foreach (var a in args)
+ {
+ if (File.Exists(a))
+ {
+ AppLog.Write($" -> send '{a}'");
+ writer.WriteLine(a);
+ }
+ }
+ writer.WriteLine();
+ client.Flush();
+ return;
+ }
+ catch (TimeoutException ex)
+ {
+ AppLog.Write(ex, $"IPC forward attempt {attempt + 1}: pipe busy");
+ }
+ catch (IOException ex)
+ {
+ AppLog.Write(ex, $"IPC forward attempt {attempt + 1}");
+ }
+ Thread.Sleep(ClientRetryDelayMs);
+ }
+
+ AppLog.Write("IPC forward failed after retries; giving up.");
+ }
+
+ public void StartServer(Action> onArgsReceived, CancellationToken ct)
+ {
+ var pipeName = GetPipeName();
+
+ _ = Task.Run(async () =>
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ try
+ {
+ await using var server = new NamedPipeServerStream(
+ pipeName,
+ PipeDirection.In,
+ NamedPipeServerStream.MaxAllowedServerInstances,
+ PipeTransmissionMode.Byte,
+ PipeOptions.Asynchronous);
+
+ await server.WaitForConnectionAsync(ct).ConfigureAwait(false);
+
+ using var reader = new StreamReader(server, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
+ var received = new List();
+ string? line;
+ while ((line = await reader.ReadLineAsync(ct).ConfigureAwait(false)) != null)
+ {
+ if (string.IsNullOrWhiteSpace(line)) break;
+ if (File.Exists(line)) received.Add(line);
+ }
+
+ if (received.Count > 0)
+ {
+ AppLog.Write($"IPC received {received.Count} file(s). First='{received[0]}'");
+ onArgsReceived(received);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ catch (IOException ex)
+ {
+ AppLog.Write(ex, "IPC server I/O error");
+ }
+ catch (ObjectDisposedException)
+ {
+ break;
+ }
+ }
+ }, ct);
+ }
+
+ public void Dispose()
+ {
+ if (_ownsMutex && _mutex is not null)
+ {
+ try { _mutex.ReleaseMutex(); }
+ catch (ApplicationException) { }
+ }
+ _mutex?.Dispose();
+ _mutex = null;
+ }
+
+ private static string GetPipeName()
+ {
+ var sid = WindowsIdentity.GetCurrent()?.User?.Value ?? "Default";
+ return $"ImageResize.ContextMenu.Pipe.{sid}";
+ }
+}
diff --git a/ImageResize.ContextMenu/app.manifest b/ImageResize.ContextMenu/app.manifest
new file mode 100644
index 0000000..504a4dd
--- /dev/null
+++ b/ImageResize.ContextMenu/app.manifest
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+ true/pm
+ PerMonitorV2
+
+
+
diff --git a/ImageResize.ContextMenu/macos/Info.plist b/ImageResize.ContextMenu/macos/Info.plist
new file mode 100644
index 0000000..9b9e8d7
--- /dev/null
+++ b/ImageResize.ContextMenu/macos/Info.plist
@@ -0,0 +1,52 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleDisplayName
+ Resize Images
+ CFBundleExecutable
+ ImageResize.ContextMenu
+ CFBundleIdentifier
+ co.aptitude.ImageResize
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ ImageResize
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ {VERSION}
+ CFBundleVersion
+ {VERSION}
+ CFBundleIconFile
+ icon.icns
+ LSMinimumSystemVersion
+ 11.0
+ LSApplicationCategoryType
+ public.app-category.graphics-design
+ NSHighResolutionCapable
+
+ CFBundleDocumentTypes
+
+
+ CFBundleTypeName
+ Image
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ public.jpeg
+ public.png
+ com.compuserve.gif
+ public.webp
+ com.microsoft.bmp
+ public.tiff
+
+
+
+
+
diff --git a/ImageResize.ContextMenu/macos/ResizeImages.workflow/Contents/document.wflow b/ImageResize.ContextMenu/macos/ResizeImages.workflow/Contents/document.wflow
new file mode 100644
index 0000000..18a0315
--- /dev/null
+++ b/ImageResize.ContextMenu/macos/ResizeImages.workflow/Contents/document.wflow
@@ -0,0 +1,152 @@
+
+
+
+
+ AMApplicationBuild
+ 519
+ AMApplicationVersion
+ 2.10
+ AMDocumentVersion
+ 2
+ actions
+
+
+ action
+
+ AMAccepts
+
+ Container
+ List
+ Optional
+
+ Types
+
+ com.apple.cocoa.path
+
+
+ AMActionVersion
+ 2.0.3
+ AMApplication
+
+ Automator
+
+ AMParameterProperties
+
+ COMMAND_STRING
+
+ CheckedForUserDefaultShell
+
+ inputMethod
+
+ shell
+
+ source
+
+
+ AMProvides
+
+ Container
+ List
+ Types
+
+ com.apple.cocoa.string
+
+
+ ActionBundlePath
+ /System/Library/Automator/Run Shell Script.action
+ ActionName
+ Run Shell Script
+ ActionParameters
+
+ COMMAND_STRING
+ open -n -a "/Applications/ImageResize.app" --args "$@"
+ CheckedForUserDefaultShell
+
+ inputMethod
+ 1
+ shell
+ /bin/bash
+ source
+
+
+ BundleIdentifier
+ com.apple.RunShellScript
+ CFBundleVersion
+ 2.0.3
+ CanShowSelectedItemsWhenRun
+
+ CanShowWhenRun
+
+ Category
+
+ AMCategoryUtilities
+
+ Class Name
+ RunShellScriptAction
+ InputUUID
+ D3DF1E84-1D2A-4F58-94E0-111111111111
+ Keywords
+
+ Shell
+
+ OutputUUID
+ D3DF1E84-1D2A-4F58-94E0-222222222222
+ UUID
+ D3DF1E84-1D2A-4F58-94E0-333333333333
+ UnlocalizedApplications
+
+ Automator
+
+ arguments
+
+ isViewVisible
+ 1
+ location
+ 309.000000:253.000000
+ nibPath
+ /System/Library/Automator/Run Shell Script.action/Contents/Resources/Base.lproj/main.nib
+
+ isViewVisible
+ 1
+
+
+ connectors
+
+ workflowMetaData
+
+ applicationBundleIDsByPath
+
+ /System/Library/CoreServices/Finder.app
+ com.apple.finder
+
+ applicationPaths
+
+ /System/Library/CoreServices/Finder.app
+
+ inputTypeIdentifier
+ com.apple.Automator.fileSystemObject.image
+ outputTypeIdentifier
+ com.apple.Automator.nothing
+ presentationMode
+ 15
+ processesInput
+ 0
+ serviceApplicationBundleID
+ com.apple.finder
+ serviceApplicationPath
+ /System/Library/CoreServices/Finder.app
+ serviceInputTypeIdentifier
+ com.apple.Automator.fileSystemObject.image
+ serviceOutputTypeIdentifier
+ com.apple.Automator.nothing
+ serviceProcessesInput
+ 0
+ systemImageName
+ NSActionTemplate
+ useAutomaticInputType
+ 0
+ workflowTypeIdentifier
+ com.apple.Automator.servicesMenu
+
+
+
diff --git a/ImageResize.ContextMenu/macos/ResizeImages.workflow/Contents/info.plist b/ImageResize.ContextMenu/macos/ResizeImages.workflow/Contents/info.plist
new file mode 100644
index 0000000..46d8889
--- /dev/null
+++ b/ImageResize.ContextMenu/macos/ResizeImages.workflow/Contents/info.plist
@@ -0,0 +1,27 @@
+
+
+
+
+ NSServices
+
+
+ NSMenuItem
+
+ default
+ Resize Images
+
+ NSMessage
+ runWorkflowAsService
+ NSRequiredContext
+
+ NSApplicationIdentifier
+ com.apple.finder
+
+ NSSendFileTypes
+
+ public.image
+
+
+
+
+
diff --git a/README.md b/README.md
index 623e33f..9a6ebce 100644
--- a/README.md
+++ b/README.md
@@ -11,8 +11,8 @@ A minimal, cross-platform image resize middleware for .NET Core that provides a
### ImageResize.Core (NuGet Package)
ASP.NET Core middleware for on-the-fly image resizing with disk caching.
-### ImageResize.ContextMenu (Windows Application)
-Windows 11 context menu integration for quick image resizing. Right-click any image file and select "Resize Images..." to quickly resize single or multiple images. [See ImageResize.ContextMenu/README.md](ImageResize.ContextMenu/README.md) for installation instructions.
+### ImageResize.ContextMenu (Desktop Application — Windows & macOS)
+Cross-platform desktop shell integration for quick image resizing. On Windows 11, right-click any image file and select "Resize Images..."; on macOS, right-click in Finder and choose Services → Resize Images. Single or multiple images, percentage or exact dimensions. [See ImageResize.ContextMenu/README.md](ImageResize.ContextMenu/README.md) for install instructions on both platforms.
## Features
diff --git a/build-macos.sh b/build-macos.sh
new file mode 100644
index 0000000..1b2ef41
--- /dev/null
+++ b/build-macos.sh
@@ -0,0 +1,167 @@
+#!/usr/bin/env bash
+# Builds a universal (arm64 + x86_64) ImageResize.app bundle and wraps it in a
+# distributable DMG alongside the Finder Quick Action. Must run on macOS — lipo
+# and hdiutil/create-dmg are not available on Linux or Windows.
+#
+# Usage: ./build-macos.sh [Release|Debug]
+# Output: publish/installer/ImageResize-ContextMenu--universal.dmg
+
+set -euo pipefail
+
+CONFIG="${1:-Release}"
+ROOT="$(cd "$(dirname "$0")" && pwd)"
+PROJECT="$ROOT/ImageResize.ContextMenu/ImageResize.ContextMenu.csproj"
+MACOS_SRC="$ROOT/ImageResize.ContextMenu/macos"
+
+if [[ "$(uname -s)" != "Darwin" ]]; then
+ echo "ERROR: build-macos.sh must run on macOS (got $(uname -s))." >&2
+ exit 1
+fi
+
+if ! command -v dotnet >/dev/null 2>&1; then
+ echo "ERROR: dotnet SDK not found on PATH." >&2
+ exit 1
+fi
+
+if ! command -v lipo >/dev/null 2>&1; then
+ echo "ERROR: lipo not found (install Xcode command-line tools: xcode-select --install)." >&2
+ exit 1
+fi
+
+# Version from Directory.Build.props — single source of truth, matches assemblies.
+# Uses grep+sed (BSD/GNU compatible) rather than awk's 3-arg match (gawk-only).
+VERSION=$(grep -o '[^<]*' "$ROOT/Directory.Build.props" | head -1 | sed -E 's|(.*)|\1|')
+if [[ -z "$VERSION" ]]; then
+ echo "ERROR: could not extract from Directory.Build.props." >&2
+ exit 1
+fi
+
+echo "ImageResize.ContextMenu macOS build"
+echo " Version: $VERSION"
+echo " Configuration: $CONFIG"
+echo
+
+OUT_ARM64="$ROOT/publish/osx-arm64"
+OUT_X64="$ROOT/publish/osx-x64"
+OUT_UNIVERSAL="$ROOT/publish/osx-universal"
+APP_DIR="$ROOT/publish/mac/ImageResize.app"
+DMG_STAGE="$ROOT/publish/mac"
+INSTALLER_DIR="$ROOT/publish/installer"
+
+rm -rf "$OUT_ARM64" "$OUT_X64" "$OUT_UNIVERSAL" "$ROOT/publish/mac"
+mkdir -p "$INSTALLER_DIR"
+
+echo "==> Publishing for osx-arm64"
+dotnet publish "$PROJECT" -c "$CONFIG" -r osx-arm64 --self-contained true \
+ -o "$OUT_ARM64" \
+ /p:PublishSingleFile=false \
+ /p:IncludeNativeLibrariesForSelfExtract=true
+
+echo "==> Publishing for osx-x64"
+dotnet publish "$PROJECT" -c "$CONFIG" -r osx-x64 --self-contained true \
+ -o "$OUT_X64" \
+ /p:PublishSingleFile=false \
+ /p:IncludeNativeLibrariesForSelfExtract=true
+
+echo "==> Merging architectures into a universal layout"
+# Start from arm64 as the base, then walk every file in x64 and either lipo-merge
+# Mach-O binaries or leave the arm64 copy in place (managed DLLs are arch-independent).
+cp -R "$OUT_ARM64" "$OUT_UNIVERSAL"
+
+while IFS= read -r -d '' x64_file; do
+ rel="${x64_file#$OUT_X64/}"
+ arm_file="$OUT_ARM64/$rel"
+ uni_file="$OUT_UNIVERSAL/$rel"
+
+ if [[ ! -f "$arm_file" ]]; then
+ # File only exists in x64 — copy across.
+ mkdir -p "$(dirname "$uni_file")"
+ cp "$x64_file" "$uni_file"
+ continue
+ fi
+
+ # Only Mach-O files (main binary + .dylib) need lipo. Text/managed/json stay as-is.
+ if /usr/bin/file -b "$x64_file" | grep -q 'Mach-O'; then
+ arm_archs=$(lipo -archs "$arm_file" 2>/dev/null || echo "")
+ x64_archs=$(lipo -archs "$x64_file" 2>/dev/null || echo "")
+
+ # If either copy is already universal (contains both archs), the published dylib is
+ # shipped as a fat binary (common for SkiaSharp). Prefer that — lipo -create would
+ # refuse when archs overlap.
+ if [[ "$arm_archs" == *"arm64"* && "$arm_archs" == *"x86_64"* ]]; then
+ : # arm_file is already fat — keep it.
+ elif [[ "$x64_archs" == *"arm64"* && "$x64_archs" == *"x86_64"* ]]; then
+ cp "$x64_file" "$uni_file"
+ else
+ lipo -create "$arm_file" "$x64_file" -output "$uni_file"
+ fi
+ fi
+done < <(find "$OUT_X64" -type f -print0)
+
+echo "==> Assembling $APP_DIR"
+mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources"
+
+# Info.plist with version substituted in-place.
+sed "s/{VERSION}/$VERSION/g" "$MACOS_SRC/Info.plist" > "$APP_DIR/Contents/Info.plist"
+
+# Bundle all runtime files into Contents/MacOS. The main executable must match CFBundleExecutable.
+cp -R "$OUT_UNIVERSAL"/. "$APP_DIR/Contents/MacOS"/
+chmod +x "$APP_DIR/Contents/MacOS/ImageResize.ContextMenu"
+
+# Icon (optional) — use .icns if present, else fall back to the .ico via best effort.
+if [[ -f "$MACOS_SRC/icon.icns" ]]; then
+ cp "$MACOS_SRC/icon.icns" "$APP_DIR/Contents/Resources/icon.icns"
+elif [[ -f "$ROOT/ImageResize.ContextMenu/Assets/icon.icns" ]]; then
+ cp "$ROOT/ImageResize.ContextMenu/Assets/icon.icns" "$APP_DIR/Contents/Resources/icon.icns"
+else
+ echo " (no .icns icon found; app will use default Finder icon)"
+fi
+
+# Stage the DMG contents: the .app for drag-install, the Quick Action to double-click install.
+cp -R "$MACOS_SRC/ResizeImages.workflow" "$DMG_STAGE/Resize Images.workflow"
+cat > "$DMG_STAGE/README.txt" < Open, then
+ click "Open" again when Gatekeeper warns about the unidentified developer.
+ (One-time step; subsequent launches are unprompted.)
+
+Uninstall:
+ rm -rf /Applications/ImageResize.app
+ rm -rf "\$HOME/Library/Services/Resize Images.workflow"
+ rm -rf "\$HOME/Library/Application Support/ImageResize"
+EOF
+
+DMG_PATH="$INSTALLER_DIR/ImageResize-ContextMenu-Setup-$VERSION-universal.dmg"
+rm -f "$DMG_PATH"
+
+echo "==> Creating $DMG_PATH"
+if command -v create-dmg >/dev/null 2>&1; then
+ create-dmg \
+ --volname "ImageResize $VERSION" \
+ --window-size 600 400 \
+ --icon-size 96 \
+ --icon "ImageResize.app" 150 200 \
+ --icon "Resize Images.workflow" 450 200 \
+ --app-drop-link 300 340 \
+ "$DMG_PATH" \
+ "$DMG_STAGE" \
+ || true # create-dmg sometimes reports non-zero on AppleScript UI timing but still produces a valid DMG
+fi
+
+# Fallback: hdiutil is guaranteed to be present on macOS.
+if [[ ! -f "$DMG_PATH" ]]; then
+ hdiutil create \
+ -volname "ImageResize $VERSION" \
+ -srcfolder "$DMG_STAGE" \
+ -ov -format UDZO \
+ "$DMG_PATH"
+fi
+
+echo
+echo "Done."
+echo " DMG: $DMG_PATH"
+echo " Size: $(du -h "$DMG_PATH" | cut -f1)"