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..ad37e013e 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 瞬间并发替换父目录。 ## 技术实现 @@ -58,11 +62,14 @@ Paste剪切板是一款 Paste 风格的本地优先剪贴板历史插件。它 ## 本地开发 ```bash +npm install -g @ztools-center/plugin-cli corepack pnpm@9.15.9 install --frozen-lockfile corepack pnpm@9.15.9 --filter @pasteboard-pro/ztools dev ``` -ZTools 开发页面默认由 Vite 启动;ATools 与 ZTools 使用独立 UI,不共享前端框架。 +官方 CLI 1.2.0 提供 `ztools create`、`ztools publish` 和 `ztools pull-contributions`。现有项目通过包内脚本启动 Vite、构建和测试;CLI 没有独立的 dev/build 子命令。独立插件仓库完成提交后使用 `ztools publish`;已经位于中心仓库工作树、且 PR 使用自定义分支时,需要沿用该 PR 的实际 head 分支更新,避免 CLI 固定的 `plugin/` 分支另建 PR。1.2.0 的 `publish --help` 会执行发布流程,不可当作只读帮助命令。 + +ATools 与 ZTools 使用独立 UI,不共享前端框架。 ## 验证 @@ -85,6 +92,41 @@ pnpm verify:visual-artifact 仓库通用构建器会读取插件根目录的 `plugin.json`,在对应平台 runner 上构建;macOS 编译并临时签名 Vision helper,Windows/Linux 跳过该 helper 并使用对应平台命令,最后从 `dist/ztools` 打包 ZIP。native 构建逻辑仍限制在插件目录内。 +## 大数据量列表回归 + +ZTools 时间线按可视范围动态挂载 30~50 张卡片(结果不足 30 条时全部挂载,超大视口优先覆盖完整可视范围),支持横向、纵向与紧凑模式。首批加载 50 条,接近末尾时预取下一页;跨页方向键等待页面返回后继续移动,刷新保留已经加载的范围和选择。拖拽排序在完整分组顺序上计算,可保存十万条 ID,避免覆盖未加载记录的顺序。 + +搜索在主插件窗口持有的 Node Worker 中执行,关闭 Paste 面板后复用缓存;输入合并窗口为 120 ms,过期结果不会覆盖新查询。后台缓存完整正文、HTML、OCR 和规范化搜索字段,沿用共享查询函数的子串、短语和筛选规则,搜索不再受界面已加载记录或一万条限制。返回列表的文本预览截取至 4096 字符;粘贴、批量粘贴、编辑和预览按 ID 读取完整原文。图片缩略图和原生拖拽按记录 ID 读取,不再为每批缩略图全量扫描历史。 + +分页游标指向带版本的搜索结果快照。数据更新或游标过期时重新查询,避免跳项;新增、编辑、删除和插件 WebDAV 同步通过文档 ID 通知更新缓存。旧宿主或未知变更通知回退为一次完整刷新。 + +```bash +# 启动时序用例直接验证生产 renderer,先生成它: +pnpm --filter @pasteboard-pro/ztools build:renderer +pnpm exec playwright test --config playwright.performance.config.ts +# 如本地没有 Playwright 自带浏览器,可使用已安装的 Chrome: +PLAYWRIGHT_CHANNEL=chrome pnpm exec playwright test --config playwright.performance.config.ts +# 可选:与虚拟列表改动之前的提交比较相同 10,000 条记录的组件挂载耗时 +PB_BENCH_REF=bd12eb67 PLAYWRIGHT_CHANNEL=chrome pnpm exec playwright test --config playwright.performance.config.ts +``` + +浏览器报告位于 `artifacts/virtual-timeline/`,包含十万条模拟数据的生产 Vue 界面、全库搜索、连续输入、分页和键盘回归。打包后的 Node Worker 可单独压测: + +```bash +pnpm --filter @pasteboard-pro/ztools build +node scripts/benchmark-history-worker.mjs +``` + +报告位于 `artifacts/search-capacity-20260908/worker-performance.json`,包括十万条模拟记录中的长文本、HTML、OCR、冷启动、查询往返 P95、调用线程定时器间隔和增量读取次数。该基准不包含 ZTools 数据库 IPC 与原生窗口创建,不代表十万条真实历史在所有机器上的性能保证。 + +宿主数据库目前没有公开原生游标/limit 查询,本次实现的是 Worker 缓存上的结果分页:主插件首次建立缓存、重载或未知外部变更仍需全量读取一次。数据库加载、宿主迁移、保留策略清理与同步仍可能产生额外全量读取;没有改写历史格式,也没有引入数据库迁移。 + +## 窗口首次显示 + +Shelf 和独立面板以隐藏状态创建。宿主 DOM-ready 后等待 renderer 的 `pasteboard-pro:window-ready` 信号;Vue 在主题、首屏数据和 DOM 更新完成后发出该信号。已就绪标记支持晚到的宿主回调,窗口不再主动刷新。macOS 的首次 `show()` 可能重新放置外接屏窗口,因此显示时先设为透明,调用 show/focus 后重新校准最新屏幕坐标,再恢复可见;尺寸补偿仅在实际 bounds 不匹配时执行。旧宿主不提供透明度 API 时仍立即校准显示后的坐标。加载期间关闭或替换窗口不会在异步加载结束后抢焦点;初始化失败时显示可关闭的错误提示。 + +`apps/ztools/tests/window.test.ts` 覆盖首次显示、连续打开、关闭重开和加载失败;`tests/performance/window-startup.spec.ts` 对生产 renderer 验证延迟主题/历史、空历史、错误状态、独立编辑窗口以及万条历史搜索。浏览器测试不代替 ZTools 原生窗口的视觉验收。 + ## 目录结构 ```text 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..7c504b048 100644 --- a/plugins/pasteboard-pro/apps/ztools/preload/clipboard-store.ts +++ b/plugins/pasteboard-pro/apps/ztools/preload/clipboard-store.ts @@ -1,3 +1,4 @@ +import { markRecordChanged, rememberRecord, recordDocumentFor } from "./record-index"; import { compareStableOrder, orderKeyBetween, @@ -57,6 +58,7 @@ export type DeleteRecordsResult = Readonly<{ export type CanonicalStoreOptions = Readonly<{ deviceId?: string; now?: () => number; + ready?: Promise; }>; export interface HostClipboardApi { @@ -157,7 +159,7 @@ async function getOptionalDocument( } } -function storedRecord(value: unknown): CanonicalClipboardRecord | undefined { +export function storedRecord(value: unknown): CanonicalClipboardRecord | undefined { if ( !isRecord(value) || value.type !== "pasteboard-pro-record" || @@ -246,6 +248,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 +256,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 +269,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, @@ -271,6 +277,8 @@ export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore { type: "pasteboard-pro-record", record: structuredClone(record), } satisfies StoredRecordDocument)); + rememberRecord(this.database, record.item.id, id); + markRecordChanged(this.database, id); } async putSyncedItem(item: PasteItem): Promise { @@ -320,6 +328,7 @@ export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore { } const current = await this.findRecordByItemId(itemId); if (current === undefined) return; + markRecordChanged(this.database, this.recordDocumentId(current.item.contentFingerprint)); try { await this.database.remove( await this.database.get(this.recordDocumentId(current.item.contentFingerprint)), @@ -330,12 +339,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, @@ -348,7 +359,44 @@ export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore { ); } + async readHistoryDocuments(): Promise { + await this.ready; + if (this.database.allDocs === undefined) throw new TypeError("ZTools database does not expose allDocs"); + const result = await this.database.allDocs({ + include_docs: true, startkey: "pasteboard-pro:record:", endkey: "pasteboard-pro:record:\uffff", + }); + const documents = documentsFromAllDocs(result, "ZTools database returned invalid record rows"); + // Only inspect IDs here. Full schema validation and search run in the worker. + for (let offset = 0; offset < documents.length; offset += 500) { + for (const document of documents.slice(offset, offset + 500)) { + if (isRecord(document) && typeof document._id === "string" && isRecord(document.record) && + isRecord(document.record.item) && typeof document.record.item.id === "string") { + rememberRecord(this.database, document.record.item.id, document._id); + } + } + if (offset + 500 < documents.length) await new Promise(resolve => setTimeout(resolve, 0)); + } + return documents; + } + + async readHistoryChanges(ids: readonly string[]): Promise { + await this.ready; + const documents: unknown[] = []; + for (let offset = 0; offset < ids.length; offset += 50) { + const batch = await Promise.all(ids.slice(offset, offset + 50).map(id => getOptionalDocument(this.database, id))); + for (const document of batch) { + const record = storedRecord(document); + if (record !== undefined && isRecord(document) && typeof document._id === "string") { + rememberRecord(this.database, record.item.id, document._id); + documents.push(document); + } + } + } + return documents; + } + async listRecords(): Promise { + await this.ready; if (this.database.allDocs === undefined) { throw new TypeError("ZTools database does not expose allDocs"); } @@ -363,6 +411,7 @@ export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore { ) .flatMap((document) => { const record = storedRecord(document); + if (record !== undefined) rememberRecord(this.database, record.item.id, this.recordDocumentId(record.item.contentFingerprint)); return record === undefined ? [] : [record]; }) .sort( @@ -376,6 +425,13 @@ export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore { query: string, limit: number, ): Promise> { + return (await this.searchWithRecords(query, limit)).result; + } + + async searchWithRecords(query: string, limit: number): Promise; + records: CanonicalClipboardRecord[]; + }>> { if (!Number.isInteger(limit) || limit < 1 || limit > 10_000) { throw new RangeError("Search limit must be an integer between 1 and 10000"); } @@ -384,12 +440,18 @@ export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore { records.map((record) => record.item), query, ); - return { items: matched.slice(0, limit), total: matched.length }; + return { result: { items: matched.slice(0, limit), total: matched.length }, records }; } async findRecordByItemId( itemId: string, ): Promise { + const documentId = recordDocumentFor(this.database, itemId); + if (documentId !== undefined) { + await this.ready; + const record = storedRecord(await getOptionalDocument(this.database, documentId)); + return record?.item.id === itemId ? record : undefined; + } return (await this.listRecords()).find((record) => record.item.id === itemId); } @@ -572,9 +634,11 @@ export class ZToolsCanonicalClipboardStore implements CanonicalClipboardStore { })); const document = await this.database.get(documentId); await this.database.remove(document); + markRecordChanged(this.database, documentId); deletedIds.push(record.item.id); } catch (error) { if (isDatabaseStatus(error, 404)) { + markRecordChanged(this.database, documentId); deletedIds.push(record.item.id); } else { failures.push({ 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/history-page.ts b/plugins/pasteboard-pro/apps/ztools/preload/history-page.ts new file mode 100644 index 000000000..405bead2a --- /dev/null +++ b/plugins/pasteboard-pro/apps/ztools/preload/history-page.ts @@ -0,0 +1,16 @@ +import type { PasteItem } from "@pasteboard-pro/core"; + +export type HistoryRequest = Readonly<{ + query?: string; + pinboardId?: string; + orderedIds?: readonly string[]; + cursor?: string; + limit?: number; +}>; + +export type HistoryPage = Readonly<{ + items: PasteItem[]; + total: number; + nextCursor?: string; + reset?: boolean; +}>; diff --git a/plugins/pasteboard-pro/apps/ztools/preload/history-rpc.ts b/plugins/pasteboard-pro/apps/ztools/preload/history-rpc.ts new file mode 100644 index 000000000..9312daf92 --- /dev/null +++ b/plugins/pasteboard-pro/apps/ztools/preload/history-rpc.ts @@ -0,0 +1,49 @@ +import type { HistoryPage, HistoryRequest } from "./history-page"; + +export const HISTORY_QUERY_CHANNEL = "pasteboard-pro:history-query"; +export const HISTORY_RESULT_CHANNEL = "pasteboard-pro:history-result"; +export type HistoryReply = Readonly<{ id: string; page?: HistoryPage; error?: string }>; + +/** Reuse the primary window's worker when the shelf closes after each paste. */ +export class HistoryRpcClient { + private readonly pending = new Map; + }>(); + private sequence = 0; + constructor( + private readonly session: string, + private readonly send: (id: string, request: HistoryRequest) => void, + ) {} + + page(request: HistoryRequest): Promise { + const id = `${this.session}:${++this.sequence}`; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error("历史查询超时,请重试")); + }, 30_000); + this.pending.set(id, { resolve, reject, timer }); + try { this.send(id, request); } + catch (error) { clearTimeout(timer); this.pending.delete(id); reject(error); } + }); + } + + receive(reply: HistoryReply): void { + const pending = this.pending.get(reply.id); + if (pending === undefined) return; + clearTimeout(pending.timer); + this.pending.delete(reply.id); + if (reply.page !== undefined) pending.resolve(reply.page); + else pending.reject(new Error(reply.error ?? "历史查询失败")); + } + + dispose(): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new Error("History window closed")); + } + this.pending.clear(); + } +} diff --git a/plugins/pasteboard-pro/apps/ztools/preload/history-search-index.ts b/plugins/pasteboard-pro/apps/ztools/preload/history-search-index.ts new file mode 100644 index 000000000..268c77f27 --- /dev/null +++ b/plugins/pasteboard-pro/apps/ztools/preload/history-search-index.ts @@ -0,0 +1,123 @@ +import { searchPasteItems, type PasteItem } from "@pasteboard-pro/core"; +import { applyListOrder } from "../src/list-order"; +import { isSmartPinboardId, matchesSmartPinboard } from "../src/smart-pinboards"; +import { storedRecord, type CanonicalClipboardRecord } from "./clipboard-store"; +import type { HistoryPage, HistoryRequest } from "./history-page"; + +/** Lives exclusively in the worker. Cursors address a versioned result snapshot. */ +export class HistorySearchIndex { + private readonly records = new Map(); + private version = 0; + private orderedSearchItems: PasteItem[] | undefined; + private readonly searchable = new Map(); + private readonly originals = new WeakMap(); + private sequence = 0; + private readonly snapshots = new Map(); + + replace(documents: readonly unknown[]): void { + this.records.clear(); + this.searchable.clear(); + this.update(documents, []); + } + + update(documents: readonly unknown[], removed: readonly string[]): void { + for (const id of removed) { this.records.delete(id); this.searchable.delete(id); } + for (const document of documents) { + const record = storedRecord(document); + const id = (document as { _id?: unknown } | null)?._id; + if (typeof id !== "string") continue; + if (record === undefined) { this.records.delete(id); this.searchable.delete(id); } + else { + this.records.set(id, record); + const normalized = normalizedSearchItem(record.item); + this.searchable.set(id, normalized); + this.originals.set(normalized, record.item); + } + } + this.orderedSearchItems = undefined; + this.version++; + this.snapshots.clear(); + } + + order(request: HistoryRequest): string[] { + const { cursor: _cursor, ...firstPage } = request; + this.page({ ...firstPage, limit: 1 }); + return [...this.snapshots.values()].at(-1)!.items.map(item => item.id); + } + + page(request: HistoryRequest): HistoryPage { + const limit = request.limit ?? 50; + if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new RangeError("Page size must be 1–100"); + const key = JSON.stringify([request.query ?? "", request.pinboardId, request.orderedIds ?? []]); + let offset = 0; + let token: string | undefined; + let snapshot: { key: string; items: PasteItem[] } | undefined; + if (request.cursor !== undefined) { + const match = /^(\d+-\d+):(\d+)$/.exec(request.cursor); + token = match?.[1]; + offset = Number(match?.[2]); + snapshot = token === undefined ? undefined : this.snapshots.get(token); + if (snapshot?.key !== key || !Number.isSafeInteger(offset) || offset < 0) { + const { cursor: _cursor, ...firstPage } = request; + return { ...this.page(firstPage), reset: true }; + } + } + if (snapshot === undefined) { + // Stable tie order matches listRecords; the shared query function preserves + // substring matching, Chinese text, quoted phrases, OCR and all filters. + this.orderedSearchItems ??= [...this.searchable.values()].sort((a, b) => + Date.parse(b.copiedAt) - Date.parse(a.copiedAt) || a.id.localeCompare(b.id)); + const matched = searchPasteItems(this.orderedSearchItems, request.query ?? "") + .map(item => this.originals.get(item)!); + const scope = request.pinboardId; + const scoped = scope === undefined ? matched : matched.filter(item => + isSmartPinboardId(scope) ? matchesSmartPinboard(item, scope) : item.pinboardId === scope); + snapshot = { key, items: applyListOrder(scoped, request.orderedIds ?? []) }; + token = `${this.version}-${++this.sequence}`; + this.snapshots.set(token, snapshot); + while (this.snapshots.size > 4) this.snapshots.delete(this.snapshots.keys().next().value!); + } + const end = Math.min(snapshot.items.length, offset + limit); + return { + items: snapshot.items.slice(offset, end).map(historyCard), + total: snapshot.items.length, + ...(end < snapshot.items.length ? { nextCursor: `${token}:${end}` } : {}), + }; + } +} + +/** The list gets bounded previews; clipboard actions always fetch full records. */ +function historyCard(item: PasteItem): PasteItem { + return { + ...item, + ...(item.title === undefined ? {} : { title: item.title.slice(0, 1_024) }), + ...(item.ocrText === undefined ? {} : { ocrText: item.ocrText.slice(0, 4_096) }), + payload: { + ...item.payload, + ...(item.payload.text === undefined ? {} : { text: item.payload.text.slice(0, 4_096) }), + ...(item.payload.html === undefined ? {} : { html: item.payload.html.slice(0, 4_096) }), + }, + }; +} + +/** Normalize searchable fields once per change, retaining originals for display/paste. */ +function normalizedSearchItem(item: PasteItem): PasteItem { + return { + ...item, + ...(item.title === undefined ? {} : { title: item.title.toLowerCase() }), + ...(item.ocrText === undefined ? {} : { ocrText: item.ocrText.toLowerCase() }), + sourceDeviceId: item.sourceDeviceId.toLowerCase(), + ...(item.sourceApp === undefined ? {} : { sourceApp: { + ...item.sourceApp, + ...(item.sourceApp.name === undefined ? {} : { name: item.sourceApp.name.toLowerCase() }), + ...(item.sourceApp.bundleId === undefined ? {} : { bundleId: item.sourceApp.bundleId.toLowerCase() }), + } }), + payload: { + ...item.payload, + ...(item.payload.text === undefined ? {} : { text: item.payload.text.toLowerCase() }), + ...(item.payload.html === undefined ? {} : { html: item.payload.html.toLowerCase() }), + ...(item.payload.mediaType === undefined ? {} : { mediaType: item.payload.mediaType.toLowerCase() }), + ...(item.payload.filePaths === undefined ? {} : { filePaths: item.payload.filePaths.map(value => value.toLowerCase()) }), + }, + }; +} diff --git a/plugins/pasteboard-pro/apps/ztools/preload/history-search.ts b/plugins/pasteboard-pro/apps/ztools/preload/history-search.ts new file mode 100644 index 000000000..4fcb3ede9 --- /dev/null +++ b/plugins/pasteboard-pro/apps/ztools/preload/history-search.ts @@ -0,0 +1,102 @@ +import { Worker } from "node:worker_threads"; +import type { HistoryPage, HistoryRequest } from "./history-page"; + +export type HistoryDocumentReader = Readonly<{ + all(): Promise; + changed(ids: readonly string[]): Promise; +}>; + +/** One lazy worker per window; full records never enter Vue's reactive state. */ +export class HistorySearchService { + private worker: Worker | undefined; + private serial = 0; + private readonly pending = new Map(); + private loaded = false; + private disposed = false; + private dirty: Set | undefined = new Set(); + private queue: Promise = Promise.resolve(); + + constructor(private readonly filename: string, private readonly reader: HistoryDocumentReader) {} + + invalidate(ids?: readonly string[]): void { + if (ids === undefined) this.dirty = undefined; + else if (this.dirty !== undefined) ids.forEach(id => this.dirty!.add(id)); + } + + page(request: HistoryRequest): Promise { + return this.query("page", request) as Promise; + } + + order(request: HistoryRequest): Promise { + return this.query("order", request) as Promise; + } + + private query(type: "page" | "order", request: HistoryRequest): Promise { + const operation = this.queue.then(async () => { + if (this.disposed) throw new Error("History search closed"); + if (!this.loaded || this.dirty === undefined) { + this.dirty = new Set(); + try { + await this.call({ type: "replace", documents: await this.reader.all() }); + this.loaded = true; + } catch (error) { this.loaded = false; throw error; } + } + while (this.dirty === undefined || this.dirty.size > 0) { + const ids = this.dirty === undefined ? undefined : [...this.dirty]; + this.dirty = new Set(); + try { + if (ids === undefined) await this.call({ type: "replace", documents: await this.reader.all() }); + else await this.call({ type: "update", documents: await this.reader.changed(ids), removed: ids }); + } catch (error) { this.dirty = undefined; throw error; } + } + return this.call({ type, request }); + }); + this.queue = operation.catch(() => undefined); + return operation; + } + + dispose(): void { + this.disposed = true; + this.fail(new Error("History search closed")); + void this.worker?.terminate(); + this.worker = undefined; + } + + private fail(error: Error): void { + this.loaded = false; + for (const request of this.pending.values()) request.reject(error); + this.pending.clear(); + } + + private call(message: Record): Promise { + if (this.disposed) return Promise.reject(new Error("History search closed")); + if (this.worker === undefined) { + const worker = new Worker(this.filename); + this.worker = worker; + worker.on("message", (response: { id: number; value?: unknown; error?: string }) => { + const request = this.pending.get(response.id); + this.pending.delete(response.id); + if (response.error === undefined) request?.resolve(response.value); + else request?.reject(new Error(response.error)); + }); + worker.on("error", error => { + if (this.worker !== worker) return; + this.worker = undefined; + this.fail(error); + void worker.terminate(); + }); + worker.on("exit", () => { + if (this.worker === worker) { + this.worker = undefined; + this.fail(new Error("History search worker exited")); + } + }); + } + const id = ++this.serial; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + try { this.worker!.postMessage({ ...message, id }); } + catch (error) { this.pending.delete(id); reject(error); } + }); + } +} diff --git a/plugins/pasteboard-pro/apps/ztools/preload/history-worker.ts b/plugins/pasteboard-pro/apps/ztools/preload/history-worker.ts new file mode 100644 index 000000000..4946a2354 --- /dev/null +++ b/plugins/pasteboard-pro/apps/ztools/preload/history-worker.ts @@ -0,0 +1,22 @@ +import { parentPort } from "node:worker_threads"; +import { HistorySearchIndex } from "./history-search-index"; +import type { HistoryRequest } from "./history-page"; + +const index = new HistorySearchIndex(); +parentPort?.on("message", (message: { + id: number; + type: "replace" | "update" | "page" | "order"; + documents?: unknown[]; + removed?: string[]; + request?: HistoryRequest; +}) => { + try { + let value: unknown; + if (message.type === "replace") index.replace(message.documents ?? []); + else if (message.type === "update") index.update(message.documents ?? [], message.removed ?? []); + else value = message.type === "order" ? index.order(message.request ?? {}) : index.page(message.request ?? {}); + parentPort?.postMessage({ id: message.id, value }); + } catch (error) { + parentPort?.postMessage({ id: message.id, error: error instanceof Error ? error.message : String(error) }); + } +}); 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..1e87ba3a8 100644 --- a/plugins/pasteboard-pro/apps/ztools/preload/index.ts +++ b/plugins/pasteboard-pro/apps/ztools/preload/index.ts @@ -1,3 +1,8 @@ +import { HistoryRpcClient, HISTORY_QUERY_CHANNEL, HISTORY_RESULT_CHANNEL, type HistoryReply } from "./history-rpc"; +import { reorderItemGroupIds, listOrderScope, type ListReorderRequest } from "../src/list-order"; +import { HistorySearchService } from "./history-search"; +import { rememberRecord, takeRecordChanges } from "./record-index"; +import type { HistoryPage, HistoryRequest } from "./history-page"; import { readFile, rm, stat } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -8,10 +13,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 +124,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,14 +164,21 @@ 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>; + searchHistoryPage(request: HistoryRequest): Promise; + getHistoryItem(itemId: string, fingerprint?: string): Promise; + reorderHistory(request: ListReorderRequest, pinboardId?: string): Promise; getPrivacySettings(): Promise; savePrivacySettings(settings: PrivacySettings): Promise; setCapturePause(pause: CapturePauseState): Promise; @@ -200,12 +233,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,11 +302,28 @@ 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); const thumbnailService = new ThumbnailService(store, nativeImage); const nativeFileDragService = new NativeFileDragService(store, ztools); +const historySearch = new HistorySearchService(path.join(__dirname, "history-worker.cjs"), { + all: () => store.readHistoryDocuments(), + changed: ids => store.readHistoryChanges(ids), +}); +const historyRpc = !isPrimaryWindow && ztools.sendToParent !== undefined + ? new HistoryRpcClient(`${Date.now()}-${Math.random().toString(36).slice(2)}`, + (id, request) => ztools.sendToParent!(HISTORY_QUERY_CHANNEL, id, request)) + : undefined; +window.addEventListener(HISTORY_RESULT_CHANNEL, event => historyRpc?.receive((event as CustomEvent).detail)); +window.addEventListener("beforeunload", () => { historySearch.dispose(); historyRpc?.dispose(); }); +window.addEventListener("pasteboard-pro:history-changed", (event) => { + const detail = (event as CustomEvent).detail; + historySearch.invalidate(Array.isArray(detail) && detail.every(id => typeof id === "string") ? detail : undefined); +}); const ocrClient = process.platform === "darwin" ? createOcrClient({ @@ -377,12 +459,13 @@ function requestShelfEdge(edge: ShelfDockEdge): void { function broadcastHistoryChanged(): void { thumbnailService.invalidateRecordIndex(); - window.dispatchEvent(new CustomEvent(HISTORY_CHANGED_CHANNEL)); + const changes = takeRecordChanges(ztools.db.promises); + window.dispatchEvent(new CustomEvent(HISTORY_CHANGED_CHANNEL, { detail: changes })); if (isPrimaryWindow) { - shelfWindows.notifyHistoryChanged(); + shelfWindows.notifyHistoryChanged(changes); return; } - ztools.sendToParent?.(HISTORY_CHANGED_CHANNEL); + ztools.sendToParent?.(HISTORY_CHANGED_CHANNEL, changes); } function broadcastWindowPreferencesChanged(): void { @@ -454,8 +537,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 +568,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); }); @@ -508,8 +592,18 @@ if (isPrimaryWindow) { void repositionShelf(edge).catch(reportSynchronizationError); } }); - ipcRenderer.on(HISTORY_CHANGED_CHANNEL, () => { - shelfWindows.notifyHistoryChanged(); + ipcRenderer.on(HISTORY_QUERY_CHANNEL, (_event, id, request) => { + if (typeof id !== "string" || id.length > 128 || !isRecord(request)) return; + void historySearch.page(request as HistoryRequest).then( + page => shelfWindows.notifyHistoryResult({ id, page }), + error => shelfWindows.notifyHistoryResult({ id, error: error instanceof Error ? error.message : "历史查询失败" }), + ); + }); + ipcRenderer.on(HISTORY_CHANGED_CHANNEL, (_event, detail) => { + const changes = Array.isArray(detail) && detail.every(id => typeof id === "string") ? detail : undefined; + historySearch.invalidate(changes); + thumbnailService.invalidateRecordIndex(); + shelfWindows.notifyHistoryChanged(changes); }); ipcRenderer.on(WINDOW_PREFERENCES_CHANGED_CHANNEL, () => { shelfWindows.notifyWindowPreferencesChanged(); @@ -574,22 +668,43 @@ 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([ - store.search(query, normalizedLimit), - store.listRecords(), - ]); + const { result, records } = await store.searchWithRecords(query, normalizedLimit); nativeFileDragService.refresh(records); return result; }, + async searchHistoryPage(request) { + const page = await (historyRpc === undefined ? historySearch.page(request) : historyRpc.page(request)); + for (const item of page.items) rememberRecord(ztools.db.promises, item.id, `pasteboard-pro:record:${item.contentFingerprint}`); + return page; + }, + async getHistoryItem(itemId, fingerprint) { + if (fingerprint !== undefined) rememberRecord(ztools.db.promises, itemId, `pasteboard-pro:record:${fingerprint}`); + return (await store.findRecordByItemId(itemId))?.item; + }, + async reorderHistory(request, pinboardId) { + const scope = listOrderScope(pinboardId); + const orders = await listOrderStore.get(); + const ids = await historySearch.order({ + ...(pinboardId === undefined ? {} : { pinboardId }), orderedIds: orders[scope] ?? [], + }); + return listOrderStore.put(scope, reorderItemGroupIds(ids, request.sourceIds, request.targetId, request.position)); + }, getPrivacySettings: () => privacyStore.get(), async savePrivacySettings(settings) { await privacyStore.put(settings); @@ -857,3 +972,4 @@ const bridge: PasteboardProBridge = { }; (window as Window & { pasteboardPro?: PasteboardProBridge }).pasteboardPro = bridge; +} diff --git a/plugins/pasteboard-pro/apps/ztools/preload/list-order-store.ts b/plugins/pasteboard-pro/apps/ztools/preload/list-order-store.ts index 5b0c77b1e..51a67e208 100644 --- a/plugins/pasteboard-pro/apps/ztools/preload/list-order-store.ts +++ b/plugins/pasteboard-pro/apps/ztools/preload/list-order-store.ts @@ -3,7 +3,7 @@ import type { ZToolsDocumentDatabase } from "./clipboard-store"; export type ListOrders = Readonly>; const LIST_ORDER_ID = "pasteboard-pro:settings:list-orders"; -const MAX_ORDERED_ITEMS = 10_000; +const MAX_ORDERED_ITEMS = 100_000; const MAX_SCOPES = 258; function isRecord(value: unknown): value is Record { 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/record-index.ts b/plugins/pasteboard-pro/apps/ztools/preload/record-index.ts new file mode 100644 index 000000000..390b5ef7b --- /dev/null +++ b/plugins/pasteboard-pro/apps/ztools/preload/record-index.ts @@ -0,0 +1,33 @@ +/** Shared by all canonical stores in one preload (including the sync store). */ +const indexes = new WeakMap; + changes: Set; +}>(); + +function index(database: object) { + let value = indexes.get(database); + if (value === undefined) { + value = { documents: new Map(), changes: new Set() }; + indexes.set(database, value); + } + return value; +} + +export function rememberRecord(database: object, itemId: string, documentId: string): void { + index(database).documents.set(itemId, documentId); +} + +export function recordDocumentFor(database: object, itemId: string): string | undefined { + return index(database).documents.get(itemId); +} + +export function markRecordChanged(database: object, documentId: string): void { + index(database).changes.add(documentId); +} + +export function takeRecordChanges(database: object): string[] { + const value = index(database); + const changes = [...value.changes]; + value.changes.clear(); + return changes; +} 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/preload/thumbnail.ts b/plugins/pasteboard-pro/apps/ztools/preload/thumbnail.ts index bb13f9ce9..be92b3460 100644 --- a/plugins/pasteboard-pro/apps/ztools/preload/thumbnail.ts +++ b/plugins/pasteboard-pro/apps/ztools/preload/thumbnail.ts @@ -22,6 +22,7 @@ export type NativeImageApi = Readonly<{ export type ThumbnailRecordStore = Readonly<{ listRecords(): Promise; + findRecordByItemId?(itemId: string): Promise; }>; const THUMBNAIL_WIDTH = 480; @@ -82,13 +83,13 @@ export class ThumbnailService { const requestedIds = uniqueItemIds(itemIds); if (requestedIds.length === 0) return []; - const byId = await this.recordsById(); + const byId = this.store.findRecordByItemId === undefined ? await this.recordsById() : undefined; const values = await concurrentMap( requestedIds, GENERATION_CONCURRENCY, async (itemId) => { - const record = byId.get(itemId); + const record = byId === undefined ? await this.store.findRecordByItemId!(itemId) : byId.get(itemId); return record === undefined ? null : await this.cachedThumbnail(record); }, ); diff --git a/plugins/pasteboard-pro/apps/ztools/preload/window.ts b/plugins/pasteboard-pro/apps/ztools/preload/window.ts index 8f26d3bff..0cfc433c7 100644 --- a/plugins/pasteboard-pro/apps/ztools/preload/window.ts +++ b/plugins/pasteboard-pro/apps/ztools/preload/window.ts @@ -1,9 +1,11 @@ +import type { HistoryReply } from "./history-rpc"; import { clampShelfBounds, type DockEdge, type Rect, } from "@pasteboard-pro/design-tokens"; import type { PasteStackState } from "@pasteboard-pro/core"; +import { waitForWindowReadyScript } from "../window-ready"; export type ShelfDisplay = Readonly<{ id: string | number; @@ -49,7 +51,9 @@ export interface BrowserWindowHandle { show(): void; focus(): void; close(): void; - setBounds(bounds: Rect): void; + setBounds(bounds: Rect, animate?: boolean): void; + getBounds?(): Rect; + setOpacity?(opacity: number): void; setContentProtection(enabled: boolean): void; } @@ -86,17 +90,20 @@ const PANEL_SIZES: Readonly(); + function setWindowBounds( handle: BrowserWindowHandle, options: BrowserWindowOptions, ): void { if (handle.isDestroyed()) return; + requestedWindowBounds.set(handle, options); handle.setBounds({ x: options.x, y: options.y, width: options.width, height: options.height, - }); + }, false); } function scheduleBoundsCorrection( @@ -104,11 +111,63 @@ function scheduleBoundsCorrection( options: BrowserWindowOptions, ): void { for (const delay of [50, 250, 750]) { - const timer = globalThis.setTimeout(() => setWindowBounds(handle, options), delay); + const timer = globalThis.setTimeout(() => { + if (handle.isDestroyed()) return; + const target = requestedWindowBounds.get(handle) ?? options; + const actual = handle.getBounds?.(); + if (actual !== undefined && actual.x === target.x && actual.y === target.y && + actual.width === target.width && actual.height === target.height) return; + setWindowBounds(handle, target); + }, delay); (timer as unknown as { unref?: () => void }).unref?.(); } } +// On macOS, show() can move an off-primary-screen window even when its +// hidden bounds are correct. Materialize it at zero opacity, then correct the +// native frame after show/focus before exposing any pixels. Older hosts without +// opacity support still receive the immediate post-show correction. +function revealWindow(handle: BrowserWindowHandle): void { + handle.setOpacity?.(0); + handle.show(); + handle.focus(); + const bounds = requestedWindowBounds.get(handle); + if (bounds !== undefined) setWindowBounds(handle, bounds); + handle.setOpacity?.(1); +} + +// ZTools invokes its DOM-ready callback once. Never reload or reveal the window +// there: the renderer still needs to load its preferences and initial content. +function createReadyWindow( + host: ShelfWindowHost, + url: string, + options: BrowserWindowOptions, + isCurrent: (handle: BrowserWindowHandle) => boolean, + onReady: (handle: BrowserWindowHandle) => void, +): BrowserWindowHandle { + let waiting = false; + const handle = host.createBrowserWindow(url, options, () => { + if (waiting) return; + waiting = true; + // Also supports hosts that invoke the callback before returning the handle. + queueMicrotask(async () => { + if (handle.isDestroyed() || !isCurrent(handle)) return; + try { + await handle.webContents.executeJavaScript(waitForWindowReadyScript); + if (handle.isDestroyed() || !isCurrent(handle)) return; + onReady(handle); + revealWindow(handle); + } catch (error) { + // A closed/replaced window must never steal focus when its work settles. + if (handle.isDestroyed() || !isCurrent(handle)) return; + console.error("Paste window initialization failed", error); + handle.close(); + } + }); + }); + return handle; +} + function preferredBounds(display: ShelfDisplay, edge: DockEdge): Rect { const { workArea } = display; @@ -243,6 +302,7 @@ export function resolveShelfPlacement( } export class ShelfWindowManager { + private readonly readyHandles = new WeakSet(); private current: BrowserWindowHandle | undefined; private currentEdge: DockEdge | undefined; @@ -281,8 +341,9 @@ export class ShelfWindowManager { setWindowBounds(this.current, windowOptions); scheduleBoundsCorrection(this.current, windowOptions); this.current.setContentProtection(options.contentProtection); - this.current.show(); - this.current.focus(); + if (this.readyHandles.has(this.current)) { + revealWindow(this.current); + } return this.current; } if (this.current !== undefined && !this.current.isDestroyed()) { @@ -291,41 +352,18 @@ export class ShelfWindowManager { this.current = undefined; const url = `index.html?shelf=1&dock=${encodeURIComponent(options.edge)}&display=${encodeURIComponent(String(display.id))}`; - let handle: BrowserWindowHandle | undefined; - let readyBeforeAssignment = false; - let preloadReloaded = false; - const finishOpening = (): void => { - if (handle === undefined) { - readyBeforeAssignment = true; - return; - } - if (!preloadReloaded) { - preloadReloaded = true; - void handle.webContents.executeJavaScript("window.location.reload()"); - return; - } - setWindowBounds(handle, windowOptions); - handle.show(); - handle.focus(); - this.host.hideMainWindow(false); - }; - - handle = this.host.createBrowserWindow( + const handle = createReadyWindow( + this.host, url, windowOptions, - finishOpening, + (candidate) => this.current === candidate, + (candidate) => this.readyHandles.add(candidate), ); this.current = handle; this.currentEdge = options.edge; setWindowBounds(handle, windowOptions); scheduleBoundsCorrection(handle, windowOptions); handle.setContentProtection(options.contentProtection); - handle.show(); - handle.focus(); - - if (readyBeforeAssignment) { - finishOpening(); - } return handle; } @@ -344,10 +382,20 @@ export class ShelfWindowManager { } } - notifyHistoryChanged(): void { + notifyHistoryResult(result: HistoryReply): void { + if (this.current === undefined || this.current.isDestroyed()) return; + const detail = JSON.stringify(result).replaceAll("<", "\\u003c"); + void this.current.webContents.executeJavaScript( + `window.dispatchEvent(new CustomEvent('pasteboard-pro:history-result', { detail: ${detail} }))`, + ); + } + + notifyHistoryChanged(changes?: readonly string[]): void { if (this.current === undefined || this.current.isDestroyed()) return; void this.current.webContents.executeJavaScript( - "window.dispatchEvent(new CustomEvent('pasteboard-pro:history-changed'))", + changes === undefined + ? "window.dispatchEvent(new CustomEvent('pasteboard-pro:history-changed'))" + : `window.dispatchEvent(new CustomEvent('pasteboard-pro:history-changed', { detail: ${JSON.stringify(changes).replaceAll("<", "\\u003c")} }))`, ); } @@ -368,6 +416,7 @@ export class ShelfWindowManager { } export class PanelWindowManager { + private readonly readyHandles = new WeakSet(); private current: | Readonly<{ key: string; handle: BrowserWindowHandle }> | undefined; @@ -396,8 +445,9 @@ export class PanelWindowManager { ) { setWindowBounds(this.current.handle, windowOptions); scheduleBoundsCorrection(this.current.handle, windowOptions); - this.current.handle.show(); - this.current.handle.focus(); + if (this.readyHandles.has(this.current.handle)) { + revealWindow(this.current.handle); + } return this.current.handle; } @@ -406,38 +456,16 @@ export class PanelWindowManager { } const url = `index.html?${params.toString()}`; - let handle: BrowserWindowHandle | undefined; - let readyBeforeAssignment = false; - let preloadReloaded = false; - const finishOpening = (): void => { - if (handle === undefined) { - readyBeforeAssignment = true; - return; - } - if (!preloadReloaded) { - preloadReloaded = true; - void handle.webContents.executeJavaScript("window.location.reload()"); - return; - } - setWindowBounds(handle, windowOptions); - handle.show(); - handle.focus(); - }; - - handle = this.host.createBrowserWindow( + const handle = createReadyWindow( + this.host, url, windowOptions, - finishOpening, + (candidate) => this.current?.handle === candidate, + (candidate) => this.readyHandles.add(candidate), ); this.current = { key, handle }; setWindowBounds(handle, windowOptions); scheduleBoundsCorrection(handle, windowOptions); - handle.show(); - handle.focus(); - - if (readyBeforeAssignment) { - finishOpening(); - } return handle; } diff --git a/plugins/pasteboard-pro/apps/ztools/public/plugin.json b/plugins/pasteboard-pro/apps/ztools/public/plugin.json index b7c11f391..1c1df09d6 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)", @@ -17,6 +17,7 @@ "features": [ { "code": "pasteboard-pro", + "mainHide": true, "explain": "打开 Paste剪切板历史", "icon": "logo.png", "cmds": [ diff --git a/plugins/pasteboard-pro/apps/ztools/scripts/assemble-dist.mjs b/plugins/pasteboard-pro/apps/ztools/scripts/assemble-dist.mjs index 084084916..ae8fd6890 100644 --- a/plugins/pasteboard-pro/apps/ztools/scripts/assemble-dist.mjs +++ b/plugins/pasteboard-pro/apps/ztools/scripts/assemble-dist.mjs @@ -35,6 +35,7 @@ export async function verifyAssembledPackage(root) { manifest.preload, manifest.logo, "package.json", + "history-worker.cjs", ...(includeVisionHelper ? ["pasteboard-vision"] : []), ]) { assert.equal(typeof relative, "string"); diff --git a/plugins/pasteboard-pro/apps/ztools/src/App.vue b/plugins/pasteboard-pro/apps/ztools/src/App.vue index 1eaf7edf8..75339e84b 100644 --- a/plugins/pasteboard-pro/apps/ztools/src/App.vue +++ b/plugins/pasteboard-pro/apps/ztools/src/App.vue @@ -1,8 +1,9 @@ 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..9beb03f1f 100644 --- a/plugins/pasteboard-pro/apps/ztools/src/components/Shelf.vue +++ b/plugins/pasteboard-pro/apps/ztools/src/components/Shelf.vue @@ -14,6 +14,8 @@ import Toolbar from "./Toolbar.vue"; const props = defineProps<{ items: readonly PasteItem[]; + total?: number; + hasMore?: boolean; pinboards: readonly Pinboard[]; smartPinboards: readonly SmartPinboard[]; selectedIds: readonly string[]; @@ -26,10 +28,12 @@ const props = defineProps<{ pasteStackCount: number; pasteStackDirection: PasteStackDirection; reorderEnabled: boolean; + canCaptureScreen: boolean; }>(); const emit = defineEmits<{ "update:query": [value: string]; + loadMore: []; select: [itemId: string, extend: boolean, toggle: boolean]; paste: [itemId: string, plainText?: boolean]; preview: [itemId: string]; @@ -47,11 +51,15 @@ const emit = defineEmits<{ clearStack: []; openPrivacySettings: []; createText: []; + captureScreen: []; reorder: [value: ListReorderRequest]; }>(); const style = computed(() => visualState(props.edge, props.density)); const pinboardStrip = ref>(); +const timeline = ref>(); + +defineExpose({ focusItem: (itemId: string) => timeline.value?.focusItem(itemId) }); function forwardSelect(itemId: string, extend: boolean, toggle: boolean): void { emit("select", itemId, extend, toggle); @@ -86,12 +94,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, nextTick, onMounted, ref, watch } from "vue"; +import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue"; import type { PasteItem, Pinboard } from "@pasteboard-pro/core"; @@ -11,10 +11,13 @@ import { type ListReorderRequest, } from "../list-order"; import PasteCard from "./PasteCard.vue"; +import { timelineFocusOffset, timelineRange } from "../virtual-timeline"; const props = withDefaults( defineProps<{ items: readonly PasteItem[]; + total?: number; + hasMore?: boolean; pinboards: readonly Pinboard[]; selectedIds: readonly string[]; focusedId: string | undefined; @@ -26,6 +29,7 @@ const props = withDefaults( ); const emit = defineEmits<{ + loadMore: []; select: [itemId: string, extend: boolean, toggle: boolean]; paste: [itemId: string]; preview: [itemId: string]; @@ -34,8 +38,21 @@ const emit = defineEmits<{ createPinboard: []; reorder: [value: ListReorderRequest]; }>(); -const track = ref(); +const track = ref(); const followLatest = ref(true); +const offset = ref(0); +const viewport = ref(800); +const cardSize = ref(240); +const gap = computed(() => props.vertical && props.compact ? 8 : 12); +const stride = computed(() => cardSize.value + gap.value); +const selectedIdSet = computed(() => new Set(props.selectedIds)); +const itemPositions = computed(() => new Map(props.items.map((item, index) => [item.id, index]))); +const orderedSelectedIds = computed(() => props.items + .filter((item) => selectedIdSet.value.has(item.id)).map((item) => item.id)); +const dragOriginId = ref(); +const mountedDragIds = ref([]); +let resizeObserver: ResizeObserver | undefined; +let itemsRevision = 0; const draggedItemIds = ref([]); const draggedItemIdSet = computed(() => new Set(draggedItemIds.value)); const reorderTarget = ref>(); @@ -58,7 +75,59 @@ const reorderShifts = computed(() => { ); }); -type VisibleAnchor = Readonly<{ itemId: string; offset: number }>; +const range = computed(() => timelineRange( + props.items.length, stride.value, offset.value, viewport.value, +)); +// Project drag shifts before selecting visible cards: a distant card can move +// into the viewport when a large selection is dragged. Keep only the actual +// drag source and initially mounted selected cards alive until dragend. +const projectedIndexes = computed(() => { + const indexes: Array = new Array(props.items.length); + props.items.forEach((item, index) => { + if (!draggedItemIdSet.value.has(item.id)) { + indexes[index + (reorderShifts.value.get(item.id) ?? 0)] = index; + } + }); + return indexes; +}); +const renderedItems = computed(() => { + const indexes = projectedIndexes.value.slice(range.value.start, range.value.end) + .filter((index): index is number => index !== undefined); + const originIndex = dragOriginId.value === undefined + ? undefined : itemPositions.value.get(dragOriginId.value); + if (originIndex !== undefined && !indexes.includes(originIndex)) indexes.push(originIndex); + for (const itemId of mountedDragIds.value) { + const index = itemPositions.value.get(itemId); + if (index !== undefined && !indexes.includes(index)) indexes.push(index); + } + return indexes.sort((a, b) => a - b).map((index) => ({ item: props.items[index]!, index })); +}); +const contentStyle = computed(() => { + const extent = Math.max(0, props.items.length * stride.value - gap.value); + return props.vertical + ? { height: `${extent}px`, width: "100%" } + : { width: `${extent}px`, height: "var(--pb-card-height)" }; +}); + +function slotStyle(index: number): Record { + return props.vertical + ? { top: `${index * stride.value}px`, left: "0", width: "100%" } + : { left: `${index * stride.value}px`, top: "0", width: `${cardSize.value}px` }; +} + +function measureTrack(): void { + const element = track.value; + if (element == null) return; + const css = getComputedStyle(element); + cardSize.value = props.vertical + ? Number.parseFloat(css.getPropertyValue("--pb-card-height")) || 142 + : Number.parseFloat(css.getPropertyValue("--pb-card-width")) || 240; + const padding = props.vertical + ? Number.parseFloat(css.paddingTop) + Number.parseFloat(css.paddingBottom) + : Number.parseFloat(css.paddingLeft) + Number.parseFloat(css.paddingRight); + viewport.value = Math.max(1, (props.vertical ? element.clientHeight : element.clientWidth) - padding); + updateFollowLatest(); +} function forwardSelect(itemId: string, extend: boolean, toggle: boolean): void { emit("select", itemId, extend, toggle); @@ -71,33 +140,35 @@ function scrollOffset(element: HTMLElement): number { function setScrollOffset(element: HTMLElement, value: number): void { if (props.vertical) element.scrollTop = value; else element.scrollLeft = value; + updateFollowLatest(); } function updateFollowLatest(): void { const element = track.value; - if (element === undefined) return; - followLatest.value = scrollOffset(element) <= LEADING_EDGE_THRESHOLD; + if (element == null) return; + offset.value = scrollOffset(element); + followLatest.value = offset.value <= LEADING_EDGE_THRESHOLD; + if (props.hasMore && offset.value + viewport.value >= (props.items.length - 15) * stride.value) emit("loadMore"); } function beginReorder(itemId: string): void { if (!props.reorderEnabled) return; - draggedItemIds.value = props.selectedIds.includes(itemId) - ? props.items - .map((item) => item.id) - .filter((candidateId) => props.selectedIds.includes(candidateId)) - : [itemId]; + mountedDragIds.value = renderedItems.value + .filter(({ item }) => item.id === itemId || selectedIdSet.value.has(item.id)) + .map(({ item }) => item.id); + dragOriginId.value = itemId; + draggedItemIds.value = selectedIdSet.value.has(itemId) ? orderedSelectedIds.value : [itemId]; } function clearReorder(): void { + mountedDragIds.value = []; + dragOriginId.value = undefined; draggedItemIds.value = []; reorderTarget.value = undefined; } function reorderItemIdsFor(itemId: string): readonly string[] { - if (!props.selectedIds.includes(itemId)) return [itemId]; - return props.items - .map((item) => item.id) - .filter((candidateId) => props.selectedIds.includes(candidateId)); + return selectedIdSet.value.has(itemId) ? orderedSelectedIds.value : [itemId]; } function updateReorderTarget(event: DragEvent): void { @@ -133,98 +204,27 @@ function commitReorder(event: DragEvent): void { clearReorder(); } -function captureVisibleAnchor(element: HTMLElement): VisibleAnchor | undefined { - const trackBounds = element.getBoundingClientRect(); - for (const card of element.querySelectorAll("[data-pb-item-id]")) { - const bounds = card.getBoundingClientRect(); - const visible = props.vertical - ? bounds.bottom > trackBounds.top && bounds.top < trackBounds.bottom - : bounds.right > trackBounds.left && bounds.left < trackBounds.right; - if (!visible) continue; - const itemId = card.dataset.pbItemId; - if (itemId === undefined) continue; - return { - itemId, - offset: props.vertical - ? bounds.top - trackBounds.top - : bounds.left - trackBounds.left, - }; - } - return undefined; -} - -function restoreVisibleAnchor( - element: HTMLElement, - anchor: VisibleAnchor, -): boolean { - const card = [...element.querySelectorAll("[data-pb-item-id]")] - .find((candidate) => candidate.dataset.pbItemId === anchor.itemId); - if (card === undefined) return false; - const trackBounds = element.getBoundingClientRect(); - const bounds = card.getBoundingClientRect(); - const nextOffset = props.vertical - ? bounds.top - trackBounds.top - : bounds.left - trackBounds.left; - setScrollOffset(element, scrollOffset(element) + nextOffset - anchor.offset); - return true; -} - -function cardStart(card: HTMLElement): number { - const bounds = card.getBoundingClientRect(); - return props.vertical ? bounds.top : bounds.left; -} - -function cardScrollStep(element: HTMLElement, card: HTMLElement): number { - const cards = [...element.querySelectorAll("[data-pb-item-id]")]; - const index = cards.indexOf(card); - const neighbor = cards[index + 1] ?? cards[index - 1]; - if (neighbor !== undefined) { - const measured = Math.abs(cardStart(neighbor) - cardStart(card)); - if (measured > 0) return measured; - } - const bounds = card.getBoundingClientRect(); - const gap = Number.parseFloat(getComputedStyle(element).gap) || 0; - return (props.vertical ? bounds.height : bounds.width) + gap; -} - -function focusedCardOutsideViewport( - element: HTMLElement, - card: HTMLElement, - direction: -1 | 1, -): boolean { - const trackBounds = element.getBoundingClientRect(); - const cardBounds = card.getBoundingClientRect(); - if (props.vertical) { - return direction < 0 - ? cardBounds.top < trackBounds.top - : cardBounds.bottom > trackBounds.bottom; - } - return direction < 0 - ? cardBounds.left < trackBounds.left - : cardBounds.right > trackBounds.right; -} - watch( - () => props.items[0]?.id, - async (itemId, previousItemId) => { - if (itemId === undefined || itemId === previousItemId) return; - const element = track.value; + () => props.items, + async (items, previousItems) => { + const revision = ++itemsRevision; const shouldFollow = followLatest.value; - const anchor = element === undefined || shouldFollow - ? undefined - : captureVisibleAnchor(element); + const previousIndex = Math.min(previousItems.length - 1, Math.floor(offset.value / stride.value)); + const anchorId = previousItems[previousIndex]?.id; + const withinCard = offset.value - previousIndex * stride.value; + clearReorder(); + // Set the range before patching DOM, otherwise a shortened search result + // could briefly mount a blank window at the old scroll position. + const anchorIndex = anchorId === undefined ? undefined : itemPositions.value.get(anchorId); + const nextOffset = shouldFollow || anchorIndex === undefined + ? 0 : Math.max(0, anchorIndex * stride.value + withinCard); + offset.value = nextOffset; await nextTick(); - const current = track.value; - if (current === undefined) return; - if (shouldFollow) { - setScrollOffset(current, 0); - emit("latestVisible", itemId); - return; - } - if (anchor === undefined || !restoreVisibleAnchor(current, anchor)) { - setScrollOffset(current, 0); - followLatest.value = true; - emit("latestVisible", itemId); + if (revision !== itemsRevision || track.value == null) return; + measureTrack(); + setScrollOffset(track.value, nextOffset); + if (nextOffset === 0 && items[0] !== undefined && items[0].id !== previousItems[0]?.id) { + emit("latestVisible", items[0].id); } }, ); @@ -232,34 +232,53 @@ watch( watch( () => props.focusedId, async (itemId, previousItemId) => { - if (itemId === undefined || previousItemId === undefined || itemId === previousItemId) { - return; - } - const previousIndex = props.items.findIndex((item) => item.id === previousItemId); - const nextIndex = props.items.findIndex((item) => item.id === itemId); - if (previousIndex < 0 || nextIndex < 0 || previousIndex === nextIndex) return; - const direction: -1 | 1 = nextIndex < previousIndex ? -1 : 1; + if (itemId === undefined || itemId === previousItemId) return; await nextTick(); - const element = track.value; - if (element === undefined) return; - const card = [...element.querySelectorAll("[data-pb-item-id]")] - .find((candidate) => candidate.dataset.pbItemId === itemId); - if (card === undefined || !focusedCardOutsideViewport(element, card, direction)) { - return; - } - setScrollOffset( - element, - scrollOffset(element) + direction * cardScrollStep(element, card), - ); - updateFollowLatest(); + if (track.value == null || props.focusedId !== itemId) return; + const index = itemPositions.value.get(itemId); + if (index === undefined) return; + setScrollOffset(track.value, timelineFocusOffset( + index, stride.value, cardSize.value, offset.value, viewport.value, + )); }, ); -onMounted(() => { - updateFollowLatest(); +async function focusItem(itemId: string): Promise { + await nextTick(); + const element = track.value; + const index = itemPositions.value.get(itemId); + if (element == null || index === undefined) return; + setScrollOffset(element, timelineFocusOffset( + index, stride.value, cardSize.value, offset.value, viewport.value, + )); + await nextTick(); + if (props.focusedId !== itemId) return; + const card = [...element.querySelectorAll("[data-pb-item-id]")] + .find((candidate) => candidate.dataset.pbItemId === itemId); + card?.focus({ preventScroll: true }); +} + +defineExpose({ focusItem }); + +watch(track, (element) => { + resizeObserver?.disconnect(); + if (element == null) return; + measureTrack(); + resizeObserver = new ResizeObserver(measureTrack); + resizeObserver.observe(element); const itemId = props.items[0]?.id; if (itemId !== undefined && followLatest.value) emit("latestVisible", itemId); +}, { flush: "post" }); + +watch(() => [props.vertical, props.compact], async () => { + const index = Math.floor(offset.value / stride.value); + await nextTick(); + if (track.value == null) return; + measureTrack(); + setScrollOffset(track.value, index * stride.value); }); + +onBeforeUnmount(() => resizeObserver?.disconnect()); @@ -320,19 +347,23 @@ onMounted(() => { .timeline__track { --pb-reorder-gap: 12px; - display: flex; - gap: 12px; + --pb-card-height: 142px; min-height: 150px; padding: 4px 3px 12px; overflow-x: auto; overscroll-behavior-x: contain; - scroll-snap-type: x proximity; + overflow-anchor: none; scrollbar-color: color-mix(in srgb, var(--pb-violet) 30%, transparent) transparent; scrollbar-width: thin; } -.timeline__track > * { - scroll-snap-align: start; +.timeline__content { + position: relative; +} + +.timeline__slot { + position: absolute; + height: var(--pb-card-height); } .timeline--vertical { @@ -341,14 +372,12 @@ onMounted(() => { } .timeline--vertical .timeline__track { - flex-direction: column; height: 100%; min-height: 0; padding: 4px 4px 12px; overflow-x: hidden; overflow-y: auto; overscroll-behavior-y: contain; - scroll-snap-type: y proximity; } .timeline--vertical.timeline--compact { @@ -357,7 +386,7 @@ onMounted(() => { .timeline--vertical.timeline--compact .timeline__track { --pb-reorder-gap: 8px; - gap: 8px; + --pb-card-height: 108px; padding: 3px 3px 9px; } diff --git a/plugins/pasteboard-pro/apps/ztools/src/components/Toolbar.vue b/plugins/pasteboard-pro/apps/ztools/src/components/Toolbar.vue index a7c03f4ab..1fb059712 100644 --- a/plugins/pasteboard-pro/apps/ztools/src/components/Toolbar.vue +++ b/plugins/pasteboard-pro/apps/ztools/src/components/Toolbar.vue @@ -1,12 +1,15 @@