剪貼簿與拖放的運作方式 ── 在業務應用程式中正確處理 OLE 資料傳輸

· · Windows, 剪貼簿, 拖放, OLE, COM, Windows 開發, WinForms, WPF

「從 Excel 複製的表格一貼上,格式就散掉。我們希望它貼成表格。」「在我們應用程式裡複製的內容,貼進 Word 會變成奇怪的東西。」「我們希望能用拖放收進檔案。」── 業務應用程式變更的諮詢對話裡,複製貼上與拖放(D&D)周邊的要求是常客。

正因為這些是「人人都當成理所當然的功能」,實際怎麼運作出奇少人知道。若把剪貼簿想成「放進一份資料的箱子」,你就解釋不了為什麼同一份複製依貼到哪裡結果不同,或為什麼關掉來源應用程式後就再也貼不上。真正的剪貼簿是把同一份內容一次以多種格式放上,讓貼上側挑選它懂的格式的機制。

而拖放,追到底,是經由 COM 介面交出與剪貼簿完全相同資料表示(IDataObject)的 OLE 資料傳輸。換句話說,複製貼上與 D&D 是兄弟:正確理解一個,另一個就在旁邊。

本文以中小企業的 IT 人員與 Windows 應用程式開發者為對象。它把剪貼簿格式怎麼運作、貼上側與複製側的做法、正確監看剪貼簿的方式、剪貼簿歷程/雲端同步/RDP 的管理原則,以及 OLE 拖放的結構與陷阱,收成一張圖。

1. 先講結論

  • 剪貼簿是同一桌面(視窗站)上應用程式共享的單一區域,坐在那裡的不是「一份資料」,而是同一份內容一次以多種格式存在。RDP 這類不同工作階段原本有不同的剪貼簿;重新導向功能才把它們接起來。因為目的地挑選它懂的格式,同一份複製依貼到哪裡結果不同。12
  • 文字用 CF_UNICODETEXT。CF_TEXT 是 ANSI 且依賴字碼頁,在日文系統上是亂碼的溫床。系統會在兩者之間隱含轉換,但正規側是 Unicode。3
  • 檔案以 CF_HDROP(路徑的雙 NUL 終止陣列)傳遞,格式化文字使用已註冊格式「HTML Format」。HTML Format 有不尋常的結構:帶位元組位移標頭的 UTF-8 文字。45
  • 「關掉來源應用程式就再也貼不上」的真正原因是延遲轉譯。它是不放承載、只放「被問起時再產生」承諾的機制;若結束時略過具體化(回應 WM_RENDERALLFORMATS,或 OLE 用 OleFlushClipboard),貼上就停工。26
  • 把貼上的資料當成來自外部的不可信輸入。Microsoft 自己明白寫道「剪貼簿資料不可信。仔細剖析它」。7
  • 監看剪貼簿,AddClipboardFormatListener + WM_CLIPBOARDUPDATE 是唯一選項。不要用輪詢,也不要用舊的 SetClipboardViewer(檢視器鏈)。也提供了讓機密不進歷程與同步的已註冊格式(ExcludeClipboardContentFromMonitorProcessing 等)。81
  • 剪貼簿歷程(Win+V)與雲端同步是 IT 管理議題。可用 GPO / Intune(Policy CSP)的 AllowClipboardHistory 與 AllowCrossDeviceClipboard 控制,RDP 剪貼簿重新導向有自己的專用原則。91011
  • 拖放是 COM。與剪貼簿相同的 IDataObject 在 IDropSource(拖曳來源)與 IDropTarget(放置目標)之間,經由 DoDragDrop 迴圈交出。RegisterDragDrop 需要以 OleInitialize(STA)初始化。1213
  • 無法從普通權限的檔案總管放到提升後的應用程式上。UIPI(依完整性層級阻擋訊息)是原因,這是設計時就該知道的約束。14

以下從剪貼簿基礎往上走。

2. 剪貼簿真正是什麼 ── 不是「一份資料」,而是「同一份內容的多種格式」

剪貼簿是共享同一桌面的每個應用程式都能碰到的共同資料共享機制(更精確地說,它是每個視窗站一份:不同使用者工作階段或 RDP 工作階段各有自己的剪貼簿。複製貼上能跨 RDP 運作,是因為重新導向功能把它們接起來──第 7 章)。第一原則是由使用者驅動:官方設計立場是不要在使用者背後放進或取出資料。1

重要的是,一次複製並不放上「一份資料」。複製的視窗清空剪貼簿,然後連續放上數種格式,把同一份內容從能力較強的格式往能力較弱的格式表達2 例如在試算表裡複製表格時,概念上剪貼簿上同時有像下面這樣的東西。

優先順序 格式 內容
1 應用程式私有格式 包含公式與格式的完整內部表示(貼回同一應用程式用)
2 HTML Format 保留表格結構與格式的 HTML 片段
3 CSV 儲存格分隔的文字
4 CF_UNICODETEXT Tab 分隔的純文字
5 影像格式 表格外觀的點陣圖

貼上側從這份清單挑選它懂的格式並取出。貼進 Word 得到有格式的表格;貼進記事本得到 Tab 分隔文字──因為兩者選了不同格式。「結果依貼到哪裡而變」不是錯誤;這是這個設計的正常後果。

