From ca41244188f2a5fe38553cf1ca55ac3fb2720595 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen <78434827+TCOTC@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:26:13 +0800 Subject: [PATCH] :art: Support copying file in the asset menu on Windows and macOS (#17049) --- app/appearance/langs/ar_SA.json | 1 + app/appearance/langs/de_DE.json | 1 + app/appearance/langs/en_US.json | 1 + app/appearance/langs/es_ES.json | 1 + app/appearance/langs/fr_FR.json | 1 + app/appearance/langs/he_IL.json | 1 + app/appearance/langs/it_IT.json | 1 + app/appearance/langs/ja_JP.json | 1 + app/appearance/langs/ko_KR.json | 1 + app/appearance/langs/pl_PL.json | 1 + app/appearance/langs/pt_BR.json | 1 + app/appearance/langs/ru_RU.json | 1 + app/appearance/langs/tr_TR.json | 1 + app/appearance/langs/zh_CHT.json | 1 + app/appearance/langs/zh_CN.json | 1 + app/src/menus/protyle.ts | 17 +++- app/src/menus/util.ts | 22 +++++ kernel/api/clipboard.go | 39 +++++++- kernel/api/router.go | 1 + kernel/go.mod | 1 + kernel/go.sum | 2 + kernel/util/clipboard.go | 26 +++++ kernel/util/clipboard_darwin.go | 104 ++++++++++++++++++++ kernel/util/clipboard_windows.go | 162 +++++++++++++++++++++++++++++++ 24 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 kernel/util/clipboard.go create mode 100644 kernel/util/clipboard_darwin.go create mode 100644 kernel/util/clipboard_windows.go diff --git a/app/appearance/langs/ar_SA.json b/app/appearance/langs/ar_SA.json index 59ed86af4..8a531137f 100644 --- a/app/appearance/langs/ar_SA.json +++ b/app/appearance/langs/ar_SA.json @@ -1333,6 +1333,7 @@ "column": "العمود", "copied": "تم النسخ", "copy": "نسخ", + "copyFile": "نسخ الملف", "copyText": "نسخ النص *", "delete-column": "حذف العمود", "delete-row": "حذف الصف", diff --git a/app/appearance/langs/de_DE.json b/app/appearance/langs/de_DE.json index 40981418c..32a8b9ef9 100644 --- a/app/appearance/langs/de_DE.json +++ b/app/appearance/langs/de_DE.json @@ -1333,6 +1333,7 @@ "column": "Spalte", "copied": "Kopiert", "copy": "Kopieren", + "copyFile": "Datei kopieren", "copyText": "Text kopieren *", "delete-column": "Spalte löschen", "delete-row": "Zeile löschen", diff --git a/app/appearance/langs/en_US.json b/app/appearance/langs/en_US.json index 31baa7e1d..1c40f0227 100644 --- a/app/appearance/langs/en_US.json +++ b/app/appearance/langs/en_US.json @@ -1333,6 +1333,7 @@ "column": "Column", "copied": "Copied", "copy": "Copy", + "copyFile": "Copy file", "copyText": "Copy text *", "delete-column": "Delete Column", "delete-row": "Delete Row", diff --git a/app/appearance/langs/es_ES.json b/app/appearance/langs/es_ES.json index 05dcd12aa..8da1dce79 100644 --- a/app/appearance/langs/es_ES.json +++ b/app/appearance/langs/es_ES.json @@ -1333,6 +1333,7 @@ "column": "Columna", "copied": "Copiado", "copy": "Copiar", + "copyFile": "Copiar archivo", "copyText": "Copiar texto *", "delete-column": "Borrar columna", "delete-row": "Borrar fila", diff --git a/app/appearance/langs/fr_FR.json b/app/appearance/langs/fr_FR.json index c1f0a66d2..9c3031ab1 100644 --- a/app/appearance/langs/fr_FR.json +++ b/app/appearance/langs/fr_FR.json @@ -1333,6 +1333,7 @@ "column": "Colonne", "copied": "Copié", "copy": "Copie", + "copyFile": "Copier le fichier", "copyText": "Copier le texte *", "delete-column": "Supprimer une Colonne", "delete-row": "Supprimer la ligne", diff --git a/app/appearance/langs/he_IL.json b/app/appearance/langs/he_IL.json index 8639a9f85..8cdebabbf 100644 --- a/app/appearance/langs/he_IL.json +++ b/app/appearance/langs/he_IL.json @@ -1333,6 +1333,7 @@ "column": "עמודה", "copied": "הועתק", "copy": "העתק", + "copyFile": "העתק קובץ", "copyText": "העתק טקסט *", "delete-column": "מחק עמודה", "delete-row": "מחק שורה", diff --git a/app/appearance/langs/it_IT.json b/app/appearance/langs/it_IT.json index d19dc3098..8dcde28f1 100644 --- a/app/appearance/langs/it_IT.json +++ b/app/appearance/langs/it_IT.json @@ -1333,6 +1333,7 @@ "column": "Colonna", "copied": "Copiato", "copy": "Copia", + "copyFile": "Copia file", "copyText": "Copia testo *", "delete-column": "Elimina colonna", "delete-row": "Elimina riga", diff --git a/app/appearance/langs/ja_JP.json b/app/appearance/langs/ja_JP.json index 9f79a9a68..6c860d7fb 100644 --- a/app/appearance/langs/ja_JP.json +++ b/app/appearance/langs/ja_JP.json @@ -1333,6 +1333,7 @@ "column": "列", "copied": "コピーしました", "copy": "コピー", + "copyFile": "ファイルをコピー", "copyText": "テキストをコピー *", "delete-column": "列を削除", "delete-row": "行を削除", diff --git a/app/appearance/langs/ko_KR.json b/app/appearance/langs/ko_KR.json index 53d3815cd..d14627044 100644 --- a/app/appearance/langs/ko_KR.json +++ b/app/appearance/langs/ko_KR.json @@ -1333,6 +1333,7 @@ "column": "열", "copied": "복사됨", "copy": "복사", + "copyFile": "파일 복사", "copyText": "텍스트 복사 *", "delete-column": "열 삭제", "delete-row": "행 삭제", diff --git a/app/appearance/langs/pl_PL.json b/app/appearance/langs/pl_PL.json index 6fd96f5dd..a8748832d 100644 --- a/app/appearance/langs/pl_PL.json +++ b/app/appearance/langs/pl_PL.json @@ -1333,6 +1333,7 @@ "column": "Kolumna", "copied": "Skopiowane", "copy": "Kopiuj", + "copyFile": "Kopiuj plik", "copyText": "Skopiuj tekst *", "delete-column": "Usuń kolumnę", "delete-row": "Usuń wiersz", diff --git a/app/appearance/langs/pt_BR.json b/app/appearance/langs/pt_BR.json index e471ed4d2..bb7884f1e 100644 --- a/app/appearance/langs/pt_BR.json +++ b/app/appearance/langs/pt_BR.json @@ -1333,6 +1333,7 @@ "column": "Coluna", "copied": "Copiado", "copy": "Copiar", + "copyFile": "Copiar arquivo", "copyText": "Copiar texto *", "delete-column": "Excluir Coluna", "delete-row": "Excluir Linha", diff --git a/app/appearance/langs/ru_RU.json b/app/appearance/langs/ru_RU.json index 9c859ff43..fa3067dd8 100644 --- a/app/appearance/langs/ru_RU.json +++ b/app/appearance/langs/ru_RU.json @@ -1333,6 +1333,7 @@ "column": "Столбец", "copied": "Скопировано", "copy": "Копировать", + "copyFile": "Копировать файл", "copyText": "Копировать текст *", "delete-column": "Удалить столбец", "delete-row": "Удалить строку", diff --git a/app/appearance/langs/tr_TR.json b/app/appearance/langs/tr_TR.json index 24c942ce5..6a689cc17 100644 --- a/app/appearance/langs/tr_TR.json +++ b/app/appearance/langs/tr_TR.json @@ -1333,6 +1333,7 @@ "column": "Sütun", "copied": "Kopyalandı", "copy": "Kopyala", + "copyFile": "Dosyayı kopyala", "copyText": "Metni kopyala *", "delete-column": "Sütunu sil", "delete-row": "Satırı sil", diff --git a/app/appearance/langs/zh_CHT.json b/app/appearance/langs/zh_CHT.json index 8e64f3276..4d9bf96fe 100644 --- a/app/appearance/langs/zh_CHT.json +++ b/app/appearance/langs/zh_CHT.json @@ -1333,6 +1333,7 @@ "column": "行", "copied": "已複製", "copy": "複製", + "copyFile": "複製檔案", "copyText": "複製 文本 *", "delete-column": "刪除行", "delete-row": "刪除列", diff --git a/app/appearance/langs/zh_CN.json b/app/appearance/langs/zh_CN.json index 163ed92d4..69252b378 100644 --- a/app/appearance/langs/zh_CN.json +++ b/app/appearance/langs/zh_CN.json @@ -1333,6 +1333,7 @@ "column": "列", "copied": "已复制", "copy": "复制", + "copyFile": "复制文件", "copyText": "复制 文本 *", "delete-column": "删除列", "delete-row": "删除行", diff --git a/app/src/menus/protyle.ts b/app/src/menus/protyle.ts index fcaa31dca..3e5528111 100644 --- a/app/src/menus/protyle.ts +++ b/app/src/menus/protyle.ts @@ -44,7 +44,7 @@ import {blockRender} from "../protyle/render/blockRender"; import {renameAsset} from "../editor/rename"; import {electronUndo} from "../protyle/undo"; import {pushBack} from "../mobile/util/MobileBackFoward"; -import {copyPNGByLink, exportAsset} from "./util"; +import {copyAsset, copyPNGByLink, exportAsset} from "./util"; import {removeInlineType} from "../protyle/toolbar/util"; import {alignImgCenter, alignImgLeft} from "../protyle/wysiwyg/commonHotkey"; import {checkFold, renameTag} from "../util/noRelyPCFunction"; @@ -1418,6 +1418,11 @@ export const imgMenu = (protyle: IProtyle, range: Range, assetElement: HTMLEleme const dataSrc = imgElement.getAttribute("data-src"); if (dataSrc && dataSrc.startsWith("assets/")) { window.siyuan.menus.menu.append(new MenuItem(exportAsset(dataSrc)).element); + /// #if !BROWSER + if (["windows", "darwin"].includes(window.siyuan.config.system.os)) { + window.siyuan.menus.menu.append(new MenuItem(copyAsset(dataSrc)).element); + } + /// #endif } if (protyle?.app?.plugins) { emitOpenMenu({ @@ -1691,6 +1696,11 @@ style="margin:4px 0;width: ${isMobile() ? "100%" : "360px"}" class="b3-text-fiel openMenu(protyle.app, linkAddress, false, true); if (linkAddress?.startsWith("assets/")) { window.siyuan.menus.menu.append(new MenuItem(exportAsset(linkAddress)).element); + /// #if !BROWSER + if (["windows", "darwin"].includes(window.siyuan.config.system.os)) { + window.siyuan.menus.menu.append(new MenuItem(copyAsset(linkAddress)).element); + } + /// #endif } } @@ -2114,6 +2124,11 @@ export const videoMenu = (protyle: IProtyle, nodeElement: Element, type: string) } if (src && src.startsWith("assets/")) { subMenus.push(exportAsset(src)); + /// #if !BROWSER + if (["windows", "darwin"].includes(window.siyuan.config.system.os)) { + subMenus.push(copyAsset(src)); + } + /// #endif } return subMenus; }; diff --git a/app/src/menus/util.ts b/app/src/menus/util.ts index 0a22b0fb2..0cb2d5687 100644 --- a/app/src/menus/util.ts +++ b/app/src/menus/util.ts @@ -39,6 +39,28 @@ export const exportAsset = (src: string) => { }; }; +// 复制资源文件到系统剪贴板,在文件资源管理器中可粘贴为文件(仅 Windows、macOS 桌面端支持) +export const copyAsset = (src: string) => { + return { + id: "copy", + label: window.siyuan.languages.copyFile, + icon: "iconCopy", + click: () => { + /// #if !BROWSER + fetchPost("/api/clipboard/writeFilePath", {path: src}, (response) => { + if (response.code === 0) { + showMessage(window.siyuan.languages.copied); + } else { + showMessage(response.msg || "", response.data?.closeTimeout ?? 5000, "error"); + } + }); + /// #else + showMessage("Copy as file is only supported in the Windows and macOS desktop app"); + /// #endif + } + }; +}; + export const openEditorTab = (app: App, ids: string[], notebookId?: string, pathString?: string, onlyGetMenus = false) => { /// #if !MOBILE const openSubmenus: IMenu[] = [{ diff --git a/kernel/api/clipboard.go b/kernel/api/clipboard.go index 6d5b8f8c7..db11661d2 100644 --- a/kernel/api/clipboard.go +++ b/kernel/api/clipboard.go @@ -17,17 +17,20 @@ package api import ( + "net/http" "os" "github.com/88250/clipboard" "github.com/88250/gulu" "github.com/gin-gonic/gin" "github.com/siyuan-note/logging" + "github.com/siyuan-note/siyuan/kernel/model" + "github.com/siyuan-note/siyuan/kernel/util" ) func readFilePaths(c *gin.Context) { ret := gulu.Ret.NewResult() - defer c.JSON(200, ret) + defer c.JSON(http.StatusOK, ret) var paths []string if !gulu.OS.IsLinux() { // Linux 端不再支持 `粘贴为纯文本` 时处理文件绝对路径 https://github.com/siyuan-note/siyuan/issues/5825 @@ -52,3 +55,37 @@ func readFilePaths(c *gin.Context) { } ret.Data = data } + +func writeFilePath(c *gin.Context) { + ret := gulu.Ret.NewResult() + defer c.JSON(http.StatusOK, ret) + + arg, ok := util.JsonArg(c, ret) + if !ok { + return + } + + pathArg, ok := arg["path"].(string) + if !ok || pathArg == "" { + ret.Code = -1 + ret.Msg = "path is required" + return + } + + absPath, err := model.GetAssetAbsPath(pathArg) + if err != nil { + logging.LogErrorf("get asset [%s] abs path failed: %s", pathArg, err) + ret.Code = -1 + ret.Msg = err.Error() + ret.Data = map[string]interface{}{"closeTimeout": 5000} + return + } + + if err = util.WriteFilePaths([]string{absPath}); err != nil { + logging.LogErrorf("write file path to clipboard failed: %s", err) + ret.Code = -1 + ret.Msg = err.Error() + ret.Data = map[string]interface{}{"closeTimeout": 5000} + return + } +} diff --git a/kernel/api/router.go b/kernel/api/router.go index e207a5a41..611bb7ed4 100644 --- a/kernel/api/router.go +++ b/kernel/api/router.go @@ -297,6 +297,7 @@ func ServeAPI(ginServer *gin.Engine) { ginServer.Handle("POST", "/api/extension/copy", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, extensionCopy) ginServer.Handle("POST", "/api/clipboard/readFilePaths", model.CheckAuth, model.CheckAdminRole, readFilePaths) + ginServer.Handle("POST", "/api/clipboard/writeFilePath", model.CheckAuth, model.CheckAdminRole, writeFilePath) ginServer.Handle("POST", "/api/asset/uploadCloud", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, uploadCloud) ginServer.Handle("POST", "/api/asset/uploadCloudByAssetsPaths", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, uploadCloudByAssetsPaths) diff --git a/kernel/go.mod b/kernel/go.mod index b9ac4b4b5..285eba1ae 100644 --- a/kernel/go.mod +++ b/kernel/go.mod @@ -38,6 +38,7 @@ require ( github.com/go-ole/go-ole v1.3.0 github.com/gofrs/flock v0.13.0 github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/gonutz/w32/v2 v2.12.1 github.com/google/uuid v1.6.0 github.com/gorilla/css v1.0.1 github.com/gorilla/websocket v1.5.3 diff --git a/kernel/go.sum b/kernel/go.sum index 4ea8278b4..9166c7757 100644 --- a/kernel/go.sum +++ b/kernel/go.sum @@ -208,6 +208,8 @@ github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9v github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/gonutz/w32/v2 v2.12.1 h1:ZTWg6ZlETDfWK1Qxx+rdWQdQWZwfhiXoyvxzFYdgsUY= +github.com/gonutz/w32/v2 v2.12.1/go.mod h1:MgtHx0AScDVNKyB+kjyPder4xIi3XAcHS6LDDU2DmdE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= diff --git a/kernel/util/clipboard.go b/kernel/util/clipboard.go new file mode 100644 index 000000000..6147d2328 --- /dev/null +++ b/kernel/util/clipboard.go @@ -0,0 +1,26 @@ +// SiYuan - Refactor your thinking +// Copyright (c) 2020-present, b3log.org +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//go:build !windows && !darwin + +package util + +import "errors" + +// 当前仅在 Windows、macOS 上实现,其他平台返回错误 +func WriteFilePaths(paths []string) error { + return errors.New("writing file paths to clipboard is not supported on this platform") +} diff --git a/kernel/util/clipboard_darwin.go b/kernel/util/clipboard_darwin.go new file mode 100644 index 000000000..38db018a9 --- /dev/null +++ b/kernel/util/clipboard_darwin.go @@ -0,0 +1,104 @@ +// SiYuan - Refactor your thinking +// Copyright (c) 2020-present, b3log.org +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//go:build darwin + +// 本文件实现 macOS NSPasteboard 写入文件路径列表:通过 writeObjects: 写入 NSURL 数组 +//(NSPasteboardTypeFileURL / public.file-url),使 Finder 等应用可识别并粘贴为文件。 +// +// 逻辑依据 Apple 官方「复制到剪贴板」三步: +// 1) 获取 general pasteboard;2) clearContents 清空;3) writeObjects: 写入符合 NSPasteboardWriting 的对象。 +// NSURL 为系统内置支持类型,写入 file URL 后系统会自动提供 public.file-url、 +// NSFilenamesPboardType、public.utf8-plain-text 等表示,兼容 Finder 与旧版 API。 +// +// 官方文档与参考: +// - Pasteboard Programming Guide (macOS) +// https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/PasteboardGuide106/Introduction/Introduction.html +// - Copying to a Pasteboard(三步流程与 writeObjects:) +// https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/PasteboardGuide106/Articles/pbCopying.html +// - NSPasteboard +// https://developer.apple.com/documentation/appkit/nspasteboard +// - NSPasteboardWriting(NSURL、NSString 等已实现) +// https://developer.apple.com/documentation/appkit/nspasteboardwriting +// +// 下文 /* ... */ 内为 CGO 内联的 Objective-C 代码,由 cgo 提取并编译,并非被注释掉的代码。 + +package util + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework AppKit -framework Foundation +#import +#import + +// writeFilePathsToPasteboard 将路径列表写入通用剪贴板,遵循 Copying to a Pasteboard 三步: +// 1) generalPasteboard;2) clearContents;3) writeObjects: 传入 NSURL 数组。 +// NSURL 符合 NSPasteboardWriting,写入后系统自动提供 public.file-url、NSFilenamesPboardType 等。 +// paths 为 UTF-8 路径字符串数组,count 为数量。 +static int writeFilePathsToPasteboard(const char** paths, int count) { + if (count <= 0) return 0; + NSMutableArray *arr = [NSMutableArray arrayWithCapacity:(NSUInteger)count]; + for (int i = 0; i < count; i++) { + NSString *path = [NSString stringWithUTF8String:paths[i]]; + if (!path) continue; + NSURL *url = [NSURL fileURLWithPath:path]; + if (url) [arr addObject:url]; + } + // 若无一有效路径(如全为非法 UTF-8 或无法转为 NSURL),返回 -2 以便 Go 侧报错 + if ([arr count] == 0) return -2; + // 步骤 1:获取通用剪贴板(cut/copy/paste 用) + NSPasteboard *pb = [NSPasteboard generalPasteboard]; + // 步骤 2:清空已有内容,再只写入本次文件路径 + [pb clearContents]; + // 步骤 3:writeObjects: 要求对象符合 NSPasteboardWriting,NSURL 已支持 + BOOL ok = [pb writeObjects:arr]; + return ok ? 0 : -1; +} +*/ +import "C" + +import ( + "errors" + "unsafe" +) + +// WriteFilePaths 将文件路径列表写入系统剪贴板(general pasteboard), +// 使 Finder 等可粘贴为文件。实现见 Pasteboard Guide — Copying to a Pasteboard。 +func WriteFilePaths(paths []string) error { + if len(paths) == 0 { + return nil + } + // 分配 C 的 char* 数组,便于传入 Objective-C + cPaths := make([]*C.char, len(paths)) + for i, p := range paths { + cPaths[i] = C.CString(p) + } + defer func() { + for _, c := range cPaths { + C.free(unsafe.Pointer(c)) + } + }() + // 取首元素地址作为 const char** 传入 + ret := C.writeFilePathsToPasteboard((**C.char)(unsafe.Pointer(&cPaths[0])), C.int(len(paths))) + switch ret { + case 0: + return nil + case -2: + return errors.New("no valid file paths to write (invalid UTF-8 or path)") + default: + return errors.New("failed to write file paths to pasteboard") + } +} diff --git a/kernel/util/clipboard_windows.go b/kernel/util/clipboard_windows.go new file mode 100644 index 000000000..ce75ea58c --- /dev/null +++ b/kernel/util/clipboard_windows.go @@ -0,0 +1,162 @@ +// SiYuan - Refactor your thinking +// Copyright (c) 2020-present, b3log.org +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//go:build windows + +// 本文件实现 Windows Shell 剪贴板格式 CF_HDROP,用于在剪贴板中传输一组已有文件的路径,使资源管理器等可识别并粘贴为文件。 +// +// 参考文档: +// - Shell 剪贴板与 CF_HDROP:https://learn.microsoft.com/en-us/windows/win32/shell/clipboard +// - DROPFILES 结构:https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/ns-shlobj_core-dropfiles +// - SetClipboardData(hMem 须为 GMEM_MOVEABLE,且 “memory must be unlocked before the Clipboard is closed”): +// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setclipboarddata +// - 官方示例 “Copy information to the clipboard”(GlobalUnlock 再 SetClipboardData): +// https://learn.microsoft.com/en-us/windows/win32/dataxchg/using-the-clipboard +// +// CF_HDROP 为预定义格式,无需 RegisterClipboardFormat。数据为全局内存对象(hGlobal), +// 其内容为 DROPFILES 结构 + 双 null 结尾的路径字符数组。 + +package util + +import ( + "encoding/binary" + "errors" + "runtime" + "syscall" + "time" + "unsafe" + + "github.com/gonutz/w32/v2" +) + +const ( + // cfHDROP 为 CF_HDROP 剪贴板格式(预定义值 15),用于传输一组已有文件的位置。 + // 见 Standard Clipboard Formats:https://learn.microsoft.com/en-us/windows/win32/dataxchg/standard-clipboard-formats + cfHDROP = 15 + dropfilesSize = 20 // DROPFILES 结构体大小(pFiles 4 + pt 8 + fNC 4 + fWide 4),https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/ns-shlobj_core-dropfiles +) + +// WriteFilePaths 将文件路径列表写入系统剪贴板,使资源管理器中可粘贴为文件。 +// +// 按文档要求,CF_HDROP 数据为 STGMEDIUM 的 hGlobal 指向的全局内存,内存内容为 DROPFILES 结构。 +// 剪贴板 API 要求在同一线程内完成 OpenClipboard、写入、CloseClipboard,故需 LockOSThread。 +// 调用顺序:先准备数据(GlobalAlloc → GlobalLock → 写入 → GlobalUnlock),再 OpenClipboard → EmptyClipboard → SetClipboardData → CloseClipboard。 +// 与官方示例 “Copy information to the clipboard” 不同,此处将内存准备提前到 OpenClipboard 之前,以缩短占用剪贴板的时间。 +func WriteFilePaths(paths []string) error { + if len(paths) == 0 { + return nil + } + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + data, err := buildDropfilesData(paths) + if err != nil { + return err + } + if len(data) == 0 { + return nil + } + + // 全局内存对象;SetClipboardData 文档要求 hMem 须由 GlobalAlloc(GMEM_MOVEABLE) 分配 + // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setclipboarddata + size := uint32(len(data)) + hMem := w32.GlobalAlloc(w32.GMEM_MOVEABLE, size) + if hMem == 0 { + return syscall.Errno(w32.GetLastError()) + } + + ptr := w32.GlobalLock(hMem) + if ptr == nil { + w32.GlobalFree(hMem) + return syscall.Errno(w32.GetLastError()) + } + + w32.MoveMemory(ptr, unsafe.Pointer(&data[0]), size) + // 必须在 SetClipboardData 之前 Unlock,否则系统无法正确管理已接管的句柄。 + // 文档:"The memory must be unlocked before the Clipboard is closed." + // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setclipboarddata + w32.GlobalUnlock(hMem) + + if err := waitOpenClipboard(); err != nil { + w32.GlobalFree(hMem) + return err + } + defer w32.CloseClipboard() + + if !w32.EmptyClipboard() { + w32.GlobalFree(hMem) + return syscall.Errno(w32.GetLastError()) + } + if w32.SetClipboardData(cfHDROP, w32.HANDLE(hMem)) == 0 { + w32.GlobalFree(hMem) + return syscall.Errno(w32.GetLastError()) + } + // 成功时系统接管 hMem,应用不得再写或 free;失败时由上面分支 GlobalFree。 + // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setclipboarddata + return nil +} + +// buildDropfilesData 构建 CF_HDROP 格式的字节切片。 +// +// 格式遵循 DROPFILES:pFiles 为偏移,指向双 null 结尾的路径字符数组。 +// https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/ns-shlobj_core-dropfiles +// 数组由若干条“完整路径 + 结尾 NULL”组成,最后再跟一个 NULL 结束整表。 +// 例如两文件时为:c:\temp1.txt\0 c:\temp2.txt\0 \0 +// 此处使用 Unicode(fWide=1),故路径为 UTF-16,每条路径含结尾 null,最后再 2 字节 null。 +func buildDropfilesData(paths []string) ([]byte, error) { + var totalLen = dropfilesSize + for _, p := range paths { + u16, err := syscall.UTF16FromString(p) + if err != nil { + return nil, err + } + totalLen += len(u16) * 2 + } + totalLen += 2 // 数组末尾的 null(双 null 结尾中的最后一个) + + buf := make([]byte, totalLen) + // DROPFILES:pFiles=20(路径数组相对本结构起始的偏移), pt=0,0, fNC=0, fWide=1(Unicode) + binary.LittleEndian.PutUint32(buf[0:4], 20) + // pt.x, pt.y, fNC, fWide + binary.LittleEndian.PutUint32(buf[16:20], 1) + + offset := dropfilesSize + for _, p := range paths { + u16, err := syscall.UTF16FromString(p) + if err != nil { + return nil, err + } + for _, c := range u16 { + binary.LittleEndian.PutUint16(buf[offset:offset+2], c) + offset += 2 + } + } + return buf, nil +} + +// waitOpenClipboard 在限定时间内重试打开剪贴板。 +// 同一时刻仅一进程可持有剪贴板(OpenClipboard 成功)。 +// https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-openclipboard +func waitOpenClipboard() error { + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if w32.OpenClipboard(0) { + return nil + } + time.Sleep(time.Millisecond) + } + return errors.New("open clipboard timeout") +}