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
8 changes: 8 additions & 0 deletions plugins/pasteboard-pro/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# 更新日志

## 1.2.0 - 2026-08-03

- 扩展 ZTools 插件市场平台支持到 Windows 和 Linux。
- Windows/Linux 使用系统安全存储保存同步凭据,使用 Tesseract OCR 和 ImageMagick 图片旋转。
- 文件拖拽与队列剪贴板格式适配 Windows、Linux;多选队列在非 macOS 上按 Enter 逐项粘贴。
- 文件预览在 Windows 使用资源管理器、Linux 使用系统默认打开器,macOS 继续使用 Quick Look。
- 非 macOS 构建跳过 macOS Vision helper,避免平台构建失败。

## 1.1.1 - 2026-07-28

- 修复宿主剪贴板历史已更新但列表未及时刷新的问题。
Expand Down
14 changes: 7 additions & 7 deletions plugins/pasteboard-pro/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ Paste剪切板是一款 Paste 风格的本地优先剪贴板历史插件。它
- 首次运行后自动登记为随 ZTools 启动,并在后台持续监听剪贴板变化。
- 按内容、来源 App、日期、类型和分组搜索历史。
- 创建、重命名、排序和着色分组,支持拖动内容加入或移出分组。
- 预览文本、图片与 PDF,支持 Quick Look、图片旋转和 macOS Vision OCR。
- 预览文本、图片与 PDF,支持平台文件预览、图片旋转和 OCR。
- 使用方向键浏览,按 Enter 粘贴,按 Escape 关闭,支持 `Command + 1–9` 快捷粘贴。
- 将多项内容加入粘贴队列关闭面板后连续按 `Command + V` 依次粘贴。
- 将多项内容加入粘贴队列;macOS 关闭面板后连续按 `Command + V` 依次粘贴,Windows/Linux 按 Enter 后按选择顺序逐项粘贴
- 新复制内容实时定位;图片优先加载轻量缩略图,避免阻塞历史列表。
- 支持紧凑布局和上、下、左、右贴边显示,始终跟随当前鼠标所在屏幕。
- 提供历史保留、附件预算、敏感内容排除、屏幕共享保护和暂停捕获。
Expand All @@ -27,15 +27,15 @@ Paste剪切板是一款 Paste 风格的本地优先剪贴板历史插件。它
1. 在 ZTools 中搜索 `Paste剪切板`、`剪贴板`、`paste` 或 `clipboard`。
2. 首次打开后,插件会登记为随 ZTools 启动;后续无需手动打开即可持续记录复制内容。
3. 使用鼠标、方向键或搜索框定位内容,按 Enter 或点击卡片完成粘贴。
4. 多选后点击“队列”,即可连续按 `Command + V` 逐项粘贴。
4. 多选后点击“队列”;macOS 可连续按 `Command + V`,Windows/Linux 在面板内按 Enter 逐项粘贴。
5. 在设置中选择贴边位置、历史保留策略、附件预算和隐私规则。

## 平台支持

- macOS:完整支持历史捕获、独立贴边浮窗、直接粘贴、Quick Look、Vision OCR、屏幕共享保护和连续粘贴队列。
- Windows / Linux:支持历史浏览、搜索、分组、复制和元数据预览;macOS 原生能力会安全降级并给出提示
- Windows / Linux:支持历史捕获、搜索、分组、复制、直接粘贴、文件拖拽和系统文件预览;OCR 需要安装 Tesseract,图片旋转需要安装 ImageMagick
- ATools:使用 Svelte UI 与 ATools 原生 bridge。
- ZTools:使用 Vue 3 UI、Electron preloadmacOS 原生 helper。
- ZTools:使用 Vue 3 UI、Electron preloadmacOS 使用 Vision helper,Windows/Linux 使用系统安全存储和外部 Tesseract/ImageMagick 命令

## 隐私与同步

Expand Down Expand Up @@ -64,7 +64,7 @@ ZTools 开发页面默认由 Vite 启动;ATools 与 ZTools 使用独立 UI,

## 验证