為什麼同一份複製依貼到哪裡結果不同複製側把同一份內容以多種格式放上剪貼簿,貼上側挑選它懂的格式,因此 Word 得到有格式的表格,記事本得到 Tab 分隔文字Word記事本複製:試算表剪貼簿(多種格式)較豐富的格式較樸素的格式應用程式私有HTML FormatCSVCF_UNICODETEXT有格式的表格Tab 分隔文字

圖 1: 複製側一次放上多種格式,貼上側各自挑選,因此同一份複製依目的地結果不同。

反過來說,開頭的抱怨──「格式散掉」、「貼上奇怪的東西」──幾乎都收斂成一側怎麼選格式,或另一側怎麼提供格式的問題。第 4 章談貼上側;第 5 章談複製側。

3. 標準格式與已註冊格式 ── CF_UNICODETEXT、CF_HDROP、HTML Format

3.1. 標準格式 ── 文字用 Unicode 側

作業系統事先定義的格式稱為標準格式。業務應用程式裡不斷出現的是以下這些。3

格式 內容
CF_TEXT 1 ANSI 文字(依賴字碼頁)
CF_UNICODETEXT 13 Unicode 文字。這是文字的正規格式
CF_HDROP 15 檔案路徑清單(HDROP 控制代碼)
CF_DIB 8 與裝置無關的點陣圖
CF_LOCALE 16 與文字關聯的地區識別碼

CF_TEXT 與 CF_UNICODETEXT 由系統彼此隱含轉換(合成格式)。字元碼轉換使用與 CF_LOCALE 關聯的字碼頁。3 依賴那次轉換會丟掉 ANSI 無法表示的字元(例如僅 Unicode 的符號與組合字元),因此規則是把應用程式讀寫統一在 CF_UNICODETEXT(.NET 的 DataFormats.UnicodeText)上

CF_UNICODETEXT 與 CF_TEXT 之間的隱含轉換應用程式只讀寫 CF_UNICODETEXT;系統用 CF_LOCALE 字碼頁隱含轉換合成 CF_TEXT。ANSI 無法表示的字元在那次轉換中被丟掉CF_LOCALE 轉換應用程式讀寫CF_UNICODETEXTCF_TEXT(ANSI)無法表示的字元被丟掉

圖 2: 應用程式讀寫統一在 Unicode 側;對 ANSI 的轉換交給系統,並接受無法表示的字元會丟掉。

3.2. CF_HDROP ── 檔案以「路徑清單」傳遞

CF_HDROP 用在檔案總管裡複製檔案,或拖放檔案時。承載不是檔案本身;它是一塊記憶體,鋪開「雙 NUL 終止」陣列:DROPFILES 結構標頭之後,以 NUL 字元分隔的完整路徑字串,結尾是空字串。標頭的 pFiles 是路徑清單的起始位移,fWide 說明字串是否為 Unicode。4

[DROPFILES header: pFiles=start offset of the path list, fWide=1(Unicode)]
C:\data\a.txt(NUL)C:\data\b.txt(NUL)(NUL)

原生程式碼用 DragQueryFile 一次取出一個;.NET 經由 DataFormats.FileDrop 以 string[] 接收。「傳遞的只有路徑,不是檔案本身」這個事實,在第 8、9 章的 D&D 會再次重要。

CF_HDROP 的記憶體區塊配置全域記憶體開頭是 DROPFILES 結構;pFiles 是路徑清單起始位移,fWide 說明是否為 Unicode。完整路徑隨後以 NUL 分隔,區塊以空字串(雙 NUL)結束。傳遞的只有路徑,不是檔案本身DROPFILES(pFiles / fWide)C:\\data\\a.txt + NULC:\\data\\b.txt + NUL空字串(雙 NUL)只傳遞路徑,不傳遞檔案

圖 3: CF_HDROP 帶的是路徑清單,不是檔案本體;標頭的 pFiles 與 fWide 決定怎麼讀。

3.3. 已註冊格式 ── RegisterClipboardFormat 與「HTML Format」

對標準格式無法表達的資料,應用程式可以自訂名稱並註冊自己的格式。把名稱傳給 RegisterClipboardFormat 會拿回格式識別碼;從不同應用程式以同一名稱註冊會傳回同一識別碼,因此一旦對名稱達成協議,就能在應用程式之間共享資料。1 在自己的一套應用程式之間傳遞結構化資料時,使用不會碰撞的名稱,例如 KomuraSoft.Report.RowData

代表性的已註冊格式是格式化文字的 「HTML Format」(與 RTF 並列的兩大豐富文字格式之一)。承載是 UTF-8 文字,但有不尋常的結構:前面附上列出位元組位移的標頭5

Version:0.9
StartHTML:<byte offset of the start of the whole HTML>
EndHTML:<byte offset of the end of the whole HTML>
StartFragment:<byte offset of the start of the fragment>
EndFragment:<byte offset of the end of the fragment>
<html><body>
<!--StartFragment--><b>bold</b> fragment text<!--EndFragment-->
</body></html>

每個位移都是從資料開頭算起的位元組位置,包含標頭本身;通常做法是預留固定寬度(例如 10 位數),建好本體後再把量到的值寫回去。StartFragment/EndFragment 以位元組(不是字元)標出「使用者實際選取的片段」起迄。在包含中文的 UTF-8 裡,字元數與位元組數會分叉,因此這個位移算錯,貼進另一個應用程式就會丟掉開頭或結尾。若自己產生 HTML Format,必須用編碼成 UTF-8 之後量到的位元組位置填標頭。5

