From 4f2cffa37230fd88ae66c26a555966c1826e80e4 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 16 Jul 2026 19:33:44 +0200 Subject: [PATCH] feat: add clipboard image handling for pasting and staging attachments --- src/EchoHub.Client/AppOrchestrator.cs | 112 +++++++- src/EchoHub.Client/Services/ClipboardImage.cs | 257 ++++++++++++++++++ src/EchoHub.Client/UI/MainWindow.cs | 31 ++- src/EchoHub.Tests/ClipboardImageTests.cs | 78 ++++++ 4 files changed, 462 insertions(+), 16 deletions(-) create mode 100644 src/EchoHub.Client/Services/ClipboardImage.cs create mode 100644 src/EchoHub.Tests/ClipboardImageTests.cs diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 9a5f883..8c73134 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -35,6 +35,10 @@ public sealed class AppOrchestrator : IDisposable private readonly HashSet _channelsLoadingMore = new(StringComparer.OrdinalIgnoreCase); private readonly List _stagedAttachments = []; + // Temp PNGs created for clipboard-image pastes; deleted once their message is sent + // (or the staging tray is cleared) so pasted screenshots don't pile up in %TEMP%. + private readonly HashSet _tempPastedFiles = []; + // E2E channels whose unlock prompt the user cancelled — don't nag on every reselect. // Cleared on connect/reconnect; an explicit /join or a send attempt re-offers the prompt. private readonly HashSet _declinedUnlocks = new(StringComparer.OrdinalIgnoreCase); @@ -94,6 +98,8 @@ public sealed class AppOrchestrator : IDisposable _mainWindow.OnDisconnectRequested += HandleDisconnect; _mainWindow.OnLogoutRequested += HandleLogout; _mainWindow.OnMessageSubmitted += HandleMessageSubmitted; + _mainWindow.OnFilesStaged += HandleFilesStaged; + _mainWindow.OnImagePasted += HandleImagePasted; _mainWindow.OnChannelSelected += HandleChannelSelected; _mainWindow.OnProfileRequested += HandleProfileRequested; _mainWindow.OnStatusRequested += HandleStatusRequested; @@ -221,11 +227,89 @@ public sealed class AppOrchestrator : IDisposable private Task HandleCmdClearAttachments() { + var temps = _stagedAttachments.Where(_tempPastedFiles.Contains).ToList(); + _tempPastedFiles.ExceptWith(temps); + CleanupPastedTempFiles(temps); + _stagedAttachments.Clear(); InvokeUI(RefreshStagingTray); return Task.CompletedTask; } + /// + /// Stages a batch of local files (multi-file paste or drag-and-drop) as attachments of the + /// next message. Runs synchronously on the UI thread — unlike routing each file through a + /// fire-and-forget /send command, a 10-file paste can't race the staging list. + /// + private void HandleFilesStaged(string channel, IReadOnlyList files) + { + if (!_conn.IsAuthenticated || !_conn.IsConnected) + return; + + var slotsLeft = HubConstants.MaxAttachmentsPerMessage - _stagedAttachments.Count; + _stagedAttachments.AddRange(files.Take(Math.Max(0, slotsLeft))); + + if (files.Count > slotsLeft) + _mainWindow.ShowError($"You can attach at most {HubConstants.MaxAttachmentsPerMessage} files per message."); + + RefreshStagingTray(); + } + + /// + /// Stages an image pasted as raw clipboard data (copied from a browser, a screenshot tool, + /// or an image editor). The PNG is written to a per-paste temp folder so it flows through + /// the same path-based staging/encryption pipeline as regular files, and the temp file is + /// deleted once the message is sent. + /// + private void HandleImagePasted(string channel, byte[] png) + { + if (!_conn.IsAuthenticated || !_conn.IsConnected) + return; + + if (_stagedAttachments.Count >= HubConstants.MaxAttachmentsPerMessage) + { + _mainWindow.ShowError($"You can attach at most {HubConstants.MaxAttachmentsPerMessage} files per message."); + return; + } + + try + { + // A unique folder per paste keeps the Discord-style "image.png" display name + // while letting several pasted images coexist in one message. + var dir = Path.Combine(Path.GetTempPath(), "EchoHub", "pasted", Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, "image.png"); + File.WriteAllBytes(path, png); + + _tempPastedFiles.Add(path); + _stagedAttachments.Add(path); + RefreshStagingTray(); + } + catch (Exception ex) + { + Log.Error(ex, "Staging a pasted clipboard image failed"); + _mainWindow.ShowError($"Pasting image failed: {ex.Message}"); + } + } + + /// Best-effort removal of pasted-image temp files and their per-paste folders. + private static void CleanupPastedTempFiles(IReadOnlyList files) + { + foreach (var file in files) + { + try + { + File.Delete(file); + if (Path.GetDirectoryName(file) is { } dir) + Directory.Delete(dir); + } + catch (Exception ex) + { + Log.Debug(ex, "Could not delete pasted-image temp file {File}", file); + } + } + } + /// /// Opens the ASCII-art size picker (no argument) or sets it directly from "s"/"m"/"l" (or /// small/medium/large). The choice is a persistent preference applied to attached images. @@ -307,17 +391,29 @@ public sealed class AppOrchestrator : IDisposable _stagedAttachments.Clear(); InvokeUI(RefreshStagingTray); - var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey); + // Pasted clipboard images live in temp files; once this send owns them they are + // deleted whether the upload succeeds or fails (the tray is already cleared). + var tempFiles = staged.Where(_tempPastedFiles.Contains).ToList(); + _tempPastedFiles.ExceptWith(tempFiles); - var outgoing = new List(); - foreach (var path in staged) - outgoing.Add(await BuildOutgoingAttachmentAsync(path, hasRoomKey ? roomKey : null, size)); + try + { + var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey); - var wireContent = hasRoomKey && !string.IsNullOrEmpty(content) - ? RoomCrypto.EncryptText(content, roomKey) - : content; + var outgoing = new List(); + foreach (var path in staged) + outgoing.Add(await BuildOutgoingAttachmentAsync(path, hasRoomKey ? roomKey : null, size)); - await _conn.Api!.SendMessageWithAttachmentsAsync(channel, wireContent, outgoing, size); + var wireContent = hasRoomKey && !string.IsNullOrEmpty(content) + ? RoomCrypto.EncryptText(content, roomKey) + : content; + + await _conn.Api!.SendMessageWithAttachmentsAsync(channel, wireContent, outgoing, size); + } + finally + { + CleanupPastedTempFiles(tempFiles); + } }, "Send failed"); } diff --git a/src/EchoHub.Client/Services/ClipboardImage.cs b/src/EchoHub.Client/Services/ClipboardImage.cs new file mode 100644 index 0000000..814e463 --- /dev/null +++ b/src/EchoHub.Client/Services/ClipboardImage.cs @@ -0,0 +1,257 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Serilog; +using SixLabors.ImageSharp; + +namespace EchoHub.Client.Services; + +/// +/// Reads raw image data from the OS clipboard (e.g. an image copied from a browser, or a +/// Win+Shift+S screenshot), which terminals cannot paste as text. Always returns PNG bytes: +/// clipboard PNG data is passed through, clipboard bitmaps (CF_DIB) are re-encoded. +/// +public static class ClipboardImage +{ + private static readonly byte[] PngMagic = [0x89, 0x50, 0x4E, 0x47]; + + public static bool TryGetPng(out byte[] png) + { + png = []; + try + { + if (OperatingSystem.IsWindows()) + return TryGetWindows(out png); + if (OperatingSystem.IsLinux()) + return TryGetLinux(out png); + if (OperatingSystem.IsMacOS()) + return TryGetMacOS(out png); + } + catch (Exception ex) + { + Log.Warning(ex, "Reading an image from the clipboard failed"); + } + + return false; + } + + private static bool IsPng(byte[] data) => + data.Length > PngMagic.Length && data.AsSpan(0, PngMagic.Length).SequenceEqual(PngMagic); + + /// + /// Converts clipboard DIB bytes (a BITMAPINFOHEADER/V4/V5 + optional palette/masks + pixel + /// data, i.e. a .bmp file without its 14-byte file header) to PNG. Returns null when the + /// data is malformed or not decodable as a bitmap. + /// + public static byte[]? DibToPng(byte[] dib) + { + if (dib.Length < 40) + return null; + + var headerSize = BitConverter.ToInt32(dib, 0); + if (headerSize < 40 || headerSize > dib.Length) + return null; + + var bitCount = BitConverter.ToUInt16(dib, 14); + var compression = BitConverter.ToUInt32(dib, 16); + var clrUsed = BitConverter.ToUInt32(dib, 32); + + // Pixel data offset: file header + info header + color masks + palette. + // BI_BITFIELDS masks follow a plain 40-byte header; larger headers embed them. + var maskBytes = headerSize == 40 && compression == 3 ? 12 + : headerSize == 40 && compression == 6 ? 16 + : 0; + var paletteEntries = clrUsed != 0 ? clrUsed + : bitCount <= 8 ? 1u << bitCount + : 0u; + var pixelOffset = (uint)(14 + headerSize + maskBytes) + paletteEntries * 4; + + var bmp = new byte[14 + dib.Length]; + bmp[0] = (byte)'B'; + bmp[1] = (byte)'M'; + BitConverter.TryWriteBytes(bmp.AsSpan(2), (uint)bmp.Length); + BitConverter.TryWriteBytes(bmp.AsSpan(10), pixelOffset); + dib.CopyTo(bmp, 14); + + try + { + using var image = Image.Load(bmp); + using var ms = new MemoryStream(); + image.SaveAsPng(ms); + return ms.ToArray(); + } + catch (Exception ex) when (ex is ImageFormatException or InvalidOperationException) + { + Log.Warning(ex, "Clipboard DIB could not be decoded as a bitmap"); + return null; + } + } + + // ── Windows: "PNG" / "image/png" registered formats, then CF_DIB ───────── + + private const uint CfDib = 8; + + [SupportedOSPlatform("windows")] + private static bool TryGetWindows(out byte[] png) + { + png = []; + + // Browsers register a "PNG" (Chromium) or "image/png" (some apps) clipboard format + // preserving transparency; CF_DIB is synthesized by Windows for everything else + // (screenshots, image editors), so together these cover all image sources. + var pngFormat = RegisterClipboardFormatW("PNG"); + var mimeFormat = RegisterClipboardFormatW("image/png"); + + var hasAny = (pngFormat != 0 && IsClipboardFormatAvailable(pngFormat)) + || (mimeFormat != 0 && IsClipboardFormatAvailable(mimeFormat)) + || IsClipboardFormatAvailable(CfDib); + if (!hasAny) + return false; + + var opened = false; + for (var attempt = 0; attempt < 5 && !opened; attempt++) + opened = OpenClipboard(IntPtr.Zero); + if (!opened) + return false; + + try + { + foreach (var format in new[] { pngFormat, mimeFormat }) + { + if (format == 0 || !IsClipboardFormatAvailable(format)) + continue; + var data = ReadHGlobal(GetClipboardData(format)); + if (data is not null && IsPng(data)) + { + png = data; + return true; + } + } + + if (IsClipboardFormatAvailable(CfDib) + && ReadHGlobal(GetClipboardData(CfDib)) is { } dib + && DibToPng(dib) is { } converted) + { + png = converted; + return true; + } + + return false; + } + finally + { + CloseClipboard(); + } + } + + [SupportedOSPlatform("windows")] + private static byte[]? ReadHGlobal(IntPtr handle) + { + if (handle == IntPtr.Zero) + return null; + + var ptr = GlobalLock(handle); + if (ptr == IntPtr.Zero) + return null; + + try + { + var size = (int)GlobalSize(handle); + if (size <= 0) + return null; + var data = new byte[size]; + Marshal.Copy(ptr, data, 0, size); + return data; + } + finally + { + GlobalUnlock(handle); + } + } + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool OpenClipboard(IntPtr hWndNewOwner); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseClipboard(); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsClipboardFormatAvailable(uint format); + + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr GetClipboardData(uint uFormat); + + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint RegisterClipboardFormatW(string lpszFormat); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GlobalLock(IntPtr hMem); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GlobalUnlock(IntPtr hMem); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern nuint GlobalSize(IntPtr hMem); + + // ── Linux: image/png via wl-paste or xclip ──────────────────────────────── + + [SupportedOSPlatform("linux")] + private static bool TryGetLinux(out byte[] png) + { + png = []; + var data = RunForBytes("wl-paste", ["--type", "image/png"]) + ?? RunForBytes("xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]); + if (data is null || !IsPng(data)) + return false; + + png = data; + return true; + } + + // ── macOS: pngpaste (brew install pngpaste), when present ──────────────── + + [SupportedOSPlatform("macos")] + private static bool TryGetMacOS(out byte[] png) + { + png = []; + var data = RunForBytes("pngpaste", ["-"]); + if (data is null || !IsPng(data)) + return false; + + png = data; + return true; + } + + private static byte[]? RunForBytes(string fileName, IEnumerable args) + { + var psi = new ProcessStartInfo(fileName) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var arg in args) + psi.ArgumentList.Add(arg); + + try + { + using var process = Process.Start(psi); + if (process is null) + return null; + + using var ms = new MemoryStream(); + process.StandardOutput.BaseStream.CopyTo(ms); + process.WaitForExit(2000); + return process.ExitCode == 0 && ms.Length > 0 ? ms.ToArray() : null; + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException) + { + return null; // tool not installed + } + } +} diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 94919e6..9ff8945 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -87,6 +87,18 @@ public sealed partial class MainWindow : Runnable /// public event Action? OnMessageSubmitted; + /// + /// Fired when local files arrive via paste or drag-and-drop to be staged as attachments. + /// Parameters: channel name, absolute paths of existing files. + /// + public event Action>? OnFilesStaged; + + /// + /// Fired when raw image data is pasted from the clipboard (e.g. copied from a browser or a + /// screenshot tool). Parameters: channel name, PNG-encoded image bytes. + /// + public event Action? OnImagePasted; + /// /// Fired when the user requests to connect via the menu. /// @@ -744,11 +756,15 @@ public sealed partial class MainWindow : Runnable } else if (e.KeyCode == CtrlVKey.KeyCode || e.KeyCode == CtrlYKey.KeyCode) { - // If a file was copied in the OS file manager, the clipboard holds a file list - // (not text) — attach it. Otherwise paste text. This is the reliable path on - // Windows Terminal, which never pastes copied files as text. + // Discord-style paste priority. Copied files in the OS file manager put a file + // list (not text) on the clipboard — attach them all. Copied image data (browser + // right-click copy, screenshot tools) is attached as a PNG. Otherwise paste text. + // Terminals never deliver either of the first two as text, so this is the only path. if (ClipboardFiles.TryGetFiles(out var pastedFiles)) StageFiles(pastedFiles); + else if (!string.IsNullOrEmpty(_messageManager.CurrentChannel) + && ClipboardImage.TryGetPng(out var pastedPng)) + OnImagePasted?.Invoke(_messageManager.CurrentChannel, pastedPng); else GuardedClipboardAction(() => _inputField.Paste(), "paste"); e.Handled = true; @@ -872,17 +888,16 @@ public sealed partial class MainWindow : Runnable } /// - /// Routes files (from a drop or a file-clipboard paste) through the /send pipeline, which - /// stages them; the next Enter sends them with any typed caption. + /// Stages files (from a drop or a file-clipboard paste) as attachments in one batch; the + /// next Enter sends them with any typed caption. /// - private void StageFiles(IEnumerable files) + private void StageFiles(IReadOnlyList files) { var channel = _messageManager.CurrentChannel; if (string.IsNullOrEmpty(channel)) return; - foreach (var file in files) - OnMessageSubmitted?.Invoke(channel, $"/send \"{file}\""); + OnFilesStaged?.Invoke(channel, files); } private void OnChatViewportChanged() diff --git a/src/EchoHub.Tests/ClipboardImageTests.cs b/src/EchoHub.Tests/ClipboardImageTests.cs new file mode 100644 index 0000000..0612b5c --- /dev/null +++ b/src/EchoHub.Tests/ClipboardImageTests.cs @@ -0,0 +1,78 @@ +using EchoHub.Client.Services; +using EchoHub.Core.Services; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Bmp; +using SixLabors.ImageSharp.PixelFormats; +using Xunit; + +namespace EchoHub.Tests; + +public class ClipboardImageTests +{ + /// + /// Encodes an image as BMP and strips the 14-byte BITMAPFILEHEADER, producing exactly what + /// the Windows clipboard hands out as CF_DIB. + /// + private static byte[] MakeDib(Image image, BmpBitsPerPixel bpp) + { + using var ms = new MemoryStream(); + image.Save(ms, new BmpEncoder { BitsPerPixel = bpp }); + return ms.ToArray()[14..]; + } + + private static Image MakeTestImage() + { + var image = new Image(4, 3); + image[0, 0] = new Rgba32(255, 0, 0); + image[3, 2] = new Rgba32(0, 0, 255); + return image; + } + + [Theory] + [InlineData(BmpBitsPerPixel.Pixel24)] + [InlineData(BmpBitsPerPixel.Pixel32)] + [InlineData(BmpBitsPerPixel.Pixel8)] // palette-based: exercises the palette offset math + public void DibToPng_ConvertsDibToValidPng(BmpBitsPerPixel bpp) + { + using var original = MakeTestImage(); + var dib = MakeDib(original, bpp); + + var png = ClipboardImage.DibToPng(dib); + + Assert.NotNull(png); + using var pngStream = new MemoryStream(png); + Assert.True(FileValidationHelper.IsValidImage(pngStream)); + + using var decoded = Image.Load(png); + Assert.Equal(original.Width, decoded.Width); + Assert.Equal(original.Height, decoded.Height); + } + + [Fact] + public void DibToPng_PreservesPixels_For24Bpp() + { + using var original = MakeTestImage(); + var dib = MakeDib(original, BmpBitsPerPixel.Pixel24); + + var png = ClipboardImage.DibToPng(dib); + + Assert.NotNull(png); + using var decoded = Image.Load(png); + Assert.Equal(new Rgba32(255, 0, 0), decoded[0, 0]); + Assert.Equal(new Rgba32(0, 0, 255), decoded[3, 2]); + } + + [Fact] + public void DibToPng_ReturnsNull_ForTruncatedData() + { + Assert.Null(ClipboardImage.DibToPng([0x28, 0x00, 0x00])); + } + + [Fact] + public void DibToPng_ReturnsNull_ForGarbageData() + { + var garbage = new byte[256]; + new Random(42).NextBytes(garbage); + Assert.Null(ClipboardImage.DibToPng(garbage)); + } +}