From d0ea37dbc2564afe0c00ef80c65e8fed2ff64d1e Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Wed, 12 Mar 2025 21:28:44 +0500 Subject: [PATCH] Refactor X11 clipboard to use session-based approach --- .../Clipboard/ClipboardReadSession.cs | 110 +++++++++++++++ .../Clipboard/EventStreamWindow.cs | 95 +++++++++++++ .../{ => Clipboard}/X11Clipboard.cs | 127 +++++------------- src/Avalonia.X11/X11Platform.cs | 2 +- 4 files changed, 241 insertions(+), 93 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 (71%) diff --git a/src/Avalonia.X11/Clipboard/ClipboardReadSession.cs b/src/Avalonia.X11/Clipboard/ClipboardReadSession.cs new file mode 100644 index 0000000000..3241cc9d47 --- /dev/null +++ b/src/Avalonia.X11/Clipboard/ClipboardReadSession.cs @@ -0,0 +1,110 @@ +using System; +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; + } + + public void Dispose() => _window.Dispose(); + + + private async Task<(IntPtr propertyData, IntPtr actualTypeAtom, int actualFormat, IntPtr nitems)?> ConvertSelectionAndGetProperty( + IntPtr target, IntPtr property) + { + XConvertSelection(_platform.Display, _x11.Atoms.CLIPBOARD, target, property, _window.Handle, + IntPtr.Zero); + + 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; + + XGetWindowProperty(_x11.Display, _window.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); + return (prop, actualTypeAtom, actualFormat, nitems); + } + + public async Task SendFormatRequest() + { + var res = await ConvertSelectionAndGetProperty(_x11.Atoms.TARGETS, _x11.Atoms.TARGETS); + if (res == null) + return null; + + var (prop, _, actualFormat, nitems) = res.Value; + + try + { + if (nitems == IntPtr.Zero) + return null; + if (actualFormat != 32) + return null; + else + { + var formats = new IntPtr[nitems.ToInt32()]; + Marshal.Copy(prop, formats, 0, formats.Length); + return formats; + } + } + finally + { + XFree(prop); + } + } + + 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!); + } + + public async Task SendDataRequest(IntPtr format) + { + var res = await ConvertSelectionAndGetProperty(format, format); + if (res == null) + return null; + + var (prop, actualTypeAtom, actualFormat, nitems) = res.Value; + + try + { + if (nitems == IntPtr.Zero) + return null; + if (actualTypeAtom == _x11.Atoms.INCR) + { + // TODO: Actually implement that monstrosity + return null; + } + else + { + var data = new byte[(int)nitems * (actualFormat / 8)]; + Marshal.Copy(prop, data, 0, data.Length); + return new (data, null, actualTypeAtom); + } + } + finally + { + XFree(prop); + } + } +} \ 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..c2842d38fa --- /dev/null +++ b/src/Avalonia.X11/Clipboard/EventStreamWindow.cs @@ -0,0 +1,95 @@ +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 static readonly Stopwatch _time = Stopwatch.StartNew(); + + public EventStreamWindow(AvaloniaX11Platform platform) + { + _platform = platform; + _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) + { + if (timeout.HasValue) + { + 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)); + if(timeout.HasValue) + _timeoutTimer.Start(); + return tcs.Task; + } + + public void Dispose() + { + 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); + } +} \ No newline at end of file diff --git a/src/Avalonia.X11/X11Clipboard.cs b/src/Avalonia.X11/Clipboard/X11Clipboard.cs similarity index 71% rename from src/Avalonia.X11/X11Clipboard.cs rename to src/Avalonia.X11/Clipboard/X11Clipboard.cs index 3dea3f812d..6773eff2f9 100644 --- a/src/Avalonia.X11/X11Clipboard.cs +++ b/src/Avalonia.X11/Clipboard/X11Clipboard.cs @@ -6,22 +6,23 @@ 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; public X11Clipboard(AvaloniaX11Platform platform) { + _platform = platform; _x11 = platform.Info; _handle = CreateEventWindow(platform, OnEvent); _avaloniaSaveTargetsAtom = XInternAtom(_x11.Display, "AVALONIA_SAVE_TARGETS_PROPERTY_ATOM", false); @@ -33,12 +34,7 @@ namespace Avalonia.X11 _x11.Atoms.UTF16_STRING }.Where(a => a != IntPtr.Zero).ToArray(); } - - private bool IsStringAtom(IntPtr atom) - { - return _textAtoms.Contains(atom); - } - + private Encoding? GetStringEncoding(IntPtr atom) { return (atom == _x11.Atoms.XA_STRING @@ -50,17 +46,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 +76,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,15 +90,15 @@ 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) @@ -136,11 +132,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); @@ -157,84 +154,18 @@ namespace Avalonia.X11 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() - { - 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; - } - - private Task SendDataRequest(IntPtr format) - { - if (_requestedDataTcs == null || _requestedDataTcs.Task.IsCompleted) - _requestedDataTcs = new TaskCompletionSource(); - XConvertSelection(_x11.Display, _x11.Atoms.CLIPBOARD, format, format, _handle, IntPtr.Zero); - return _requestedDataTcs.Task; } 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(); + using var session = OpenReadSession(); + var res = await session.SendFormatRequest(); var target = _x11.Atoms.UTF8_STRING; if (res != null) { @@ -247,7 +178,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(); } @@ -326,7 +267,8 @@ namespace Avalonia.X11 if (!HasOwner) return []; - var res = await SendFormatRequest(); + using var session = OpenReadSession(); + var res = await session.SendFormatRequest(); if (res == null) return []; @@ -351,11 +293,12 @@ namespace Avalonia.X11 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 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())