HTML Format 標頭與位移的關係標頭的 StartHTML 與 EndHTML 指向整份 HTML,StartFragment 與 EndFragment 指向使用者選取的片段,兩者都是從資料開頭算起的位元組位置。因為 UTF-8 裡字元數與位元組數會分叉,用編碼後量到的位元組位置填標頭標頭(位元組位移)整份 HTML選取的片段位移是 UTF-8 之後的位元組

圖 4: HTML Format 的位移是編碼後的位元組位置;用字元數去填,貼上就會切錯片段。

CSV(.NET 的 DataFormats.CommaSeparatedValue)也常用於表格資料。為了與 Excel 互通,同時提供 HTML Format(帶格式)、CSV(僅值)與 CF_UNICODETEXT(Tab 分隔),就不必只挑一個貼上目的地。

4. 貼上側的做法 ── 格式優先順序與驗證

4.1. 從豐富格式往下看

剪貼簿上的格式依複製側放上的順序排列(也就是從表達力較強到較弱)。貼上側的基準是在你能處理的格式之中,從資訊最多的那個開始看。在 Win32 你可以用 EnumClipboardFormats 列舉並使用第一個認得的格式,或把自己的優先清單傳給 GetPriorityClipboardFormat 讓它選。2

在 .NET 裡分支看起來像下面這樣。

// Pasting a table: look from rich to plain
var data = Clipboard.GetDataObject();
if (data is null) return;

// Advertising a format does not guarantee the payload is a string. Use this
// branch only when the type also checks out; otherwise fall through to the next candidate
if (data.GetDataPresent(DataFormats.Html)
    && data.GetData(DataFormats.Html) is string html)
{
    // Validate the HTML Format header, then import as a table
}
else if (data.GetDataPresent(DataFormats.CommaSeparatedValue))
{
    // Import as CSV
}
else if (data.GetDataPresent(DataFormats.UnicodeText))
{
    // Import as tab-separated text
}

這就是開頭抱怨「貼上 Excel 表格就散掉」的答案。只讀純文字的應用程式永遠收不到表格結構。格式清單往下接受到哪裡,是貼上側的設計決定

從豐富格式往下看的貼上分支若 HTML Format 存在且承載也是字串,就匯入為表格;否則試 CSV;若也沒有,落到 Tab 分隔文字。候選都不在就拒絕開始貼上HTML Format + 字串?驗證標頭 → 表格有 CSV?以 CSV 匯入UnicodeText?Tab 分隔文字拒絕

圖 5: 貼上側從資訊最多的格式往下找;只讀純文字就永遠收不到表格結構。

4.2. 貼上的資料是外部輸入

容易漏掉,但剪貼簿內容是來自外部的資料,你不知道哪個應用程式放上去的。Microsoft 也在 OLE 剪貼簿文件裡警告「剪貼簿資料不可信。在應用程式使用前仔細剖析它」。7

  • 驗證 HTML Format 標頭位移不指向緩衝區外(發出壞標頭的應用程式確實存在)。
  • 匯入為數字、日期或代碼的值,應走與畫面上輸入相同的驗證。
  • 對巨大資料加上防禦。即使有人貼上數百 MB 的影像或數百萬行文字,也不要擋住 UI,超過上限就拒絕。要注意:.NET 的 GetData 在你呼叫的那一刻,就把整個承載具體化成受控字串(延遲轉譯也作為其中一部分執行),因此把大小檢查放在 GetData 之後不是防禦。在 Win32,對 GetClipboardData 傳回的 HGLOBAL 檢查 GlobalSize,確實能在「不要進入轉換與剖析成受控字串」這個階段防禦,但對延遲轉譯格式,GetClipboardData 本身就會啟動轉譯,因此仍無法阻止複製來源側的具體化。為了不讓 UI 凍結,把擷取移出 UI 執行緒(即便如此,因為 .NET 的 Clipboard 要求 STA,要在設成 STA 的專用執行緒上做,不要在 Task.Run 的執行緒集區執行緒(MTA)上──第 5.1 節)。

「無論從哪條路到達的外部值,使用前都要驗證」這個想法,與「不要直接使用 QR Code 的解碼值」所講的相同。因為是使用者動作就假設貼上安全,事故就從這裡開始。

使用前先驗證貼上的資料從剪貼簿取出的資料依序經過格式存在、承載類型、大小上限與內容驗證;任何一項失敗就拒絕或落到下一個候選格式類型不對太大無效格式存在?承載類型 OK?大小在上限內?驗證內容匯入拒絕/下一個格式

圖 6: 貼上的資料是外部輸入;格式存在、類型、大小與內容都要過關才匯入。

5. 複製側的做法 ── 一次提供多種格式,以及延遲轉譯

5.1. 一次放上多種格式

複製側的做法是 4.1 的反向:同時提供豐富格式與純格式。用 WinForms/WPF 的 DataObject 幾行就能寫。15

