diff --git a/plugins/pasteboard-pro/CHANGELOG.md b/plugins/pasteboard-pro/CHANGELOG.md index e129bc2ca..d262bc606 100644 --- a/plugins/pasteboard-pro/CHANGELOG.md +++ b/plugins/pasteboard-pro/CHANGELOG.md @@ -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`,兼容既有安装、数据和同步协议。 diff --git a/plugins/pasteboard-pro/README.md b/plugins/pasteboard-pro/README.md index a319cb12b..e14e144ac 100644 --- a/plugins/pasteboard-pro/README.md +++ b/plugins/pasteboard-pro/README.md @@ -15,7 +15,7 @@ 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 后按选择顺序逐项粘贴。 - 新复制内容实时定位;图片优先加载轻量缩略图,避免阻塞历史列表。 - 支持紧凑布局和上、下、左、右贴边显示,始终跟随当前鼠标所在屏幕。 @@ -23,6 +23,7 @@ Paste剪切板是一款 Paste 风格的本地优先剪贴板历史插件。它 - 使用端到端加密 WebDAV 同步正文、OCR、分组、图片、PDF 与外观配置。 - 主题色、纯色背景和背景图片持久保存在插件本地;选图时不会上传,只有启用 WebDAV 后才进入加密同步。 - 可从设置的隐私页清空全部剪贴板历史、插件自管附件和当前系统剪贴板。 +- ZTools 3.2 可从工具栏截图:Shelf 只写入系统剪贴板,再由主窗口唯一的历史监听器入库;插件会 best-effort 传入 `autoConfirm=false`,但当前公开的一参数 wrapper 可能忽略它,因此不能保证进入截图编辑态。截图区域尺寸也只在宿主实际回传时显示。ZTools 3.2 还支持将本地图片和文件原生拖到外部应用。 ## 使用方式 @@ -30,7 +31,8 @@ Paste剪切板是一款 Paste 风格的本地优先剪贴板历史插件。它 2. 首次打开后,插件会登记为随 ZTools 启动;后续无需手动打开即可持续记录复制内容。 3. 使用鼠标、方向键或搜索框定位内容,按 Enter 或点击卡片完成粘贴。 4. 多选后点击“队列”;macOS 可连续按 `Command + V`,Windows/Linux 在面板内按 Enter 逐项粘贴。 -5. 在设置中选择贴边位置、历史保留策略、附件预算和隐私规则。 +5. 在“设置 → 通用”中可跳转到 ZTools 的全局快捷键配置;Windows 请使用未被系统占用的组合,`Win + V` 保留给系统剪贴板历史。 +6. 在设置中选择贴边位置、历史保留策略、附件预算和隐私规则。 ## 平台支持 @@ -38,6 +40,7 @@ Paste剪切板是一款 Paste 风格的本地优先剪贴板历史插件。它 - 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`。 ## 隐私与同步 @@ -46,6 +49,7 @@ Paste剪切板是一款 Paste 风格的本地优先剪贴板历史插件。它 - WebDAV record、blob 和 index 均经过端到端加密。 - WebDAV 凭据与派生密钥不进入 renderer,本地密钥材料存放于系统钥匙串。 - 搜索工具仅返回脱敏的结构化元数据,不把 OCR 正文写入 Agent 审计内容。 +- 附件清理只接受插件生成的内容寻址路径,并在删除前校验实际 SHA-256、`O_NOFOLLOW` 文件句柄、canonical 路径及 inode;校验异常时拒绝删除。Node.js 没有 `unlinkat`,因此这里不宣称能原子防御同一系统账号的恶意进程在最终 unlink 瞬间并发替换父目录。 ## 技术实现 diff --git a/plugins/pasteboard-pro/apps/ztools/package.json b/plugins/pasteboard-pro/apps/ztools/package.json index faf661525..68f8942b6 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.2.0", + "version": "1.3.0", "private": true, "type": "module", "scripts": { diff --git a/plugins/pasteboard-pro/apps/ztools/preload/clipboard-store.ts b/plugins/pasteboard-pro/apps/ztools/preload/clipboard-store.ts index ae6f96a24..6164d84e5 100644 --- a/plugins/pasteboard-pro/apps/ztools/preload/clipboard-store.ts +++ b/plugins/pasteboard-pro/apps/ztools/preload/clipboard-store.ts @@ -57,6 +57,7 @@ export type DeleteRecordsResult = Readonly<{ export type CanonicalStoreOptions = Readonly<{ deviceId?: string; now?: () => number; + ready?: Promise; }>; export interface HostClipboardApi { @@ -246,6 +247,7 @@ async function putWithConflictRetry( export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore { private readonly deviceId: string; private readonly now: () => number; + private readonly ready: Promise; constructor( private readonly database: ZToolsDocumentDatabase, @@ -253,9 +255,11 @@ export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore { ) { this.deviceId = options.deviceId?.trim() || "ztools-local"; this.now = options.now ?? Date.now; + this.ready = options.ready ?? Promise.resolve(); } async findByFingerprint(fingerprint: string): Promise { + await this.ready; const document = await getOptionalDocument( this.database, this.recordDocumentId(fingerprint), @@ -264,6 +268,7 @@ export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore { } async put(record: CanonicalClipboardRecord): Promise { + await this.ready; const id = this.recordDocumentId(record.item.contentFingerprint); await putWithConflictRetry(this.database, id, (revision) => ({ _id: id, @@ -330,12 +335,14 @@ export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore { } async getCursor(): Promise { + await this.ready; return storedCursor( await getOptionalDocument(this.database, CURSOR_DOCUMENT_ID), ); } async setCursor(cursor: HostCursor): Promise { + await this.ready; await putWithConflictRetry( this.database, CURSOR_DOCUMENT_ID, @@ -349,6 +356,7 @@ export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore { } async listRecords(): Promise { + await this.ready; if (this.database.allDocs === undefined) { throw new TypeError("ZTools database does not expose allDocs"); } diff --git a/plugins/pasteboard-pro/apps/ztools/preload/clipboard-window-role.ts b/plugins/pasteboard-pro/apps/ztools/preload/clipboard-window-role.ts new file mode 100644 index 000000000..2b1d8750f --- /dev/null +++ b/plugins/pasteboard-pro/apps/ztools/preload/clipboard-window-role.ts @@ -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"; +} diff --git a/plugins/pasteboard-pro/apps/ztools/preload/host-compatibility.ts b/plugins/pasteboard-pro/apps/ztools/preload/host-compatibility.ts new file mode 100644 index 000000000..5b292a38d --- /dev/null +++ b/plugins/pasteboard-pro/apps/ztools/preload/host-compatibility.ts @@ -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; diff --git a/plugins/pasteboard-pro/apps/ztools/preload/index.ts b/plugins/pasteboard-pro/apps/ztools/preload/index.ts index 1df830b7d..e6bfe4f55 100644 --- a/plugins/pasteboard-pro/apps/ztools/preload/index.ts +++ b/plugins/pasteboard-pro/apps/ztools/preload/index.ts @@ -8,10 +8,25 @@ import { type HostClipboardApi, type ZToolsDocumentDatabase, } from "./clipboard-store"; +import { + clipboardWindowRole, + ownsClipboardHistoryMirror, +} from "./clipboard-window-role"; import { ensureZToolsAutoStart } from "./auto-start"; import type { PasteStackState } from "@pasteboard-pro/core"; import { createOcrClient, createTesseractOcrClient } from "./ocr"; import { NativeFileDragService } from "./native-file-drag"; +import { + inspectHostCompatibility, + type ZToolsHostCompatibility, +} from "./host-compatibility"; +import { migratePasteboardProPluginData } from "./plugin-data-migration"; +import { resolvePasteboardProDataPaths } from "./plugin-data"; +import { + importScreenCapture, + type ScreenCaptureImportResult, + type ScreenshotNativeImageApi, +} from "./screenshot-import"; import { localizeMirroredImage } from "./mirrored-image"; import { openQuickLook } from "./quick-look"; import { rotateImageFile } from "./image-rotation"; @@ -104,13 +119,19 @@ type ZToolsDisplay = Readonly<{ }>; type ZToolsHost = Readonly<{ + getAppVersion?(): unknown; + getPath?(name: string): unknown; onPluginEnter(callback: (parameter: unknown) => void): void; registerTool( name: string, handler: (input?: unknown) => Promise, ): void; getNativeId(): string; - startDrag(file: string | string[]): void; + startDrag?(file: string | string[]): void; + screenCapture?( + callback: (image: unknown, bounds?: unknown) => void, + autoConfirm?: boolean, + ): void | Promise; clipboard: HostClipboardApi & ClipboardPasteHost & Readonly<{ @@ -138,13 +159,17 @@ type ZToolsHost = Readonly<{ }>; type PasteboardProBridge = Readonly<{ + getHostCompatibility(): ZToolsHostCompatibility; getPlatformCapabilities(): Readonly<{ platform: NodeJS.Platform; supportsGlobalPasteQueue: boolean; supportsQuickLook: boolean; supportsSystemOcr: boolean; supportsImageRotation: boolean; + supportsNativeFileDrag: boolean; + supportsScreenCapture: boolean; }>; + captureScreenshot(): Promise; searchHistory(query?: string, limit?: number): Promise>; getPrivacySettings(): Promise; savePrivacySettings(settings: PrivacySettings): Promise; @@ -200,12 +225,44 @@ if (host === undefined) { } const ztools: ZToolsHost = host; +const hostCompatibility = inspectHostCompatibility(ztools); +const platformCapabilities = { + 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", + supportsNativeFileDrag: hostCompatibility.supportsNativeFileDrag, + supportsScreenCapture: hostCompatibility.supportsScreenCapture, +} as const; + +if (!hostCompatibility.supported) { + const compatibilityBridge = { + getHostCompatibility: () => hostCompatibility, + getPlatformCapabilities: () => platformCapabilities, + } as PasteboardProBridge; + (window as Window & { pasteboardPro?: PasteboardProBridge }).pasteboardPro = + compatibilityBridge; +} else { const windowParams = new URLSearchParams(window.location.search); -const isShelfWindow = windowParams.get("shelf") === "1"; -const isPanelWindow = windowParams.has("panel"); -const isPrimaryWindow = !isShelfWindow && !isPanelWindow; +const windowRole = clipboardWindowRole(windowParams); +const isShelfWindow = windowRole === "shelf"; +const isPanelWindow = windowRole === "panel"; +const isPrimaryWindow = windowRole === "primary"; +const dataPaths = resolvePasteboardProDataPaths(ztools); +const dataMigration = isPrimaryWindow + ? migratePasteboardProPluginData(ztools.db.promises, dataPaths) + : Promise.resolve(); +dataMigration.catch((error) => console.error("[pasteboard-pro] pluginData migration failed", error)); const store = new ZToolsCanonicalClipboardStore(ztools.db.promises, { deviceId: ztools.getNativeId(), + ready: dataMigration, }); const privacyStore = new ZToolsPrivacySettingsStore(ztools.db.promises); const windowPreferencesStore = new ZToolsWindowPreferencesStore(ztools.db.promises, { @@ -237,6 +294,9 @@ const keychain = const syncRepository = new ZToolsSyncEntityRepository( ztools.db.promises, ztools.getNativeId(), + dataPaths.blobRoot, + dataPaths.legacyBlobRoots, + dataMigration, ); const shelfWindows = new ShelfWindowManager(ztools); const panelWindows = new PanelWindowManager(ztools); @@ -454,8 +514,8 @@ async function runRetentionIfDue(): Promise { } } -function scheduleHistoryMirror(): void { - synchronization = synchronization +function scheduleHistoryMirror(): Promise { + const nextSynchronization = synchronization .then(async () => { const privacy = await privacyStore.get(); const result = await mirrorHostHistory(ztools.clipboard, store, { @@ -485,11 +545,12 @@ function scheduleHistoryMirror(): void { await runRetentionIfDue(); broadcastHistoryChanged(); void scheduleVaultSync().catch(reportSynchronizationError); - }) - .catch(reportSynchronizationError); + }); + synchronization = nextSynchronization.catch(reportSynchronizationError); + return nextSynchronization; } -if (isPrimaryWindow) { +if (ownsClipboardHistoryMirror(windowRole)) { void ensureZToolsAutoStart(ipcRenderer).catch((error: unknown) => { console.warn("Paste剪切板自动启动登记失败", error); }); @@ -574,13 +635,20 @@ 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", - }), + getHostCompatibility: () => hostCompatibility, + getPlatformCapabilities: () => platformCapabilities, + captureScreenshot: () => + importScreenCapture( + ztools, + nativeImage as NativeImageApi & ScreenshotNativeImageApi, + clipboard, + { + canImport: async () => { + const privacy = await privacyStore.get(); + return !isCapturePaused(privacy.pause); + }, + }, + ), async searchHistory(query = "", limit = 1_000) { const normalizedLimit = Math.max(1, Math.min(10_000, Math.floor(limit))); const [result, records] = await Promise.all([ @@ -857,3 +925,4 @@ const bridge: PasteboardProBridge = { }; (window as Window & { pasteboardPro?: PasteboardProBridge }).pasteboardPro = bridge; +} diff --git a/plugins/pasteboard-pro/apps/ztools/preload/native-file-drag.ts b/plugins/pasteboard-pro/apps/ztools/preload/native-file-drag.ts index 3ec0f827c..d677eef92 100644 --- a/plugins/pasteboard-pro/apps/ztools/preload/native-file-drag.ts +++ b/plugins/pasteboard-pro/apps/ztools/preload/native-file-drag.ts @@ -1,9 +1,10 @@ +import { statSync } from "node:fs"; import path from "node:path"; import type { CanonicalClipboardRecord } from "./clipboard-store"; type NativeFileDragHost = Readonly<{ - startDrag(file: string | string[]): void; + startDrag?(file: string | string[]): void; }>; type NativeFileDragStore = Readonly<{ @@ -16,6 +17,13 @@ export class NativeFileDragService { constructor( private readonly store: NativeFileDragStore, private readonly host: NativeFileDragHost, + private readonly isFile: (filePath: string) => boolean = (filePath) => { + try { + return statSync(filePath).isFile(); + } catch { + return false; + } + }, ) {} refresh(records: readonly CanonicalClipboardRecord[]): void { @@ -32,7 +40,14 @@ export class NativeFileDragService { start(itemId: string): boolean { const filePaths = this.paths.get(itemId); - if (filePaths === undefined) return false; + if ( + filePaths === undefined || + this.host.startDrag === undefined || + filePaths.some((filePath) => !this.isFile(filePath)) + ) { + this.paths.delete(itemId); + return false; + } try { this.host.startDrag(filePaths.length === 1 ? filePaths[0]! : [...filePaths]); return true; diff --git a/plugins/pasteboard-pro/apps/ztools/preload/plugin-data-migration.ts b/plugins/pasteboard-pro/apps/ztools/preload/plugin-data-migration.ts new file mode 100644 index 000000000..796a96afc --- /dev/null +++ b/plugins/pasteboard-pro/apps/ztools/preload/plugin-data-migration.ts @@ -0,0 +1,112 @@ +import { createHash } from "node:crypto"; +import { cp, lstat, mkdir, readFile, readdir, rm, rmdir, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import type { PasteboardProDataPaths } from "./plugin-data"; + +type MigrationDatabase = Readonly<{ + allDocs?: (options: Readonly>) => Promise; + put: (document: Record) => Promise; +}>; + +function inside(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return relative.length > 0 && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); +} + +async function assertSafeTree(root: string): Promise { + for (const entry of await readdir(root, { withFileTypes: true })) { + const candidate = path.join(root, entry.name); + const status = await lstat(candidate); + if (status.isSymbolicLink()) throw new TypeError("PasteboardPro 数据迁移不允许符号链接"); + if (status.isDirectory()) await assertSafeTree(candidate); + else if (!status.isFile()) throw new TypeError("PasteboardPro 数据迁移只允许普通文件和目录"); + } +} + +async function fileDigest(filePath: string): Promise { + return createHash("sha256").update(await readFile(filePath)).digest("hex"); +} + +async function copyAndVerify(source: string, destination: string): Promise { + await mkdir(destination, { recursive: true }); + for (const entry of await readdir(source, { withFileTypes: true })) { + const from = path.join(source, entry.name); + const to = path.join(destination, entry.name); + try { await stat(to); } catch { await cp(from, to, { recursive: true, force: false, errorOnExist: true }); } + const [sourceStatus, destinationStatus] = await Promise.all([lstat(from), lstat(to)]); + if (sourceStatus.isDirectory() !== destinationStatus.isDirectory()) return false; + if (sourceStatus.isDirectory()) { + if (!(await copyAndVerify(from, to))) return false; + } else if (!sourceStatus.isFile() || !destinationStatus.isFile() + || sourceStatus.size !== destinationStatus.size + || (await fileDigest(from)) !== (await fileDigest(to))) return false; + } + return true; +} + +function documents(result: unknown): Record[] { + if (Array.isArray(result)) return result.filter((value): value is Record => value !== null && typeof value === "object"); + if (!result || typeof result !== "object") return []; + const rows = (result as { rows?: unknown }).rows; + return Array.isArray(rows) + ? rows.flatMap((row) => row && typeof row === "object" && (row as any).doc ? [(row as any).doc] : []) + : []; +} + +function failedResult(result: unknown): boolean { + if (!result || typeof result !== "object") return false; + const value = result as Record; + return value.error === true || typeof value.error === "string" || value.ok === false + || (Number.isFinite(Number(value.status)) && Number(value.status) >= 400); +} + +function migratedPath(filePath: unknown, legacyRoot: string, blobRoot: string): unknown { + if (typeof filePath !== "string") return filePath; + const absolute = path.resolve(filePath); + return inside(legacyRoot, absolute) ? path.join(blobRoot, path.relative(legacyRoot, absolute)) : filePath; +} + +async function rewriteDatabasePaths(database: MigrationDatabase, legacyRoots: readonly string[], blobRoot: string): Promise { + if (database.allDocs === undefined) throw new TypeError("ZTools database does not expose allDocs for pluginData migration"); + const result = await database.allDocs({ include_docs: true, startkey: "pasteboard-pro:record:", endkey: "pasteboard-pro:record:\uffff" }); + if (failedResult(result)) throw new Error("PasteboardPro 迁移读取数据库失败"); + for (const document of documents(result)) { + const record = document.record; + const origin = record?.origin; + if (!origin || typeof origin !== "object") continue; + let imagePath = origin.imagePath; + for (const legacyRoot of legacyRoots) imagePath = migratedPath(imagePath, legacyRoot, blobRoot); + if (imagePath === origin.imagePath) continue; + const putResult = await database.put({ ...document, record: { ...record, origin: { ...origin, imagePath } } }); + if (failedResult(putResult)) throw new Error("PasteboardPro 迁移更新附件路径失败"); + } +} + +export async function migratePasteboardProPluginData(database: MigrationDatabase, paths: PasteboardProDataPaths): Promise { + if (!paths.usesPluginData || paths.legacyBlobRoots.length === 0) return; + const existingRoots: string[] = []; + for (const legacyRoot of paths.legacyBlobRoots) { + try { + if (!(await stat(legacyRoot)).isDirectory()) continue; + await assertSafeTree(legacyRoot); + if (!(await copyAndVerify(legacyRoot, paths.blobRoot))) throw new Error("PasteboardPro 旧附件校验失败"); + existingRoots.push(path.resolve(legacyRoot)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + if (existingRoots.length === 0) return; + await rewriteDatabasePaths(database, existingRoots, paths.blobRoot); + for (const legacyRoot of existingRoots) { + await rm(legacyRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + try { await stat(legacyRoot); throw new Error("PasteboardPro 旧附件目录清理失败"); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + try { await rmdir(path.dirname(legacyRoot)); } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT" && code !== "ENOTEMPTY") throw error; + } + } + await writeFile(path.join(paths.dataRoot, ".pasteboard-pro-plugin-data-migration-v1.json"), JSON.stringify({ version: 1, completedAt: new Date().toISOString() })); +} diff --git a/plugins/pasteboard-pro/apps/ztools/preload/plugin-data.ts b/plugins/pasteboard-pro/apps/ztools/preload/plugin-data.ts new file mode 100644 index 000000000..9ba0833fd --- /dev/null +++ b/plugins/pasteboard-pro/apps/ztools/preload/plugin-data.ts @@ -0,0 +1,54 @@ +import os from "node:os"; +import path from "node:path"; + +type PluginDataHost = Readonly<{ + getPath?: (name: string) => unknown; +}>; + +export type PasteboardProDataPaths = Readonly<{ + dataRoot: string; + blobRoot: string; + legacyBlobRoots: readonly string[]; + usesPluginData: boolean; +}>; + +export function legacyPasteboardProDataRoot(homeDirectory = os.homedir()): string { + return path.join(homeDirectory, ".pasteboard-pro", "ztools"); +} + +export function resolvePasteboardProDataPaths( + host: PluginDataHost, + legacyDataRoot = legacyPasteboardProDataRoot(), +): PasteboardProDataPaths { + let pluginDataRoot: string | undefined; + try { + const candidate = host.getPath?.("pluginData"); + if (typeof candidate === "string" && path.isAbsolute(candidate)) { + pluginDataRoot = path.resolve(candidate); + } + } catch { + pluginDataRoot = undefined; + } + const resolvedLegacyRoot = path.resolve(legacyDataRoot); + const dataRoot = pluginDataRoot ?? resolvedLegacyRoot; + const blobRoot = path.join(dataRoot, ...(pluginDataRoot === undefined ? ["blobs"] : ["sync", "blobs"])); + const legacyBlobRoot = path.join(resolvedLegacyRoot, "blobs"); + return { + dataRoot, + blobRoot, + legacyBlobRoots: + path.resolve(blobRoot) === path.resolve(legacyBlobRoot) ? [] : [legacyBlobRoot], + usesPluginData: pluginDataRoot !== undefined, + }; +} + +export function resolvePasteboardBlobRoots( + host: PluginDataHost, + homeDirectory = os.homedir(), +): Readonly<{ primary: string; legacy: readonly string[] }> { + const paths = resolvePasteboardProDataPaths( + host, + legacyPasteboardProDataRoot(homeDirectory), + ); + return { primary: paths.blobRoot, legacy: paths.legacyBlobRoots }; +} diff --git a/plugins/pasteboard-pro/apps/ztools/preload/screenshot-import.ts b/plugins/pasteboard-pro/apps/ztools/preload/screenshot-import.ts new file mode 100644 index 000000000..27183014b --- /dev/null +++ b/plugins/pasteboard-pro/apps/ztools/preload/screenshot-import.ts @@ -0,0 +1,134 @@ +import type { NativeImageLike } from "./thumbnail"; + +export type ScreenCaptureBounds = Readonly<{ + x: number; + y: number; + width: number; + height: number; +}>; + +export type ScreenCaptureImportResult = Readonly<{ + bounds?: ScreenCaptureBounds; +}>; + +type ScreenCaptureHost = Readonly<{ + screenCapture?: ( + callback: (image: unknown, bounds?: unknown) => void, + autoConfirm?: boolean, + ) => void | Promise; +}>; + +export const SCREEN_CAPTURE_UNAVAILABLE_MESSAGE = + "当前 ZTools 版本不支持截图导入"; +export const SCREEN_CAPTURE_PAUSED_MESSAGE = + "剪贴板捕获已暂停,请先继续捕获"; + +export type ScreenCaptureImportOptions = Readonly<{ + canImport?: () => boolean | Promise; +}>; + +export type ScreenshotNativeImageApi = Readonly<{ + createFromDataURL(value: string): NativeImageLike; +}>; + +export type ScreenshotClipboard = Readonly<{ + writeImage(image: NativeImageLike): void; +}>; + +export function importCapturedScreenshot( + imageDataUrl: string, + dependencies: Readonly<{ + nativeImage: ScreenshotNativeImageApi; + clipboard: ScreenshotClipboard; + }>, +): boolean { + if (!/^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/]+=*$/iu.test(imageDataUrl)) { + return false; + } + try { + const image = dependencies.nativeImage.createFromDataURL(imageDataUrl); + if (image.isEmpty()) return false; + dependencies.clipboard.writeImage(image); + return true; + } catch { + return false; + } +} + +function captureBounds(value: unknown): ScreenCaptureBounds | undefined { + if (value === null || typeof value !== "object") return undefined; + const candidate = value as Record; + if ( + typeof candidate.x !== "number" || + typeof candidate.y !== "number" || + typeof candidate.width !== "number" || + typeof candidate.height !== "number" || + candidate.width <= 0 || + candidate.height <= 0 + ) return undefined; + return { + x: candidate.x, + y: candidate.y, + width: candidate.width, + height: candidate.height, + }; +} + +export async function importScreenCapture( + host: ScreenCaptureHost, + nativeImage: ScreenshotNativeImageApi, + clipboard: ScreenshotClipboard, + options: ScreenCaptureImportOptions = {}, +): Promise { + const screenCapture = host.screenCapture; + if (screenCapture === undefined) { + throw new Error(SCREEN_CAPTURE_UNAVAILABLE_MESSAGE); + } + if (options.canImport !== undefined && !(await options.canImport())) { + throw new Error(SCREEN_CAPTURE_PAUSED_MESSAGE); + } + return new Promise((resolve, reject) => { + let settled = false; + const complete = (image: unknown, rawBounds?: unknown): void => { + if (settled) return; + settled = true; + void (async () => { + const bounds = captureBounds(rawBounds); + if (rawBounds !== undefined && rawBounds !== null && bounds === undefined) { + throw new TypeError("截图区域信息无效"); + } + if (options.canImport !== undefined && !(await options.canImport())) { + throw new Error(SCREEN_CAPTURE_PAUSED_MESSAGE); + } + if (typeof image === "string") { + if (!importCapturedScreenshot(image, { nativeImage, clipboard })) { + throw new TypeError("截图图像无效"); + } + } else { + const capturedImage = image as NativeImageLike; + if ( + capturedImage === null || + typeof capturedImage !== "object" || + capturedImage.isEmpty() + ) throw new TypeError("截图图像无效"); + clipboard.writeImage(capturedImage); + } + resolve(bounds === undefined ? {} : { bounds }); + })().catch(reject); + }; + try { + const result = screenCapture(complete, false); + if (result !== undefined && typeof (result as PromiseLike).then === "function") { + void Promise.resolve(result).catch((error: unknown) => { + if (!settled) { + settled = true; + reject(error); + } + }); + } + } catch (error) { + settled = true; + reject(error); + } + }); +} diff --git a/plugins/pasteboard-pro/apps/ztools/preload/sync-repository.ts b/plugins/pasteboard-pro/apps/ztools/preload/sync-repository.ts index f3c4671ac..5bee22892 100644 --- a/plugins/pasteboard-pro/apps/ztools/preload/sync-repository.ts +++ b/plugins/pasteboard-pro/apps/ztools/preload/sync-repository.ts @@ -1,5 +1,17 @@ import { createHash } from "node:crypto"; -import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { constants as fileSystemConstants } from "node:fs"; +import { + lstat, + mkdir, + open, + readFile, + realpath, + rename, + rm, + stat, + unlink, + writeFile, +} from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -23,6 +35,8 @@ import { const TOMBSTONE_PREFIX = "pasteboard-pro:tombstone:"; const MAX_BLOB_BYTES = 100 * 1_024 * 1_024; +const MANAGED_BLOB_FILE_PATTERN = + /^([0-9a-f]{64})\.(png|jpg|webp|tiff|pdf|rtf|rtfd|bin)$/u; function blobExtension(mediaType: string): string { return ( @@ -55,6 +69,118 @@ function databaseStatus(error: unknown, status: number): boolean { return isRecord(error) && (error.status === status || error.statusCode === status); } +function missingFile(error: unknown): boolean { + return isRecord(error) && error.code === "ENOENT"; +} + +function insideRoot(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return ( + relative.length > 0 && + !relative.startsWith(`..${path.sep}`) && + relative !== ".." && + !path.isAbsolute(relative) + ); +} + +type PathIdentity = Readonly<{ + path: string; + device: number; + inode: number; +}>; + +async function checkedPathChain( + root: string, + target: string, +): Promise { + const relative = path.relative(root, target); + if (!insideRoot(root, target)) { + throw new TypeError("Refusing to inspect a blob outside its managed root"); + } + const entries = [root]; + let cursor = root; + for (const segment of relative.split(path.sep)) { + cursor = path.join(cursor, segment); + entries.push(cursor); + } + + const identities: PathIdentity[] = []; + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]!; + let status; + try { + status = await lstat(entry); + } catch (error) { + if (missingFile(error)) return undefined; + throw error; + } + if (status.isSymbolicLink()) { + throw new TypeError( + index === 0 + ? "Refusing to delete through a symbolic-link blob root" + : "Refusing to delete through a symbolic link", + ); + } + const isTarget = index === entries.length - 1; + if (!isTarget && !status.isDirectory()) { + throw new TypeError("Blob parent path must contain only directories"); + } + if (isTarget && !status.isFile()) { + throw new TypeError("Managed blob target must be a regular file"); + } + identities.push({ + path: entry, + device: status.dev, + inode: status.ino, + }); + } + return identities; +} + +function samePathChain( + before: readonly PathIdentity[], + after: readonly PathIdentity[], +): boolean { + return ( + before.length === after.length && + before.every((entry, index) => { + const candidate = after[index]; + return ( + candidate !== undefined && + entry.path === candidate.path && + entry.device === candidate.device && + entry.inode === candidate.inode + ); + }) + ); +} + +function managedBlobDigest(root: string, filePath: string): string | undefined { + const relative = path.relative(root, filePath); + const segments = relative.split(path.sep); + if (segments.length !== 2) return undefined; + const match = MANAGED_BLOB_FILE_PATTERN.exec(segments[1]!); + if (match === null || segments[0] !== match[1]!.slice(0, 2)) return undefined; + return match[1]; +} + +function assertBlobIdentifier(blobId: string): void { + if (blobId.length === 0) { + throw new TypeError("Blob id must be non-empty"); + } + if (blobId.includes("\0")) { + throw new TypeError("Blob id cannot contain a NUL byte"); + } + if ( + blobId === "." || + blobId === ".." || + blobId.includes("/") || + blobId.includes("\\") + ) { + throw new TypeError("Blob id must be a safe object identifier"); + } +} + function revision(value: unknown): string | undefined { return isRecord(value) && typeof value._rev === "string" ? value._rev : undefined; } @@ -106,6 +232,8 @@ export class ZToolsSyncEntityRepository implements SyncEntityRepository { "ztools", "blobs", ), + private readonly legacyBlobRoots: readonly string[] = [], + private readonly ready: Promise = Promise.resolve(), ) { this.clipboard = new ZToolsCanonicalClipboardStore(database, { deviceId }); this.pinboards = new ZToolsPinboardStore(database, { deviceId }); @@ -130,6 +258,7 @@ export class ZToolsSyncEntityRepository implements SyncEntityRepository { } async listEntities(): Promise { + await this.ready; const preferences = await this.preferences.getSyncEntity(); const entities: SyncEntity[] = [ ...(await this.clipboard.listRecords()).map((record) => record.item), @@ -196,6 +325,7 @@ export class ZToolsSyncEntityRepository implements SyncEntityRepository { } async applyEntities(entities: readonly SyncEntity[]): Promise { + await this.ready; for (const entity of entities) { if ("deleted" in entity) { if (entity.entityType === "paste_item") { @@ -217,6 +347,7 @@ export class ZToolsSyncEntityRepository implements SyncEntityRepository { } async readBlob(blobId: string): Promise { + await this.ready; const record = (await this.clipboard.listRecords()).find( (candidate) => candidate.item.payload.blobId === blobId, ); @@ -246,6 +377,7 @@ export class ZToolsSyncEntityRepository implements SyncEntityRepository { bytes: Uint8Array, mediaType: string, ): Promise> { + await this.ready; if (bytes.byteLength > MAX_BLOB_BYTES) { throw new RangeError("Blob exceeds 100 MiB"); } @@ -259,19 +391,117 @@ export class ZToolsSyncEntityRepository implements SyncEntityRepository { } async deleteLocalBlob(input: Readonly<{ blobId: string; filePath: string }>): Promise { - if (input.blobId.length === 0) { - throw new TypeError("Blob id must be non-empty"); - } - const root = path.resolve(this.blobRoot); + await this.ready; + assertBlobIdentifier(input.blobId); const filePath = path.resolve(input.filePath); - const relative = path.relative(root, filePath); - if (relative.length === 0 || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + const managedRoots = [this.blobRoot, ...this.legacyBlobRoots].map((root) => path.resolve(root)); + const lexicalRoot = managedRoots.find((root) => insideRoot(root, filePath)); + if (lexicalRoot === undefined) { throw new TypeError("Refusing to delete a blob outside the plugin blob directory"); } - await rm(filePath, { force: true }); + + const initialChain = await checkedPathChain(lexicalRoot, filePath); + if (initialChain === undefined) return; + const expectedDigest = managedBlobDigest(lexicalRoot, filePath); + if (expectedDigest === undefined) { + throw new TypeError("Refusing to delete a blob outside the content-addressed layout"); + } + + const [canonicalRoot, canonicalFile] = await Promise.all([ + realpath(lexicalRoot), + realpath(filePath), + ]); + if (!insideRoot(canonicalRoot, canonicalFile)) { + throw new TypeError("Refusing to delete a blob outside the canonical plugin blob directory"); + } + if (managedBlobDigest(canonicalRoot, canonicalFile) !== expectedDigest) { + throw new TypeError("Canonical blob path does not match its content-addressed layout"); + } + + const noFollow = process.platform === "win32" + ? 0 + : fileSystemConstants.O_NOFOLLOW; + const handle = await open( + canonicalFile, + fileSystemConstants.O_RDONLY | noFollow, + ); + let handleOpen = true; + try { + const openedStatus = await handle.stat(); + if (!openedStatus.isFile()) { + throw new TypeError("Managed blob target must remain a regular file"); + } + if (openedStatus.size > MAX_BLOB_BYTES) { + throw new RangeError("Managed blob exceeds 100 MiB"); + } + const actualDigest = createHash("sha256") + .update(await handle.readFile()) + .digest("hex"); + if (actualDigest !== expectedDigest) { + throw new TypeError("Managed blob content does not match its path digest"); + } + + const remainingRecords = await this.clipboard.listRecords(); + for (const record of remainingRecords) { + const recordPath = record.origin.imagePath; + if (recordPath === undefined) continue; + let canonicalRecordPath: string; + try { + canonicalRecordPath = await realpath(recordPath); + } catch (error) { + if (missingFile(error)) continue; + throw error; + } + if ( + canonicalRecordPath === canonicalFile || + record.origin.pluginBlobId === input.blobId + ) { + return; + } + } + + const finalChain = await checkedPathChain(lexicalRoot, filePath); + if (finalChain === undefined || !samePathChain(initialChain, finalChain)) { + throw new TypeError("Managed blob path changed during deletion validation"); + } + const [finalCanonicalRoot, finalCanonicalFile] = await Promise.all([ + realpath(lexicalRoot), + realpath(filePath), + ]); + if ( + finalCanonicalRoot !== canonicalRoot || + finalCanonicalFile !== canonicalFile + ) { + throw new TypeError("Managed blob canonical path changed during deletion validation"); + } + const finalStatus = await lstat(finalCanonicalFile); + if ( + finalStatus.isSymbolicLink() || + !finalStatus.isFile() || + finalStatus.dev !== openedStatus.dev || + finalStatus.ino !== openedStatus.ino + ) { + throw new TypeError("Managed blob file changed during deletion validation"); + } + + if (process.platform === "win32") { + await handle.close(); + handleOpen = false; + } + // Node does not expose unlinkat(2), so the final pathname unlink cannot + // be made atomic with the verified file descriptor. The content-addressed + // whitelist plus repeated parent/canonical/inode checks fail closed for + // untrusted metadata and ordinary path replacement; a malicious process + // racing this exact final syscall under the same OS account is outside + // this API's enforceable boundary. + await unlink(finalCanonicalFile); + } finally { + if (handleOpen) await handle.close(); + } } async writeBlob(blob: SyncBlob): Promise { + await this.ready; if (blob.bytes.byteLength > MAX_BLOB_BYTES) { throw new RangeError(`Blob ${blob.id} exceeds 100 MiB`); } diff --git a/plugins/pasteboard-pro/apps/ztools/public/plugin.json b/plugins/pasteboard-pro/apps/ztools/public/plugin.json index b7c11f391..f2a8ba8f8 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.2.0", + "version": "1.3.0", "main": "index.html", "preload": "preload.js", "unpack": "@(pasteboard-vision|paste-stack-key-monitor.py)", diff --git a/plugins/pasteboard-pro/apps/ztools/src/App.vue b/plugins/pasteboard-pro/apps/ztools/src/App.vue index 1eaf7edf8..89bcf3869 100644 --- a/plugins/pasteboard-pro/apps/ztools/src/App.vue +++ b/plugins/pasteboard-pro/apps/ztools/src/App.vue @@ -34,6 +34,12 @@ import { } from "./state"; import { queryAfterTypeToSearch } from "./type-to-search"; import { isListNavigationKey, shouldResumeListControl } from "./list-control-key"; +import { shelfKeyboardRoute } from "./keyboard-routing"; +import { + hasAnyCommandModifier, + matchesPrimaryShortcut, + resolveShortcutPlatform, +} from "./platform-shortcuts"; import { themeCssVariables } from "./theme"; import { applyListOrder, @@ -57,6 +63,14 @@ const panelMode = ? panel : undefined; const isShelfMode = params.get("shelf") === "1"; +const hostCompatibility = window.pasteboardPro?.getHostCompatibility(); +const requiresHostUpgrade = hostCompatibility?.supported === false; +const platformCapabilities = window.pasteboardPro?.getPlatformCapabilities(); +const shortcutPlatform = resolveShortcutPlatform( + platformCapabilities?.platform, +); +const supportsScreenCapture = + platformCapabilities?.supportsScreenCapture === true; const dockValue = params.get("dock"); const edge: DockEdge = dockValue === "top" || @@ -70,11 +84,15 @@ const state = reactive( createPasteboardState({ items: [], dockEdge: edge, + shortcutPlatform, }), ); const pinboards = ref([]); const query = ref(""); const paused = ref(false); +const canCaptureScreen = computed( + () => supportsScreenCapture && !paused.value, +); const activePinboardId = ref(); const listOrders = ref({}); const listOrderSaving = ref(false); @@ -378,12 +396,12 @@ function onKeydown(event: KeyboardEvent): void { } if (!isShelfMode) return; if (settingsOpen.value) return; - if (event.metaKey && event.key.toLowerCase() === "f") { + if (matchesPrimaryShortcut(event, shortcutPlatform, "f")) { event.preventDefault(); document.querySelector("[data-pb-search]")?.focus(); return; } - if (event.metaKey && !event.shiftKey && event.key.toLowerCase() === "n") { + if (matchesPrimaryShortcut(event, shortcutPlatform, "n")) { event.preventDefault(); createTextItem(); return; @@ -420,33 +438,50 @@ function onKeydown(event: KeyboardEvent): void { return; } } - if (event.metaKey && !event.shiftKey && event.key.toLowerCase() === "c") { + if (matchesPrimaryShortcut(event, shortcutPlatform, "c")) { event.preventDefault(); void copySelection(); return; } - if (event.metaKey && !event.shiftKey && event.key.toLowerCase() === "t") { + if (matchesPrimaryShortcut(event, shortcutPlatform, "t")) { event.preventDefault(); void togglePause(); return; } - if (event.metaKey && !event.shiftKey && event.key.toLowerCase() === "e" && focusedItem.value !== undefined) { + if (matchesPrimaryShortcut(event, shortcutPlatform, "e") && focusedItem.value !== undefined) { event.preventDefault(); editItem(focusedItem.value.id); return; } - if (event.metaKey && !event.shiftKey && event.key.toLowerCase() === "r" && focusedItem.value !== undefined) { + if (matchesPrimaryShortcut(event, shortcutPlatform, "r") && focusedItem.value !== undefined) { event.preventDefault(); renameItem(focusedItem.value.id); return; } - const isSpaceActivation = - event.key === " " && - event.target instanceof Element && - event.target.closest( - "button, a[href], summary, [role='button'], [role='menuitem']", - ) !== null; - const nextQuery = isSpaceActivation + const previousSelection = state.selection.selected.join("\0"); + const keyboardTarget = event.target instanceof Element ? event.target : undefined; + const listCardTarget = keyboardTarget?.closest("[data-pb-item-id]"); + const keyboardRoute = shelfKeyboardRoute(event.key, { + nativeControl: + keyboardTarget !== undefined && + keyboardTarget.closest( + "button, a[href], summary, [role='button'], [role='menuitem']", + ) !== null, + listCard: listCardTarget !== undefined && listCardTarget !== null, + }); + if (keyboardRoute === "native-control") return; + if ( + keyboardRoute === "list-card" && + !hasAnyCommandModifier(event) && + listCardTarget !== undefined && + listCardTarget !== null + ) { + const itemId = listCardTarget.dataset.pbItemId; + if (itemId !== undefined && !state.selection.selected.includes(itemId)) { + state.replaceSelection(itemId); + } + } + const nextQuery = keyboardRoute === "list-card" ? undefined : queryAfterTypeToSearch(query.value, event); if (nextQuery !== undefined) { @@ -459,11 +494,11 @@ function onKeydown(event: KeyboardEvent): void { ); return; } - const previousSelection = state.selection.selected.join("\0"); const effect = state.handleKeyboard( { key: event.key, metaKey: event.metaKey, + ctrlKey: event.ctrlKey, shiftKey: event.shiftKey, altKey: event.altKey, }, @@ -493,6 +528,20 @@ async function togglePause(): Promise { status.value = paused.value ? "剪贴板捕获已暂停" : "剪贴板捕获已继续"; } +async function captureScreen(): Promise { + status.value = "正在截取屏幕…"; + try { + const result = await window.pasteboardPro?.captureScreenshot(); + if (result === undefined) throw new Error("当前 ZTools 版本不支持截图导入"); + const bounds = result.bounds; + status.value = bounds === undefined + ? "截图已写入剪贴板,正在同步历史" + : `截图已写入剪贴板(${Math.round(bounds.width)} × ${Math.round(bounds.height)}),正在同步历史`; + } catch (error) { + status.value = error instanceof Error ? error.message : "截图导入失败"; + } +} + function onMirrored(event: Event): void { const detail = (event as CustomEvent<{ imported: number }>).detail; status.value = detail.imported > 0 ? `已导入 ${detail.imported} 条记录` : "历史已同步"; @@ -804,6 +853,7 @@ async function onWindowPreferencesChanged(): Promise { } onMounted(async () => { + if (requiresHostUpgrade) return; window.addEventListener("keydown", onKeydown); window.addEventListener("pasteboard-pro:paste-stack-changed", onPasteStackChanged); window.addEventListener( @@ -911,10 +961,21 @@ onBeforeUnmount(() => { :style="themeStyle" :class="{ 'stage--panel': panelMode !== undefined, - 'stage--primary': !isShelfMode && panelMode === undefined, + 'stage--primary': !requiresHostUpgrade && !isShelfMode && panelMode === undefined, 'stage--image-background': hasImageBackground, }" > + + @@ -1017,6 +1081,24 @@ onBeforeUnmount(() => { place-items: center; } +.upgrade-required { + align-self: center; + justify-self: center; + width: min(420px, calc(100% - 32px)); + padding: 28px; + border: 1px solid var(--pb-line); + border-radius: 20px; + background: var(--pb-glass-strong); + box-shadow: 0 24px 80px var(--pb-shadow); + color: var(--pb-ink); + text-align: center; +} + +.upgrade-required img { width: 52px; height: 52px; border-radius: 14px; } +.upgrade-required h1 { margin: 14px 0 8px; font-size: 22px; } +.upgrade-required p { margin: 0; color: var(--pb-muted); line-height: 1.6; } +.upgrade-required small { display: block; margin-top: 10px; color: var(--pb-muted); } + .stage--image-background .shelf.glass-surface { background-color: var(--pb-theme-background-color, var(--pb-window-bg)); background-image: var(--pb-theme-background-image, none); diff --git a/plugins/pasteboard-pro/apps/ztools/src/components/PasteCard.vue b/plugins/pasteboard-pro/apps/ztools/src/components/PasteCard.vue index edd977f33..f846e6172 100644 --- a/plugins/pasteboard-pro/apps/ztools/src/components/PasteCard.vue +++ b/plugins/pasteboard-pro/apps/ztools/src/components/PasteCard.vue @@ -10,6 +10,7 @@ import { import { containContextMenuKeydown } from "../context-menu-keyboard"; import { writeSourceDragData } from "../drag-content"; import { LIST_REORDER_MIME } from "../list-order"; +import { hasPrimaryShortcutModifier, resolveShortcutPlatform } from "../platform-shortcuts"; const props = defineProps<{ item: PasteItem; @@ -38,6 +39,9 @@ const card = ref(); const thumbnailUrl = ref(); const thumbnailRequested = ref(false); const reorderDragging = ref(false); +const shortcutPlatform = resolveShortcutPlatform( + window.pasteboardPro?.getPlatformCapabilities().platform, +); const contextMenu = ref<{ x: number; y: number }>(); let stopObservingThumbnail: (() => void) | undefined; let reorderDragPreview: HTMLElement | undefined; @@ -71,6 +75,15 @@ function openContextMenu(event: MouseEvent): void { window.addEventListener("keydown", closeContextMenuOnEscape); } +function selectCard(event: MouseEvent): void { + emit( + "select", + props.item.id, + event.shiftKey, + hasPrimaryShortcutModifier(event, shortcutPlatform), + ); +} + function assignToPinboard(pinboardId: string | undefined): void { emit("assignPinboard", { pinboardId, itemId: props.item.id }); closeContextMenu(); @@ -228,16 +241,18 @@ function prepareNativeFileDrag(): void { } function beginNativeFileDrag(event: DragEvent): void { - event.dataTransfer?.setData("application/x-pasteboard-pro-item", props.item.id); + // Stop nested image/file drag events from reaching the card reorder handler. + event.stopPropagation(); + const started = window.pasteboardPro?.startNativeFileDrag(props.item.id) ?? false; + if (started) { + event.preventDefault(); + return; + } + // ZTools 2.4–3.1 has no startDrag API. Preserve the browser data-transfer + // payload so those hosts still support in-plugin/source dragging. if (event.dataTransfer !== null) { - event.dataTransfer.effectAllowed = "copy"; + writeSourceDragData(props.item, event.dataTransfer); } - // 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(() => { @@ -292,13 +307,11 @@ onBeforeUnmount(() => { role="option" tabindex="0" :draggable="reorderEnabled !== false" - @click="emit('select', item.id, $event.shiftKey, $event.metaKey)" + @click="selectCard" @dblclick="emit('paste', item.id)" @contextmenu.prevent.stop="openContextMenu" @dragstart="beginReorderDrag" @dragend="finishReorderDrag" - @keydown.enter="emit('paste', item.id)" - @keydown.space.prevent="emit('preview', item.id)" >
{{ item.kind.replace('_', ' ') }} diff --git a/plugins/pasteboard-pro/apps/ztools/src/components/PinboardStrip.vue b/plugins/pasteboard-pro/apps/ztools/src/components/PinboardStrip.vue index 8ffd22462..8e75cb4fb 100644 --- a/plugins/pasteboard-pro/apps/ztools/src/components/PinboardStrip.vue +++ b/plugins/pasteboard-pro/apps/ztools/src/components/PinboardStrip.vue @@ -2,6 +2,8 @@ import { computed, onBeforeUnmount, ref } from "vue"; import type { Pinboard } from "@pasteboard-pro/core"; + +import { primaryModifierLabel, resolveShortcutPlatform } from "../platform-shortcuts"; import type { SmartPinboard } from "../smart-pinboards"; import { containContextMenuKeydown } from "../context-menu-keyboard"; @@ -20,6 +22,13 @@ const emit = defineEmits<{ assign: [pinboardId: string | undefined, itemId: string]; }>(); +const quickPasteShortcutLabel = computed(() => { + const platform = resolveShortcutPlatform( + window.pasteboardPro?.getPlatformCapabilities().platform, + ); + return `${primaryModifierLabel(platform)} 1–9 快捷粘贴`; +}); + const creating = ref(false); const editingId = ref(); const draft = ref(""); @@ -204,7 +213,7 @@ onBeforeUnmount(closeManagementMenu); @blur="commitCreate" /> - ⌘ 1–9 快捷粘贴 + {{ quickPasteShortcutLabel }}
+import { computed } from "vue"; + +import { primaryModifierLabel, resolveShortcutPlatform } from "../platform-shortcuts"; + defineProps<{ modelValue: string }>(); const emit = defineEmits<{ "update:modelValue": [value: string] }>(); + +const searchShortcutLabel = computed(() => { + const platform = resolveShortcutPlatform( + window.pasteboardPro?.getPlatformCapabilities().platform, + ); + return `${primaryModifierLabel(platform)} F`; +}); diff --git a/plugins/pasteboard-pro/apps/ztools/src/components/SettingsPanel.vue b/plugins/pasteboard-pro/apps/ztools/src/components/SettingsPanel.vue index 17fdddcda..6c88a76e6 100644 --- a/plugins/pasteboard-pro/apps/ztools/src/components/SettingsPanel.vue +++ b/plugins/pasteboard-pro/apps/ztools/src/components/SettingsPanel.vue @@ -26,6 +26,11 @@ import { } from "../privacy-view"; import { syncStatusPresentation } from "../sync-view"; import { themeCssVariables } from "../theme"; +import { + primaryModifierName, + resolveShortcutPlatform, +} from "../platform-shortcuts"; +import { openPasteShortcutSettings, type ZToolsShortcutSettingsApi } from "../hotkey-settings"; type SettingsTab = "general" | "appearance" | "privacy" | "sync"; type ThemeBackgroundType = ThemeBackground["type"]; @@ -72,6 +77,7 @@ const backgroundImageError = ref(""); const clearingHistory = ref(false); const historyClearMessage = ref(""); const historyClearFailed = ref(false); +const hotkeySettingsMessage = ref(""); const initialBlobBudgetUnit = preferredBlobBudgetUnit( props.privacySettings.retention.maxBlobBytes, ); @@ -111,6 +117,24 @@ const blobBudgetConstraints = computed(() => const syncPresentation = computed(() => syncStatusPresentation(props.syncSettings.status), ); +const shortcutPlatform = computed(() => + resolveShortcutPlatform(window.pasteboardPro?.getPlatformCapabilities().platform), +); +const multiPasteHint = computed(() => { + if (shortcutPlatform.value === "darwin") { + return "一次性模式按 Enter 合并粘贴;逐一模式可连续按 Command-V。"; + } + if (shortcutPlatform.value === "win32") { + return "一次性模式按 Enter 合并粘贴;逐一模式需在插件内按 Enter。Win+V 是 Windows 系统剪贴板历史快捷键,不能用于 Paste。"; + } + return "一次性模式按 Enter 合并粘贴;逐一模式需在插件内按 Enter。"; +}); +const hotkeySettingsHint = computed(() => { + if (shortcutPlatform.value === "win32") { + return "请设置未被系统占用的组合;Win+V 保留给 Windows 剪贴板历史。"; + } + return `在 ZTools 中设置 Paste剪切板的全局唤起快捷键;列表内快捷键使用 ${primaryModifierName(shortcutPlatform.value)}。`; +}); const themePreviewStyle = computed>(() => { const variables = themeCssVariables( { @@ -226,6 +250,13 @@ async function clearHistory(): Promise { } } +function openHotkeySettings(): void { + const ztools = (window as Window & { ztools?: ZToolsShortcutSettingsApi }).ztools; + hotkeySettingsMessage.value = openPasteShortcutSettings(ztools) + ? "已打开 ZTools 快捷键设置。" + : "当前环境不支持打开 ZTools 快捷键设置;请在 ZTools 中右键 Paste剪切板后选择设置快捷键。"; +} + function selectedThemeBackground(): ThemeBackground { if (form.backgroundType === "color") { return { type: "color", color: form.backgroundColor }; @@ -321,8 +352,13 @@ function save(): void { {{ option[1] }}
- 一次性模式按 Enter 合并粘贴;macOS 逐一模式可连续按 Command-V,Windows/Linux 按 Enter 逐项粘贴。 + {{ multiPasteHint }} +
+ 全局唤起快捷键{{ hotkeySettingsHint }} + +
+

{{ hotkeySettingsMessage }}

@@ -411,6 +447,7 @@ function save(): void { .option-field { display:grid; gap:9px; margin:0; padding:15px 0; border:0; border-top:1px solid var(--pb-line); }.option-field legend { padding:0; font-size:12px; font-weight:750; }.option-grid { display:grid; gap:7px; }.option-grid--four { grid-template-columns:repeat(4,1fr); }.option-grid--three { grid-template-columns:repeat(3,1fr); }.option-grid--two { grid-template-columns:repeat(2,1fr); }.option-grid label { position:relative; }.option-grid input { position:absolute; opacity:0; pointer-events:none; }.option-grid span { display:grid; min-height:36px; border:1px solid var(--pb-line); border-radius:10px; background:color-mix(in srgb,var(--pb-glass-strong) 58%,transparent); color:var(--pb-muted); cursor:pointer; font-size:11px; font-weight:700; place-items:center; }.option-grid input:checked + span { border-color:var(--pb-violet); background:color-mix(in srgb,var(--pb-violet) 12%,var(--pb-window-bg)); color:var(--pb-violet); }.option-grid input:disabled + span { cursor:not-allowed; opacity:.45; }.option-grid input:focus-visible + span { outline:2px solid color-mix(in srgb,var(--pb-violet) 45%,transparent); outline-offset:1px; } .theme-preview { display:grid; grid-template-columns:auto 1fr auto; gap:14px; align-items:center; min-height:112px; margin-bottom:16px; padding:20px; overflow:hidden; border:1px solid var(--pb-line); border-radius:16px; background-position:center; background-repeat:no-repeat; background-size:cover; box-shadow:inset 0 0 0 999px color-mix(in srgb,var(--pb-glass) 32%,transparent); }.theme-preview__brand { color:var(--preview-accent); font-size:20px; font-weight:850; letter-spacing:-.04em; }.theme-preview div { display:flex; gap:5px; }.theme-preview i { width:26px; height:32px; border:1px solid color-mix(in srgb,var(--preview-accent) 22%,var(--pb-line)); border-radius:8px; background:color-mix(in srgb,var(--pb-glass-strong) 72%,transparent); }.theme-preview__button { display:grid; min-height:34px; padding:0 12px; border-radius:9px; background:var(--preview-accent); color:var(--preview-on-accent); font-size:10px; font-weight:750; place-items:center; }.color-field,.background-image-control { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 0; border-top:1px solid var(--pb-line); }.color-field > span:first-child,.background-image-control > span:first-child { display:grid; gap:3px; }.color-field strong,.background-image-control strong { font-size:12px; }.color-field small,.background-image-control small { color:var(--pb-muted); font-size:10px; line-height:1.4; }.color-field__control { display:flex; align-items:center; gap:8px; }.color-field__control input { width:36px; height:32px; padding:2px; border:1px solid var(--pb-line); border-radius:9px; background:transparent; cursor:pointer; }.color-field__control code { min-width:66px; color:var(--pb-muted); font-size:10px; text-transform:uppercase; }.color-field--compact { border-bottom:1px solid var(--pb-line); }.background-image-control > div { display:flex; gap:7px; }.quiet-action { min-height:34px; padding:0 11px; border:1px solid var(--pb-line); border-radius:9px; background:color-mix(in srgb,var(--pb-glass-strong) 58%,transparent); color:var(--pb-ink); cursor:pointer; font-size:10px; font-weight:700; }.quiet-action:hover { border-color:var(--pb-violet); color:var(--pb-violet); }.quiet-action:disabled { cursor:wait; opacity:.55; }.quiet-action--danger { color:#c34455; }.settings-error { margin:8px 0 0; color:#c34455; font-size:10px; } .settings-toggle { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 0; border-top:1px solid var(--pb-line); }.settings-toggle span { display:grid; gap:3px; }.settings-toggle strong { font-size:12px; }.settings-toggle small { color:var(--pb-muted); font-size:10px; line-height:1.4; }.settings-toggle input { width:32px; accent-color:var(--pb-violet); } +.shortcut-settings { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 0; border-top:1px solid var(--pb-line); }.shortcut-settings > span { display:grid; gap:3px; }.shortcut-settings strong { font-size:12px; }.shortcut-settings small { color:var(--pb-muted); font-size:10px; line-height:1.4; }.shortcut-settings button { flex:none; min-height:34px; padding:0 11px; border:1px solid var(--pb-line); border-radius:9px; background:color-mix(in srgb,var(--pb-glass-strong) 58%,transparent); color:var(--pb-ink); cursor:pointer; font-size:10px; font-weight:700; }.shortcut-settings button:hover,.shortcut-settings button:focus-visible { border-color:var(--pb-violet); color:var(--pb-violet); outline:2px solid color-mix(in srgb,var(--pb-violet) 30%,transparent); outline-offset:1px; } .danger-zone { display:flex; align-items:center; justify-content:space-between; gap:16px; margin-top:18px; padding:14px; border:1px solid color-mix(in srgb,#c34455 28%,var(--pb-line)); border-radius:13px; background:color-mix(in srgb,#c34455 5%,transparent); }.danger-zone > span { display:grid; gap:3px; }.danger-zone strong { color:#b63f50; font-size:12px; }.danger-zone small { color:var(--pb-muted); font-size:10px; line-height:1.45; }.danger-zone button { flex:none; min-height:34px; padding:0 12px; border:1px solid color-mix(in srgb,#c34455 38%,var(--pb-line)); border-radius:9px; background:transparent; color:#b63f50; cursor:pointer; font-size:10px; font-weight:750; }.danger-zone button:hover { background:color-mix(in srgb,#c34455 9%,transparent); }.danger-zone button:disabled { cursor:wait; opacity:.55; }.settings-result { margin:8px 0 0; color:#23825d; font-size:10px; }.settings-result--error { color:#c34455; } .settings-grid,.sync-grid { display:grid; grid-template-columns:1fr 1fr; gap:11px; padding:14px 0; border-top:1px solid var(--pb-line); }.settings-grid label,.settings-field { display:grid; gap:6px; }.settings-grid span,.settings-field span { color:var(--pb-muted); font-size:10px; font-weight:700; }.settings-grid input,.settings-field input,.settings-field textarea,textarea { width:100%; padding:9px 11px; border:1px solid var(--pb-line); border-radius:10px; outline:none; background:color-mix(in srgb,var(--pb-glass-strong) 58%,transparent); color:var(--pb-ink); }.settings-field { margin-top:12px; }.settings-field--wide { grid-column:1 / -1; margin-top:0; }.settings-field input:focus,.settings-field textarea:focus,textarea:focus { border-color:var(--pb-violet); box-shadow:0 0 0 3px color-mix(in srgb,var(--pb-violet) 14%,transparent); }.settings-field input:disabled { opacity:.45; } textarea { resize:vertical; font:11px/1.45 "SFMono-Regular",Consolas,monospace; } .budget-control { display:grid; grid-template-columns:minmax(0,1fr) 84px; overflow:hidden; border:1px solid var(--pb-line); border-radius:10px; background:color-mix(in srgb,var(--pb-glass-strong) 58%,transparent); }.budget-control input { min-width:0; border:0; border-radius:0; background:transparent; }.budget-control select { min-width:0; padding:0 10px; border:0; border-left:1px solid var(--pb-line); outline:0; background:color-mix(in srgb,var(--pb-violet) 7%,transparent); color:var(--pb-ink); cursor:pointer; font:700 11px/1 system-ui,sans-serif; } diff --git a/plugins/pasteboard-pro/apps/ztools/src/components/Shelf.vue b/plugins/pasteboard-pro/apps/ztools/src/components/Shelf.vue index e447c2aef..612950d3f 100644 --- a/plugins/pasteboard-pro/apps/ztools/src/components/Shelf.vue +++ b/plugins/pasteboard-pro/apps/ztools/src/components/Shelf.vue @@ -26,6 +26,7 @@ const props = defineProps<{ pasteStackCount: number; pasteStackDirection: PasteStackDirection; reorderEnabled: boolean; + canCaptureScreen: boolean; }>(); const emit = defineEmits<{ @@ -47,6 +48,7 @@ const emit = defineEmits<{ clearStack: []; openPrivacySettings: []; createText: []; + captureScreen: []; reorder: [value: ListReorderRequest]; }>(); @@ -86,12 +88,14 @@ function requestCreatePinboard(): void { :paused="paused" :compact="density === 'compact'" :reorder-enabled="reorderEnabled" + :can-capture-screen="canCaptureScreen" :edge="edge" @update:query="emit('update:query', $event)" @toggle-pause="emit('togglePause')" @toggle-compact="emit('toggleCompact')" @open-privacy-settings="emit('openPrivacySettings')" @create-text="emit('createText')" + @capture-screen="emit('captureScreen')" /> +import { computed } from "vue"; import type { DockEdge } from "@pasteboard-pro/design-tokens"; +import { primaryModifierName, resolveShortcutPlatform } from "../platform-shortcuts"; import SearchBar from "./SearchBar.vue"; defineProps<{ query: string; paused: boolean; compact: boolean; + canCaptureScreen: boolean; edge: DockEdge; }>(); @@ -16,7 +19,15 @@ const emit = defineEmits<{ toggleCompact: []; openPrivacySettings: []; createText: []; + captureScreen: []; }>(); + +const createTextShortcutTitle = computed(() => { + const platform = resolveShortcutPlatform( + window.pasteboardPro?.getPlatformCapabilities().platform, + ); + return `新建文本(${primaryModifierName(platform)}-N)`; +});