Added pasting and sending features

This commit is contained in:
Ebbe Baß
2026-08-13 15:43:34 +02:00
parent 940e80b630
commit ceadaff995
22 changed files with 771 additions and 55 deletions
@@ -0,0 +1,67 @@
using EbbesMemeClipboard.Native;
namespace EbbesMemeClipboard.Services;
public sealed class AutoPasteService : IAutoPasteService
{
// Both delays exist because target apps (Discord/Slack/Teams etc. especially) need a moment
// to actually process regaining focus, and then again to process the pasted image, before
// they're ready for the next synthetic keystroke - firing too soon is a common cause of the
// paste (or the follow-up Enter) silently getting dropped.
private static readonly TimeSpan FocusSettleDelay = TimeSpan.FromMilliseconds(200);
private static readonly TimeSpan PasteSettleDelay = TimeSpan.FromMilliseconds(400);
private IntPtr _capturedForeground;
public void CaptureForegroundWindow() => _capturedForeground = NativeMethods.GetForegroundWindow();
public async Task PasteAsync(bool alsoSend)
{
var target = _capturedForeground;
if (target == IntPtr.Zero || !RestoreForeground(target))
{
// Couldn't confirm we got the right window focused - the clipboard write already
// succeeded regardless, so bail rather than risk sending keystrokes somewhere else.
return;
}
await Task.Delay(FocusSettleDelay);
InputSimulation.SendKeyCombo(InputSimulation.VK_CONTROL, InputSimulation.VK_V);
if (alsoSend)
{
await Task.Delay(PasteSettleDelay);
InputSimulation.SendKeyTap(InputSimulation.VK_RETURN);
}
}
private static bool RestoreForeground(IntPtr target)
{
// No synthetic Alt tap here (a common trick to "unlock" SetForegroundWindow when focus-
// stealing prevention would otherwise block it) - it isn't needed in our case, since the
// picker window is already the foreground process at this point (from Activate() moments
// earlier), which is itself one of the documented conditions Windows exempts from that
// restriction. It was also actively harmful for Electron/Chromium targets like Teams:
// those apps often treat a lone Alt press as "toggle menu bar focus", which knocked their
// compose box's own internal focus away right before the paste - the window still came
// back to the foreground correctly, but Ctrl+V had nothing focused to land in.
uint targetThread = NativeMethods.GetWindowThreadProcessId(target, out _);
uint currentThread = NativeMethods.GetCurrentThreadId();
bool attached = targetThread != currentThread &&
NativeMethods.AttachThreadInput(currentThread, targetThread, true);
try
{
NativeMethods.SetForegroundWindow(target);
}
finally
{
if (attached)
{
NativeMethods.AttachThreadInput(currentThread, targetThread, false);
}
}
return NativeMethods.GetForegroundWindow() == target;
}
}
@@ -3,6 +3,7 @@ using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Windows;
using System.Windows.Media.Imaging;
namespace EbbesMemeClipboard.Services;
@@ -10,8 +11,6 @@ public sealed class ClipboardService : IClipboardService
{
public async Task CopyLocalFileAsync(string filePath)
{
bool isGif = ImageDecoding.IsGif(filePath);
var firstFrame = await Task.Run(() => ImageDecoding.DecodeFirstFrame(filePath));
var data = new DataObject();
@@ -23,18 +22,36 @@ public sealed class ClipboardService : IClipboardService
// WPF's DataObject.SetImage auto-converts to CF_DIB - no manual header work needed here.
data.SetImage(firstFrame);
if (isGif)
{
// CF_HTML: the format most browser/rich-text paste targets use, and the only one of
// the three that preserves animation for those targets (CF_DIB is a single frame).
var bytes = await File.ReadAllBytesAsync(filePath);
var fragment = $"<img src=\"data:image/gif;base64,{Convert.ToBase64String(bytes)}\">";
data.SetData(DataFormats.Html, BuildCfHtml(fragment));
}
// "PNG": a registered clipboard format (not one of the classic CF_* constants) that
// Chromium-based apps specifically look for and prefer over CF_DIB/CF_BITMAP when
// reading a pasted image, because those older formats are lossy for transparency. This
// covers Teams, Slack, Discord's web layer, and browsers - without it, paste into any of
// them can silently produce nothing even though CF_DIB is present and perfectly valid.
using var pngStream = new MemoryStream();
var pngEncoder = new PngBitmapEncoder();
pngEncoder.Frames.Add(BitmapFrame.Create(firstFrame));
pngEncoder.Save(pngStream);
pngStream.Position = 0;
data.SetData("PNG", pngStream);
// CF_HTML: the other route those same web-based paste targets check, and the only one of
// these formats that preserves animation for them, for GIFs.
var bytes = await File.ReadAllBytesAsync(filePath);
var fragment = $"<img src=\"data:{GetMimeType(filePath)};base64,{Convert.ToBase64String(bytes)}\">";
data.SetData(DataFormats.Html, BuildCfHtml(fragment));
await SetClipboardWithRetryAsync(data);
}
private static string GetMimeType(string filePath) =>
Path.GetExtension(filePath).ToLowerInvariant() switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
_ => "application/octet-stream",
};
private static async Task SetClipboardWithRetryAsync(DataObject data)
{
const int maxAttempts = 5;
@@ -0,0 +1,18 @@
using System.Text;
using System.Windows.Input;
namespace EbbesMemeClipboard.Services;
internal static class HotkeyFormatter
{
internal static string Format(ModifierKeys modifiers, Key key)
{
var sb = new StringBuilder();
if (modifiers.HasFlag(ModifierKeys.Control)) sb.Append("Ctrl + ");
if (modifiers.HasFlag(ModifierKeys.Alt)) sb.Append("Alt + ");
if (modifiers.HasFlag(ModifierKeys.Shift)) sb.Append("Shift + ");
if (modifiers.HasFlag(ModifierKeys.Windows)) sb.Append("Win + ");
sb.Append(key);
return sb.ToString();
}
}
@@ -0,0 +1,10 @@
namespace EbbesMemeClipboard.Services;
public interface IAutoPasteService
{
/// <summary>Remembers whatever window was focused before the picker is shown, so it can be restored afterward.</summary>
void CaptureForegroundWindow();
/// <summary>Restores focus to the captured window and pastes (Ctrl+V); optionally also presses Enter to send it.</summary>
Task PasteAsync(bool alsoSend);
}
@@ -8,4 +8,5 @@ public interface ILocalMemeLibraryService
IReadOnlyList<LocalMemeRecord> Search(string query);
string GetFullPath(LocalMemeRecord record);
Task<IReadOnlyList<LocalMemeRecord>> ImportAsync(IEnumerable<string> sourceFilePaths);
Task RemoveAsync(LocalMemeRecord record);
}
@@ -0,0 +1,9 @@
using EbbesMemeClipboard.Models;
namespace EbbesMemeClipboard.Services;
public interface ISettingsService
{
AppSettings Current { get; }
Task SaveAsync();
}
@@ -75,6 +75,31 @@ public sealed class LocalMemeLibraryService : ILocalMemeLibraryService
return imported;
}
public async Task RemoveAsync(LocalMemeRecord record)
{
var path = GetFullPath(record);
_records.RemoveAll(r => r.Id == record.Id);
await SaveIndexAsync();
try
{
if (File.Exists(path))
{
// Recycle Bin, not a hard delete - a misclick on "Remove" shouldn't be unrecoverable.
Microsoft.VisualBasic.FileIO.FileSystem.DeleteFile(
path,
Microsoft.VisualBasic.FileIO.UIOption.OnlyErrorDialogs,
Microsoft.VisualBasic.FileIO.RecycleOption.SendToRecycleBin);
}
}
catch (IOException)
{
// File locked or otherwise inaccessible - it's already out of the index (what the
// picker shows), so leave the orphaned file for manual cleanup rather than failing.
}
}
private List<LocalMemeRecord> LoadIndex()
{
if (!File.Exists(_indexPath))
@@ -0,0 +1,49 @@
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using EbbesMemeClipboard.Models;
namespace EbbesMemeClipboard.Services;
public sealed class SettingsService : ISettingsService
{
private readonly string _settingsPath;
private readonly JsonSerializerOptions _jsonOptions = new()
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter() },
};
public AppSettings Current { get; }
public SettingsService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var dir = Path.Combine(appData, "EbbesMemeClipboard");
Directory.CreateDirectory(dir);
_settingsPath = Path.Combine(dir, "settings.json");
Current = Load();
}
private AppSettings Load()
{
if (!File.Exists(_settingsPath))
return new AppSettings();
try
{
var json = File.ReadAllText(_settingsPath);
return JsonSerializer.Deserialize<AppSettings>(json, _jsonOptions) ?? new AppSettings();
}
catch (JsonException)
{
return new AppSettings();
}
}
public async Task SaveAsync()
{
var json = JsonSerializer.Serialize(Current, _jsonOptions);
await File.WriteAllTextAsync(_settingsPath, json);
}
}