// WinForms (System.Windows.Forms). WPF is the same shape with System.Windows DataObject/Clipboard
var data = new DataObject();
data.SetData(DataFormats.Html, htmlFormatText);       // HTML Format string including the header
data.SetData(DataFormats.CommaSeparatedValue, csv);   // CSV
data.SetData(DataFormats.UnicodeText, plainText);     // Plain text
Clipboard.SetDataObject(data, copy: true);            // copy:true = keep after the app exits

兩點注意。第一,.NET 的 Clipboard 類別只能從 STA 執行緒使用15 WinForms/WPF 的 UI 執行緒因 [STAThread] 而是 STA,因此通常不是問題,但從背景執行緒碰它會失敗(STA/MTA 基礎見「COM STA/MTA 基礎」)。第二,copy: true 的意義與下一小節的延遲轉譯綁在一起。

5.2. 延遲轉譯 ── 為什麼「關掉來源就貼不上」

每次用多種格式建大型承載很浪費,因此剪貼簿有一個叫延遲轉譯的機制。把 NULL 當資料控制代碼傳給 SetClipboardData,註冊的就不是承載,而只是「被問起時再產生」的承諾;有人要求該格式時,WM_RENDERFORMAT 送到複製來源,那時才產生資料。2

這個設計的後果就是開頭的「關掉來源應用程式就再也貼不上」。結束前,複製來源收到 WM_RENDERALLFORMATS,並負責把尚未轉譯的每種格式具體化;不做就結束,格式就遺失。2

延遲轉譯與關掉後貼不上的原因複製來源用 NULL 控制代碼只註冊承諾,並經由 WM_RENDERFORMAT 隨需具體化。結束時它負責用 WM_RENDERALLFORMATS 把每種格式具體化;略過那個,格式就遺失RENDERALLFORMATS略過具體化SetClipboardData NULL = 承諾貼上側要求它WM_RENDERFORMAT → 現在才建複製來源即將結束結束後仍能貼上關掉後格式遺失

圖 7: 延遲轉譯只放承諾;結束時不具體化,尚未轉譯的格式就隨來源一起消失。

在 OLE 剪貼簿(用 OleSetClipboard 放上 IDataObject 的風格)裡,這個關係更清楚。剪貼簿持有的只是指向資料物件的指標,在應用程式結束時呼叫 OleFlushClipboard 把資料具體化到剪貼簿上,結束後仍能貼上6 .NET 的 Clipboard.SetDataObject(data, copy: true) 就是指定這個「結束後保留」行為。

在 Excel 裡複製大範圍再試圖結束時,提示「剪貼簿上有大量資訊。您要稍後將此資訊貼到其他程式嗎?」正是要不要執行這次具體化(flush)的確認。若在自己的應用程式使用延遲轉譯,記住結束時的具體化是同一套的一部分。延遲轉譯是效能最佳化,而且轉譯要求在訊息處理內部同步執行,產生很久的資料有凍結 UI 的取捨。2

6. 監看剪貼簿的做法 ── 接聽者、重試與歷程排除

6.1. 使用 AddClipboardFormatListener

「想偵測條碼機的值或從業務系統的複製並自動匯入」這類需求,需要監看剪貼簿變更。歷史上有三種方法;今天正確答案是一個。8

方法 評估
用計時器讀取(輪詢) 浪費,而且可能漏掉更新。不要用
SetClipboardViewer(檢視器鏈) 鏈上一個應用程式的錯誤會弄壞整條鏈。只為向後相容而保留
AddClipboardFormatListener 建議。WM_CLIPBOARDUPDATE 送到已註冊的視窗
監看剪貼簿的流程控制代碼建立時用 AddClipboardFormatListener 註冊,無論哪個應用程式複製都會送到 WM_CLIPBOARDUPDATE。以重試讀取,控制代碼銷毀時用 RemoveClipboardFormatListener 對稱解除註冊解除註冊AddClipboardFormatListener等待某個應用程式複製WM_CLIPBOARDUPDATE以重試讀取(6.2)RemoveClipboardFormatListener

圖 8: 建議的監看是接聽者加上變更訊息;建立與銷毀控制代碼時對稱註冊與解除。

// Minimal WinForms implementation
public partial class MainForm : Form
{
    [DllImport("user32.dll", SetLastError = true)]
    static extern bool AddClipboardFormatListener(IntPtr hwnd);
    [DllImport("user32.dll", SetLastError = true)]
    static extern bool RemoveClipboardFormatListener(IntPtr hwnd);
    const int WM_CLIPBOARDUPDATE = 0x031D;

    protected override void OnHandleCreated(EventArgs e)
    {
        base.OnHandleCreated(e);
        AddClipboardFormatListener(Handle);
    }

    protected override void OnHandleDestroyed(EventArgs e)
    {
        // Unregister symmetrically to match handle destruction / recreation
        RemoveClipboardFormatListener(Handle);
        base.OnHandleDestroyed(e);
    }

    protected override void WndProc(ref Message m)
    {
        if (m.Msg == WM_CLIPBOARDUPDATE)
        {
            // Read Clipboard.GetDataObject() here and import if the format is one you need
        }
        base.WndProc(ref m);
    }
}

6.2. 打不開時重試

