🎨 Support copying file in the asset menu on Windows and macOS (#17049)

This commit is contained in:
Jeffrey Chen 2026-02-16 11:26:13 +08:00 committed by GitHub
parent d484ddc079
commit ca41244188
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 387 additions and 2 deletions

26
kernel/util/clipboard.go Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
//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")
}

View file

@ -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 <https://www.gnu.org/licenses/>.
//go:build darwin
// 本文件实现 macOS NSPasteboard 写入文件路径列表:通过 writeObjects: 写入 NSURL 数组
//NSPasteboardTypeFileURL / public.file-url使 Finder 等应用可识别并粘贴为文件。
//
// 逻辑依据 Apple 官方「复制到剪贴板」三步:
// 1) 获取 general pasteboard2) 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
// - NSPasteboardWritingNSURL、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 <AppKit/AppKit.h>
#import <Foundation/Foundation.h>
// writeFilePathsToPasteboard 将路径列表写入通用剪贴板,遵循 Copying to a Pasteboard 三步:
// 1) generalPasteboard2) clearContents3) 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];
// 步骤 3writeObjects: 要求对象符合 NSPasteboardWritingNSURL 已支持
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")
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
//go:build windows
// 本文件实现 Windows Shell 剪贴板格式 CF_HDROP用于在剪贴板中传输一组已有文件的路径使资源管理器等可识别并粘贴为文件。
//
// 参考文档:
// - Shell 剪贴板与 CF_HDROPhttps://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
// - SetClipboardDatahMem 须为 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 Formatshttps://learn.microsoft.com/en-us/windows/win32/dataxchg/standard-clipboard-formats
cfHDROP = 15
dropfilesSize = 20 // DROPFILES 结构体大小pFiles 4 + pt 8 + fNC 4 + fWide 4https://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 格式的字节切片。
//
// 格式遵循 DROPFILESpFiles 为偏移,指向双 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
// 此处使用 UnicodefWide=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)
// DROPFILESpFiles=20路径数组相对本结构起始的偏移, pt=0,0, fNC=0, fWide=1Unicode
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")
}