diff --git a/osu.Framework.Android/AndroidClipboard.cs b/osu.Framework.Android/AndroidClipboard.cs index c6f3151340..bfd137cba5 100644 --- a/osu.Framework.Android/AndroidClipboard.cs +++ b/osu.Framework.Android/AndroidClipboard.cs @@ -1,7 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using Android.Content; +using NuGet.Packaging; using osu.Framework.Platform; using SixLabors.ImageSharp; @@ -10,6 +12,7 @@ namespace osu.Framework.Android public class AndroidClipboard : Clipboard { private readonly ClipboardManager? clipboardManager; + private readonly Dictionary customFormatValues = new Dictionary(); public AndroidClipboard(AndroidGameView view) { @@ -18,14 +21,35 @@ public AndroidClipboard(AndroidGameView view) public override string? GetText() => clipboardManager?.PrimaryClip?.GetItemAt(0)?.Text; - public override void SetText(string text) + public override Image? GetImage() => null; + + public override bool SetData(ClipboardData data) { - if (clipboardManager != null) - clipboardManager.PrimaryClip = ClipData.NewPlainText(null, text); - } + if (clipboardManager == null) + return false; - public override Image? GetImage() => null; + customFormatValues.Clear(); + clipboardManager.PrimaryClip = null; + + if (data.IsEmpty()) + return false; + + bool success = true; + + if (data.Text != null) + clipboardManager.PrimaryClip = ClipData.NewPlainText(null, data.Text); - public override bool SetImage(Image image) => false; + if (data.Image != null) + success = false; + + customFormatValues.AddRange(data.CustomFormatValues); + + return success; + } + + public override string? GetCustom(string format) + { + return customFormatValues[format]; + } } } diff --git a/osu.Framework/Platform/Clipboard.cs b/osu.Framework/Platform/Clipboard.cs index e1ff2bc438..9c7c2e4164 100644 --- a/osu.Framework/Platform/Clipboard.cs +++ b/osu.Framework/Platform/Clipboard.cs @@ -20,7 +20,10 @@ public abstract class Clipboard /// Copy text to the clipboard. /// /// Text to copy to the clipboard - public abstract void SetText(string text); + public void SetText(string text) + { + SetData(new ClipboardData { Text = text }); + } /// /// Retrieve an image from the clipboard. @@ -33,6 +36,37 @@ public abstract class Clipboard /// /// The image to copy to the clipboard /// Whether the image was successfully copied or not - public abstract bool SetImage(Image image); + public bool SetImage(Image image) + { + return SetData(new ClipboardData { Image = image }); + } + + /// + /// Retrieve content with custom mime type from the clipboard. + /// + /// Mime type of the clipboard item to retrieve + public abstract string? GetCustom(string mimeType); + + /// + /// Copy item with custom mime type to the clipboard + /// + /// Mime type of the clipboard item + /// Text to copy to the clipboard + public void SetCustom(string mimeType, string text) + { + var data = new ClipboardData + { + CustomFormatValues = { [mimeType] = text } + }; + + SetData(data); + } + + /// + /// Copy multiple values to the clipboard + /// + /// Data to copy the clipboard + /// Whether the data was successfully copied or not + public abstract bool SetData(ClipboardData data); } } diff --git a/osu.Framework/Platform/ClipboardData.cs b/osu.Framework/Platform/ClipboardData.cs new file mode 100644 index 0000000000..48f19f1482 --- /dev/null +++ b/osu.Framework/Platform/ClipboardData.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using SixLabors.ImageSharp; + +namespace osu.Framework.Platform +{ + /// + /// Holds multiple concurrent representations of some data to be copied to the clipboard. + /// + public struct ClipboardData + { + /// + /// Text to be stored in the default plaintext format in the clipboard. + /// + public string? Text; + + /// + /// Image to be stored in the default image format in the clipboard. + /// + public Image? Image; + + /// + /// Contains values to be stored with custom mime type in the clipboard. + /// Keyed by mime type. + /// + public readonly Dictionary CustomFormatValues = new Dictionary(); + + public ClipboardData() + { + Text = null; + Image = null; + } + + /// + /// Returns true if no data is present + /// + public bool IsEmpty() + { + return Text == null && Image == null && CustomFormatValues.Count == 0; + } + } +} diff --git a/osu.Framework/Platform/HeadlessClipboard.cs b/osu.Framework/Platform/HeadlessClipboard.cs index b7dbe9d033..b3bf2b536c 100644 --- a/osu.Framework/Platform/HeadlessClipboard.cs +++ b/osu.Framework/Platform/HeadlessClipboard.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using NuGet.Packaging; using SixLabors.ImageSharp; namespace osu.Framework.Platform @@ -15,21 +17,25 @@ public class HeadlessClipboard : Clipboard { private string? clipboardText; private Image? clipboardImage; + private readonly Dictionary customValues = new Dictionary(); public override string? GetText() => clipboardText; - public override void SetText(string text) + public override Image? GetImage() => clipboardImage?.CloneAs(); + + public override string? GetCustom(string mimeType) { - clipboardImage = null; - clipboardText = text; + return customValues[mimeType]; } - public override Image? GetImage() => clipboardImage?.CloneAs(); - - public override bool SetImage(Image image) + public override bool SetData(ClipboardData data) { - clipboardText = null; - clipboardImage = image; + clipboardText = data.Text; + clipboardImage = data.Image; + customValues.Clear(); + + customValues.AddRange(data.CustomFormatValues); + return true; } } diff --git a/osu.Framework/Platform/MacOS/MacOSClipboard.cs b/osu.Framework/Platform/MacOS/MacOSClipboard.cs index b49e83d9aa..d887780ff8 100644 --- a/osu.Framework/Platform/MacOS/MacOSClipboard.cs +++ b/osu.Framework/Platform/MacOS/MacOSClipboard.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using NuGet.Packaging; using osu.Framework.Platform.MacOS.Native; using SixLabors.ImageSharp; @@ -11,13 +13,16 @@ public class MacOSClipboard : Clipboard { private readonly NSPasteboard generalPasteboard = NSPasteboard.GeneralPasteboard(); + private readonly Dictionary customFormatValues = new Dictionary(); + public override string? GetText() => Cocoa.FromNSString(getFromPasteboard(Class.Get("NSString"))); public override Image? GetImage() => Cocoa.FromNSImage(getFromPasteboard(Class.Get("NSImage"))); - public override void SetText(string text) => setToPasteboard(Cocoa.ToNSString(text)); - - public override bool SetImage(Image image) => setToPasteboard(Cocoa.ToNSImage(image)); + public override string? GetCustom(string mimeType) + { + return customFormatValues[mimeType]; + } private IntPtr getFromPasteboard(IntPtr @class) { @@ -32,12 +37,29 @@ private IntPtr getFromPasteboard(IntPtr @class) return objects?.Length > 0 ? objects[0] : IntPtr.Zero; } + public override bool SetData(ClipboardData data) + { + generalPasteboard.ClearContents(); + customFormatValues.Clear(); + + bool success = true; + + if (data.Text != null) + success &= setToPasteboard(Cocoa.ToNSString(data.Text)); + + if (data.Image != null) + success &= setToPasteboard(Cocoa.ToNSImage(data.Image)); + + customFormatValues.AddRange(data.CustomFormatValues); + + return success; + } + private bool setToPasteboard(IntPtr handle) { if (handle == IntPtr.Zero) return false; - generalPasteboard.ClearContents(); generalPasteboard.WriteObjects(NSArray.ArrayWithObject(handle)); return true; } diff --git a/osu.Framework/Platform/SDL2/SDL2Clipboard.cs b/osu.Framework/Platform/SDL2/SDL2Clipboard.cs index c49cd048fb..64c7b28379 100644 --- a/osu.Framework/Platform/SDL2/SDL2Clipboard.cs +++ b/osu.Framework/Platform/SDL2/SDL2Clipboard.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using NuGet.Packaging; using SDL2; using SixLabors.ImageSharp; @@ -8,21 +10,39 @@ namespace osu.Framework.Platform.SDL2 { public class SDL2Clipboard : Clipboard { + private readonly Dictionary customFormatValues = new Dictionary(); + // SDL cannot differentiate between string.Empty and no text (eg. empty clipboard or an image) // doesn't matter as text editors don't really allow copying empty strings. // assume that empty text means no text. public override string? GetText() => SDL.SDL_HasClipboardText() == SDL.SDL_bool.SDL_TRUE ? SDL.SDL_GetClipboardText() : null; - public override void SetText(string text) => SDL.SDL_SetClipboardText(text); - public override Image? GetImage() { return null; } - public override bool SetImage(Image image) + public override string? GetCustom(string mimeType) + { + return customFormatValues[mimeType]; + } + + public override bool SetData(ClipboardData data) { - return false; + customFormatValues.Clear(); + + if (data.IsEmpty()) + return false; + + if (data.Image != null) + return false; + + if (data.Text != null) + SDL.SDL_SetClipboardText(data.Text); + + customFormatValues.AddRange(data.CustomFormatValues); + + return true; } } } diff --git a/osu.Framework/Platform/Windows/WindowsClipboard.cs b/osu.Framework/Platform/Windows/WindowsClipboard.cs index 53393328a4..7f44aff5dc 100644 --- a/osu.Framework/Platform/Windows/WindowsClipboard.cs +++ b/osu.Framework/Platform/Windows/WindowsClipboard.cs @@ -2,10 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Text; +using Newtonsoft.Json; +using osu.Framework.Logging; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Bmp; @@ -13,24 +16,27 @@ namespace osu.Framework.Platform.Windows { public class WindowsClipboard : Clipboard { - [DllImport("User32.dll")] + [DllImport("User32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool IsClipboardFormatAvailable(uint format); - [DllImport("User32.dll")] + [DllImport("User32.dll", SetLastError = true)] private static extern IntPtr GetClipboardData(uint uFormat); - [DllImport("user32.dll")] + [DllImport("user32.dll", SetLastError = true)] private static extern IntPtr SetClipboardData(uint uFormat, IntPtr hMem); - [DllImport("User32.dll")] + [DllImport("user32.dll", SetLastError = true)] + private static extern uint RegisterClipboardFormatW(IntPtr lpszFormat); + + [DllImport("User32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool OpenClipboard(IntPtr hWndNewOwner); - [DllImport("user32.dll")] + [DllImport("user32.dll", SetLastError = true)] private static extern bool EmptyClipboard(); - [DllImport("User32.dll")] + [DllImport("User32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool CloseClipboard(); @@ -63,19 +69,13 @@ public class WindowsClipboard : Clipboard private static readonly byte[] bmp_header_field = { 0x42, 0x4D }; + private readonly Dictionary registeredFormatIdentifiers = new Dictionary(); + public override string? GetText() { return getClipboard(cf_unicodetext, bytes => Encoding.Unicode.GetString(bytes).TrimEnd('\0')); } - public override void SetText(string text) - { - int bytes = (text.Length + 1) * 2; - IntPtr source = Marshal.StringToHGlobalUni(text); - - setClipboard(source, bytes, cf_unicodetext); - } - public override Image? GetImage() { return getClipboard(cf_dib, bytes => @@ -89,7 +89,175 @@ public override void SetText(string text) }); } - public override bool SetImage(Image image) + public override string? GetCustom(string mimeType) + { + uint formatIdentifier = getFormatIdentifier(mimeType); + if (formatIdentifier == 0) + return null; + + string? value = getClipboard(formatIdentifier, bytes => Encoding.Unicode.GetString(bytes).TrimEnd('\0')); + if (value != null) + return value; + + /* + * Web browsers store clipboard entries with custom mime types differently, so we will need to check if an equivalent entry has been created. + * https://github.com/w3c/editing/blob/gh-pages/docs/clipboard-pickling/explainer.md#os-interaction-format-naming + */ + uint webCustomFormatMapIdentifier = getFormatIdentifier("Web Custom Format Map"); + + if (webCustomFormatMapIdentifier != 0) + { + var webCustomFormatMap = getClipboard(webCustomFormatMapIdentifier, bytes => + JsonConvert.DeserializeObject>( + Encoding.ASCII.GetString(bytes)) + ); + + if (webCustomFormatMap != null && webCustomFormatMap.TryGetValue(mimeType, out string? formatName)) + { + uint webCustomFormatIdentifier = getFormatIdentifier(formatName); + + if (webCustomFormatIdentifier != 0) + { + return getClipboard(webCustomFormatIdentifier, bytes => Encoding.UTF8.GetString(bytes).TrimEnd('\0')); + } + } + } + + return null; + } + + /// + /// Retrieves the identifier for to a given clipboard format, and registers a new format with the Win32 API if needed. + /// + /// Name of the clipboard format + /// Identifier of the created format. Will return 0 if registering the format failed. + private uint getFormatIdentifier(string formatName) + { + if (registeredFormatIdentifiers.TryGetValue(formatName, out uint format)) + { + return format; + } + + IntPtr source = Marshal.StringToHGlobalUni(formatName); + + uint createdFormat = RegisterClipboardFormatW(source); + + GlobalFree(source); + + if (createdFormat == 0) + { + int error = Marshal.GetLastWin32Error(); + Logger.Log($"Failed to register clipboard format \"{formatName}\" with Win32 API with error code ${error}.", level: LogLevel.Error); + + return 0; + } + + registeredFormatIdentifiers[formatName] = createdFormat; + + return createdFormat; + } + + public override bool SetData(ClipboardData data) + { + if (data.IsEmpty()) + { + return false; + } + + var clipboardEntries = new List(); + + if (data.Text != null) + clipboardEntries.Add(createTextEntryUtf16(data.Text, cf_unicodetext)); + + if (data.Image != null) + clipboardEntries.Add(createImageEntry(data.Image)); + + foreach (var entry in data.CustomFormatValues) + { + uint formatIdentifier = getFormatIdentifier(entry.Key); + + if (formatIdentifier == 0) + return false; + + clipboardEntries.Add(createTextEntryUtf16(entry.Value, formatIdentifier)); + } + + if (data.CustomFormatValues.Count > 0) + { + /* + * Required for compatibility with browser clipboard https://github.com/w3c/editing/blob/gh-pages/docs/clipboard-pickling/explainer.md + * Clipboard entries are stored in a predefined range of clipboard format names, on Windows being `Web Custom Format`, where `n` is 0-indexed + * and incrementing with each custom mime type. + * To resolve the original mime types, a special clipboard entry is required (`Web Custom Format Map` on Windows) that maps the original mime types + * to the actual clipboard entries: + * ```json + * { + * "text/foo": "Web Custom Format0", + * "text/bar": "Web Custom Format1" + * } + * ``` + * + * This limitation is in place to prevent websites from creating arbitrary amounts of clipboard formats (Notably on Windows, the number of clipboard + * formats is limited to about 16,000), as well as allowing unicode values in clipboard format names on MacOS. + */ + + var webCustomFormatMap = new Dictionary(); + + var customEntries = data.CustomFormatValues.ToList(); + + for (int i = 0; i < customEntries.Count; i++) + { + string formatName = customEntries[i].Key; + string content = customEntries[i].Value; + + string webCustomFormatName = $"Web Custom Format{i}"; + + webCustomFormatMap[formatName] = webCustomFormatName; + + uint webCustomFormatIdentifier = getFormatIdentifier(webCustomFormatName); + if (webCustomFormatIdentifier == 0) + return false; + + clipboardEntries.Add(createTextEntryUtf8(content, webCustomFormatIdentifier)); + } + + uint webCustomFormatMapIdentifier = getFormatIdentifier("Web Custom Format Map"); + + if (webCustomFormatMapIdentifier == 0) + return false; + + clipboardEntries.Add( + createTextEntryUtf8( + JsonConvert.SerializeObject(webCustomFormatMap), + webCustomFormatMapIdentifier + ) + ); + } + + return setClipboard(clipboardEntries); + } + + private ClipboardEntry createTextEntryUtf16(string text, uint format) + { + int bytes = (text.Length + 1) * 2; + IntPtr source = Marshal.StringToHGlobalUni(text); + + return new ClipboardEntry(source, bytes, format); + } + + private ClipboardEntry createTextEntryUtf8(string text, uint format) + { + int bytes = Encoding.UTF8.GetByteCount(text); + byte[] buffer = new byte[bytes + 1]; + + Encoding.UTF8.GetBytes(text, 0, text.Length, buffer, 0); + IntPtr source = Marshal.AllocHGlobal(buffer.Length); + Marshal.Copy(buffer, 0, source, buffer.Length); + + return new ClipboardEntry(source, bytes, format); + } + + private ClipboardEntry createImageEntry(Image image) { byte[] array; @@ -103,60 +271,105 @@ public override bool SetImage(Image image) IntPtr unmanagedPointer = Marshal.AllocHGlobal(array.Length); Marshal.Copy(array, 0, unmanagedPointer, array.Length); - return setClipboard(unmanagedPointer, array.Length, cf_dib); + return new ClipboardEntry(unmanagedPointer, array.Length, cf_dib); } - private static bool setClipboard(IntPtr pointer, int bytes, uint format) + private readonly struct ClipboardEntry { - bool success = false; + public readonly IntPtr Pointer; + public readonly int Bytes; + public readonly uint Format; + + public ClipboardEntry(IntPtr pointer, int bytes, uint format) + { + Pointer = pointer; + Bytes = bytes; + Format = format; + } + } + + private static bool setClipboard(List entries) + { + if (entries.Count == 0) + { + return false; + } + + bool success = true; try { if (!OpenClipboard(IntPtr.Zero)) + { + int error = Marshal.GetLastWin32Error(); + Logger.Log($"Failed to open clipboard with Win32 API with error code {error}", level: LogLevel.Error); + return false; + } - EmptyClipboard(); + if (!EmptyClipboard()) + { + int error = Marshal.GetLastWin32Error(); + Logger.Log($"Failed to empty clipboard with Win32 API with error code {error}", level: LogLevel.Error); - // IMPORTANT: SetClipboardData requires memory that was acquired with GlobalAlloc using GMEM_MOVABLE. - IntPtr hGlobal = GlobalAlloc(ghnd, (UIntPtr)bytes); + return false; + } - try + foreach (var entry in entries) { - IntPtr target = GlobalLock(hGlobal); - if (target == IntPtr.Zero) - return false; + // IMPORTANT: SetClipboardData requires memory that was acquired with GlobalAlloc using GMEM_MOVABLE. + IntPtr hGlobal = GlobalAlloc(ghnd, (UIntPtr)entry.Bytes); try { - unsafe + IntPtr target = GlobalLock(hGlobal); + if (target == IntPtr.Zero) + return false; + + try { - Buffer.MemoryCopy((void*)pointer, (void*)target, bytes, bytes); + unsafe + { + Buffer.MemoryCopy((void*)entry.Pointer, (void*)target, entry.Bytes, entry.Bytes); + } } - } - finally - { - if (target != IntPtr.Zero) - GlobalUnlock(target); + finally + { + if (target != IntPtr.Zero) + GlobalUnlock(target); - Marshal.FreeHGlobal(pointer); - } + Marshal.FreeHGlobal(entry.Pointer); + } + + if (SetClipboardData(entry.Format, hGlobal).ToInt64() != 0) + { + // IMPORTANT: SetClipboardData takes ownership of hGlobal upon success. + hGlobal = IntPtr.Zero; + } + else + { + int error = Marshal.GetLastWin32Error(); + Logger.Log($"Failed to set clipboard data with Win32 API with error code {error}", level: LogLevel.Error); - if (SetClipboardData(format, hGlobal).ToInt64() != 0) + success = false; + } + } + finally { - // IMPORTANT: SetClipboardData takes ownership of hGlobal upon success. - hGlobal = IntPtr.Zero; - success = true; + if (hGlobal != IntPtr.Zero) + GlobalFree(hGlobal); } } - finally - { - if (hGlobal != IntPtr.Zero) - GlobalFree(hGlobal); - } } finally { - CloseClipboard(); + if (!CloseClipboard()) + { + int error = Marshal.GetLastWin32Error(); + Logger.Log($"Failed to close clipboard with Win32 API with error code {error}", level: LogLevel.Error); + + success = false; + } } return success; @@ -170,7 +383,12 @@ private static bool setClipboard(IntPtr pointer, int bytes, uint format) try { if (!OpenClipboard(IntPtr.Zero)) + { + int error = Marshal.GetLastWin32Error(); + Logger.Log($"Failed to open clipboard with Win32 API with error code {error}", level: LogLevel.Error); + return default; + } IntPtr handle = GetClipboardData(format); if (handle == IntPtr.Zero) @@ -200,7 +418,11 @@ private static bool setClipboard(IntPtr pointer, int bytes, uint format) } finally { - CloseClipboard(); + if (!CloseClipboard()) + { + int error = Marshal.GetLastWin32Error(); + Logger.Log($"Failed to close clipboard with Win32 API with error code {error}", level: LogLevel.Error); + } } } }