一次只能有一個視窗開啟剪貼簿;另一個處理程序開著時,OpenClipboard 會失敗。2 WM_CLIPBOARDUPDATE 剛到之後,複製來源或另一個監看者常常仍在操作,因此暫時讀取失敗是正常事件。一定要加上幾次重試,中間短暫等待(數十毫秒)。注意讓你指定重試次數與間隔的 .NET Clipboard 多載只存在於寫入側 SetDataObject。讀取側(GetDataObject 等)沒有對等物,因此要自己寫捕捉–等待–重試──WinForms 上是 ExternalException,WPF 上是 COMException。

剪貼簿讀取重試流程一次只能有一個視窗開啟剪貼簿,因此變更通知後立刻讀取可能與另一個處理程序競態而失敗。發生例外時等數十毫秒再重試;碰到上限就這次放棄,下次更新再撿成功使用中重試上限WM_CLIPBOARDUPDATE嘗試讀取匯入(第 4 章檢查)等數十毫秒這次放棄

圖 9: 變更通知後立刻讀取常會撞上仍開著的來源;短暫等待後重試是日常,不是例外。

6.3. 不讓它進歷程與同步 ── 照顧處理機密的複製功能

Windows 有剪貼簿歷程(Win+V)與跨裝置同步(雲端剪貼簿),應用程式放上的資料預設都在兩者範圍內。把密碼或帳號這類機密放上複製功能的應用程式,也放上把內容排除出歷程與同步的已註冊格式1

  • ExcludeClipboardContentFromMonitorProcessing:放上這個,該次複製的內容既不進歷程也不進同步。
  • CanIncludeInClipboardHistory(DWORD 0):只抑制歷程。
  • CanUploadToCloudClipboard(DWORD 0):只抑制跨裝置同步。

密碼管理員複製的密碼不會留在 Win+V 上,原因就是這個機制。把名稱傳給 RegisterClipboardFormat 取得格式識別碼,並與普通資料並排放上,因此任何處理機密的業務應用程式都值得實作。

7. IT 視角的剪貼簿 ── 歷程、雲端同步與 RDP 控制

稍微離開開發,這裡是對管理員重要的點。剪貼簿歷程累積最近的複製,雲端剪貼簿把複製同步到以同一 Microsoft 帳戶/Microsoft Entra 帳戶登入的裝置。10 方便之餘,也產生殘留與外溢:從業務系統複製的個人資訊累積在歷程裡,工作 PC 上複製的內容同步到個人 PC

組織裡用來控制這個的兩個原則如下。

控制什麼 GPO(電腦設定 > 系統管理範本 > 系統 > OS 原則) Policy CSP(Intune) 預設
剪貼簿歷程 Allow Clipboard History Experience/AllowClipboardHistory 允許
跨裝置同步 Allow Clipboard synchronization across devices Privacy/AllowCrossDeviceClipboard 允許

兩者從 Windows 10 版本 1809 起可用;停用後設定應用程式裡對應項目會變灰,原則立即生效。910

另一個常客是 RDP(遠端桌面)剪貼簿重新導向。預設本機 PC 與遠端工作階段之間可以複製貼上,因此可能變成把機密帶離伺服器的路徑。「不允許剪貼簿重新導向」原則(登錄值 fDisableClip)可以雙向阻擋。11 近期 Windows Server / Windows 11 版本也加了更細的原則,例如把伺服器到用戶端方向限制成只有文字。徹底禁止還是分階段限制,是營運與安全性的平衡。

剪貼簿內容可能擴散的路徑與控制點複製的內容預設在歷程與雲端同步範圍內,RDP 上經由重新導向傳到另一個工作階段。每條路徑都能用原則控制,應用程式側也能用排除格式把自己排除出歷程與同步剪貼簿歷程(Win+V)雲端同步RDP 重新導向AllowClipboardHistoryAllowCrossDeviceClipboardfDisableClip應用程式排除格式(6.3)

圖 10: 歷程、雲端同步與 RDP 重新導向是三條擴散路徑;原則與排除格式是對應的控制點。

8. 拖放是 COM ── IDataObject + IDropSource + IDropTarget

8.1. 與剪貼簿相同的資料,不同的搬運方式

OLE 拖放以以下三個角色運轉。12

角色 誰實作 工作
IDataObject 拖曳來源 正在搬運的承載。與剪貼簿相同的多格式資料物件
IDropSource 拖曳來源 決定拖曳繼續或取消,以及游標回饋
IDropTarget 放置目標 在 DragEnter/DragOver/DragLeave/Drop 宣告接受/拒絕,並接收放置

拖曳來源呼叫 DoDragDrop,拖曳迴圈開始,滑鼠進入放置目標視窗時通知該 IDropTarget;放置時交出 IDataObject。官方文件也說「D&D 提供與剪貼簿複製貼上完全相同的功能。若應用程式已經實作複製貼上,加上去的很少」。12 換句話說,第 2 到 5 章建好的多格式 DataObject 原樣成為 D&D 承載

OLE 拖放的流程拖曳來源把 IDataObject 放進承載並呼叫 DoDragDrop 開始拖曳迴圈;放置目標的 IDropTarget 在 DragEnter 與 DragOver 宣告接受/拒絕,在 Drop 時從 IDataObject 挑選格式並取出DoDragDrop滑鼠進入按鈕放開IDataObject + IDropSource拖曳迴圈DragEnter/Over:EffectIDropTarget.Drop挑選格式並取出

