# Technical Documentation: Jeric-X/SyncClipboard

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/Jeric-X/SyncClipboard)

### Overview (`README.md`)

# SyncClipboard
[](https://github.com/Jeric-X/SyncClipboard/actions?query=branch%3Amaster)

中文 | [English](README_EN.md)

<details>
<summary>目录</summary>

- [SyncClipboard](#syncclipboard)
  - [功能](#功能)
  - [不兼容变更记录](#不兼容变更记录)
    - [v3.1.1](#v311)
  - [客户端](#客户端)
    - [Windows](#windows)
      - [安装板](#安装板)
      - [便携版](#便携版)
      - [WinGet](#winget)
      - [故障排除](#故障排除)
    - [macOS](#macos)
      - [手动安装](#手动安装)
      - [Homebrew](#homebrew)
      - [故障排除](#故障排除-1)
    - [Linux](#linux)
      - [手动安装](#手动安装-1)
      - [Arch Linux](#arch-linux)
      - [故障排除](#故障排除-2)
    - [桌面客户端命令行参数](#桌面客户端命令行参数)
      - [--shutdown-previous](#--shutdown-previous)
      - [--command-{command-name}](#--command-command-name)
    - [IOS](#ios)
      - [使用快捷指令](#使用快捷指令)
    - [Android](#android)
      - [使用SyncClipboard Mobile](#使用syncclipboard-mobile)
      - [使用Sync Clipboard Flutter](#使用sync-clipboard-flutter)
      - [使用AutoJs6脚本](#使用autojs6脚本)
      - [使用Fcitx5-SyncClipboard](#使用fcitx5-syncclipboard)
      - [使用syncclipboard-xposed](#使用syncclipboard-xposed)
    - [鸿蒙OS (HarmonyOS Next)](#鸿蒙os-harmonyos-next)
      - [使用ClipLink](#使用cliplink)
    - [客户端配置说明](#客户端配置说明)
  - [服务器](#服务器)
    - [独立服务器](#独立服务器)
      - [服务器配置](#服务器配置)
      - [Docker](#docker)
      - [Arch Linux](#arch-linux-1)
    - [客户端内置服务器](#客户端内置服务器)
    - [WebDAV服务器](#webdav服务器)
    - [S3服务器](#s3服务器)
  - [API](#api)
    - [获取剪贴板](#获取剪贴板)
    - [上传剪贴板](#上传剪贴板)
    - [SyncClipboard.json](#syncclipboardjson)
    - [S3 同步协议规范](#s3-同步协议规范)
  - [项目依赖](#项目依赖)
  - [打赏](#打赏)

</details>

## 功能

- 跨平台（Windows/macOS/Linux/移动端）剪贴板实时同步、剪贴板历史记录管理、历史记录同步
- 支持客户端内置服务器、docker部署服务器，也可以使用支持WebDAV协议或S3兼容API的对象存储作为服务器
- 基于 SyncClipboard API 的社区客户端与工具
- 优化图片类型的剪贴板，功能有：
  - 从任意位置复制图片时，可以直接向文件系统粘贴图片文件，反之亦然
  - 从浏览器复制图片后，后台下载原图到本地，解决无法从浏览器直接复制动态图的问题
  - 从文件系统复制较新格式类型的图片文件时（webp/heic等），在剪贴板内储存gif或jpg格式，用于直接向支持图片的文本框粘贴图片

> [!WARNING]  
> 剪贴板历史记录功能处于早期阶段，请做好丢失全部信息的准备，重要信息不要仅依赖本工具保存
>

## 不兼容变更记录
### [v3.1.1](https://github.com/Jeric-X/SyncClipboard/issues/286)
v3.1.1及以上的客户端、服务器与之前的版本不兼容，同步网络中的客户端、服务器、第三方客户端需同步升级


## 客户端

桌面客户端（Windows/Linux/macOS）运行在后台时将自动同步剪贴板
<details>
<summary>展开/折叠截图</summary>



</details>

### Windows
#### 安装板

在[Release](https://github.com/Jeric-X/SyncClipboard/releases/latest)页面下载名字以`SyncClipboard_win_`开头的exe安装包，双击运行安装程序后即可使用

#### 便携版

在[Release](https://github.com/Jeric-X/SyncClipboard/releases/latest)页面下载名字以`SyncClipboard_win_`开头的zip文件，解压后运行`SyncClipboard.exe`

#### WinGet

```powershell
winget install --id JericX.SyncClipboard --exact --source winget
```

#### 故障排除
- 支持的最低系统版本为Windows10 2004
- 在Windows 10中运行SyncClipboard时界面图标大范围出错，请下载安装微软[Segoe Fluent Icons](https://aka.ms/SegoeFluentIcons)图标字体

### macOS

#### 手动安装
在[Release](https://github.com/Jeric-X/SyncClipboard/releases/latest)页面下载名字以`SyncClipboard_macos_`开头的安装包，双击后拖动SyncClipboard图标到Applications文件夹

#### Homebrew

```shell
brew install --cask Jeric-X/tap/syncclipboard
```

#### 故障排除
- 系统提示`由于开发者无法验证，“SyncClipboard”无法打开`： 
在macOS的`设置`->`隐私与安全性`页面，点击`仍要打开`
- 系统提示`“SyncClipboard”已损坏，无法打开`：在终端中执行`sudo xattr -d com.apple.quarantine /Applications/SyncClipboard.app`
- 部分功能依赖辅助功能权限，软件在需要时会弹窗提示授权。在系统设置中已授权但SyncClipboard仍显示未授权时，先在授权列表中删除SyncClipboard，然后重启SyncClipboard重新申请

### Linux
#### 手动安装
在[Release](https://github.com/Jeric-X/SyncClipboard/releases/latest)页面下载名字以`SyncClipboard_linux_`开头的安装包

#### Arch Linux

Arch Linux 用户可以直接从[AUR](https://aur.archlinux.org/packages/syncclipboard-desktop)安装（由 [@devome](https://github.com/devome) 维护）：

```shell
paru -Sy syncclipboard-desktop
```

安装后从菜单中启动即可。如果在命令行中使用命令`syncclipboard-desktop`启动报错，请将环境变量`LANG`设置为`en_US.UTF-8`，以`LANG=en_US.UTF-8 syncclipboard-desktop`来启动。

#### 故障排除

- 要求系统使用 **glibc 2.38 或更高版本**，可运行 `ldd --version` 查看当前版本。
- 剪贴板同步不及时、无法同步、上传乱码：建议在系统内安装`xclip`（X11）或`wl-clipboard`（Wayland），SyncClipboard会使用这些工具辅助获取剪贴板以增强稳定性。使用`xclip -version`或`wl-paste -version`命令确认是否已安装
- 使用`deb`、`rpm`安装包时，升级安装失败时，请先删除旧版再安装新版
- 使用`AppImage`包时，请确认AppImage文件具有可执行权限
- Wayland 下快捷键或使用SyncClipboard间接触发的复制、粘贴无法使用时，请先检查[输入设备权限](docs/Linux-Input-Permissions.md)。

> [!NOTE]  
> 需要彻底删除SyncClipboard时请手动删除配置文件和临时文件目录：  
> `%AppData%\SyncClipboard\`(Windows)，`~/Library/Application Support/SyncClipboard/`(macOS)，`~/.config/SyncClipboard/`(Linux)

### 桌面客户端命令行参数

#### --shutdown-previous
关闭已经运行的SyncClipboard，运行新的实例

#### --command-{command-name}
运行指定命令，`{command-name}`为命令名称，设置快捷键后，在配置文件中可以查看对应的命令名称，即使清除快捷键配置，命令行参数仍然有效  
当存在已经运行的SyncClipboard时，不会启动新的实例，而是调用已经运行的SyncClipboard执行命令。当不存在已经运行的SyncClipboard时，将在启动完成后立刻执行命令  
支持多个`--command-{command-name}`参数，多个命令同时执行  

> [!NOTE]  
> macOS使用命令行参数时，请使用可执行程序的完整路径`/Applications/SyncClipboard.app/Contents/MacOS/SyncClipboard.Desktop.MacOS`

### IOS 
#### 使用[快捷指令](https://apps.apple.com/cn/app/%E5%BF%AB%E6%8D%B7%E6%8C%87%E4%BB%A4/id1462947752)  

> [!WARNING]  
> 导入快捷快捷指令时，可能无法修改服务器信息([#306](https://github.com/Jeric-X/SyncClipboard/issues/306))，可以先不修改服务器信息直接导入，导入后再编辑快捷指令中的服务器信息
>

- 手动同步，导入这个[快捷指令](https://www.icloud.com/shortcuts/321944d62831416f9d2ba7917917686a)，手动触发上传或下载
- 自动同步，导入这个[快捷指令](https://www.icloud.com/shortcuts/05e7ac5aca5f4f588b776117cf740587)，运行后设备会自动在后台同步剪贴板内容，此快捷指令将执行无限时长，需要手动关闭，你还可以手动修改同步后是否发送系统通知、查询的间隔秒数
- 自动上传短信验证码，参考这个帖子中的视频教程 https://github.com/Jeric-X/SyncClipboard/discussions/60

### Android
#### 使用[SyncClipboard Mobile](https://github.com/Jeric-X/syncclipboard-mobile)

- 从通知中心、桌面快捷方式、分享菜单中快捷手动触发
- 一定程度的后台同步能力
- 剪贴板历史记录及同步
- 自动上传短信验证码

#### 使用[Sync Clipboard Flutter](https://github.com/bling-yshs/sync-clipboard-flutter)

这是一个使用 Flutter 构建的 Material 3 风格的、适配了SyncClipboard API的安卓客户端应用，支持从控制中心快捷上传或下载。

功能详情、使用步骤、系统要求等信息请查看该项目的 [README](https://github.com/bling-yshs/sync-clipboard-flutter)

#### 使用[AutoJs6脚本](https://github.com/imgs/SyncAutojs6)

此项目通过[AutoJs6](https://github.com/SuperMonster003/AutoJs6)基于悬浮窗的后台剪贴板获取方式，实现了Android 10+系统上的后台剪贴板同步能力

#### 使用[Fcitx5-SyncClipboard](https://github.com/qh7574/Fcitx5-SyncClipboard)

这是一个为 Fcitx5-android 开发的插件，借助输入法特权实现与 SyncClipboard 服务的无感剪贴板同步，无需手动获取；同时支持同步大文本、图片、文件等，文件无感下载并智能复制 uri 到剪贴板。

具体使用方法、操作事项等请参见该项目 [README.md](https://github.com/qh7574/Fcitx5-SyncClipboard)

#### 使用[syncclipboard-xposed](https://github.com/shaklow/syncclipboard-xposed)
一个lsposed模块，实现完全无感自动同步


### 鸿蒙OS (HarmonyOS Next)
#### 使用[ClipLink](https://github.com/xiebaiyuan/ClipLink)

适配 HarmonyOS Next 的客户端。从 [Releases](https://github.com/xiebaiyuan/ClipLink/releases) 页面下载 `.hap` 文件，通过 [auto-installer](https://github.com/likuai2010/auto-installer) 或 `hdc install` 命令进行 sideload 安装（受鸿蒙系统限制，暂不支持直接安装）。


### 客户端配置说明

全平台依赖三条必要配置（配置的拼写可能会有所不同，含义相同）。
- user
- password
- url，格式为http(s)://ip(或者域名):port。使用WebDav服务器时，url需要具体到一个已存在的文件夹作为工作目录，例如`https://domain.com/dav/folder1/working%20folder`，特殊符号需要使用url转义字符代替，不要使用这个文件夹存储其他文件。不使用桌面客户端（Windows/Linux/macOS）时需在工作目录中再创建`file`文件夹以同步文件，桌面客户端会在设置服务器时自动创建`file`文件夹。url尽量不要以斜线分隔符`/`结尾，在部分客户端中会出现问题。

## 服务器
### 独立服务器
[SyncClipboard.Server](https://github.com/Jeric-X/SyncClipboard/releases/)支持跨平台运行，依赖[ASP.NET Core 10.0](https://dotnet.microsoft.com/zh-cn/download/dotnet/10.0)，安装`ASP.NET Core 运行时`后，通过以下命令运行
```
dotnet /path/to/SyncClipboard.Server.dll --contentRoot ./
```
工作目录与dll所在目录一致，需要写入权限。如需修改工作目录，拷贝一份`appsettings.json`到新工作目录并修改`--contentRoot`后的路径  

#### 服务器配置
服务器通过`appsettings.json`文件配置，形式如下：
```jsonc
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "Kestrel": {
    "Endpoints": {
      "http": {
        "Url": "http://*:5033"
      },
      //"https": {
      //  "Url": "https://*:5033"
      //}
    },
    //"Certificates": {
    //  "Default": {
    //    "Path": "/path/to/pem",
    //    "KeyPath": "/path/to/pem_key"
    //  }
    //}
  },
  "AppSettings": {
    "UserName": "your_username",
    "Password": "your_password",
    "MaxSavedHistoryCount": 1000, // 历史记录数量上限，0 表示不限数量；保留时长限制仍生效
    "HistoryRetentionMinutes": 0 // 历史记录保留时长（分钟），0 表示不限时长；数量限制仍生效
  }
}
```
如需启用HTTPS，请取消`https`和`Certificates`部分的注释，并设定HTTPS证书路径。最后将`http`部分注释或删除以关闭不安全的连接。如需同时启用HTTP和HTTPS，请将二者`Url`设置为不同的端口号  
不同类型证书的配置方法可以参考[微软官方文档](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/servers/kestrel/endpoints?view=aspnetcore-10.0#configure-https-in-appsettingsjson)

用户名和密码支持使用环境变量配置，当环境变量`SYNCCLIPBOARD_USERNAME`、`SYNCCLIPBOARD_PASSWORD`均不为空时，将优先使用这两个环境变量作为用户名和密码  

环境变量`ASPNETCORE_hostBuilder__reloadConfigOnChange`用于配置是否自动识别appsettings.json变动并重载配置，默认值为`false`，修改为任何非`false`值后会启用此功能

> [!WARNING]  
> HTTP使用明文传输，在公共网络部署服务器请启用HTTPS或使用反向代理工具配置HTTPS。无法从证书颁发机构获取证书时，推荐使用开源工具[mkcert](https://github.com/FiloSottile/mkcert)或其他方式生成自签名证书

#### Docker

```shell
# docker
docker run -d \
  --name=syncclipboard-server \
  -p 5033:5033 \
  -e SYNCCLIPBOARD_USERNAME=your_username \
  -e SYNCCLIPBOARD_PASSWORD=your_password \
  -v /data/syncclipboard-server:/app/data \
  --restart unless-stopped \
  jericx/syncclipboard-server:latest

# docker compose
curl -sL https://github.com/Jeric-X/SyncClipboard/raw/master/src/SyncClipboard.Server/docker-compose.yml >> docker-compose.yml
docker compose up -d
```

首次启动容器后，在容器目录`/app/data`（即主机`/data/syncclipboard-server`目录）下会自动创建默认的`appsettings.json`  
修改`appsettings.json`时，涉及文件路径的，请注意容器与主机间的文件映射关系

#### Arch Linux

可以直接从 [AUR](https://aur.archlinux.org/packages/syncclipboard-server) 安装（由 [@devome](https://github.com/devome) 维护）：

```shell
paru -Sy syncclipboard-server
```

配置文件路径为`/etc/syncclipboard/appsettings.json`，修改配置后使用`systemctl`命令启动即可：

```shell
sudo systemctl enable --now syncclipboard.service
```

### 客户端内置服务器

桌面客户端（Windows/Linux/macOS）内置了服务器功能，可以使用可视界面配置

### WebDAV服务器
可以使用支持WebDAV协议的网盘作为服务器  
测试过的服务器：   

- [x] [Nextcloud](https://nextcloud.com/) 
- [x] [AList](https://alist.nn.ci/)
- [x] [InfiniCLOUD](https://infini-cloud.net/en/)
- [x] [aliyundrive-webdav](https://github.com/messense/aliyundrive-webdav)

### S3服务器
桌面客户端支持使用 AWS 官方 S3 SDK 直连 S3，也支持使用兼容 S3 API 的对象存储服务。  
添加账号时选择`S3`，配置以下字段：

- `Server Address`：可选，AWS 可留空；使用兼容 S3 的服务时填写对应 endpoint
- `Region`：签名区域，例如`us-east-1`
- `Bucket Name`：用于存储`SyncClipboard.json`与`file/`对象的 bucket
- `Object Prefix`：可选，建议设置独立前缀（如`syncclipboard`）隔离数据
- `Force Path-Style Addressing`：兼容服务建议开启
- `Access Key ID` / `Secret Access Key`：访问密钥

## API

在独立服务器运行环境下设定环境变量ASPNETCORE_ENVIRONMENT为Development后运行服务器，或桌面客户端打开服务器并打开设置里的诊断模式后，
访问`http://ip:端口/swagger/index.html`可以打开API描述页面

API路径不以`/api/`起始的为WebDAV兼容API，实现客户端时，调用此类API可以同时支持基于WebDAV服务器与SyncClipboard官方服务器的剪贴板同步功能，其中的关键API的说明如下

### 获取剪贴板
```shell
GET /SyncClipboard.json
GET /file/dataName            # optional
```

### 上传剪贴板
```shell
PUT /file/dataName            # optional
PUT /SyncClipboard.json
```

### SyncClipboard.json
```jsonc
{
  "type": "Text",                          // or Image/File/Group, required
  "hash": "string",                        // optional, empty string is treated as null
  "text": "string",                        // required
  "hasData": true,                         // or false, required
  "dataName": "string",                    // if hasData is true, required
  "transferDataHash": "string",            // optional
  "size": 0,                               // optional
  "syncedAt": "2026-10-09T15:30:00+08:00"  // optional
}
```

- API所有字段大小写敏感
- `text`储存剪贴板预览字符串，或完整的Text类型剪贴板内容
- `hasData`标识是否使用一个额外文件存储完整的剪贴板信息
  - 对于Image/File/Group类型，`hasData`恒为true
  - 对于Text类型，可以根据原字符串的长度，可选是否使用额外的UTF8编码的`.txt`文件存储完整字符串，如果这样做，`text`字段仅储存完整字符串的起始部分内容
- `hash`值为剪贴板内容的唯一标识，计算方法请参考[docs/Hash.md](docs/Hash.md)
  - 发送方应尽量提供`hash`信息
  - 当`hash`值存在时，接收方应验证`hash`信息与剪贴板内容的一致性，在不一致时执行错误处理流程
  - 当`hash`为空时，或处于无法计算`hash`的环境，可以使用`type`/`text`的组合简单判断剪贴板内容的相等性
- `transferDataHash`是实际传输文件完整字节流的 SHA-256，使用 64 位大写十六进制字符串，用于校验传输文件完整性
  - 仅在 `hasData` 为 `true` 时使用
- `size`标识复制文件的总字节大小，或Text类型剪贴板完整字符串的长度，仅用于展示
- `syncedAt`记录当前远端剪贴板的提交时间，使用带 UTC 偏移量的 ISO 8601 时间
  - 官方服务器收到剪贴板推送时会使用服务器时间，忽略客户端提供的时间
  - WebDAV/S3 由客户端在推送时设置
  - 客户端时钟可能不同，不应使用该字段判断并发写入顺序

### S3 同步协议规范

使用 S3 兼容对象存储作为同步后端时的协议与数据格式规范请参阅 [S3 Adapter Design](docs/S3-Adapter-Design.md)。

## 项目依赖
[NativeNotification](https://github.com/Jeric-X/NativeNotification)  
[Magick.NET](https://github.com/dlemstra/Magick.NET)  
[.NET Community Toolkit](https://github.com/CommunityToolkit/dotnet)  
[H.NotifyIcon](https://github.com/HavenDV/H.NotifyIcon)  
[WinUIEx](https://github.com/dotMorten/WinUIEx)  
[moq](https://github.com/moq/moq)  
[Avalonia](https://avaloniaui.net/)  
[FluentAvalonia](https://github.com/amwx/FluentAvalonia)  
[AsyncImageLoader.Avalonia](https://github.com/AvaloniaUtils/AsyncImageLoader.Avalonia)  
[Vanara](https://github.com/dahall/Vanara)  
[Tmds.DBus](https://github.com/tmds/Tmds.DBus)  
[SharpHook](https://github.com/TolikPylypchuk/SharpHook)  
[Quartz.NET](https://github.com/quartznet/quartznet)  
[MiSans](https://hyperos.mi.com/font)  

## 打赏

如果 SyncClipboard 对你有所帮助，并希望打赏支持，请[参阅此处](docs/donate.md)

---

### Hash (`docs/Hash.md`)

# Profile Hash 计算方法

## 概述

所有 Profile 类型的 Hash 基于 SHA256 算法，涉及文本的使用 UTF-8 编码。  
Hash 值通常以大写十六进制字符串形式表示，实际使用时应该对大小写不敏感。


## 1. TextProfile (纯文本)

将文本进行UTF8编码后计算SHA256

## 2. FileProfile 和 ImageProfile (单个文件或图片)

1. 计算文件内容的 SHA256 哈希值并转换为大写十六进制字符串
2. 获取文件名（不含路径）
3. 构造字符串：`文件名|文件内容SHA256字符串`
4. 对该字符串进行 UTF-8 编码后再次计算 SHA256

### 伪代码

```
ContentHash = SHA256(FileContent)
CombinedString = "FileName|" + ToUpperCase(ContentHash)
Hash = SHA256(UTF8(CombinedString))
```

## 3. GroupProfile (多文件或文件夹)

### 计算方法

对所有文件和目录排序后，按特定格式和顺序拼接成完整字符串计算 SHA256

### 详细说明

#### 3.1 Entry 收集
- 收集所有输入文件/目录及其子文件/子目录，每一条作为一个 entry
- 每个 entry 以输入文件的父目录为根，取相对路径作为 EntryName
- EntryName 中的路径分隔符统一为 `/`
- 目录的 EntryName 以 `/` 结尾

#### 3.2 排序
- 取 EntryName 按 UTF-8 编码后的byte数组，以字典序升序排序

#### 3.3 Entry 的 Hash 输入字符串格式
- 目录：`D|{entryName}\0`
- 文件：`F|{entryName}|{length}|{contentHash}\0`

其中：
- `{entryName}`：相对路径名
- `{length}`：文件字节长度
- `{contentHash}`：文件内容的 SHA256 哈希值（大写十六进制）
- 每条 entry 的 hash 输入字符串以`\0`结尾

#### 3.4 哈希计算
- 将所有 entry hash 输入字符串按序拼接成一个字符串，UTF8 编码后计算 SHA256

### 示例

假设有以下文件结构：
```
folder/
  ├── a.txt (100 bytes, hash: abc...123)
  └── subdir/
      └── b.txt (200 bytes, hash: def...456)
```

排序、拼接所有 Entry hash 输入字符串，得到最终的输入字符串（这里将`\0`显示为换行）：
```
D|folder/
F|folder/a.txt|100|ABC...123
D|folder/subdir/
F|folder/subdir/b.txt|200|DEF...456

```
将上述字符串使用UTF8编码转换为 byte 数组，对此 byte 数组进行 SHA256 计算得到最终的hash

---

### Linux Input Permissions (`docs/Linux-Input-Permissions.md`)

# Linux 输入设备权限（ACL）

中文 | [English](Linux-Input-Permissions_EN.md)

## 什么时候需要权限

是否需要设备权限，取决于当前 Linux 桌面会话使用 X11 还是 Wayland，以及「系统设置 → 通用 → 全局快捷键后端」的选择。更改后端后需要重启 SyncClipboard 才会生效。可运行 `echo "$XDG_SESSION_TYPE"` 查看当前会话类型，通常为 `x11` 或 `wayland`。

| 桌面会话 | 全局快捷键后端选项 | 实际使用方式 | 是否需要设备权限 |
| --- | --- | --- | --- |
| X11 | 自动（默认） | XRecord/XTest | 不需要 |
| X11 | x11 | XRecord/XTest | 不需要 |
| X11 | libinput/uinput | libinput/uinput | 需要 |
| Wayland | 自动（默认） | libinput/uinput | 需要 |
| Wayland | x11 | 通过 XWayland 使用 XRecord/XTest | 不需要，但功能可能不生效 |
| Wayland | libinput/uinput | libinput/uinput | 需要 |

需要设备权限的组合中，监听全局快捷键需要 `/dev/input/event*` 的读写权限，使用SyncClipboard间接触发的复制、粘贴(本质为模拟按键输入)只需要 `/dev/uinput` 的写权限。XRecord 后端无需配置下述设备 ACL，应用也会隐藏输入设备权限管理模块。

## 配置 ACL

对于使用 udev 并支持本地活动会话 ACL 的桌面系统，可通过 `uaccess` 为当前活动用户自动设置 ACL，无需加入 `input` 组。

`udevadm` 并非所有 Linux 系统都有，可用 `command -v udevadm` 检查。精简系统或使用其他设备管理器的系统可能没有；即使有该命令，`uaccess` 也需要相应规则和会话管理支持。

创建规则，文件名前缀使用 `70`，确保先于系统的 `73-seat-late.rules` 执行：

```shell
sudo tee /etc/udev/rules.d/70-syncclipboard.rules >/dev/null <<'EOF'
ACTION=="remove", GOTO="syncclipboard_end"
SUBSYSTEM=="input", KERNEL=="event*", TAG+="uaccess"
SUBSYSTEM=="misc", KERNEL=="uinput", TAG+="uaccess"
LABEL="syncclipboard_end"
EOF
```

若只需要模拟按键，可仅保留 `uinput` 那条设备规则。若 `/dev/uinput` 不存在，先执行 `sudo modprobe uinput`。随后重新加载规则：

```shell
sudo udevadm control --reload-rules
sudo udevadm trigger
sudo udevadm settle
```

用 `getfacl /dev/uinput` 和 `getfacl /dev/input/event0` 查看 ACL，应包含当前用户的 `rw-` 条目。`uaccess` 授予的读写权限覆盖上述需求。重启 SyncClipboard，并在「系统设置」中查看权限状态。

> [!WARNING]
> ACL 授权作用于当前用户，并非只授权 SyncClipboard。

临时测试也可直接使用 [setfacl](https://man7.org/linux/man-pages/man1/setfacl.1.html)，不依赖 udev。在运行 SyncClipboard 的普通用户终端执行；只需要模拟按键时仅执行第二条：

```shell
sudo setfacl -m "u:$(id -un):rw" /dev/input/event*
sudo setfacl -m "u:$(id -un):w" /dev/uinput
```

设备重建或会话切换后，手动设置的 ACL 可能丢失。若缺少 `getfacl`、`setfacl`，请安装发行版的 `acl` 包（Ubuntu/Debian：`sudo apt install acl`）。

## 取消授权

先完全退出 SyncClipboard，然后在运行它的普通用户终端执行以下操作。

如果配置过上述 udev 规则，先删除规则并重新加载；如果只使用过手动 `setfacl` 授权，跳过这一步：

```shell
sudo rm /etc/udev/rules.d/70-syncclipboard.rules
sudo udevadm control --reload-rules
sudo udevadm trigger
sudo udevadm settle
```

删除现有设备上属于当前用户的 ACL 条目，仅对之前授权过的设备执行：

```shell
sudo setfacl -x "u:$(id -un)" /dev/input/event*
sudo setfacl -x "u:$(id -un)" /dev/uinput
```

`-x` 删除指定用户的条目，不会清空其他用户的 ACL 条目。检查结果：

```shell
getfacl /dev/uinput
getfacl /dev/input/event0
```

对应的 `user:你的用户名:...` 条目应已消失。如果仍通过用户组或其他系统规则获得权限，删除这份 ACL 不会取消那些权限。

---

### Linux Input Permissions EN (`docs/Linux-Input-Permissions_EN.md`)

# Linux Input Device Permissions (ACL)

[中文](Linux-Input-Permissions.md) | English

## When permissions are required

Device permissions depend on the Linux desktop session (X11 or Wayland) and the selection under **System settings → General → Global hotkey backend**. Restart SyncClipboard after changing the backend. Run `echo "$XDG_SESSION_TYPE"` to check the session type, usually `x11` or `wayland`.

| Desktop session | Global hotkey backend option | Input mechanism | Device permissions required? |
| --- | --- | --- | --- |
| X11 | Automatic (default) | XRecord/XTest | No |
| X11 | x11 | XRecord/XTest | No |
| X11 | libinput/uinput | libinput/uinput | Yes |
| Wayland | Automatic (default) | libinput/uinput | Yes |
| Wayland | x11 | XRecord/XTest through XWayland | No, but functionality may not work |
| Wayland | libinput/uinput | libinput/uinput | Yes |

For combinations requiring device permissions, global hotkeys need read/write access to `/dev/input/event*`; copy/paste operations indirectly triggered through SyncClipboard (implemented by simulating keyboard input) only need write access to `/dev/uinput`. The XRecord backend needs none of the device ACLs below, and the app hides the input device permission management section for it.

## Configure ACLs

On desktops with udev and active local session ACL support, use `uaccess` to automatically grant access to the active user, without joining the `input` group.

Not every Linux system provides `udevadm`; check with `command -v udevadm`. Minimal systems or alternative device managers may omit it. Having the command alone does not guarantee `uaccess` support: the corresponding rules and session management must also be available.

Create a rule with prefix `70` so it runs before `73-seat-late.rules`:

```shell
sudo tee /etc/udev/rules.d/70-syncclipboard.rules >/dev/null <<'EOF'
ACTION=="remove", GOTO="syncclipboard_end"
SUBSYSTEM=="input", KERNEL=="event*", TAG+="uaccess"
SUBSYSTEM=="misc", KERNEL=="uinput", TAG+="uaccess"
LABEL="syncclipboard_end"
EOF
```

For simulated input only, keep just the `uinput` device rule. If `/dev/uinput` is missing, run `sudo modprobe uinput` first. Reload the rules:

```shell
sudo udevadm control --reload-rules
sudo udevadm trigger
sudo udevadm settle
```

Check `getfacl /dev/uinput` and `getfacl /dev/input/event0` for a named user entry with `rw-` access. Restart SyncClipboard and refresh its permission status in System settings.

> [!WARNING]
> ACL permissions apply to the current user, not exclusively to SyncClipboard.

For temporary testing, use [setfacl](https://man7.org/linux/man-pages/man1/setfacl.1.html) without udev. Run these from the regular user's terminal; only the second command is needed for simulated input:

```shell
sudo setfacl -m "u:$(id -un):rw" /dev/input/event*
sudo setfacl -m "u:$(id -un):w" /dev/uinput
```

Manual ACLs may be lost when devices are recreated or sessions change. If `getfacl` or `setfacl` is missing, install your distribution's `acl` package (`sudo apt install acl` on Ubuntu/Debian).

## Revoke access

Fully exit SyncClipboard first, then run the following from the regular user's terminal used to run the app.

If you configured the udev rule above, remove it and reload the rules. Skip this step if you only granted access manually with `setfacl`:

```shell
sudo rm /etc/udev/rules.d/70-syncclipboard.rules
sudo udevadm control --reload-rules
sudo udevadm trigger
sudo udevadm settle
```

Remove the current user's ACL entries from existing devices. Only run the commands for devices you previously granted access to:

```shell
sudo setfacl -x "u:$(id -un)" /dev/input/event*
sudo setfacl -x "u:$(id -un)" /dev/uinput
```

`-x` removes the specified user's entries without clearing other users' ACL entries. Check the result:

```shell
getfacl /dev/uinput
getfacl /dev/input/event0
```

The corresponding `user:your-username:...` entries should be gone. Access granted through groups or other system rules is not revoked by removing these ACL entries.

---

### S3 Adapter Design (`docs/S3-Adapter-Design.md`)

# SyncClipboard S3 同步协议规范

本文档描述 SyncClipboard 使用 S3 兼容对象存储作为同步后端的**存储布局约定**以及相关开发指南。

---

## 1. S3 专有配置项

以下配置项为 S3 服务器依赖。

| 参数 | 说明 | 必填 | 默认值 |
|------|------|------|--------|
| `ServiceURL` | S3 兼容端点 URL。使用 AWS 原生服务时留空 | 否 | 空 |
| `Region` | AWS 区域标识（如 `us-east-1`、`ap-northeast-1`） | 否 | `us-east-1` |
| `BucketName` | 存储桶名称 | **是** | — |
| `ObjectPrefix` | 所有对象 key 的统一前缀，用于隔离多客户端或多用途 | 否 | 空 |
| `ForcePathStyle` | 是否使用路径风格寻址（`endpoint/bucket/key`），大多数 S3 兼容服务需要开启 | 否 | `false` |
| `AccessKeyId` | 访问密钥 ID | **是** | — |
| `SecretAccessKey` | 访问密钥 Secret | **是** | — |

### 端点选择规则

- **AWS 原生**：`ServiceURL` 留空，SDK 根据 `Region` 自动解析标准 AWS S3 端点。
- **S3 兼容服务**（MinIO / Cloudflare R2 / 阿里云 OSS 等）：填写 `ServiceURL`（如 `https://s3.example.com`），一般同时启用 `ForcePathStyle = true`。

### 认证方式

使用 **AWS Signature V4** 签名。

---

## 2. 对象存储布局

所有对象 key 的格式为 `{ObjectPrefix}/{相对路径}`。前缀的首尾 `/` 会被 trim。

```
{BucketName}/
  └── {ObjectPrefix}/                       # 为空时没有此级
       ├── SyncClipboard.json               # 剪贴板 Profile 元数据文件
       └── file/                            # 附件目录（零或多个数据文件）
            ├── {dataName1}                 # 例如 "Text_2025-04-15_08-45-23_abc12345.tmp.txt"
            └── {dataName2}                 # 例如 "screenshot.png"
```

---

## 3. Profile 元数据格式（`SyncClipboard.json`）

参照 [API 说明](../README.md#syncclipboardjson)。

---

## 4. S3 API 调用约定

以下按操作列出具体的 S3 API 调用。所有操作均使用 AWS Signature V4 认证。

### 4.1 连接测试

| S3 操作 | `ListObjectsV2` |
|---------|-----------------|
| Bucket | `{BucketName}` |
| Prefix | `{ObjectPrefix}` |
| MaxKeys | `1` |

成功返回即说明凭据和桶可用。

### 4.2 初始化（确保目录结构）

检查 `file/` 目录标记是否存在：

| S3 操作 | `HeadObject`（GetObjectMetadata） |
|---------|----------------------------------|
| Key | `{prefix}/file/` |

如果返回 **404 / NoSuchKey**，则创建目录标记：

| S3 操作 | `PutObject` |
|---------|-------------|
| Key | `{prefix}/file/` |
| Body | 空字符串 |
| Content-Type | `application/x-directory` |

### 4.3 读取 Profile

| S3 操作 | `GetObject` |
|---------|-------------|
| Key | `{prefix}/SyncClipboard.json` |

- 成功：读取响应体 → JSON 反序列化为 ProfileDto
- 404 / NoSuchKey：返回空（首次使用，尚无 Profile）

### 4.4 写入 Profile

| S3 操作 | `PutObject` |
|---------|-------------|
| Key | `{prefix}/SyncClipboard.json` |
| Body | JSON 序列化的 ProfileDto |
| Content-Type | `application/json; charset=utf-8` |

### 4.5 上传数据文件

| S3 操作 | `PutObject` |
|---------|-------------|
| Key | `{prefix}/file/{fileName}` |
| Body | 文件二进制内容 |

`fileName` 即 ProfileDto 中的 `dataName` 字段值。

### 4.6 下载数据文件

| S3 操作 | `GetObject` |
|---------|-------------|
| Key | `{prefix}/file/{dataName}` |

`dataName` 来自 ProfileDto 的 `dataName` 字段。

### 4.7 清理旧文件

分页列出 `file/` 目录下所有对象并批量删除：

**第一步：列出对象**

| S3 操作 | `ListObjectsV2` |
|---------|-----------------|
| Prefix | `{prefix}/file/` |
| MaxKeys | `1000` |

使用 `ContinuationToken` 分页直到全部列出。

**第二步：批量删除**

| S3 操作 | `DeleteObjects` |
|---------|-----------------|
| Objects | 上一步列出的所有 key |


---

## 5. S3 兼容服务注意事项

### 5.1 签名与编码

对于非 AWS 的 S3 兼容服务，在 PUT 请求时应注意：

| 设置 | 说明 |
|------|------|
| 禁用 Chunked Transfer Encoding | 不使用 `Transfer-Encoding: chunked` |
| 禁用 Payload Signing | 不在请求体上计算 SHA-256 签名（使用 `UNSIGNED-PAYLOAD`） |
| 禁用 Trailer Checksum | 不追加 `x-amz-checksum-*` trailer |

许多 S3 兼容网关（R2、MinIO、OSS gateway 等）不支持 AWS SDK v3+ 默认启用的流式 Trailer 签名。

### 5.2 路径风格 vs 虚拟主机风格

- **AWS 标准**：虚拟主机风格 `{bucket}.s3.{region}.amazonaws.com/{key}`
- **兼容服务**：通常需要路径风格 `{endpoint}/{bucket}/{key}`（开启 `ForcePathStyle`）

### 5.3 协议选择

- 如果 `ServiceURL` 以 `http://` 开头，使用 HTTP；否则使用 HTTPS
- AWS 原生始终使用 HTTPS

---

---

### Donate (`docs/donate.md`)

# Donate / 打赏

如果 SyncClipboard 对你有帮助，欢迎打赏支持项目持续维护。
If SyncClipboard helps you, donations are welcome to support ongoing maintenance.

## Notes / 说明

1. issue 是否能被快速解决，主要取决于问题描述是否详细、是否容易复现、是否对功能有重大影响、作者空闲时间等因素。
Issue resolution speed mainly depends on whether the report is detailed, reproducible, has significant impact on core functionality, and on the author's available time.
2. 请在打赏时备注名字、网络 ID 或其他用户标识。
Please leave your name, online ID, or other user identifier when donating.

## Alipay / 支付宝

<img src="image/donate/alipay.jpg" alt="Alipay QR Code / 支付宝收款码" width="320" />

## WeChat Pay / 微信支付

<img src="image/donate/wechat_pay.png" alt="WeChat Pay QR Code / 微信收款码" width="320" />

---

### Ai Design DotNet10 And Dependencies Incremental Merge Plan (`docs/ai_design/DotNet10-And-Dependencies-Incremental-Merge-Plan.md`)

# .NET 10 与主要依赖升级总结

升级围绕 .NET 10、桌面 UI 框架和开发工具展开，按技术主题拆分改动，保留业务行为与配置格式。本文记录版本变化、兼容性适配和升级范围的取舍。

## 版本变化

| 范围 | 升级内容 |
| --- | --- |
| .NET | 从 .NET 8 迁移到 .NET 10；SDK 使用 10.0.302 和 latestPatch，macOS workload 固定版本；同步调整目标框架、CI 与容器构建 |
| 微软基础库与 MVVM | Microsoft.Extensions 和 SignalR 客户端更新到 10.0.12；CommunityToolkit.Mvvm 更新到 8.4.2 |
| Windows UI | Windows App SDK 2.4.0、WinUI Toolkit 8.2.251219、WinUIEx 2.9.3、H.NotifyIcon.WinUI 2.4.1 |
| Avalonia | 11.3.18 → 12.1.2；FluentAvaloniaUI 2.3.0 → 3.1.0；AsyncImageLoader.Avalonia 3.3.0 → 3.8.0 |
| 数据库 | EF Core/Sqlite/Design 9.0.8 → 9.0.20；SQLitePCLRaw 传递依赖更新到 2.1.12，保留 EF Core 9 主版本 |
| 测试工具 | Microsoft.NET.Test.Sdk 经 18.4.0 更新到 18.10.1；coverlet.collector 更新到 10.0.1 |
| 容器开发工具 | Microsoft.VisualStudio.Azure.Containers.Tools.Targets 1.22.1 → 1.23.0 |

## 兼容性适配与问题修复

- **历史查询：** 显式使用适合表达式树的 LINQ Contains 调用，避免 .NET 10 下重载选择引入 ReadOnlySpan，导致 EF Core 在切换历史分类或统计选择项时抛出异常。
- **Avalonia 控件与 API：** 迁移 FluentAvalonia 的 FA 前缀控件和事件类型，以内置 FABreadcrumbBar 替换独立 BreadcrumbBar 包；适配布局、样式、绑定、焦点、窗口装饰和已移除的 API。
- **图片与拖拽：** 图片读取显式使用 PNG 编码；历史列表和预览面板保留首次按下事件并传入拖拽 API，在拖拽结束或取消后清理状态。
- **关于页进度动画：** 修复缓存页面离开后再次显示时，不确定进度动画不能恢复的问题，并在代码注释中关联上游 PR。
- **服务器配置：** 首次生成 appsettings.json 后重新加载已有配置源，避免追加 JSON 配置源覆盖环境变量和命令行参数，保持命令行 > 环境变量 > 配置文件的优先级。
- **测试兼容：** 修正阻塞 CI 的 ZIP 测试问题，保留既有测试框架和断言写法。
- **资源生成：** 资源访问类在编译时生成到 `I18n/Strings.Designer.cs`，作为设计时源码项供 IDE 加载，但通过 `.gitignore` 排除版本控制，避免平台、语言或 SDK 差异产生无意义 diff；格式检查前先构建 Core 项目，确保语义分析可以加载该类型。

## 保留的实现与依赖

| 范围 | 取舍 |
| --- | --- |
| 测试框架 | 保留 MSTest.TestFramework/TestAdapter 3.10.4，避免引入 MSTest 4 分析器对跨平台格式检查的影响 |
| 图片处理 | 保留 Magick.NET 14.9.1 和 Magick.NET.SystemDrawing 8.0.15 |
| S3 | 保留 AWSSDK.S3 3.7.414，不迁移 v4；S3 签名、初始化与进度修复不包含在此次升级范围内 |
| 全局输入与原生调用 | 保留 SharpHook 5.2.3、Vanara 4.0.1 |
| 后台调度 | 保留 Quartz 3.14.0，不引入 Quartz 4 的生命周期改造；额外的任务取消和调度初始化修复不包含在升级范围内 |
| API 文档 | 保留 Swashbuckle.AspNetCore 8.1.1 |
| Linux 打包 | 保留 PupNet 1.8.0 和既有 RPM/DEB 依赖声明，ARM64 RPM 打包问题不在升级范围内 |
| MVVM 属性 | 保留字段生成属性的写法和 MVVMTK0042 silent，避免批量 partial 属性迁移扩大改动 |

系统最低版本声明未作调整；依赖升级带来的实际系统兼容性由目标系统测试确认。升级验证采用本地编译、非 UI 测试及各平台 PR CI，UI 由人工验收，不下载 CI 产物进行额外二进制审计。

README 的运行时说明与正式发布版本对应：在 .NET 10 源码迁移与正式发布之间，服务器说明使用 ASP.NET Core 8.0，避免用户按开发分支信息安装错误的运行时；不新增 no-dotnet-runtime 使用说明。

## 关联 PR

- [#419](https://github.com/Jeric-X/SyncClipboard/pull/419)：集中升级探索，为拆分范围和兼容修复提供参考。
- [#420](https://github.com/Jeric-X/SyncClipboard/pull/420)：.NET 10 工具链、目标框架、构建与容器。
- [#421](https://github.com/Jeric-X/SyncClipboard/pull/421)：微软基础库、MVVM、WinUI 依赖及历史查询兼容修复。
- [#422](https://github.com/Jeric-X/SyncClipboard/pull/422)：Avalonia 12、配套控件适配与 EF Core 补丁升级。
- [#426](https://github.com/Jeric-X/SyncClipboard/pull/426)：测试与容器开发工具升级、服务器首次启动配置优先级修复，以及 README 与升级总结整理。

---

### Ai Design Issue 320 History Export Design (`docs/ai_design/Issue-320-History-Export-Design.md`)

# 历史记录导出设计

状态：已实现，平台验证结果见文末。日期：2026-10-09。

[Issue #320](https://github.com/Jeric-X/SyncClipboard/issues/320) 希望将历史记录导出为可读、可长期保存的数据。本期只实现客户端本地历史导出：历史记录设置页导出全部记录，历史记录窗口在多选模式下导出选中记录；两个入口共用独立的导出模态窗口。有附件时自动生成 ZIP，无附件时自动生成 JSON。

## 范围和设计决策

本期包含 Avalonia 和 WinUI3 两套界面，以及 Core 中共用的导出逻辑。不实现导入、配置备份还原、数据库替换、旧数据库迁移、Excel 导出、定时导出或远端历史下载。

导出格式不提供手动选择，不提供“仅导出元数据”或“排除附件”选项。所有类型统一导出其传输文件，每条记录最多对应一个附件。导出层不解释或重组传输文件内部结构。只导出完整记录；内容缺失、损坏或无法读取时跳过整条记录，归档中不保留其元数据或残缺附件。操作结束后将未导出的记录及原因写入独立报告文档，并在结果窗口提供报告路径。

先选择保存目录，再展示数量和估算大小，用户开始后从传输文件直接流式写入目标。已有附件不复制到临时目录，也不在内存中组装整个归档。最终完整记录中有传输文件时生成 ZIP，仅剩无需文件的记录时生成 JSON，没有完整记录时只生成未导出报告。短文本内联保存，长文本保留文本传输文件，Group 保留传输 ZIP，其他类型同样使用各自的传输文件。

未来新增类型沿用相同约定，由 Profile 提供传输文件及其完整性验证。导出层只依赖统一传输能力，不维护类型白名单，不随新增类型增加文件收集、解包或目录重建分支。

导出不改变历史记录、访问时间、收藏、置顶、同步状态、保留策略或当前剪贴板，不触发上传或下载，不停止全局同步服务。缺少现成传输文件但本地内容完整时，可通过现有 Profile 流程生成可复用的传输文件；不为已有附件建立导出副本。

选择保存目录后，模态窗口展示待导出条目数量和估算大小，由用户点击“开始导出”。不检查磁盘剩余空间；实际文件操作失败仍按失败流程处理。

## 现有实现和接入位置

| 位置 | 当前行为 | 本期接入 |
| --- | --- | --- |
| `HistorySettingViewModel` 和两端 `HistorySettingPage` | 提供历史开关、保留策略和清空历史 | 增加“导出历史记录”，传入全部记录范围 |
| `HistoryViewModel.Selection.cs` | 使用 `selectedHistoryRecords` 和 `HistoryRecordKey` 保存全局选择，跨分页和筛选保留 | 提供所选键的副本，由视图调用导出工具 |
| 两端 `HistoryWindow` 多选操作栏 | 提供全选、删除、收藏、退出多选 | 增加“导出”，无选中项时禁用 |
| `HistoryManager` | 使用 `_dbSemaphore` 协调主要数据库操作 | 提供不可变的导出快照，避免返回跟踪实体 |
| `HistoryRecord` 与各类 `Profile` | 保存预览、文件路径、传输文件和内容哈希 | 转换为独立导出模型，通过统一传输能力取得附件 |
| 两端对话框服务 | 已区分主窗口和 `HistoryWindow` 对话框宿主 | 复用宿主绑定模式，增加专用导出对话框呈现器 |

实现前应特别注意：`GetHistory()` 会读取全部数据库记录，包括已删除记录；`VisibleSelectedItems` 仅包含已加载并可见的选择；`HistoryRecord.Text` 对长文本可能只是预览。这三者均不能直接作为完整导出结果。

## 导出入口和范围

### 历史记录设置页

增加独立的“导出历史记录”设置项，描述为“导出内容完整的本地历史记录”，按钮为“导出”。点击打开导出模态窗口，范围固定为“全部历史记录”。

全部记录指预估阶段数据库快照中 `IsDeleted == false` 的客户端本地记录，包含收藏和置顶记录。它不受历史窗口的搜索、类型筛选、分页和排序状态影响，也不读取服务器上尚未存在于本地数据库的记录。

历史功能关闭但本地仍有记录时，仍允许导出。先选择目录，预估阶段确认没有记录时显示“没有可导出的历史记录”，不生成空归档。

### 历史记录窗口

仅在多选操作栏提供“导出”按钮。命令执行时复制 `selectedHistoryRecords.Keys`，之后选择变化不改变此次请求。范围包含跨筛选、跨分页的全部已选记录，不只包含当前可见记录。

快照查询时已经删除或不存在的已选项计入跳过数量，并在结果中说明。全部已选项均不可用时不生成归档。成功、取消或失败后均不主动清空选择、不退出多选模式。

内部用 `selected == null` 表示全部记录，非空键集合表示所选记录；空键集合不能被解释为全部记录。界面不再增加时间、类型等筛选器，需要限定范围时使用历史窗口的现有筛选和多选功能。

## 共用导出模态窗口

新增独立的 `HistoryExportDialog` 和共用 `HistoryExportViewModel`。独立指具有自己的视图、状态和完整导出生命周期，不将流程嵌入设置页或多选栏。

Avalonia 使用专用 `FAContentDialog`，WinUI3 使用专用 `ContentDialog`，遵循已有编辑对话框的模式。两个入口在各自平台复用同一组件；每次打开创建新的 ViewModel，不共享前一次导出的进度和取消令牌。

窗口包含：

- 标题“导出历史记录”，以及“全部历史记录”或“选中的 N 条记录”。
- 开始前显示本次选择的总条目数量（设置页入口为全部记录数量）和估算大小，例如“预计导出 128 条记录，大小约 240 MiB”；准备失败的记录不在预处理界面提示，原因保留到最终结果和报告。
- 已选保存目录和“更换位置”入口，开始导出后固定目录。
- 界面仅显示数量、预计大小、保存位置和必要结果，不展示 JSON/ZIP 格式规则等实现细节。
- 预估及流式导出阶段的进度，当前记录数及已处理字节数；尚未知总量时显示不定进度。
- 完成后显示成功导出数量；文件缺失或其他失败数量大于 0 时才显示对应项，全部成功时仅显示“已导出 N 条记录”。结果窗口和报告摘要使用相同规则。
- 导出文件和未导出报告的完整路径分别显示“备份文件”和“未导出记录报告”标题；只提供“打开文件夹”，不直接打开文件或报告。
- 随状态变化的“开始导出”“取消”“关闭”按钮。

窗口按阶段展示：预估和导出中仅显示进度、当前状态与取消；待导出显示范围、目录、数量、大小、更换位置、可用的开始按钮和关闭；结束后隐藏范围、目录设置、开始及进度，仅保留结果、带标题的已生成文件路径、“打开文件夹”及关闭。仅存在导出文件或报告时提供打开文件夹。预估失败或取消后可更换位置重新准备；正式导出结束后不重复执行本次计划。

正常流程为：

```text
先弹出目录选择器
  → 用户选择保存目录
  → 打开导出模态窗口，查询快照并估算大小
  → 展示待导出条目数量和估算大小
  → 开始导出
  → 逐条取得并验证传输文件，跳过不可导出的记录
  → 从源文件直接流式写入所选目录中的归档
  → 根据实际完整记录完成 ZIP 或 JSON 并提交
  → 在同一目录将未导出记录及原因写入独立报告
  → 在原模态窗口显示成功导出数量、因缺失文件未导出数量和报告路径
```

跳过缺失文件的记录后直接继续，不增加“保存不完整记录”的确认步骤。若最终没有完整记录，仅在已选目录保存未导出报告并显示数量和路径，不生成空归档，也不重新询问位置。

选择的是目录，而非带固定扩展名的文件。文件名主体为 `SyncClipboard-history-yyyyMMdd-HHmmss`，最终根据实际结果采用 `.json` 或 `.zip`；同名追加编号，不覆盖已有文件。导出中途不再弹出位置或格式选择器。

取消最初的目录选择器即结束本次入口操作，不生成任何文件。实际导出过程中关闭窗口或按取消，先取消任务、释放句柄并删除本次未提交输出，再保存报告并进入结果状态。已提交的最终文件不再报告为取消。

同一进程同时只允许一个导出流程，另一个入口提示已有导出正在进行；此限制只针对导出，不阻塞其他历史操作。

### 导出前预估

位置选择完成后异步查询快照并估算，不阻塞界面。预估完成后启用“开始导出”，用户可在预估中或查看结果后取消。导出前显示的条目数量为本次选择的总量，包含准备失败及已失效的选择；设置页入口显示本次快照的全部记录数量。预计大小仍仅统计准备成功的记录，失败原因保留到最终报告。

估算指最终导出文件的预计大小，不是所需磁盘空间。它由 JSON 文本及元数据的预计 UTF-8 编码大小、可用传输文件大小和外层 ZIP 条目开销组成，不预先假定外层压缩能节省空间，也不将字符数直接当成字节数。

- 已有传输文件：调用 Profile 的统一准备和验证能力，再读取文件长度；旧记录沿用 Profile 的原文件路径回退。验证可能读取完整文件并计算哈希。
- 尚需生成的传输文件：本地内容完整时调用统一 `PrepareTransferData` 能力，在正常历史存储位置生成传输文件并计入大小。准备阶段填好记录的 `transferData`（名称、归档相对路径、实际长度和哈希），本机源路径仅保留在本次导出计划中，供正式导出直接复用，不写回数据库；不新增类型分支或下载内容。
- 无法准备或无法读取大小的记录：直接排除，分类保留未导出原因。没有“大小未知”类别，不将这些记录及其 JSON 元数据计入估算。
- 预估阶段会检查和准备内容；缺失、损坏、读取失败或无法取得长度的记录直接进入未导出清单，正式导出不再处理这些记录，最终报告保留预估失败原因。取消和磁盘不足终止预估。正式导出仅打开准备好的传输文件并验证长度和哈希，不重建 Profile、不再次准备或生成传输文件；文件已不存在时按文件缺失跳过，即使原文件仍完整也不重新生成。

`HistoryRecord.Size` 在不同类型下的含义可能不同，不能直接求和作为传输字节数。预估仅供判断规模，不承诺精确大小或严格上界。实际写入时展示已处理的附件字节数，不为预估制作附件副本。

预估结果和数量只用于内部计划与界面，不写入归档。不查询磁盘剩余空间；实际生成传输文件或写入输出遇到磁盘不足时报告失败，并清理本次未提交输出。

### 对话框宿主和焦点

设置页入口绑定主窗口，历史入口绑定历史窗口，目录选择器使用相同宿主。WinUI3 当前文件夹选择器固定使用主窗口，接入时需支持历史窗口宿主。

从历史窗口打开导出时，在模态窗口及目录选择器生命周期持有可释放的“模态操作保持显示”作用域。`OnLostFocus()` 在作用域内不因 `CloseWhenLostFocus` 隐藏窗口；结束后恢复原有行为，不修改用户置顶和失焦设置。不要复用会被 `OnGotFocus()` 重置的 `_remainWindowForViewDetail`。

模态打开期间，历史列表的复制、粘贴、删除和 Esc 退出多选等快捷键不抢占输入。验证置顶历史窗口不遮挡导出对话框或目录选择器，macOS 额外验证窗口隐藏、Space 和焦点切换。

## 文件格式

### 归档结构

无附件时输出一个 UTF-8、可读缩进的 JSON 文件。有附件时输出普通 ZIP：

```text
SyncClipboard-history-20261009-153000.zip
├── history.json
└── files/
    ├── Image-{hash}/
    │   └── image.png
    ├── Text-{hash}/
    │   └── full-text.txt
    └── Group-{hash}/
        └── group.zip
```

JSON 文件和 ZIP 中的 `history.json` 使用同一结构；文件中包含版本信息，不另设一份重复的 manifest。所有附件引用使用归档内相对路径和 `/` 分隔符，不输出原机器绝对路径作为恢复依据。每条记录的目录名使用 `Profile.GetProfileId(type, normalizedHash)`，即 `{Type}-{Hash}`，完整路径为 `files/{profileId}/{transferFileName}`。哈希按导出记录中的规则规范为大写，不截断；目录名不依赖导出顺序或数据库 ID，也不使用本地工作目录的 `{Type}_{Hash}` 命名。

示例中的 `{hash}` 代表实际完整哈希。传输文件名独立保存在元数据中，实际名称来自传输数据描述并进行安全规范化。生成的 Profile ID 必须是合法的单个路径段，异常身份应报告错误，不能通过替换字符将不同身份映射到同一目录。相同 Profile ID 的附件路径只能写入一次；发现相互冲突的重复内容时报告错误，不静默覆盖。

Group 的传输 ZIP 是外层导出 ZIP 中的一个普通文件，不展开为多个附件。用户解压外层归档后可以再解压 Group 传输包查看原文件；外层打包可以对已压缩附件使用不压缩模式，但不得改写附件字节。

### 文档和记录模型

新增独立的 `HistoryExportDocument` 与 `HistoryExportRecord`，不直接序列化 `HistoryRecord`、`ProfileDto` 或 `ProfilePersistentInfo`。历史记录快照直接转换为 `HistoryExportRecord`，此时将类型转为字符串、哈希转大写、时间转 UTC；预估和正式导出始终复用同一记录模型，不再保留 `HistoryExportItem` 或第二次模型转换。本机原文件路径、传输文件路径/哈希及计算属性标记 `JsonIgnore`，仅在准备与报告阶段使用，不写入备份。

| 文档字段 | 含义 |
| --- | --- |
| `format` | 固定值 `syncclipboard-history` |
| `formatVersion` | 本期为 `1`，独立于数据库和配置版本 |
| `appVersion` | 创建文件的客户端版本 |
| `exportedAt` | UTC ISO 8601 导出时间 |
| `records` | 通过完整性验证并实际导出的历史记录数组 |

导出范围用于程序内部选择记录，结果统计用于界面和独立报告，均不写入归档文件。

| 记录字段 | 含义 |
| --- | --- |
| `type`、`hash` | 内容类型字符串与原内容身份，哈希统一大小写 |
| `text` | 原记录的文本元数据，原样保留；短文本是全文，长文本是预览，其他类型按原描述保存 |
| `size` | Profile 内容大小：先保留历史记录原值，准备后采用 `Profile.GetSize()` 返回的缓存值或新计算值；不使用传输文件字节数替代 |
| `timestamp`、`lastModified`、`lastAccessed` | UTC ISO 8601 时间 |
| `starred`、`pinned`、`from` | 收藏、置顶和来源；导出字段使用正确拼写 `starred` |
| `hasTransferData` | 此记录是否需要传输文件；为 `true` 时必须附带完整文件 |
| `transferData` | 已验证传输文件的描述，含 `name`、归档相对 `path`、实际字节数 `size` 和 `sha256`；仅无需传输文件时为 `null` |

传输文件描述只保存本次已取得并验证的文件，描述在准备阶段确定，实际打包按该描述验证字节数和哈希，成功后原样写入清单。`hasTransferData == false` 且 `transferData == null` 表示无需附件，例如短文本；`hasTransferData == true` 时，`transferData` 和对应文件必须同时存在。不能取得完整文件的记录不进入 `records`。存在传输文件时，JSON 中的 `text` 不代表完整内容，长文本尤其不能用预览冒充全文。

文本元数据不修剪空白、不转换换行；传输文件逐字节复制，不重新编码或重复将长文本全文写入 JSON。`hash` 是 Profile 内容身份，`transferData.sha256` 是此次传输文件的字节哈希，两者不能混用。记录 `size` 的单位沿用 Profile 语义（例如文本为字符数、文件为字节数），与始终表示文件字节数的 `transferData.size` 区分，不能当作归档总字节数。未知类型通用回退保留原记录的 `size`，不尝试推导。

不导出数据库 ID、`SyncStatus`、远端 `Version`、删除墓碑及本地可用性内部标志。归档只包含完整记录，不设置完整性状态或错误字段。是否可导出由此次实际验证决定，不能直接信任 `IsLocalFileReady`。未来导入可基于版本化格式实现，但本期不实现导入接口或其去重、合并、同步策略。

记录按 `Timestamp` 降序，再按类型和规范化哈希稳定排序。使用流式 JSON 写入，避免将全部长文本和附件同时加载到内存。

## 统一传输文件导出流程

| 当前类型示例 | 导出的传输文件 | 格式影响 |
| --- | --- | --- |
| 短文本 | 无，文本元数据包含全文 | 无其他附件时为 JSON |
| 长文本 | 完整文本传输文件 | 成功收集时为 ZIP，即使没有其他附件 |
| 图片 | Profile 提供的图片传输文件 | 成功收集时为 ZIP |
| 单文件 | Profile 提供的文件本身 | 成功收集时为 ZIP |
| 文件组 | Profile 提供的完整传输 ZIP | 成功收集时为 ZIP，外层归档中保留该 ZIP |
| 未来新增类型 | Profile 提供的传输文件，不要求导出层理解内部格式 | 成功收集时为 ZIP |

上表仅说明现有类型的结果，不能实现为导出层按 `ProfileType` 分支。统一流程如下：

1. 准备阶段从快照重建独立 Profile，依据 `HasTransferData` 判断是否需要附件。无需附件时仅保留文本元数据，不为短文本人为创建文件。
2. 已有传输文件优先校验并直接读取原文件流，保留完整字节，不复制到临时目录，不解包或重打包。存在可信持久化 `TransferDataHash` 时核对字节；缺少该绑定时由 Profile 校验内容身份，不能只新算一个哈希就宣称文件属于该记录。
3. 仅在准备阶段，没有可用传输文件但本地内容完整时，调用现有 `PrepareTransferData` 能力，在 Profile 的正常传输数据位置生成文件，然后直接读取。生成的是传输文件本身，不是已有附件的导出副本，不额外建立导出专用临时目录或预备副本集合。
4. 每条记录在写入前验证源文件可读且身份有效；读取过程中同时计算实际字节数和 SHA-256。只保存源引用、期望哈希及元数据，不保存附件快照。
5. 开始写入该附件前，准备好的文件已不存在时按文件缺失跳过整条记录并保存原因，不回退原文件或重新生成传输包。不会仅导出该记录的元数据，也不会用残缺原文件生成看似完整的 Group 包。附件写入中途的失败按下文归档失败规则处理。

重建传输文件可能得到与旧传输包不同的字节哈希，例如 ZIP 容器元数据变化；必须验证其 Profile 内容身份与原记录一致，并记录新文件的字节哈希。复用现有有效传输文件时则保证字节完全相同。

导出层不调用本地化收集 Group 子项，不自行遍历其内部目录或新增类型哈希算法；类型知识留在 Profile 层。必要的新传输文件沿用原有生成位置，不写回历史记录时间、同步状态或触发上传。传输文件属于应用运行中使用的数据，导出流程不管理其生命周期，不在成功、失败、取消、关闭或重新预估时删除；`PrepareTransferData` 保持原有两个参数，不提供新建文件登记回调。生成文件时的内部原子写入沿用 Profile 机制，不要求移除其自身保障；导出不得覆盖用户原文件或主动删除复用的传输文件。

新增类型只需接入已有 Profile 工厂和传输契约，就能复用导出流程。对于当前程序完全无法重建的类型，如存在持久化传输哈希的文件绑定，预估时也必须读取并校验实际文件后才能纳入导出，仍可按通用文件契约导出完整记录；否则跳过并报告缺少处理能力或验证依据，不猜测内容、不静默忽略。此兜底不等于承诺理解未来版本的数据库格式。

仅远端有内容但本地尚未下载的记录，因本地文件缺失而跳过，不触发网络请求。图片、文件、Group 或长文本内容缺失不会阻止其他完整记录导出；操作结束后统一报告成功数量与缺失跳过数量，不把跳过的记录写入归档。

## 快照和一致性

本期提供数据库元数据快照，传输文件直接从源读取，不创建内容副本，也不承诺数据库与外部文件的全局时间点快照。

1. 在 `HistoryManager` 的数据库同步边界内读取符合范围的非删除记录，复制数组等可变字段，形成脱离 EF 跟踪的快照后释放锁。大范围查询不得仅取 UI 已加载页；分批实现时须使用一致的数据库读事务，按稳定键遍历，不以会变动的分页偏移拼接不同时间点的数据。
2. 验证与读取尽量使用同一打开句柄，并采用平台允许的共享限制；不能假设所有平台都能阻止外部写入。传输文件直接流式写入目标，同时核对实际字节数和哈希。
3. 源文件被同步、清理或外部程序删除、替换时，通过打开、读取或校验失败报告受影响项；没有暂存副本提供隔离。内存只保留元数据、结果及有界读写缓冲。
4. 不为导出长期持有 `_dbSemaphore`，不暂停上传、下载或清理；没有附件的记录也不修改访问时间来避免清理。

导出范围使用预估时的同一份快照，不在开始后重新查询改变范围。快照后新增记录不纳入；删除记录若内容仍可验证则可导出，否则按实际原因跳过或失败。

### 条目失败与归档失败

创建某个 ZIP 条目前，打开并验证准备阶段确定的传输文件；可识别的缺失、损坏等源数据问题在此时跳过。只有附件完整写入且哈希一致后，记录才进入成功候选集合。

一旦条目已开始写入，若发生中途读取失败、源内容变化或目标写入失败，整次归档失败并删除未提交输出。首版使用 `ZipArchiveMode.Create` 流式写入，不能假设可删除已写入的残缺条目；也不使用会缓存附件的 Update 模式，不为单条回滚引入自制 ZIP 写入器。

这一边界让常见的缺失记录可以跳过，同时避免在没有附件副本的情况下发布残缺归档。整次失败时，前面已写入但未提交的候选记录也不计成功，报告分别说明具体源错误与因归档未提交而未保存的记录。

## 写入和失败处理

遇到第一个已验证且可读取的传输文件时才创建外层 ZIP，后续附件逐条流式写入。全部处理结束后，将成功候选集合写为 ZIP 内的 `history.json`，再关闭并提交 ZIP。若没有成功附件但有完整内联记录，直接生成 JSON；没有完整记录时只在已选目录保存报告。Group 内部文件和目录不单独计数。

最终文件提交成功后，显示“已导出 28 条记录，2 条记录因文件缺失未导出”。统计按记录计数，一条 Group 缺少多个原文件仍只计为一条未导出记录。准备阶段若没有有效传输包且无法从本地取得完整内容，计为缺失；准备时重建成功不计入跳过。正式打包时若准备好的传输文件已丢失，直接计为缺失，不再重建。

损坏、校验不通过、无权限、缺少类型处理能力以及快照查询时已失效的选择分别记录为其他未导出原因，不混入“缺失文件”数量。有这些情况时在结果中补充“K 条记录因其他原因未导出”并提供明细。每条请求记录只计入一个最终分类。统计用于内部结果、界面和独立报告，不写入 JSON／ZIP 归档。

“已导出”数量必须与成功提交文件中的 `records` 数量一致。取消或目标写入失败时不把已准备条目计为成功，显示取消或失败；全部因缺失而跳过时显示导出 0 条及缺失数量，不宣称已生成文件。

源文件流直接写入所选目录中唯一的 `.partial` 输出文件，关闭并校验完成后在同一目录改名为最终 `.zip` 或 `.json`。这只是最终归档的写入中名称，不是附件暂存目录，完成后也不再复制一次归档。文件名冲突追加编号，创建与提交均不覆盖旧文件。目录不可写或消失时报告失败，不在中途重新弹出位置选择器。

| 场景 | 行为 |
| --- | --- |
| 用户取消导出 | 释放句柄，删除未提交输出，在已选目录保存已知结果报告 |
| 无记录 | 已选目录中不生成归档或报告 |
| 全部选择失效或全部记录不完整 | 不生成归档，仅在已选目录保存报告并显示数量及路径 |
| 条目开始写入前发现源文件缺失 | 跳过整条记录，结束后计入“因缺失文件未导出” |
| 条目开始写入前源文件无权限、损坏或校验失败 | 跳过并单独报告原因，不计作缺失 |
| 条目写入中途读取或校验失败 | 整次归档失败，删除未提交输出，不能保留残缺条目 |
| 数据库读取失败、生成传输文件时磁盘不足、归档写入失败 | 整次任务失败，不发布半成品，不覆盖已有文件 |
| 无法重建 Profile 的记录类型 | 有可验证传输文件时通用复制；否则跳过整条记录并报告缺少处理能力或验证依据 |
| 用户取消或关闭导出窗口 | 请求取消并等待收尾；清理失败提示残留路径。应用被强制终止时可能残留 `.partial`，不视作成功归档 |

只清理由本次任务创建的未提交输出，不删除源文件、复用的传输文件、已提交归档或报告。内部结果使用稳定问题代码供界面和报告分类，不向归档写入错误明细；日志不主动写入完整剪贴板文本或凭据内容。

外层 ZIP 条目名必须由受控的相对路径生成，禁止绝对路径、`..`、重复目标和越界。内层传输文件作为完整字节流保存，导出层不展开它；Profile 自身校验或生成传输文件所需的临时操作沿用现有安全约束。磁盘不足视为整次任务失败。实现使用现有 .NET JSON、ZIP 能力，首版无需增加 Excel 或第三方归档依赖。

## 未导出记录报告

使用 UTF-8 纯文本 `.txt` 文档，方便直接打开和搜索。操作结束且存在未导出记录时自动生成，无需用户再次选择保存路径；全部成功时不生成空报告。报告独立于归档，不放进 `history.json` 或 ZIP，也不恢复归档中已删除的统计和错误字段。

报告始终保存在开始时选择的目录，名称为 `{本次导出文件名主体}-not-exported.txt`。同名追加编号，不覆盖旧报告。全部记录被跳过时，仍使用已选目录和本次文件名主体，不再询问位置，也不新增应用内报告目录。

报告包含：

- 操作时间、最终状态，以及实际生成的归档路径；没有归档时明确写“未生成归档”。
- 请求记录数、成功导出数、因缺失文件未导出数及其他原因未导出数，与界面保持一致。
- 每条未导出记录的 Profile ID、类型、记录时间、来源及文本预览或文件名，足以让用户在历史中识别该记录。已失效的选择至少保留请求中的类型和哈希，其余字段标记为不可用。
- 具体未导出原因，例如“本地传输文件及原文件均不存在”“文件读取被拒绝”“传输文件校验不通过”。存在相关路径时列出原传输文件路径及已知缺失源路径，便于定位；不为报告重新遍历 Group 或重复读取附件。

每条记录使用独立文本段落，预览最多保留 200 个 Unicode 字符并标明截断，不把完整长文本或二进制数据写入报告。问题说明使用可读文案，可附内部问题代码，不直接输出异常堆栈。报告中的路径是用于排查的本机路径，与归档内部的相对路径用途不同。

成功数量以最终提交为准。整体写入失败时，已写入但未提交的记录标为“归档写入失败，未保存”，不计作成功。若源文件在复制过程中读取失败或内容变化，出错的那条记录保留具体源失败原因，其余未提交记录才标为归档失败；目标写入失败不归因于源文件。取消时保留已确认原因，未检查条目标为“操作取消，未检查”。取消位置选择或仅预估后关闭不生成报告。若数据库错误导致范围无法完整取得，只报告已知信息并说明数量未知，不编造明细。

结果窗口等待报告写入结束后显示数量和实际完整路径。所选目录无法保存报告时，提示“未导出报告保存失败”及原因，保留界面明细供复制或重试，不静默改存其他目录，不虚报路径。报告失败不删除成功归档，二者结果分别展示。

## 代码结构

| 新增或调整项 | 职责 |
| --- | --- |
| Core `Utilities/History/HistoryExport/` | 导出专用的记录、预估计划、文档、进度、未导出原因和结果；通常每个类型独立文件，传输数据与记录同文件，不占用全应用通用的 `Models` 目录 |
| Core `Utilities/History/HistoryExport/HistoryExporter.cs` | 预估、统一传输读取、流式输出、格式判定、取消和提交 |
| Core `Utilities/History/HistoryExport/HistoryExportReportWriter.cs` | 报告命名、防覆盖、在已选目录写入并返回实际路径 |
| Profile 传输准备和验证能力 | 验证或生成各类型传输文件，复用原有位置并保留文件，不建立导出副本 |
| `HistoryManager` 导出快照方法 | 在现有数据库同步边界内读取并复制记录 |
| Core `ViewModels/HistoryExportViewModel.cs` | 位置选择、预估、开始、取消、数量、归档与报告路径展示 |
| Core `Interfaces/IHistoryExportDialog.cs` | 按入口宿主显示共用模态流程，不暴露平台窗口类型 |
| Desktop 和 WinUI3 `Views/HistoryExportDialog` | 平台模态界面及绑定 |
| Desktop 和 WinUI3 `Utilities/HistoryExportDialogPresenter.cs` | 宿主解析、目录选择器、显示保持作用域 |
| 两处入口视图与历史窗口 ViewModel | 复制选择键或传入全部范围，打开对话框 |
| `Strings.resx` 与 `Strings.zh-CN.resx` | 导出文案和结果信息 |

对话框呈现器可沿用普通注册对应主窗口、键控 `HistoryWindow` 注册对应历史窗口的方式。模态组件每次独立创建，导出工具负责任务互斥。`HistoryExporter` 为按需调用的工具，不实现常驻 `IService`；保持单例注册以共享导出互斥状态。目录选择器通过平台回调或小接口提供，不让 Core 持有窗口、`XamlRoot` 或选择器对象。

位置选择后在后台调用 `GetExportSnapshotAsync(selected)` 和 `EstimateAsync(snapshot, selected, token)`，返回含准备成功的快照记录、估算大小和未导出清单的 `HistoryExportPlan`。用户开始后调用 `ExportAsync(plan, destinationDirectory)`，在一次流程中逐条验证、读取和写入。计划不持有附件副本，不再拆成“全部附件复制完成”和“开始输出”两个阶段。`finally` 释放源流、归档和目标句柄，并清理未提交输出。导出计划只保存数据，不需要释放；新生成和已有传输文件均保留。

操作结束时统一调用报告写入器，向界面返回归档路径、成功与未导出数量、报告路径或报告错误。报告使用独立于已取消导出令牌的收尾生命周期；记录信息保存在内部结果中，不依赖附件副本。

## 验收和验证

### 核心行为

- 位置确定后才预估，预估数量与后续使用的快照一致；仅对准备成功的记录按文件字节长度、JSON UTF-8 大小估算，失败记录计入导出前显示的选择总量，但不计入估算大小或实际导出成功数量。
- 预估可生成缺失的传输文件并按实际长度计入，正式导出沿用完整 `transferData` 并验证文件，不重新生成；准备后的文件丢失时跳过并报告文件缺失；不复制附件或下载内容。无法准备的记录直接跳过并保留报告原因；取消和空间不足终止预估。
- 只有短文本时导出为 JSON，全文、空白、换行、Unicode、时间、来源、收藏、置顶一致。
- 仅长文本或短文本与长文本混合时，只要保留的完整记录包含传输文件就导出 ZIP；附件完整保留原字节，JSON 保存预览和有效的 `transferData` 引用。长文本文件缺失时整条记录跳过，不得用预览冒充全文。
- 图片、文件、文件组与文本混合导出为 ZIP，JSON 引用的每个传输文件存在且校验匹配；Group 传输 ZIP 保持完整字节，原目录层级包含在传输包内。
- 缺少现成传输文件但本地内容完整时，通过统一能力生成并验证文件；重建传输包与记录的 Profile 内容身份一致。原文件残缺且没有有效传输包时跳过整条记录，不输出残缺 Group 包。
- 使用仅提供统一传输能力的测试 Profile，验证新增类型无需修改导出工具即可输出元数据和传输文件；已有文件缺少可信哈希绑定时验证内容身份，不接受错误文件。
- 写入条目前发现缺失时跳过，元数据和附件均不进入归档；仅剩短文本时输出 JSON，有传输文件时输出 ZIP，全部跳过时仅在已选目录生成报告。
- 已有传输文件直接流式读入目标，不制作附件副本，内存占用不随附件总量增长；文件在条目写入中途变化、读取失败或校验失败时不发布残缺 ZIP，清理未提交输出并如实报告整次失败。
- 成功数量等于已提交文件的记录数量，缺失按记录计数，不按文件数计数；校验失败、无权限、失效选择等其他原因不误计为文件缺失。
- 报告逐条列出未导出记录及实际原因，数量与最终结果一致；整体写入失败和用户取消不会被误写成文件缺失，已准备未提交的记录不计成功。
- 报告与归档始终在已选目录保存，全部跳过时仍在该目录保存报告；重名不覆盖，写入失败不虚报路径或删除成功归档。
- 全部导出覆盖所有分页且排除删除记录；选中导出覆盖跨分页、跨筛选的选择，不包含未选记录。
- 同一 Profile 的导出目录不随记录顺序变化，使用完整、规范化的 `Profile.GetProfileId`，且 JSON 附件引用与实际路径一致。不同 Profile 的同名传输文件相互隔离，重复身份冲突和不安全路径不会造成覆盖或越界。
- 导出不改变数据库内容、访问时间、同步状态、剪贴板及用户原文件，不触发网络下载或上传；必要的新传输文件沿用原有生成位置，生成后保留，导出计划不负责清理。
- 源文件读取中变动、缺失、无权限以及校验失败有明确结果；目标写入失败和取消不破坏已有目标，不留下被视为成功的半成品。
- 大附件流式处理，进度可更新、取消可响应，不把整个 ZIP 加载进内存。

### 界面行为

- 两个入口使用同一模态组件，先选择目录，无选中项时按钮禁用；位置选择取消不读取附件或生成文件。
- 用户点击“开始导出”前能看到条目数量和估算大小；未导出信息仅在结束后展示，预估中可以取消，不出现磁盘剩余空间检查或容量确认步骤。
- 文件扩展名与最终内容一致，导出中途不再弹出位置或格式选择器。
- 写入前识别的不完整记录自动跳过，结束后显示数量与报告路径；全部跳过时显示 0 条及已选目录中的报告，整次失败不把已写入未提交记录计为成功。
- 历史窗口不因模态或目录选择器失焦而隐藏，置顶不遮挡选择器；关闭后恢复原有行为并保留选择状态。
- 连续重复点击或从另一个入口发起不会启动并行导出；取消、失败后可重新开始。

### 仓库验证

核心测试放在 `SyncClipboard.Test`，使用临时数据库、临时附件和可控依赖，不访问开发者真实历史。重点验证导出契约和失败行为，不编写仅证明“没有导入入口”的测试。Avalonia 对话框行为放在 `SyncClipboard.Test.Desktop`；WinUI3 验证在 Windows 执行。真实 OS 行为测试按仓库要求加平台类别和运行时跳过判断。

按修改范围运行 Core 和 Desktop 测试及对应平台构建。只格式化本次修改的 XAML／AXAML，使用 `src/Settings.XamlStyler` 并以 `-p` 验证；C# 按仓库要求检查。手工验证 Windows、macOS、Linux 的目录选择器、置顶和失焦场景。

## 实施顺序

1. 建立导出模型、范围请求、数据库快照读取及数量和大小预估，完成短文本 JSON 输出及核心测试。
2. 完成统一传输读取与验证、直接流式输出、报告、自动格式判定、取消和安全提交，覆盖长文本、图片、文件和 Group。
3. 实现共用模态 ViewModel 和两端视图，接入先选目录、预估及历史设置页入口。
4. 接入多选导出、正确宿主和焦点保持，完成跨平台验证。

四步共同构成本期导出功能；导入留待独立需求和设计，不影响本期交付。

## 本次实现验证（2026-10-09）

- 已在从 `origin/master`（`d9f60d2e`）建立的 `codex/history-export` 分支实现。
- Core 完整测试：738 通过，30 项按运行环境跳过；导出专项 14 项覆盖 JSON、传输字节、缺失与损坏、Group 重建及仅剩传输包、取消、目标不可用、未知类型和名称冲突。
- Desktop 完整测试：117 通过、1 跳过、3 失败。新增导出弹窗测试通过；失败为现有 `ServiceProvider.ConfigedServices` 中的 `IWebDav`、`IClipboardFactory`、`IClipboardChangingListener`，对应测试未注册 `IWebDav` 或 `IMainWindow`。本次未修改这些测试的注册配置。
- Avalonia 共享项目及 macOS `osx-arm64` 客户端构建通过。构建仍报告仓库现有 Magick.NET 包安全警告，本次未变更依赖版本。
- 已检查本次 C# 文件格式及 6 个 XAML／AXAML 文件，`git diff --check` 通过。
- WinUI3 已接入同一 Core 流程，但未在本机 macOS 上编译。Windows、Linux 原生目录选择器以及多显示器、Space 切换下的交互仍需对应平台验证。

---

### Ai Design Issue 340 Paste Target Window Design (`docs/ai_design/Issue-340-Paste-Target-Window-Design.md`)

# Issue #340：历史窗口保持显示时恢复粘贴目标窗口

## 背景

[Issue #340](https://github.com/Jeric-X/SyncClipboard/issues/340) 描述了如下问题：历史窗口置顶后，普通复制不会关闭窗口，但“复制并粘贴”仍会关闭窗口。若仅将“复制并粘贴”改为不关闭窗口，历史窗口仍占有键盘焦点，模拟的 `Ctrl+V` / `Cmd+V` 会被发送给历史窗口，而不是用户原本希望接收内容的窗口。

因此，该功能的核心不是单纯改变历史窗口的关闭条件，而是：

1. Windows 和 macOS 持续记录最近使用的、非历史窗口的前台窗口。
2. Windows 和 macOS 仅在历史窗口置顶时，才在“复制并粘贴”前将该窗口恢复为前台窗口。
3. 历史窗口需要保持显示时，粘贴后仍保持显示和置顶状态。
4. 平台不支持记录窗口，或者目标窗口激活失败时，隐藏历史窗口完成粘贴；非置顶时保持隐藏，置顶时再恢复。

Linux 的历史记录粘贴功能不启用前台窗口监听和目标窗口恢复。Linux 上的“复制并粘贴”固定依赖隐藏历史窗口后的系统焦点回退：非置顶时隐藏后不恢复，置顶时临时隐藏并在粘贴后恢复，X11 和 Wayland 行为一致；全局快捷键黑名单仍独立监听前台程序。

`CloseWhenLostFocus` 继续保留。它仍用于控制未置顶的历史窗口是否在失焦时关闭，不属于本功能的替代项。

## 目标行为

| 场景 | 历史窗口行为 | 粘贴目标处理 |
| --- | --- | --- |
| 未置顶，普通复制 | 按现有逻辑关闭 | 不发送粘贴快捷键 |
| 已置顶，普通复制 | 保持显示 | 不发送粘贴快捷键 |
| 未置顶，复制并粘贴 | 按现有逻辑关闭 | 所有平台统一依赖关闭后的系统焦点回退，不主动激活目标窗口 |
| 已置顶，复制并粘贴，目标可恢复 | 保持显示和置顶，但不占有键盘焦点 | 激活目标窗口，确认成功后粘贴 |
| 已置顶，复制并粘贴，目标不可记录或激活失败 | 临时隐藏，粘贴后恢复显示和置顶 | 让系统回退到原窗口后粘贴 |
| Linux，复制并粘贴 | 非置顶时隐藏后不恢复；置顶时临时隐藏并恢复 | 不记录或激活目标窗口，依赖窗口隐藏后的系统焦点回退 |

## 总体设计

设计分为五层：

1. `INativeWindowController` 负责读取当前前台窗口，并提供平台原生的窗口级引用；同时负责使用该引用重新激活窗口。
2. `INativeForegroundWindowWatcher` 保持平台 native 职责和 `Start/Stop` 接口，但事件升级为窗口级识别，尽可能携带发生变化的原生窗口引用。
3. `ForegroundWindowMonitor` 是通用中间层，统一控制 watcher 的启停、通过 provider 主动查询当前前台窗口，并向业务服务提供变化事件。
4. Windows 和 macOS 上，`ForegroundWindowTrackingService` 订阅中间层，持续保存最近一个可用的非历史窗口，供置顶模式的复制并粘贴功能使用；Linux 上该 tracking service 不订阅。`HotkeyBlacklistService` 在所有平台仍按配置独立订阅中间层。
5. `HistoryViewModel.CopyToClipboard` 负责粘贴流程编排：设置剪贴板、决定历史窗口是否保持显示；非置顶时统一隐藏后粘贴，置顶时再按平台决定恢复目标窗口或执行临时隐藏兜底。

## 1. 扩展前台窗口信息

### 1.1 平台原生窗口引用

现有 `WindowInfo` 只包含进程名、窗口标题和可执行文件名。这些字段适合匹配规则，但不能可靠地定位并重新激活一个具体窗口。应增加只在本次进程运行期间有效、不可序列化的平台原生窗口引用。

建议定义一个不暴露平台实现细节的基类：

```csharp
public abstract record NativeWindowInfo
{
    public required int ProcessId { get; init; }
}
```

Core 提供几个运行时实现，平台 provider 只创建并解释与自身平台匹配的类型：

```csharp
public sealed record WindowsNativeWindowInfo : NativeWindowInfo
{
    public required nint Hwnd { get; init; }
}

public sealed record X11NativeWindowInfo : NativeWindowInfo
{
    public required string DisplayName { get; init; }
    public required nuint WindowId { get; init; }
}

public sealed record MacNativeWindowInfo : NativeWindowInfo
{
    public required string BundleIdentifier { get; init; }
    public long? WindowNumber { get; init; }
    public string? WindowTitle { get; init; }
    public ScreenPosition? WindowBounds { get; init; }
}
```

Windows 可以保存精确的窗口句柄。macOS 应优先使用 `NSWindow.WindowNumber`/`AXWindowNumber` 作为窗口级身份，并使用 Accessibility API 重新定位具体窗口；如果目标应用（例如部分 Electron 应用）不提供窗口身份，则以应用 PID 激活和前台确认作为降级结果。不要长期保存裸的 `AXUIElementRef`，避免所有权、释放和窗口失效问题；可以保存 PID、窗口编号、标题、窗口位置等信息，在恢复时重新枚举并匹配窗口。Linux 的 X11 Window ID 仅用于前台程序监听和快捷键黑名单，不用于历史记录粘贴目标恢复。

### 1.2 扩展 `WindowDetail`

```csharp
public readonly struct WindowDetail
{
    public WindowInfo? WindowInfo { get; init; }
    public ScreenPosition? Bounds { get; init; }
    public NativeWindowInfo? NativeWindowInfo { get; init; }
}
```

`NativeWindowInfo` 只用于运行时窗口操作：

- 不写入用户配置。
- 不参与历史记录同步。
- 不跨进程传递。
- 无需序列化，也不要求各平台使用统一的字段布局。
- 每次使用前必须验证窗口仍然存在，并验证它没有被句柄复用为其他进程的窗口。

### 1.3 由 provider 恢复窗口

原生信息的解释和操作应留在平台 provider 内，调用者不直接判断 `HWND` 或 macOS 窗口信息。

```csharp
public interface INativeWindowController
{
    WindowDetail? GetForegroundWindowDetail();
    WindowDetail? GetWindowDetail(NativeWindowInfo window);
    WindowInfo? GetForegroundWindowInfo();

    bool TryActivateWindow(NativeWindowInfo window);
}
```

`GetWindowDetail` 用于把 watcher 携带的平台原生窗口引用扩展为完整快照，避免 native 事件到达后再次查询“当前窗口”时读到另一扇窗口。无法读取时返回 `null`。

`TryActivateWindow` 暂时只返回 `bool`，不引入结果枚举或失败原因对象。provider 内部完成窗口有效性检查和平台激活，并在失败时自行记录足够的诊断日志。返回 `true` 表示平台认为激活成功；返回 `false` 时由上层进入临时隐藏兜底。

## 2. 前台窗口中间层与目标跟踪

### 2.1 watcher 保持 native 职责并升级为窗口级事件

`INativeForegroundWindowWatcher` 仍只负责平台 native 监听和启停，但允许调整接口以提供窗口级识别：

```csharp
public interface INativeForegroundWindowWatcher : IDisposable
{
    event Action<NativeWindowInfo?>? ForegroundWindowChanged;

    void Start();
    void Stop();
}
```

它只处理平台 native 监听，不缓存业务窗口详情，不判断历史窗口，也不知道热键黑名单或粘贴目标。其他业务服务不直接依赖它。

事件参数尽可能表示触发变化的具体窗口：

- Windows `WinEvent` 直接使用回调提供的 `HWND` 构造 `WindowsNativeWindowInfo`，实现窗口级而不是进程级识别。
- macOS 尽可能从激活应用和 Accessibility API 获得焦点窗口信息；只能识别应用时允许退化为应用级引用。
- Linux/X11 watcher 按顶层 Window ID 识别变化，供全局快捷键黑名单使用；历史记录粘贴目标 tracking service 在 Linux 不订阅 monitor。
- 读取失败时允许传入 `null`。

基于系统 native 事件的 watcher 收到一次事件就直接转发一次，即使连续事件指向同一个窗口也不去重。上层业务可能需要知道同一窗口重新激活或焦点重新确认。

只有轮询型 watcher 需要避免每个周期都产生重复事件；其去重键必须优先使用窗口级 `NativeWindowInfo`，不能只比较进程名。轮询从窗口 A 切换到同一进程的窗口 B 时必须发出变化事件。

### 2.2 新增通用中间层

新增 `ForegroundWindowMonitor`，作为 `INativeForegroundWindowWatcher` 和业务服务之间的唯一入口：

```csharp
public interface IForegroundWindowMonitor
{
    WindowDetail? GetCurrentForegroundWindow();

    event Action<WindowDetail?>? ForegroundWindowChanged;
}

public sealed class ForegroundWindowMonitor
    : IForegroundWindowMonitor, IDisposable
{
    // 具体实现省略
}
```

中间层职责：

- 持有 singleton `INativeForegroundWindowWatcher` 和 `INativeWindowController`。
- 统一订阅 watcher 的原始变化事件。
- 每次收到通知时只调用一次 provider：有 native 事件窗口时调用 `GetWindowDetail`，没有时调用 `GetForegroundWindowDetail`，生成完整快照。
- 提供 `GetCurrentForegroundWindow()`，供新订阅者主动读取当前快照。
- 将同一份窗口快照发送给全部业务订阅者，避免各业务服务重复读取 provider 得到不一致结果。
- 根据自身 `ForegroundWindowChanged` 是否存在订阅者，控制 watcher 的 `Start/Stop`。

订阅事件本身不立即向新订阅者推送当前窗口。需要初始化状态的业务服务必须采用“先订阅，再主动调用 `GetCurrentForegroundWindow()`”的方式；之后只处理新的变化事件。主动读取时 monitor 调用 provider，读取失败或 provider 抛出异常时记录日志并返回 `null`，且不因此触发变化事件。

其他服务只能注入 `IForegroundWindowMonitor`，不能直接注入 `INativeForegroundWindowWatcher`。native watcher 的 `Start/Stop` 使用方式保持不变，但事件可以升级为携带窗口级原生信息；其使用范围被限制在中间层内部。

### 2.3 中间层根据业务订阅状态控制 watcher

中间层使用自定义事件访问器维护业务订阅者：

- 第一个业务事件处理器订阅时，中间层先订阅 native watcher，再调用 `watcher.Start()`。
- 最后一个业务事件处理器解除订阅时，中间层调用 `watcher.Stop()`，再解除 native watcher 订阅。
- `Dispose()` 无条件停止 watcher、解除 native 事件并清理中间层状态。
- 业务服务只订阅或退订中间层事件，不能调用 watcher 的 `Start/Stop`。

建议实现轮廓：

```csharp
private readonly object _syncRoot = new();
private Action<WindowDetail?>? _foregroundWindowChanged;
private bool _isWatching;

public event Action<WindowDetail?>? ForegroundWindowChanged
{
    add
    {
        if (value is null)
            return;

        lock (_syncRoot)
        {
            ThrowIfDisposed();
            var shouldStart = _foregroundWindowChanged is null;
            _foregroundWindowChanged += value;

            if (shouldStart)
                StartWatchingLocked();
        }
    }
    remove
    {
        if (value is null)
            return;

        lock (_syncRoot)
        {
            _foregroundWindowChanged -= value;

            if (_foregroundWindowChanged is null)
                StopWatchingLocked();
        }
    }
}

private void StartWatchingLocked()
{
    if (_isWatching)
        return;

    _watcher.ForegroundWindowChanged += OnNativeForegroundWindowChanged;
    _watcher.Start();
    _isWatching = true;
}

private void StopWatchingLocked()
{
    if (!_isWatching)
        return;

    _watcher.Stop();
    _watcher.ForegroundWindowChanged -= OnNativeForegroundWindowChanged;
    _isWatching = false;
}
```

实现约束：

- 启停必须幂等，订阅 native 事件必须早于 `Start()`，避免丢失启动时的首次通知。
- 同一个 delegate 重复订阅时遵循标准 .NET event 语义，停止条件以调用列表为空为准。
- native 回调在哪个原始线程到达，monitor 就在哪个线程读取 provider 并触发业务回调。monitor 不负责委托到主线程；需要 UI 线程的订阅者自行使用 `IThreadDispatcher`。
- provider 读取失败或抛出异常时，本次窗口快照为 `null`，仍向业务订阅者广播 `null`。
- monitor 不对 native 事件做额外去重。native watcher 转发的相同窗口事件也原样广播。
- monitor 不建立事件队列，也不对多次回调进行串行化；每次回调独立读取和广播自己的快照。
- 触发业务回调时复制 invocation list，并逐个调用。每个订阅者使用独立的 `try/catch` 隔离；一个订阅者抛出异常不能阻止其他订阅者收到事件，异常由 monitor 记录日志。
- 业务回调在锁外执行，允许回调内部退订，不能因此死锁。
- `Stop()` 与已经进入的 native 回调允许并发完成，无需引入 generation/version 或专用串行队列。
- macOS watcher 的内部主线程调度逻辑保持在 native 实现中，中间层不感知平台差异。应用激活通知之外，使用窗口级轮询补足同一应用内窗口切换；轮询只过滤身份相同的窗口。

业务回调隔离的实现轮廓：

```csharp
private void OnNativeForegroundWindowChanged(NativeWindowInfo? nativeWindow)
{
    WindowDetail? snapshot;
    try
    {
        snapshot = nativeWindow is null
            ? _provider.GetForegroundWindowDetail()
            : _provider.GetWindowDetail(nativeWindow);
    }
    catch (Exception ex)
    {
        _logger.Write(Tag, ex.Message);
        snapshot = null;
    }

    Delegate[] callbacks;
    lock (_syncRoot)
    {
        callbacks = _foregroundWindowChanged?
            .GetInvocationList() ?? [];
    }

    foreach (var callback in callbacks)
    {
        try
        {
            ((Action<WindowDetail?>)callback)(snapshot);
        }
        catch (Exception ex)
        {
            _logger.Write(Tag, ex.Message);
        }
    }
}
```

### 2.4 持续跟踪粘贴目标

新增 `ForegroundWindowTrackingService`，建议作为系统级 `IService` 注册并随应用启动：

```csharp
public sealed class ForegroundWindowTrackingService : Service
{
    public WindowDetail? LastExternalWindow { get; }

    public bool TryActivateLastExternalWindow();
}
```

它只依赖 `IForegroundWindowMonitor` 和 `INativeWindowController`：

- 服务启动时先订阅中间层，再主动调用 `GetCurrentForegroundWindow()` 初始化目标；停止时退订。
- 每次变化时判断窗口是否为 SyncClipboard 历史窗口。
- 保存最近一个带有可用 `NativeWindowInfo` 的外部窗口。
- 对外提供线程安全的目标快照。
- 调用 provider 验证并恢复目标窗口，只向调用方返回成功或失败；具体失败原因由 provider 记录。

Windows 和 macOS 上，该服务在应用正常运行期间始终订阅中间层，因此中间层会保持 native watcher 运行。Linux 上服务不订阅中间层。

`HotkeyBlacklistService` 仍是独立业务服务：

- 配置启用且黑名单非空时，订阅 `IForegroundWindowMonitor.ForegroundWindowChanged`。
- 订阅后立即调用 `GetCurrentForegroundWindow()` 完成第一次黑名单判断，不能等待下一次变化事件。
- 功能关闭时只解除自己的中间层订阅。
- 保留现有防抖和黑名单匹配逻辑。
- 不直接依赖 provider 或 native watcher，也不调用 watcher 的 `Start/Stop`。
- Linux 上仍按配置订阅 monitor，保证前台程序黑名单继续动态生效；这不代表历史记录粘贴功能会订阅或使用这些事件。

### 2.5 排除历史窗口

服务只排除历史窗口本身，不排除 SyncClipboard 当前进程的其他窗口。这样设置窗口、对话框或未来新增的应用窗口仍可在需要时成为普通前台窗口。

为避免 `ForegroundWindowTrackingService` 反向注入 keyed `IWindow` 造成 `HistoryWindow → HistoryViewModel → TrackingService → HistoryWindow` 的 DI 循环，由现有 `HistoryViewModel.Init(IWindow window)` 或历史窗口显示完成事件主动注册历史窗口的原生身份：

```csharp
public interface IWindow
{
    NativeWindowInfo? GetNativeWindowInfo();
}

foregroundWindowTrackingService.SetHistoryWindow(
    window.GetNativeWindowInfo());
```

原生句柄可能只有窗口真正创建或显示后才能获得，因此每次历史窗口完成创建或重新创建 native handle 时都要刷新注册值。如果注册的新身份与当前 `LastExternalWindow` 相同，立即清除该错误目标。

粘贴目标比较使用平台 `NativeWindowInfo` 的窗口级身份：Windows 比较 `HWND` 并核对 PID，macOS 优先比较 Window Number。部分应用无法从 AX 侧读取 Window Number，此时退化为 PID 与窗口标题；如果两边都没有窗口级信息，才退化为应用 PID。窗口位置和尺寸只作为描述信息，任何情况下都不能参与窗口身份判断，因为所有应用都允许移动和调整窗口大小。Linux 的 X11 Window ID 比较只用于 watcher 轮询去重和快捷键黑名单，不用于粘贴目标。

即使平台无法提供 `NativeWindowInfo`，monitor 仍可向 `ForegroundWindowChanged` 广播描述性的窗口详情供黑名单功能使用，但不能将它作为可直接恢复的目标。

### 2.6 并发和失效处理

- `LastExternalWindow` 的读写需要加锁，或使用不可变引用配合原子替换。
- 激活开始时先复制一份目标快照，避免激活期间被中间层事件更新。
- 窗口关闭、进程退出或句柄被复用时，由 provider 在 `TryActivateWindow` 内验证并返回 `false`。
- 激活历史窗口本身触发的中间层事件不能覆盖 `LastExternalWindow`。
- 历史记录粘贴目标不应依赖固定的一秒轮询来捕获快速切换；Windows 和 macOS 使用系统事件。Linux 的轮询只服务于全局快捷键黑名单。

## 3. 复制并粘贴流程

### 3.1 正常路径

建议将 `CopyToClipboard` 的流程拆成“复制”和“粘贴目标切换”两个阶段：

```csharp
public async Task CopyToClipboard(
    HistoryRecordVM record,
    bool paste,
    CancellationToken token)
{
    // 现有的文件和内容有效性检查保持不变。
    var profile = await GetValidatedProfileAsync(record, token);
    if (profile is null)
        return;

    if (!paste)
    {
        CloseHistoryWindowWhenNeeded();
        await localClipboardSetter.Set(profile, token);
        return;
    }

    await localClipboardSetter.Set(profile, token);
    await PasteToLastExternalWindowAsync(token);
}
```

粘贴阶段的伪代码：

```csharp
private async Task PasteToLastExternalWindowAsync(CancellationToken token)
{
    if (!IsTopmost)
    {
        ClearSelectedItem();
        window.ScrollToTop();
        window.Hide();
        await PasteAfterHistoryWindowHiddenOrClosedAsync(token);
        return;
    }

    if (OperatingSystem.IsLinux())
    {
        await PasteWithTemporarilyHiddenHistoryWindowAsync(token);
        return;
    }

    if (foregroundWindowTrackingService
        .TryActivateLastExternalWindow())
    {
        keyboard.Paste();
        return;
    }

    await PasteWithTemporarilyHiddenHistoryWindowAsync(token);
}
```

这里的 `IsTopmost` 只决定一次显式“复制并粘贴”操作是否保持历史窗口显示；`CloseWhenLostFocus` 仍只在 `OnLostFocus` 中生效。

### 3.2 激活成功的判定

以下激活流程只用于置顶模式。以 Windows 为例：

1. 验证 `HWND` 仍有效，且所属 PID 与捕获时一致。
2. 若窗口最小化，按平台策略决定是否恢复。
3. 调用 `SetForegroundWindow`。
4. 在较短的超时时间内检查 `GetForegroundWindow() == targetHwnd`。
5. 确认后才发送粘贴快捷键。

建议轮询 10～20ms 一次，总超时 200～300ms。使用状态确认而不是只写一个固定延迟，既降低竞态，也避免无意义等待。

### 3.3 临时隐藏兜底

以下情况进入兜底：

- 平台无法提供窗口级原生信息。
- 没有记录到外部前台窗口。
- 目标窗口已经关闭或失效。
- 平台拒绝激活目标窗口。
- 激活调用完成，但超时后目标仍未成为前台窗口。

兜底步骤：

1. ViewModel 读取历史窗口是否可见、是否处于活动状态。
2. ViewModel 调用基础 `Hide()` 隐藏历史窗口。
3. 给系统一次焦点回退机会；允许无法确认具体目标，但不能在历史窗口仍然可见时发送粘贴键。
4. 发送 `Ctrl+V` / `Cmd+V`。
5. 等待模拟键盘事件发送完成。
6. ViewModel 在 `finally` 中调用 `Show(wasActive)` 恢复历史窗口及其原有激活状态。

窗口的显示编排属于 ViewModel。`IWindow` 只提供 UI 框架层的基础能力：

```csharp
public interface IWindow
{
    bool IsVisible { get; }
    bool IsActive { get; }
    void Show(bool activate);
    void Hide();
}
```

`HistoryViewModel.RunTemporarilyHiddenAsync` 负责检查窗口是否可见、记录原有激活状态、隐藏、执行操作，并在内部的 `finally` 中恢复。返回 `false` 表示窗口当时不可见；粘贴发送或取消过程中发生异常也不会让历史窗口永久消失。

恢复时应以临时隐藏前的激活状态为准，而不是固定使用“显示但不激活”：如果历史窗口原本处于活动状态，可在所有模拟按键释放后重新激活；如果原本没有激活，则只恢复显示。窗口实例没有销毁，因此位置、尺寸、`Topmost` 和窗口状态由底层 UI 框架自然保留，不在业务层重复记录。

所有平台上，如果历史窗口非置顶，则隐藏并在粘贴后保持隐藏。Linux 不尝试激活目标窗口；历史窗口置顶时才走临时隐藏并恢复路径。

### 3.4 兜底允许无法确认目标

直接激活失败后，只要历史窗口已经成功临时隐藏，兜底路径允许在无法确认当前具体目标窗口的情况下继续发送粘贴快捷键。其依据是系统通常会把焦点回退给历史窗口之前的活动窗口；该路径是显式接受的 best-effort 行为。

兜底不要求 provider 能读取当前窗口，也不因读取结果为 `null` 而取消粘贴。相关信息只用于日志诊断。若历史窗口本身无法隐藏，才应避免把粘贴快捷键发送给仍然持有焦点的历史窗口。

## 4. 平台实现建议

### Windows（WinUI3 和 Avalonia）

- 捕获：`GetForegroundWindow`、`GetWindowThreadProcessId`。
- watcher：WinEvent 回调直接转发事件提供的 `HWND`，不因与上次 `HWND` 相同而去重。
- 验证：`IsWindow`，并再次核对 PID。
- 激活：`SetForegroundWindow`；最小化窗口可结合 `ShowWindowAsync(SW_RESTORE)`。
- 确认：轮询 `GetForegroundWindow`。
- `TryActivateWindow` 失败时由 provider 记录 `HWND`、PID、平台返回值和最后错误等日志，并只向上层返回 `false`。
- WinUI3 与 Avalonia 当前各有 provider，实现可以分别接入相同的 Core 接口；后续再考虑合并 Win32 代码。

### macOS

- 捕获应用：`NSWorkspace.SharedWorkspace.FrontmostApplication`。
- 捕获窗口：通过 Accessibility API 读取主窗口或焦点窗口的标题和边界。
- watcher：native 激活通知直接转发；另以轻量轮询识别同一应用内的主窗口变化。能获得焦点窗口时携带窗口级信息，否则允许应用级信息或 `null`。
- 在历史窗口显示或重新激活之前同步读取一次当前前台窗口，补足同一应用内窗口切换发生在下一次轮询之前的竞态。
- 恢复应用：`NSRunningApplication.Activate`。
- 恢复窗口：重新枚举目标应用窗口，匹配捕获时的信息，设置 AX frontmost/main/focused 属性并执行 `AXRaise`；如果捕获时没有可用窗口身份则退化为应用级恢复，存在身份但无法精确恢复时返回失败并进入隐藏兜底。
- 捕获窗口时优先读取 `AXFocusedWindow`，再回退到 `AXMainWindow`；历史窗口使用原生 `NSWindow.WindowNumber` 注册身份，避免标题或坐标系差异导致误识别。
- `NSRunningApplication.Activate`/`AXRaise` 返回成功只代表请求已被接受。业务层异步等待并再次读取前台窗口，确认具体目标已获得前台后才发送粘贴键；这段等待不能阻塞 macOS 主线程。
- 隐藏兜底发送 SharpHook 按键后不能立即恢复历史窗口；需要保留短暂派发窗口，让 macOS 消费完整的按下/释放事件序列后再恢复。
- 恢复失败由 provider 记录日志并返回 `false`。
- 根据 PID 取得运行中应用后必须核对捕获时的 Bundle Identifier，拒绝已经被其他应用复用的 PID。
- UI 操作必须通过主线程 dispatcher。

### Linux（X11/Wayland）

- `ForegroundWindowTrackingService` 不订阅 monitor；历史记录复制并粘贴功能不监听前台窗口。
- `HotkeyBlacklistService` 仍按配置订阅 monitor；X11 provider 和轮询 watcher 保留窗口身份与描述读取，供该功能使用。
- Linux provider 的 `TryActivateWindow` 固定返回 `false`，X11 Window ID 不用于恢复粘贴目标。
- 删除 `_NET_ACTIVE_WINDOW`、`XSendEvent`、`XFlush` 及相关 ClientMessage 结构。
- “复制并粘贴”固定先隐藏历史窗口并等待系统把焦点交给下层窗口；非置顶时粘贴后保持隐藏，置顶时在按键派发完成后恢复历史窗口。
- 隐藏历史窗口到发送粘贴快捷键之间的等待时间从 runtime `HistoryWindow` 配置的 `PasteDelayAfterWindowHiddenMilliseconds` 读取，默认 200ms；该内部调节项不在设置界面展示。

## 5. 预计代码改动

### Core

- `Models/WindowDetail.cs`
  - 增加 `NativeWindowInfo`。
- `Models/NativeWindowInfo.cs`
  - 新增运行时平台窗口引用基类。
- `Interfaces/INativeWindowController.cs`
  - 增加返回 `bool` 的窗口激活方法，失败日志由 provider 自己记录。
- `Interfaces/INativeForegroundWindowWatcher.cs` 及平台实现
  - 保持 native 监听职责和 `Start/Stop`。
  - 事件增加窗口级 `NativeWindowInfo`，native 事件直接转发不去重，轮询按窗口级身份去重。
- 新增 `IForegroundWindowMonitor` 和 `ForegroundWindowMonitor`
  - 统一控制 watcher 的 `Start/Stop`。
  - 提供主动读取当前前台窗口快照的方法及变化事件。
  - 保持原始回调线程，读取失败广播 `null`，并隔离每个业务回调的异常。
- `Interfaces/IWindow.cs`
  - 只提供 `Show(bool activate)`、`Hide()`、可见性/激活状态和定位等 UI 框架基础能力。
- 新增 `ForegroundWindowTrackingService`。
- `HotkeyBlacklistService`
  - 独立订阅 foreground window monitor，不再直接操作 watcher 或 provider。
- `HistoryViewModel`
  - 注入 tracking service。
  - 重构 `CopyToClipboard` 的窗口恢复和兜底流程。
  - 提供 `ShowWithAutoPosition` 和 `SwitchVisible`，统一编排显示、自动定位、搜索框聚焦和显示前目标捕获。
  - 负责临时隐藏操作，并在 `finally` 中按原激活状态恢复窗口。
  - 保留 `CloseWhenLostFocus` 和现有 `OnLostFocus` 判断。
- `AppCore.ConfigCommonService`
  - 将 `ForegroundWindowMonitor` 注册为 singleton `IForegroundWindowMonitor`。
- `AppCore.ConfigurateUserService`
  - 注册并启动 `ForegroundWindowTrackingService`。

### 平台项目

- WinUI3 `Win32NativeWindowController`
  - 返回 `HWND` 原生信息，实现验证和激活。
- Avalonia Windows `WindowsNativeWindowController`
  - 同上。
- Avalonia Linux `LinuxNativeWindowController`
  - 保留前台窗口身份、描述和边界读取供快捷键黑名单使用，但不实现窗口激活。
- Avalonia Linux `PollingForegroundWindowWatcher`
  - 仍可由快捷键黑名单通过 monitor 启停；历史记录粘贴目标 tracking 不订阅。
- macOS `MacNativeWindowController`
  - 返回 PID 和窗口级匹配信息，实现应用/窗口激活。
- Avalonia、WinUI3、macOS 历史窗口
  - 仅实现 `Show(bool activate)`、`Hide()`、定位和搜索框聚焦等底层窗口能力。

## 6. 测试方案

### Core 单元测试

使用 mock watcher、monitor、provider、tracking service、`IWindow` 和键盘发送器覆盖：

1. 已置顶且 provider 返回可恢复窗口时，先设置剪贴板，再激活窗口，最后发送粘贴键。
2. 已置顶且激活成功时，历史窗口不关闭、不临时隐藏。
3. 已置顶但平台不支持原生窗口时，临时隐藏、粘贴，并尽量恢复原有窗口状态。
4. 已置顶但激活超时时，进入相同兜底流程。
5. 临时隐藏后即使无法确认有效目标，也继续发送粘贴键并恢复历史窗口。
6. 未置顶时所有平台都保持原来的关闭行为，隐藏后直接等待并粘贴，不调用目标窗口激活。
7. `CloseWhenLostFocus = false` 时，失焦仍不关闭。
8. `CloseWhenLostFocus = true` 且未置顶时，失焦关闭。
9. tracking service 不会用历史窗口覆盖最近的外部窗口，但允许记录同进程的其他窗口。
10. 目标窗口关闭或句柄被复用后，服务拒绝激活并进入兜底。
11. 黑名单功能关闭时，前台窗口 tracking 仍持续运行。
12. monitor 的第一个业务订阅者加入时只启动一次 native watcher，最后一个业务订阅者移除时只停止一次。
13. 多个业务服务独立退订 monitor 时，不会提前停止 native watcher。
14. monitor 的事件回调内部退订不会死锁，也不会破坏其他订阅者。
15. 每次 native 变化只读取一次 provider，所有业务订阅者收到同一个 `WindowDetail` 快照。
16. 新订阅者不会立即收到事件；主动调用 `GetCurrentForegroundWindow()` 才获得当前快照。
17. provider 读取失败时所有订阅者收到 `null`。
18. 一个订阅者抛出异常时，其余订阅者仍能收到相同事件。
19. monitor 不切换线程，业务回调运行在 native watcher 的原始回调线程。
20. native watcher 连续报告同一个窗口时，monitor 仍逐次转发；轮询 watcher 只过滤窗口身份完全相同的轮询结果。
21. 只排除历史窗口；同进程内其他 SyncClipboard 窗口仍可被记录。
22. ViewModel 的临时隐藏流程在正常、异常和取消路径上都恢复原有窗口可见性和激活状态。
23. Linux 的历史记录粘贴 tracking 不订阅 monitor；复制并粘贴在非置顶时按“隐藏、粘贴”执行，置顶时按“隐藏、粘贴、恢复”执行；快捷键黑名单仍能按需启动 watcher。

建议为 `VirtualKeyboard` 增加接口或将粘贴发送器抽象化，以便测试调用顺序、直接激活路径和“目标无法确认但临时隐藏成功时仍发送粘贴键”的兜底路径。

### 手工平台验证

- 在两个编辑器窗口间切换，确保恢复的是具体窗口而不只是应用。
- 历史窗口置顶时连续从多条记录交替粘贴，窗口始终显示。
- 目标窗口最小化、关闭或切换桌面时验证降级行为。
- 验证鼠标点击历史记录、键盘回车、快捷键打开历史窗口三种入口。
- 验证 Windows UAC/不同完整性级别导致激活失败时进入隐藏兜底；非置顶时保持隐藏，置顶时在粘贴后恢复历史窗口。
- 验证 macOS 没有辅助功能权限时的应用级恢复和隐藏兜底。
- 验证 X11 与 Wayland 的历史记录粘贴功能不监听或激活前台窗口；非置顶时隐藏后不恢复，置顶时粘贴后恢复；同时验证全局快捷键黑名单监听不回归。

## 7. 验收标准

1. `CloseWhenLostFocus` 配置及其 UI 保持存在，行为不回归。
2. 历史窗口置顶时执行“复制并粘贴”，历史窗口保持显示。
3. Windows 和 macOS 上，内容被粘贴到最近使用的具体外部窗口。
4. 无法记录或激活目标窗口时，隐藏历史窗口仍能完成粘贴；非置顶时保持隐藏，置顶时恢复显示。
5. 兜底路径无法确认具体目标时，只要历史窗口已成功隐藏，仍按 best-effort 方式发送粘贴快捷键。
6. native watcher 保持平台监听职责和 `Start/Stop`，事件升级为窗口级识别；由 foreground window monitor 根据业务订阅状态统一启停，任一业务服务的退订都不会中断仍在使用 monitor 的其他服务。
7. 仅历史窗口自身被排除，同一进程的其他窗口不被误排除。
8. monitor 不切换线程、不串行化事件、不对 native 事件额外去重；读取失败广播 `null`，并隔离订阅者异常。
9. provider 激活接口只返回 `bool`，失败原因由平台 provider 写入日志。
10. 临时隐藏后恢复原有可见性和激活状态；同一窗口实例的位置、尺寸、置顶和窗口状态由 UI 框架自然保留。
11. Linux 的历史记录粘贴功能不监听或激活前台窗口；复制并粘贴在非置顶时隐藏后保持隐藏，置顶时临时隐藏并在粘贴后恢复。全局快捷键黑名单仍可独立监听前台程序。

---

### Ai Design Issue 408 Missing Group Data Retry Design (`docs/ai_design/Issue-408-Missing-Group-Data-Retry-Design.md`)

# Issue #408：Group 本地数据缺失后的空 ZIP 与无限重试

## 文档信息

- 问题来源：[Issue #408](https://github.com/Jeric-X/SyncClipboard/issues/408)
- 核对分支：`master`
- 核对提交：`a8b272e6efca1506cb1679afd4f6d5d0206b461f`
- 核对日期：2026-08-31
- 范围：客户端历史记录上传、Group 传输数据生成、历史传输队列、服务端上传校验、孤儿目录清理

## 结论

Issue 描述的故障链在当前 `master` 上成立。

当一条 `Group` 历史记录仍为 `LocalOnly`，但 `HistoryRecord.FilePath` 指向的一个或全部本地文件已经不存在时，当前代码不会在入队或打包前拒绝该记录。`GroupProfile.PrepareTransferData` 会忽略不存在的路径并创建一个空 ZIP；没有任何条目时，该 ZIP 通常为 22 字节。客户端随后使用旧的 Group 哈希上传它，服务端解包后算出空条目集合的 SHA-256 `E3B0C442...`，与元数据哈希不一致，最终返回 500。

同一个传输任务会每隔 3 秒重试，连续失败 5 次后停止整个历史传输队列。后续重连、配置变更或其他全量同步触发又会调用 `ResumeQueue()`，重新从数据库构造同一条 `LocalOnly` 记录并生成一个带新时间戳的 ZIP，于是形成“生成文件—上传—失败—停队—恢复—再次生成”的循环。

建议一次修复以下四层，形成完整闭环：

1. `GroupProfile` 在流式打包过程中同步校验实际写入内容，并使用临时文件原子生成 ZIP；失败时不留下文件。
2. 上传边界发现本地数据无效后持久化不可用状态；后续自动同步不再重复入队，队列也不把此类错误计入全局连续失败数。
3. 服务端把无效归档、哈希不符等数据错误返回为可识别的 422，并清理暂存文件；客户端将所有远端拒绝交给普通重试和连续失败熔断处理。
4. 孤儿目录清理按 `{Type}_{Hash}` 或规范化完整路径匹配数据库记录，避免主动删除仍被引用的历史目录。

不建议通过删除坏记录、把 500 改成 400、延长重试间隔或只禁止空数组来单独处理。这些办法只能中断部分症状，不能覆盖部分文件缺失、文件内容变化、打包过程竞态和队列级联停止。

## 当前代码核对

| 环节 | 当前实现 | 问题 |
| --- | --- | --- |
| 数据库记录还原 | `MapperExtensions.ToProfile()` 只传入 `Hash`、`Size` 和 `FilePath` | 历史记录没有保存或恢复 Group 的 `_transferDataPath`；源文件失效后没有可复用归档 |
| 自动上传筛选 | `HistorySyncer.SyncPendingUploadsAsync()` 只筛选 `SyncStatus == LocalOnly`，随后调用内容控制检查 | 内容控制不是本地数据完整性检查；缺失路径仍可能通过文件名过滤 |
| ZIP 生成 | `GroupProfile.PrepareTransferData()` 遍历 `_files`，仅处理当时存在的目录或文件 | 不存在的路径被静默跳过；零个条目也会成功返回 ZIP 路径 |
| 上传 | `HistoryTransferQueue.ExecuteUploadAsync()` 直接调用 `PrepareTransferData()` 和 `UploadHistoryAsync()` | 上传前没有完整性前置条件，也没有本地永久错误分类 |
| 服务端校验 | `GroupProfile.ExtractAndVerifyTransferData()` 解包后重新计算哈希 | 校验本身正确；空归档会得到 `E3B0C442...` 并抛出 `InvalidDataException` |
| HTTP 映射 | `HistoryController.Put()` 只把 `ArgumentException` 映射为 400 | `InvalidDataException` 落入通用异常处理并返回 500，还把堆栈返回给客户端 |
| 队列重试 | `HistoryTransferQueue.ExecuteTaskAsync()` 对所有非取消异常执行同一种重试 | 确定性的本地缺失也被当作瞬时网络错误；5 次后停止整个队列 |
| 队列恢复 | `HistoryService.SyncTaskImpl()` 在全量同步前无条件调用 `ResumeQueue()` | 新一轮同步会再次扫描并入队同一条坏记录 |
| 目录清理 | `CleanupOrphanedHistoryFolders()` 用裸 `Hash` 对比目录名 | 实际目录为 `{Type}_{Hash}`，因此仍被数据库引用的目录也可能在 7 天后被误判为孤儿 |

关键代码位置：

- `src/SyncClipboard.Shared/Profiles/GroupProfile.cs`：`PrepareTransferData`、`IsLocalDataValid`、`ExtractAndVerifyTransferData`
- `src/SyncClipboard.Core/Models/MapperExtensions.cs`：`ToProfile`
- `src/SyncClipboard.Core/Utilities/History/HistorySyncer.cs`：`SyncPendingUploadsAsync`
- `src/SyncClipboard.Core/Utilities/History/HistoryTransferQueue.cs`：`ExecuteTaskAsync`、`ExecuteUploadAsync`、`ResumeQueue`
- `src/SyncClipboard.Core/UserServices/ClipboardService/HistoryService.cs`：`SyncTaskImpl`
- `src/SyncClipboard.Core/Utilities/History/HistoryManager.cs`：`CleanupOrphanedHistoryFolders`
- `src/SyncClipboard.Server.Core/Controllers/HistoryController.cs`：`Put`
- `src/SyncClipboard.Server.Core/Services/History/HistoryService.cs`：`SaveTransferDataAsync`

## 故障流程

```mermaid
flowchart TD
    A["DB: Group / LocalOnly"] --> B["ToProfile 恢复旧 Hash 与 FilePath"]
    B --> C["本地路径已全部或部分失效"]
    C --> D["PrepareTransferData 静默跳过失效路径"]
    D --> E["生成零条目 ZIP，通常为 22 字节"]
    E --> F["携带旧 Group Hash POST /api/history"]
    F --> G["服务端解包并计算实际 Hash"]
    G --> H["Hash 不符，返回 500"]
    H --> I["同一任务每 3 秒重试"]
    I --> J["连续失败 5 次，停止整个队列"]
    J --> K["下一次全量同步 ResumeQueue"]
    K --> A
```

在同一个队列任务的 5 次尝试中，`GroupProfile` 通常会复用第一次生成的 ZIP；每次新的全量同步会重新从数据库构造 Profile，从而生成新的时间戳文件名。因此目录数量主要随“全量同步触发次数”增长，而不是严格地每次 HTTP 重试都增长。

## 目标行为

1. 自动同步发现本地数据缺失时，不创建 ZIP、不发起上传，也不影响其他记录传输。
2. Group 任意一个顶层源路径缺失，或现有内容与记录哈希不符时，整组上传失败；不得静默上传剩余子集。
3. 打包过程中发生删除、改名、权限变化或取消时，不留下最终 ZIP 或临时文件。
4. 本地确定性错误不参与瞬时网络重试；远端拒绝统一进入普通重试，并在连续失败达到阈值时触发全局停队。
5. 服务端收到无效 ZIP 或哈希不符的数据时返回稳定的 4xx 错误，不创建 DB 记录，不残留上传文件或解压目录。
6. 默认保留本地数据不可用的记录；数据恢复到原路径且哈希重新匹配后，`LocalOnly` 记录可在后续全量同步中正常上传。用户也可以显式开启“本地文件丢失时自动删除记录”，直接丢弃没有远端副本的 `LocalOnly` 记录。
7. 孤儿清理不得删除任何仍被数据库记录引用的 `{Type}_{Hash}` 目录。

## 解决方案

### 1. 在 Group 打包边界建立强校验

修改 `GroupProfile.PrepareTransferData()`，在进入 ZIP 创建阶段前完成以下快速检查：

1. `_files` 不为 `null` 且至少包含一个顶层路径。
2. 所有顶层路径都存在；只要一个缺失就拒绝整个 Group，不能只打包剩余路径。
3. 检查失败时抛出明确的领域异常，例如 `LocalProfileDataUnavailableException`，携带 Profile ID 和适合本地日志的原因。不要用返回 `null` 表示失败，因为 `null` 在现有接口中表示“不需要传输数据”。

打包前不再单独读取所有源文件执行完整哈希校验。写入每个 ZIP 文件条目时，在同一轮流式读取中同步计算实际写入内容的 SHA-256 和字节数；所有条目写完后，按 Group 哈希规则汇总条目名称、长度和内容哈希，并与 Profile 已有 Hash 比较。这样既能覆盖内容变化和打包竞态，也避免压缩前后重复读取全部内容。

ZIP 生成改为临时文件加原子提交：

1. 在目标工作目录中创建唯一的 `*.tmp` 文件，使用 `FileMode.CreateNew`。
2. 记录实际写入的 ZIP 条目及文件内容哈希；零条目时抛出本地数据异常。空目录本身应写入目录条目，因此合法的“一个空目录”不会被误判。
3. 汇总实际写入条目的 Group Hash，并与预期 Hash 比较；不重新打开或解压临时 ZIP。
4. 校验成功并关闭 ZIP 和文件流后，再把临时文件移动为最终的 `File_*.zip`。
5. 只有最终移动成功后才设置 `_transferDataName` 和 `_transferDataPath`。
6. 任何异常或取消都在 `finally` 中删除临时文件；不得覆盖或暴露半成品。

当前不维护通用的传输数据可信状态，也不提供跳过准备阶段校验的标志。每次直接调用 `PrepareTransferData()` 都必须重新计算待返回数据的 Hash 并与 Profile Hash 比较；Group 的既有 ZIP 也要逐条读取并按 GroupEntry 规则重新计算。既有 ZIP 校验失败时不再复用它，而是回退到 `_files` 重新生成并校验新 ZIP；只有 `_files` 同样缺失或 Hash 不匹配时才抛出本地数据异常。失败的既有 ZIP 可能来自外部路径，因此回退时只解除引用，不主动删除原文件。生产代码中没有 `SetTransferData(..., verify: true)` 后立刻调用 `PrepareTransferData()` 的路径，因此暂不增加“不强制验证”参数。`PrepareDataWithCache()` 是明确例外：`GetCachedFilePathAsync()` 已核对缓存文件的大小和原始 SHA-256，缓存命中后调用 `SetTransferData(..., verify: false)` 并直接返回路径，不重复执行 Profile 语义 Hash 校验。

这层是最后一道本地防线，必须独立成立。即使调用方忘记预检，也不能生成空 ZIP。

### 2. 在上传边界持久化本地不可用状态

本地数据检查由各个 Profile 的 `PrepareTransferData()` 在真正准备上传数据时负责：File/Image 重新计算文件名与内容组合 Hash，Text 重新计算内联文本或传输文件 Hash，Group 对既有 ZIP 重新读取条目计算 Hash、对新 ZIP 则复用打包时同步得到的条目 Hash。`HasTransferData == true` 时必须返回当前可读取且 Hash 匹配的文件路径，准备阶段确认缺失或 Hash 变化时抛出 `LocalProfileDataUnavailableException`。`HistoryTransferQueue.ExecuteUploadAsync()` 不再额外执行快速预检、路径存在性检查或本地异常捕获；上传适配器也会直接打开传入路径，不会静默省略已经丢失的文件。若文件在准备完成后、适配器打开前临时消失，原始 `FileNotFoundException`/`DirectoryNotFoundException` 进入普通重试，不立即修改记录；下一次重试重新执行 `PrepareTransferData()`，仍无法恢复时才抛出本地不可重试异常。`ExecuteTaskAsync()` 捕获该异常后，通过 `HistoryManager.HandleLocalFileUnavailableAsync()` 将上传记录的 `IsLocalFileReady` 置为 `false`；持久化失败只记录日志，不影响当前任务按不可重试错误结束。该方法只标记状态，不读取自动删除配置，也不直接删除记录。

`IsLocalFileReady == false` 不再单独表示“服务器有数据”。相关扫描必须同时参考 `SyncStatus`：

- `LocalOnly + false`：本地数据不可用，不自动上传、不下载，也不作为远程孤儿删除；后台刷新时根据自动删除开关决定保留或删除；
- `Synced/NeedSync + false`：历史同步开启时可以进入下载流程；关闭同步时按既有远端状态清理流程删除；
- 后续同步直接跳过 `LocalOnly + false`，不在后台重新检查本地路径。历史记录窗口为这类记录直接显示“重新读取本地文件”菜单，不在构建菜单时读取磁盘；只有用户点击该菜单并通过完整校验后，才将 `IsLocalFileReady` 恢复为 `true`，记录可在后续同步中重新入队。

这样既能持久化本地缺失状态，也不会把上传失败转换为不存在远端数据的下载循环。

历史记录设置页提供默认关闭的 `AutoDeleteMissingLocalFiles` 开关。删除决策统一放在 `HistorySyncer` 的后台刷新流程，不由 `HistoryManager` 的配置加载或本地数据检查直接触发。配置变化会调用 `HistoryService.TriggerSyncTask()`，因此修改开关本身也会触发一次相同的后台处理。

关闭历史同步时，`RemoveRemoteHistorys()` 先将所有 `Synced/NeedSync` 转为 `LocalOnly`，再统一复用本地记录规则：`IsDeleted` 始终删除；`IsLocalFileReady == false` 仅在 `AutoDeleteMissingLocalFiles` 开启时删除，否则保留。同步开启时则先合并远端记录、处理真正的远端孤儿，再对剩余 `LocalOnly` 记录应用相同规则。这样关闭同步后不会残留 `Synced/NeedSync`，删除结果也不再取决于转换前的同步状态。

### 3. 将本地不可重试错误从队列熔断中隔离

`HistoryTransferQueue.ExecuteTaskAsync()` 当前对所有异常执行相同的 3 秒重试和全局连续失败计数。应至少区分：

| 错误类别 | 示例 | 队列行为 |
| --- | --- | --- |
| 本地不可重试 | 文件不存在、Group 哈希已变化、零条目归档 | 当前任务立即 `Failed`，通知 UI 并移除；不增加 `_consecutiveFailures` |
| 临时本地打开失败 | 准备完成后文件或目录在适配器打开前消失 | 进入普通重试；下一次 `PrepareTransferData()` 仍无法恢复时再转为本地不可重试错误 |
| 远端失败 | 服务端返回 400/422 或其他拒绝 | 与网络和未知远端错误一致，进入普通重试并增加 `_consecutiveFailures`；连续 5 次后停止队列 |
| 瞬时错误 | 连接失败、超时、408、429、可恢复的 5xx | 按现有策略重试，并参与网络故障熔断 |
| 取消 | 用户取消、服务停止 | 当前任务 `Cancelled`，不重试 |

建议新增两个明确异常类型：

- Shared 层的 `LocalProfileDataUnavailableException`，由 Profile 打包代码抛出；
- Core 层的 `RemoteHistoryDataRejectedException`，由 `OfficialAdapter` 在识别服务端 400/422 错误码后抛出，并交给队列的普通失败分支。

队列只为本地数据不可用异常提供独立的不可重试完成分支。上传任务在该分支尝试持久化 `IsLocalFileReady = false`，失败时记录日志并继续；随后统一设置 `ErrorMessage`、`CompletedTime`、`CompletionSource`，调用 `NotifyStatusChanged()` 和 `RemoveTask()`。远端拒绝不修改 `IsLocalFileReady`，由普通重试与熔断路径处理。

完成异常分类后，`SyncTaskImpl()` 中无条件 `ResumeQueue()` 不会重新拉起这条坏记录，因为自动扫描会直接跳过 `LocalOnly + false`。是否进一步把 `ResumeQueue()` 限制到“网络恢复、账号切换或用户显式重试”可以作为队列可靠性增强，但不是关闭 #408 的必要改动，建议不要与本次修复强耦合。

### 4. 服务端返回可识别的语义错误并清理暂存数据

服务端必须继续保留独立校验，不能信任新客户端一定会先做本地检查。

建议让上传数据校验失败返回 `422 Unprocessable Entity`，响应使用 `ProblemDetails`，并提供稳定错误码，例如：

```json
{
  "status": 422,
  "title": "History transfer data is invalid",
  "code": "history_data_invalid",
  "detail": "Group data hash mismatch."
}
```

实现时不要简单扩大 `HistoryController.Put()` 的通用 `catch`。更安全的做法是让 `HistoryService.SaveTransferDataAsync()` 把预期的数据校验异常包装为领域异常，Controller 只把该领域异常映射为 422；其他未知异常仍为 500。生产响应不应返回服务器堆栈。

`SaveTransferDataAsync()` 还需要使用暂存路径，并在校验失败时清理：

- 上传的 ZIP 文件；
- `GroupProfile.SetTransferData()` 已创建的解压目录；
- 变空的工作目录。

只有 `SetTransferData(..., verify: true)` 成功后，才把文件移动到最终持久化位置并写入数据库。这样旧客户端继续发送坏数据时，服务端也不会堆积垃圾文件。

`OfficialAdapter.UploadHistoryAsync()` 识别 400/422 后抛出 `RemoteHistoryDataRejectedException`。该异常保留远端拒绝的语义，但队列仍按普通远端失败重试；客户端在 `PrepareTransferData()` 阶段保证待发送数据正确，因此远端拒绝不反向修改本地文件就绪状态。

### 5. 修正孤儿历史目录匹配

`CleanupOrphanedHistoryFolders()` 不应把数据库裸 `Hash` 与目录 basename 比较。建议从数据库读取 `(Type, Hash)`，用 `Profile.QueryGetWorkingDir(historyFolder, type, hash)` 生成规范目录，并比较规范化完整路径：

- Windows 使用 `OrdinalIgnoreCase`；
- macOS/Linux 为避免误删，可统一使用保守的 `OrdinalIgnoreCase`，代价只是极少量假阴性清理；
- 删除前仍保留 7 天保护期；
- 无法解析为 Profile 工作目录的文件夹宁可保留并记录日志，不应冒险删除。

这项修复不是空 ZIP 的直接拦截点，但它修复了一个能够主动制造“DB 仍在、目录已失效”前置条件的代码错误，应与 #408 一起完成。

### 6. 不采用的替代方案

| 方案 | 不采用原因 |
| --- | --- |
| 强制自动删除所有缺失数据的历史记录 | 改为默认关闭的用户设置，并仅控制没有远端副本的 `LocalOnly` 记录；远端状态仍遵循历史同步开关的既有生命周期 |
| 把记录直接标为 `Synced` | 服务器实际上没有记录，会造成永久的状态谎报 |
| 只判断 `_files.Length == 0` | 无法覆盖全部路径失效、部分路径失效、内容变化和打包竞态 |
| 只把服务端 500 改为 400/422 | 不能修复客户端本地数据准备问题；客户端仍会按统一远端失败策略重试 4xx |
| 只增加指数退避 | 只能降低频率，不能终止确定性失败，也会继续占用队列和磁盘 |
| 为 `HistoryRecord` 新增 TransferDataFile 字段作为主修复 | 只有之前已经成功生成并保存归档时才有帮助；首次上传前源文件丢失仍无法恢复，还引入数据库迁移 |

## 手动验证方案

### 1. 测试准备

使用专用测试客户端和测试服务端，不要在包含重要历史记录的正式数据目录上操作。

1. 使用“打开应用数据文件夹”确认当前客户端数据目录；项目允许用户更改数据位置，不要只依赖平台默认路径。
2. 完全退出客户端，备份整个应用数据目录。
3. 准备一个独立 Official Server（内置服务器或独立服务器均可），开启客户端历史记录和历史同步。
4. 清空测试服务器历史，或使用全新账号，确保目标 Group 在服务端不存在。
5. 客户端日志位于应用数据目录的 `log/`；本地历史数据库为 `data/history.db`；历史文件位于 `file/history/`。
6. 测试中先关闭历史同步，创建 Group 记录，再删除文件，最后开启同步。这样可以保证记录仍为 `LocalOnly`，且服务端没有提前收到它。

可在客户端退出后用 SQLite 确认最新记录：

```sql
SELECT ID, Type, Hash, SyncStatus, IsLocalFileReady, FilePath
FROM HistoryRecords
ORDER BY ID DESC
LIMIT 10;
```

记录对应的工作目录为：

```text
<应用数据目录>/file/history/Group_<HASH>/
```

### 2. 修复前基线复现

1. 关闭历史同步。
2. 创建两个小文件 `a.txt`、`b.txt`，内容分别不同，在文件管理器中同时复制，等待历史面板出现一条 Group 记录。
3. 完全退出客户端。
4. 查询 `history.db`，确认该记录 `SyncStatus = LocalOnly`，记录它的 Hash。
5. 删除精确目录 `file/history/Group_<HASH>/`，保留数据库。该操作只允许在专用测试数据目录中执行。
6. 启动客户端并开启历史同步。
7. 等待至少 20 秒，观察客户端日志、服务端日志和 Group 目录。
8. 通过关闭/开启历史同步、重连服务器或重启客户端，再触发 3 次全量同步。

当前未修复版本的预期现象：

- Group 目录被重新创建，每轮全量同步新增一个 `File_*.zip`；零条目 ZIP 通常为 22 字节。
- 服务端日志出现 `Group data hash mismatch`，Actual 为 `E3B0C442...`。
- 客户端日志出现连续失败 `1/5` 到 `5/5`，随后“队列因连续失败5次已停止”。
- 再次触发全量同步后同一 Profile ID 又开始失败。
- 服务器没有成功创建该历史记录；关闭“本地文件丢失时自动删除记录”时，数据库仍保留该 `LocalOnly` 记录。

macOS/Linux 可检查 ZIP：

```bash
find "<应用数据目录>/file/history/Group_<HASH>" -type f -name '*.zip' -size 22c -print
```

Windows PowerShell 可检查大小：

```powershell
Get-ChildItem "<应用数据目录>\file\history\Group_<HASH>" -Filter *.zip |
    Select-Object Name, Length, LastWriteTime
```

### 3. 修复后核心场景

在修复版本上重新使用干净的客户端和服务器数据，重复基线步骤，并验证：

- Group 工作目录内没有新增 ZIP，也没有 `*.tmp` 文件；目录不存在也属于正常结果。
- 客户端没有对该 Profile 发起 `POST /api/history`。
- 日志只出现可识别的本地跳过原因，不出现 3 秒重试和 `5/5` 停队。
- 连续触发 10 次全量同步后，ZIP 数量、临时文件数量和服务端请求数量均不增长。
- 另建一条有效的 `LocalOnly` 文本或文件记录，它仍能立即上传，证明坏 Group 没有阻塞队列。
- 重启客户端后结果不变。

随后恢复原来的两个文件到数据库记录指向的原路径，并保证名称和内容完全一致，再触发全量同步。预期该 Group 能上传成功，状态变为 `Synced`。若只恢复路径但内容不同，仍应被拒绝。

### 4. 部分缺失与内容变化

分别执行以下用例，每个用例都从一条新的、尚未上传的 Group 记录开始：

| 用例 | 操作 | 预期 |
| --- | --- | --- |
| 仅缺失一个文件 | Group 含 `a.txt`、`b.txt`，删除 `b.txt` | 整组跳过；不得只上传 `a.txt`；无 ZIP、无 HTTP 请求 |
| 文件内容变化 | 创建记录后修改 `a.txt` 内容 | 打包边界完整哈希校验失败；无最终 ZIP；不请求服务器 |
| 文件改名 | 创建记录后把 `a.txt` 改名 | 按本地缺失处理；不上传剩余条目 |
| 空目录 | Group 仅含一个真实存在的空目录 | 生成包含目录条目的合法 ZIP并上传成功，不应误判为零条目 |
| 零字节文件 | Group 仅含一个零字节文件 | 生成包含文件条目的合法 ZIP并上传成功 |
| 打包中删除 | 对较大 Group 开始上传后立刻删除一个源文件 | 当前任务不可重试地失败；无最终 ZIP、无残留临时文件；其他任务继续 |
| 打包中取消 | 上传时关闭历史同步或退出客户端 | 临时文件被清理；下次启动没有把半成品当作有效归档 |

### 5. 服务端防御性校验

使用旧客户端或直接 HTTP 请求验证服务端独立防线。先创建一个合法但零条目的 ZIP：

```bash
python3 -c "import zipfile; zipfile.ZipFile('empty.zip', 'w').close()"
```

向测试服务器发送 Group 元数据，并确保 `data` 是 multipart 的最后一个字段：

```bash
curl -i -u '<用户名>:<密码>' '<服务器地址>/api/history' \
  -F 'hash=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' \
  -F 'type=Group' \
  -F 'createTime=2026-08-31T00:00:00Z' \
  -F 'lastModified=2026-08-31T00:00:00Z' \
  -F 'lastAccessed=2026-08-31T00:00:00Z' \
  -F 'starred=false' \
  -F 'pinned=false' \
  -F 'version=0' \
  -F 'isDeleted=false' \
  -F 'text=invalid group' \
  -F 'size=0' \
  -F 'hasData=true' \
  -F 'data=@empty.zip;type=application/octet-stream'
```

修复后的预期结果：

- HTTP 状态为 422，而不是 500；
- 响应包含稳定错误码 `history_data_invalid`；
- 响应不包含服务器堆栈；
- 服务端 DB 没有新增记录；
- 服务端持久化目录没有留下 ZIP、解压目录或空工作目录；
- 客户端把该响应作为远端失败进入普通重试；连续失败达到阈值后按既有逻辑停止队列，但不得把记录反向标记为本地文件缺失。

再补充一个“ZIP 含条目但元数据 Hash 错误”的请求，预期结果相同。最后上传一个真实有效的 Group，确认正常路径仍返回 200。

### 6. 孤儿目录清理验证

该任务有 7 天保护期，建议用可注入时钟或提取出的纯判断方法做自动化测试；手动测试可以在 Debug 构建中临时把截止时间注入为当前时间，不能把测试常量提交到正式代码。

准备以下目录后运行一次清理任务：

| 目录 | DB 记录 | 年龄 | 预期 |
| --- | --- | --- | --- |
| `Group_<有效Hash>` | 有相同 `Type + Hash` | 超过 7 天 | 保留 |
| `File_<有效Hash>` | 有相同 `Type + Hash` | 超过 7 天 | 保留 |
| `Group_<孤儿Hash>` | 无 | 超过 7 天 | 删除 |
| `Group_<新孤儿Hash>` | 无 | 不足 7 天 | 保留 |
| 无法解析的自定义目录 | 无 | 超过 7 天 | 保守保留并记录日志 |

重点确认修复前的裸 Hash 比较已经消失，目录判断使用 `Type + Hash` 或规范化完整路径。

### 7. 跨平台冒烟

问题路径不依赖 UI，但文件路径和文件系统大小写规则不同。至少验证：

- macOS Apple Silicon：原 issue 环境，执行完整核心场景；
- Windows：验证反斜杠路径、被占用文件和 PowerShell 文件计数；
- Linux：分别在常见大小写敏感文件系统上验证目录匹配和临时文件清理。

如果资源有限，macOS 执行全矩阵，Windows/Linux 至少执行“全部缺失、部分缺失、有效 Group、孤儿目录保留”四项。

## 建议自动化测试

手动验证通过后，应补充回归测试，防止将来从其他入口绕过预检：

1. `GroupProfile.PrepareTransferData`：空 `_files`、全部缺失、部分缺失、内容变更均抛出本地数据异常且目录无 ZIP/临时文件。
2. `GroupProfile.PrepareTransferData`：空目录和零字节文件能够生成非零条目归档；既有 ZIP 每次重算 Hash，校验失败但 `_files` 有效时重新生成，`_files` 也无效时抛出本地数据异常。
3. `FileProfile`/`ImageProfile`/`TextProfile.PrepareTransferData`：本地内容在 Profile Hash 建立后发生变化时抛出本地数据异常。
4. 缓存入口：`GetCachedFilePathAsync()` 拒绝文件大小或原始 SHA-256 已变化的缓存；命中后可直接设置并返回路径，不重复调用 `PrepareTransferData()`。
5. `HistoryTransferQueue`：首次发现无效 `LocalOnly` 后将 `IsLocalFileReady` 置为 `false`；`HistorySyncer` 信任该状态并跳过记录，只有用户点击历史记录窗口右键菜单的“重新读取本地文件”并通过完整校验后才恢复状态。
6. `HistoryManager`：本地数据不可用时只把 `IsLocalFileReady` 标为 `false`，不直接删除记录。
7. `HistorySyncer`：关闭同步后先将全部 `Synced/NeedSync` 转为 `LocalOnly`，再根据 `AutoDeleteMissingLocalFiles` 统一决定是否删除本地数据不可用记录；同步开启时在远端合并后执行同一规则。
8. `HistoryTransferQueue`：本地不可重试异常只执行一次且不增加全局连续失败数；远端 422 进入普通重试并参与连续失败熔断。
9. `HistoryController`/`HistoryService`：空 ZIP、损坏 ZIP和哈希不符返回 422，DB 与文件系统无残留。
10. `CleanupOrphanedHistoryFolders`：引用目录不会删除，超过保护期的真实孤儿会删除。

测试使用 MSTest；涉及文件系统的用例全部使用独立临时目录，并在测试结束后清理。服务端接口测试应使用临时 SQLite 数据库和临时持久化目录，不能读取开发者真实应用数据。

## 验收标准

- [ ] 本地 Group 数据全部或部分缺失时，0 个新 ZIP、0 个临时文件、0 个上传请求。
- [ ] 本地 Group 内容与记录哈希不一致时，不生成最终归档，不访问服务器。
- [ ] 本地不可重试错误不进入重试且不增加全局连续失败数；服务端拒绝进入普通重试并参与连续失败熔断。
- [ ] 坏 Group 与有效记录同时存在时，有效记录仍能上传。
- [ ] 连续 10 次全量同步和一次客户端重启后，磁盘文件数与服务端请求数不增长。
- [ ] 服务端无效数据响应为 422，响应无堆栈，DB 与持久化目录无残留。
- [ ] 合法 Group、空目录 Group、零字节文件 Group 均可正常上传。
- [ ] 自动删除开关默认关闭且只在后台刷新时控制 `LocalOnly + !IsLocalFileReady`；关闭同步前的 `Synced/NeedSync` 状态不会影响最终决定。
- [ ] 仍被数据库引用且超过 7 天的 `{Type}_{Hash}` 目录不会被孤儿清理删除。
- [ ] macOS 完整验证通过，Windows/Linux 冒烟验证通过。

满足以上条件后，可以关闭 Issue #408。队列的指数退避、持久化错误详情或更醒目的 UI“本地数据缺失”提示可以作为后续增强，不应阻塞本问题修复。

---

### Ai Design Transfer Data Hash Design (`docs/ai_design/Transfer-Data-Hash-Design.md`)

# `TransferDataFile`/`transferDataHash` 与 Group ZIP 快速校验设计

## 文档信息

- 核对分支：`codex/transfer-data-hash`
- 核对基线提交：`3002e15b26ba`
- 核对日期：2026-09-06
- 范围：客户端与服务端历史数据库、客户端传输文件路径持久化、历史与普通剪贴板同步 DTO、传输文件生成/接收、Group ZIP 校验
- 实现状态：已在 `codex/transfer-data-hash` 分支完成配套代码、EF Core migration 与测试

## 结论

在客户端 `HistoryRecord` 中新增可空字段 `TransferDataFile` 和 `TransferDataHash`，服务端 `HistoryRecordEntity` 新增可空字段 `TransferDataHash`（服务端已经有 `TransferDataFile`）。`TransferDataHash` 的数据库列名为 `TransferDataHash`；历史文件 API 通过 `X-SyncClipboard-Transfer-Data-Hash` 请求/响应头传输，普通剪贴板同步继续使用 `ProfileDto.transferDataHash`。该字段记录“本次实际传输文件的完整字节流”的 SHA-256，统一使用 64 位大写十六进制字符串。

客户端 `TransferDataFile` 持久化本地传输文件路径，只供本机恢复文件引用，不进入历史同步 DTO。工作目录内文件按现有规则保存相对路径，工作目录外的本地源文件可以保留绝对路径。客户端重启后可通过它重新定位 Group ZIP，再使用 `TransferDataHash` 做整包快速校验。

`TransferDataHash` 与现有 `Profile.Hash` 的职责不同：

- `Profile.Hash` 标识剪贴板内容的语义，用于去重和构造 Profile ID；
- `TransferDataHash` 标识一个具体传输文件，用于检查文件是否与先前验证过的传输对象完全一致。

Group 的快速路径不能只检查字段和 ZIP 文件是否非空。必须重新计算当前 ZIP 的 SHA-256，并与可信的 `TransferDataHash` 相等，才能跳过逐条解压、计算文件内容哈希和重建 Group 语义哈希。若当前操作需要解压，ZIP 路径穿越防护和安全解压不能跳过；若只是复用或发送已验证 ZIP，则不应为了校验而打开或枚举 ZIP 内部结构。

首次建立一个 Group 的 `Profile.Hash` 与 `TransferDataHash` 绑定时，仍必须完成现有的内部结构校验。特别是服务端不能信任上传请求中由客户端声明的 `transferDataHash`，否则恶意或有缺陷的客户端可以给任意 ZIP 自行计算 SHA-256，并借此绕过 Group 语义校验。客户端可以信任官方服务器已经验证并持久化的绑定；WebDAV/S3 中的绑定来自其他客户端，仍必须由当前客户端完成首次语义验证。

## 字段语义

### 两类哈希

| Profile 类型 | `Profile.Hash` | `TransferDataHash` |
| --- | --- | --- |
| Text（内联短文本） | UTF-8 文本的 SHA-256 | `null`，因为没有传输文件 |
| Text（长文本文件） | UTF-8 文本内容的 SHA-256 | 传输文本文件完整字节的 SHA-256；按当前无 BOM UTF-8 写入规则应与 `Profile.Hash` 相等 |
| File | `SHA256("文件名\|文件内容SHA256")` | 文件内容字节的 SHA-256，通常不等于 `Profile.Hash` |
| Image | 与 File 相同 | 图片文件字节的 SHA-256，通常不等于 `Profile.Hash` |
| Group | 规范化条目名称、长度和各文件内容哈希汇总得到的语义 SHA-256 | 最终 ZIP 文件完整字节的 SHA-256，通常不等于 `Profile.Hash` |

这里“Text/File/Image 与文件本身的 hash 一致”指 `TransferDataHash` 等于传输文件字节的 SHA-256，不表示 File/Image 的 `Profile.Hash` 要改成文件内容 SHA-256。现有 Profile 哈希算法和 Profile ID 均保持不变。

同一个 Group 内容可以因压缩级别、条目时间戳、ZIP 实现或条目写入细节不同而产生不同的 ZIP 字节，因此也可以有不同的 `TransferDataHash`；它们仍然共享同一个 `Profile.Hash`。`TransferDataHash` 不能参与历史记录去重、工作目录命名或 Profile ID 计算。

### 空值与本地绑定状态

字段使用 `string?`，不使用空字符串表达未知状态：

- `null`：没有传输文件，或旧记录尚未建立传输文件哈希；
- 64 位 SHA-256：持久化时表示已经与当前本地或服务端文件验证绑定的传输文件哈希；普通同步 DTO 中的远端声明值仍不自动可信；
- 其他格式：视为无效输入，不得进入快速路径。

Profile 不保存额外的绑定信任标记。由 DTO 创建的 Profile 可以在内存中暂存远端声明的 `TransferDataHash`，但此时没有本地 `TransferDataFile`，不得持久化该值。下载层完成整包哈希验证并成功调用 `SetTransferData()` 后，路径与哈希同时存在即表示绑定已经建立。Profile 仅在内存中记录本次已经确认过的传输文件路径，用于避免 `Localize()` 立即重复扫描同一个文件；该路径缓存不持久化。由持久化记录重建 Profile 时，路径与非空哈希表示绑定以前已经建立，本地文件在再次使用时通过显式的 `IsTransferDataValid()` 重新检查。

## 当前实现与改造原因

当前 `GroupProfile` 有两条昂贵路径：

1. `PrepareTransferData()` 复用既有 ZIP 前，通过 `VerifyExistingTransferArchiveAsync()` 打开归档、逐条读取内容并重建 Group 哈希；
2. 外部先验证 ZIP SHA-256，再调用 `SetTransferData(path, transferDataHash, verify: true, ...)`，解压 ZIP 后通过 `CaclHashAndSize()` 重新读取解压后的全部文件并重建 Group 哈希。

两条路径都只能证明 ZIP 的内容仍对应 `Profile.Hash`，但没有持久化“这一个已经验证过的 ZIP 文件”的标识。进程重启或从数据库重建 Profile 后，无法通过整包哈希识别相同 ZIP，只能重复读取内部文件。

服务端数据库已经保存 `TransferDataFile`，所以新增哈希后可以在进程重启后继续识别同一个 ZIP。客户端当前只保存 Group 解压后或原始的 `FilePath`，会丢失 ZIP 引用；新增客户端 `TransferDataFile` 后，`HistoryRecord -> Profile` 重建必须同时恢复 ZIP 路径和哈希，从而让客户端也能跨进程复用同一个已验证 ZIP。

服务端实体中目前已有 `TransferDataSha256` 和 `TransferDataMd5` 两个非空字符串字段，但生产代码没有读写它们，也没有把它们映射到 Profile、历史 DTO 或客户端数据库。新字段采用明确的统一名称 `TransferDataHash`。本次不删除旧列，避免把性能优化与破坏性数据库清理耦合；也不直接信任或复制旧列值。后续可在独立迁移中清理旧列。

## 数据模型与协议

### 持久化模型

修改以下模型：

- `SyncClipboard.Core.Models.HistoryRecord`：新增 `string? TransferDataFile` 和 `string? TransferDataHash`；
- `SyncClipboard.Server.Core.Models.HistoryRecordEntity`：新增 `string? TransferDataHash`；
- `SyncClipboard.Shared.Profiles.Models.ProfilePersistentInfo`：新增 `string? TransferDataHash`；
- `Profile`：保存当前传输文件哈希，并仅缓存当前进程中已经校验过的本地文件路径，以避免 `Localize()` 紧接着重复计算文件哈希；不保存独立的绑定信任状态。

所有 Profile 的持久化、构造和 `CopyTo()` 都要传递该字段。重点映射位置包括：

- 客户端 `HistoryManager.ToHistoryRecord()` 与 `MapperExtensions.ToProfile()`，同时映射 `TransferDataFile` 和 `TransferDataHash`；
- 服务端 `Mapper.ToHistoryEntity()` 与 `Mapper.ToProfile()`；
- 服务端已有记录替换传输文件时的字段复制；
- 客户端新增本地记录、下载完成、上传成功和远端记录合并。

客户端 `TransferDataFile` 的类型语义如下：

| Profile 类型 | 客户端 `TransferDataFile` | `FilePath` |
| --- | --- | --- |
| Text（内联短文本） | `null` | 空数组 |
| Text（长文本文件） | 传输文本文件的持久化路径 | 可以继续保存同一文本文件路径 |
| File/Image | 传输文件的持久化路径 | 可以继续保存同一文件路径 |
| Group | ZIP 的持久化路径，正常为工作目录内相对路径 | 保存原始或解压后的顶层文件/目录路径，不用 ZIP 替换 |

客户端字段不得保存服务端路径、下载 URL 或由远端元数据指定的持久化路径。`Profile.Persist()` 继续通过 `GetPersistentPath()` 处理路径：工作目录内保存相对路径，工作目录外保留本地绝对路径；`Profile.Create()` 继续通过 `GetFullPath()` 在规范的 `{Type}_{Profile.Hash}` 工作目录下恢复相对路径。无论相对还是绝对，字段值只能来自本地 Profile 持久化结果，不能从远程 DTO 写入。

本地 `TransferDataFile` 与 `TransferDataHash` 必须在同一次数据库保存中更新。替换 Group ZIP 或其他本地传输文件时，不得出现新路径配旧哈希。删除或判定本地传输文件不可用时同时清空两者；不能为了后续下载而提前保存服务端声明的哈希。

客户端一条记录可能同时具有本地内容和服务端副本，而相同 Group 的本地 ZIP 与服务端 ZIP 字节不一定相同。采用以下归属规则避免一个字段同时代表两个文件：

- `IsLocalFileReady == true` 时，字段描述客户端当前可用于传输的本地文件；没有已生成/下载的本地传输文件时允许为 `null`；
- `IsLocalFileReady == false` 时，`TransferDataFile` 和 `TransferDataHash` 都是 `null`；
- 同步合并远端元数据时，永远不从 DTO 更新本地文件路径或哈希；
- 下载文件时从同一个 HTTP 响应取得响应头和文件体，流式校验成功后才把文件路径与哈希一起持久化。

### 历史文件传输协议

`HistoryRecordDto` 不包含 `TransferDataHash` 或 `TransferDataFile`。历史列表、单条元数据查询和 SignalR 变更通知只同步内容语义与记录状态，因此 `SyncRemoteHistoryAsync()` 不会在文件尚未下载时建立任何本地文件哈希绑定。

历史文件上传和下载使用同一个协议头：

```http
X-SyncClipboard-Transfer-Data-Hash: <64 位 SHA-256>
```

- 上传：客户端在 `POST /api/history` 的 multipart 请求头中发送该值，文件仍位于 `data` part；hash 与文件体属于同一次请求；
- 下载：服务端在 `GET /api/history/{profileId}/data` 的响应头中发送该值，文件位于同一次响应体；
- 请求头或响应头存在时必须恰好有一个值，并规范化为大写；格式错误拒绝本次传输；
- 上传声明与服务端实际收到的文件字节不匹配时返回稳定的 422 `history_data_invalid`；即使匹配，新的 Group 记录仍必须完成内部语义校验；
- 下载时客户端先写入唯一临时文件，同时计算 SHA-256；匹配后才原子移动到本地正式路径并建立持久化绑定；
- 旧客户端不发送上传头时，服务端仍按完整校验路径处理并自行计算；旧服务端不返回下载头时，新客户端回退到原有完整 Profile 语义验证。

`TransferDataFile` 仍是客户端或服务端各自的本地内部状态，不通过 API 交换。服务端最终持久化自己计算出的哈希，而不是直接持久化上传请求头的字符串。

原生客户端读取该响应头不受 CORS 限制。若未来允许跨域浏览器 JavaScript 直接调用下载接口，服务端或反向代理还需要暴露 `Access-Control-Expose-Headers: X-SyncClipboard-Transfer-Data-Hash`；常规反向代理应同时保留该自定义请求头和响应头。

### 普通剪贴板同步 DTO

`ProfileDto` 同样新增可空 `TransferDataHash`，JSON 字段名为 `transferDataHash`。值为 `null` 时序列化省略该字段，因此旧客户端、旧服务端以及已有 WebDAV/S3 `SyncClipboard.json` 均保持兼容。

普通同步的 File/Image/Text/Group 在生成或验证传输文件后，由 `ToProfileDto()` 输出当前哈希。通用 `Profile.Create(ProfileDto)` 只把声明值作为内存中的下载候选值，不设置本地文件路径，也不会将其写入历史数据库。官方服务器与 WebDAV/S3 的来源差异不在 Profile 创建阶段表达，而在下载层调用 `SetTransferData()` 时决定是否继续验证 Profile 语义。

`SetTransferData(path, verify, ...)` 保持原接口。新增带 `transferDataHash` 的重载，传入值必须是调用方已经对实际文件验证过的 SHA-256；该重载不读取文件，也不把传入值与 Profile 内暂存的远端声明值重复比较，`verify` 只控制是否继续验证 Profile 语义。WebDAV/S3 在下载层验证整包哈希后使用新重载的 `verify: true`，继续执行完整 Profile 语义验证；官方服务器在下载层验证响应头与文件后使用新重载的 `verify: false`，直接提交服务端已建立的绑定。此时 Group 只记录 ZIP 路径与哈希，不解压 ZIP。客户端随后调用 `Localize()` 准备可复制的本地文件；同一 Profile 实例已经确认当前路径正确时直接安全解压，不重复计算 ZIP SHA-256，进程重启或路径变化后才通过 `IsTransferDataValid()` 重新比对持久化 hash。没有 hash 参数且 `verify: false` 时只附加文件路径并清除候选 hash，防止把未验证的 DTO 声明值意外持久化。

官方服务端接收 `PUT SyncClipboard.json` 时不直接信任该字段：先验证字段格式和暂存文件的整包 SHA-256，再清除 DTO 中的哈希创建 Profile，以保证首次 Group 文件仍执行内部语义校验。验证完成后，服务端通过自己计算出的哈希保存并广播新的 `ProfileDto`。

## 哈希产生与更新

### Text

- 短文本没有传输文件，保持 `TransferDataHash = null`；
- 长文本写入无 BOM UTF-8 文件后，计算文件 SHA-256；
- `PrepareTransferData()` 或带验证模式的 `SetTransferData()` 已读取文件校验文本哈希时，复用同一次计算结果设置字段；
- 文件内容变化时，清空旧值并按现有本地数据不可用流程报错。

### File / Image

将文件校验重构为一次读取同时得到：

1. 文件内容 SHA-256，即 `TransferDataHash`；
2. 文件名与内容哈希组合后的 `Profile.Hash`。

只有组合哈希与记录的 `Profile.Hash` 相等后，才保存内容 SHA-256。同一次文件读取同时生成两个哈希，避免重复扫描大文件。

### Group：新建 ZIP

`CreateTransferArchiveAsync()` 继续在写入每个条目时计算文件内容哈希并核对 Group 语义哈希。ZIP 完全关闭、临时文件校验成功并原子移动为最终文件后，再计算最终 ZIP 文件 SHA-256 并设置 `TransferDataHash`。

不能在 ZIP 关闭前固定哈希，因为中央目录等字节在关闭归档时才写完。若后续 SHA-256 计算、移动或持久化失败，不得提交新哈希；数据库中的文件引用与哈希必须保持上一组一致值或同时为空。

### 接收传输文件

服务端保存上传流时可以用 `IncrementalHash` 在写盘的同一遍复制中计算 SHA-256，避免额外读取整个传输文件。客户端现有下载接口可先下载到目标文件，再流式计算 SHA-256；后续若改造下载流，也可以在下载写盘时同步计算。

任何从网络接收的文件都应先写入临时路径。完成整包哈希和所需语义校验后再原子发布，失败时删除临时文件与本次新建的解压目录。该原子化要求沿用现有拒绝上传清理规则。

## Group ZIP 校验流程

### 快速路径判定

对已有或刚下载的 ZIP 执行以下顺序：

1. 文件必须存在且扩展名符合现有要求；
2. `TransferDataHash` 必须是合法 SHA-256，并且与本地传输文件路径一起来自成功的 `SetTransferData()` 或本地持久化记录；
3. 流式计算当前 ZIP 完整字节的 SHA-256；
4. 只有实际值与字段值大小写无关地相等时，才命中快速路径；
5. 命中后跳过逐条内容哈希和 Group 语义哈希重建。

“ZIP 与 `TransferDataHash` 都存在”在实现中必须解释为“ZIP 存在、字段合法，并且 ZIP 的实际 SHA-256 与字段相等”，不能只检查非空。

```mermaid
flowchart TD
    A["下载层接收 Group ZIP"] --> B["核对 ZIP SHA-256 与远端 TransferDataHash"]
    B --> C{"绑定来源可信?"}
    C -- "否：WebDAV/S3" --> D["安全解压并重建 Group.Hash"]
    D --> E{"语义哈希匹配?"}
    E -- 否 --> F["拒绝文件并清理本次产物"]
    E -- 是 --> G["提交本地路径与实际 TransferDataHash"]
    C -- "是：官方服务器" --> G
    G --> H["Localize 时安全解压"]
    H --> I["持久化文件引用与 TransferDataHash"]
```

### 快速路径不能跳过的检查

即使整包哈希匹配，以下行为仍保留：

- 当前操作需要解压时，由打开 `ZipArchive` 自然完成的可读性检查；
- 当前操作需要解压时的目标路径规范化和 Zip Slip 防护；
- 取消令牌、I/O 异常和权限错误处理；
- 需要把 Group 放入本地剪贴板时的实际解压；
- 临时文件/目录失败清理。

快速路径跳过的是“为证明 ZIP 内容对应 `Profile.Hash` 而进行的逐条内容 SHA-256、长度汇总、排序和 Group 哈希重建”。仅复用或发送 ZIP 时不打开归档；需要解压时仍执行安全解压，但不为语义校验再次读取解压结果。

### 哈希缺失或不匹配

- 哈希为 `null`：按旧逻辑完整验证；成功后计算 ZIP SHA-256，供本次 Profile 使用，并在下一次历史记录保存时回填；
- 哈希格式错误：网络输入直接拒绝，本地旧数据按未知哈希处理并记录警告；
- 哈希与 ZIP 不匹配：视为文件损坏或被替换，不允许通过内部结构碰巧仍有效而静默更新哈希；
- `PrepareTransferData()` 遇到本地缓存 ZIP 哈希不匹配时，可以从仍然有效的 `_files` 重新生成；无法重新生成则抛出 `LocalProfileDataUnavailableException`；
- 网络下载或服务端存储文件不匹配时，拒绝当前文件并走现有错误/重试流程，不把错误值写入数据库。

不匹配时不回退到“内部结构有效就接受”，是为了让 `TransferDataHash` 真正提供端到端的具体文件完整性保证，并避免掩盖存储损坏。

## 信任边界

### 客户端本地生成

File/Image/Text 在核对现有 `Profile.Hash` 后保存文件 SHA-256；Group 在打包过程中核对完整语义哈希后保存 ZIP SHA-256。因此客户端本地生成的值可以用于后续本地快速校验，也可以随上传请求发送。

### 服务端首次上传

服务端按以下顺序处理新 Group：

1. 将请求流写入临时文件，同时计算实际 ZIP SHA-256；
2. 若请求声明了 `transferDataHash`，先与实际值比较，不匹配立即 422；
3. 不论客户端声明值是否匹配，都执行一次完整 Group 内部语义校验，确认 ZIP 对应请求中的 `Profile.Hash`；
4. 成功后持久化服务端计算的实际 SHA-256，并原子发布文件和记录；
5. 后续对同一服务端文件的检查才允许使用整包哈希快速路径。

Text/File/Image 也应由服务端计算实际 SHA-256 并持久化。它们继续执行现有 Profile 校验，不能用请求字段代替文件名、文本或 Profile 哈希校验。

### 客户端下载

所有来源都必须先计算实际文件 SHA-256；若远端提供的哈希不相等，立即拒绝当前文件。之后按来源区分：

- 官方服务器：客户端信任服务器已经建立的 `TransferDataHash` 与 `Profile.Hash` 绑定，整包哈希匹配后可以跳过 Profile 语义验证；Group 仍在需要落地时执行安全解压和 Zip Slip 防护；
- WebDAV/S3：绑定由其他客户端写入，不可信。File/Image/Text 继续验证 Profile 语义，Group 安全解压并重建 Group 语义哈希；
- 服务端未返回传输哈希：不能跳过语义验证，成功后由本机计算并建立绑定；
- 验证成功或从可信官方服务器取得后，路径和两类哈希一起持久化；数据库不保存额外信任状态，非空字段本身必须满足已验证绑定不变量；
- 后续从数据库恢复时，只需重新计算本地传输文件 SHA-256 并与持久化值比较，匹配即可走快速路径。

## 数据库迁移与兼容

客户端和服务端分别新增一条 EF Core migration。客户端 migration 增加路径和哈希两列：

```sql
ALTER TABLE HistoryRecords ADD COLUMN TransferDataFile TEXT NULL;
ALTER TABLE HistoryRecords ADD COLUMN TransferDataHash TEXT NULL;
```

服务端已经有 `TransferDataFile`，只增加哈希列：

```sql
ALTER TABLE HistoryRecords ADD COLUMN TransferDataHash TEXT NULL;
```

迁移设计要求：

1. 列可空、无默认空字符串、无需索引；
2. 不在 migration 中扫描历史文件或 ZIP，避免启动迁移变成大规模 I/O；
3. 现有客户端记录的两个新字段保持 `null`；下次 Profile 持久化时写入路径，文件下一次成功完整验证时写入哈希；
4. 服务端现有 `TransferDataSha256`/`TransferDataMd5` 保留原状，不自动复制到新列；生产代码从未为其建立可信写入不变量；
5. `Down()` 只删除新列，不修改旧列；
6. 客户端继续通过 `HistoryManager.InitDatabaseContext()` 应用 migration，服务端继续通过 `MigrationHelper` 应用 migration。

新旧版本兼容策略：

| 组合 | 行为 |
| --- | --- |
| 新客户端 + 新官方服务端 | 文件请求/响应头与文件体同次传输；整包哈希匹配后可使用服务端已验证绑定 |
| 旧客户端 + 新服务端 | 上传头缺失；服务端完整验证并自行生成；旧客户端忽略下载响应头 |
| 新客户端 + 旧服务端 | 下载响应头缺失；客户端使用旧的完整语义验证路径 |
| 迁移前历史记录 | 字段为 `null`；首次完整验证成功后惰性回填 |

## 关键代码改造点

### Shared

- `ProfileDto.cs`：增加可选 `TransferDataHash`，空值不写入 JSON；
- `Profiles/Models/ProfilePersistentInfo.cs`：增加字段；
- `Profiles/Profile.cs`：保存/暴露当前传输文件哈希，提供格式规范化和显式 `IsTransferDataValid()` 逻辑；
- `Profiles/TextProfile.cs`：生成、验证、持久化和复制传输文件哈希；
- `Profiles/FileProfile.cs`：一次读取生成内容哈希与组合 Profile 哈希；Image 自动继承；
- `Profiles/GroupProfile.cs`：
  - 新 ZIP 关闭后计算 ZIP SHA-256；
  - 将 `ExtractAndVerifyTransferData()` 拆成安全解压与可选语义验证；
  - `PrepareTransferData()` 使用整包哈希快速路径，`SetTransferData()` 的 hash 重载只消费外部验证结果；
  - 旧记录完整验证成功后学习哈希；
  - `CopyTo()` 复制字段。

### Client Core

- `Models/HistoryRecord.cs`：增加 `TransferDataFile` 和 `TransferDataHash` 持久化属性；
- `Models/MapperExtensions.cs`：DTO、HistoryRecord、Profile 三方映射；
- `Utilities/History/HistoryManager.cs`：新增/更新记录时保存 `ProfilePersistentInfo.TransferDataFile` 和哈希；按本地可用状态合并远端字段；
- `Utilities/History/HistoryTransferQueue.cs`：
  - 上传在 `PrepareTransferData()` 后重新取得 Profile 持久化信息，把已验证哈希单独交给文件上传 API；
  - 下载不再预取历史 DTO，直接使用文件响应携带的哈希；
  - 下载流校验成功后才把下载文件路径与哈希随本地记录持久化；
- `RemoteServer/Adapter/OfficialServer/OfficialAdapter.cs`：上传设置请求头；下载读取响应头，流式计算并核对文件 SHA-256，临时文件验证成功后原子发布。

### Server Core

- `Models/HistoryRecordEntity.cs`：持久化已验证字段；`HistoryRecordDto.cs` 明确不携带本地文件绑定；
- `Models/Mapper.cs`：Profile 持久化信息与实体之间传递字段；
- `Controllers/HistoryController.cs`：上传解析并校验请求头，下载在响应头返回服务端已验证哈希；
- `Controllers/SyncClipboardController.cs`：校验普通同步 DTO 的可选字段和暂存传输文件，首次 Group 接收仍执行完整语义校验；
- `Services/History/HistoryService.cs`：上传写盘时计算实际 SHA-256；首次 Group 上传完成语义校验后才保存；更新传输文件时同步更新字段；下载时向 Controller 返回文件路径与已验证哈希。

## 并发与原子性

`TransferDataHash` 描述具体文件，因此更新顺序不能暴露“新文件 + 旧哈希”或“旧文件 + 新哈希”：

1. 文件写入唯一临时路径；
2. 计算 SHA-256 并完成所需语义校验；
3. 关闭所有文件和 ZIP 流；
4. 原子移动文件到最终路径；
5. 在同一次 `SaveChangesAsync()` 中保存 `TransferDataFile` 和 `TransferDataHash`；
6. DB 保存失败时清理本次新文件，或保留可由孤儿清理处理的未引用文件，但不能修改内存中的已提交状态。

Group 的 `_transferDataLock` 继续保护同一 Profile 实例的准备过程。哈希计算与文件使用之间仍存在文件被外部替换的理论竞态；上传适配器应尽快以只读共享策略打开已验证文件。服务端和下载端以临时文件接收并原子发布，避免其他线程看到半成品。

## 测试计划

### 单元测试

1. Text 长文本：`TransferDataHash == Profile.Hash`；短文本为 `null`。
2. File/Image：字段等于文件内容 SHA-256，且在一般文件名下不等于组合后的 `Profile.Hash`。
3. File/Image 内容变化：准备失败，不保留旧 `TransferDataHash`。
4. Group 新建 ZIP：先通过语义校验，再得到等于 ZIP 文件 SHA-256 的字段。
5. Group 旧记录哈希为空：完整校验成功后学习字段；无效 ZIP 仍被拒绝。
6. Group 已知哈希匹配：复用 ZIP 或安全解压时不再调用条目内容哈希/Group 哈希重建逻辑。
7. Group 已知哈希不匹配：不进入内部校验兜底；有有效源文件时重新生成，否则报本地数据不可用。
8. Group ZIP 匹配时仍拒绝 Zip Slip 路径；快速路径不能关闭路径安全检查。
9. `ProfilePersistentInfo` 和 `CopyTo()` 往返不丢字段。

为可靠证明“没有读取 ZIP 内部内容”，建议把整包校验和语义校验拆成可独立观测的内部方法，并通过测试替身或调用计数验证分支；不要只用耗时断言。

### 客户端历史测试

1. migration 能从现有客户端数据库升级，旧记录的 `TransferDataFile` 和 `TransferDataHash` 均为 `null`；
2. 本地新增、已有记录更新、下载完成、上传成功后路径与哈希正确持久化；
3. 远端记录在本地无数据时不保存服务端文件哈希；本地已有不同 ZIP 时不被远端元数据错误覆盖；
4. `HistoryRecord -> Profile -> HistoryRecord` 往返保持路径与哈希，客户端重启后 Group 能恢复 ZIP；
5. 软删除、本地文件丢失和重新下载时遵守清空/保留规则。

### 服务端与协议测试

1. migration 能从现有服务端数据库升级，不修改旧哈希列；
2. 旧客户端不发送请求头时上传成功，服务端自行生成并在后续文件下载响应头中返回；
3. 声明值与上传字节不匹配时返回 422，不创建记录、不残留文件；
4. 恶意客户端为错误 Group ZIP 提供“正确的 ZIP SHA-256”时，首次上传仍因 Group 语义哈希不匹配而返回 422；
5. 有效 Group 首次上传完整验证，后续相同服务端文件使用快速路径；
6. 历史查询接口和 SignalR 通知不包含 `transferDataHash`，文件下载响应头包含它；
7. 替换记录传输文件时，文件引用与哈希同步更新。
8. 不含 `transferDataHash` 的旧版 `ProfileDto` 可正常反序列化，空值序列化时省略字段。
9. 普通同步 `ProfileDto` 在 Text/File/Image/Group 构造与输出间保持哈希。
10. 普通同步首次 Group 上传不能利用自声明 ZIP 哈希绕过语义校验。

### 回归与性能验证

- 运行 `SyncClipboard.Test` 中现有 `ProfileTransferValidationTests` 和 `GroupProfileTransferTests`，旧的损坏/空 ZIP 行为不得放宽；
- 运行客户端历史同步、服务端历史上传和 DI 验证测试；
- 构造包含大量文件的 Group，对比首次完整校验与后续快速校验：后续应只顺序读取 ZIP 字节一次，不再解压并逐文件计算 SHA-256；
- 验证取消、磁盘满、权限错误、DB 保存失败时没有错误哈希或半成品被提交。

## 验收标准

- [x] 客户端历史数据库有可空 `TransferDataFile` 和 `TransferDataHash` 列，服务端有可空 `TransferDataHash` 列，并能从现有数据库无损升级。
- [x] 客户端 `TransferDataFile` 只保存本地持久化路径，不进入 DTO；Group 可在客户端重启后恢复 ZIP 引用。
- [x] 历史文件 API 通过对称请求/响应头传递 `TransferDataHash`，历史 DTO 不携带该字段；普通剪贴板 `ProfileDto` 仍以可选字段传递。
- [x] 字段统一表示传输文件完整字节的 SHA-256，不改变任何 Profile 哈希算法。
- [x] Text/File/Image 在已有文件校验读取中生成字段；Group 保存最终 ZIP 的 SHA-256。
- [x] 新旧客户端/服务端组合可以正常工作；字段缺失只影响性能，不影响正确性。
- [x] 服务端不信任客户端声明值，首次 Group 上传仍完整验证内部语义。
- [x] 客户端信任官方服务器已验证的哈希绑定；WebDAV/S3 的 DTO 声明仍在本地首次完整验证。
- [x] ZIP 实际 SHA-256 与可信字段相等时，不再逐条计算内部文件哈希或重建 Group 哈希。
- [x] 哈希不匹配时拒绝或从有效源数据重建，不静默接受并改写字段。
- [x] 快速路径设置数据时不解压；真正需要本地文件时执行安全解压和 Zip Slip 防护。
- [x] 客户端 `TransferDataFile` 与 `TransferDataHash` 原子更新，失败不会留下已提交的错误绑定。
- [x] 现有空 ZIP、内容变更、缺失文件和服务端 422 回归测试继续通过。

## 非目标

- 不改变 `Profile.Hash`、Profile ID、历史去重键或工作目录结构；
- 不保证相同 Group 内容生成字节完全一致的 ZIP；
- 不用 `TransferDataHash` 替代 TLS、认证、Zip Slip 防护或首次 Group 语义校验；
- 不在本次改动中删除服务端旧的 `TransferDataSha256`/`TransferDataMd5` 列；
- 不改变普通剪贴板同步现有的文件上传端点、对象键或 Profile ID。

---

### Ai Design Updater Platform Tests (`docs/ai_design/Updater-Platform-Tests.md`)

# 更新器平台测试

业务测试统一位于 `src/SyncClipboard.Test`，引用 `SyncClipboard.Updater.Core`，以测试用 `IUpdateInteraction` 提供选择、记录结果，不启动更新器 UI。GUI 只检查已发布更新器的 `--smoke-test`：真实创建窗口，Windows 还创建并关闭对话框。

## 分类与 CI

| 分类 | 执行位置 | 覆盖内容 |
| --- | --- | --- |
| 无平台分类 | `core-test.yml` | 参数解析、包校验、工厂选择、状态转换，以及不依赖特定系统的业务测试 |
| `PlatformWindows` | `windows-test.yml` | ZIP 准备和替换、ACL/只读/占用错误、回滚、进程等待、锁、自清理和 core 的 Windows 分支 |
| `PlatformMacOS` | `macos-test.yml` | 真实 DMG、签名检查、bundle 交换和回滚、重启、卸载挂载点、清理和 core 的 macOS 分支 |
| `PlatformLinux` | `linux-test.yml` | 真实 AppImage、执行权限、替换和回滚、重启、进程取消，以及输入设备权限等 core 的 Linux 分支 |
| `UpdaterSmoke` + 平台分类 | 对应 workflow 的 smoke 步骤 | 使用主程序产物组装并启动 NativeAOT 更新器，确认 `GUI_SMOKE=PASS` 和退出码 |

目录用于组织代码，`TestCategory` 决定筛选。适用于多个系统的测试标注多个平台分类；core 流程排除全部平台分类，业务步骤排除 `UpdaterSmoke`。CI 依据 `dotnet test` 的退出码判定结果，测试诊断通过 CI 控制台日志查看，不生成或上传 TRX 附件。修改 filter 时使用 `--list-tests` 核对实际选择的用例。

Windows 保留 `function-test` 和 `smoke-test` 两个 job。Linux 将两类测试合并到 `function-test` job，内部步骤分别为 `function-test`、`smoke-test`。macOS 同样使用一个 `function-test` job，在 ARM64 runner 上依次执行功能测试和 smoke。

三个平台 workflow 接收所有需要对应操作系统的测试，更新器集成测试只是其中一部分。它们由 push/PR 构建入口调用，等待对应系统的构建产物，不再编译一套测试专用更新器。正式 release 等待它们成功。

- 功能测试在 Windows/Linux 的原生 x64 runner、macOS 的原生 ARM64 runner 上执行。
- Windows smoke 只使用 x64、携带 .NET 的产物，覆盖是否携带 Windows App SDK，共 2 组。未携带 App SDK 的环境按 NuGet 锁定版本安装运行时 MSIX；若当前用户已安装相同包名、发布者和架构的相同或更高版本，则跳过该包。
- macOS smoke 仅在 ARM64 执行，挂载真实构建 DMG，从 `.app` 提取更新器依赖，最终卸载 DMG。
- Linux smoke 只使用 x64 self-contained 产物，与功能测试共用一个 job；只有 smoke 使用 Xvfb，功能测试不需要显示服务器。
- Windows、Linux 选择的 self-contained 指主程序发行产物的打包方式；运行测试宿主的 runner 安装了 .NET SDK，并非无 .NET 的裸系统测试。

## 行为验证边界

业务测试调用实际包准备、文件替换、进程等待、重启和清理方法，验证安装文件、用户数据、备份、可执行权限及重启标记。DMG 和 AppImage 使用临时目录中的最小应用；ZIP 使用系统原生可执行文件验证包内容和架构。Windows 自清理使用真实子进程和文件占用，验证等待退出后删除。

业务测试不通过已发布 GUI 更新器驱动完整 `UpdateWorker.RunAsync`，因此与 smoke 分别覆盖业务步骤和发布依赖。现有 workspace 校验不为测试绕过；不增加生产测试参数、故障注入或 UI 自动点击。交互选择由测试实现返回，系统提权授权窗口仍需人工验证。

## 本地执行

```bash
# 跨平台 core 测试
dotnet test src/SyncClipboard.Test --filter 'TestCategory!=PlatformWindows&TestCategory!=PlatformMacOS&TestCategory!=PlatformLinux'

# 本机平台业务测试，按系统替换分类
dotnet test src/SyncClipboard.Test --filter 'TestCategory=PlatformMacOS&TestCategory!=UpdaterSmoke'

# 已发布 GUI smoke，按系统替换分类
dotnet test src/SyncClipboard.Test --filter 'TestCategory=PlatformMacOS&TestCategory=UpdaterSmoke'
```

Smoke 前设置：Windows 的 `SYNC_CLIPBOARD_UPDATER_TEST_EXE` 指向主程序输出中的 `SyncClipboard.Updater.exe`；macOS 的 `SYNC_CLIPBOARD_UPDATER_TEST_SOURCE` 指向 `.app`；Linux 的同名变量指向主程序发布目录。所有依赖通过生产代码的 `Prepare*UpdaterAsync` 复制，测试不维护第二份依赖列表。

Linux 业务测试还需要 `SYNC_CLIPBOARD_APPIMAGE_TEST_TOOL` 指向本机架构的 appimagetool 1.9.1。CI 校验固定 SHA-256；生成的 AppImage 使用 extract-and-run 启动，避免依赖 FUSE。

测试失败原因、异常堆栈和诊断输出通过 CI 日志查看；子进程测试和 smoke 有超时，测试只清理自己创建的临时资源。

---

### Ai Design Updater Packaging (`docs/ai_design/updater-packaging.md`)

# NativeAOT 更新器的构建与依赖复用

## 项目职责

- `SyncClipboard.Updater.Core`：交互接口、参数、语言，以及 ZIP、DMG、AppImage 的校验、安装、回滚、重启和清理；不引用界面框架或主程序业务项目。
- `SyncClipboard.Updater.WinUI`：WinUI 主程序对应的 Windows 更新界面。
- `SyncClipboard.Updater.Avalonia`：Avalonia 桌面入口对应的更新界面，包括 macOS、Linux 和 Windows Avalonia。

两个界面项目引用 Core，导入 `src/SyncClipboard.Updater.Core/Updater.props` 共用 NativeAOT 发布配置。输出统一命名为 `SyncClipboard.Updater`，Core 被链接到可执行文件中，不向应用包添加 `SyncClipboard.Updater.Core.dll`。

## 构建与打包

桌面入口导入 `src/SyncClipboard.Updater.Core/Updater.targets`，随主程序发布同架构更新器，只收集一个可执行文件。Windows/Linux 放在主输出目录，macOS 通过 BundleResource 放在 `.app/Contents/Resources/Updater/` 并随应用签名。Server 不参与。

所有 WinUI 组合都打包更新器，包括未附带 .NET 或 Windows App SDK 的组合。更新器始终为 NativeAOT；只有 `WindowsAppSDKSelfContained` 跟随主程序，使用 SDK 的标准自动初始化机制：

- 自包含 SDK：EXE 含本地 WinRT 激活清单，使用临时目录里的原生库。
- 不包含 SDK：EXE 通过 bootstrap 使用系统安装的 SDK；原生 bootstrap DLL 来自主程序输出，托管 bootstrap 程序集被 AOT 链接进 EXE。

两种 SDK 模式分别编译 EXE。自包含构建的激活清单绑定本地 DLL，不能只在运行时调用 bootstrap 就切换成系统运行库。每个发布包仍只新增一个 EXE。

WinUI 更新器在 Debug 配置下也开启编译优化，避免 .NET 10 NativeAOT 在 WinUI 引用跟踪回调中触发 GC 死锁（[dotnet/runtime#121538](https://github.com/dotnet/runtime/issues/121538)）。界面冒烟测试在窗口和对话框存活时强制执行 GC，验证该路径能够完成。

发布在独立 dotnet 进程进行，`--artifacts-path` 按入口项目、配置、TFM、RID、WinUI SDK 模式隔离中间产物。主程序的 .NET `SelfContained` 等全局属性不会污染更新器。发布失败直接阻止主程序构建，设计时构建跳过。

## 运行时依赖复用

`FileReplacementPackageInstaller` 创建独立临时目录和标记，复制当前版本更新器及所需依赖，然后启动更新器。Windows 的依赖名单集中在 `WindowsUpdaterFiles`，只复制主程序输出中存在的 DLL，缺失时直接跳过，不据此禁止安装或漏打包更新器。无 SDK 的包使用系统运行库。

WinUI 自包含输出的 `SyncClipboard.pri` 含合并后的主题资源，复制为临时目录的 `resources.pri`。Debug 构建的 PRI 引用外部 `App.xbf`，该文件存在时一并复制；Release 通常将其嵌入 PRI。只有 EXE 和 DLL 不足以绘制 WinUI 控件；PRI 和 XBF 同样复用主程序编译产物，不在发布包新增另一份。Windows Avalonia 复用 Skia/HarfBuzz，macOS/Linux 保留原有 dylib/so 复制及权限处理。

更新器替换安装目录时，不加载原安装目录里的 DLL。WinUI 会持续映射临时目录的 DLL，因此成功更新后启动系统 PowerShell 清理进程，等待更新器退出，再验证目录命名和标记，以 LiteralPath 删除。路径、PID 和进程启动时间通过环境变量传递，不拼接进脚本。安装失败或主动终止仍保留工作目录和备份。

## 验证

平台无关测试由 core workflow 执行；依赖真实系统行为的测试由 Windows、macOS、Linux 独立 workflow 按平台分类执行。各平台复用主程序构建产物运行更新器 GUI smoke，业务逻辑通过无 UI 的交互接口验证。

更改 Windows App SDK、WinUI 控件或 Avalonia 版本后，应重新验证原生依赖集合。运行库初始化遵循 [Microsoft 官方文档](https://learn.microsoft.com/windows/apps/windows-app-sdk/use-windows-app-sdk-run-time)。

---

--- METRICS ---
- Files Extracted: 13
- Estimated Token Budget: ~25123 tokens
- Canonical Reference: https://codewiki.google/github.com/Jeric-X/SyncClipboard
