Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -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 *)"
]
}
}
34 changes: 33 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
12 changes: 12 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@
<PackageVersion Include="System.IO.Hashing" Version="10.0.6" />
</ItemGroup>

<ItemGroup Label="Avalonia (ContextMenu desktop UI)">
<PackageVersion Include="Avalonia" Version="11.2.5" />
<PackageVersion Include="Avalonia.Desktop" Version="11.2.5" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.2.5" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.2.5" />
<PackageVersion Include="Avalonia.Diagnostics" Version="11.2.5" />
<PackageVersion Include="MessageBox.Avalonia" Version="3.3.1.1" />
<!-- Pin Tmds.DBus.Protocol past GHSA-xrw6-gwf8-vvr9 (fixed in 0.21.0+). Avalonia 11.2.5
pulls a vulnerable 0.20.0 transitively; the explicit ref below overrides that. -->
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.92.0" />
</ItemGroup>

<ItemGroup Label="SourceLink">
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,13 @@
<Window x:Class="ImageResize.ContextMenu.AboutWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ImageResize.ContextMenu.AboutWindow"
Title="About Resize Images"
Width="460"
Height="340"
ResizeMode="NoResize"
CanResize="False"
ShowInTaskbar="False"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource {x:Static SystemColors.WindowBrushKey}}"
Foreground="{DynamicResource {x:Static SystemColors.WindowTextBrushKey}}">
<Grid Margin="24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
WindowStartupLocation="CenterOwner">
<Grid Margin="24" RowDefinitions="Auto,Auto,Auto,*,Auto">

<TextBlock Grid.Row="0"
Text="Resize Images"
Expand All @@ -39,21 +30,23 @@
<TextBlock TextWrapping="Wrap"
FontSize="12"
Opacity="0.75"
Text="Minimal, cross-platform image-resize middleware for ASP.NET Core, plus a Windows desktop companion app. Distributed under the MIT License." />
<TextBlock Margin="0,12,0,0" FontSize="12">
<Hyperlink x:Name="GitHubLink"
NavigateUri="https://github.com/YodasMyDad/ImageResize"
RequestNavigate="Hyperlink_RequestNavigate">
github.com/YodasMyDad/ImageResize
</Hyperlink>
</TextBlock>
Text="Minimal, cross-platform image-resize middleware for ASP.NET Core, plus a desktop companion app. Distributed under the MIT License." />
<HyperlinkButton x:Name="GitHubLink"
Margin="0,8,0,0"
Padding="0"
FontSize="12"
HorizontalAlignment="Left"
Content="github.com/YodasMyDad/ImageResize"
NavigateUri="https://github.com/YodasMyDad/ImageResize"/>
</StackPanel>

<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right">
<Button x:Name="CloseButton"
Content="Close"
Width="96"
Height="30"
Height="32"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
IsDefault="True"
IsCancel="True"
Click="CloseButton_Click" />
Expand Down
19 changes: 19 additions & 0 deletions ImageResize.ContextMenu/AboutWindow.axaml.cs
Original file line number Diff line number Diff line change
@@ -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();
}
33 changes: 0 additions & 33 deletions ImageResize.ContextMenu/AboutWindow.xaml.cs

This file was deleted.

8 changes: 8 additions & 0 deletions ImageResize.ContextMenu/App.axaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ImageResize.ContextMenu.App"
RequestedThemeVariant="Default">
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>
194 changes: 194 additions & 0 deletions ImageResize.ContextMenu/App.axaml.cs
Original file line number Diff line number Diff line change
@@ -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<MainWindow>();
desktop.MainWindow = mainWindow;

_ipcCts = new CancellationTokenSource();
_singleInstance!.StartServer(OnArgsReceivedFromSecondary, _ipcCts.Token);

desktop.Exit += OnDesktopExit;
}

private static void OnArgsReceivedFromSecondary(IReadOnlyList<string> 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<ImageResizeOptions>(options =>
{
options.DefaultQuality = 99;
options.PngCompressionLevel = 6;
options.AllowUpscale = false;
options.Backend = ImageBackend.SkiaSharp;
options.WebRoot = string.Empty;
});

services.AddSingleton<IImageCodec, SkiaCodec>();
services.AddSingleton<IImageResizerService>(sp =>
{
var options = sp.GetRequiredService<IOptions<ImageResizeOptions>>();
var codec = sp.GetRequiredService<IImageCodec>();
var logger = sp.GetRequiredService<ILogger<ImageResizerService>>();
var cache = new NullImageCache();
return new ImageResizerService(options, cache, codec, logger);
});

services.AddSingleton<ImageProcessor>();
services.AddTransient<MainWindow>();

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<bool> 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<Stream> OpenReadAsync(string path, CancellationToken ct = default) => Task.FromResult(Stream.Null);
}
Loading
Loading