圖 11: 拖放迴圈交出的是與剪貼簿相同的 IDataObject;貼上側的格式挑選原樣適用。

8.2. 需要 OleInitialize(STA)

要當放置目標的視窗用 RegisterDragDrop 註冊,這裡有一個經典陷阱。若用 CoInitialize/CoInitializeEx 初始化 COM,RegisterDragDrop 一律以 E_OUTOFMEMORY 失敗;必須用 OleInitialize 初始化13 OleInitialize 把 COM 初始化成 STA,因為 D&D 是植根於視窗與訊息幫浦這個 STA 世界的功能。呼叫執行緒也必須在跑訊息幫浦;略過那個,其他應用程式在拖曳期間會卡住。13 這裡的背景正是「COM STA/MTA 基礎」裡的執行緒模型討論。

在 WinForms/WPF 應用程式裡,架構會照顧 OLE 初始化與介面實作,因此開發者只要寫事件。

// WinForms: accept dropped files
listView1.AllowDrop = true;
listView1.DragEnter += (s, e) =>
{
    // Also check that the source allows Copy (some sources only allow Move/Link)
    e.Effect = e.Data.GetDataPresent(DataFormats.FileDrop)
            && (e.AllowedEffect & DragDropEffects.Copy) == DragDropEffects.Copy
        ? DragDropEffects.Copy      // Accept: receive as a copy
        : DragDropEffects.None;     // Do not accept
};
listView1.DragDrop += (s, e) =>
{
    // Drag data is also untrusted input. Even if it advertises FileDrop, the payload
    // can be null or a different type, and GetData itself can fail
    object data;
    try { data = e.Data.GetData(DataFormats.FileDrop); }
    catch (COMException) { return; }
    if (data is not string[] paths) return;
    foreach (var path in paths)
    {
        // Validate the path before importing (Section 9.3)
    }
};

WPF 的形狀相同:用元素上的 AllowDrop="True"DragOver/Drop 事件接收,並用 e.Data.GetData(DataFormats.FileDrop) 取出路徑陣列。在每一次 DragEnter/DragOver 宣告接受/拒絕(Effect) 是 IDropTarget 的慣例;略過它就會得到游標停在「不允許」永遠不變的錯誤。

9. D&D 的陷阱 ── 提升、移動與路徑驗證

9.1. 無法放到以系統管理員身分提升的應用程式上

從檔案總管把檔案放到以「以系統管理員身分執行」啟動的應用程式上什麼都不發生──這不是實作錯誤,是作業系統行為。UIPI(使用者介面權限隔離)預設阻擋從較低完整性處理程序送到較高完整性視窗的訊息,因此來自普通權限(中完整性)檔案總管的放置通知永遠到不了提升後的應用程式。14

UIPI 如何阻擋放到提升後的應用程式從中完整性檔案總管到高完整性提升應用程式的放置通知,預設被 UIPI 阻擋且永遠不到達。把 UI 留在普通權限並隔離特權工作,放置就會到達放置通知被擋通過委託特權工作檔案總管(中)UIPI提升後的應用程式:沒有放置普通 UI:放置到達隔離的提升處理程序

圖 12: 中完整性的檔案總管無法把放置送到高完整性視窗;把 UI 留在普通權限,放置才會到。

用 ChangeWindowMessageFilterEx 個別允許 WM_DROPFILES 等特定訊息的變通做法廣為人知,14 但那放行的是較舊(WM_DROPFILES)的放置通知;它沒有解決整體 OLE D&D。實務指引很清楚:停止把應用程式設計成一直提升執行。只把需要提升的工作隔離到另一個處理程序,UI 本身就能留在普通權限並接收 D&D(隔離設計詳見「Windows 應用程式中把「僅需要系統管理員權限的處理」分離出來的具體寫法」)。

9.2. DragDropEffects 的意義 ── Move 是「原本會消失」的契約

DragDropEffects 上的 Copy/Move/Link 不是裝飾;它們是拖曳來源與放置目標之間的契約。拖曳來源在 DoDragDrop 宣告它允許的效果集合,放置目標挑選實際效果,Move 成功時,拖曳來源刪除資料(檔案)──那是慣例。若接收側不經思索地傳回 Move,就得到「我放下去,原本的檔案消失了」的事故。對業務應用程式的匯入用途,接收側聲明 Copy 是安全預設。

DragDropEffects 契約──Move 刪除原本拖曳來源在 DoDragDrop 宣告允許的效果集合,放置目標挑選實際效果。Move 成功時拖曳來源刪除檔案,因此匯入時接收側應聲明 CopyCopyMove來源:允許的效果目標:挑選 Effect原本留下(匯入)來源刪除檔案

圖 13: Move 成功等於來源刪除原本;匯入用途讓接收側聲明 Copy。

9.3. 驗證放下的路徑

CF_HDROP/FileDrop 裡傳遞的只有路徑(第 3.2 節)。匯入前,把它走與貼上相同的不可信輸入驗證。

  • 檔案或資料夾:整份資料夾被放下時發生什麼,當成規格決定(遞迴匯入,或拒絕)。
  • OneDrive 預留位置:路徑可能存在而檔案本體不在本機──隨選檔案。你一打開就開始下載,離線就失敗。行為與對策見「OneDrive “Files On-Demand” and Business Apps」。
  • 長路徑與不尋常路徑:超過 MAX_PATH 的路徑、網路(UNC)路徑,以及卸除式媒體上的路徑,只有在確認下游處理能應付後才接受。
  • 數量與總大小:為了讓放下數千個檔案不凍結 UI,把匯入做成非同步,並加上上限與進度顯示。

