Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 155 additions & 1 deletion apps/desktop/src/main/cindy-brain/__tests__/librarySlot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -50,6 +55,7 @@ describe('GhostLibrarySlot', () => {
let bindingStore: LibraryBindingStore;
let showItemInFolder: ReturnType<typeof vi.fn>;
let showSaveDialog: ReturnType<typeof vi.fn>;
let writeClipboardPng: ReturnType<typeof vi.fn>;
let syncAgentReadonlyExtraDir: ReturnType<typeof vi.fn>;
let clock: number;

Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -449,6 +457,152 @@ describe('GhostLibrarySlot', () => {
expect(fs.existsSync(dest)).toBe(false);
});

// 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, 24);

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' });
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' });
// 插进 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();
});

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: 生产注入无主壳窗 → 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' });
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<void>((resolve) => {
release = resolve;
});
let started!: () => void;
const opened = new Promise<void>((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));
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src/main/cindy-brain/forge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 幂等续跑
Expand Down Expand Up @@ -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;跨库一致性用幂等 + 墓碑
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/main/cindy-brain/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {
app,
BrowserWindow,
clipboard,
dialog,
ipcMain,
nativeImage,
safeStorage,
shell,
type WebContents,
Expand Down Expand Up @@ -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('没有可挂靠的宿主窗口');
}
Comment thread
PraiseZhu marked this conversation as resolved.
const image = nativeImage.createFromBuffer(pngBytes);
if (image.isEmpty()) {
throw new Error('无法把 PNG 字节写成剪贴板位图');
}
clipboard.writeImage(image);
},
});
// 面板只读投影(cindy-ghost://<id>/library/<relPath>)的解析器:与电子脑
// read 同源校验(binding 根 + vault 路径纪律),失败折叠 404。
Expand Down
Loading