Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions plugins/pasteboard-pro/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# 更新日志

## 1.3.0 - 2026-09-01

- 适配 ZTools 3.2:新增截图入库、原生文件外拖和插件专属 `pluginData` 附件目录;首次启动会校验迁移旧附件、重写数据库路径并删除旧目录。ZTools 2.4–3.1 直接安装仍使用旧目录与浏览器拖拽回退。截图对公开的一参数 wrapper 以第二参数 `false` 作 best-effort 编辑态请求,不能保证 wrapper 会转发;区域尺寸按宿主是否回传而可选。
- ZTools Shelf 截图只写系统剪贴板,由主窗口唯一负责历史镜像与广播,避免双窗口重复入库。
- 插件附件删除改为内容寻址白名单,复核 SHA-256、无符号链接文件句柄、canonical 路径、父链和 inode;无法验证或仍被记录引用时拒绝删除。
- 低于 ZTools 2.4 时只显示升级提示,不初始化剪贴板数据库、同步、窗口或后台监听。
- Windows/Linux 列表快捷键改用 Ctrl,覆盖搜索、新建、复制、暂停、编辑、重命名、全选、多选和数字快捷粘贴。
- 通用设置新增 ZTools 全局快捷键入口,并明确 `Win + V` 为 Windows 系统剪贴板历史快捷键。
- 修复带 Ctrl、Meta 或 Alt 的方向键和 Enter 被误判为裸键、导致搜索框失焦或列表误操作的问题。
- 统一卡片与列表的 Enter/Space 键盘路由,避免事件冒泡造成重复粘贴或绕过修饰键保护。

## 1.2.0 - 2026-08-03

- 插件源码目录与唯一 ID 保持为 `pasteboard-pro`,兼容既有安装、数据和同步协议。
Expand Down
8 changes: 6 additions & 2 deletions plugins/pasteboard-pro/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,29 +15,32 @@ Paste剪切板是一款 Paste 风格的本地优先剪贴板历史插件。它
- 按内容、来源 App、日期、类型和分组搜索历史。
- 创建、重命名、排序和着色分组,支持拖动内容加入或移出分组。
- 预览文本、图片与 PDF,支持平台文件预览、图片旋转和 OCR。
- 使用方向键浏览,按 Enter 粘贴,按 Escape 关闭支持 `Command + 1–9` 快捷粘贴。
- 使用方向键浏览,按 Enter 粘贴,按 Escape 关闭;macOS 支持 `Command + 1–9`,Windows/Linux 支持 `Ctrl + 1–9` 快捷粘贴。
- 将多项内容加入粘贴队列;macOS 关闭面板后连续按 `Command + V` 依次粘贴,Windows/Linux 按 Enter 后按选择顺序逐项粘贴。
- 新复制内容实时定位;图片优先加载轻量缩略图,避免阻塞历史列表。
- 支持紧凑布局和上、下、左、右贴边显示,始终跟随当前鼠标所在屏幕。
- 提供历史保留、附件预算、敏感内容排除、屏幕共享保护和暂停捕获。
- 使用端到端加密 WebDAV 同步正文、OCR、分组、图片、PDF 与外观配置。
- 主题色、纯色背景和背景图片持久保存在插件本地;选图时不会上传,只有启用 WebDAV 后才进入加密同步。
- 可从设置的隐私页清空全部剪贴板历史、插件自管附件和当前系统剪贴板。
- ZTools 3.2 可从工具栏截图:Shelf 只写入系统剪贴板,再由主窗口唯一的历史监听器入库;插件会 best-effort 传入 `autoConfirm=false`,但当前公开的一参数 wrapper 可能忽略它,因此不能保证进入截图编辑态。截图区域尺寸也只在宿主实际回传时显示。ZTools 3.2 还支持将本地图片和文件原生拖到外部应用。

## 使用方式

1. 在 ZTools 中搜索 `Paste剪切板`、`剪贴板`、`paste` 或 `clipboard`。
2. 首次打开后,插件会登记为随 ZTools 启动;后续无需手动打开即可持续记录复制内容。
3. 使用鼠标、方向键或搜索框定位内容,按 Enter 或点击卡片完成粘贴。
4. 多选后点击“队列”;macOS 可连续按 `Command + V`,Windows/Linux 在面板内按 Enter 逐项粘贴。
5. 在设置中选择贴边位置、历史保留策略、附件预算和隐私规则。
5. 在“设置 → 通用”中可跳转到 ZTools 的全局快捷键配置;Windows 请使用未被系统占用的组合,`Win + V` 保留给系统剪贴板历史。
6. 在设置中选择贴边位置、历史保留策略、附件预算和隐私规则。