10. 總結

  • 剪貼簿是在同一桌面(視窗站)共享的單一區域裡,把同一份內容一次以多種格式放上的機制。貼上側挑選格式,因此同一份複製結果不同。
  • 文字是 CF_UNICODETEXT,檔案是 CF_HDROP,格式化文字是已註冊格式 HTML Format(位元組位移標頭 + UTF-8)。
  • 貼上側從豐富往樸素看,並把承載當成外部輸入。複製側一次提供多種格式,若使用延遲轉譯也實作結束時具體化(WM_RENDERALLFORMATS / OleFlushClipboard)。
  • 監看是 AddClipboardFormatListener + WM_CLIPBOARDUPDATE。用重試準備 OpenClipboard 競態,並用 ExcludeClipboardContentFromMonitorProcessing 等讓機密不進歷程與同步。
  • IT 可用 GPO / Intune 控制剪貼簿歷程、雲端同步與 RDP 重新導向。預設全部允許,因此處理機密的環境要刻意決定。
  • D&D 是 COM:IDropSource/IDropTarget 交出與剪貼簿相同的 IDataObject。RegisterDragDrop 需要 OleInitialize(STA)。
  • 放到提升後應用程式上會被 UIPI 阻擋。DragDropEffects 上的 Move 是「原本會消失」的契約;匯入前先驗證放下的路徑。

對使用者來說,複製貼上與 D&D 應該像空氣一樣的功能。正因如此,「貼不上」、「散掉」、「消失了」才那麼傷體驗──而提供多種格式、正確處理放置的應用程式,本身就會讓日常操作更順。希望這在你決定先修什麼時有用。

相關文章

相關諮詢領域

小村軟體有限公司承接業務應用程式裡複製貼上與拖放支援的設計與實作(提供多種格式、Excel 互通、匯入放下的檔案)、「一貼上就散掉」或「複製消失」這類問題的根本原因調查、監看剪貼簿的輸入自動化,以及讓機密資料不進歷程與同步的實作。涉及 COM 與 OLE 下層的案例,即使從隔離症狀開始也歡迎。

參考連結

  1. Microsoft Learn, Clipboard Formats. 關於視窗能把同一資訊以多種剪貼簿格式放上;經由 RegisterClipboardFormat 的已註冊格式(註冊同一名稱傳回同一值,因此應用程式能共享);合成格式;以及用 ExcludeClipboardContentFromMonitorProcessing、CanIncludeInClipboardHistory 與 CanUploadToCloudClipboard 把內容排除出剪貼簿歷程/雲端同步。  2 3 4 5

  2. Microsoft Learn, Clipboard Operations. 關於一次只能有一個視窗開啟剪貼簿;複製時從表達力較強到較弱放上格式;貼上時用 EnumClipboardFormats / GetPriorityClipboardFormat 選格式;把 NULL 傳給 SetClipboardData 的延遲轉譯以及 WM_RENDERFORMAT / WM_RENDERALLFORMATS 責任;以及延遲轉譯的取捨。  2 3 4 5 6 7 8

  3. Microsoft Learn, Standard Clipboard Formats. 關於標準格式 CF_TEXT(ANSI)、CF_UNICODETEXT、CF_HDROP、CF_DIB 與 CF_LOCALE 的定義,以及系統使用與 CF_LOCALE 關聯的字碼頁隱含轉換 CF_TEXT 與 CF_UNICODETEXT。  2 3

  4. Microsoft Learn, Shell Clipboard Formats. 關於 CF_HDROP 由 DROPFILES 結構加上雙 NUL 終止的完整路徑字串陣列組成;用 DragQueryFile 取出個別路徑;以及 CFSTR_ 殼層格式需要經由 RegisterClipboardFormat 註冊。  2

  5. Microsoft Learn, HTML Clipboard Format. 關於已註冊名稱是「HTML Format」;帶 Version、StartHTML、EndHTML、StartFragment、EndFragment 等位元組位移的標頭結構;編碼一律是 UTF-8;以及 StartFragment/EndFragment 註解慣例。  2 3

  6. Microsoft Learn, OleFlushClipboard function (ole2.h). 關於 OleSetClipboard 讓剪貼簿只持有指向資料物件的指標;OleFlushClipboard 把資料具體化到剪貼簿上,應用程式結束後仍能貼上;以及結束時不需要保留就用 OleSetClipboard(NULL) 清空剪貼簿。  2

  7. Microsoft Learn, OleGetClipboard function (ole2.h). 關於如何從剪貼簿取得 IDataObject,以及剪貼簿資料不可信、應用程式使用前應仔細剖析的警告。  2

  8. Microsoft Learn, Using the clipboard. 關於比較三種監看剪貼簿的方式(檢視器視窗、序號與格式接聽者);新程式應經由 AddClipboardFormatListener 使用接聽者;檢視器鏈在鏈維護不完整時脆弱;以及序號不是該輪詢的東西。  2

  9. Microsoft Learn, Policy CSP - Experience. 關於用 Experience/AllowClipboardHistory 原則允許或拒絕剪貼簿歷程;從 Windows 10 版本 1809 起可用;預設為允許;以及 GPO 對應在「系統 > OS 原則」下,變更立即生效。  2

  10. Microsoft Learn, Policy CSP - Privacy. 關於用 Privacy/AllowCrossDeviceClipboard 原則允許或拒絕跨裝置剪貼簿同步;同步發生在以同一 Microsoft 帳戶/Microsoft Entra 帳戶登入的裝置之間;以及預設為允許。  2 3

  11. Microsoft Learn, Policy CSP - ADMX_TerminalServer. 關於 TS_CLIENT_CLIPBOARD(「不允許剪貼簿重新導向」,登錄值 fDisableClip)能禁止遠端桌面工作階段中本機與遠端之間的剪貼簿共享,以及重新導向預設允許。  2

  12. Microsoft Learn, Drag and Drop (COM). 關於 OLE 拖放以 IDropSource(拖曳來源)、IDropTarget(放置目標)與 DoDragDrop(OLE 提供的迴圈)三者運轉;提供與剪貼簿複製貼上相同的功能,因此已實作複製貼上的應用程式只需加上很少;以及回饋的種類。  2 3

  13. Microsoft Learn, RegisterDragDrop function (ole2.h). 關於用 IDropTarget 註冊放置目標視窗;若用 CoInitialize/CoInitializeEx 初始化 COM 一律以 E_OUTOFMEMORY 失敗,因此需要 OleInitialize;以及呼叫執行緒沒有在跑訊息幫浦時拖曳來源應用程式會卡住。  2 3

  14. Microsoft Learn, ChangeWindowMessageFilterEx function (winuser.h). 關於 UIPI 是預設阻擋接收來自較低完整性傳送端訊息的安全性機制,以及用訊息篩選(MSGFLT_ALLOW)依視窗允許特定訊息。  2 3

  15. Microsoft Learn, How to add data to the Clipboard (Windows Forms). 關於用 DataObject 與 Clipboard.SetDataObject 一次放上多種格式的資料;以多種格式加入以便其他應用程式認得;以及 Clipboard 類別只能從 STA 執行緒使用,因此需要 [STAThread]。  2

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