CI 使用仓库通用的 macOS 插件构建任务;插件自身要求 Node.js 20+ 与仓库现有的 pnpm 9:
CI 使用仓库通用的多平台插件构建任务;插件自身要求 Node.js 20+ 与仓库现有的 pnpm 9:

```bash
pnpm typecheck
Expand All @@ -81,7 +81,7 @@ pnpm test:visual
pnpm verify:visual-artifact
```

仓库通用构建器会读取插件根目录的 `plugin.json`,将插件分配到 macOS runner,再调用插件目录内的 `build-plugin.sh` 编译并临时签名 Vision helper,最后从 `dist/ztools` 打包 ZIP。该流程与 `local-search-neo` 一样,把 native 构建逻辑限制在插件目录内
仓库通用构建器会读取插件根目录的 `plugin.json`,在对应平台 runner 上构建;macOS 编译并临时签名 Vision helper,Windows/Linux 跳过该 helper 并使用对应平台命令,最后从 `dist/ztools` 打包 ZIP。native 构建逻辑仍限制在插件目录内

## 目录结构

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.1.1",
"version": "1.2.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
38 changes: 30 additions & 8 deletions plugins/pasteboard-pro/apps/ztools/preload/image-rotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type ImageRotationInput = Readonly<{
}>;

export type ImageRotationOptions = Readonly<{
platform?: NodeJS.Platform;
spawn?: OcrSpawn;
stat?: (path: string) => Promise<Readonly<{ isFile(): boolean; size?: number }>>;
timeoutMs?: number;
Expand Down Expand Up @@ -52,13 +53,21 @@ export async function rotateImageFile(
}
const spawn = options.spawn ?? defaultSpawn;
const degrees = input.quarterTurns > 0 ? "90" : "-90";

await new Promise<void>((resolve, reject) => {
let child: OcrProcess;
try {
child = spawn(
"/usr/bin/sips",
[
const platform = options.platform ?? process.platform;
const command =
platform === "darwin"
? "/usr/bin/sips"
: platform === "win32"
? "magick.exe"
: platform === "linux"
? "magick"
: undefined;
if (command === undefined) {
throw new Error("当前平台不支持图片旋转");
}
const args =
platform === "darwin"
? [
"--rotate",
degrees,
"--setProperty",
Expand All @@ -67,7 +76,20 @@ export async function rotateImageFile(
input.sourcePath,
"--out",
input.destinationPath,
],
]
: [
input.sourcePath,
"-rotate",
degrees,
`png:${input.destinationPath}`,
];

await new Promise<void>((resolve, reject) => {
let child: OcrProcess;
try {
child = spawn(
command,
args,
{ shell: false, stdio: ["pipe", "pipe", "pipe"] },
);
} catch (error) {
Expand Down
58 changes: 45 additions & 13 deletions plugins/pasteboard-pro/apps/ztools/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@ import {
} from "./clipboard-store";
import { ensureZToolsAutoStart } from "./auto-start";
import type { PasteStackState } from "@pasteboard-pro/core";
import { createOcrClient } from "./ocr";
import { createOcrClient, createTesseractOcrClient } from "./ocr";
import { NativeFileDragService } from "./native-file-drag";
import { openQuickLook } from "./quick-look";
import { rotateImageFile } from "./image-rotation";
import { createKeychainSecretStore } from "./keychain";
import {
createKeychainSecretStore,
createPortableSecretStore,
type SafeStorageLike,
} from "./keychain";
import { copyCanonicalRecord, pasteCanonicalRecord } from "./paste-item";
import {
isCapturePaused,
Expand Down Expand Up @@ -75,10 +79,11 @@ type IpcRendererLike = Readonly<{
invoke(channel: string, ...args: unknown[]): Promise<unknown>;
}>;

const { clipboard, ipcRenderer, nativeImage } = require("electron") as {
const { clipboard, ipcRenderer, nativeImage, safeStorage } = require("electron") as {
clipboard: ClipboardWriter;
ipcRenderer: IpcRendererLike;
nativeImage: NativeImageApi;
safeStorage?: SafeStorageLike;
};

type ZToolsDisplay = Readonly<{
Expand Down Expand Up @@ -118,6 +123,13 @@ type ZToolsHost = Readonly<{
}>;

type PasteboardProBridge = Readonly<{
getPlatformCapabilities(): Readonly<{
platform: NodeJS.Platform;
supportsGlobalPasteQueue: boolean;
supportsQuickLook: boolean;
supportsSystemOcr: boolean;
supportsImageRotation: boolean;
}>;
searchHistory(query?: string, limit?: number): Promise<Readonly<{ items: unknown[]; total: number }>>;
getPrivacySettings(): Promise<PrivacySettings>;
savePrivacySettings(settings: PrivacySettings): Promise<PrivacySettings>;
Expand Down Expand Up @@ -183,7 +195,23 @@ const pinboardStore = new ZToolsPinboardStore(ztools.db.promises, {
deviceId: ztools.getNativeId(),
});
const syncStore = new ZToolsSyncStore(ztools.db.promises);
const keychain = createKeychainSecretStore();
const keychain =
process.platform === "darwin"
? createKeychainSecretStore()
: safeStorage === undefined
? {
save: async () => {
throw new Error("当前平台没有可用的系统安全存储,无法保存同步密码");
},
load: async () => {
throw new Error("当前平台没有可用的系统安全存储,无法读取同步密码");
},
delete: async () => undefined,
}
: createPortableSecretStore({
database: ztools.db.promises,
safeStorage,
});
const syncRepository = new ZToolsSyncEntityRepository(
ztools.db.promises,
ztools.getNativeId(),
Expand All @@ -192,9 +220,12 @@ const shelfWindows = new ShelfWindowManager(ztools);
const panelWindows = new PanelWindowManager(ztools);
const thumbnailService = new ThumbnailService(store, nativeImage);
const nativeFileDragService = new NativeFileDragService(store, ztools);
const ocrClient = createOcrClient({
helperPath: path.join(__dirname, "pasteboard-vision"),
});
const ocrClient =
process.platform === "darwin"
? createOcrClient({
helperPath: path.join(__dirname, "pasteboard-vision"),
})
: createTesseractOcrClient({ platform: process.platform });
let synchronization = Promise.resolve();
let vaultSynchronization: Promise<SyncSettings> | undefined;
let vaultSyncRequested = false;
Expand Down Expand Up @@ -498,6 +529,13 @@ if (isPrimaryWindow) {
}

const bridge: PasteboardProBridge = {
getPlatformCapabilities: () => ({
platform: process.platform,
supportsGlobalPasteQueue: process.platform === "darwin",
supportsQuickLook: process.platform === "darwin" || process.platform === "win32" || process.platform === "linux",
supportsSystemOcr: true,
supportsImageRotation: process.platform === "darwin" || process.platform === "win32" || process.platform === "linux",
}),
async searchHistory(query = "", limit = 1_000) {
const normalizedLimit = Math.max(1, Math.min(10_000, Math.floor(limit)));
const [result, records] = await Promise.all([
Expand Down Expand Up @@ -632,9 +670,6 @@ const bridge: PasteboardProBridge = {
prepareNativeFileDrag: (itemId) => nativeFileDragService.prepare(itemId),
startNativeFileDrag: (itemId) => nativeFileDragService.start(itemId),
async recognizeItem(itemId) {
if (process.platform !== "darwin") {
throw new Error("本地 Vision OCR 仅支持 macOS");
}
const record = await store.findRecordByItemId(itemId);
const imagePath = record?.origin.imagePath;
if (record === undefined || imagePath === undefined) {
Expand All @@ -647,9 +682,6 @@ const bridge: PasteboardProBridge = {
return text;
},
async rotateImage(itemId, quarterTurns) {
if (process.platform !== "darwin") {
throw new Error("本地图片旋转仅支持 macOS");
}
const record = await store.findRecordByItemId(itemId);
const imagePath = record?.origin.imagePath;
if (record === undefined || record.item.kind !== "image" || imagePath === undefined) {
Expand Down
103 changes: 103 additions & 0 deletions plugins/pasteboard-pro/apps/ztools/preload/keychain.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { execFile as nodeExecFile } from "node:child_process";

import type { ZToolsDocumentDatabase } from "./clipboard-store";

export const PASTEBOARD_KEYCHAIN_SERVICE = "com.pasteboardpro.ztools.sync";

const SECURITY_PATH = "/usr/bin/security";
Expand Down Expand Up @@ -30,6 +32,21 @@ export interface KeychainSecretStore {
delete(account: string): Promise<void>;
}

export type SafeStorageLike = Readonly<{
isEncryptionAvailable(): boolean;
encryptString(value: string): Uint8Array;
decryptString(value: Uint8Array): string;
getSelectedStorageBackend?: () => string;
}>;

export type PortableSecretStoreOptions = Readonly<{
database: ZToolsDocumentDatabase;
safeStorage: SafeStorageLike;
logger?: Readonly<{ error(message: string, details?: unknown): void }>;
}>;

const PORTABLE_SECRET_DOCUMENT_PREFIX = "pasteboard-pro:secret:";

export type KeychainSecretStoreOptions = Readonly<{
execFile?: KeychainExecFile;
service?: string;
Expand Down Expand Up @@ -60,6 +77,92 @@ function validateSecret(secret: string): void {
}
}

function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

function documentId(account: string): string {
return `${PORTABLE_SECRET_DOCUMENT_PREFIX}${encodeURIComponent(account)}`;
}

async function getSecretDocument(
database: ZToolsDocumentDatabase,
id: string,
): Promise<Record<string, unknown> | undefined> {
try {
const document = await database.get(id);
return isRecord(document) ? document : undefined;
} catch (error) {
if (isRecord(error) && (error.status === 404 || error.statusCode === 404)) {
return undefined;
}
throw error;
}
}

function assertPortableEncryption(safeStorage: SafeStorageLike): void {
if (!safeStorage.isEncryptionAvailable()) {
throw new Error("当前平台没有可用的系统安全存储,无法保存同步密码");
}
const backend = safeStorage.getSelectedStorageBackend?.();
if (backend === "basic_text") {
throw new Error("当前 Linux 安全存储未启用,请先配置系统密钥环");
}
}

/**
* Stores credentials using Electron safeStorage on Windows and Linux.
* The ciphertext is kept in the plugin database, never the plaintext secret.
*/
export function createPortableSecretStore(
options: PortableSecretStoreOptions,
): KeychainSecretStore {
const { database, safeStorage } = options;
return {
async save(accountValue, secret) {
const account = validateAccount(accountValue);
validateSecret(secret);
assertPortableEncryption(safeStorage);
const id = documentId(account);
const current = await getSecretDocument(database, id);
const ciphertext = Buffer.from(safeStorage.encryptString(secret)).toString("base64");
await database.put({
_id: id,
...(typeof current?._rev === "string" ? { _rev: current._rev } : {}),
type: "pasteboard-pro-secret",
account,
ciphertext,
});
},
async load(accountValue) {
const account = validateAccount(accountValue);
assertPortableEncryption(safeStorage);
const document = await getSecretDocument(database, documentId(account));
if (
document === undefined ||
document.type !== "pasteboard-pro-secret" ||
document.account !== account ||
typeof document.ciphertext !== "string"
) return undefined;
try {
return safeStorage.decryptString(Buffer.from(document.ciphertext, "base64"));
} catch (error) {
options.logger?.error("PasteboardPro secure storage decryption failed", {
account,
error,
});
throw new Error("同步密码无法解密,请重新配置同步", { cause: error });
}
},
async delete(accountValue) {
const account = validateAccount(accountValue);
const document = await getSecretDocument(database, documentId(account));
if (document === undefined || database.remove === undefined) return;
await database.remove(document);
},
};
}

function missingItem(error: KeychainExecError, stderr: string): boolean {
return (
error.code === 44 ||
Expand Down
Loading
Loading