## 平台支持

- macOS:完整支持历史捕获、独立贴边浮窗、直接粘贴、Quick Look、Vision OCR、屏幕共享保护和连续粘贴队列。
- Windows / Linux:支持历史捕获、搜索、分组、复制、直接粘贴、文件拖拽和系统文件预览;OCR 需要安装 Tesseract,图片旋转需要安装 ImageMagick。
- ATools:使用 Svelte UI 与 ATools 原生 bridge。
- ZTools:使用 Vue 3 UI、Electron preload;macOS 使用 Vision helper,Windows/Linux 使用系统安全存储和外部 Tesseract/ImageMagick 命令。
- 最低支持 ZTools 2.4;2.4–3.1 使用旧数据目录与浏览器拖拽路径。3.2 首次启动会校验迁移旧附件并重写数据库路径,完成后删除旧目录,之后仅使用宿主提供的插件专属 `pluginData`。

## 隐私与同步

Expand All @@ -46,6 +49,7 @@ Paste剪切板是一款 Paste 风格的本地优先剪贴板历史插件。它
- WebDAV record、blob 和 index 均经过端到端加密。
- WebDAV 凭据与派生密钥不进入 renderer,本地密钥材料存放于系统钥匙串。
- 搜索工具仅返回脱敏的结构化元数据,不把 OCR 正文写入 Agent 审计内容。
- 附件清理只接受插件生成的内容寻址路径,并在删除前校验实际 SHA-256、`O_NOFOLLOW` 文件句柄、canonical 路径及 inode;校验异常时拒绝删除。Node.js 没有 `unlinkat`,因此这里不宣称能原子防御同一系统账号的恶意进程在最终 unlink 瞬间并发替换父目录。

## 技术实现

