Initial first version.
This commit is contained in:
@@ -12,3 +12,12 @@
|
|||||||
# Built Visual Studio Code Extensions
|
# Built Visual Studio Code Extensions
|
||||||
*.vsix
|
*.vsix
|
||||||
|
|
||||||
|
# ---> dotnet / Visual Studio
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
*.user
|
||||||
|
publish/
|
||||||
|
|
||||||
|
# Local app data seeded during manual testing
|
||||||
|
diag.log
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/EbbesMemeClipboard/EbbesMemeClipboard.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Application x:Class="EbbesMemeClipboard.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:tb="clr-namespace:H.NotifyIcon;assembly=H.NotifyIcon.Wpf">
|
||||||
|
<Application.Resources>
|
||||||
|
<tb:TaskbarIcon x:Key="TrayIcon"
|
||||||
|
IconSource="/Assets/tray-icon.ico"
|
||||||
|
ToolTipText="Ebbe's Meme Clipboard"
|
||||||
|
MenuActivation="RightClick">
|
||||||
|
<tb:TaskbarIcon.ContextMenu>
|
||||||
|
<ContextMenu>
|
||||||
|
<MenuItem Header="Open Picker" Click="TrayOpenPicker_Click"/>
|
||||||
|
<MenuItem Header="Add Memes..." Click="TrayAddMemes_Click"/>
|
||||||
|
<Separator/>
|
||||||
|
<MenuItem Header="Exit" Click="TrayExit_Click"/>
|
||||||
|
</ContextMenu>
|
||||||
|
</tb:TaskbarIcon.ContextMenu>
|
||||||
|
</tb:TaskbarIcon>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using EbbesMemeClipboard.Services;
|
||||||
|
using EbbesMemeClipboard.ViewModels;
|
||||||
|
using EbbesMemeClipboard.Views;
|
||||||
|
using H.NotifyIcon;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace EbbesMemeClipboard;
|
||||||
|
|
||||||
|
public partial class App : Application
|
||||||
|
{
|
||||||
|
private static readonly ModifierKeys DefaultHotkeyModifiers = ModifierKeys.Control | ModifierKeys.Alt;
|
||||||
|
private const Key DefaultHotkeyKey = Key.M;
|
||||||
|
|
||||||
|
private IServiceProvider _services = null!;
|
||||||
|
private IGlobalHotkeyService _hotkeyService = null!;
|
||||||
|
private PickerWindow _pickerWindow = null!;
|
||||||
|
private PickerViewModel _pickerViewModel = null!;
|
||||||
|
private TaskbarIcon? _trayIcon;
|
||||||
|
|
||||||
|
protected override void OnStartup(StartupEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnStartup(e);
|
||||||
|
ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||||
|
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddSingleton<ILocalMemeLibraryService, LocalMemeLibraryService>();
|
||||||
|
services.AddSingleton<IClipboardService, ClipboardService>();
|
||||||
|
services.AddSingleton<IGlobalHotkeyService, GlobalHotkeyService>();
|
||||||
|
services.AddSingleton<PickerViewModel>();
|
||||||
|
services.AddSingleton<PickerWindow>();
|
||||||
|
_services = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
_pickerViewModel = _services.GetRequiredService<PickerViewModel>();
|
||||||
|
_pickerWindow = _services.GetRequiredService<PickerWindow>();
|
||||||
|
|
||||||
|
_trayIcon = (TaskbarIcon)Resources["TrayIcon"];
|
||||||
|
_trayIcon.TrayLeftMouseUp += (_, _) => _pickerWindow.ToggleVisibility();
|
||||||
|
|
||||||
|
_hotkeyService = _services.GetRequiredService<IGlobalHotkeyService>();
|
||||||
|
if (_hotkeyService.Register(DefaultHotkeyModifiers, DefaultHotkeyKey))
|
||||||
|
{
|
||||||
|
_hotkeyService.HotkeyPressed += (_, _) => Dispatcher.Invoke(() => _pickerWindow.ToggleVisibility());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
MessageBox.Show(
|
||||||
|
"Couldn't register the global hotkey (Ctrl+Alt+M) - it may already be in use by another application. " +
|
||||||
|
"You can still open the picker from the tray icon.",
|
||||||
|
"Ebbe's Meme Clipboard",
|
||||||
|
MessageBoxButton.OK,
|
||||||
|
MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TrayOpenPicker_Click(object sender, RoutedEventArgs e) => _pickerWindow.ShowNearCursor();
|
||||||
|
|
||||||
|
private async void TrayAddMemes_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_pickerWindow.ShowNearCursor();
|
||||||
|
await _pickerViewModel.ImportFilesCommand.ExecuteAsync(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TrayExit_Click(object sender, RoutedEventArgs e) => Shutdown();
|
||||||
|
|
||||||
|
protected override void OnExit(ExitEventArgs e)
|
||||||
|
{
|
||||||
|
_hotkeyService?.Dispose();
|
||||||
|
_trayIcon?.Dispose();
|
||||||
|
base.OnExit(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
[assembly:ThemeInfo(
|
||||||
|
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
|
||||||
|
//(used if a resource is not found in the page,
|
||||||
|
// or application resource dictionaries)
|
||||||
|
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
|
||||||
|
//(used if a resource is not found in the page,
|
||||||
|
// app, or any theme specific resource dictionaries)
|
||||||
|
)]
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 766 B |
@@ -0,0 +1,39 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net10.0-windows</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UseWPF>true</UseWPF>
|
||||||
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<AssemblyName>EbbesMemeClipboard</AssemblyName>
|
||||||
|
<RootNamespace>EbbesMemeClipboard</RootNamespace>
|
||||||
|
<ApplicationIcon>Assets\tray-icon.ico</ApplicationIcon>
|
||||||
|
<!-- This is a WPF app; DPI awareness is correctly declared via app.manifest (the standard
|
||||||
|
WPF mechanism). WinForms is referenced only for two static helpers (Cursor/Screen),
|
||||||
|
not for any WinForms UI, so its "use SetHighDpiMode instead" suggestion doesn't apply. -->
|
||||||
|
<NoWarn>$(NoWarn);WFO0003</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="H.NotifyIcon.Wpf" Version="2.4.1" />
|
||||||
|
<PackageReference Include="WPF-UI" Version="4.3.0" />
|
||||||
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Resource Include="Assets\tray-icon.ico" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- UseWPF + UseWindowsForms both contribute implicit global usings, and
|
||||||
|
System.Windows.Forms collides with System.Windows on Application/DataObject/
|
||||||
|
KeyEventArgs/DragEventArgs. WPF types should win unqualified; the few WinForms
|
||||||
|
helpers we use (Cursor, Screen) are referenced with a full type name instead. -->
|
||||||
|
<Using Remove="System.Windows.Forms" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace EbbesMemeClipboard.Models;
|
||||||
|
|
||||||
|
public sealed class LocalMemeRecord
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
public required string FileName { get; init; }
|
||||||
|
public required string OriginalFileName { get; init; }
|
||||||
|
public required DateTimeOffset DateAdded { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace EbbesMemeClipboard.Native;
|
||||||
|
|
||||||
|
internal static class HotkeyConstants
|
||||||
|
{
|
||||||
|
internal const int WM_HOTKEY = 0x0312;
|
||||||
|
|
||||||
|
internal const uint MOD_ALT = 0x0001;
|
||||||
|
internal const uint MOD_CONTROL = 0x0002;
|
||||||
|
internal const uint MOD_SHIFT = 0x0004;
|
||||||
|
internal const uint MOD_WIN = 0x0008;
|
||||||
|
|
||||||
|
// Prevents the shell from re-firing WM_HOTKEY on key-repeat while the combo is held down.
|
||||||
|
internal const uint MOD_NOREPEAT = 0x4000;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace EbbesMemeClipboard.Native;
|
||||||
|
|
||||||
|
internal static class NativeMethods
|
||||||
|
{
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
internal static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
internal static extern bool UnregisterHotKey(IntPtr hWnd, int id);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using EbbesMemeClipboard.Models;
|
||||||
|
using EbbesMemeClipboard.Services;
|
||||||
|
|
||||||
|
namespace EbbesMemeClipboard.ViewModels;
|
||||||
|
|
||||||
|
public partial class MemeTileViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private const int ThumbnailPixelWidth = 150;
|
||||||
|
|
||||||
|
public LocalMemeRecord Record { get; }
|
||||||
|
public string FullPath { get; }
|
||||||
|
public string DisplayName => Record.OriginalFileName;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private BitmapSource? _thumbnail;
|
||||||
|
|
||||||
|
public MemeTileViewModel(LocalMemeRecord record, string fullPath)
|
||||||
|
{
|
||||||
|
Record = record;
|
||||||
|
FullPath = fullPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LoadThumbnailAsync()
|
||||||
|
{
|
||||||
|
var path = FullPath;
|
||||||
|
Thumbnail = await Task.Run(() => ImageDecoding.DecodeFirstFrame(path, ThumbnailPixelWidth));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.IO;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using EbbesMemeClipboard.Services;
|
||||||
|
|
||||||
|
namespace EbbesMemeClipboard.ViewModels;
|
||||||
|
|
||||||
|
public partial class PickerViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly ILocalMemeLibraryService _library;
|
||||||
|
private readonly IClipboardService _clipboard;
|
||||||
|
|
||||||
|
public ObservableCollection<MemeTileViewModel> Items { get; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _searchText = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _statusMessage = string.Empty;
|
||||||
|
|
||||||
|
public event EventHandler? RequestClose;
|
||||||
|
|
||||||
|
public PickerViewModel(ILocalMemeLibraryService library, IClipboardService clipboard)
|
||||||
|
{
|
||||||
|
_library = library;
|
||||||
|
_clipboard = clipboard;
|
||||||
|
RefreshItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSearchTextChanged(string value) => RefreshItems();
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SelectItemAsync(MemeTileViewModel? tile)
|
||||||
|
{
|
||||||
|
if (tile is null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _clipboard.CopyLocalFileAsync(tile.FullPath);
|
||||||
|
StatusMessage = $"Copied {tile.DisplayName}";
|
||||||
|
RequestClose?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
catch (IOException ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Couldn't copy {tile.DisplayName}: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ImportFilesAsync()
|
||||||
|
{
|
||||||
|
var dialog = new Microsoft.Win32.OpenFileDialog
|
||||||
|
{
|
||||||
|
Multiselect = true,
|
||||||
|
Filter = "Images and GIFs|*.jpg;*.jpeg;*.png;*.gif",
|
||||||
|
Title = "Add memes",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dialog.ShowDialog() == true)
|
||||||
|
{
|
||||||
|
await ImportPathsAsync(dialog.FileNames);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ImportPathsAsync(IEnumerable<string> paths)
|
||||||
|
{
|
||||||
|
var candidates = paths
|
||||||
|
.Where(p => Path.GetExtension(p).ToLowerInvariant() is ".jpg" or ".jpeg" or ".png" or ".gif")
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
{
|
||||||
|
StatusMessage = "No supported image/GIF files in that selection.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var imported = await _library.ImportAsync(candidates);
|
||||||
|
RefreshItems();
|
||||||
|
StatusMessage = $"Added {imported.Count} meme(s).";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnShown()
|
||||||
|
{
|
||||||
|
SearchText = string.Empty;
|
||||||
|
RefreshItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshItems()
|
||||||
|
{
|
||||||
|
var records = string.IsNullOrWhiteSpace(SearchText)
|
||||||
|
? _library.GetAll()
|
||||||
|
: _library.Search(SearchText);
|
||||||
|
|
||||||
|
Items.Clear();
|
||||||
|
foreach (var record in records)
|
||||||
|
{
|
||||||
|
var tile = new MemeTileViewModel(record, _library.GetFullPath(record));
|
||||||
|
Items.Add(tile);
|
||||||
|
_ = tile.LoadThumbnailAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<Window x:Class="EbbesMemeClipboard.Views.PickerWindow"
|
||||||
|
x:Name="RootWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:EbbesMemeClipboard.ViewModels"
|
||||||
|
Title="Meme Clipboard"
|
||||||
|
Width="420" Height="520"
|
||||||
|
WindowStyle="None"
|
||||||
|
AllowsTransparency="True"
|
||||||
|
Background="Transparent"
|
||||||
|
ShowInTaskbar="False"
|
||||||
|
Topmost="True"
|
||||||
|
ResizeMode="NoResize"
|
||||||
|
WindowStartupLocation="Manual"
|
||||||
|
AllowDrop="True"
|
||||||
|
Deactivated="PickerWindow_OnDeactivated"
|
||||||
|
KeyDown="PickerWindow_OnKeyDown"
|
||||||
|
Drop="PickerWindow_OnDrop"
|
||||||
|
DragOver="PickerWindow_OnDragOver">
|
||||||
|
<Window.Resources>
|
||||||
|
<Style x:Key="FlatButtonStyle" TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="#2B2D31"/>
|
||||||
|
<Setter Property="Foreground" Value="White"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="8" SnapsToDevicePixels="True">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" Margin="{TemplateBinding Padding}"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#3A3D42"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsPressed" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#454952"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
|
<Border Background="#F0202225" CornerRadius="10" BorderBrush="#3A3B3E" BorderThickness="1">
|
||||||
|
<Grid Margin="12">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<Grid Grid.Row="0" Margin="0,0,0,10">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Border Grid.Column="0" Background="#2B2D31" CornerRadius="8">
|
||||||
|
<TextBox x:Name="SearchBox"
|
||||||
|
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="Transparent" Foreground="White" BorderThickness="0"
|
||||||
|
Padding="10,7" FontSize="14"
|
||||||
|
CaretBrush="White"/>
|
||||||
|
</Border>
|
||||||
|
<Button Grid.Column="1" Content="+" Width="36" Height="36" Margin="8,0,0,0"
|
||||||
|
FontSize="18" Style="{StaticResource FlatButtonStyle}"
|
||||||
|
Command="{Binding ImportFilesCommand}"
|
||||||
|
ToolTip="Add memes"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
|
||||||
|
<Grid>
|
||||||
|
<TextBlock Text="No memes yet. Click + or drag files in here."
|
||||||
|
Foreground="#7B7D85" FontSize="13"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
|
TextWrapping="Wrap" TextAlignment="Center" Width="260">
|
||||||
|
<TextBlock.Style>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding Items.Count}" Value="0">
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Style>
|
||||||
|
</TextBlock>
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding Items}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<WrapPanel Orientation="Horizontal"/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate DataType="{x:Type vm:MemeTileViewModel}">
|
||||||
|
<Button Width="88" Height="88" Margin="4" Padding="0"
|
||||||
|
Style="{StaticResource FlatButtonStyle}"
|
||||||
|
Command="{Binding DataContext.SelectItemCommand, ElementName=RootWindow}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
ToolTip="{Binding DisplayName}">
|
||||||
|
<Image Source="{Binding Thumbnail}" Stretch="Uniform" Width="80" Height="80"/>
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</Grid>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="2" Text="{Binding StatusMessage}" Foreground="#9A9CA3" FontSize="11" Margin="2,8,0,0"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using EbbesMemeClipboard.ViewModels;
|
||||||
|
|
||||||
|
namespace EbbesMemeClipboard.Views;
|
||||||
|
|
||||||
|
public partial class PickerWindow : Window
|
||||||
|
{
|
||||||
|
private readonly PickerViewModel _viewModel;
|
||||||
|
|
||||||
|
public PickerWindow(PickerViewModel viewModel)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_viewModel = viewModel;
|
||||||
|
DataContext = _viewModel;
|
||||||
|
_viewModel.RequestClose += (_, _) => Hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowNearCursor()
|
||||||
|
{
|
||||||
|
PositionNearCursor();
|
||||||
|
_viewModel.OnShown();
|
||||||
|
Show();
|
||||||
|
Activate();
|
||||||
|
SearchBox.Focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ToggleVisibility()
|
||||||
|
{
|
||||||
|
if (IsVisible)
|
||||||
|
{
|
||||||
|
Hide();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ShowNearCursor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Positions the popup near the mouse cursor, clamped to the current monitor's working
|
||||||
|
/// area. The real Windows Emoji Panel can follow the text caret because it's a privileged
|
||||||
|
/// shell component; a third-party app can't replicate that, so cursor position is the
|
||||||
|
/// practical stand-in. Uses a single DPI scale factor for the conversion from WinForms'
|
||||||
|
/// physical pixels to WPF's device-independent units, which is exact for same-DPI
|
||||||
|
/// multi-monitor setups (the common case) and only approximate when the cursor is on a
|
||||||
|
/// secondary monitor running a different DPI scale than the one this window last rendered on.
|
||||||
|
/// </summary>
|
||||||
|
private void PositionNearCursor()
|
||||||
|
{
|
||||||
|
var cursor = System.Windows.Forms.Cursor.Position;
|
||||||
|
var workingArea = System.Windows.Forms.Screen.FromPoint(cursor).WorkingArea;
|
||||||
|
|
||||||
|
double scale = VisualTreeHelper.GetDpi(this).DpiScaleX;
|
||||||
|
|
||||||
|
double left = cursor.X / scale;
|
||||||
|
double top = cursor.Y / scale;
|
||||||
|
double maxLeft = (workingArea.Right / scale) - Width;
|
||||||
|
double maxTop = (workingArea.Bottom / scale) - Height;
|
||||||
|
double minLeft = workingArea.Left / scale;
|
||||||
|
double minTop = workingArea.Top / scale;
|
||||||
|
|
||||||
|
Left = Math.Clamp(left, minLeft, Math.Max(minLeft, maxLeft));
|
||||||
|
Top = Math.Clamp(top, minTop, Math.Max(minTop, maxTop));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PickerWindow_OnDeactivated(object? sender, EventArgs e) => Hide();
|
||||||
|
|
||||||
|
private void PickerWindow_OnKeyDown(object sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == Key.Escape) Hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PickerWindow_OnDragOver(object sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None;
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void PickerWindow_OnDrop(object sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Data.GetData(DataFormats.FileDrop) is string[] paths)
|
||||||
|
{
|
||||||
|
await _viewModel.ImportPathsAsync(paths);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnClosing(CancelEventArgs e)
|
||||||
|
{
|
||||||
|
// This window is reused for the app's whole lifetime - closing it (e.g. Alt+F4) should
|
||||||
|
// just hide it. Real shutdown only happens via the tray icon's Exit command.
|
||||||
|
e.Cancel = true;
|
||||||
|
Hide();
|
||||||
|
base.OnClosing(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="EbbesMemeClipboard.app"/>
|
||||||
|
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<!-- Windows Vista/7/8/8.1/10/11 -->
|
||||||
|
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}"/>
|
||||||
|
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
|
||||||
|
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
|
||||||
|
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<!-- Per-Monitor V2 DPI awareness: needed for correct popup placement across monitors with different scaling -->
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
|
||||||
|
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
</assembly>
|
||||||
Reference in New Issue
Block a user