From ab535d0c7a0c4e985be58240c5a8ed7e14bea47b Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Sun, 27 Jul 2025 22:27:51 +0500 Subject: [PATCH] [X11] Added INCR support (#18428) * Refactor X11 clipboard to use session-based approach * INCR client * Implemented INCR server * Detect INCR threshold * missing return * Handle review comments --------- Co-authored-by: Julien Lebosquain (cherry picked from commit ec0edda7c30701909d393d99180b16e2aaf65c27) --- .../Clipboard/ClipboardReadSession.cs | 152 ++++++++++++++ .../Clipboard/EventStreamWindow.cs | 110 ++++++++++ .../{ => Clipboard}/X11Clipboard.cs | 198 +++++++++--------- src/Avalonia.X11/X11Platform.cs | 2 +- src/Avalonia.X11/XLib.cs | 6 + 5 files changed, 370 insertions(+), 98 deletions(-) create mode 100644 src/Avalonia.X11/Clipboard/ClipboardReadSession.cs create mode 100644 src/Avalonia.X11/Clipboard/EventStreamWindow.cs rename src/Avalonia.X11/{ => Clipboard}/X11Clipboard.cs (67%) diff --git a/src/Avalonia.X11/Clipboard/ClipboardReadSession.cs b/src/Avalonia.X11/Clipboard/ClipboardReadSession.cs new file mode 100644 index 0000000000..7f964e8007 --- /dev/null +++ b/src/Avalonia.X11/Clipboard/ClipboardReadSession.cs @@ -0,0 +1,152 @@ +using System; +using System.Buffers; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using static Avalonia.X11.XLib; +namespace Avalonia.X11.Clipboard; + +class ClipboardReadSession : IDisposable +{ + private readonly AvaloniaX11Platform _platform; + private readonly EventStreamWindow _window; + private readonly X11Info _x11; + + public ClipboardReadSession(AvaloniaX11Platform platform) + { + _platform = platform; + _window = new EventStreamWindow(platform); + _x11 = _platform.Info; + XSelectInput(_x11.Display, _window.Handle, new IntPtr((int)XEventMask.PropertyChangeMask)); + } + + public void Dispose() => _window.Dispose(); + + class PropertyReadResult(IntPtr data, IntPtr actualTypeAtom, int actualFormat, IntPtr nItems) + : IDisposable + { + public IntPtr Data => data; + public IntPtr ActualTypeAtom => actualTypeAtom; + public int ActualFormat => actualFormat; + public IntPtr NItems => nItems; + + public void Dispose() + { + XFree(Data); + } + } + + private async Task + WaitForSelectionNotifyAndGetProperty(IntPtr property) + { + var ev = await _window.WaitForEventAsync(ev => + ev.type == XEventName.SelectionNotify + && ev.SelectionEvent.selection == _x11.Atoms.CLIPBOARD + && ev.SelectionEvent.property == property + ); + + if (ev == null) + return null; + + var sel = ev.Value.SelectionEvent; + + return ReadProperty(sel.property); + } + + private PropertyReadResult ReadProperty(IntPtr property) + { + XGetWindowProperty(_x11.Display, _window.Handle, property, IntPtr.Zero, new IntPtr (0x7fffffff), true, + (IntPtr)Atom.AnyPropertyType, + out var actualTypeAtom, out var actualFormat, out var nitems, out var bytes_after, out var prop); + return new (prop, actualTypeAtom, actualFormat, nitems); + } + + private Task ConvertSelectionAndGetProperty( + IntPtr target, IntPtr property) + { + XConvertSelection(_platform.Display, _x11.Atoms.CLIPBOARD, target, property, _window.Handle, + IntPtr.Zero); + return WaitForSelectionNotifyAndGetProperty(property); + } + + public async Task SendFormatRequest() + { + using var res = await ConvertSelectionAndGetProperty(_x11.Atoms.TARGETS, _x11.Atoms.TARGETS); + if (res == null) + return null; + + if (res.NItems == IntPtr.Zero) + return null; + if (res.ActualFormat != 32) + return null; + else + { + var formats = new IntPtr[res.NItems.ToInt32()]; + Marshal.Copy(res.Data, formats, 0, formats.Length); + return formats; + } + } + + public class GetDataResult(byte[]? data, MemoryStream? stream, IntPtr actualTypeAtom) + { + public IntPtr TypeAtom => actualTypeAtom; + public byte[] AsBytes() => data ?? stream!.ToArray(); + public MemoryStream AsStream() => stream ?? new MemoryStream(data!); + } + + private async Task ReadIncr(IntPtr property) + { + XFlush(_platform.Display); + var ms = new MemoryStream(); + void Append(PropertyReadResult res) + { + var len = (int)res.NItems * (res.ActualFormat / 8); + var data = ArrayPool.Shared.Rent(len); + Marshal.Copy(res.Data, data, 0, len); + ms.Write(data, 0, len); + ArrayPool.Shared.Return(data); + } + IntPtr actualTypeAtom = IntPtr.Zero; + while (true) + { + var ev = await _window.WaitForEventAsync(x => + x is { type: XEventName.PropertyNotify, PropertyEvent.state: 0 } && + x.PropertyEvent.atom == property); + + if (ev == null) + return null; + + using var part = ReadProperty(property); + + if (actualTypeAtom == IntPtr.Zero) + actualTypeAtom = part.ActualTypeAtom; + if(part.NItems == IntPtr.Zero) + break; + + Append(part); + } + + return new(null, ms, actualTypeAtom); + } + + public async Task SendDataRequest(IntPtr format) + { + using var res = await ConvertSelectionAndGetProperty(format, format); + if (res == null) + return null; + + if (res.NItems == IntPtr.Zero) + return null; + if (res.ActualTypeAtom == _x11.Atoms.INCR) + { + return await ReadIncr(format); + } + else + { + var data = new byte[(int)res.NItems * (res.ActualFormat / 8)]; + Marshal.Copy(res.Data, data, 0, data.Length); + return new (data, null, res.ActualTypeAtom); + } + + } +} \ No newline at end of file diff --git a/src/Avalonia.X11/Clipboard/EventStreamWindow.cs b/src/Avalonia.X11/Clipboard/EventStreamWindow.cs new file mode 100644 index 0000000000..913e5ed258 --- /dev/null +++ b/src/Avalonia.X11/Clipboard/EventStreamWindow.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Threading; + +namespace Avalonia.X11; + +internal class EventStreamWindow : IDisposable +{ + private readonly AvaloniaX11Platform _platform; + private IntPtr _handle; + public IntPtr Handle => _handle; + private readonly List<(Func filter, TaskCompletionSource tcs, TimeSpan timeout)> _listeners = new(); + // We are adding listeners to an intermediate collection to avoid freshly added listeners to be called + // in the same event loop iteration and potentially processing an event that was not meant for them. + private readonly List<(Func filter, TaskCompletionSource tcs, TimeSpan timeout)> _addedListeners = new(); + private readonly DispatcherTimer _timeoutTimer; + private readonly bool _isForeign; + private static readonly Stopwatch _time = Stopwatch.StartNew(); + + public EventStreamWindow(AvaloniaX11Platform platform, IntPtr? foreignWindow = null) + { + _platform = platform; + if (foreignWindow.HasValue) + { + _isForeign = true; + _handle = foreignWindow.Value; + _platform.Windows[_handle] = OnEvent; + } + else + _handle = XLib.CreateEventWindow(platform, OnEvent); + + _timeoutTimer = new(TimeSpan.FromSeconds(1), DispatcherPriority.Background, OnTimer); + } + + void MergeListeners() + { + _listeners.AddRange(_addedListeners); + _addedListeners.Clear(); + } + + private void OnTimer(object? sender, EventArgs eventArgs) + { + MergeListeners(); + for (var i = 0; i < _listeners.Count; i++) + { + var (filter, tcs, timeout) = _listeners[i]; + if (timeout < _time.Elapsed) + { + _listeners.RemoveAt(i); + i--; + tcs.SetResult(null); + } + } + if(_listeners.Count == 0) + _timeoutTimer.Stop(); + } + + private void OnEvent(ref XEvent xev) + { + MergeListeners(); + for (var i = 0; i < _listeners.Count; i++) + { + var (filter, tcs, timeout) = _listeners[i]; + if (filter(xev)) + { + _listeners.RemoveAt(i); + i--; + tcs.SetResult(xev); + } + } + } + + public Task WaitForEventAsync(Func predicate, TimeSpan? timeout = null) + { + timeout ??= TimeSpan.FromSeconds(5); + + if (timeout < TimeSpan.Zero) + throw new TimeoutException(); + if(timeout > TimeSpan.FromDays(1)) + throw new ArgumentOutOfRangeException(nameof(timeout)); + + var tcs = new TaskCompletionSource(); + _addedListeners.Add((predicate, tcs, _time.Elapsed + timeout.Value)); + + _timeoutTimer.Start(); + return tcs.Task; + } + + public void Dispose() + { + _timeoutTimer.Stop(); + + _platform.Windows.Remove(_handle); + if (_isForeign) + XLib.XSelectInput(_platform.Display, _handle, IntPtr.Zero); + else + XLib.XDestroyWindow(_platform.Display, _handle); + + _handle = IntPtr.Zero; + var toDispose = _listeners.ToList(); + toDispose.AddRange(_addedListeners); + _listeners.Clear(); + _addedListeners.Clear(); + foreach(var l in toDispose) + l.tcs.SetResult(null); + } +} diff --git a/src/Avalonia.X11/X11Clipboard.cs b/src/Avalonia.X11/Clipboard/X11Clipboard.cs similarity index 67% rename from src/Avalonia.X11/X11Clipboard.cs rename to src/Avalonia.X11/Clipboard/X11Clipboard.cs index 911d80db19..75f224126a 100644 --- a/src/Avalonia.X11/X11Clipboard.cs +++ b/src/Avalonia.X11/Clipboard/X11Clipboard.cs @@ -1,27 +1,31 @@ using System; +using System.Buffers; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Text; using System.Threading.Tasks; using Avalonia.Input; using Avalonia.Input.Platform; +using Avalonia.X11.Clipboard; using static Avalonia.X11.XLib; namespace Avalonia.X11 { internal class X11Clipboard : IClipboard { + private readonly AvaloniaX11Platform _platform; private readonly X11Info _x11; private IDataObject? _storedDataObject; private IntPtr _handle; private TaskCompletionSource? _storeAtomTcs; - private TaskCompletionSource? _requestedFormatsTcs; - private TaskCompletionSource? _requestedDataTcs; private readonly IntPtr[] _textAtoms; private readonly IntPtr _avaloniaSaveTargetsAtom; + private int _maximumPropertySize; public X11Clipboard(AvaloniaX11Platform platform) { + _platform = platform; _x11 = platform.Info; _handle = CreateEventWindow(platform, OnEvent); _avaloniaSaveTargetsAtom = XInternAtom(_x11.Display, "AVALONIA_SAVE_TARGETS_PROPERTY_ATOM", false); @@ -32,13 +36,15 @@ namespace Avalonia.X11 _x11.Atoms.UTF8_STRING, _x11.Atoms.UTF16_STRING }.Where(a => a != IntPtr.Zero).ToArray(); - } - private bool IsStringAtom(IntPtr atom) - { - return _textAtoms.Contains(atom); + var extendedMaxRequestSize = XExtendedMaxRequestSize(_platform.Display); + var maxRequestSize = XMaxRequestSize(_platform.Display); + _maximumPropertySize = + (int)Math.Min(0x100000, (extendedMaxRequestSize == IntPtr.Zero + ? maxRequestSize + : extendedMaxRequestSize).ToInt64() - 0x100); } - + private Encoding? GetStringEncoding(IntPtr atom) { return (atom == _x11.Atoms.XA_STRING @@ -50,17 +56,17 @@ namespace Avalonia.X11 ? Encoding.Unicode : null; } - + private unsafe void OnEvent(ref XEvent ev) { if (ev.type == XEventName.SelectionClear) - { + { _storeAtomTcs?.TrySetResult(true); return; } if (ev.type == XEventName.SelectionRequest) - { + { var sel = ev.SelectionRequestEvent; var resp = new XEvent { @@ -80,7 +86,7 @@ namespace Avalonia.X11 { resp.SelectionEvent.property = WriteTargetToProperty(sel.target, sel.requestor, sel.property); } - + XSendEvent(_x11.Display, sel.requestor, false, new IntPtr((int)EventMask.NoEventMask), ref resp); } @@ -94,21 +100,19 @@ namespace Avalonia.X11 _x11.Atoms.XA_ATOM, 32, PropertyMode.Replace, atoms, atoms.Length); return property; } - else if(target == _x11.Atoms.SAVE_TARGETS && _x11.Atoms.SAVE_TARGETS != IntPtr.Zero) + else if (target == _x11.Atoms.SAVE_TARGETS && _x11.Atoms.SAVE_TARGETS != IntPtr.Zero) { return property; } - else if ((textEnc = GetStringEncoding(target)) != null + else if ((textEnc = GetStringEncoding(target)) != null && _storedDataObject?.Contains(DataFormats.Text) == true) { var text = _storedDataObject.GetText(); - if(text == null) + if (text == null) return IntPtr.Zero; var data = textEnc.GetBytes(text); - fixed (void* pdata = data) - XChangeProperty(_x11.Display, window, property, target, 8, - PropertyMode.Replace, - pdata, data.Length); + SendDataToClient(window, property, target, data); + return property; } else if (target == _x11.Atoms.MULTIPLE && _x11.Atoms.MULTIPLE != IntPtr.Zero) @@ -136,11 +140,12 @@ namespace Avalonia.X11 return property; } - else if(_x11.Atoms.GetAtomName(target) is { } atomName && _storedDataObject?.Contains(atomName) == true) + else if (_x11.Atoms.GetAtomName(target) is { } atomName && + _storedDataObject?.Contains(atomName) == true) { var objValue = _storedDataObject.Get(atomName); - - if(!(objValue is byte[] bytes)) + + if (!(objValue is byte[] bytes)) { if (objValue is string s) bytes = Encoding.UTF8.GetBytes(s); @@ -148,93 +153,66 @@ namespace Avalonia.X11 return IntPtr.Zero; } - XChangeProperty(_x11.Display, window, property, target, 8, - PropertyMode.Replace, - bytes, bytes.Length); + SendDataToClient(window, property, target, bytes); return property; } else return IntPtr.Zero; } - if (ev.type == XEventName.SelectionNotify && ev.SelectionEvent.selection == _x11.Atoms.CLIPBOARD) - { - var sel = ev.SelectionEvent; - if (sel.property == IntPtr.Zero) - { - _requestedFormatsTcs?.TrySetResult(null); - _requestedDataTcs?.TrySetResult(null); - } - XGetWindowProperty(_x11.Display, _handle, sel.property, IntPtr.Zero, new IntPtr (0x7fffffff), true, (IntPtr)Atom.AnyPropertyType, - out var actualTypeAtom, out var actualFormat, out var nitems, out var bytes_after, out var prop); - Encoding? textEnc; - if (nitems == IntPtr.Zero) - { - _requestedFormatsTcs?.TrySetResult(null); - _requestedDataTcs?.TrySetResult(null); - } - else - { - if (sel.property == _x11.Atoms.TARGETS) - { - if (actualFormat != 32) - _requestedFormatsTcs?.TrySetResult(null); - else - { - var formats = new IntPtr[nitems.ToInt32()]; - Marshal.Copy(prop, formats, 0, formats.Length); - _requestedFormatsTcs?.TrySetResult(formats); - } - } - else if ((textEnc = GetStringEncoding(actualTypeAtom)) != null) - { - var text = textEnc.GetString((byte*)prop.ToPointer(), nitems.ToInt32()); - _requestedDataTcs?.TrySetResult(text); - } - else - { - if (actualTypeAtom == _x11.Atoms.INCR) - { - // TODO: Actually implement that monstrosity - _requestedDataTcs?.TrySetResult(null); - } - else - { - var data = new byte[(int)nitems * (actualFormat / 8)]; - Marshal.Copy(prop, data, 0, data.Length); - _requestedDataTcs?.TrySetResult(data); - } - } - } - - XFree(prop); - } } - private Task SendFormatRequest() + async void SendIncrDataToClient(IntPtr window, IntPtr property, IntPtr target, Stream data) { - if (_requestedFormatsTcs == null || _requestedFormatsTcs.Task.IsCompleted) - _requestedFormatsTcs = new TaskCompletionSource(); - XConvertSelection(_x11.Display, _x11.Atoms.CLIPBOARD, _x11.Atoms.TARGETS, _x11.Atoms.TARGETS, _handle, - IntPtr.Zero); - return _requestedFormatsTcs.Task; + data.Position = 0; + using var events = new EventStreamWindow(_platform, window); + using var _ = data; + XSelectInput(_x11.Display, window, new IntPtr((int)XEventMask.PropertyChangeMask)); + var size = new IntPtr(data.Length); + XChangeProperty(_x11.Display, window, property, _x11.Atoms.INCR, 32, PropertyMode.Replace, ref size, 1); + var buffer = ArrayPool.Shared.Rent((int)Math.Min(_maximumPropertySize, data.Length)); + while (true) + { + if (null == await events.WaitForEventAsync(x => + x.type == XEventName.PropertyNotify && x.PropertyEvent.atom == property + && x.PropertyEvent.state == 1, TimeSpan.FromMinutes(1))) + break; + var read = await data.ReadAsync(buffer, 0, buffer.Length); + if(read == 0) + break; + XChangeProperty(_x11.Display, window, property, target, 8, PropertyMode.Replace, buffer, read); + } + ArrayPool.Shared.Return(buffer); + + // Finish the transfer + XChangeProperty(_x11.Display, window, property, target, 8, PropertyMode.Replace, IntPtr.Zero, 0); } - private Task SendDataRequest(IntPtr format) + void SendDataToClient(IntPtr window, IntPtr property, IntPtr target, byte[] bytes) { - if (_requestedDataTcs == null || _requestedDataTcs.Task.IsCompleted) - _requestedDataTcs = new TaskCompletionSource(); - XConvertSelection(_x11.Display, _x11.Atoms.CLIPBOARD, format, format, _handle, IntPtr.Zero); - return _requestedDataTcs.Task; + if (bytes.Length < _maximumPropertySize) + { + XChangeProperty(_x11.Display, window, property, target, 8, + PropertyMode.Replace, + bytes, bytes.Length); + } + else + SendIncrDataToClient(window, property, target, new MemoryStream(bytes)); } private bool HasOwner => XGetSelectionOwner(_x11.Display, _x11.Atoms.CLIPBOARD) != IntPtr.Zero; + private ClipboardReadSession OpenReadSession() => new(_platform); + public async Task GetTextAsync() { if (!HasOwner) return null; - var res = await SendFormatRequest(); + if (TryGetInProcessDataObject() is { } inProc) + return inProc.GetText(); + + using var session = OpenReadSession(); + var res = await session.SendFormatRequest(); var target = _x11.Atoms.UTF8_STRING; if (res != null) { @@ -247,7 +225,17 @@ namespace Avalonia.X11 } } - return (string?)await SendDataRequest(target); + return ConvertData(await session.SendDataRequest(target)) as string; + } + + private object? ConvertData(ClipboardReadSession.GetDataResult? result) + { + if (result == null) + return null; + if (GetStringEncoding(result.TypeAtom) is { } textEncoding) + return textEncoding.GetString(result.AsBytes()); + // TODO: image encoding + return result.AsBytes(); } @@ -272,6 +260,12 @@ namespace Avalonia.X11 private Task StoreAtomsInClipboardManager(IDataObject data) { + // Skip storing atoms if the data object contains any non-trivial formats or trivial formats are too big + if (data.GetDataFormats().Any(f => f != DataFormats.Text) + || data.GetText()?.Length * 2 > 64 * 1024 + ) + return Task.CompletedTask; + if (_x11.Atoms.CLIPBOARD_MANAGER != IntPtr.Zero && _x11.Atoms.SAVE_TARGETS != IntPtr.Zero) { var clipboardManager = XGetSelectionOwner(_x11.Display, _x11.Atoms.CLIPBOARD_MANAGER); @@ -314,19 +308,24 @@ namespace Avalonia.X11 return StoreAtomsInClipboardManager(data); } - public Task TryGetInProcessDataObjectAsync() + private IDataObject? TryGetInProcessDataObject() { if (XGetSelectionOwner(_x11.Display, _x11.Atoms.CLIPBOARD) == _handle) - return Task.FromResult(_storedDataObject); - return Task.FromResult(null); + return _storedDataObject; + return null; } + public Task TryGetInProcessDataObjectAsync() => Task.FromResult(TryGetInProcessDataObject()); + public async Task GetFormatsAsync() { if (!HasOwner) return []; - - var res = await SendFormatRequest(); + if (TryGetInProcessDataObject() is { } inProc) + return inProc.GetDataFormats().ToArray(); + + using var session = OpenReadSession(); + var res = await session.SendFormatRequest(); if (res == null) return []; @@ -347,15 +346,20 @@ namespace Avalonia.X11 { if (!HasOwner) return null; + + if(TryGetInProcessDataObject() is {} inProc) + return inProc.Get(format); + if (format == DataFormats.Text) return await GetTextAsync(); var formatAtom = _x11.Atoms.GetAtom(format); - var res = await SendFormatRequest(); + using var session = OpenReadSession(); + var res = await session.SendFormatRequest(); if (res is null || !res.Contains(formatAtom)) return null; - - return await SendDataRequest(formatAtom); + + return ConvertData(await session.SendDataRequest(formatAtom)); } /// diff --git a/src/Avalonia.X11/X11Platform.cs b/src/Avalonia.X11/X11Platform.cs index bebae3f0ae..39319e01df 100644 --- a/src/Avalonia.X11/X11Platform.cs +++ b/src/Avalonia.X11/X11Platform.cs @@ -81,7 +81,7 @@ namespace Avalonia.X11 .Bind().ToConstant(new KeyGestureFormatInfo(new Dictionary() { }, meta: "Super")) .Bind().ToFunc(() => KeyboardDevice) .Bind().ToConstant(new X11CursorFactory(Display)) - .Bind().ToConstant(new X11Clipboard(this)) + .Bind().ToLazy(() => new X11Clipboard(this)) .Bind().ToSingleton() .Bind().ToConstant(new X11IconLoader()) .Bind().ToConstant(new LinuxMountedVolumeInfoProvider()) diff --git a/src/Avalonia.X11/XLib.cs b/src/Avalonia.X11/XLib.cs index cfd3a03c8f..2c8ecf2c94 100644 --- a/src/Avalonia.X11/XLib.cs +++ b/src/Avalonia.X11/XLib.cs @@ -559,6 +559,12 @@ namespace Avalonia.X11 [DllImport(libX11)] public static extern void XFreeEventData(IntPtr display, void* cookie); + [DllImport(libX11)] + public static extern IntPtr XMaxRequestSize(IntPtr display); + + [DllImport(libX11)] + public static extern IntPtr XExtendedMaxRequestSize(IntPtr display); + [DllImport(libX11Randr)] public static extern int XRRQueryExtension (IntPtr dpy, out int event_base_return,