通过 Web 服务共享 Windows 剪贴板
您曾经在多台计算机上工作过吗?您希望能够将剪贴板内容从一台计算机复制到其他计算机吗?我一直都很希望能够有一种快速而简便的方法,通过简单的复制和粘贴将文本代码段、屏幕快照、甚至文件移动到其他计算机上。如果您对这个话题感兴趣,那么请继续阅读本文。
我希望无论两台计算机是否同时在线都可以实现此操作,并且不会被防火墙、NAT 等停止。因此我选择基于服务器的体系结构而非对等体系结构。该体系结构包括客户端应用程序(通过调用 Web 服务将剪贴板内容传输到服务器)、Web 服务(缓存剪贴板内容)、另一个客户端组件(从服务器上检索剪贴板内容并将它们放置到本地计算机的剪贴板上)。
为解决这个问题,我们需要以编程方式在作为复制来源和复制目标的两台计算机上访问剪贴板。值得庆幸的是,.NET 在本地 Windows 剪贴板 API 中提供了一个受管包装程序,我们可以通过它进行访问。相关命名空间是 C# 的 Clipboard 和 my.Computer.Clipboard。由于我们所感兴趣的是将剪贴板对象从一台计算机移动到另一台,因此我们首先需要确定要将哪些类型的对象放置到剪贴板上,以便于我们对其进行各种操作(复制文本、图像及文件)。通过使用 Clipboard 命名空间编写简要代码段,我们可以遍历要对其执行各种类型操作的剪贴板上的所有对象,以了解我们正在处理的内容。
Visual C#
IDataObject clipData = Clipboard.GetDataObject(); //检索剪贴板上所有可用格式的一个字符串数组。 string[] formats = clipData.GetFormats(); //遍历剪贴板可用格式列表 foreach (string format in formats) { //将每个对象添加到一个数组列表,以便我们能够检查对象类型 object dataObject = clipData.GetData(format); }
Visual Basic
Dim clipData As IDataObject = Clipboard.GetDataObject Dim formats() As String = clipData.GetFormats '遍历剪贴板可用格式列表 For Each format As String In formats '将每个对象添加到一个数组列表,以便我们能够检查对象类型 Dim dataObject As Object = clipData.GetData(format) Next
下面的屏幕快照显示了将 Word 中的文本复制到剪贴板中的结果。字符串数组中的每一个格式都表示剪贴板中的数据。因为目标应用程序(我们将向其中粘贴)还是未知的,所以剪贴板中有多种格式,每种格式包含相同的数据。本项目的目的是在目标计算机的剪贴板上复制所有这些格式。
检查完剪贴板上的文本、图像及文件对象的对象之后,就很容易确定出我们需要关注的主要对象类型。它们是“System.IO.MemoryStream”、“System.IO.FileStream”、“System.Drawing.Bitmap”和“System.String”。因为所有这些信息都会通过 Web 服务传输到服务器,一种简单的方法就是将所有对象序列化为字节进行传输。这样操作的原因有很多,其中一项事实就是,复杂对象(例如 MemoryStream)不能像 Strings 那样被简单地序列化并通过 Web 服务发送。此外,某些对象很大,已超出 Web 服务调用所允许的范围,因此在传输时需要分解成较小的部分,然后再在服务器端以正确的顺序重新组装。同样,当客户端请求剪贴板项目时,我们需要分解各个对象,然后通过 Web 服务将结果返回到客户端,接着再重新组装。
要创建的第一项是一个基本函数,它将这些很大的流分解成更多的易于管理的字节数组,以传输给 Web 服务。下面的这个函数通过发送 MemoryStream 块执行该任务,其中的块大小通过“byteCount”常量进行限制。达到该限制值之后,缓冲区中的内容就会通过调用 Web 服务来发送,以在服务器上进行存储和组装。一旦我们需要发送的内容为 0 字节或字节数少于“byteCount”常量数,我们将发送缓冲区中的剩余元素,并使用“isFinalTransaction”标志来通知 Web 服务此特定对象已传输完毕。
Visual C#
private void UploadStreamBlock(string format, string objectType, MemoryStream memStream) { //每次我们输入此函数,即开始了一个新事务。一个事务代表剪贴板上的一个完整对象, //我们在服务器端使用它来知道如何将流放回到一起 string transactionGuid = System.Guid.NewGuid().ToString(); memStream.Position = 0; byte[] buffer = new byte[byteCount]; bool isFinalTransaction = false; //当目前的流位置加上我们的字节计数小于流长度时,尽可能 //继续发送。 while ((memStream.Position + byteCount) <= memStream.Length) { //如果恰好位于流的最后一个字节,则将最终事务标志设置为 true,使服务器 //知道这是所需的此事务的最后一位。 if (memStream.Position + byteCount == memStream.Length) { isFinalTransaction = true; } //将流读入缓冲区,以便通过 Web 服务进行传输。 memStream.Read(buffer, 0, byteCount); ws.InsertMessageStream(buffer, format, objectType, transactionGuid, isFinalTransaction, clipBoardGUID); } long remainingBytes = memStream.Length - memStream.Position; //如果还有剩余字节,则计算出还有多少剩余字节并通过 Web 服务传输此对象的 //最后一位。 if ((int)remainingBytes > 0) { byte[] remainingBuffer = new byte[(int)remainingBytes]; memStream.Read(remainingBuffer, 0, (int)remainingBytes); ws.InsertMessageStream(remainingBuffer, format, objectType, transactionGuid, true, clipBoardGUID); } }
Visual Basic
Private Sub UploadStreamBlock(ByVal format As String, ByVal objectType As String, ByVal memStream As MemoryStream) '每次我们输入此函数,即开始了一个新事务。一个事务代表剪贴板上的一个完整对象, '我们在服务器端使用它来知道如何将流放回到一起 Dim transactionGuid As String = System.Guid.NewGuid.ToString memStream.Position = 0 Dim buffer() As Byte = New Byte((byteCount) - 1) {} Dim isFinalTransaction As Boolean = False '当目前的流位置加上我们的字节计数小于流长度时,尽可能 '继续发送。 While ((memStream.Position + byteCount) _ <= memStream.Length) '如果恰好位于流的最后一个字节,则将最终事务标志设置为 true,使服务器 '知道这是所需的此事务的最后一位。 If ((memStream.Position + byteCount) _ = memStream.Length) Then isFinalTransaction = True End If '将流读入缓冲区,以便通过 Web 服务进行传输。 memStream.Read(buffer, 0, byteCount) clipService.InsertMessageStream(buffer, format, objectType, transactionGuid, isFinalTransaction, clipBoardGUID) End While Dim remainingBytes As Long = (memStream.Length - memStream.Position) '如果还有剩余字节,则计算出还有多少剩余字节并通过 Web 服务传输此对象的 '最后一位。 If (CType(remainingBytes, Integer) > 0) Then Dim remainingBuffer() As Byte = New Byte((CType(remainingBytes, Integer)) - 1) {} memStream.Read(remainingBuffer, 0, CType(remainingBytes, Integer)) clipService.InsertMessageStream(remainingBuffer, format, objectType, transactionGuid, True, clipBoardGUID) End If End Sub
Web 服务的服务器端需要将全部剪贴板内容由大量的字节数组重新组合到一起,因此保留所有对象、对象的类型以及格式对于剪贴板在目标计算机上正常工作是至关重要的。我们使用 clipBoardGuid 来确定我们是在进行新的剪贴板张贴,还是在将对象添加到已有实例上。同时使用 isFinalTranaction 标志来了解此字节数组应该是现有事务的一部分,还是新事务中的第一个。所有剪贴板项都会保存到磁盘中,以便稍后由请求它们的任何客户端进行检索。下面是执行此功能的代码。
Visual C#
[WebMethod] public void InsertMessageStream(byte[] buffer, string format, string objectType, string transactionGuid, bool isFinalTransaction, string clipBoardGUID) { //当前目录始终基于此时正发送的剪贴板。 string clipBoardGUIDDirectory = System.Web.HttpContext.Current.Request.PhysicalApplicationPath + clipBoardGUID; try { //如果该目录不存在,则删除所有其他目录(剪贴板实例)并创建一个新目录 //如果该目录已经存在,则此特定事务是同一剪贴板的一部分,因此不要做任何事情。 //这能奏效是因为 clipboardDirectory 不是从客户端发送的 GUID。 if (!Directory.Exists(clipBoardGUIDDirectory)) { string[] dirs = Directory.GetDirectories(System.Web.HttpContext.Current.Request.PhysicalApplicationPath); foreach (string dir in dirs) { Directory.Delete(dir, true); } Directory.CreateDirectory(clipBoardGUIDDirectory); } } catch { } //根据当前事务、格式和对象类型创建文件名。我们将在以后对此进行解析, //以便知道如何将其添加回目标剪贴板。 string fileName = clipBoardGUIDDirectory + "//" + transactionGuid + "_" + format + "_" + objectType; FileStream fs = new FileStream(fileName, FileMode.Append, FileAccess.Write); fs.Position = fs.Length; fs.Write(buffer, 0, buffer.Length); fs.Close(); }
Visual Basic
<WebMethod()> _ Public Sub InsertMessageStream(ByVal buffer() As Byte, ByVal format As String, ByVal objectType As String, ByVal transactionGuid As String, ByVal isFinalTransaction As Boolean, ByVal clipBoardGUID As String) '当前目录始终基于此时正发送的剪贴板。 Dim clipBoardDataDirectory As String = (System.Web.HttpContext.Current.Request.PhysicalApplicationPath + "//Clipboard_Data") Dim clipBoardGUIDDirectory As String = (clipBoardDataDirectory + ("//" + clipBoardGUID)) Try '如果该目录不存在,则删除所有其他目录(剪贴板实例)并创建一个新目录 '如果该目录已经存在,则此特定事务是同一剪贴板的一部分,因此不要做任何事情。 '这能奏效是因为 clipboardDirectory 不是基于从客户端发送的 GUID。 If Not Directory.Exists(clipBoardGUIDDirectory) Then Dim dirs() As String = Directory.GetDirectories(clipBoardDataDirectory) For Each dir As String In dirs Directory.Delete(dir, True) Next Directory.CreateDirectory(clipBoardGUIDDirectory) End If Catch End Try '根据当前事务、格式和对象类型创建文件名。我们将在以后对此进行解析 '以便知道如何将其添加回目标剪贴板。 Dim fileName As String = (clipBoardGUIDDirectory + ("//" _ + (transactionGuid + ("_" _ + (format + ("_" + objectType)))))) Dim fs As FileStream = New FileStream(fileName, FileMode.Append, FileAccess.Write) fs.Position = fs.Length fs.Write(buffer, 0, buffer.Length) fs.Close() End Sub
每种剪贴板格式对象都存储在磁盘上,以便客户端以后进行检索。请注意下面屏幕快照中如何使用文件名来存储对象、对象类型以及剪贴板格式的唯一 transactionID。所有这些信息片段对于正确地重新组装项以及将它们放置到目标剪贴板上都是必不可少的。
现在服务器上对每种剪贴板格式对象都有了对应的表示,我们需要一种方法能够将每一项重新放回目标剪贴板上。下面的 Web 服务方法提供类型为“ClipboardStream”的返回结果。ClipboardStream 对象包含将各项重新组装到目标剪贴板中所必需的所有相关信息。因为 Web 服务是请求-响应类型关系,所以 Web 服务期望客户端继续调用 Web 服务,直到成功接收所有剪贴板项。此外,更大的复杂性也由此引入,因为每个单独的剪贴板项都可能会拆分成多个项(当它们超出常量“byteCount”所设置的最大长度时),因此目标计算机必须跟踪每个请求并通过名为“currentByte”的变量告知服务器最后一个事务停止的位置。Web 服务代码如下所示。
Visual C#
[WebMethod] public ClipboardStream GetMessageStream(string transactionGUID, string[] previousTransactionGUIDs, string clipBoardGUID, long currentByte) { string clipBoardDataDirectory = System.Web.HttpContext.Current.Request.PhysicalApplicationPath + "Clipboard_Data"; string clipBoardGUIDDirectory = clipBoardDataDirectory + "//" + clipBoardGUID; string currentTransaction = ""; bool isLastTransaction = false; //如果 clipBoardGUID 不为空,则只需确保该目录仍存在。 if (clipBoardGUID != "") { //如果该目录不存在,会引发异常,它一定已经被删除了。 if (!Directory.Exists(clipBoardGUIDDirectory)) { throw new Exception("请求的剪贴板不存在。它一定已经被删除了。"); } } //如果 clipboardGUID 为空,则这是客户端与服务器的第一次接触,我们需要 //选择可用的剪贴板 GUID 返回给用户。 else { string[] availableClipBoard = Directory.GetDirectories(clipBoardDataDirectory)[0].Split('//'); clipBoardGUID = availableClipBoard[availableClipBoard.Length - 1]; clipBoardGUIDDirectory += clipBoardGUID; } //我们需要获取下一个事务。每次完成一个事务,我们都在客户端将其添加到 previousTransactionGUIDs, //使我们知道不用再次发送它。 currentTransaction = GetCurrentTransaction(clipBoardGUIDDirectory, previousTransactionGUIDs); //如果当前事务为空,则我们的工作已经完成,已经没有内容需要发送给客户端 if (currentTransaction == null) { return null; } //打开文件流并将其设置到客户端需要的位置。 FileStream fs = new FileStream(currentTransaction, FileMode.Open); fs.Position = currentByte; //确定这是否是该对象的最后一个事务,以便通知客户端。 long numBytesToRead = fs.Length - currentByte; if (numBytesToRead > byteCount) { numBytesToRead = byteCount; isLastTransaction = false; } else { isLastTransaction = true; } //将文件流字节读入缓冲区并填充对象以返回给客户端。 byte[] buffer = new byte[numBytesToRead]; fs.Read(buffer, 0, (int)numBytesToRead); fs.Close(); FileInfo fi = new FileInfo(currentTransaction); ClipboardStream clipboardStream = new ClipboardStream(); clipboardStream.Buffer = buffer; clipboardStream.ClipBoardID = clipBoardGUID; clipboardStream.Format = fi.Name.Split('_')[1]; clipboardStream.ObjectType = fi.Name.Split('_')[2]; clipboardStream.IsLastTransaction = isLastTransaction; clipboardStream.TransactionID = currentTransaction; return clipboardStream; }
Visual Basic
<WebMethod()> _ Public Function GetMessageStream(ByVal transactionGUID As String, ByVal previousTransactionGUIDs() As String, ByVal clipBoardGUID As String, ByVal currentByte As Long) As ClipboardStream Dim clipBoardDataDirectory As String = (System.Web.HttpContext.Current.Request.PhysicalApplicationPath + "Clipboard_Data") Dim clipBoardGUIDDirectory As String = clipBoardDataDirectory Dim currentTransaction As String = "" Dim isLastTransaction As Boolean = False 'if the clipBoardGUID is not empty then we only need to make sure that the directory still exists. If (clipBoardGUID <> "") Then 'if the directory does not exist throw an exception, it must have already been deleted. If Not Directory.Exists(clipBoardGUIDDirectory) Then Throw New Exception("Requested clipboard does not exist. It must have been deleted.") End If End If 'if the clipboardGUID is empty then this is the client's first contact with the server and we need 'to select the available clipboard GUID to return to the user. Dim availableClipBoard() As String = Directory.GetDirectories(clipBoardDataDirectory)(0).Split(Microsoft.VisualBasic.ChrW(92)) clipBoardGUID = availableClipBoard((availableClipBoard.Length - 1)) clipBoardGUIDDirectory = (clipBoardGUIDDirectory + "/" + clipBoardGUID) 'we need to get the next transaction. Each time we finish a transaction we add it to previousTransactionGUIDs 'at the client end so we know not to send it again. currentTransaction = GetCurrentTransaction(clipBoardGUIDDirectory, previousTransactionGUIDs) 'if the current transaction is null then we're done and there are no more to send to the client If (currentTransaction Is Nothing) Then Return Nothing End If 'open the filestream and set it to the position requested by the client. Dim fs As FileStream = New FileStream(currentTransaction, FileMode.Open) fs.Position = currentByte 'determind if this is the last transaction or not for this object so we can let the client know. Dim numBytesToRead As Long = (fs.Length - currentByte) If (numBytesToRead > byteCount) Then numBytesToRead = byteCount isLastTransaction = False Else isLastTransaction = True End If 'read the filestream bytes to the buffer and populate the object to return to the client. Dim buffer() As Byte = New Byte((numBytesToRead) - 1) {} fs.Read(buffer, 0, CType(numBytesToRead, Integer)) fs.Close() Dim fi As FileInfo = New FileInfo(currentTransaction) Dim clipboardStream As ClipboardStream = New ClipboardStream clipboardStream.Buffer = buffer clipboardStream.ClipBoardID = clipBoardGUID clipboardStream.Format = fi.Name.Split(Microsoft.VisualBasic.ChrW(95))(1) clipboardStream.ObjectType = fi.Name.Split(Microsoft.VisualBasic.ChrW(95))(2) clipboardStream.IsLastTransaction = isLastTransaction clipboardStream.TransactionID = currentTransaction Return clipboardStream End Function