Windows 宿主机突然无法将部分文件复制到 Ubuntu 虚拟机,但文字复制正常,虚拟机向宿主机复制文件也正常。更容易误判的是:退出 Diodon 剪贴板管理器后,一度恢复;随后又发现,部分压缩包分片仍然无法粘贴,小型 JSON 文件却可以。
最后定位到的问题,是 open-vm-tools 的 X11 文件复制实现复用了不同剪贴板格式的数据。Diodon 会影响格式的请求顺序,因此可能触发或放大故障,但并不是唯一原因。
本文记录排查证据、源码逻辑及本机兼容修复。环境版本按实际配置列出;业务文件名、账号、时间和现场数据已匿名化。通用协议名称和系统路径保留原样,尖括号字段表示需要替换的示例值。
1. 故障环境与现象
| 项目 | 环境 |
|---|---|
| 宿主机 | Windows11 Pro,版本为 26H2 |
| 虚拟化软件 | VMware Workstation Pro,版本为 26H1u1 |
| 虚拟机 | Ubuntu,版本为 24.04 |
| 图形会话 | GNOME / X11 |
| 虚拟机工具 | open-vm-tools 与 open-vm-tools-desktop,版本为 13.0.10-0ubuntu0.24.04.1 |
| 剪贴板历史工具 | Diodon,版本为 1.13.0-1build2 |
故障表现并不是“剪贴板完全失效”:
- Windows → Ubuntu:文字可以复制,部分文件无法粘贴。
- Ubuntu → Windows:用户反馈文件复制正常。
- 同一归档的一些分片失败,较小的尾部分片可以成功。
- 小型 JSON 文件可以复制。
- 关闭 Diodon 后曾短暂恢复,但无法消除所有失败情况。
这里的 .aac 指归档分片文件名的尾缀,并非音频格式。排查过程中没有发现按扩展名拦截文件的证据。
2. 先确认基础条件,而不是直接重装 Tools
首先检查虚拟机内部的几个基础条件:
| 检查项 | 现场结果 | 对排查的意义 |
|---|---|---|
| 图形会话 | X11 | 不符合常见的 Wayland 不兼容场景 |
| Tools 软件包 | 核心包与桌面包均已安装 | 不是缺少桌面集成组件 |
| 系统代理与桌面代理 | 均在运行 | 不是代理进程完全退出 |
| vmblock FUSE | 已挂载 | 文件传输所需挂载存在 |
| 可用磁盘空间 | 充足 | 暂时不支持磁盘写满的判断 |
这些结果只能排除部分常见原因,不能证明文件复制的每个环节都正常。
随后,文件管理器日志给出了更有区分度的错误:
Failed to read clipboard: Nautilus Clipboard must begin with “cut” or “copy”.
Diodon 同时报告:
Adding file(s) to history failed: No such file or directory x-special/nautilus-clipboard
这两条日志把调查方向从“Tools 是否安装”推进到了“文件剪贴板的数据格式是否正确”。
3. 退出 Diodon 后恢复,并不足以证明它是唯一根因
第一次退出 Diodon 后,文件复制恢复,容易得出“Diodon 与 VMware 冲突”的结论。
但进一步测试推翻了这个结论的完整性:Diodon 已退出,某些分片仍然失败,文件管理器仍然报相同的格式错误。
更准确的解释是:Diodon 参与了触发条件,但底层还有格式兼容问题。停止一个参与者后现象改变,只能说明它影响了故障路径,不能立即将根因全部归到它身上。
4. 文件已经进入虚拟机,为什么仍然粘贴失败?
检查 VMware 的接收缓存后,发现失败分片已经存在于虚拟机内部,其缓存大小与该分片的预期大小一致。
这意味着至少部分失败发生在下面流程的后半段:
Windows 复制文件
↓
VMware 接收并暂存文件
↓
向 Linux 剪贴板提供文件清单
↓
Ubuntu 文件管理器解析清单
↓
复制到用户选择的目标文件夹
“文件已在接收缓存中”与“已成功粘贴到目标目录”是两件事。文件数据可以已经到达,而文件管理器因为解析不了清单,无法执行最后一步。
同样,缓存文件大小一致并不等同于与 Windows 原文件内容完全一致。要证明跨系统内容完整性,仍需比较两端的校验值。
5. 决定性证据:不同格式请求收到了同一份旧格式资料
Linux 剪贴板可以同时提供多种数据格式。复制文件时,几个重要格式分别承担不同用途:
| 格式 | 应提供的内容 |
|---|---|
x-special/gnome-copied-files | copy 或 cut,随后是文件 URI 列表 |
text/uri-list | 文件 URI 列表 |
UTF8_STRING | 文字表示;兼容层中使用本地文件路径 |
GNOME 文件格式的示意内容如下,实际 URI 中需要正确编码特殊字符:
copy
file:///tmp/VMwareDnD/<TRANSFER_ID>/<FILE_NAME>
但失败时,监听抓到的文件格式内容是:
x-special/nautilus-clipboard
copy
file:///tmp/VMwareDnD/<TRANSFER_ID>/<FILE_NAME>
问题不是多了一段普通说明文字,而是多出来的第一行属于旧 Nautilus 文字表示。它不能直接充当 x-special/gnome-copied-files 的数据开头。
现场还抓到了更关键的对照:失败分片的 GNOME 文件格式、URI 列表和 UTF-8 文字请求,都返回了同一份旧格式资料;成功 JSON 文件的这些格式则分别符合预期。
因此,文件管理器的报错与实际数据完全对应:它期待第一行是 copy 或 cut,实际读到的却是 x-special/nautilus-clipboard。
6. 源码为什么会产生这种结果?
检查与安装版本对应的 open-vm-tools 官方源码,关键逻辑位于 copyPasteUIX11.cpp。
文件列表传输路径:缓存与具体格式绑定
LocalGetFileRequestCB 会根据首次请求的格式,构造文件清单,并保存到 mHGCopiedUriList。
在文件传输已经启动后,如果又收到请求,该函数会直接返回缓存的 mHGCopiedUriList,没有根据当前请求的格式重新构造内容。
可以把这个问题理解成:
- 某个组件先请求旧 Nautilus 的文字表示。
- VMware 生成带旧格式头的清单,并缓存起来。
- 文件管理器随后请求 GNOME 文件格式。
- VMware 仍然返回前面缓存的旧格式内容。
- 文件管理器因第一行不符合协议而拒绝粘贴。
如果第一次请求的格式不同,后续错误的表现也可能不同。缺陷的本质是“把某一种格式的缓存,当成所有格式都能使用的数据”。
文件内容传输路径:每次按请求格式生成
另一个函数 LocalGetFileContentsRequestCB 会针对每次请求,分别生成对应格式的数据,没有复用上述同一份格式化清单。
源码中的两条处理路径,加上现场大小不同的文件表现差异,支持“小文件与较大文件进入不同处理路径”的解释。但这次排查没有测定宿主机选择路径的确切阈值,也不能据此推出一个适用于所有版本的文件大小限制。
Diodon 在其中起什么作用?
Diodon 为保存历史,会读取剪贴板的文字表示和可用格式。这会影响请求顺序,使前述缓存缺陷更容易显现。
关闭 Diodon 后仍然失败,也符合这个机制:其他桌面组件同样可能请求不同格式,不必依赖 Diodon 才能触发。
7. 修复方案:增加用户级文件剪贴板兼容层
本次没有修改系统安装的 VMware Tools,也没有通过禁用 Diodon 来规避日常需求,而是在用户会话中增加一个兼容服务。
它处理的是格式转换,流程如下:
- 识别来源:只接受 VMware 暂存路径内、本地
file:URI 组成的文件清单。 - 解析旧格式:移除不属于 GNOME 文件协议的旧文字格式头,提取复制指令和文件地址。
- 等待文件可用:在工作线程访问 vmblock 路径,配合阻塞文件系统等待传输,再解析为可访问的本地缓存路径。
- 分别生成格式:为 GNOME、URI 列表及 Diodon 的文字读取生成各自正确的数据。
- 避免覆盖新内容:发布前重新检查剪贴板是否仍是刚才那一份文件资料;用户若已复制其他内容,就放弃这次处理。
实现上结合剪贴板所有权变化事件与定时检查,减少同一 VMware 进程更新内容时漏检的机会;对相同清单去重,避免反复处理。
其中,GNOME 文件格式以 copy 或 cut 开头,最后不额外附加换行;URI 列表只包含编码后的 URI;文字表示使用本地路径,避免 Diodon 把旧格式标记误当文件路径。
服务不接管普通文字、图片,也不接管不属于 VMware 暂存路径的文件清单。等待文件可用是对既有传输机制的配合,不是额外做了一次文件内容校验。
8. 第一次兼容修复为什么没有生效?
兼容层初版识别了一个标准运行目录下的 VMware 路径:
/run/vmblock-fuse/blockdir/...
现场实际返回的路径却使用另一个目录入口:
/var/run/vmblock-fuse/blockdir/...
在这台 Ubuntu 中,两者指向同一处,但字符串前缀不同。由于兼容层按路径前缀限制处理范围,初版把真实文件清单跳过了。
这造成了一个典型现象:服务状态显示运行正常,却没有完成任何文件格式转换。
补齐路径别名识别及对应的本地路径映射后,再次运行测试,用户确认文件复制恢复。
这个过程也提醒我们:检查服务“正在运行”只是第一步,还要有“已识别文件清单”“已发布兼容格式”等业务层面的处理记录。
9. Diodon 最终保留了哪些功能?
Diodon 的运行、历史记录、快捷键与原有自动启动均保留。
另一个配套调整,是关闭 keep-clipboard-content。该设置控制剪贴板暂时变空时,是否自动回填上一条内容;它与保存历史记录不是同一件事。
关闭自动回填,可以减少 VMware 切换剪贴板所有权时被旧内容干扰的机会。它本身不足以修复本次格式缓存缺陷,最终恢复依赖兼容层正确处理文件格式。
10. 如何验证修复?
验证分为三层:
| 层次 | 验证内容 | 本次结果 |
|---|---|---|
| 格式与路径测试 | 旧格式头、多文件、URI 输入、中文与空格文件名、路径别名、非目标内容排除 | 通过 |
| GTK 实际数据交换 | 使用独立 X11 selection 模拟错误内容,核对修复后的多种格式 | 通过 |
| 宿主机实际操作 | 重新复制此前失败的归档分片到虚拟机 | 用户确认恢复 |
独立 selection 的意义是:在真实 GTK 数据交换机制上测试,同时不拿用户当前的系统剪贴板做实验。
这次确认了原先失败文件的复制恢复,不能据此宣称所有文件大小、桌面环境或 VMware 版本都已覆盖。
11. 部署、日志与回滚
兼容程序作为 systemd 用户服务运行,并随图形会话启动。它不需要修改系统 VMware 安装包;日志采用新加坡时区,按日轮转。
运维时应区分几种状态:
- 服务运行中,但没有识别记录:检查桌面会话、实际路径及来源识别规则。
- 已识别文件,但没有发布记录:检查等待文件可用的步骤、异常日志,以及用户是否已复制新内容。
- 已发布正确格式,但仍无法粘贴:继续检查文件管理器、目标目录权限与文件可访问性。
本次兼容服务的管理命令如下:
systemctl --user status vmware-clipboard-compat.service
journalctl --user -u vmware-clipboard-compat.service
systemctl --user disable --now vmware-clipboard-compat.service
最后一条命令用于停用兼容服务。如果要完整撤销本次配置调整,还应将 Diodon 的自动回填设置恢复到变更前记录的值。
12. 适用范围与后续改进
这是针对当前环境的本机兼容补丁,并未修复上游源代码中的缺陷。本文验证的是 X11 场景,不代表解决了 Wayland 的剪贴板兼容问题。
从上游实现角度,更直接的修复方向是将“原始文件清单”与“某种格式的序列化结果”分开:保留原始 URI 数据,在每次回调时根据当前请求生成对应格式,而不是把第一次生成的结果返回给所有请求。
如果未来升级 open-vm-tools 或 VMware Workstation,应重新检查相关实现,并在停用本机兼容层后进行针对性复测,再决定是否移除它。
13. 完整修复代码与部署
主程序为 191 行。下面是本次最终启用、经文件复制实测确认有效的完整代码,没有省略函数。它是独立的用户级兼容层,没有修改 open-vm-tools 的系统二进制文件。
13.1 依赖
程序使用系统 Python 的 PyGObject、GTK 3,以及 Python 标准库。uv 用于调用系统 Python,不需要通过 pip 安装 PyGObject。
在 Ubuntu 上,如尚未安装相关组件,可先安装:
sudo apt install python3-gi gir1.2-gtk-3.0
下文假设 uv 已安装在 ~/.local/bin/uv;如果位置不同,需要相应调整用户服务的 ExecStart。
13.2 主程序 compat.py
将以下完整内容保存为当前目录的 compat.py:
"""Keep VMware file clipboard formats consistent for GNOME and Diodon."""
import ctypes
import ctypes.util
import logging
from logging.handlers import TimedRotatingFileHandler
import os
from pathlib import Path
import threading
import time
from urllib.parse import unquote, urlparse
def parse_files(payload, home):
"""Accept only file lists staged by VMware, never ordinary text."""
lines = payload.decode('utf-8').rstrip('\x00\r\n').splitlines()
if lines and lines[0] == 'x-special/nautilus-clipboard':
lines.pop(0)
operation = lines.pop(0) if lines and lines[0] in ('copy', 'cut') else 'copy'
if not lines:
return None
roots = ('/run/vmblock-fuse/blockdir/', '/var/run/vmblock-fuse/blockdir/',
'/tmp/VMwareDnD/',
str(Path(home) / '.cache/vmware/drag_and_drop') + '/')
for uri in lines:
parsed = urlparse(uri)
if parsed.scheme != 'file' or parsed.netloc not in ('', 'localhost'):
return None
if not unquote(parsed.path).startswith(roots):
return None
return operation, lines
def stable_paths(uris):
"""Wait for vmblock transfers before publishing ordinary local paths."""
paths = []
for uri in uris:
path = unquote(urlparse(uri).path)
os.stat(path)
for prefix in ('/run/vmblock-fuse/blockdir/', '/var/run/vmblock-fuse/blockdir/'):
if path.startswith(prefix):
path = '/tmp/VMwareDnD/' + path[len(prefix):]
break
path = os.path.realpath(path)
os.stat(path)
paths.append(path)
return paths
def formats(operation, paths):
uris = [Path(path).as_uri() for path in paths]
return {
'x-special/gnome-copied-files': (operation + '\n' + '\n'.join(uris)).encode(),
'text/uri-list': ('\r\n'.join(uris) + '\r\n').encode(),
'UTF8_STRING': '\n'.join(paths).encode(),
'text/plain;charset=utf-8': '\n'.join(paths).encode(),
'text/plain': '\n'.join(paths).encode(),
}
def main():
os.environ['TZ'] = 'Asia/Singapore'
time.tzset()
log_dir = Path(os.environ.get('XDG_STATE_HOME', str(Path.home() / '.local/state'))) / 'vmware-clipboard-compat'
log_dir.mkdir(parents=True, exist_ok=True)
logger = logging.getLogger('vmware-clipboard-compat')
logger.setLevel(logging.INFO)
handler = TimedRotatingFileHandler(log_dir / 'compat.log', when='midnight', backupCount=7)
handler.setFormatter(logging.Formatter('%(asctime)s UTC+8 %(levelname)s %(message)s'))
logger.addHandler(handler)
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, Gdk, GLib
gtk = ctypes.CDLL(ctypes.util.find_library('gtk-3'))
gdk = ctypes.CDLL(ctypes.util.find_library('gdk-3'))
gdk.gdk_atom_intern.argtypes = [ctypes.c_char_p, ctypes.c_int]
gdk.gdk_atom_intern.restype = ctypes.c_void_p
gtk.gtk_clipboard_get.argtypes = [ctypes.c_void_p]
gtk.gtk_clipboard_get.restype = ctypes.c_void_p
selection_name = os.environ.get('VMWARE_CLIPBOARD_SELECTION', 'CLIPBOARD')
clipboard_pointer = gtk.gtk_clipboard_get(gdk.gdk_atom_intern(selection_name.encode(), False))
gtk.gtk_selection_data_set.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p, ctypes.c_int]
gtk.gtk_selection_data_set_text.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int]
gtk.gtk_selection_data_set_text.restype = ctypes.c_int
class Entry(ctypes.Structure):
_fields_ = [('target', ctypes.c_char_p), ('flags', ctypes.c_uint), ('info', ctypes.c_uint)]
get_callback_type = ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint, ctypes.c_void_p)
clear_callback_type = ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.c_void_p)
gtk.gtk_clipboard_set_with_data.argtypes = [ctypes.c_void_p, ctypes.POINTER(Entry), ctypes.c_uint, get_callback_type, clear_callback_type, ctypes.c_void_p]
gtk.gtk_clipboard_set_with_data.restype = ctypes.c_int
clipboard = Gtk.Clipboard.get(Gdk.Atom.intern(selection_name, False))
state = {'generation': 0, 'owns': False, 'reading': False, 'preparing': False, 'last_payload': None, 'payloads': {}, 'names': []}
@get_callback_type
def provide(_clipboard, selection, info, _data):
try:
name = state['names'][info]
payload = state['payloads'][name]
if name.startswith('text/plain') or name == 'UTF8_STRING':
gtk.gtk_selection_data_set_text(selection, payload, len(payload))
else:
atom = gdk.gdk_atom_intern(name.encode(), False)
gtk.gtk_selection_data_set(selection, atom, 8, payload, len(payload))
except Exception:
logger.exception('Could not provide file clipboard format')
@clear_callback_type
def cleared(*_args):
state['owns'] = False
def publish(generation, operation, paths, original):
state['preparing'] = False
current = clipboard.wait_for_contents(Gdk.Atom.intern('x-special/gnome-copied-files', False))
if current is None or current.get_data() != original:
state['last_payload'] = None
return False
if generation != state['generation']:
return False
state['payloads'] = formats(operation, paths)
state['names'] = list(state['payloads'])
entries = (Entry * len(state['names']))(*[Entry(name.encode(), 0, i) for i, name in enumerate(state['names'])])
state['owns'] = True
if not gtk.gtk_clipboard_set_with_data(clipboard_pointer, entries, len(entries), provide, cleared, None):
state['owns'] = False
logger.error('Could not publish normalized file clipboard')
else:
logger.info('Published compatible file clipboard: %d item(s)', len(paths))
return False
def received(_clipboard, selection, generation):
state['reading'] = False
if generation != state['generation'] or state['owns']:
GLib.idle_add(inspect)
return
payload = selection.get_data()
if not payload:
state['last_payload'] = None
return
try:
parsed = parse_files(payload, Path.home())
except (UnicodeError, ValueError):
return
if not parsed:
return
if payload == state['last_payload']:
return
state['last_payload'] = payload
state['preparing'] = True
operation, uris = parsed
logger.info('Preparing VMware file clipboard: %d item(s)', len(uris))
def prepared_error():
state['preparing'] = False
return False
def prepare():
try:
paths = stable_paths(uris)
GLib.idle_add(publish, generation, operation, paths, payload)
except OSError:
logger.exception('Could not prepare received VMware files')
GLib.idle_add(prepared_error)
threading.Thread(target=prepare, daemon=True).start()
def inspect():
if state['owns'] or state['reading'] or state['preparing']:
return False
state['reading'] = True
clipboard.request_contents(Gdk.Atom.intern('x-special/gnome-copied-files', False), received, state['generation'])
return False
def changed(*_args):
state['generation'] += 1
GLib.timeout_add(40, inspect)
clipboard.connect('owner-change', changed)
GLib.timeout_add(100, inspect)
def poll():
inspect()
return True
GLib.timeout_add(350, poll)
logger.info('Started VMware file clipboard compatibility helper')
Gtk.main()
if __name__ == '__main__':
main()
代码里有几个与故障直接相关的设计:
parse_files同时识别/run与/var/run两种 VMware 路径入口,拒绝非本地 URI 和非 VMware 暂存路径。stable_paths访问 vmblock 路径,再映射到本地缓存位置;该过程在工作线程执行,避免在主事件循环中等待文件传输。formats为不同请求分别生成数据,避免重现上游“多个格式共用一份序列化缓存”的问题。publish在接管前核对原始剪贴板是否仍未变化,防止等待期间用户复制了新内容却被旧文件清单覆盖。changed与poll分别处理事件通知和定时补查;对正在读取、正在准备或已由本服务提供的资料做去重处理。
13.3 安装程序和用户服务
在保存了 compat.py 的目录中执行:
mkdir -p "$HOME/.local/share/vmware-clipboard-compat"
mkdir -p "$HOME/.config/systemd/user"
install -m 644 compat.py "$HOME/.local/share/vmware-clipboard-compat/compat.py"
将下面内容保存为 ~/.config/systemd/user/vmware-clipboard-compat.service。%h 是 systemd 的用户主目录标记,不需要手动写入账号名。
[Unit]
Description=VMware file clipboard compatibility for GNOME and Diodon
PartOf=graphical-session.target
After=graphical-session.target
[Service]
Type=simple
ExecStart=%h/.local/bin/uv run --no-project --python /usr/bin/python3 python3 %h/.local/share/vmware-clipboard-compat/compat.py
Restart=on-failure
RestartSec=3
[Install]
WantedBy=graphical-session.target
此服务需要当前图形会话的 X11 环境。启用前可先检查 systemd 用户管理器是否获得了对应环境:
systemctl --user show-environment
如果其中缺少 DISPLAY 或当前会话所需的 XAUTHORITY,可在已登录的桌面终端中导入:
systemctl --user import-environment DISPLAY XAUTHORITY
启动服务:
systemctl --user daemon-reload
systemctl --user enable --now vmware-clipboard-compat.service
13.4 Diodon 配套设置
先记录原值,便于完整回滚:
gsettings get net.launchpad.Diodon.clipboard keep-clipboard-content
再关闭自动回填旧内容:
gsettings set net.launchpad.Diodon.clipboard keep-clipboard-content false
保留 Diodon 正常运行和自动启动。这个设置不会关闭历史记录功能;它只是避免剪贴板短暂为空时被上一条内容回填。
13.5 检查处理记录与实际效果
检查服务状态和程序日志:
systemctl --user status vmware-clipboard-compat.service
journalctl --user -u vmware-clipboard-compat.service
tail -f "${XDG_STATE_HOME:-$HOME/.local/state}/vmware-clipboard-compat/compat.log"
程序日志按日轮转,使用新加坡时区。正常接手文件后,应能看到类似记录:
<TIMESTAMP> UTC+8 INFO Preparing VMware file clipboard: <COUNT> item(s)
<TIMESTAMP> UTC+8 INFO Published compatible file clipboard: <COUNT> item(s)
从 Windows 重新复制此前失败的文件,切换到 Ubuntu 后稍等片刻再粘贴,同时核对上述处理日志。仅看到服务为 active,不足以证明某次文件清单已被转换。
由于这是会话内的异步兼容层,检测、接收与重新发布需要时间。如果在转换完成前立刻粘贴,仍可能先遇到原来的格式错误;应以发布日志和后续实测确认处理结果。
随后再检查 Diodon 是否能继续记录和粘贴文字历史。需要验证文件内容完整性时,应另外比较 Windows 原文件与 Ubuntu 目标文件的校验值。
13.6 停用与完整撤销
停用自动启动并停止服务:
systemctl --user disable --now vmware-clipboard-compat.service
如果决定移除程序,可再删除本节安装的服务文件和程序文件,然后重新加载用户服务配置;Diodon 的自动回填设置则恢复为变更前记录的值。无需重新安装 VMware Tools。
参考实现位置
- VMware open-vm-tools 官方项目:
copyPasteUIX11.cpp。 - 文件列表传输回调:
CopyPasteUIX11::LocalGetFileRequestCB。 - 文件内容传输回调:
CopyPasteUIX11::LocalGetFileContentsRequestCB。 - 相关缓存字段:
mHGCopiedUriList。 - Diodon 官方项目:
ClipboardManager的剪贴板读取逻辑,以及Controller的文件历史与自动回填逻辑。
复查源码时应选择与实际安装包匹配的版本,并考虑发行版补丁;不能仅凭最新分支的代码推断历史版本行为。

Comments 1 条评论
博主 小左
fff