為什麼從 Excel 複製的表格貼進我的應用程式時格式會散掉?
剪貼簿並不持有「一份資料」。同一份內容一次以數種格式放上(來源應用程式的私有格式、HTML Format、CSV、Unicode 文字等),目的地應用程式挑選它懂的格式並取出。格式散掉時,典型原因是目的地只讀純文字(CF_UNICODETEXT)。若也想要表格結構,貼上側應優先實作 HTML Format 或 CSV。反過來說,若希望其他應用程式能從你自己應用程式的複製正確貼上,複製時同時提供豐富格式與純格式。
為什麼關掉我複製來源的應用程式後就再也貼不上?
因為來源使用延遲轉譯。處理大型資料的應用程式在複製時不放上承載;它們只在剪貼簿上註冊一個「被問起時再產生」的承諾。若來源隨後結束卻沒有回應關閉時的 WM_RENDERALLFORMATS 把資料具體化,任何尚未轉譯的格式就會遺失。使用 OLE 剪貼簿(IDataObject)的應用程式,可在關閉時呼叫 OleFlushClipboard 把資料具體化,讓結束後仍能貼上。
我自己的應用程式要怎麼監看剪貼簿變更?
目前建議的方法是用 AddClipboardFormatListener 把視窗註冊為接聽者,並處理每次內容變更都會送到的 WM_CLIPBOARDUPDATE 訊息。用計時器輪詢內容浪費工作且可能漏掉更新;以 SetClipboardViewer 為基礎的舊檢視器鏈只為向後相容而保留,因為鏈上一個應用程式的錯誤會弄壞整條鏈。另外注意讀取時 OpenClipboard 可能因另一個處理程序握著剪貼簿而失敗,因此想讓讀取穩定,應實作短暫等待後重試。
有沒有辦法讓密碼這類機密不要進剪貼簿歷程(Win+V)?
有兩根槓桿,一根在應用程式側,一根在原則側。應用程式側,複製時一併放上已註冊格式 ExcludeClipboardContentFromMonitorProcessing,該內容就既不進歷程也不進跨裝置同步。也可以用 CanIncludeInClipboardHistory(僅歷程)與 CanUploadToCloudClipboard(僅同步)各自獨立控制。這是密碼管理員使用的機制。若想對整個組織關掉,可用群組原則或 Intune(Policy CSP)的 AllowClipboardHistory 與 AllowCrossDeviceClipboard 停用歷程與雲端同步本身。
為什麼無法把檔案拖放到以系統管理員身分執行的應用程式上?
因為名為 UIPI(使用者介面權限隔離)的安全性機制會阻擋從較低完整性處理程序送到較高完整性視窗的訊息傳遞。檔案總管以普通權限(中完整性)執行,因此拖放通知永遠到不了提升後應用程式的視窗。用 ChangeWindowMessageFilterEx 個別允許 WM_DROPFILES 等訊息的變通做法廣為人知,但那只適用於較舊的放置通知。真正的修正是停止把應用程式設計成一直提升執行,只把需要提升的工作隔離到另一個處理程序。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