From a87a82837d042658b8d1892cd212d6a327aaf1e7 Mon Sep 17 00:00:00 2001 From: wangzihao Date: Mon, 3 Aug 2026 13:34:00 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(pasteboardpro):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=B7=A8=E5=B9=B3=E5=8F=B0=E8=BF=90=E8=A1=8C=E4=B8=8E=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E8=83=BD=E5=8A=9B=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 扩展 Windows/Linux 平台能力,适配系统安全存储、OCR、图片旋转、文件预览和队列剪贴板格式,并同步更新多平台构建校验。 AI-Co-Authored-By: Codex --- .../pasteboard-pro/apps/ztools/package.json | 2 +- .../apps/ztools/preload/image-rotation.ts | 38 +++++-- .../apps/ztools/preload/index.ts | 58 +++++++--- .../apps/ztools/preload/keychain.ts | 103 ++++++++++++++++++ .../pasteboard-pro/apps/ztools/preload/ocr.ts | 95 ++++++++++++++++ .../ztools/preload/paste-stack-runtime.ts | 36 +++++- .../apps/ztools/preload/quick-look.ts | 22 +++- .../apps/ztools/public/plugin.json | 4 +- .../apps/ztools/scripts/assemble-dist.mjs | 13 ++- .../apps/ztools/scripts/verify-package.mjs | 3 +- .../pasteboard-pro/apps/ztools/src/App.vue | 29 ++++- .../apps/ztools/src/components/Preview.vue | 2 +- .../ztools/src/components/SettingsPanel.vue | 4 +- .../pasteboard-pro/apps/ztools/src/env.d.ts | 7 ++ .../apps/ztools/tests/image-rotation.test.ts | 31 ++++++ .../apps/ztools/tests/keychain.test.ts | 54 +++++++++ .../apps/ztools/tests/ocr.test.ts | 27 +++++ .../ztools/tests/paste-stack-runtime.test.ts | 21 ++++ .../apps/ztools/tests/quick-look.test.ts | 21 +++- plugins/pasteboard-pro/build-plugin.sh | 15 ++- plugins/pasteboard-pro/plugin.json | 4 +- .../scripts/test-workspace-contract.mjs | 2 +- 22 files changed, 535 insertions(+), 56 deletions(-) diff --git a/plugins/pasteboard-pro/apps/ztools/package.json b/plugins/pasteboard-pro/apps/ztools/package.json index 2f10f92f5..899fcafba 100644 --- a/plugins/pasteboard-pro/apps/ztools/package.json +++ b/plugins/pasteboard-pro/apps/ztools/package.json @@ -1,6 +1,6 @@ { "name": "@pasteboard-pro/ztools", - "version": "1.1.1", + "version": "1.2.0", "private": true, "type": "module", "scripts": { diff --git a/plugins/pasteboard-pro/apps/ztools/preload/image-rotation.ts b/plugins/pasteboard-pro/apps/ztools/preload/image-rotation.ts index 3acc7e051..f0d8e4c12 100644 --- a/plugins/pasteboard-pro/apps/ztools/preload/image-rotation.ts +++ b/plugins/pasteboard-pro/apps/ztools/preload/image-rotation.ts @@ -11,6 +11,7 @@ export type ImageRotationInput = Readonly<{ }>; export type ImageRotationOptions = Readonly<{ + platform?: NodeJS.Platform; spawn?: OcrSpawn; stat?: (path: string) => Promise>; timeoutMs?: number; @@ -52,13 +53,21 @@ export async function rotateImageFile( } const spawn = options.spawn ?? defaultSpawn; const degrees = input.quarterTurns > 0 ? "90" : "-90"; - - await new Promise((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", @@ -67,7 +76,20 @@ export async function rotateImageFile( input.sourcePath, "--out", input.destinationPath, - ], + ] + : [ + input.sourcePath, + "-rotate", + degrees, + `png:${input.destinationPath}`, + ]; + + await new Promise((resolve, reject) => { + let child: OcrProcess; + try { + child = spawn( + command, + args, { shell: false, stdio: ["pipe", "pipe", "pipe"] }, ); } catch (error) { diff --git a/plugins/pasteboard-pro/apps/ztools/preload/index.ts b/plugins/pasteboard-pro/apps/ztools/preload/index.ts index 26d90329f..415a14b4d 100644 --- a/plugins/pasteboard-pro/apps/ztools/preload/index.ts +++ b/plugins/pasteboard-pro/apps/ztools/preload/index.ts @@ -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, @@ -75,10 +79,11 @@ type IpcRendererLike = Readonly<{ invoke(channel: string, ...args: unknown[]): Promise; }>; -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<{ @@ -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>; getPrivacySettings(): Promise; savePrivacySettings(settings: PrivacySettings): Promise; @@ -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(), @@ -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 | undefined; let vaultSyncRequested = false; @@ -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([ @@ -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) { @@ -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) { diff --git a/plugins/pasteboard-pro/apps/ztools/preload/keychain.ts b/plugins/pasteboard-pro/apps/ztools/preload/keychain.ts index 61b318954..45aa0b342 100644 --- a/plugins/pasteboard-pro/apps/ztools/preload/keychain.ts +++ b/plugins/pasteboard-pro/apps/ztools/preload/keychain.ts @@ -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"; @@ -30,6 +32,21 @@ export interface KeychainSecretStore { delete(account: string): Promise; } +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; @@ -60,6 +77,92 @@ function validateSecret(secret: string): void { } } +function isRecord(value: unknown): value is Record { + 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 | 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 || diff --git a/plugins/pasteboard-pro/apps/ztools/preload/ocr.ts b/plugins/pasteboard-pro/apps/ztools/preload/ocr.ts index 20ec075b0..afb176acb 100644 --- a/plugins/pasteboard-pro/apps/ztools/preload/ocr.ts +++ b/plugins/pasteboard-pro/apps/ztools/preload/ocr.ts @@ -41,6 +41,13 @@ export type OcrClientOptions = Readonly<{ timeoutMs?: number; }>; +export type TesseractOcrClientOptions = Readonly<{ + platform?: NodeJS.Platform; + spawn?: OcrSpawn; + stat?: (path: string) => Promise>; + timeoutMs?: number; +}>; + export interface OcrClient { recognize(imagePath: string): Promise; } @@ -61,6 +68,8 @@ const IMAGE_EXTENSIONS = new Set([ ".bmp", ]); +const MAX_TESSERACT_OUTPUT_BYTES = 1 * 1_024 * 1_024; + function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -211,3 +220,89 @@ export function createOcrClient(options: OcrClientOptions): OcrClient { }, }; } + +/** + * Uses the portable Tesseract CLI on Windows/Linux. The executable is kept + * external so the plugin does not ship platform-specific native binaries. + */ +export function createTesseractOcrClient( + options: TesseractOcrClientOptions = {}, +): OcrClient { + const platform = options.platform ?? process.platform; + if (platform !== "win32" && platform !== "linux") { + throw new Error("Tesseract OCR 仅用于 Windows/Linux"); + } + const command = platform === "win32" ? "tesseract.exe" : "tesseract"; + const spawn = options.spawn ?? defaultSpawn; + const stat = options.stat ?? nodeStat; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new RangeError("OCR timeout must be finite and positive"); + } + + return { + async recognize(imagePath) { + if (!path.isAbsolute(imagePath)) { + throw new TypeError("OCR imagePath must be absolute"); + } + if (!IMAGE_EXTENSIONS.has(path.extname(imagePath).toLowerCase())) { + throw new TypeError("OCR input must use a supported image extension"); + } + const file = await stat(imagePath); + if (!file.isFile()) throw new TypeError("OCR imagePath must point to a file"); + return await new Promise((resolve, reject) => { + let child: OcrProcess; + try { + child = spawn(command, [imagePath, "stdout"], { + shell: false, + stdio: ["pipe", "pipe", "pipe"], + }); + } catch (error) { + reject(error); + return; + } + let settled = false; + let outputBytes = 0; + const output: Buffer[] = []; + const stderr: Buffer[] = []; + const finish = (callback: () => void): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + callback(); + }; + const timer = setTimeout(() => { + child.kill(); + finish(() => reject(new Error(`Tesseract OCR timed out after ${timeoutMs} ms`))); + }, timeoutMs); + child.stdout.on("data", (chunk) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + outputBytes += buffer.length; + if (outputBytes > MAX_TESSERACT_OUTPUT_BYTES) { + child.kill(); + finish(() => reject(new RangeError("OCR response cannot exceed 1 MiB"))); + return; + } + output.push(buffer); + }); + child.stderr.on("data", (chunk) => { + if (Buffer.concat(stderr).length < 8_192) { + stderr.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))); + } + }); + child.on("error", (error) => finish(() => reject(error))); + child.on("close", (code) => { + finish(() => { + if (code !== 0) { + const message = Buffer.concat(stderr).toString("utf8").trim(); + reject(new Error(message || `Tesseract OCR exited with code ${code}`)); + return; + } + resolve(Buffer.concat(output).toString("utf8").trim()); + }); + }); + child.stdin.end(); + }); + }, + }; +} diff --git a/plugins/pasteboard-pro/apps/ztools/preload/paste-stack-runtime.ts b/plugins/pasteboard-pro/apps/ztools/preload/paste-stack-runtime.ts index 84f7f6b4c..d314ab549 100644 --- a/plugins/pasteboard-pro/apps/ztools/preload/paste-stack-runtime.ts +++ b/plugins/pasteboard-pro/apps/ztools/preload/paste-stack-runtime.ts @@ -1,6 +1,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import path from "node:path"; import readline from "node:readline"; +import { pathToFileURL } from "node:url"; import { reducePasteStack, @@ -76,10 +77,42 @@ function fileListPropertyList(filePaths: readonly string[]): Uint8Array { ); } +function windowsFileDropBuffer(filePaths: readonly string[]): Uint8Array { + const names = Buffer.from(`${filePaths.join("\0")}\0\0`, "utf16le"); + const header = Buffer.alloc(20); + header.writeUInt32LE(20, 0); + header.writeUInt32LE(1, 16); + return Buffer.concat([header, names]); +} + +function linuxFileUriList(filePaths: readonly string[]): Uint8Array { + return Buffer.from( + `${filePaths.map((filePath) => pathToFileURL(filePath).href).join("\r\n")}\r\n`, + "utf8", + ); +} + +function fileClipboardData( + filePaths: readonly string[], + platform: NodeJS.Platform, +): Readonly<{ format: string; buffer: Uint8Array }> { + if (platform === "darwin") { + return { format: "NSFilenamesPboardType", buffer: fileListPropertyList(filePaths) }; + } + if (platform === "win32") { + return { format: "FileNameW", buffer: windowsFileDropBuffer(filePaths) }; + } + if (platform === "linux") { + return { format: "text/uri-list", buffer: linuxFileUriList(filePaths) }; + } + throw new Error("当前平台不支持文件粘贴"); +} + export function writePreparedStackItem( item: PreparedStackItem, clipboard: ClipboardWriter, nativeImage: NativeImageApi, + platform: NodeJS.Platform = process.platform, ): boolean { if (item.type === "text") { clipboard.writeText(item.text); @@ -90,7 +123,8 @@ export function writePreparedStackItem( return true; } if (item.type === "files") { - clipboard.writeBuffer("NSFilenamesPboardType", fileListPropertyList(item.filePaths)); + const data = fileClipboardData(item.filePaths, platform); + clipboard.writeBuffer(data.format, data.buffer); return true; } const image = nativeImage.createFromPath(item.imagePath); diff --git a/plugins/pasteboard-pro/apps/ztools/preload/quick-look.ts b/plugins/pasteboard-pro/apps/ztools/preload/quick-look.ts index b42d6a707..c5216101d 100644 --- a/plugins/pasteboard-pro/apps/ztools/preload/quick-look.ts +++ b/plugins/pasteboard-pro/apps/ztools/preload/quick-look.ts @@ -23,6 +23,22 @@ export type QuickLookOptions = Readonly<{ const QUICK_LOOK_PATH = "/usr/bin/qlmanage"; const DEFAULT_TIMEOUT_MS = 5_000; +function previewCommand(platform: NodeJS.Platform, filePath: string): Readonly<{ + command: string; + args: readonly string[]; +}> { + if (platform === "darwin") { + return { command: QUICK_LOOK_PATH, args: ["-p", filePath] }; + } + if (platform === "win32") { + return { command: "explorer.exe", args: [filePath] }; + } + if (platform === "linux") { + return { command: "xdg-open", args: [filePath] }; + } + throw new Error("当前平台不支持文件预览"); +} + const defaultSpawn: QuickLookSpawn = (command, args, options) => nodeSpawn(command, [...args], options) as QuickLookProcess; @@ -30,9 +46,6 @@ export async function openQuickLook( filePath: string, options: QuickLookOptions = {}, ): Promise { - if ((options.platform ?? process.platform) !== "darwin") { - throw new Error("Quick Look 仅支持 macOS"); - } if (!path.isAbsolute(filePath)) { throw new TypeError("Quick Look path must be absolute"); } @@ -44,11 +57,12 @@ export async function openQuickLook( if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { throw new RangeError("Quick Look timeout must be finite and positive"); } + const command = previewCommand(options.platform ?? process.platform, filePath); await new Promise((resolve, reject) => { let child: ReturnType; try { - child = (options.spawn ?? defaultSpawn)(QUICK_LOOK_PATH, ["-p", filePath], { + child = (options.spawn ?? defaultSpawn)(command.command, command.args, { shell: false, stdio: "ignore", detached: true, diff --git a/plugins/pasteboard-pro/apps/ztools/public/plugin.json b/plugins/pasteboard-pro/apps/ztools/public/plugin.json index 51530c368..b7c11f391 100644 --- a/plugins/pasteboard-pro/apps/ztools/public/plugin.json +++ b/plugins/pasteboard-pro/apps/ztools/public/plugin.json @@ -4,7 +4,7 @@ "title": "Paste剪切板", "description": "Paste 风格的本地剪贴板历史、分组、预览与连续粘贴队列", "author": "harris", - "version": "1.1.1", + "version": "1.2.0", "main": "index.html", "preload": "preload.js", "unpack": "@(pasteboard-vision|paste-stack-key-monitor.py)", @@ -12,7 +12,7 @@ "pluginSetting": { "backgroundRunning": true }, - "platform": ["darwin"], + "platform": ["darwin", "win32", "linux"], "categories": ["productivity"], "features": [ { diff --git a/plugins/pasteboard-pro/apps/ztools/scripts/assemble-dist.mjs b/plugins/pasteboard-pro/apps/ztools/scripts/assemble-dist.mjs index 66f1ce815..084084916 100644 --- a/plugins/pasteboard-pro/apps/ztools/scripts/assemble-dist.mjs +++ b/plugins/pasteboard-pro/apps/ztools/scripts/assemble-dist.mjs @@ -12,6 +12,7 @@ const helperPath = path.join( "dist", "pasteboard-vision", ); +const includeVisionHelper = process.platform === "darwin"; const outputRoot = path.resolve(appRoot, "../../dist/ztools"); async function filesRecursively(root, prefix = "") { @@ -34,14 +35,16 @@ export async function verifyAssembledPackage(root) { manifest.preload, manifest.logo, "package.json", - "pasteboard-vision", + ...(includeVisionHelper ? ["pasteboard-vision"] : []), ]) { assert.equal(typeof relative, "string"); assert.equal((await stat(path.join(root, relative))).isFile(), true); } - const helper = await stat(path.join(root, "pasteboard-vision")); - assert.notEqual(helper.mode & 0o111, 0, "Vision helper must be executable"); + if (includeVisionHelper) { + const helper = await stat(path.join(root, "pasteboard-vision")); + assert.notEqual(helper.mode & 0o111, 0, "Vision helper must be executable"); + } const files = await filesRecursively(root); const forbidden = files.filter( @@ -58,7 +61,9 @@ export async function assemblePackage() { await rm(outputRoot, { recursive: true, force: true }); await mkdir(outputRoot, { recursive: true }); await cp(buildRoot, outputRoot, { recursive: true }); - await cp(helperPath, path.join(outputRoot, "pasteboard-vision")); + if (includeVisionHelper) { + await cp(helperPath, path.join(outputRoot, "pasteboard-vision")); + } return verifyAssembledPackage(outputRoot); } diff --git a/plugins/pasteboard-pro/apps/ztools/scripts/verify-package.mjs b/plugins/pasteboard-pro/apps/ztools/scripts/verify-package.mjs index 73ab2fb77..5ad1c3a05 100644 --- a/plugins/pasteboard-pro/apps/ztools/scripts/verify-package.mjs +++ b/plugins/pasteboard-pro/apps/ztools/scripts/verify-package.mjs @@ -30,7 +30,8 @@ assert.equal( ); assert.equal(manifest.logo, "logo.png"); assert.deepEqual(manifest.pluginSetting, { backgroundRunning: true }); -assert.deepEqual(manifest.platform, ["darwin"]); +assert.deepEqual(manifest.platform, ["darwin", "win32", "linux"]); +assert.equal(manifest.platform.includes(process.platform), true); assert.deepEqual(manifest.categories, ["productivity"]); assert.equal("development" in manifest, false); diff --git a/plugins/pasteboard-pro/apps/ztools/src/App.vue b/plugins/pasteboard-pro/apps/ztools/src/App.vue index 18b5edbb6..e3000258d 100644 --- a/plugins/pasteboard-pro/apps/ztools/src/App.vue +++ b/plugins/pasteboard-pro/apps/ztools/src/App.vue @@ -152,13 +152,17 @@ async function pasteItem( } } -async function pasteItems(itemIds: readonly string[], plainText = false): Promise { +async function pasteItems( + itemIds: readonly string[], + plainText = false, + combineText = true, +): Promise { const items = itemIds.flatMap((itemId) => { const item = visibleItems.value.find((candidate) => candidate.id === itemId); return item === undefined ? [] : [item]; }); const combinedContent = - items.length === itemIds.length && items.length > 1 + combineText && items.length === itemIds.length && items.length > 1 ? combinedTextPasteContent(items) : undefined; if (combinedContent !== undefined) { @@ -294,6 +298,12 @@ async function handleEffect(effect: PasteboardKeyboardEffect | null): Promise async function quickLookItem(itemId: string): Promise { try { await window.pasteboardPro?.quickLookItem(itemId); - status.value = "已在 Quick Look 中打开"; + status.value = "已打开文件"; } catch (error) { status.value = error instanceof Error ? error.message : "Quick Look 打开失败"; } @@ -687,7 +697,18 @@ onMounted(async () => { const settings = await window.pasteboardPro?.getPrivacySettings(); paused.value = settings?.pause.paused ?? false; const pasteStack = await window.pasteboardPro?.getPasteStack(); - if (pasteStack !== undefined) state.setPasteStack(pasteStack); + const platformCapabilities = window.pasteboardPro?.getPlatformCapabilities(); + if ( + pasteStack !== undefined && + (platformCapabilities?.supportsGlobalPasteQueue ?? true) + ) { + state.setPasteStack(pasteStack); + } else if (pasteStack !== undefined && pasteStack.itemIds.length > 0) { + const cleared = { direction: pasteStack.direction, itemIds: [] as string[] }; + state.setPasteStack(cleared, true); + await window.pasteboardPro?.savePasteStack(cleared); + status.value = "已清理仅支持 macOS 的旧粘贴队列"; + } await loadHistory(); await loadPinboards(); }); diff --git a/plugins/pasteboard-pro/apps/ztools/src/components/Preview.vue b/plugins/pasteboard-pro/apps/ztools/src/components/Preview.vue index 52d6c2fac..7497f3c58 100644 --- a/plugins/pasteboard-pro/apps/ztools/src/components/Preview.vue +++ b/plugins/pasteboard-pro/apps/ztools/src/components/Preview.vue @@ -71,7 +71,7 @@ watch( 识别文字 diff --git a/plugins/pasteboard-pro/apps/ztools/src/components/SettingsPanel.vue b/plugins/pasteboard-pro/apps/ztools/src/components/SettingsPanel.vue index 6ada7cd98..ec5ede166 100644 --- a/plugins/pasteboard-pro/apps/ztools/src/components/SettingsPanel.vue +++ b/plugins/pasteboard-pro/apps/ztools/src/components/SettingsPanel.vue @@ -217,7 +217,7 @@ function save(): void { {{ option[1] }} - 一次性模式按 Enter 合并粘贴;逐一模式按 Enter 生成队列,再连续按 Command-V。 + 一次性模式按 Enter 合并粘贴;macOS 逐一模式可连续按 Command-V,Windows/Linux 按 Enter 逐项粘贴。 @@ -244,7 +244,7 @@ function save(): void { -

密码和派生密钥只保存在 macOS 钥匙串;插件数据库不保存明文秘密。

+

密码和派生密钥只保存在系统安全存储;插件数据库不保存明文秘密。

diff --git a/plugins/pasteboard-pro/apps/ztools/src/env.d.ts b/plugins/pasteboard-pro/apps/ztools/src/env.d.ts index b350e575e..98c5d612d 100644 --- a/plugins/pasteboard-pro/apps/ztools/src/env.d.ts +++ b/plugins/pasteboard-pro/apps/ztools/src/env.d.ts @@ -14,6 +14,13 @@ import type { PasteStackState } from "@pasteboard-pro/core"; declare global { interface Window { pasteboardPro?: Readonly<{ + getPlatformCapabilities(): { + platform: NodeJS.Platform; + supportsGlobalPasteQueue: boolean; + supportsQuickLook: boolean; + supportsSystemOcr: boolean; + supportsImageRotation: boolean; + }; searchHistory( query?: string, limit?: number, diff --git a/plugins/pasteboard-pro/apps/ztools/tests/image-rotation.test.ts b/plugins/pasteboard-pro/apps/ztools/tests/image-rotation.test.ts index 222420b63..44e85ecbb 100644 --- a/plugins/pasteboard-pro/apps/ztools/tests/image-rotation.test.ts +++ b/plugins/pasteboard-pro/apps/ztools/tests/image-rotation.test.ts @@ -93,4 +93,35 @@ describe("image rotation", () => { ).rejects.toThrow(/64 KiB/i); expect(process.killed).toBe(true); }); + + it("uses ImageMagick on Windows and Linux", async () => { + for (const [platform, command] of [["win32", "magick.exe"], ["linux", "magick"]] as const) { + const process = new FakeProcess(); + const calls: unknown[][] = []; + const spawn: OcrSpawn = (...args) => { + calls.push(args); + queueMicrotask(() => process.emit("close", 0)); + return process; + }; + await rotateImageFile( + { + sourcePath: "/tmp/input.jpg", + destinationPath: "/tmp/output.png", + quarterTurns: 1, + }, + { + platform, + spawn, + stat: async () => ({ isFile: () => true }), + }, + ); + expect(calls[0]?.[0]).toBe(command); + expect(calls[0]?.[1]).toEqual([ + "/tmp/input.jpg", + "-rotate", + "90", + "png:/tmp/output.png", + ]); + } + }); }); diff --git a/plugins/pasteboard-pro/apps/ztools/tests/keychain.test.ts b/plugins/pasteboard-pro/apps/ztools/tests/keychain.test.ts index 52bcd1542..3d72239d2 100644 --- a/plugins/pasteboard-pro/apps/ztools/tests/keychain.test.ts +++ b/plugins/pasteboard-pro/apps/ztools/tests/keychain.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { createKeychainSecretStore, + createPortableSecretStore, PASTEBOARD_KEYCHAIN_SERVICE, type KeychainExecFile, } from "../preload/keychain"; @@ -68,4 +69,57 @@ describe("PasteboardPro Keychain adapter", () => { await expect(store.load("webdav")).resolves.toBeUndefined(); expect(logger.error).not.toHaveBeenCalled(); }); + + it("stores encrypted credentials through Electron safeStorage on Windows/Linux", async () => { + const documents = new Map>(); + const database = { + async get(id: string) { + const document = documents.get(id); + if (document === undefined) throw { status: 404 }; + return structuredClone(document); + }, + async put(document: Record) { + documents.set(document._id as string, structuredClone(document)); + return { ok: true }; + }, + async remove(document: Record) { + documents.delete(document._id as string); + return { ok: true }; + }, + }; + const safeStorage = { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`encrypted:${value}`, "utf8"), + decryptString: (value: Uint8Array) => + Buffer.from(value).toString("utf8").replace(/^encrypted:/u, ""), + getSelectedStorageBackend: () => "gnome-libsecret", + }; + const store = createPortableSecretStore({ database, safeStorage }); + + await store.save("webdav", "super-secret"); + expect([...documents.values()][0]).toMatchObject({ + type: "pasteboard-pro-secret", + account: "webdav", + ciphertext: expect.not.stringContaining("super-secret"), + }); + await expect(store.load("webdav")).resolves.toBe("super-secret"); + await store.delete("webdav"); + await expect(store.load("webdav")).resolves.toBeUndefined(); + }); + + it("refuses Linux basic_text safeStorage instead of persisting plaintext", async () => { + const store = createPortableSecretStore({ + database: { + async get() { throw { status: 404 }; }, + async put() { return {}; }, + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value) => Buffer.from(value), + decryptString: (value) => Buffer.from(value).toString("utf8"), + getSelectedStorageBackend: () => "basic_text", + }, + }); + await expect(store.save("webdav", "secret")).rejects.toThrow(/安全存储/); + }); }); diff --git a/plugins/pasteboard-pro/apps/ztools/tests/ocr.test.ts b/plugins/pasteboard-pro/apps/ztools/tests/ocr.test.ts index 39a9723be..145052311 100644 --- a/plugins/pasteboard-pro/apps/ztools/tests/ocr.test.ts +++ b/plugins/pasteboard-pro/apps/ztools/tests/ocr.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { createOcrClient, + createTesseractOcrClient, validateOcrRequest, type OcrProcess, type OcrSpawn, @@ -130,4 +131,30 @@ describe("OCR protocol", () => { expect(source).toContain("readLine"); expect(source).not.toMatch(/URLSession|Process\(|system\(|NSTask/); }); + + it("uses Tesseract CLI on Windows/Linux", async () => { + for (const [platform, command] of [["win32", "tesseract.exe"], ["linux", "tesseract"]] as const) { + const process = new FakeProcess(); + const calls: unknown[][] = []; + const spawn: OcrSpawn = (...args) => { + calls.push(args); + queueMicrotask(() => { + process.stdout.emit("data", Buffer.from("hello from tesseract\n")); + process.emit("close", 0); + }); + return process; + }; + const client = createTesseractOcrClient({ + platform, + spawn, + stat: async () => ({ isFile: () => true }), + }); + await expect(client.recognize("/tmp/input.png")).resolves.toBe("hello from tesseract"); + expect(calls[0]).toEqual([ + command, + ["/tmp/input.png", "stdout"], + { shell: false, stdio: ["pipe", "pipe", "pipe"] }, + ]); + } + }); }); diff --git a/plugins/pasteboard-pro/apps/ztools/tests/paste-stack-runtime.test.ts b/plugins/pasteboard-pro/apps/ztools/tests/paste-stack-runtime.test.ts index 358b8f089..08886915a 100644 --- a/plugins/pasteboard-pro/apps/ztools/tests/paste-stack-runtime.test.ts +++ b/plugins/pasteboard-pro/apps/ztools/tests/paste-stack-runtime.test.ts @@ -48,6 +48,27 @@ describe("paste stack runtime", () => { expect(writes).toEqual(["first queued value"]); }); + it("writes native file clipboard formats on Windows and Linux", () => { + const writes: Array<{ format: string; buffer: Uint8Array }> = []; + const clipboard = { + write() {}, + writeText() {}, + writeImage() {}, + writeBuffer(format: string, buffer: Uint8Array) { + writes.push({ format, buffer }); + }, + }; + const nativeImage = { createFromPath() { throw new Error("unexpected image load"); } }; + const item = { type: "files" as const, filePaths: ["/tmp/a.txt", "/tmp/b.txt"] }; + + expect(writePreparedStackItem(item, clipboard, nativeImage, "win32")).toBe(true); + expect(writes[0]?.format).toBe("FileNameW"); + expect(Buffer.from(writes[0]!.buffer).toString("utf16le")).toContain("/tmp/a.txt"); + expect(writePreparedStackItem(item, clipboard, nativeImage, "linux")).toBe(true); + expect(writes[1]?.format).toBe("text/uri-list"); + expect(Buffer.from(writes[1]!.buffer).toString("utf8")).toContain("file:///tmp/a.txt"); + }); + it("consumes one persisted item for every released Command-V press", async () => { let document: Record | undefined; const stackStore = new ZToolsPasteStackStore({ diff --git a/plugins/pasteboard-pro/apps/ztools/tests/quick-look.test.ts b/plugins/pasteboard-pro/apps/ztools/tests/quick-look.test.ts index b3fdba62f..1326e9d21 100644 --- a/plugins/pasteboard-pro/apps/ztools/tests/quick-look.test.ts +++ b/plugins/pasteboard-pro/apps/ztools/tests/quick-look.test.ts @@ -68,9 +68,22 @@ describe("Quick Look", () => { ).rejects.toThrow("launch failed"); }); - it("rejects Quick Look on non-macOS platforms", async () => { - await expect( - openQuickLook("/tmp/example.pdf", { platform: "linux" }), - ).rejects.toThrow("Quick Look 仅支持 macOS"); + it("opens files with the platform default viewer on Windows and Linux", async () => { + for (const [platform, command] of [["win32", "explorer.exe"], ["linux", "xdg-open"]] as const) { + const child = new FakeProcess(); + const calls: unknown[][] = []; + const spawn: QuickLookSpawn = (...args) => { + calls.push(args); + queueMicrotask(() => child.emit("spawn")); + return child; + }; + await openQuickLook("/tmp/example.pdf", { + platform, + spawn, + stat: async () => ({ isFile: () => true }), + }); + expect(calls[0]?.[0]).toBe(command); + expect(calls[0]?.[1]).toEqual(["/tmp/example.pdf"]); + } }); }); diff --git a/plugins/pasteboard-pro/build-plugin.sh b/plugins/pasteboard-pro/build-plugin.sh index 08fe8de28..159b59ac1 100755 --- a/plugins/pasteboard-pro/build-plugin.sh +++ b/plugins/pasteboard-pro/build-plugin.sh @@ -10,16 +10,15 @@ if [ "$NODE_MAJOR" -lt 20 ]; then fi HELPER="$ROOT/apps/ztools/native/vision-helper/dist/pasteboard-vision" -if [ "$(uname -s)" != "Darwin" ]; then - echo "PasteboardPro contains a macOS native helper and must be built on macOS" >&2 - exit 1 +if [ "$(uname -s)" = "Darwin" ]; then + "$ROOT/apps/ztools/native/vision-helper/build.sh" + codesign --force --sign - "$HELPER" + codesign --verify --strict "$HELPER" + chmod +x "$HELPER" +else + echo "非 macOS 构建跳过 Vision helper,运行时使用 Tesseract OCR" fi -"$ROOT/apps/ztools/native/vision-helper/build.sh" -codesign --force --sign - "$HELPER" -codesign --verify --strict "$HELPER" -chmod +x "$HELPER" - corepack pnpm@9.15.9 install --frozen-lockfile corepack pnpm@9.15.9 test corepack pnpm@9.15.9 test:contract diff --git a/plugins/pasteboard-pro/plugin.json b/plugins/pasteboard-pro/plugin.json index 51530c368..b7c11f391 100644 --- a/plugins/pasteboard-pro/plugin.json +++ b/plugins/pasteboard-pro/plugin.json @@ -4,7 +4,7 @@ "title": "Paste剪切板", "description": "Paste 风格的本地剪贴板历史、分组、预览与连续粘贴队列", "author": "harris", - "version": "1.1.1", + "version": "1.2.0", "main": "index.html", "preload": "preload.js", "unpack": "@(pasteboard-vision|paste-stack-key-monitor.py)", @@ -12,7 +12,7 @@ "pluginSetting": { "backgroundRunning": true }, - "platform": ["darwin"], + "platform": ["darwin", "win32", "linux"], "categories": ["productivity"], "features": [ { diff --git a/plugins/pasteboard-pro/scripts/test-workspace-contract.mjs b/plugins/pasteboard-pro/scripts/test-workspace-contract.mjs index e2db594eb..a7947c4e2 100644 --- a/plugins/pasteboard-pro/scripts/test-workspace-contract.mjs +++ b/plugins/pasteboard-pro/scripts/test-workspace-contract.mjs @@ -17,7 +17,7 @@ const normalizedWorkspace = workspace.replaceAll("\r\n", "\n"); assert.equal(pkg.private, true); assert.equal(pkg.packageManager, "pnpm@9.15.9"); assert.deepEqual(manifest, ztoolsManifest); -assert.deepEqual(manifest.platform, ["darwin"]); +assert.deepEqual(manifest.platform, ["darwin", "win32", "linux"]); assert.equal(pkg.scripts.test, "vitest run"); assert.equal( pkg.scripts["test:release-archive"], From da103978926b2528babc07d74fe8d6305705ca84 Mon Sep 17 00:00:00 2001 From: wangzihao Date: Mon, 3 Aug 2026 13:34:07 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(pasteboardpro):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=8B=96=E6=8B=BD=E5=89=AA=E8=B4=B4=E6=9D=BF=E6=BA=90=E5=86=85?= =?UTF-8?q?=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为文本和富文本写入真实拖拽格式,图片和文件使用宿主原生源文件拖拽,并阻止缩略图 URL 作为默认拖拽内容。 AI-Co-Authored-By: Codex --- .../apps/ztools/src/components/PasteCard.vue | 18 ++- .../apps/ztools/src/drag-content.ts | 26 +++++ .../apps/ztools/tests/drag-content.test.ts | 103 ++++++++++++++++++ 3 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 plugins/pasteboard-pro/apps/ztools/src/drag-content.ts create mode 100644 plugins/pasteboard-pro/apps/ztools/tests/drag-content.test.ts diff --git a/plugins/pasteboard-pro/apps/ztools/src/components/PasteCard.vue b/plugins/pasteboard-pro/apps/ztools/src/components/PasteCard.vue index bdff1c725..08ac2eea2 100644 --- a/plugins/pasteboard-pro/apps/ztools/src/components/PasteCard.vue +++ b/plugins/pasteboard-pro/apps/ztools/src/components/PasteCard.vue @@ -8,6 +8,7 @@ import { observeThumbnailVisibility, } from "../thumbnail-loader"; import { containContextMenuKeydown } from "../context-menu-keyboard"; +import { writeSourceDragData } from "../drag-content"; const props = defineProps<{ item: PasteItem; @@ -60,10 +61,11 @@ function handleContextMenuKeydown(event: KeyboardEvent): void { } function beginDrag(event: DragEvent): void { - event.dataTransfer?.setData("application/x-pasteboard-pro-item", props.item.id); - if (event.dataTransfer !== null) { - event.dataTransfer.effectAllowed = "move"; + if (props.item.kind === "image" || props.item.payload.filePaths !== undefined) { + beginNativeFileDrag(event); + return; } + if (event.dataTransfer !== null) writeSourceDragData(props.item, event.dataTransfer); } function prepareNativeFileDrag(): void { @@ -79,9 +81,12 @@ function beginNativeFileDrag(event: DragEvent): void { if (event.dataTransfer !== null) { event.dataTransfer.effectAllowed = "copy"; } - if (window.pasteboardPro?.startNativeFileDrag(props.item.id) === true) { - event.stopPropagation(); - } + // The nested element has a browser-native drag behavior that exposes + // its thumbnail URL. Always cancel that default payload; the host API below + // supplies the original image/file as a native file drag instead. + window.pasteboardPro?.startNativeFileDrag(props.item.id); + event.preventDefault(); + event.stopPropagation(); } const bodyText = computed(() => { @@ -134,6 +139,7 @@ onBeforeUnmount(() => { role="option" tabindex="0" draggable="true" + @pointerdown="prepareNativeFileDrag" @dragstart="beginDrag" @click="emit('select', item.id, $event.shiftKey, $event.metaKey)" @dblclick="emit('paste', item.id)" diff --git a/plugins/pasteboard-pro/apps/ztools/src/drag-content.ts b/plugins/pasteboard-pro/apps/ztools/src/drag-content.ts new file mode 100644 index 000000000..bd900a7e2 --- /dev/null +++ b/plugins/pasteboard-pro/apps/ztools/src/drag-content.ts @@ -0,0 +1,26 @@ +import type { PasteItem } from "@pasteboard-pro/core"; + +export type DragDataTransfer = { + setData(format: string, data: string): void; + effectAllowed: string; +}; + +export function writeSourceDragData( + item: PasteItem, + dataTransfer: DragDataTransfer, +): void { + dataTransfer.setData("application/x-pasteboard-pro-item", item.id); + if (item.kind === "image" || item.payload.filePaths !== undefined) { + dataTransfer.effectAllowed = "copy"; + return; + } + + const text = item.payload.text ?? item.ocrText; + if (text !== undefined) { + dataTransfer.setData("text/plain", text); + } + if (item.payload.html !== undefined) { + dataTransfer.setData("text/html", item.payload.html); + } + dataTransfer.effectAllowed = "copy"; +} diff --git a/plugins/pasteboard-pro/apps/ztools/tests/drag-content.test.ts b/plugins/pasteboard-pro/apps/ztools/tests/drag-content.test.ts new file mode 100644 index 000000000..446de0b56 --- /dev/null +++ b/plugins/pasteboard-pro/apps/ztools/tests/drag-content.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import { PasteItemSchema, type PasteItem } from "@pasteboard-pro/core"; +import { writeSourceDragData } from "../src/drag-content"; + +function item(overrides: Partial = {}): PasteItem { + return PasteItemSchema.parse({ + id: "item-1", + kind: "text", + sourceDeviceId: "device-1", + copiedAt: "2026-07-17T00:00:00.000Z", + updatedAt: "2026-07-17T00:00:00.000Z", + contentFingerprint: "fingerprint-1", + payload: { revision: "revision-1" }, + pinned: false, + fieldClocks: {}, + ...overrides, + }); +} + +function transfer(): { + setData: (format: string, data: string) => void; + effectAllowed: string; + values: Map; +} { + const values = new Map(); + return { + setData(format, data) { + values.set(format, data); + }, + effectAllowed: "none", + values, + }; +} + +describe("source clipboard drag data", () => { + it("exposes the original plain text to external drop targets", () => { + const dataTransfer = transfer(); + + writeSourceDragData( + item({ payload: { revision: "revision-1", text: "actual source text" } }), + dataTransfer, + ); + + expect(dataTransfer.values).toEqual( + new Map([ + ["application/x-pasteboard-pro-item", "item-1"], + ["text/plain", "actual source text"], + ]), + ); + expect(dataTransfer.effectAllowed).toBe("copy"); + }); + + it("provides both plain and rich source formats for rich text", () => { + const dataTransfer = transfer(); + + writeSourceDragData( + item({ + kind: "rich_text", + payload: { + revision: "revision-1", + text: "plain source", + html: "rich source", + }, + }), + dataTransfer, + ); + + expect(dataTransfer.values.get("text/plain")).toBe("plain source"); + expect(dataTransfer.values.get("text/html")).toBe("rich source"); + }); + + it("does not turn image or file records into internal links", () => { + for (const clipboardItem of [ + item({ + id: "image-1", + kind: "image", + payload: { + revision: "revision-image", + text: "https://example.test/image", + blobId: "blob-image", + }, + }), + item({ + id: "files-1", + kind: "files", + payload: { + revision: "revision-files", + text: "https://example.test/files", + filePaths: ["/tmp/report.pdf"], + }, + }), + ]) { + const dataTransfer = transfer(); + writeSourceDragData(clipboardItem, dataTransfer); + + expect(dataTransfer.values).toEqual( + new Map([["application/x-pasteboard-pro-item", clipboardItem.id]]), + ); + expect(dataTransfer.effectAllowed).toBe("copy"); + } + }); +}); From 13b0a5df7fa1141fe7f536ada95fe0165f750359 Mon Sep 17 00:00:00 2001 From: wangzihao Date: Mon, 3 Aug 2026 13:34:13 +0800 Subject: [PATCH 3/5] =?UTF-8?q?docs(pasteboardpro):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E8=B7=A8=E5=B9=B3=E5=8F=B0=E4=BD=BF=E7=94=A8=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充 Windows/Linux 平台能力、队列行为和构建验证说明,并记录 1.2.0 版本变更。 AI-Co-Authored-By: Codex --- plugins/pasteboard-pro/CHANGELOG.md | 8 ++++++++ plugins/pasteboard-pro/README.md | 14 +++++++------- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/plugins/pasteboard-pro/CHANGELOG.md b/plugins/pasteboard-pro/CHANGELOG.md index 7d0b3603e..b6cff9154 100644 --- a/plugins/pasteboard-pro/CHANGELOG.md +++ b/plugins/pasteboard-pro/CHANGELOG.md @@ -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 - 修复宿主剪贴板历史已更新但列表未及时刷新的问题。 diff --git a/plugins/pasteboard-pro/README.md b/plugins/pasteboard-pro/README.md index 30f8dbe1d..342c3cbb8 100644 --- a/plugins/pasteboard-pro/README.md +++ b/plugins/pasteboard-pro/README.md @@ -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 后按选择顺序逐项粘贴。 - 新复制内容实时定位;图片优先加载轻量缩略图,避免阻塞历史列表。 - 支持紧凑布局和上、下、左、右贴边显示,始终跟随当前鼠标所在屏幕。 - 提供历史保留、附件预算、敏感内容排除、屏幕共享保护和暂停捕获。 @@ -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 preload 与 macOS 原生 helper。 +- ZTools:使用 Vue 3 UI、Electron preload;macOS 使用 Vision helper,Windows/Linux 使用系统安全存储和外部 Tesseract/ImageMagick 命令。 ## 隐私与同步 @@ -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 @@ -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 构建逻辑仍限制在插件目录内。 ## 目录结构 From 6395445b112835875658543ec42b23cd27273bb9 Mon Sep 17 00:00:00 2001 From: wangzihao Date: Mon, 3 Aug 2026 14:08:56 +0800 Subject: [PATCH 4/5] =?UTF-8?q?test(pasteboardpro):=20=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E6=97=8B=E8=BD=AC=E6=B5=8B=E8=AF=95=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 macOS sips 断言显式指定 darwin,避免 Linux CI 使用 ImageMagick 时误判失败。 AI-Co-Authored-By: Codex --- plugins/pasteboard-pro/apps/ztools/tests/image-rotation.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/pasteboard-pro/apps/ztools/tests/image-rotation.test.ts b/plugins/pasteboard-pro/apps/ztools/tests/image-rotation.test.ts index 44e85ecbb..6ceff33c4 100644 --- a/plugins/pasteboard-pro/apps/ztools/tests/image-rotation.test.ts +++ b/plugins/pasteboard-pro/apps/ztools/tests/image-rotation.test.ts @@ -37,6 +37,7 @@ describe("image rotation", () => { quarterTurns: -1, }, { + platform: "darwin", spawn, stat: async () => ({ isFile: () => true, size: 128 }), }, From bb05181dd90b55a8ec1aa26adb13366e3eb8c377 Mon Sep 17 00:00:00 2001 From: wangzihao Date: Wed, 5 Aug 2026 16:46:46 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(pasteboardpro):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E5=86=85=E5=AE=B9=E7=B2=98=E8=B4=B4=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改用插件保存的源内容写回剪贴板,并正确处理 ZTools 剪贴板 API 的成功状态。 AI-Co-Authored-By: Codex (GPT-5) --- .../apps/ztools/preload/paste-item.ts | 41 ++++--- .../apps/ztools/preload/privacy.ts | 62 +++++++---- .../apps/ztools/tests/paste-item.test.ts | 103 ++++++++++++++++-- .../apps/ztools/tests/privacy.test.ts | 30 ++++- 4 files changed, 188 insertions(+), 48 deletions(-) diff --git a/plugins/pasteboard-pro/apps/ztools/preload/paste-item.ts b/plugins/pasteboard-pro/apps/ztools/preload/paste-item.ts index d3ca7a370..e439df851 100644 --- a/plugins/pasteboard-pro/apps/ztools/preload/paste-item.ts +++ b/plugins/pasteboard-pro/apps/ztools/preload/paste-item.ts @@ -1,5 +1,6 @@ import type { CanonicalClipboardRecord } from "./clipboard-store"; import { + clipboardWriteSucceeded, performDirectPaste, type ClipboardPasteHost, type DirectPasteResult, @@ -10,19 +11,32 @@ export function directPasteTarget( record: CanonicalClipboardRecord, plainText = false, ): DirectPasteTarget { - if (!plainText && record.origin.host === "ztools") { - return { type: "host", hostItemId: record.origin.hostItemId }; - } - if (!plainText && record.item.kind === "image" && record.origin.imagePath !== undefined) { + const target = canonicalContentTarget(record, plainText); + if (target !== undefined) return target; + throw new RangeError("该同步记录只有远端附件,当前设备尚未下载内容"); +} + +function canonicalContentTarget( + record: CanonicalClipboardRecord, + plainText = false, +): DirectPasteTarget | undefined { + const filePaths = record.item.payload.filePaths?.filter( + (filePath) => filePath.length > 0, + ); + if (!plainText && filePaths !== undefined && filePaths.length > 0) { return { type: "content", - content: { type: "image", content: record.origin.imagePath }, + content: { type: "file", content: filePaths }, }; } - if (!plainText && record.item.payload.html !== undefined) { + if ( + !plainText && + record.item.kind === "image" && + record.origin.imagePath !== undefined + ) { return { type: "content", - content: { type: "html", content: record.item.payload.html }, + content: { type: "image", content: record.origin.imagePath }, }; } const text = record.item.payload.text ?? record.item.ocrText; @@ -32,7 +46,7 @@ export function directPasteTarget( content: { type: "text", content: text }, }; } - throw new RangeError("该同步记录只有远端附件,当前设备尚未下载内容"); + return undefined; } export function pasteCanonicalRecord( @@ -49,9 +63,10 @@ export async function copyCanonicalRecord( plainText = false, ): Promise { const target = directPasteTarget(record, plainText); - if (target.type === "host") { - await host.write(target.hostItemId, false); - } else { - await host.writeContent(target.content, false); - } + const result = + target.type === "host" + ? await host.write(target.hostItemId, false) + : await host.writeContent(target.content, false); + if (clipboardWriteSucceeded(result)) return; + throw new Error("ZTools 未能复制所选剪贴板内容"); } diff --git a/plugins/pasteboard-pro/apps/ztools/preload/privacy.ts b/plugins/pasteboard-pro/apps/ztools/preload/privacy.ts index 88a8f3145..ed4bba88e 100644 --- a/plugins/pasteboard-pro/apps/ztools/preload/privacy.ts +++ b/plugins/pasteboard-pro/apps/ztools/preload/privacy.ts @@ -56,21 +56,32 @@ export type RetentionPrunePlan = Readonly<{ overBudget: boolean; }>; -export type ClipboardWriteContent = Readonly<{ - type: string; - content: unknown; -}>; +export type ClipboardWriteContent = + | Readonly<{ type: "text"; content: string }> + | Readonly<{ type: "image"; content: string }> + | Readonly<{ type: "file"; content: string | readonly string[] }>; + +export type ClipboardWriteResult = + | boolean + | Readonly<{ success: boolean }>; + +export function clipboardWriteSucceeded(result: ClipboardWriteResult): boolean { + return ( + result === true || + (typeof result === "object" && result.success === true) + ); +} export type DirectPasteTarget = | Readonly<{ type: "host"; hostItemId: string }> | Readonly<{ type: "content"; content: ClipboardWriteContent }>; export interface ClipboardPasteHost { - write(id: string, shouldPaste: boolean): Promise; + write(id: string, shouldPaste: boolean): Promise; writeContent( input: ClipboardWriteContent, shouldPaste: boolean, - ): Promise; + ): Promise; } export type DirectPasteResult = @@ -488,22 +499,33 @@ export async function performDirectPaste( target: DirectPasteTarget, host: ClipboardPasteHost, ): Promise { - const write = async (shouldPaste: boolean): Promise => { - if (target.type === "host") { - await host.write(target.hostItemId, shouldPaste); - } else { - await host.writeContent(target.content, shouldPaste); - } + const write = async ( + currentTarget: DirectPasteTarget, + shouldPaste: boolean, + ): Promise => { + const result = + currentTarget.type === "host" + ? await host.write(currentTarget.hostItemId, shouldPaste) + : await host.writeContent(currentTarget.content, shouldPaste); + return clipboardWriteSucceeded(result); }; + let directPasteError: unknown = new Error("ZTools 未能写入所选剪贴板内容"); try { - await write(true); - return { status: "pasted" }; - } catch (directPasteError) { - await write(false); - return { - status: "accessibility_required", - directPasteError: errorMessage(directPasteError), - }; + if (await write(target, true)) { + return { status: "pasted" }; + } + } catch (error) { + directPasteError = error; } + + if (!(await write(target, false))) { + throw new Error("ZTools 未能复制所选剪贴板内容", { + cause: directPasteError, + }); + } + return { + status: "accessibility_required", + directPasteError: errorMessage(directPasteError), + }; } diff --git a/plugins/pasteboard-pro/apps/ztools/tests/paste-item.test.ts b/plugins/pasteboard-pro/apps/ztools/tests/paste-item.test.ts index e2c15ed6f..ce2b26a0c 100644 --- a/plugins/pasteboard-pro/apps/ztools/tests/paste-item.test.ts +++ b/plugins/pasteboard-pro/apps/ztools/tests/paste-item.test.ts @@ -31,10 +31,10 @@ function record(overrides: Partial = {}): CanonicalCli } describe("canonical direct paste", () => { - it("uses the host-native record for formatted paste", () => { + it("uses the canonical source content instead of the disposable host record", () => { expect(directPasteTarget(record())).toEqual({ - type: "host", - hostItemId: "host-1", + type: "content", + content: { type: "text", content: "Plain fallback" }, }); }); @@ -45,25 +45,106 @@ describe("canonical direct paste", () => { }); }); + it("uses canonical image and file sources supported by ZTools", () => { + const baseItem = record().item; + const imageRecord = record({ + item: PasteItemSchema.parse({ + ...baseItem, + kind: "image", + payload: { revision: "image-revision", mediaType: "image/png" }, + }), + origin: { + host: "ztools", + hostItemId: "image-host-id", + hostType: "image", + imagePath: "/tmp/source.png", + }, + }); + const fileRecord = record({ + item: PasteItemSchema.parse({ + ...baseItem, + kind: "files", + payload: { + revision: "files-revision", + filePaths: ["/tmp/source-a.txt", "/tmp/source-b.txt"], + }, + }), + origin: { + host: "ztools", + hostItemId: "file-host-id", + hostType: "file", + }, + }); + + expect(directPasteTarget(imageRecord)).toEqual({ + type: "content", + content: { type: "image", content: "/tmp/source.png" }, + }); + expect(directPasteTarget(fileRecord)).toEqual({ + type: "content", + content: { + type: "file", + content: ["/tmp/source-a.txt", "/tmp/source-b.txt"], + }, + }); + }); + it("preserves the copy-only fallback when direct insertion is denied", async () => { const host = { - write: vi.fn().mockRejectedValueOnce(new Error("denied")).mockResolvedValueOnce(undefined), - writeContent: vi.fn(), + write: vi.fn().mockRejectedValueOnce(new Error("denied")), + writeContent: vi + .fn() + .mockRejectedValueOnce(new Error("denied")) + .mockResolvedValueOnce({ success: true }), }; await expect(pasteCanonicalRecord(record(), host)).resolves.toEqual({ status: "accessibility_required", directPasteError: "denied", }); - expect(host.write.mock.calls).toEqual([ - ["host-1", true], - ["host-1", false], + expect(host.writeContent.mock.calls).toEqual([ + [{ type: "text", content: "Plain fallback" }, true], + [{ type: "text", content: "Plain fallback" }, false], ]); + expect(host.write).not.toHaveBeenCalled(); + }); + + it("pastes canonical history content without reading the host id", async () => { + const host = { + write: vi.fn(async () => false), + writeContent: vi.fn(async () => ({ success: true })), + }; + + await expect(pasteCanonicalRecord(record(), host)).resolves.toEqual({ + status: "pasted", + }); + expect(host.writeContent).toHaveBeenCalledWith( + { type: "text", content: "Plain fallback" }, + true, + ); + expect(host.write).not.toHaveBeenCalled(); }); it("copies without ever requesting direct insertion", async () => { - const host = { write: vi.fn(), writeContent: vi.fn() }; + const host = { + write: vi.fn(async () => true), + writeContent: vi.fn(async () => ({ success: true })), + }; await copyCanonicalRecord(record(), host); - expect(host.write).toHaveBeenCalledWith("host-1", false); - expect(host.writeContent).not.toHaveBeenCalled(); + expect(host.writeContent).toHaveBeenCalledWith( + { type: "text", content: "Plain fallback" }, + false, + ); + expect(host.write).not.toHaveBeenCalled(); + }); + + it("rejects copy when the host returns an unsuccessful result object", async () => { + const host = { + write: vi.fn(async () => ({ success: false })), + writeContent: vi.fn(async () => ({ success: false })), + }; + + await expect(copyCanonicalRecord(record(), host)).rejects.toThrow( + "ZTools 未能复制所选剪贴板内容", + ); }); }); diff --git a/plugins/pasteboard-pro/apps/ztools/tests/privacy.test.ts b/plugins/pasteboard-pro/apps/ztools/tests/privacy.test.ts index 6ae13ae8c..5ca9775fe 100644 --- a/plugins/pasteboard-pro/apps/ztools/tests/privacy.test.ts +++ b/plugins/pasteboard-pro/apps/ztools/tests/privacy.test.ts @@ -277,8 +277,8 @@ describe("retention", () => { describe("direct paste", () => { it("uses the host-native write path when available", async () => { - const write = vi.fn(async () => undefined); - const writeContent = vi.fn(async () => undefined); + const write = vi.fn(async () => true); + const writeContent = vi.fn(async () => true); await expect( performDirectPaste( @@ -294,7 +294,7 @@ describe("direct paste", () => { const write = vi .fn() .mockRejectedValueOnce(new Error("paste denied")) - .mockResolvedValueOnce(undefined); + .mockResolvedValueOnce(true); await expect( performDirectPaste( @@ -310,7 +310,7 @@ describe("direct paste", () => { }); it("writes canonical content for synced or plugin-owned items", async () => { - const writeContent = vi.fn(async () => undefined); + const writeContent = vi.fn(async () => true); await expect( performDirectPaste( @@ -323,4 +323,26 @@ describe("direct paste", () => { true, ); }); + + it("does not report success when every clipboard write returns false", async () => { + const write = vi.fn(async () => ({ success: false })); + const writeContent = vi.fn(async () => ({ success: false })); + + await expect( + performDirectPaste( + { type: "content", content: { type: "text", content: "selected history" } }, + { write, writeContent }, + ), + ).rejects.toThrow("ZTools 未能复制所选剪贴板内容"); + expect(writeContent).toHaveBeenNthCalledWith( + 1, + { type: "text", content: "selected history" }, + true, + ); + expect(writeContent).toHaveBeenNthCalledWith( + 2, + { type: "text", content: "selected history" }, + false, + ); + }); });