mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 00:26:07 +02:00
feat: add clipboard image handling for pasting and staging attachments
This commit is contained in:
@@ -35,6 +35,10 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
private readonly HashSet<string> _channelsLoadingMore = new(StringComparer.OrdinalIgnoreCase);
|
private readonly HashSet<string> _channelsLoadingMore = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly List<string> _stagedAttachments = [];
|
private readonly List<string> _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<string> _tempPastedFiles = [];
|
||||||
|
|
||||||
// E2E channels whose unlock prompt the user cancelled — don't nag on every reselect.
|
// 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.
|
// Cleared on connect/reconnect; an explicit /join or a send attempt re-offers the prompt.
|
||||||
private readonly HashSet<string> _declinedUnlocks = new(StringComparer.OrdinalIgnoreCase);
|
private readonly HashSet<string> _declinedUnlocks = new(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -94,6 +98,8 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_mainWindow.OnDisconnectRequested += HandleDisconnect;
|
_mainWindow.OnDisconnectRequested += HandleDisconnect;
|
||||||
_mainWindow.OnLogoutRequested += HandleLogout;
|
_mainWindow.OnLogoutRequested += HandleLogout;
|
||||||
_mainWindow.OnMessageSubmitted += HandleMessageSubmitted;
|
_mainWindow.OnMessageSubmitted += HandleMessageSubmitted;
|
||||||
|
_mainWindow.OnFilesStaged += HandleFilesStaged;
|
||||||
|
_mainWindow.OnImagePasted += HandleImagePasted;
|
||||||
_mainWindow.OnChannelSelected += HandleChannelSelected;
|
_mainWindow.OnChannelSelected += HandleChannelSelected;
|
||||||
_mainWindow.OnProfileRequested += HandleProfileRequested;
|
_mainWindow.OnProfileRequested += HandleProfileRequested;
|
||||||
_mainWindow.OnStatusRequested += HandleStatusRequested;
|
_mainWindow.OnStatusRequested += HandleStatusRequested;
|
||||||
@@ -221,11 +227,89 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
private Task HandleCmdClearAttachments()
|
private Task HandleCmdClearAttachments()
|
||||||
{
|
{
|
||||||
|
var temps = _stagedAttachments.Where(_tempPastedFiles.Contains).ToList();
|
||||||
|
_tempPastedFiles.ExceptWith(temps);
|
||||||
|
CleanupPastedTempFiles(temps);
|
||||||
|
|
||||||
_stagedAttachments.Clear();
|
_stagedAttachments.Clear();
|
||||||
InvokeUI(RefreshStagingTray);
|
InvokeUI(RefreshStagingTray);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleFilesStaged(string channel, IReadOnlyList<string> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Best-effort removal of pasted-image temp files and their per-paste folders.</summary>
|
||||||
|
private static void CleanupPastedTempFiles(IReadOnlyList<string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens the ASCII-art size picker (no argument) or sets it directly from "s"/"m"/"l" (or
|
/// 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.
|
/// small/medium/large). The choice is a persistent preference applied to attached images.
|
||||||
@@ -307,17 +391,29 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_stagedAttachments.Clear();
|
_stagedAttachments.Clear();
|
||||||
InvokeUI(RefreshStagingTray);
|
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<OutgoingAttachment>();
|
try
|
||||||
foreach (var path in staged)
|
{
|
||||||
outgoing.Add(await BuildOutgoingAttachmentAsync(path, hasRoomKey ? roomKey : null, size));
|
var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey);
|
||||||
|
|
||||||
var wireContent = hasRoomKey && !string.IsNullOrEmpty(content)
|
var outgoing = new List<OutgoingAttachment>();
|
||||||
? RoomCrypto.EncryptText(content, roomKey)
|
foreach (var path in staged)
|
||||||
: content;
|
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");
|
}, "Send failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using Serilog;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<string> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,6 +87,18 @@ public sealed partial class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action<string, string>? OnMessageSubmitted;
|
public event Action<string, string>? OnMessageSubmitted;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when local files arrive via paste or drag-and-drop to be staged as attachments.
|
||||||
|
/// Parameters: channel name, absolute paths of existing files.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string, IReadOnlyList<string>>? OnFilesStaged;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string, byte[]>? OnImagePasted;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fired when the user requests to connect via the menu.
|
/// Fired when the user requests to connect via the menu.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -744,11 +756,15 @@ public sealed partial class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
else if (e.KeyCode == CtrlVKey.KeyCode || e.KeyCode == CtrlYKey.KeyCode)
|
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
|
// Discord-style paste priority. Copied files in the OS file manager put a file
|
||||||
// (not text) — attach it. Otherwise paste text. This is the reliable path on
|
// list (not text) on the clipboard — attach them all. Copied image data (browser
|
||||||
// Windows Terminal, which never pastes copied files as text.
|
// 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))
|
if (ClipboardFiles.TryGetFiles(out var pastedFiles))
|
||||||
StageFiles(pastedFiles);
|
StageFiles(pastedFiles);
|
||||||
|
else if (!string.IsNullOrEmpty(_messageManager.CurrentChannel)
|
||||||
|
&& ClipboardImage.TryGetPng(out var pastedPng))
|
||||||
|
OnImagePasted?.Invoke(_messageManager.CurrentChannel, pastedPng);
|
||||||
else
|
else
|
||||||
GuardedClipboardAction(() => _inputField.Paste(), "paste");
|
GuardedClipboardAction(() => _inputField.Paste(), "paste");
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
@@ -872,17 +888,16 @@ public sealed partial class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Routes files (from a drop or a file-clipboard paste) through the /send pipeline, which
|
/// Stages files (from a drop or a file-clipboard paste) as attachments in one batch; the
|
||||||
/// stages them; the next Enter sends them with any typed caption.
|
/// next Enter sends them with any typed caption.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void StageFiles(IEnumerable<string> files)
|
private void StageFiles(IReadOnlyList<string> files)
|
||||||
{
|
{
|
||||||
var channel = _messageManager.CurrentChannel;
|
var channel = _messageManager.CurrentChannel;
|
||||||
if (string.IsNullOrEmpty(channel))
|
if (string.IsNullOrEmpty(channel))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
foreach (var file in files)
|
OnFilesStaged?.Invoke(channel, files);
|
||||||
OnMessageSubmitted?.Invoke(channel, $"/send \"{file}\"");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnChatViewportChanged()
|
private void OnChatViewportChanged()
|
||||||
|
|||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Encodes an image as BMP and strips the 14-byte BITMAPFILEHEADER, producing exactly what
|
||||||
|
/// the Windows clipboard hands out as CF_DIB.
|
||||||
|
/// </summary>
|
||||||
|
private static byte[] MakeDib(Image<Rgba32> image, BmpBitsPerPixel bpp)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
image.Save(ms, new BmpEncoder { BitsPerPixel = bpp });
|
||||||
|
return ms.ToArray()[14..];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Image<Rgba32> MakeTestImage()
|
||||||
|
{
|
||||||
|
var image = new Image<Rgba32>(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<Rgba32>(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<Rgba32>(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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user