diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4926581..9b3f1d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,10 +129,41 @@ jobs: name: installers path: publish/installer/*.exe + build-dmg: + name: Build ContextMenu DMG (macos) + runs-on: macos-latest + needs: build-library + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore bumped Directory.Build.props + uses: actions/download-artifact@v4 + with: + name: bumped-props + path: . + + - name: Install create-dmg + run: brew install create-dmg + + - name: Build universal .app + .dmg + run: ./build-macos.sh Release + + - name: Upload DMG + uses: actions/upload-artifact@v4 + with: + name: dmg + path: publish/installer/*.dmg + publish: name: Publish to nuget.org + GitHub Release runs-on: ubuntu-latest - needs: [build-library, build-installer] + needs: [build-library, build-installer, build-dmg] if: ${{ inputs.dry_run != true }} steps: - uses: actions/checkout@v4 @@ -150,6 +181,11 @@ jobs: name: installers path: release/ + - uses: actions/download-artifact@v4 + with: + name: dmg + path: release/ + - uses: actions/download-artifact@v4 with: name: bumped-props @@ -194,6 +230,7 @@ jobs: release/ImageResize.*.nupkg release/ImageResize.*.snupkg release/ImageResize-ContextMenu-Setup-*.exe + release/ImageResize-ContextMenu-Setup-*.dmg generate_release_notes: true draft: false prerelease: ${{ contains(needs.build-library.outputs.version, '-') }} diff --git a/CLAUDE.md b/CLAUDE.md index d6a3583..d4eaffe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,10 +4,10 @@ Orientation for Claude / new agents working in this repo. Keep this file short a ## Project overview -ImageResize is a minimal, cross-platform image-resize middleware for ASP.NET Core, backed by SkiaSharp, plus a Windows desktop companion app that exposes the same engine through an Explorer context-menu. +ImageResize is a minimal, cross-platform image-resize middleware for ASP.NET Core, backed by SkiaSharp, plus a cross-platform desktop companion app (Windows + macOS, built on Avalonia UI) that exposes the same engine through an Explorer context-menu (Windows) / Finder Services (macOS). -- Current version: **3.1.0** (published as the `ImageResize` NuGet package). Version lives in [Directory.Build.props](Directory.Build.props) — do NOT re-add `` in any csproj. -- Target runtime: **.NET 10** (`net10.0`, plus `net10.0-windows` for the WPF app) +- Current version: **3.2.0** (published as the `ImageResize` NuGet package). Version lives in [Directory.Build.props](Directory.Build.props) — do NOT re-add `` in any csproj. +- Target runtime: **.NET 10** (`net10.0` across all projects — ContextMenu is Avalonia UI and cross-platform) - License: MIT ## Solution layout @@ -15,7 +15,7 @@ ImageResize is a minimal, cross-platform image-resize middleware for ASP.NET Cor The solution is [ImageResize.sln](ImageResize.sln) with four projects: - [ImageResize.Core/](ImageResize.Core/) — the NuGet library. ASP.NET Core middleware + SkiaSharp codec + disk cache. `net10.0`, framework-refs `Microsoft.AspNetCore.App`. `GeneratePackageOnBuild=true`, so a Release build emits a `.nupkg`. -- [ImageResize.ContextMenu/](ImageResize.ContextMenu/) — WPF desktop app (`net10.0-windows`, `WinExe`, `UseWPF=true`). Registers a Windows 11 Explorer context-menu entry; single-instance via mutex, forwards additional invocations over a named pipe. +- [ImageResize.ContextMenu/](ImageResize.ContextMenu/) — Avalonia UI desktop app (`net10.0`, cross-platform). Surfaces via a Windows 11 Explorer context-menu entry and a macOS Finder Quick Action. Single-instance + arg-forwarding is platform-split behind `ISingleInstance`: named pipe + mutex on Windows, Unix domain socket + exclusive lockfile on macOS/Linux. - [ImageResize.Example/](ImageResize.Example/) — Razor Pages demo that consumes the middleware; useful as a smoke test. - [ImageResize.Tests/](ImageResize.Tests/) — NUnit 4 + Shouldly + Moq unit tests for resize math, cache, path matching, and bounds validation. @@ -23,6 +23,7 @@ The solution is [ImageResize.sln](ImageResize.sln) with four projects: - .NET 10 SDK; `Nullable=enable`, `ImplicitUsings=enable`, `LangVersion=latest` - SkiaSharp **3.119.2** (+ Linux / macOS / Win32 native assets) +- Avalonia UI **11.2.5** (ContextMenu); `MessageBox.Avalonia` **3.3.1.1**; `Tmds.DBus.Protocol` pinned to **0.92.0** to clear GHSA-xrw6-gwf8-vvr9 - Microsoft.Extensions.{DependencyInjection,Logging,Options} **10.0.6** - NUnit **4.5.1**, NUnit3TestAdapter **6.2.0**, Shouldly **4.3.0**, Moq **4.20.72**, Coverlet **10.0.0** @@ -48,6 +49,15 @@ Windows installer for the ContextMenu app (PowerShell; requires Inno Setup on PA Output: `publish/installer/ImageResize-ContextMenu-Setup-*.exe`. +macOS universal `.app` + `.dmg` (must run on macOS — needs `lipo` from Xcode CLT; `brew install create-dmg` optional): + +```bash +./build-macos.sh # Release, default +./build-macos.sh Debug +``` + +Output: `publish/installer/ImageResize-ContextMenu-Setup--universal.dmg`. CI at `macos-latest` runs this on every PR and uploads the DMG as an artefact. + ## Key architectural patterns - DI + middleware registration: `AddImageResize()` / `UseImageResize()` in [ImageResizeServiceCollectionExtensions.cs](ImageResize.Core/Extensions/ImageResizeServiceCollectionExtensions.cs). Registers `IValidateOptions` with validate-on-start. @@ -55,13 +65,14 @@ Output: `publish/installer/ImageResize-ContextMenu-Setup-*.exe`. - Thundering-herd protection via `AsyncKeyedLocker` in [ImageResizerService.cs](ImageResize.Core/Services/ImageResizerService.cs). - Atomic cache writes (temp file → flush → rename) + XxHash128-keyed folder sharding in [FileSystemImageCache.cs](ImageResize.Core/Cache/FileSystemImageCache.cs). Hashing helpers centralised in [HashingUtilities.cs](ImageResize.Core/Utilities/HashingUtilities.cs). - Options-pattern config in [ImageResizeOptions.cs](ImageResize.Core/Configuration/ImageResizeOptions.cs) with nested `Bounds` / `Cache` / `ResponseCache` sub-options and a `MaxSourceBytes` decompression-bomb cap; validated by [ImageResizeOptionsValidator.cs](ImageResize.Core/Configuration/ImageResizeOptionsValidator.cs). -- ContextMenu single-instance + IPC (mutex + named pipe) in [App.xaml.cs](ImageResize.ContextMenu/App.xaml.cs). Version-string resolution in [VersionInfo.cs](ImageResize.ContextMenu/VersionInfo.cs) (reads `AssemblyInformationalVersion` → flows from `Directory.Build.props`). +- ContextMenu single-instance + IPC: [`ISingleInstance`](ImageResize.ContextMenu/Services/ISingleInstance.cs) with [`WindowsSingleInstance`](ImageResize.ContextMenu/Services/WindowsSingleInstance.cs) (mutex + named pipe) and [`UnixSingleInstance`](ImageResize.ContextMenu/Services/UnixSingleInstance.cs) (exclusive lockfile + Unix domain socket); wired in [App.axaml.cs](ImageResize.ContextMenu/App.axaml.cs). Shared per-OS paths / logging in [AppPaths.cs](ImageResize.ContextMenu/Services/AppPaths.cs) + [AppLog.cs](ImageResize.ContextMenu/Services/AppLog.cs). Version-string resolution in [VersionInfo.cs](ImageResize.ContextMenu/VersionInfo.cs) (reads `AssemblyInformationalVersion` → flows from `Directory.Build.props`). ## Entry points - Middleware pipeline: [ImageResizeMiddleware.cs](ImageResize.Core/Middleware/ImageResizeMiddleware.cs) - Web demo: [ImageResize.Example/Program.cs](ImageResize.Example/Program.cs) -- Desktop app: [ImageResize.ContextMenu/App.xaml.cs](ImageResize.ContextMenu/App.xaml.cs) → [MainWindow.xaml.cs](ImageResize.ContextMenu/MainWindow.xaml.cs) +- Desktop app: [ImageResize.ContextMenu/Program.cs](ImageResize.ContextMenu/Program.cs) → [App.axaml.cs](ImageResize.ContextMenu/App.axaml.cs) → [MainWindow.axaml.cs](ImageResize.ContextMenu/MainWindow.axaml.cs) +- macOS packaging sources: [ImageResize.ContextMenu/macos/](ImageResize.ContextMenu/macos/) (`Info.plist` templated with `{VERSION}`; `ResizeImages.workflow` is a hand-rolled Automator Quick Action) ## Configuration @@ -84,7 +95,21 @@ Output: `publish/installer/ImageResize-ContextMenu-Setup-*.exe`. - `AllowUpscale` defaults to `false`; resizes never enlarge past the source. - The middleware must be registered **before** `UseStaticFiles()` and before routing — otherwise static files win and the middleware never sees the request. - `GeneratePackageOnBuild` is on for `ImageResize.Core`, so every Release build drops a `.nupkg` into `nupkgs/`. Don't commit them; the `.gitignore` already excludes them. -- ContextMenu is `net10.0-windows` — it will not build on Linux/macOS. Core + Example + Tests do build cross-platform. +- ContextMenu is Avalonia and builds on Windows + macOS + Linux. Windows-only code paths (Explorer COM, Win32 P/Invoke to Progman/WorkerW, named-pipe IPC) are guarded both at compile time with `[SupportedOSPlatform("windows")]` and at runtime inside `OperatingSystem.IsWindows()` blocks — do not hoist them out. +- **Do not re-introduce WPF.** The port to Avalonia was deliberate. No ``, no `net10.0-windows` TFM on ContextMenu, no `System.Windows.*` references, no `.xaml` files (Avalonia's source generator only picks up `.axaml`). +- **Do not sign macOS builds with a paid Apple cert.** By design: no Apple Developer Program ($99/yr) — the `.dmg` ships unsigned, users right-click → Open once to bypass Gatekeeper. README documents this. + +## Code signing (Windows installer) + +Windows users see a SmartScreen *"Unknown publisher"* warning on first run because the Inno Setup installer is not Authenticode-signed. Chosen fix: **SignPath.io free OSS program** (no cost, no hardware tokens, signs in CI). + +Status: + +- ✅ **Part 1 (in-repo, done):** workaround documented in [ImageResize.ContextMenu/README.md](ImageResize.ContextMenu/README.md); [Installer.iss](ImageResize.ContextMenu/Installer.iss) publisher string set, icon enabled. +- ⏳ **Part 2 (offline, waiting on Lee):** register at and request the free OSS plan for `YodasMyDad/ImageResize`. Once approved they issue an org ID, project slug, signing-policy slug, and API token. +- ⏳ **Part 3 (wire into CI, after Part 2):** add `SIGNPATH_API_TOKEN` secret, update [.github/workflows/release.yml](.github/workflows/release.yml) to submit the `.exe` via `signpath-io/github-action-submit-signing-request@v1` and publish the *signed* artefact in the Release. The existing "Build + sign installers" step currently doesn't actually sign. + +macOS `.dmg` is intentionally unsigned — no Apple Developer Program. Do not add `codesign` / notarisation steps. ## Where to read more