Initial first version.

This commit is contained in:
Ebbe Baß
2026-08-13 10:04:59 +02:00
parent df725b43c4
commit 940e80b630
22 changed files with 890 additions and 0 deletions
@@ -0,0 +1,82 @@
using System.Collections.Specialized;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Windows;
namespace EbbesMemeClipboard.Services;
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();
// CF_HDROP: lets Discord/Slack/Teams/Explorer paste the real file (animated, for GIFs).
data.SetFileDropList(new StringCollection { filePath });
// CF_DIB fallback: apps that only accept bitmap paste (e.g. Paint) get a static image.
// 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));
}
await SetClipboardWithRetryAsync(data);
}
private static async Task SetClipboardWithRetryAsync(DataObject data)
{
const int maxAttempts = 5;
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
Clipboard.SetDataObject(data, copy: true);
return;
}
catch (COMException) when (attempt < maxAttempts)
{
// Another process is transiently holding the clipboard - back off and retry.
await Task.Delay(75);
}
}
}
/// <summary>
/// Builds a CF_HTML clipboard payload. WPF's DataObject.SetData(DataFormats.Html, ...) does
/// NOT wrap plain HTML into CF_HTML automatically - the Version/StartHTML/EndHTML/
/// StartFragment/EndFragment header with exact byte offsets has to be built by hand, or
/// paste targets will reject or mis-render the fragment.
/// </summary>
private static string BuildCfHtml(string htmlFragment)
{
const string prefix = "<html><body><!--StartFragment-->";
const string suffix = "<!--EndFragment--></body></html>";
int headerLength = Encoding.UTF8.GetByteCount(FormatHeader(0, 0, 0, 0));
int startHtml = headerLength;
int startFragment = startHtml + Encoding.UTF8.GetByteCount(prefix);
int endFragment = startFragment + Encoding.UTF8.GetByteCount(htmlFragment);
int endHtml = endFragment + Encoding.UTF8.GetByteCount(suffix);
return FormatHeader(startHtml, endHtml, startFragment, endFragment) + prefix + htmlFragment + suffix;
}
private static string FormatHeader(int startHtml, int endHtml, int startFragment, int endFragment) =>
"Version:0.9\r\n" +
$"StartHTML:{startHtml:D10}\r\n" +
$"EndHTML:{endHtml:D10}\r\n" +
$"StartFragment:{startFragment:D10}\r\n" +
$"EndFragment:{endFragment:D10}\r\n";
}
@@ -0,0 +1,74 @@
using System.Windows.Input;
using System.Windows.Interop;
using EbbesMemeClipboard.Native;
namespace EbbesMemeClipboard.Services;
public sealed class GlobalHotkeyService : IGlobalHotkeyService
{
private const int HotkeyId = 0xB001;
private const int HwndMessage = -3;
private readonly HwndSource _messageSource;
private bool _registered;
public event EventHandler? HotkeyPressed;
public GlobalHotkeyService()
{
var parameters = new HwndSourceParameters("EbbesMemeClipboardHotkeyWindow")
{
WindowStyle = 0,
ParentWindow = new IntPtr(HwndMessage),
};
_messageSource = new HwndSource(parameters);
_messageSource.AddHook(WndProc);
}
public bool Register(ModifierKeys modifiers, Key key)
{
Unregister();
uint nativeModifiers = ToNativeModifiers(modifiers) | HotkeyConstants.MOD_NOREPEAT;
uint virtualKey = (uint)KeyInterop.VirtualKeyFromKey(key);
_registered = NativeMethods.RegisterHotKey(_messageSource.Handle, HotkeyId, nativeModifiers, virtualKey);
return _registered;
}
public void Unregister()
{
if (_registered)
{
NativeMethods.UnregisterHotKey(_messageSource.Handle, HotkeyId);
_registered = false;
}
}
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg == HotkeyConstants.WM_HOTKEY && wParam.ToInt32() == HotkeyId)
{
HotkeyPressed?.Invoke(this, EventArgs.Empty);
handled = true;
}
return IntPtr.Zero;
}
private static uint ToNativeModifiers(ModifierKeys modifiers)
{
uint result = 0;
if (modifiers.HasFlag(ModifierKeys.Alt)) result |= HotkeyConstants.MOD_ALT;
if (modifiers.HasFlag(ModifierKeys.Control)) result |= HotkeyConstants.MOD_CONTROL;
if (modifiers.HasFlag(ModifierKeys.Shift)) result |= HotkeyConstants.MOD_SHIFT;
if (modifiers.HasFlag(ModifierKeys.Windows)) result |= HotkeyConstants.MOD_WIN;
return result;
}
public void Dispose()
{
Unregister();
_messageSource.RemoveHook(WndProc);
_messageSource.Dispose();
}
}
@@ -0,0 +1,11 @@
namespace EbbesMemeClipboard.Services;
public interface IClipboardService
{
/// <summary>
/// Places a local file on the clipboard using multiple formats simultaneously so both
/// file-paste apps (Discord/Slack/Teams) and bitmap-only apps (Paint) get something
/// useful, and animated GIFs survive as animations wherever possible.
/// </summary>
Task CopyLocalFileAsync(string filePath);
}
@@ -0,0 +1,13 @@
using System.Windows.Input;
namespace EbbesMemeClipboard.Services;
public interface IGlobalHotkeyService : IDisposable
{
event EventHandler? HotkeyPressed;
/// <summary>Returns false if the combo is already claimed by the OS or another app.</summary>
bool Register(ModifierKeys modifiers, Key key);
void Unregister();
}
@@ -0,0 +1,11 @@
using EbbesMemeClipboard.Models;
namespace EbbesMemeClipboard.Services;
public interface ILocalMemeLibraryService
{
IReadOnlyList<LocalMemeRecord> GetAll();
IReadOnlyList<LocalMemeRecord> Search(string query);
string GetFullPath(LocalMemeRecord record);
Task<IReadOnlyList<LocalMemeRecord>> ImportAsync(IEnumerable<string> sourceFilePaths);
}
@@ -0,0 +1,38 @@
using System.IO;
using System.Windows.Media.Imaging;
namespace EbbesMemeClipboard.Services;
internal static class ImageDecoding
{
internal static bool IsGif(string path) =>
Path.GetExtension(path).Equals(".gif", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Decodes just the first frame of an image (for GIFs, frame 0 only - the rest of the
/// animation is never touched). The result is frozen so it can be handed back across
/// threads safely when called from a background thread.
/// </summary>
internal static BitmapSource DecodeFirstFrame(string path, int? decodePixelWidth = null)
{
if (IsGif(path))
{
var decoder = new GifBitmapDecoder(new Uri(path), BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
var frame = decoder.Frames[0];
frame.Freeze();
return frame;
}
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.UriSource = new Uri(path);
bitmap.CacheOption = BitmapCacheOption.OnLoad;
if (decodePixelWidth is int width)
{
bitmap.DecodePixelWidth = width;
}
bitmap.EndInit();
bitmap.Freeze();
return bitmap;
}
}
@@ -0,0 +1,101 @@
using System.IO;
using System.Text.Json;
using EbbesMemeClipboard.Models;
namespace EbbesMemeClipboard.Services;
public sealed class LocalMemeLibraryService : ILocalMemeLibraryService
{
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg", ".jpeg", ".png", ".gif",
};
private readonly string _libraryRoot;
private readonly string _indexPath;
private readonly List<LocalMemeRecord> _records;
private readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
public LocalMemeLibraryService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
_libraryRoot = Path.Combine(appData, "EbbesMemeClipboard", "Library");
Directory.CreateDirectory(_libraryRoot);
_indexPath = Path.Combine(_libraryRoot, "index.json");
_records = LoadIndex();
}
public IReadOnlyList<LocalMemeRecord> GetAll() =>
_records.OrderByDescending(r => r.DateAdded).ToList();
public IReadOnlyList<LocalMemeRecord> Search(string query) =>
_records
.Where(r => r.OriginalFileName.Contains(query, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(r => r.DateAdded)
.ToList();
public string GetFullPath(LocalMemeRecord record) => Path.Combine(_libraryRoot, record.FileName);
public async Task<IReadOnlyList<LocalMemeRecord>> ImportAsync(IEnumerable<string> sourceFilePaths)
{
var imported = new List<LocalMemeRecord>();
foreach (var sourcePath in sourceFilePaths)
{
var extension = Path.GetExtension(sourcePath);
if (!AllowedExtensions.Contains(extension) || !File.Exists(sourcePath))
continue;
var storedFileName = $"{Guid.NewGuid():N}{extension.ToLowerInvariant()}";
var destinationPath = Path.Combine(_libraryRoot, storedFileName);
using (var sourceStream = File.OpenRead(sourcePath))
using (var destStream = File.Create(destinationPath))
{
await sourceStream.CopyToAsync(destStream);
}
var record = new LocalMemeRecord
{
Id = Guid.NewGuid().ToString("N"),
FileName = storedFileName,
OriginalFileName = Path.GetFileName(sourcePath),
DateAdded = DateTimeOffset.Now,
};
_records.Add(record);
imported.Add(record);
}
if (imported.Count > 0)
{
await SaveIndexAsync();
}
return imported;
}
private List<LocalMemeRecord> LoadIndex()
{
if (!File.Exists(_indexPath))
return new List<LocalMemeRecord>();
try
{
var json = File.ReadAllText(_indexPath);
return JsonSerializer.Deserialize<List<LocalMemeRecord>>(json, _jsonOptions) ?? new List<LocalMemeRecord>();
}
catch (JsonException)
{
// Corrupt index: start fresh rather than crashing. Previously imported files stay
// on disk but drop out of search until re-imported.
return new List<LocalMemeRecord>();
}
}
private async Task SaveIndexAsync()
{
var json = JsonSerializer.Serialize(_records, _jsonOptions);
await File.WriteAllTextAsync(_indexPath, json);
}
}