From 2d23c1ebb0da5f53ef3ce453ea22c79b3e30fe69 Mon Sep 17 00:00:00 2001 From: Tom Laird-McConnell Date: Thu, 9 Jan 2025 09:48:06 -0800 Subject: [PATCH] implement cross platform clipboards --- .../Infrastructure/ConsoloniaPlatform.cs | 25 ---- .../Infrastructure/NaiveClipboard.cs | 15 +- .../Clipboard/ClipboardProcessRunner.cs | 114 +++++++++++++++ .../Clipboard/MacOSXClipboard.cs | 130 ++++++++++++++++++ .../Clipboard/WSLClipboard.cs | 98 +++++++++++++ .../Clipboard/X11Clipboard.cs | 52 +++++++ .../Clipboard/XClipClipboard.cs | 105 ++++++++++++++ .../Consolonia.PlatformSupport.csproj | 1 + .../PlatformSupportExtensions.cs | 31 ++++- 9 files changed, 540 insertions(+), 31 deletions(-) create mode 100644 src/Consolonia.PlatformSupport/Clipboard/ClipboardProcessRunner.cs create mode 100644 src/Consolonia.PlatformSupport/Clipboard/MacOSXClipboard.cs create mode 100644 src/Consolonia.PlatformSupport/Clipboard/WSLClipboard.cs create mode 100644 src/Consolonia.PlatformSupport/Clipboard/X11Clipboard.cs create mode 100644 src/Consolonia.PlatformSupport/Clipboard/XClipClipboard.cs diff --git a/src/Consolonia.Core/Infrastructure/ConsoloniaPlatform.cs b/src/Consolonia.Core/Infrastructure/ConsoloniaPlatform.cs index 378620be..acaa52a2 100644 --- a/src/Consolonia.Core/Infrastructure/ConsoloniaPlatform.cs +++ b/src/Consolonia.Core/Infrastructure/ConsoloniaPlatform.cs @@ -66,31 +66,6 @@ public void Initialize() //.Bind().ToConstant(new GtkSystemDialog()) /*.Bind().ToConstant(new LinuxMountedVolumeInfoProvider())*/; - if (OperatingSystem.IsWindows()) - { - AvaloniaLocator.CurrentMutable.Bind() - .ToFunc(() => - { - Assembly assembly = Assembly.Load("Avalonia.Win32"); - ArgumentNullException.ThrowIfNull(assembly, "Avalonia.Win32"); - Type type = assembly.GetType(assembly.GetName().Name + ".ClipboardImpl"); - ArgumentNullException.ThrowIfNull(type, "ClipboardImpl"); - var clipboard = Activator.CreateInstance(type) as IClipboard; - ArgumentNullException.ThrowIfNull(clipboard, nameof(clipboard)); - return clipboard; - }); - } - else if (OperatingSystem.IsMacOS()) - { - // TODO: Implement or reuse MacOS clipboard - AvaloniaLocator.CurrentMutable.Bind().ToConstant(new NaiveClipboard()); - } - else if (OperatingSystem.IsLinux()) - { - // TODO: Implement or reuse X11 clipboard - AvaloniaLocator.CurrentMutable.Bind().ToConstant(new NaiveClipboard()); - } - } [DebuggerStepThrough] diff --git a/src/Consolonia.Core/Infrastructure/NaiveClipboard.cs b/src/Consolonia.Core/Infrastructure/NaiveClipboard.cs index fc9d8e5b..27d324f4 100644 --- a/src/Consolonia.Core/Infrastructure/NaiveClipboard.cs +++ b/src/Consolonia.Core/Infrastructure/NaiveClipboard.cs @@ -5,39 +5,44 @@ namespace Consolonia.Core.Infrastructure { - // TODO: Replace this with avalonia platform implementation - internal class NaiveClipboard : IClipboard + /// + /// This clipboard only stores memory in the same process, so it is not useful for sharing data between processes. + /// + public class NaiveClipboard : IClipboard { private string _text = String.Empty; #pragma warning disable CA1822 // Mark members as static public async Task ClearAsync() { + await Task.CompletedTask; _text = String.Empty; } - public async Task GetDataAsync(string format) + public Task GetDataAsync(string format) { throw new NotImplementedException(); } - public async Task GetFormatsAsync() + public Task GetFormatsAsync() { throw new NotImplementedException(); } public async Task GetTextAsync() { + await Task.CompletedTask; return _text; } - public async Task SetDataObjectAsync(IDataObject data) + public Task SetDataObjectAsync(IDataObject data) { throw new NotImplementedException(); } public async Task SetTextAsync(string text) { + await Task.CompletedTask; _text = text; } } diff --git a/src/Consolonia.PlatformSupport/Clipboard/ClipboardProcessRunner.cs b/src/Consolonia.PlatformSupport/Clipboard/ClipboardProcessRunner.cs new file mode 100644 index 00000000..044c0938 --- /dev/null +++ b/src/Consolonia.PlatformSupport/Clipboard/ClipboardProcessRunner.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Consolonia.PlatformSupport.Clipboard +{ + /// Provides cut, copy, and paste support for the OS clipboard. + /// + /// On Windows, the class uses the Windows Clipboard APIs via P/Invoke. + /// + /// On Linux, when not running under Windows Subsystem for Linux (WSL), the class uses + /// the xclip command line tool. If xclip is not installed, the clipboard will not work. + /// + /// + /// On Linux, when running under Windows Subsystem for Linux (WSL), the class launches + /// Windows' powershell.exe via WSL interop and uses the "Set-Clipboard" and "Get-Clipboard" Powershell CmdLets. + /// + /// + /// On the Mac, the class uses the MacO OS X pbcopy and pbpaste command line tools and + /// the Mac clipboard APIs vai P/Invoke. + /// + /// + + /// + /// Helper class for console drivers to invoke shell commands to interact with the clipboard. + /// + internal static class ClipboardProcessRunner + { + public static (int exitCode, string result) Bash( + string commandLine, + string inputText = "", + bool waitForOutput = false + ) + { + var arguments = $"-c \"{commandLine}\""; + (int exitCode, string result) = Process("bash", arguments, inputText, waitForOutput); + + return (exitCode, result.TrimEnd()); + } + + public static bool DoubleWaitForExit(this Process process) + { + bool result = process.WaitForExit(500); + + if (result) + { + process.WaitForExit(); + } + + return result; + } + + public static bool FileExists(this string value) { return !string.IsNullOrEmpty(value) && !value.Contains("not found"); } + + public static (int exitCode, string result) Process( + string cmd, + string arguments, + string input = null, + bool waitForOutput = true + ) + { + var output = string.Empty; + + using (var process = new Process + { + StartInfo = new() + { + FileName = cmd, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + UseShellExecute = false, + CreateNoWindow = true + } + }) + { + TaskCompletionSource eventHandled = new(); + process.Start(); + + if (!string.IsNullOrEmpty(input)) + { + process.StandardInput.Write(input); + process.StandardInput.Close(); + } + + if (!process.WaitForExit(5000)) + { + var timeoutError = + $@"Process timed out. Command line: {process.StartInfo.FileName} {process.StartInfo.Arguments}."; + + throw new TimeoutException(timeoutError); + } + + if (waitForOutput && process.StandardOutput.Peek() != -1) + { + output = process.StandardOutput.ReadToEnd(); + } + + if (process.ExitCode > 0) + { + output = $@"Process failed to run. Command line: {cmd} {arguments}. + Output: {output} + Error: {process.StandardError.ReadToEnd()}"; + } + + return (process.ExitCode, output); + } + } + } +} \ No newline at end of file diff --git a/src/Consolonia.PlatformSupport/Clipboard/MacOSXClipboard.cs b/src/Consolonia.PlatformSupport/Clipboard/MacOSXClipboard.cs new file mode 100644 index 00000000..d72ac701 --- /dev/null +++ b/src/Consolonia.PlatformSupport/Clipboard/MacOSXClipboard.cs @@ -0,0 +1,130 @@ +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Avalonia.Input; +using Avalonia.Input.Platform; + +namespace Consolonia.PlatformSupport.Clipboard +{ + + /// + /// A clipboard implementation for MacOSX. This implementation uses the Mac clipboard API (via P/Invoke) to + /// copy/paste. The existence of the Mac pbcopy and pbpaste commands is used to determine if copy/paste is supported. + /// + internal class MacOSXClipboard : IClipboard + { + private readonly nint _allocRegister = sel_registerName("alloc"); + private readonly nint _clearContentsRegister = sel_registerName("clearContents"); + private readonly nint _generalPasteboard; + private readonly nint _generalPasteboardRegister = sel_registerName("generalPasteboard"); + private readonly nint _initWithUtf8Register = sel_registerName("initWithUTF8String:"); + private readonly nint _nsPasteboard = objc_getClass("NSPasteboard"); + private readonly nint _nsString = objc_getClass("NSString"); + private readonly nint _nsStringPboardType; + private readonly nint _setStringRegister = sel_registerName("setString:forType:"); + private readonly nint _stringForTypeRegister = sel_registerName("stringForType:"); + private readonly nint _utf8Register = sel_registerName("UTF8String"); + private readonly nint _utfTextType; + + public MacOSXClipboard() + { + _utfTextType = objc_msgSend( + objc_msgSend(_nsString, _allocRegister), + _initWithUtf8Register, + "public.utf8-plain-text" + ); + + _nsStringPboardType = objc_msgSend( + objc_msgSend(_nsString, _allocRegister), + _initWithUtf8Register, + "NSStringPboardType" + ); + _generalPasteboard = objc_msgSend(_nsPasteboard, _generalPasteboardRegister); + if (!CheckSupport()) + throw new System.Exception("clipboard operations are not supported pbcopy and pbpaste are not available on this system."); + } + + private static bool CheckSupport() + { + (int exitCode, string result) = ClipboardProcessRunner.Bash("which pbcopy", waitForOutput: true); + + if (exitCode != 0 || !result.FileExists()) + { + return false; + } + + (exitCode, result) = ClipboardProcessRunner.Bash("which pbpaste", waitForOutput: true); + + return exitCode == 0 && result.FileExists(); + } + + [DllImport("/System/Library/Frameworks/AppKit.framework/AppKit", CharSet = CharSet.Unicode)] + private static extern nint objc_getClass(string className); + + [DllImport("/System/Library/Frameworks/AppKit.framework/AppKit")] + private static extern nint objc_msgSend(nint receiver, nint selector); + + [DllImport("/System/Library/Frameworks/AppKit.framework/AppKit", CharSet = CharSet.Unicode)] + private static extern nint objc_msgSend(nint receiver, nint selector, string arg1); + + [DllImport("/System/Library/Frameworks/AppKit.framework/AppKit")] + private static extern nint objc_msgSend(nint receiver, nint selector, nint arg1); + + [DllImport("/System/Library/Frameworks/AppKit.framework/AppKit")] + private static extern nint objc_msgSend(nint receiver, nint selector, nint arg1, nint arg2); + + [DllImport("/System/Library/Frameworks/AppKit.framework/AppKit", CharSet = CharSet.Unicode)] + private static extern nint sel_registerName(string selectorName); + + public async Task GetTextAsync() + { + await Task.CompletedTask; + + nint ptr = objc_msgSend(_generalPasteboard, _stringForTypeRegister, _nsStringPboardType); + nint charArray = objc_msgSend(ptr, _utf8Register); + + return Marshal.PtrToStringAnsi(charArray); + } + + public async Task SetTextAsync(string text) + { + await Task.CompletedTask; + + nint str = default; + + try + { + str = objc_msgSend(objc_msgSend(_nsString, _allocRegister), _initWithUtf8Register, text); + objc_msgSend(_generalPasteboard, _clearContentsRegister); + objc_msgSend(_generalPasteboard, _setStringRegister, str, _utfTextType); + } + finally + { + if (str != default(nint)) + { + objc_msgSend(str, sel_registerName("release")); + } + } + } + + public Task ClearAsync() + { + return SetTextAsync(string.Empty); + } + + public Task SetDataObjectAsync(IDataObject data) + { + throw new System.NotImplementedException(); + } + + public Task GetFormatsAsync() + { + throw new System.NotImplementedException(); + } + + public Task GetDataAsync(string format) + { + throw new System.NotImplementedException(); + } + } + +} diff --git a/src/Consolonia.PlatformSupport/Clipboard/WSLClipboard.cs b/src/Consolonia.PlatformSupport/Clipboard/WSLClipboard.cs new file mode 100644 index 00000000..90dc225f --- /dev/null +++ b/src/Consolonia.PlatformSupport/Clipboard/WSLClipboard.cs @@ -0,0 +1,98 @@ +using System; +using System.Threading.Tasks; +using Avalonia.Input; +using Avalonia.Input.Platform; + +namespace Consolonia.PlatformSupport.Clipboard +{ + + /// + /// A clipboard implementation for Linux, when running under WSL. This implementation uses the Windows clipboard + /// to store the data, and uses Windows' powershell.exe (launched via WSL interop services) to set/get the Windows + /// clipboard. + /// + internal class WSLClipboard : IClipboard + { + private static string _powershellPath = string.Empty; + + private bool _isSupported; + public WSLClipboard() + { + if (string.IsNullOrEmpty(_powershellPath)) + { + // Specify pwsh.exe (not pwsh) to ensure we get the Windows version (invoked via WSL) + (int exitCode, string result) = ClipboardProcessRunner.Bash("which pwsh.exe", waitForOutput: true); + + if (exitCode > 0) + { + (exitCode, result) = ClipboardProcessRunner.Bash("which powershell.exe", waitForOutput: true); + } + + if (exitCode == 0) + { + _powershellPath = result; + } + } + + _isSupported = !string.IsNullOrEmpty(_powershellPath); + } + + public async Task ClearAsync() + { + await SetTextAsync(string.Empty); + } + + public Task GetDataAsync(string format) + { + throw new NotImplementedException(); + } + + public Task GetFormatsAsync() + { + throw new NotImplementedException(); + } + + public async Task GetTextAsync() + { + if (!_isSupported) + { + return string.Empty; + } + + (int exitCode, string output) = + ClipboardProcessRunner.Process(_powershellPath, "-noprofile -command \"Get-Clipboard\""); + + if (exitCode == 0) + { + return output; + } + + return string.Empty; + } + + public Task SetDataObjectAsync(IDataObject data) + { + throw new NotImplementedException(); + } + + public async Task SetTextAsync(string text) + { + await Task.CompletedTask; + + if (!_isSupported) + { + return; + } + + (int exitCode, string output) = ClipboardProcessRunner.Process( + _powershellPath, + $"-noprofile -command \"Set-Clipboard -Value \\\"{text}\\\"\"" + ); + + if (exitCode != 0) + { + throw new InvalidOperationException($"Failed to set clipboard text: {output} using powershell"); + } + } + } +} diff --git a/src/Consolonia.PlatformSupport/Clipboard/X11Clipboard.cs b/src/Consolonia.PlatformSupport/Clipboard/X11Clipboard.cs new file mode 100644 index 00000000..ba69900a --- /dev/null +++ b/src/Consolonia.PlatformSupport/Clipboard/X11Clipboard.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading.Tasks; +using Avalonia.Input; +using Avalonia.Input.Platform; + +namespace Consolonia.PlatformSupport.Clipboard +{ + + /// + /// A clipboard implementation for X11; + /// + internal class X11Clipboard : IClipboard + { + public X11Clipboard() + { + } + + public async Task ClearAsync() + { + await Task.CompletedTask; + Medo.X11.X11Clipboard.Clipboard.Clear(); + } + + public Task GetDataAsync(string format) + { + throw new NotImplementedException(); + } + + public Task GetFormatsAsync() + { + throw new NotImplementedException(); + } + + public async Task GetTextAsync() + { + await Task.CompletedTask; + return Medo.X11.X11Clipboard.Clipboard.GetText(); + } + + public Task SetDataObjectAsync(IDataObject data) + { + throw new NotImplementedException(); + } + + public async Task SetTextAsync(string text) + { + await Task.CompletedTask; + + Medo.X11.X11Clipboard.Clipboard.SetText(text); + } + } +} diff --git a/src/Consolonia.PlatformSupport/Clipboard/XClipClipboard.cs b/src/Consolonia.PlatformSupport/Clipboard/XClipClipboard.cs new file mode 100644 index 00000000..2c5b0ce0 --- /dev/null +++ b/src/Consolonia.PlatformSupport/Clipboard/XClipClipboard.cs @@ -0,0 +1,105 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Unix.Terminal; + +namespace Consolonia.PlatformSupport.Clipboard +{ + + /// A clipboard implementation for Linux. This implementation uses the xclip command to access the clipboard. + /// If xclip is not installed, this implementation will not work. + internal class XClipClipboard : IClipboard + { + private string _xclipPath = string.Empty; + private readonly bool _isSupported; + + public XClipClipboard() + { + (int exitCode, string result) = ClipboardProcessRunner.Bash("which xclip", waitForOutput: true); + + if (exitCode == 0 && result.FileExists()) + { + _xclipPath = result; + + _isSupported = true; + } + else + _isSupported = false; + } + + + public Task ClearAsync() + { + throw new NotImplementedException(); + } + + public Task GetDataAsync(string format) + { + throw new NotImplementedException(); + } + + public Task GetFormatsAsync() + { + throw new NotImplementedException(); + } + + public async Task GetTextAsync() + { + if (!_isSupported) + { + throw new NotSupportedException("xclip is not installed."); + } + + string tempFileName = Path.GetTempFileName(); + var xclipargs = "-selection clipboard -o"; + + try + { + (int exitCode, string result) = + ClipboardProcessRunner.Bash($"{_xclipPath} {xclipargs} > {tempFileName}", waitForOutput: false); + + if (exitCode == 0) + { + return File.ReadAllText(tempFileName); + } + } + catch (Exception e) + { + throw new NotSupportedException($"\"{_xclipPath} {xclipargs}\" failed.", e); + } + finally + { + File.Delete(tempFileName); + } + + return string.Empty; + } + + public Task SetDataObjectAsync(IDataObject data) + { + throw new NotImplementedException(); + } + + public async Task SetTextAsync(string text) + { + await Task.CompletedTask; + if (!_isSupported) + { + throw new NotSupportedException("xclip is not installed."); + } + + var xclipargs = "-selection clipboard -i"; + + try + { + (int exitCode, _) = ClipboardProcessRunner.Bash($"{_xclipPath} {xclipargs}", text); + } + catch (Exception e) + { + throw new NotSupportedException($"\"{_xclipPath} {xclipargs} < {text}\" failed", e); + } + } + } +} diff --git a/src/Consolonia.PlatformSupport/Consolonia.PlatformSupport.csproj b/src/Consolonia.PlatformSupport/Consolonia.PlatformSupport.csproj index dbe245e5..75d0967a 100644 --- a/src/Consolonia.PlatformSupport/Consolonia.PlatformSupport.csproj +++ b/src/Consolonia.PlatformSupport/Consolonia.PlatformSupport.csproj @@ -1,6 +1,7 @@ + diff --git a/src/Consolonia.PlatformSupport/PlatformSupportExtensions.cs b/src/Consolonia.PlatformSupport/PlatformSupportExtensions.cs index 54ca8123..0afdf253 100644 --- a/src/Consolonia.PlatformSupport/PlatformSupportExtensions.cs +++ b/src/Consolonia.PlatformSupport/PlatformSupportExtensions.cs @@ -1,11 +1,14 @@ using System; +using System.Reflection; using Avalonia; using Avalonia.Controls; +using Avalonia.Input.Platform; using Consolonia.Core.Drawing.PixelBufferImplementation; using Consolonia.Core.Drawing.PixelBufferImplementation.EgaConsoleColor; using Consolonia.Core.Dummy; using Consolonia.Core.Infrastructure; using Consolonia.PlatformSupport; +using Consolonia.PlatformSupport.Clipboard; // ReSharper disable CheckNamespace #pragma warning disable IDE0161 @@ -34,7 +37,33 @@ public static AppBuilder UseAutoDetectedConsole(this AppBuilder builder) _ => new DefaultNetConsole() }; - return builder.UseConsole(console).UseAutoDetectConsoleColorMode(); + return builder.UseConsole(console) + .UseAutoDectectedClipboard() + .UseAutoDetectConsoleColorMode(); + } + + public static AppBuilder UseAutoDectectedClipboard(this AppBuilder builder) + { + if (OperatingSystem.IsWindows()) + { + // we can consume the avalonia clipboard implementation because it's self contained enough for us to reach inside and pull it out. + Assembly assembly = Assembly.Load("Avalonia.Win32"); + ArgumentNullException.ThrowIfNull(assembly, "Avalonia.Win32"); + Type type = assembly.GetType(assembly.GetName().Name + ".ClipboardImpl"); + ArgumentNullException.ThrowIfNull(type, "ClipboardImpl"); + var clipboard = Activator.CreateInstance(type) as IClipboard; + return builder.With(clipboard ?? new NaiveClipboard()); + } + else if (OperatingSystem.IsMacOS()) + { + return builder.With(new MacOSXClipboard()); + } + else if (OperatingSystem.IsLinux()) + { + return builder.With(new X11Clipboard()); + } + else + return builder.With(new NaiveClipboard()); } public static AppBuilder UseAutoDetectConsoleColorMode(this AppBuilder builder)