From b26dad84e3012e45b42a506960fc311727d784bb Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Fri, 4 Sep 2026 20:45:13 +0800 Subject: [PATCH 1/6] feat(desktop): add library clipboardWrite PNG bitmap op Plugins already send library-request clipboardWrite; the host whitelist rejected it as PATH_INVALID. Add the op as a PNG bitmap write via nativeImage + clipboard.writeImage, not saveAs/reveal or the media:copy-to-clipboard file-reference path. Signed-off-by: PraiseZhu --- .../cindy-brain/__tests__/librarySlot.test.ts | 119 +++++++++++++++++- apps/desktop/src/main/cindy-brain/forge.ts | 14 +++ apps/desktop/src/main/cindy-brain/index.ts | 17 +++ .../src/main/cindy-brain/librarySlot.ts | 68 ++++++++++ apps/desktop/src/shared/ghost.ts | 5 +- 5 files changed, 221 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index a7de810788b..cb13ae6c86c 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -9,7 +9,12 @@ import * as os from 'node:os'; import * as path from 'node:path'; import Database from 'better-sqlite3'; -import { GhostLibrarySlot, libraryAvailableRef, type GhostLibrarySlotDeps } from '../librarySlot.js'; +import { + GhostLibrarySlot, + LIBRARY_CLIPBOARD_WRITE_MAX_BYTES, + libraryAvailableRef, + type GhostLibrarySlotDeps, +} from '../librarySlot.js'; import { createHash } from 'node:crypto'; import { LibraryBindingStore } from '../libraryBinding.js'; import { LibraryVault } from '../libraryVault.js'; @@ -50,6 +55,7 @@ describe('GhostLibrarySlot', () => { let bindingStore: LibraryBindingStore; let showItemInFolder: ReturnType; let showSaveDialog: ReturnType; + let writeClipboardPng: ReturnType; let syncAgentReadonlyExtraDir: ReturnType; let clock: number; @@ -84,11 +90,13 @@ describe('GhostLibrarySlot', () => { betterSqliteModulePath: () => 'better-sqlite3', showItemInFolder: (...args: unknown[]) => showItemInFolder(...args), showSaveDialog: (...args: unknown[]) => showSaveDialog(...args), + writeClipboardPng: (...args: unknown[]) => writeClipboardPng(...args), syncAgentReadonlyExtraDir: (...args: unknown[]) => syncAgentReadonlyExtraDir(...args), now: () => clock, }; showItemInFolder = vi.fn(); showSaveDialog = vi.fn(async () => ({ canceled: true })); + writeClipboardPng = vi.fn(async () => {}); syncAgentReadonlyExtraDir = vi.fn(async () => {}); slot = new GhostLibrarySlot(deps); }); @@ -449,6 +457,115 @@ describe('GhostLibrarySlot', () => { expect(fs.existsSync(dest)).toBe(false); }); + const MIN_PNG = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + ]); + const pngB64 = MIN_PNG.toString('base64'); + + it('clipboardWrite: 成功写回 bytes,不调用 Finder/saveAs', + async () => { + await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + const r = await slot.handleLibraryRequest(GHOST_ID, { + op: 'clipboardWrite', content: pngB64, encoding: 'base64', + }); + expect(r).toEqual({ ok: true, op: 'clipboardWrite', bytes: MIN_PNG.byteLength }); + expect(writeClipboardPng).toHaveBeenCalledTimes(1); + expect(Buffer.from(writeClipboardPng.mock.calls[0]?.[0] as Buffer)).toEqual(MIN_PNG); + expect(showItemInFolder).not.toHaveBeenCalled(); + expect(showSaveDialog).not.toHaveBeenCalled(); + expect(JSON.stringify(r)).not.toContain(tmp); + }, + ); + + it('clipboardWrite: 空字节失败,不调用 writeClipboardPng', async () => { + await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + const r = await slot.handleLibraryRequest(GHOST_ID, { + op: 'clipboardWrite', content: '', encoding: 'base64', + }); + expect(r).toMatchObject({ ok: false, errorCode: 'PATH_INVALID' }); + expect(writeClipboardPng).not.toHaveBeenCalled(); + }); + + it('clipboardWrite: 非法 encoding / 非 base64 / 非 PNG 失败,不调用 writeClipboardPng', async () => { + await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + const utf8 = await slot.handleLibraryRequest(GHOST_ID, { + op: 'clipboardWrite', content: pngB64, encoding: 'utf8', + }); + expect(utf8).toMatchObject({ ok: false, errorCode: 'PATH_INVALID' }); + const badB64 = await slot.handleLibraryRequest(GHOST_ID, { + op: 'clipboardWrite', content: '%%%not-base64%%%', encoding: 'base64', + }); + expect(badB64).toMatchObject({ ok: false, errorCode: 'PATH_INVALID' }); + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46]).toString('base64'); + const notPng = await slot.handleLibraryRequest(GHOST_ID, { + op: 'clipboardWrite', content: jpeg, encoding: 'base64', + }); + expect(notPng).toMatchObject({ ok: false, errorCode: 'PATH_INVALID' }); + expect(writeClipboardPng).not.toHaveBeenCalled(); + expect(showItemInFolder).not.toHaveBeenCalled(); + expect(showSaveDialog).not.toHaveBeenCalled(); + }); + + it('clipboardWrite: 超限 payload 失败且上限是有限整数', async () => { + expect(Number.isFinite(LIBRARY_CLIPBOARD_WRITE_MAX_BYTES)).toBe(true); + expect(LIBRARY_CLIPBOARD_WRITE_MAX_BYTES).toBeGreaterThan(0); + await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + const tooBig = 'A'.repeat(Math.floor((LIBRARY_CLIPBOARD_WRITE_MAX_BYTES * 4) / 3) + 16); + const r = await slot.handleLibraryRequest(GHOST_ID, { + op: 'clipboardWrite', content: tooBig, encoding: 'base64', + }); + expect(r).toMatchObject({ ok: false, errorCode: 'TOO_LARGE' }); + expect(writeClipboardPng).not.toHaveBeenCalled(); + }); + + it('clipboardWrite: 未知 op 仍拒,不调用 writeClipboardPng', async () => { + await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + const r = await slot.handleLibraryRequest(GHOST_ID, { op: 'clipboardPaste' }); + expect(r).toMatchObject({ ok: false, errorCode: 'PATH_INVALID' }); + expect(writeClipboardPng).not.toHaveBeenCalled(); + }); + + it('clipboardWrite: 同插件两次请求间隔不足 = RATE_LIMITED(按尝试记账)', async () => { + await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + const first = await slot.handleLibraryRequest(GHOST_ID, { + op: 'clipboardWrite', content: pngB64, encoding: 'base64', + }); + expect(first.ok).toBe(true); + clock += 1_000; + const second = await slot.handleLibraryRequest(GHOST_ID, { + op: 'clipboardWrite', content: pngB64, encoding: 'base64', + }); + expect(second).toMatchObject({ ok: false, errorCode: 'RATE_LIMITED' }); + expect(writeClipboardPng).toHaveBeenCalledTimes(1); + }); + + it('clipboardWrite: 账号切换后旧会话不得继续写', async () => { + await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let started!: () => void; + const opened = new Promise((resolve) => { + started = resolve; + }); + writeClipboardPng.mockImplementationOnce(async () => { + started(); + await gate; + }); + const pending = slot.handleLibraryRequest(GHOST_ID, { + op: 'clipboardWrite', content: pngB64, encoding: 'base64', + }); + await opened; + scopeKey = 'local:owner-b:1'; + await slot.disposeAll(); + release(); + const r = await pending; + expect(r).toMatchObject({ ok: false, errorCode: 'LIBRARY_UNAVAILABLE' }); + expect(showSaveDialog).not.toHaveBeenCalled(); + }); + it('open/status 握手含 authorizedReadonly 与 generation/identity,JSON 不含绝对库根', async () => { const open = await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); if (!open.ok || open.op !== 'open') throw new Error(JSON.stringify(open)); diff --git a/apps/desktop/src/main/cindy-brain/forge.ts b/apps/desktop/src/main/cindy-brain/forge.ts index 5cb7114f739..74647800d0e 100644 --- a/apps/desktop/src/main/cindy-brain/forge.ts +++ b/apps/desktop/src/main/cindy-brain/forge.ts @@ -3476,6 +3476,14 @@ const saved = await cindy.library({ op: 'saveAs', path: 'exports/a.psd', name: ' // 或 { ok:true, cancelled:false, path:'exports/a.psd', bytes } // path 永远是库内相对键,不是用户另存到的绝对路径 +// 写系统剪贴板 PNG 位图(不是 Finder 文件列表,也不是 saveAs) +const copied = await cindy.library({ + op: 'clipboardWrite', content: pngBase64, encoding: 'base64', +}); +// copied = { ok:true, bytes } —— bytes 是写入的 PNG 字节数 +// 空字节 / 非 base64 / 非 PNG / 超限 → { ok:false, errorCode, message } +// 外部应用能否粘上由操作系统剪贴板决定,插件侧不要自己承诺粘贴完成 + // SQLite:参数化语句 + 首词白名单(SELECT/WITH/INSERT/REPLACE/UPDATE/DELETE/ // CREATE/DROP/ALTER/REINDEX/ANALYZE);ATTACH/PRAGMA/VACUUM/事务语句一律拒, // 事务由宿主管理(db.batch 整批原子),迁移按 user_version 幂等续跑 @@ -3507,6 +3515,12 @@ await cindy.library({ op: 'db.check', dbPath: 'library.sqlite' }); // quick_ch 对话框期间账号切换则拒绝拷贝(\`LIBRARY_UNAVAILABLE\`); 拷贝完成替换前、reveal 打开文件夹前再核一次会话; 确认后先拷到目标旁临时文件再替换,失败不破坏已有文件; +- **clipboardWrite**:只收 \`encoding:'base64'\` 的 PNG 字节,写系统剪贴板位图, + 成功回 \`{ ok:true, bytes }\`。不是 saveAs,也不在文件夹中显示作品。 + 空字节 / 非法 encoding / 非 PNG / 超限一律结构化失败,永不 \`ok:true\`。 + 同插件 3 秒内连发 \`RATE_LIMITED\`;无主壳窗 / 宿主不能写剪贴板 \`UNSUPPORTED\`; + 账号切换后旧会话不得继续写(\`LIBRARY_UNAVAILABLE\`)。 + 外部粘贴是否成功由操作系统与目标应用决定,插件侧不要单独承诺已粘上; - **不可用 ≠ 空**:\`state:'unavailable'\` 时**不要**当空库重建、不要触发 清理、不要把素材判成已删——如实向用户展示状态,等位置恢复; - **无跨库事务**:多个 .sqlite 之间没有 ATTACH;跨库一致性用幂等 + 墓碑 diff --git a/apps/desktop/src/main/cindy-brain/index.ts b/apps/desktop/src/main/cindy-brain/index.ts index a6b65422e57..a7367adf029 100644 --- a/apps/desktop/src/main/cindy-brain/index.ts +++ b/apps/desktop/src/main/cindy-brain/index.ts @@ -1,8 +1,10 @@ import { app, BrowserWindow, + clipboard, dialog, ipcMain, + nativeImage, safeStorage, shell, type WebContents, @@ -5270,6 +5272,21 @@ export function getGhostLibrarySlot(): GhostLibrarySlot { }); return { canceled: picked.canceled, filePath: picked.filePath }; }, + writeClipboardPng: async (pngBytes) => { + // 插件要的是外部应用能粘的图像位图,不是 Finder 文件列表。 + // 禁止复用 media:copy-to-clipboard 的 Set-Clipboard / osascript POSIX file 通道。 + const candidates = mainShellWindows().filter( + (window) => window.isVisible() && !window.isMinimized(), + ); + if (candidates.length === 0) { + throw new Error('没有可挂靠的宿主窗口'); + } + const image = nativeImage.createFromBuffer(pngBytes); + if (image.isEmpty()) { + throw new Error('无法把 PNG 字节写成剪贴板位图'); + } + clipboard.writeImage(image); + }, }); // 面板只读投影(cindy-ghost:///library/)的解析器:与电子脑 // read 同源校验(binding 根 + vault 路径纪律),失败折叠 404。 diff --git a/apps/desktop/src/main/cindy-brain/librarySlot.ts b/apps/desktop/src/main/cindy-brain/librarySlot.ts index 2d403830dc6..1dac1dbfd2d 100644 --- a/apps/desktop/src/main/cindy-brain/librarySlot.ts +++ b/apps/desktop/src/main/cindy-brain/librarySlot.ts @@ -35,6 +35,14 @@ import type { LibraryDbResult } from './libraryDbCore.js'; /** 正本相对键:assets//<64-hex>/blob.(不是 .)。 */ const LIBRARY_BLOB_REL_RE = /^assets\/([0-9a-f]{2})\/([0-9a-f]{64})\/blob\.([A-Za-z0-9]+)$/i; const LIBRARY_SIDECAR_BASENAME = new Set(['meta.json', 'preview.webp']); +/** clipboardWrite 单次 PNG 上限:与 library 单次 write 同为 16MiB,必须是有限整数。 */ +export const LIBRARY_CLIPBOARD_WRITE_MAX_BYTES = 16 * 1024 * 1024; +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +function isPngBuffer(bytes: Buffer): boolean { + return bytes.byteLength >= PNG_SIGNATURE.byteLength + && bytes.subarray(0, PNG_SIGNATURE.byteLength).equals(PNG_SIGNATURE); +} export function libraryBlobRelPath(hash: string, ext: string): string { const cleanExt = ext.replace(/^\./, ''); @@ -110,6 +118,8 @@ export interface GhostLibrarySlotDeps { showItemInFolder?(absPath: string): void; /** 系统另存为(生产接 dialog.showSaveDialog;标题/正文由主机拼装并带已核验插件名)。 */ showSaveDialog?(opts: { defaultPath: string; ghostName: string }): Promise<{ canceled: boolean; filePath?: string }>; + /** 写系统剪贴板 PNG 位图(生产接 nativeImage + clipboard.writeImage;零 Electron 单测注入 fake)。 */ + writeClipboardPng?(pngBytes: Buffer): Promise | void; /** 可注入时钟(单测限速);默认 Date.now。 */ now?(): number; /** @@ -134,6 +144,8 @@ export class GhostLibrarySlot { private readonly lastRevealAttemptAt = new Map(); /** 插件 id → 上次 saveAs 尝试时刻(按尝试记账;对齐 pick/confirm 骚扰钳制)。 */ private readonly lastSaveAsAttemptAt = new Map(); + /** 插件 id → 上次 clipboardWrite 尝试时刻(按尝试记账;对齐 pick/confirm 骚扰钳制)。 */ + private readonly lastClipboardWriteAttemptAt = new Map(); /** 全局另存为对话框在场标记(系统弹窗一次一个,不排队)。 */ private saveAsDialogInFlight = false; /** @@ -642,6 +654,62 @@ export class GhostLibrarySlot { const st = await fs.promises.stat(dest); return { ok: true, op: 'saveAs', cancelled: false, path: relPath, bytes: st.size }; } + case 'clipboardWrite': { + if (req.encoding !== 'base64') { + return fail('PATH_INVALID', 'clipboardWrite 只接受 encoding:"base64" 的 PNG 字节'); + } + if (typeof req.content !== 'string') { + return fail('PATH_INVALID', 'clipboardWrite 需要 content(base64 PNG)'); + } + if (req.content.length > (LIBRARY_CLIPBOARD_WRITE_MAX_BYTES * 4) / 3 + 8) { + return fail('TOO_LARGE', `clipboardWrite 内容超限(上限 ${LIBRARY_CLIPBOARD_WRITE_MAX_BYTES} 字节)`); + } + if (!/^[A-Za-z0-9+/=\r\n]*$/.test(req.content)) { + return fail('PATH_INVALID', 'clipboardWrite content 不是合法 base64'); + } + const pngBytes = Buffer.from(req.content, 'base64'); + if (pngBytes.byteLength === 0) { + return fail('PATH_INVALID', 'clipboardWrite 不能写入空字节'); + } + if (pngBytes.byteLength > LIBRARY_CLIPBOARD_WRITE_MAX_BYTES) { + return fail('TOO_LARGE', `clipboardWrite 内容超限(上限 ${LIBRARY_CLIPBOARD_WRITE_MAX_BYTES} 字节)`); + } + if (!isPngBuffer(pngBytes)) { + return fail('PATH_INVALID', 'clipboardWrite 只接受 PNG 字节'); + } + if (!this.deps.writeClipboardPng) { + return fail('UNSUPPORTED', '当前宿主不能写入系统剪贴板'); + } + + const now = this.deps.now?.() ?? Date.now(); + const last = this.lastClipboardWriteAttemptAt.get(ghostId); + this.lastClipboardWriteAttemptAt.set(ghostId, now); + if (last !== undefined && now - last < GHOST_PICK_MIN_INTERVAL_MS) { + return fail('RATE_LIMITED', '写入剪贴板请求太频繁,稍后再试'); + } + const stale = this.rejectIfSessionStale( + ghostId, + session, + '账号已切换,写入剪贴板已取消', + ); + if (stale) return stale; + try { + await this.deps.writeClipboardPng(pngBytes); + } catch (error) { + this.deps.log?.warn('ghost library clipboardWrite failed', { + ghostId, + err: error instanceof Error ? error.message : String(error), + }); + return fail('INTERNAL', '写入系统剪贴板失败'); + } + const afterWrite = this.rejectIfSessionStale( + ghostId, + session, + '账号已切换,写入剪贴板已取消', + ); + if (afterWrite) return afterWrite; + return { ok: true, op: 'clipboardWrite', bytes: pngBytes.byteLength }; + } case 'db.open': { const resolved = await this.resolveDbPath(session, req.dbPath); if (!('abs' in resolved)) return resolved; diff --git a/apps/desktop/src/shared/ghost.ts b/apps/desktop/src/shared/ghost.ts index 0131e0da170..32175cc0475 100644 --- a/apps/desktop/src/shared/ghost.ts +++ b/apps/desktop/src/shared/ghost.ts @@ -8248,6 +8248,7 @@ export const GHOST_LIBRARY_OPS = [ 'db.userVersion', 'reveal', 'saveAs', + 'clipboardWrite', ] as const; export type GhostLibraryOp = (typeof GHOST_LIBRARY_OPS)[number]; @@ -8257,7 +8258,7 @@ export interface GhostPipeLibraryRequest { op: GhostLibraryOp; /** library 相对路径(段数/总长上限比 fs 宽:32 段/512 字符)。 */ path?: string; - /** write 内容(≤16MiB;更大走 writeBegin 分块流)。 */ + /** write 内容(≤16MiB;更大走 writeBegin 分块流);clipboardWrite 只收 encoding:'base64' 的 PNG 字节。 */ content?: string; encoding?: 'utf8' | 'base64'; /** write:排他创建(目标已存在则 ALREADY_EXISTS)。 */ @@ -8353,6 +8354,8 @@ export type GhostPipeLibraryResult = | { ok: true; op: 'saveAs'; cancelled: true } /** saveAs 成功:path 是库内相对键(与请求相同),不是用户另存到的绝对路径。 */ | { ok: true; op: 'saveAs'; cancelled: false; path: string; bytes: number } + /** clipboardWrite 成功:bytes 是写入系统剪贴板的 PNG 位图字节数,不是文件引用。 */ + | { ok: true; op: 'clipboardWrite'; bytes: number } | { ok: false; errorCode: string; message: string }; /** Library 概览(ghosts:library-overview IPC 载荷;设置页插件详情消费)。 */ From 2f12ae24be35d3ca108296f2ccef5c7007791bf3 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Fri, 4 Sep 2026 21:21:11 +0800 Subject: [PATCH 2/6] fix(desktop): reject invalid base64 and truncated PNG in clipboardWrite v1 review: Buffer.from accepted bad padding, and isPngBuffer only checked the 8-byte magic, so truncated PNG still reached writeClipboardPng. Decode strictly and require a full IHDR header. Signed-off-by: PraiseZhu --- .../cindy-brain/__tests__/librarySlot.test.ts | 19 +++++++++++---- .../src/main/cindy-brain/librarySlot.ts | 23 +++++++++++++++---- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index cb13ae6c86c..8125bb8ebb2 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -457,11 +457,14 @@ describe('GhostLibrarySlot', () => { expect(fs.existsSync(dest)).toBe(false); }); - const MIN_PNG = Buffer.from([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, - ]); + const MIN_PNG = Buffer.alloc(24); + MIN_PNG.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0); + MIN_PNG.writeUInt32BE(13, 8); + MIN_PNG.set([0x49, 0x48, 0x44, 0x52], 12); + MIN_PNG.writeUInt32BE(1, 16); + MIN_PNG.writeUInt32BE(1, 20); const pngB64 = MIN_PNG.toString('base64'); + const truncatedPng = MIN_PNG.subarray(0, 16); it('clipboardWrite: 成功写回 bytes,不调用 Finder/saveAs', async () => { @@ -502,6 +505,14 @@ describe('GhostLibrarySlot', () => { op: 'clipboardWrite', content: jpeg, encoding: 'base64', }); expect(notPng).toMatchObject({ ok: false, errorCode: 'PATH_INVALID' }); + const padded = await slot.handleLibraryRequest(GHOST_ID, { + op: 'clipboardWrite', content: `${pngB64}=AAAA`, encoding: 'base64', + }); + expect(padded).toMatchObject({ ok: false, errorCode: 'PATH_INVALID' }); + const truncated = await slot.handleLibraryRequest(GHOST_ID, { + op: 'clipboardWrite', content: truncatedPng.toString('base64'), encoding: 'base64', + }); + expect(truncated).toMatchObject({ ok: false, errorCode: 'PATH_INVALID' }); expect(writeClipboardPng).not.toHaveBeenCalled(); expect(showItemInFolder).not.toHaveBeenCalled(); expect(showSaveDialog).not.toHaveBeenCalled(); diff --git a/apps/desktop/src/main/cindy-brain/librarySlot.ts b/apps/desktop/src/main/cindy-brain/librarySlot.ts index 1dac1dbfd2d..0005cc76b54 100644 --- a/apps/desktop/src/main/cindy-brain/librarySlot.ts +++ b/apps/desktop/src/main/cindy-brain/librarySlot.ts @@ -38,10 +38,25 @@ const LIBRARY_SIDECAR_BASENAME = new Set(['meta.json', 'preview.webp']); /** clipboardWrite 单次 PNG 上限:与 library 单次 write 同为 16MiB,必须是有限整数。 */ export const LIBRARY_CLIPBOARD_WRITE_MAX_BYTES = 16 * 1024 * 1024; const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const PNG_IHDR = Buffer.from('IHDR', 'ascii'); +/** PNG 签名(8) + IHDR 长度(4) + 类型(4) + 宽高(8) = 24。截断头不得当图像写剪贴板。 */ +const PNG_MIN_HEADER_BYTES = 24; + +function decodeStrictBase64(content: string): Buffer | null { + const compact = content.replace(/[\r\n]/g, ''); + if (compact.length === 0) return Buffer.alloc(0); + if (compact.length % 4 !== 0) return null; + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(compact)) return null; + const decoded = Buffer.from(compact, 'base64'); + if (decoded.toString('base64') !== compact) return null; + return decoded; +} function isPngBuffer(bytes: Buffer): boolean { - return bytes.byteLength >= PNG_SIGNATURE.byteLength - && bytes.subarray(0, PNG_SIGNATURE.byteLength).equals(PNG_SIGNATURE); + if (bytes.byteLength < PNG_MIN_HEADER_BYTES) return false; + if (!bytes.subarray(0, PNG_SIGNATURE.byteLength).equals(PNG_SIGNATURE)) return false; + if (!bytes.subarray(12, 16).equals(PNG_IHDR)) return false; + return bytes.readUInt32BE(8) === 13; } export function libraryBlobRelPath(hash: string, ext: string): string { @@ -664,10 +679,10 @@ export class GhostLibrarySlot { if (req.content.length > (LIBRARY_CLIPBOARD_WRITE_MAX_BYTES * 4) / 3 + 8) { return fail('TOO_LARGE', `clipboardWrite 内容超限(上限 ${LIBRARY_CLIPBOARD_WRITE_MAX_BYTES} 字节)`); } - if (!/^[A-Za-z0-9+/=\r\n]*$/.test(req.content)) { + const pngBytes = decodeStrictBase64(req.content); + if (pngBytes === null) { return fail('PATH_INVALID', 'clipboardWrite content 不是合法 base64'); } - const pngBytes = Buffer.from(req.content, 'base64'); if (pngBytes.byteLength === 0) { return fail('PATH_INVALID', 'clipboardWrite 不能写入空字节'); } From cdee48aaf5acc6da9cc3242b743ce9227d083e43 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Fri, 4 Sep 2026 21:26:39 +0800 Subject: [PATCH 3/6] fix(desktop): require a complete PNG, not a 24-byte header v1 follow-up: a 24-byte signature+IHDR prefix is still truncated. Accept only a full IHDR chunk plus IEND, and use a real 1x1 PNG fixture. Signed-off-by: PraiseZhu --- .../cindy-brain/__tests__/librarySlot.test.ts | 13 ++++++------- apps/desktop/src/main/cindy-brain/librarySlot.ts | 16 ++++++++++++---- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index 8125bb8ebb2..1016ca5a58d 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -457,14 +457,13 @@ describe('GhostLibrarySlot', () => { expect(fs.existsSync(dest)).toBe(false); }); - const MIN_PNG = Buffer.alloc(24); - MIN_PNG.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0); - MIN_PNG.writeUInt32BE(13, 8); - MIN_PNG.set([0x49, 0x48, 0x44, 0x52], 12); - MIN_PNG.writeUInt32BE(1, 16); - MIN_PNG.writeUInt32BE(1, 20); + // 1x1 灰度 PNG:签名 + 完整 IHDR(含 13 字节数据与 CRC) + IDAT + IEND。 + const MIN_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR42mNgAAAAAgAB5Sfe/AAAAABJRU5ErkJggg==', + 'base64', + ); const pngB64 = MIN_PNG.toString('base64'); - const truncatedPng = MIN_PNG.subarray(0, 16); + const truncatedPng = MIN_PNG.subarray(0, 24); it('clipboardWrite: 成功写回 bytes,不调用 Finder/saveAs', async () => { diff --git a/apps/desktop/src/main/cindy-brain/librarySlot.ts b/apps/desktop/src/main/cindy-brain/librarySlot.ts index 0005cc76b54..aea8908605b 100644 --- a/apps/desktop/src/main/cindy-brain/librarySlot.ts +++ b/apps/desktop/src/main/cindy-brain/librarySlot.ts @@ -39,8 +39,10 @@ const LIBRARY_SIDECAR_BASENAME = new Set(['meta.json', 'preview.webp']); export const LIBRARY_CLIPBOARD_WRITE_MAX_BYTES = 16 * 1024 * 1024; const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const PNG_IHDR = Buffer.from('IHDR', 'ascii'); -/** PNG 签名(8) + IHDR 长度(4) + 类型(4) + 宽高(8) = 24。截断头不得当图像写剪贴板。 */ -const PNG_MIN_HEADER_BYTES = 24; +const PNG_IEND = Buffer.from('IEND', 'ascii'); +/** 签名(8) + 完整 IHDR 块(4+4+13+4=25) = 33。缺 IHDR 数据/CRC 的截断头不得写剪贴板。 */ +const PNG_IHDR_CHUNK_BYTES = 25; +const PNG_MIN_BYTES = 8 + PNG_IHDR_CHUNK_BYTES; function decodeStrictBase64(content: string): Buffer | null { const compact = content.replace(/[\r\n]/g, ''); @@ -53,10 +55,16 @@ function decodeStrictBase64(content: string): Buffer | null { } function isPngBuffer(bytes: Buffer): boolean { - if (bytes.byteLength < PNG_MIN_HEADER_BYTES) return false; + if (bytes.byteLength < PNG_MIN_BYTES) return false; if (!bytes.subarray(0, PNG_SIGNATURE.byteLength).equals(PNG_SIGNATURE)) return false; + if (bytes.readUInt32BE(8) !== 13) return false; if (!bytes.subarray(12, 16).equals(PNG_IHDR)) return false; - return bytes.readUInt32BE(8) === 13; + const width = bytes.readUInt32BE(16); + const height = bytes.readUInt32BE(20); + if (width === 0 || height === 0) return false; + const ihdrCrcOffset = 8 + PNG_IHDR_CHUNK_BYTES - 4; + if (ihdrCrcOffset + 4 > bytes.byteLength) return false; + return bytes.includes(PNG_IEND); } export function libraryBlobRelPath(hash: string, ext: string): string { From 922ce17c1edbdfb69a498aebf572b9deb21f4648 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Fri, 4 Sep 2026 21:37:20 +0800 Subject: [PATCH 4/6] fix(desktop): walk PNG chunks instead of searching for IEND includes('IEND') accepted truncated images with the ASCII marker embedded mid-file. Parse length+type+data+crc, CRC-check IHDR, and require IEND as the final chunk with no trailing bytes. Signed-off-by: PraiseZhu --- .../cindy-brain/__tests__/librarySlot.test.ts | 14 ++++++ .../src/main/cindy-brain/librarySlot.ts | 45 +++++++++++++++---- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index 1016ca5a58d..e6e5dae4ac7 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -512,6 +512,20 @@ describe('GhostLibrarySlot', () => { op: 'clipboardWrite', content: truncatedPng.toString('base64'), encoding: 'base64', }); expect(truncated).toMatchObject({ ok: false, errorCode: 'PATH_INVALID' }); + // 插进 IDAT 数据区:IHDR 结束于 33,IDAT type 后是 offset 41。 + const iendInIdat = Buffer.concat([ + MIN_PNG.subarray(0, 41), + Buffer.from('IEND', 'ascii'), + MIN_PNG.subarray(41), + ]); + const embedded = await slot.handleLibraryRequest(GHOST_ID, { + op: 'clipboardWrite', content: iendInIdat.toString('base64'), encoding: 'base64', + }); + expect(embedded).toMatchObject({ ok: false, errorCode: 'PATH_INVALID' }); + const trailing = await slot.handleLibraryRequest(GHOST_ID, { + op: 'clipboardWrite', content: Buffer.concat([MIN_PNG, Buffer.from([0x00])]).toString('base64'), encoding: 'base64', + }); + expect(trailing).toMatchObject({ ok: false, errorCode: 'PATH_INVALID' }); expect(writeClipboardPng).not.toHaveBeenCalled(); expect(showItemInFolder).not.toHaveBeenCalled(); expect(showSaveDialog).not.toHaveBeenCalled(); diff --git a/apps/desktop/src/main/cindy-brain/librarySlot.ts b/apps/desktop/src/main/cindy-brain/librarySlot.ts index aea8908605b..e26181d68a1 100644 --- a/apps/desktop/src/main/cindy-brain/librarySlot.ts +++ b/apps/desktop/src/main/cindy-brain/librarySlot.ts @@ -20,6 +20,7 @@ import { randomBytes } from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { crc32 } from 'node:zlib'; import { GHOST_LIBRARY_OPS, @@ -41,7 +42,8 @@ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0 const PNG_IHDR = Buffer.from('IHDR', 'ascii'); const PNG_IEND = Buffer.from('IEND', 'ascii'); /** 签名(8) + 完整 IHDR 块(4+4+13+4=25) = 33。缺 IHDR 数据/CRC 的截断头不得写剪贴板。 */ -const PNG_IHDR_CHUNK_BYTES = 25; +const PNG_IHDR_DATA_BYTES = 13; +const PNG_IHDR_CHUNK_BYTES = 4 + 4 + PNG_IHDR_DATA_BYTES + 4; const PNG_MIN_BYTES = 8 + PNG_IHDR_CHUNK_BYTES; function decodeStrictBase64(content: string): Buffer | null { @@ -57,14 +59,39 @@ function decodeStrictBase64(content: string): Buffer | null { function isPngBuffer(bytes: Buffer): boolean { if (bytes.byteLength < PNG_MIN_BYTES) return false; if (!bytes.subarray(0, PNG_SIGNATURE.byteLength).equals(PNG_SIGNATURE)) return false; - if (bytes.readUInt32BE(8) !== 13) return false; - if (!bytes.subarray(12, 16).equals(PNG_IHDR)) return false; - const width = bytes.readUInt32BE(16); - const height = bytes.readUInt32BE(20); - if (width === 0 || height === 0) return false; - const ihdrCrcOffset = 8 + PNG_IHDR_CHUNK_BYTES - 4; - if (ihdrCrcOffset + 4 > bytes.byteLength) return false; - return bytes.includes(PNG_IEND); + let offset = PNG_SIGNATURE.byteLength; + let sawIhdr = false; + let sawIend = false; + let chunkIndex = 0; + while (offset + 12 <= bytes.byteLength) { + if (sawIend) return false; + const length = bytes.readUInt32BE(offset); + const typeStart = offset + 4; + const dataStart = typeStart + 4; + const next = dataStart + length + 4; + if (!Number.isSafeInteger(length) || length < 0 || next > bytes.byteLength) return false; + const type = bytes.subarray(typeStart, dataStart); + const data = bytes.subarray(dataStart, dataStart + length); + const crc = bytes.readUInt32BE(dataStart + length); + if ((crc32(Buffer.concat([type, data])) >>> 0) !== crc) return false; + if (chunkIndex === 0) { + if (!type.equals(PNG_IHDR) || length !== PNG_IHDR_DATA_BYTES) return false; + const width = data.readUInt32BE(0); + const height = data.readUInt32BE(4); + if (width === 0 || height === 0) return false; + sawIhdr = true; + } else if (type.equals(PNG_IHDR)) { + return false; + } + if (type.equals(PNG_IEND)) { + if (length !== 0) return false; + if (next !== bytes.byteLength) return false; + sawIend = true; + } + offset = next; + chunkIndex += 1; + } + return sawIhdr && sawIend && offset === bytes.byteLength; } export function libraryBlobRelPath(hash: string, ext: string): string { From c7ab4f5c5b5f668a8052cd69a39ebe77ffdd4606 Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sat, 5 Sep 2026 00:36:01 +0800 Subject: [PATCH 5/6] fix(desktop): map missing shell window to UNSUPPORTED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FORGE_GUIDE promises UNSUPPORTED when no main shell window can host clipboardWrite. Catch the production '没有可挂靠的宿主窗口' error instead of collapsing it into INTERNAL. Signed-off-by: PraiseZhu --- .../main/cindy-brain/__tests__/librarySlot.test.ts | 13 +++++++++++++ apps/desktop/src/main/cindy-brain/librarySlot.ts | 11 +++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts index e6e5dae4ac7..676a737330b 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts @@ -543,6 +543,19 @@ describe('GhostLibrarySlot', () => { expect(writeClipboardPng).not.toHaveBeenCalled(); }); + it('clipboardWrite: 生产注入无主壳窗 → UNSUPPORTED,不伪装 INTERNAL', async () => { + await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); + clock += 4_000; + writeClipboardPng.mockImplementationOnce(async () => { + throw new Error('没有可挂靠的宿主窗口'); + }); + const r = await slot.handleLibraryRequest(GHOST_ID, { + op: 'clipboardWrite', content: pngB64, encoding: 'base64', + }); + expect(r).toMatchObject({ ok: false, errorCode: 'UNSUPPORTED' }); + expect(writeClipboardPng).toHaveBeenCalledTimes(1); + }); + it('clipboardWrite: 未知 op 仍拒,不调用 writeClipboardPng', async () => { await slot.handleLibraryRequest(GHOST_ID, { op: 'open' }); const r = await slot.handleLibraryRequest(GHOST_ID, { op: 'clipboardPaste' }); diff --git a/apps/desktop/src/main/cindy-brain/librarySlot.ts b/apps/desktop/src/main/cindy-brain/librarySlot.ts index e26181d68a1..883d4722acb 100644 --- a/apps/desktop/src/main/cindy-brain/librarySlot.ts +++ b/apps/desktop/src/main/cindy-brain/librarySlot.ts @@ -45,6 +45,8 @@ const PNG_IEND = Buffer.from('IEND', 'ascii'); const PNG_IHDR_DATA_BYTES = 13; const PNG_IHDR_CHUNK_BYTES = 4 + 4 + PNG_IHDR_DATA_BYTES + 4; const PNG_MIN_BYTES = 8 + PNG_IHDR_CHUNK_BYTES; +/** 与 getGhostLibrarySlot 生产接线同文案:无可见主壳窗时抛出,槽内映射 UNSUPPORTED。 */ +const CLIPBOARD_NO_HOST_WINDOW = '没有可挂靠的宿主窗口'; function decodeStrictBase64(content: string): Buffer | null { const compact = content.replace(/[\r\n]/g, ''); @@ -746,10 +748,11 @@ export class GhostLibrarySlot { try { await this.deps.writeClipboardPng(pngBytes); } catch (error) { - this.deps.log?.warn('ghost library clipboardWrite failed', { - ghostId, - err: error instanceof Error ? error.message : String(error), - }); + const message = error instanceof Error ? error.message : String(error); + this.deps.log?.warn('ghost library clipboardWrite failed', { ghostId, err: message }); + if (message === CLIPBOARD_NO_HOST_WINDOW) { + return fail('UNSUPPORTED', '当前没有可挂靠的宿主窗口,无法写入系统剪贴板'); + } return fail('INTERNAL', '写入系统剪贴板失败'); } const afterWrite = this.rejectIfSessionStale( From 7123008d92d6557bc974c4170f7cdb962b9b635a Mon Sep 17 00:00:00 2001 From: PraiseZhu Date: Sat, 5 Sep 2026 16:54:28 +0800 Subject: [PATCH 6/6] fix(desktop): disclose library clipboardWrite in plugin details Plugin details for library:true only mentioned Finder/Explorer and Save As. Host already writes PNG bitmaps to the system clipboard without a confirm dialog, so the five-locale copy and ghost.ts comment now say so. Signed-off-by: PraiseZhu --- apps/desktop/src/renderer/i18n/locales/en/common.json | 2 +- apps/desktop/src/renderer/i18n/locales/ja/common.json | 2 +- apps/desktop/src/renderer/i18n/locales/ko/common.json | 2 +- apps/desktop/src/renderer/i18n/locales/zh-CN/common.json | 2 +- apps/desktop/src/renderer/i18n/locales/zh-TW/common.json | 2 +- apps/desktop/src/shared/ghost.ts | 3 ++- 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index f1d8d9a58fe..3943df8a5e9 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -4828,7 +4828,7 @@ "fsWrite": "Can write files (create / modify)", "fsWriteDetail": "Its own private data folder is always writable. Writes into the current session's working directory follow the session's permission mode (auto-approve modes write directly; per-action modes ask you first). Any other folder always requires your confirmation. Files are always written by the host — the plugin itself never touches your file system.", "libraryPersist": "Can use a durable library (large files and databases)", - "libraryPersistDetail": "It gets a Cindy-managed storage area for your work (canvases, images, databases) with no normal plugin data quota. All file and database operations are executed by the host; the plugin only uses relative paths inside its own library and cannot touch your other files. It can also ask Cindy to show a library file in Finder or Explorer, or open the system Save As dialog so you choose where a copy goes; cancelling Save As copies nothing. Uninstalling the plugin does not delete this data; deletion requires separate confirmation in Cindy settings.", + "libraryPersistDetail": "It gets a Cindy-managed storage area for your work (canvases, images, databases) with no normal plugin data quota. All file and database operations are executed by the host; the plugin only uses relative paths inside its own library and cannot touch your other files. It can also ask Cindy to show a library file in Finder or Explorer, or open the system Save As dialog so you choose where a copy goes; cancelling Save As copies nothing. It can also write a PNG bitmap to the system clipboard with no confirmation dialog; this overwrites the current clipboard. Uninstalling the plugin does not delete this data; deletion requires separate confirmation in Cindy settings.", "networkHost": "Accesses the network domain {{host}}", "networkSecret": "Needs a credential from you: \"{{name}}\"", "networkSecretGhostInputDetail": "Collected by this plugin's own settings UI — the plugin page sees the value at entry time. It is then handed to the host in one step for encrypted storage; once stored, the plugin can never read it back, and the host injects it only for the domains it declares.", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index 30d76c846c9..ee2771e97ac 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -4827,7 +4827,7 @@ "fsWrite": "ファイルを書き込めます(作成/変更)", "fsWriteDetail": "専用データフォルダにはいつでも書き込めます。現在のセッションの作業ディレクトリへの書き込みはセッションの権限モードに従います(自動承認モードでは直接書き込み、逐次確認モードでは事前に承認を求めます)。それ以外のフォルダへの書き込みは毎回確認が必要です。ファイルは常にホストが代理で書き込み、プラグイン自体があなたのファイルシステムに直接アクセスすることはありません。", "libraryPersist": "永続ライブラリを使用可能(大量のファイルとデータベース)", - "libraryPersistDetail": "Cindy が管理する独立した保存領域を取得し、作品データ(キャンバス、画像、データベースなど)を保存します。通常のプラグインデータ割り当ての制限を受けません。ファイルとデータベースの操作はすべてホストが代行実行し、プラグインはライブラリ内の相対パスのみを使用して、他のファイルに触れることはできません。ライブラリ内のファイルをシステムのフォルダで表示したり、システムの名前を付けて保存ダイアログを開いてコピー先を選ばせることもできます。キャンセルすれば何もコピーされません。プラグインをアンインストールしてもこのデータは削除されず、削除には Cindy 設定での個別確認が必要です。", + "libraryPersistDetail": "Cindy が管理する独立した保存領域を取得し、作品データ(キャンバス、画像、データベースなど)を保存します。通常のプラグインデータ割り当ての制限を受けません。ファイルとデータベースの操作はすべてホストが代行実行し、プラグインはライブラリ内の相対パスのみを使用して、他のファイルに触れることはできません。ライブラリ内のファイルをシステムのフォルダで表示したり、システムの名前を付けて保存ダイアログを開いてコピー先を選ばせることもできます。キャンセルすれば何もコピーされません。確認ダイアログなしで PNG ビットマップをシステムクリップボードに書き込むこともでき、その場合は現在のクリップボード内容が上書きされます。プラグインをアンインストールしてもこのデータは削除されず、削除には Cindy 設定での個別確認が必要です。", "networkHost": "ネットワークドメイン {{host}} にアクセス", "networkSecret": "認証情報「{{name}}」の入力が必要", "networkSecretGhostInputDetail": "この資格情報はプラグイン自身の設定画面で入力を受け付けるため、入力時にはプラグインのページを経由します。その後ホストへ一度だけ渡されて暗号化保存され、保存後はプラグインから読み出せません。注入は宣言されたドメインへのリクエスト時のみホストが行います。", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 863e8ffda71..7714ce8d567 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -4827,7 +4827,7 @@ "fsWrite": "파일을 쓸 수 있음(생성/수정)", "fsWriteDetail": "전용 데이터 폴더에는 언제든지 쓸 수 있습니다. 현재 세션의 작업 디렉터리 쓰기는 세션의 권한 모드를 따릅니다(자동 승인 모드에서는 바로 쓰고, 개별 확인 모드에서는 먼저 승인을 요청합니다). 그 외 폴더에 쓰려면 매번 확인이 필요합니다. 파일은 항상 호스트가 대신 기록하며 플러그인 자체는 파일 시스템에 직접 접근할 수 없습니다.", "libraryPersist": "영구 라이브러리 사용 가능(대용량 파일 및 데이터베이스)", - "libraryPersistDetail": "Cindy가 관리하는 독립 저장 공간을 받아 작업 데이터(캔버스, 이미지, 데이터베이스 등)를 저장하며, 일반 플러그인 데이터 할당량 제한을 받지 않습니다. 파일과 데이터베이스 작업은 모두 호스트가 대신 실행하고 플러그인은 라이브러리 내 상대 경로만 사용하여 다른 파일에는 접근할 수 없습니다. 라이브러리 파일을 시스템 폴더에서 보여 주거나 시스템 다른 이름으로 저장 대화상자를 열어 복사 위치를 고르게 할 수도 있으며, 취소하면 아무 파일도 복사되지 않습니다. 플러그인을 제거해도 이 데이터는 삭제되지 않으며, 삭제는 Cindy 설정에서 별도 확인이 필요합니다.", + "libraryPersistDetail": "Cindy가 관리하는 독립 저장 공간을 받아 작업 데이터(캔버스, 이미지, 데이터베이스 등)를 저장하며, 일반 플러그인 데이터 할당량 제한을 받지 않습니다. 파일과 데이터베이스 작업은 모두 호스트가 대신 실행하고 플러그인은 라이브러리 내 상대 경로만 사용하여 다른 파일에는 접근할 수 없습니다. 라이브러리 파일을 시스템 폴더에서 보여 주거나 시스템 다른 이름으로 저장 대화상자를 열어 복사 위치를 고르게 할 수도 있으며, 취소하면 아무 파일도 복사되지 않습니다. 확인 대화상자 없이 PNG 비트맵을 시스템 클립보드에 쓸 수도 있으며, 이때 현재 클립보드 내용이 덮어쓰입니다. 플러그인을 제거해도 이 데이터는 삭제되지 않으며, 삭제는 Cindy 설정에서 별도 확인이 필요합니다.", "networkHost": "네트워크 도메인 {{host}}에 접근", "networkSecret": "자격 증명 \"{{name}}\" 입력 필요", "networkSecretGhostInputDetail": "이 자격 증명은 플러그인 자체 설정 화면에서 입력받으므로 입력 시점에 플러그인 페이지를 거칩니다. 이후 호스트에 한 번만 전달되어 암호화 저장되며, 저장 후에는 플러그인이 다시 읽을 수 없습니다. 주입은 선언된 도메인 요청 시에만 호스트가 수행합니다.", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index 27827f0727e..7c7ca112caf 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -4827,7 +4827,7 @@ "fsWrite": "可写入文件(创建/修改)", "fsWriteDetail": "它自己的专属数据目录随时可写;当前任务的工作目录跟随任务的权限模式(免批模式直接写,逐条确认模式先征求你同意);其它目录每次都需要你确认。文件始终由主机代写,插件本身无法直接访问你的文件系统。", "libraryPersist": "可使用持久作品库(大量文件与数据库)", - "libraryPersistDetail": "它会获得一个由 Cindy 管理的独立存储区,用来保存你的作品数据(如画布、图片、数据库),不受普通插件数据配额限制。文件与数据库操作全部由主机代执行,插件只能使用库内相对路径,无法触碰你的其它文件。它还可以让 Cindy 在系统文件夹中显示库内文件,或弹出系统另存为窗口由你选择拷贝位置;取消则不会拷出任何文件。卸载插件不会删除这些数据;删除必须在 Cindy 设置中单独确认。", + "libraryPersistDetail": "它会获得一个由 Cindy 管理的独立存储区,用来保存你的作品数据(如画布、图片、数据库),不受普通插件数据配额限制。文件与数据库操作全部由主机代执行,插件只能使用库内相对路径,无法触碰你的其它文件。它还可以让 Cindy 在系统文件夹中显示库内文件,或弹出系统另存为窗口由你选择拷贝位置;取消则不会拷出任何文件。它还可以把 PNG 位图写入系统剪贴板;没有确认框,会覆盖当前剪贴板内容。卸载插件不会删除这些数据;删除必须在 Cindy 设置中单独确认。", "networkHost": "访问网络域名 {{host}}", "networkSecret": "需要你提供凭证「{{name}}」", "networkSecretGhostInputDetail": "凭证由这个插件自己的设置界面收集,录入时会经过插件页面;随后一次性交给主机加密保管,存入后插件无法读回,主机只在请求它声明的域名时注入。", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json index b7a656a568e..609056b4880 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -4827,7 +4827,7 @@ "fsWrite": "可寫入檔案(建立/修改)", "fsWriteDetail": "它自己的專屬資料目錄隨時可寫;當前任務的工作目錄跟隨任務的權限模式(免批模式直接寫,逐條確認模式先徵求你同意);其它目錄每次都需要你確認。檔案始終由主機代寫,插件本身無法直接訪問你的檔案系統。", "libraryPersist": "可使用持久作品庫(大量檔案與資料庫)", - "libraryPersistDetail": "它會取得一個由 Cindy 管理的獨立儲存區,用來保存你的作品資料(如畫布、圖片、資料庫),不受一般插件資料配額限制。檔案與資料庫操作全部由主機代執行,插件只能使用庫內相對路徑,無法觸碰你的其它檔案。它還可以讓 Cindy 在系統資料夾中顯示庫內檔案,或彈出系統另存為視窗由你選擇拷貝位置;取消則不會拷出任何檔案。解除安裝插件不會刪除這些資料;刪除必須在 Cindy 設定中單獨確認。", + "libraryPersistDetail": "它會取得一個由 Cindy 管理的獨立儲存區,用來保存你的作品資料(如畫布、圖片、資料庫),不受一般插件資料配額限制。檔案與資料庫操作全部由主機代執行,插件只能使用庫內相對路徑,無法觸碰你的其它檔案。它還可以讓 Cindy 在系統資料夾中顯示庫內檔案,或彈出系統另存為視窗由你選擇拷貝位置;取消則不會拷出任何檔案。它還可以把 PNG 點陣圖寫入系統剪貼簿;沒有確認框,會覆蓋目前剪貼簿內容。解除安裝插件不會刪除這些資料;刪除必須在 Cindy 設定中單獨確認。", "networkHost": "訪問網路域名 {{host}}", "networkSecret": "需要你提供憑證「{{name}}」", "networkSecretGhostInputDetail": "憑證由這個插件自己的設定介面收集,錄入時會經過插件頁面;隨後一次性交給主機加密保管,存入後插件無法讀回,主機只在請求它宣告的域名時注入。", diff --git a/apps/desktop/src/shared/ghost.ts b/apps/desktop/src/shared/ghost.ts index 32175cc0475..6e7eae5c9ca 100644 --- a/apps/desktop/src/shared/ghost.ts +++ b/apps/desktop/src/shared/ghost.ts @@ -2085,7 +2085,8 @@ export function ghostPermissionItems(manifest: GhostManifest): GhostPermissionIt items.push({ key: 'fs', kind: 'fs', labelKey: 'fsWrite', detailKey: 'fsWriteDetail' }); } // library 能力:持久作品库(用户数据语义,不是临时缓存)。详情页必须讲清 - // 卸载不删、删除走独立确认,以及会打开系统文件夹/另存为对话框。 + // 卸载不删、删除走独立确认、会打开系统文件夹/另存为对话框,以及可把 PNG + // 位图写入系统剪贴板(无确认框,会覆盖当前剪贴板)。 if (manifest.library === true) { items.push({ key: 'library',