Expand Down
2 changes: 1 addition & 1 deletion plugins/pasteboard-pro/apps/ztools/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pasteboard-pro/ztools",
"version": "1.2.0",
"version": "1.3.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
8 changes: 8 additions & 0 deletions plugins/pasteboard-pro/apps/ztools/preload/clipboard-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export type DeleteRecordsResult = Readonly<{
export type CanonicalStoreOptions = Readonly<{
deviceId?: string;
now?: () => number;
ready?: Promise<void>;
}>;

export interface HostClipboardApi {
Expand Down Expand Up @@ -246,16 +247,19 @@ async function putWithConflictRetry(
export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore {
private readonly deviceId: string;
private readonly now: () => number;
private readonly ready: Promise<void>;

constructor(
private readonly database: ZToolsDocumentDatabase,
options: CanonicalStoreOptions = {},
) {
this.deviceId = options.deviceId?.trim() || "ztools-local";
this.now = options.now ?? Date.now;
this.ready = options.ready ?? Promise.resolve();
}

async findByFingerprint(fingerprint: string): Promise<PasteItem | undefined> {
await this.ready;
const document = await getOptionalDocument(
this.database,
this.recordDocumentId(fingerprint),
Expand All @@ -264,6 +268,7 @@ export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore {
}

async put(record: CanonicalClipboardRecord): Promise<void> {
await this.ready;
const id = this.recordDocumentId(record.item.contentFingerprint);
await putWithConflictRetry(this.database, id, (revision) => ({
_id: id,
Expand Down Expand Up @@ -330,12 +335,14 @@ export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore {
}

async getCursor(): Promise<HostCursor | undefined> {
await this.ready;
return storedCursor(
await getOptionalDocument(this.database, CURSOR_DOCUMENT_ID),
);
}

async setCursor(cursor: HostCursor): Promise<void> {
await this.ready;
await putWithConflictRetry(
this.database,
CURSOR_DOCUMENT_ID,
Expand All @@ -349,6 +356,7 @@ export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore {
}

async listRecords(): Promise<CanonicalClipboardRecord[]> {
await this.ready;
if (this.database.allDocs === undefined) {
throw new TypeError("ZTools database does not expose allDocs");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export type ClipboardWindowRole = "primary" | "shelf" | "panel";

export function clipboardWindowRole(
parameters: URLSearchParams,
): ClipboardWindowRole {
if (parameters.get("shelf") === "1") return "shelf";
if (parameters.has("panel")) return "panel";
return "primary";
}

export function ownsClipboardHistoryMirror(
role: ClipboardWindowRole,
): boolean {
return role === "primary";
}
131 changes: 131 additions & 0 deletions plugins/pasteboard-pro/apps/ztools/preload/host-compatibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
export const MINIMUM_ZTOOLS_VERSION = "2.4.0";
export const EDITABLE_SCREEN_CAPTURE_VERSION = "3.2.0";

export type ZToolsHostCompatibility = Readonly<{
currentVersion?: string;
minimumVersion: string;
supported: boolean;
supportsPluginData: boolean;
supportsNativeFileDrag: boolean;
supportsScreenCapture: boolean;
}>;

type VersionHost = Readonly<{
getAppVersion?: () => unknown;
getPath?: (name: string) => unknown;
startDrag?: unknown;
screenCapture?: unknown;
}>;

type VersionParts = Readonly<{
core: readonly [number, number, number];
prerelease: readonly string[];
}>;

function versionParts(value: string): VersionParts | undefined {
const match = /^(\d+)\.(\d+)(?:\.(\d+))?(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u.exec(
value.trim(),
);
if (match === null) return undefined;
return {
core: [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)],
prerelease: match[4]?.split(".") ?? [],
};
}

function comparePrerelease(
left: readonly string[],
right: readonly string[],
): number {
if (left.length === 0 || right.length === 0) {
return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
}
const length = Math.max(left.length, right.length);
for (let index = 0; index < length; index += 1) {
const leftPart = left[index];
const rightPart = right[index];
if (leftPart === undefined || rightPart === undefined) {
return leftPart === rightPart ? 0 : leftPart === undefined ? -1 : 1;
}
if (leftPart === rightPart) continue;
const leftNumeric = /^\d+$/u.test(leftPart);
const rightNumeric = /^\d+$/u.test(rightPart);
if (leftNumeric && rightNumeric) return Number(leftPart) - Number(rightPart);
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
return leftPart.localeCompare(rightPart);
}
return 0;
}

export function compareZToolsVersions(left: string, right: string): number | undefined {
const leftParts = versionParts(left);
const rightParts = versionParts(right);
if (leftParts === undefined || rightParts === undefined) return undefined;
for (let index = 0; index < 3; index += 1) {
const difference = leftParts.core[index]! - rightParts.core[index]!;
if (difference !== 0) return difference;
}
return comparePrerelease(leftParts.prerelease, rightParts.prerelease);
}

export const compareVersions = compareZToolsVersions;

export function detectZToolsHostCompatibility(host: VersionHost): ZToolsHostCompatibility {
let currentVersion: string | undefined;
try {
const value = host.getAppVersion?.();
if (typeof value === "string" && value.trim().length > 0) {
currentVersion = value.trim();
}
} catch {
currentVersion = undefined;
}
const comparison =
currentVersion === undefined
? undefined
: compareZToolsVersions(currentVersion, MINIMUM_ZTOOLS_VERSION);
const editableScreenCaptureComparison =
currentVersion === undefined
? undefined
: compareZToolsVersions(currentVersion, EDITABLE_SCREEN_CAPTURE_VERSION);
let supportsPluginData = false;
try {
const pluginData = host.getPath?.("pluginData");
supportsPluginData = typeof pluginData === "string" && pluginData.length > 0;
} catch {
supportsPluginData = false;
}
let supportsNativeFileDrag = false;
try {
supportsNativeFileDrag = typeof host.startDrag === "function";
} catch {
supportsNativeFileDrag = false;
}
let supportsScreenCapture = false;
try {
const screenCapture = host.screenCapture;
supportsScreenCapture =
editableScreenCaptureComparison !== undefined &&
editableScreenCaptureComparison >= 0 &&
typeof screenCapture === "function";
} catch {
supportsScreenCapture = false;
}
return {
...(currentVersion === undefined ? {} : { currentVersion }),
minimumVersion: MINIMUM_ZTOOLS_VERSION,
// A real host with no readable version is treated as unsupported. Browser
// previews have no preload bridge and are handled separately in the UI.
supported: comparison !== undefined && comparison >= 0,
supportsPluginData,
supportsNativeFileDrag,
// ZTools 3.2 documents the editable capture contract as
// screenCapture(callback, autoConfirm). Some 3.2.0 installations still
// expose a one-argument wrapper even though the underlying host accepts
// autoConfirm as an optional second argument. Function.length therefore
// cannot be used as a capability signal; bounds remain optional at runtime.
supportsScreenCapture,
};
}

export const inspectHostCompatibility = detectZToolsHostCompatibility;
Loading
Loading