diff --git a/apps/desktop/src/main/services/TextInsertService.ts b/apps/desktop/src/main/services/TextInsertService.ts index 97f2f7e..fe5c7b8 100644 --- a/apps/desktop/src/main/services/TextInsertService.ts +++ b/apps/desktop/src/main/services/TextInsertService.ts @@ -4,9 +4,18 @@ // @nut-tree-fork/nut-js + electron clipboard API 사용. import { EventEmitter } from 'events' -import { clipboard } from 'electron' import { getLogger } from './LoggerService' import { D3ROError, ErrorCode } from '@d3ro/core/errors' +import { + applyClipboardRestore, + captureClipboard, + planClipboardRestore, + type ClipboardImage, + type ClipboardPort, + type ClipboardSnapshot +} from './text-insert/clipboard-snapshot' +import { electronClipboardPort } from './text-insert/electron-clipboard-port' +import { NutKeyboardPort, type KeyboardPort } from './text-insert/nut-keyboard-port' const logger = getLogger('TextInsertService') @@ -16,16 +25,9 @@ const logger = getLogger('TextInsertService') type InsertMethod = 'clipboard' | 'keyboard' -interface ClipboardSnapshot { - text: string | null - html: string | null - image: Electron.NativeImage | null - rtf: string | null - hasContent: boolean -} - interface InsertResult { success: boolean + /** 실제로 쓴 방식 — 클립보드를 되돌릴 수 없으면 'clipboard' 요청도 'keyboard' 가 된다 */ method: InsertMethod textLength: number durationMs: number @@ -39,72 +41,68 @@ interface TextInsertEvents { 'clipboard-restored': (payload: Record) => void } +/** 의존성 주입 (테스트·다른 플랫폼 어댑터용). 생략하면 Electron + nut-js 를 쓴다. */ +export interface TextInsertDeps { + clipboard?: ClipboardPort + keyboard?: KeyboardPort + sleep?: (ms: number) => Promise + platform?: NodeJS.Platform +} + // ============================================================ // TextInsertService // ============================================================ -class TextInsertService extends EventEmitter { - private _nutKeyboard: NutKeyboard | null = null - private _nutLoadPromise: Promise | null = null - +export class TextInsertService extends EventEmitter { + private readonly _clipboard: ClipboardPort + private readonly _keyboard: KeyboardPort + private readonly _sleep: (ms: number) => Promise + private readonly _platform: NodeJS.Platform /** - * nut-js는 ESM + 네이티브 모듈이므로 lazy dynamic import 한다. + * 삽입 직렬화 꼬리. 받아쓰기·음성 액션·제안 수락이 동시에 삽입하면 서로의 + * save/restore 가 엇갈려 다른 삽입의 텍스트를 "원래 클립보드"로 되돌리게 된다. */ - private async _ensureNut(): Promise { - if (this._nutKeyboard) return this._nutKeyboard + private _tail: Promise = Promise.resolve() - if (!this._nutLoadPromise) { - this._nutLoadPromise = (async () => { - try { - const nut = await import('@nut-tree-fork/nut-js') - this._nutKeyboard = { - pressKey: nut.keyboard.pressKey.bind(nut.keyboard), - releaseKey: nut.keyboard.releaseKey.bind(nut.keyboard), - type: nut.keyboard.type.bind(nut.keyboard), - Key: nut.Key - } - logger.info('nut-js loaded successfully') - } catch (error) { - logger.error(`Failed to load nut-js: ${error instanceof Error ? error.message : String(error)}`) - throw new D3ROError( - ErrorCode.TextInsertKeySimulationFailed, - 'nut-js 로드 실패. 키보드 시뮬레이션을 사용할 수 없습니다.' - ) - } - })() - } - - await this._nutLoadPromise - if (!this._nutKeyboard) { - throw new D3ROError(ErrorCode.TextInsertKeySimulationFailed, 'nut-js not available') - } - return this._nutKeyboard + constructor(deps: TextInsertDeps = {}) { + super() + this._platform = deps.platform ?? process.platform + this._clipboard = deps.clipboard ?? electronClipboardPort + this._keyboard = deps.keyboard ?? new NutKeyboardPort(this._platform) + this._sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))) } /** * 텍스트를 현재 활성 앱에 삽입한다. * 기본 전략: clipboard save → set → Ctrl+V → restore + * 동시 호출은 도착 순서대로 하나씩 처리한다. */ - async insertText(text: string, method: InsertMethod = 'clipboard'): Promise { + insertText(text: string, method: InsertMethod = 'clipboard'): Promise { + const run = this._tail.then(() => this._insertNow(text, method)) + this._tail = run.then( + () => undefined, + () => undefined + ) + return run + } + + private async _insertNow(text: string, method: InsertMethod): Promise { const start = performance.now() this.emit('insert-started', { text, method }) try { - if (method === 'clipboard') { - await this._insertViaClipboard(text) - } else { - await this._insertViaKeyboard(text) - } + const used: InsertMethod = + method === 'clipboard' ? await this._insertViaClipboard(text) : await this._insertViaKeyboard(text) const result: InsertResult = { success: true, - method, + method: used, textLength: text.length, durationMs: performance.now() - start } this.emit('insert-completed', { result }) - logger.info(`Text inserted (${text.length} chars, ${Math.round(result.durationMs)}ms)`) + logger.info(`Text inserted (${text.length} chars, ${Math.round(result.durationMs)}ms, ${used})`) return result } catch (error) { const d3roError = @@ -123,38 +121,47 @@ class TextInsertService extends EventEmitter { /** * 클립보드 방식: save → set → Cmd/Ctrl+V → restore (Speakly 패턴) * macOS는 ⌘+V, Windows/Linux는 Ctrl+V로 분기. + * 원래 클립보드를 되돌릴 수 없으면(예: 탐색기에서 복사한 파일) 클립보드를 건드리지 않고 + * 키보드 방식으로 넣는다. 실제로 쓴 방식을 돌려준다. */ - private async _insertViaClipboard(text: string): Promise { - // 1. 기존 클립보드 저장 + private async _insertViaClipboard(text: string): Promise { + // 1. 기존 클립보드 저장 + 복원 가능성 판정 const snapshot = this.saveClipboard() + const plan = planClipboardRestore(snapshot) + if (plan.kind === 'unrestorable') { + logger.info( + `Clipboard cannot be restored (formats=${plan.formats.join(',') || '?'}); typing instead` + ) + return this._insertViaKeyboard(text) + } + if (plan.kind === 'write' && plan.dropped.length > 0) { + logger.debug(`Clipboard formats not restorable alongside text: ${plan.dropped.join(',')}`) + } this.emit('clipboard-saved', {}) try { // 2. 클립보드에 텍스트 설정 - clipboard.writeText(text) + this._clipboard.writeText(text) // 짧은 지연: 일부 앱이 클립보드 변경을 받아들일 시간 필요 await this._sleep(20) // 3. Paste 단축키 시뮬레이션 — 플랫폼별 modifier - const nut = await this._ensureNut() - const pasteModKey = - process.platform === 'darwin' ? nut.Key.LeftSuper : nut.Key.LeftControl - - await nut.pressKey(pasteModKey, nut.Key.V) - await nut.releaseKey(nut.Key.V, pasteModKey) + await this._keyboard.paste() // 4. 붙여넣기 완료 대기 — 앱이 paste 이벤트를 처리할 시간 // macOS는 비동기 처리가 좀 더 느린 경우가 있어 250ms로 잡음 - await this._sleep(process.platform === 'darwin' ? 250 : 150) + await this._sleep(this._platform === 'darwin' ? 250 : 150) // 5. 클립보드 복원 (원래 내용으로 되돌림) - this.restoreClipboard(snapshot) - this.emit('clipboard-restored', {}) + if (this._restoreIfStillOurs(text, snapshot)) { + this.emit('clipboard-restored', {}) + } + return 'clipboard' } catch (error) { // 실패 시에도 클립보드 복원 시도 try { - this.restoreClipboard(snapshot) + this._restoreIfStillOurs(text, snapshot) } catch { logger.warn('Failed to restore clipboard after insert error') } @@ -162,52 +169,41 @@ class TextInsertService extends EventEmitter { } } + /** + * 클립보드가 아직 우리가 넣은 텍스트일 때만 되돌린다. 그 사이 사용자가 새로 복사했다면 + * 그 내용을 덮어쓰지 않는다 (writeText 자체가 실패한 경우도 여기서 걸러진다). + */ + private _restoreIfStillOurs(text: string, snapshot: ClipboardSnapshot): boolean { + if (this._clipboard.readText() !== text) { + logger.debug('Clipboard changed during insert; skipping restore') + return false + } + return this.restoreClipboard(snapshot) + } + /** * 키보드 방식: 한 글자씩 타이핑 (느리지만 클립보드 비파괴) */ - private async _insertViaKeyboard(text: string): Promise { - const nut = await this._ensureNut() - await nut.type(text) + private async _insertViaKeyboard(text: string): Promise { + await this._keyboard.type(text) + return 'keyboard' } /** - * 현재 클립보드 상태를 캡처한다. + * 현재 클립보드 상태를 모든 포맷으로 캡처한다. */ - saveClipboard(): ClipboardSnapshot { - const text = clipboard.readText() || null - const html = clipboard.readHTML() || null - const rtf = clipboard.readRTF() || null - const image = clipboard.readImage() - const hasImage = image && !image.isEmpty() - - return { - text, - html, - image: hasImage ? image : null, - rtf, - hasContent: !!(text || html || rtf || hasImage) - } + saveClipboard(): ClipboardSnapshot { + return captureClipboard(this._clipboard) } /** - * 저장된 클립보드 상태를 복원한다. + * 저장된 클립보드 상태를 복원한다. 되돌릴 수 없는 스냅샷이면 아무것도 쓰지 않고 false. + * 원래 포맷이 있었는데 clear() 하는 일은 없다. */ - restoreClipboard(snapshot: ClipboardSnapshot): void { - if (!snapshot.hasContent) { - clipboard.clear() - return - } - - // 텍스트가 있으면 텍스트 우선 복원 - if (snapshot.text) { - clipboard.writeText(snapshot.text) - } else if (snapshot.html) { - clipboard.writeHTML(snapshot.html) - } else if (snapshot.rtf) { - clipboard.writeRTF(snapshot.rtf) - } else if (snapshot.image) { - clipboard.writeImage(snapshot.image) - } + restoreClipboard(snapshot: ClipboardSnapshot): boolean { + const restored = applyClipboardRestore(this._clipboard, planClipboardRestore(snapshot)) + if (!restored) logger.warn('Clipboard snapshot is not restorable; left as is') + return restored } dispose(): void { @@ -215,10 +211,6 @@ class TextInsertService extends EventEmitter { logger.info('TextInsertService disposed') } - private _sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) - } - // ── EventEmitter 타입 오버라이드 ─────────────────────── override on( @@ -243,17 +235,6 @@ class TextInsertService extends EventEmitter { } } -// ── nut-js 타입 (lazy import용) ────────────────────────── - -type NutModule = typeof import('@nut-tree-fork/nut-js') - -interface NutKeyboard { - pressKey: NutModule['keyboard']['pressKey'] - releaseKey: NutModule['keyboard']['releaseKey'] - type: NutModule['keyboard']['type'] - Key: NutModule['Key'] -} - // ── 싱글톤 ───────────────────────────────────────────── let instance: TextInsertService | null = null diff --git a/apps/desktop/src/main/services/text-insert/clipboard-snapshot.ts b/apps/desktop/src/main/services/text-insert/clipboard-snapshot.ts new file mode 100644 index 0000000..f89de3d --- /dev/null +++ b/apps/desktop/src/main/services/text-insert/clipboard-snapshot.ts @@ -0,0 +1,178 @@ +// src/main/services/text-insert/clipboard-snapshot.ts +// 클립보드 스냅샷·복원 정책 (IO는 ClipboardPort 뒤로 숨긴다). +// +// 붙여넣기 방식 삽입은 사용자의 클립보드를 잠시 빌려 쓴다. 빌린 뒤 원래대로 돌려놓지 +// 못하면 사용자가 복사해 둔 것이 사라진다. 두 가지 함정이 있다. +// 1) 텍스트·HTML·RTF·이미지를 하나만 되돌리면 Word/브라우저/Excel 서식이 사라진다. +// → 표준 포맷은 clipboard.write() 한 번으로 함께 되돌린다. +// 2) 탐색기에서 복사한 파일(CF_HDROP)처럼 표준 포맷이 하나도 없으면 예전 코드는 +// "비어 있다"고 보고 clear() 했다. → availableFormats() 가 비어 있지 않으면 절대 +// clear() 하지 않는다. 되돌릴 수 없는 클립보드는 'unrestorable' 로 판정하고, +// 호출자는 클립보드를 건드리지 않는 키보드 방식으로 돌아가야 한다. +// +// Electron 의 write()/writeBuffer() 는 매번 클립보드 전체를 교체하므로 표준 포맷과 +// 사용자 정의 버퍼를 한 번에 합쳐 쓸 수 없다. 그래서 사용자 정의 버퍼는 표준 포맷이 +// 없고 그 포맷 하나뿐일 때만 writeBuffer 로 되돌린다. + +/** 스냅샷이 다루는 이미지의 최소 계약 (Electron.NativeImage 가 만족한다). */ +export interface ClipboardImage { + isEmpty(): boolean +} + +export interface ClipboardWriteData { + text?: string + html?: string + rtf?: string + image?: TImage +} + +/** 클립보드 IO 포트. 운영은 Electron clipboard, 테스트는 메모리 구현을 꽂는다. */ +export interface ClipboardPort { + availableFormats(): string[] + readText(): string + readHTML(): string + readRTF(): string + readImage(): TImage + readBuffer(format: string): Buffer + writeText(text: string): void + write(data: ClipboardWriteData): void + writeBuffer(format: string, buffer: Buffer): void + clear(): void +} + +export interface ClipboardBufferEntry { + format: string + buffer: Buffer +} + +export interface ClipboardSnapshot { + /** 캡처 시점 availableFormats() 결과 */ + formats: string[] + text: string | null + html: string | null + rtf: string | null + image: TImage | null + /** 표준 포맷이 아닌 포맷 중 readBuffer 로 읽어 낸 것 */ + buffers: ClipboardBufferEntry[] + /** 표준 포맷이 아닌 포맷 중 읽지 못한 것 (빈 버퍼 또는 예외) */ + unreadable: string[] +} + +export type ClipboardRestorePlan = + /** 원래 비어 있었다 — clear() 로 되돌린다 */ + | { kind: 'clear' } + /** 표준 포맷을 함께 되돌린다. dropped 는 함께 쓸 수 없어 잃는 포맷 */ + | { kind: 'write'; data: ClipboardWriteData; dropped: string[] } + /** 사용자 정의 포맷 하나뿐 — writeBuffer 로 되돌린다 */ + | { kind: 'buffer'; entry: ClipboardBufferEntry } + /** 되돌릴 방법이 없다 — 클립보드를 빌려 쓰면 안 된다 */ + | { kind: 'unrestorable'; formats: string[] } + +/** + * text/plain, text/html, text/rtf, 이미지 계열은 readText/readHTML/readRTF/readImage 와 + * write() 가 직접 다룬다. 나머지는 사용자 정의(플랫폼) 포맷이다. + */ +export function isStandardClipboardFormat(format: string): boolean { + const f = format.toLowerCase() + return ( + f.startsWith('text/plain') || + f === 'text/html' || + f === 'text/rtf' || + f === 'image/png' || + f === 'image/bmp' || + f === 'image/jpeg' + ) +} + +/** availableFormats() 가 실패했을 때 unreadable 에 남기는 표식 */ +export const UNKNOWN_FORMATS = '' + +/** 현재 클립보드의 모든 포맷을 캡처한다. */ +export function captureClipboard( + port: ClipboardPort +): ClipboardSnapshot { + const buffers: ClipboardBufferEntry[] = [] + const unreadable: string[] = [] + + let formats: string[] + try { + formats = port.availableFormats().filter((f) => typeof f === 'string' && f.length > 0) + } catch { + // 포맷 목록을 모르면 비어 있다고 가정하지 않는다 (clear() 로 지우지 않게) + formats = [] + unreadable.push(UNKNOWN_FORMATS) + } + const image = port.readImage() + + for (const format of formats) { + if (isStandardClipboardFormat(format)) continue + let buffer: Buffer | null = null + try { + buffer = port.readBuffer(format) + } catch { + buffer = null + } + if (buffer && buffer.length > 0) { + buffers.push({ format, buffer }) + } else { + unreadable.push(format) + } + } + + return { + formats, + text: port.readText() || null, + html: port.readHTML() || null, + rtf: port.readRTF() || null, + image: image && !image.isEmpty() ? image : null, + buffers, + unreadable + } +} + +/** 스냅샷을 어떻게 되돌릴지 정한다 (순수 함수). */ +export function planClipboardRestore( + snapshot: ClipboardSnapshot +): ClipboardRestorePlan { + const data: ClipboardWriteData = {} + if (snapshot.text) data.text = snapshot.text + if (snapshot.html) data.html = snapshot.html + if (snapshot.rtf) data.rtf = snapshot.rtf + if (snapshot.image) data.image = snapshot.image + + const custom = [...snapshot.buffers.map((b) => b.format), ...snapshot.unreadable] + + if (Object.keys(data).length > 0) { + return { kind: 'write', data, dropped: custom } + } + if (custom.length === 0) { + // 표준 포맷만 광고됐고 내용이 모두 비어 있었다 — 실질적으로 빈 클립보드 + return { kind: 'clear' } + } + if (snapshot.buffers.length === 1 && snapshot.unreadable.length === 0) { + return { kind: 'buffer', entry: snapshot.buffers[0] } + } + return { kind: 'unrestorable', formats: snapshot.formats } +} + +/** + * 계획대로 클립보드를 되돌린다. 'unrestorable' 이면 아무것도 쓰지 않고 false 를 돌려준다. + */ +export function applyClipboardRestore( + port: ClipboardPort, + plan: ClipboardRestorePlan +): boolean { + switch (plan.kind) { + case 'clear': + port.clear() + return true + case 'write': + port.write(plan.data) + return true + case 'buffer': + port.writeBuffer(plan.entry.format, plan.entry.buffer) + return true + case 'unrestorable': + return false + } +} diff --git a/apps/desktop/src/main/services/text-insert/electron-clipboard-port.ts b/apps/desktop/src/main/services/text-insert/electron-clipboard-port.ts new file mode 100644 index 0000000..d93a4ab --- /dev/null +++ b/apps/desktop/src/main/services/text-insert/electron-clipboard-port.ts @@ -0,0 +1,19 @@ +// src/main/services/text-insert/electron-clipboard-port.ts +// ClipboardPort 의 Electron 어댑터. electron.clipboard 는 호출 시점에 참조한다 +// (모듈 로드만으로 네이티브 클립보드에 닿지 않게). + +import { clipboard } from 'electron' +import type { ClipboardPort } from './clipboard-snapshot' + +export const electronClipboardPort: ClipboardPort = { + availableFormats: () => clipboard.availableFormats(), + readText: () => clipboard.readText(), + readHTML: () => clipboard.readHTML(), + readRTF: () => clipboard.readRTF(), + readImage: () => clipboard.readImage(), + readBuffer: (format) => clipboard.readBuffer(format), + writeText: (text) => clipboard.writeText(text), + write: (data) => clipboard.write(data), + writeBuffer: (format, buffer) => clipboard.writeBuffer(format, buffer), + clear: () => clipboard.clear() +} diff --git a/apps/desktop/src/main/services/text-insert/nut-keyboard-port.ts b/apps/desktop/src/main/services/text-insert/nut-keyboard-port.ts new file mode 100644 index 0000000..659a1c5 --- /dev/null +++ b/apps/desktop/src/main/services/text-insert/nut-keyboard-port.ts @@ -0,0 +1,75 @@ +// src/main/services/text-insert/nut-keyboard-port.ts +// 키 입력 시뮬레이션 포트와 nut-js 어댑터. +// nut-js 는 ESM + 네이티브 모듈이므로 첫 사용 시 lazy dynamic import 한다. + +import { getLogger } from '../LoggerService' +import { D3ROError, ErrorCode } from '@d3ro/core/errors' + +const logger = getLogger('TextInsertService') + +/** 텍스트 삽입에 필요한 키 입력만 노출하는 포트. */ +export interface KeyboardPort { + /** 플랫폼 붙여넣기 단축키 (macOS ⌘+V, 그 외 Ctrl+V) */ + paste(): Promise + /** 글자 단위 타이핑 (클립보드 비파괴) */ + type(text: string): Promise +} + +type NutModule = typeof import('@nut-tree-fork/nut-js') + +interface NutKeyboard { + pressKey: NutModule['keyboard']['pressKey'] + releaseKey: NutModule['keyboard']['releaseKey'] + type: NutModule['keyboard']['type'] + Key: NutModule['Key'] +} + +export class NutKeyboardPort implements KeyboardPort { + private _nutKeyboard: NutKeyboard | null = null + private _nutLoadPromise: Promise | null = null + + constructor(private readonly _platform: NodeJS.Platform = process.platform) {} + + async paste(): Promise { + const nut = await this._ensureNut() + const pasteModKey = this._platform === 'darwin' ? nut.Key.LeftSuper : nut.Key.LeftControl + await nut.pressKey(pasteModKey, nut.Key.V) + await nut.releaseKey(nut.Key.V, pasteModKey) + } + + async type(text: string): Promise { + const nut = await this._ensureNut() + await nut.type(text) + } + + private async _ensureNut(): Promise { + if (this._nutKeyboard) return this._nutKeyboard + + if (!this._nutLoadPromise) { + this._nutLoadPromise = (async () => { + try { + const nut = await import('@nut-tree-fork/nut-js') + this._nutKeyboard = { + pressKey: nut.keyboard.pressKey.bind(nut.keyboard), + releaseKey: nut.keyboard.releaseKey.bind(nut.keyboard), + type: nut.keyboard.type.bind(nut.keyboard), + Key: nut.Key + } + logger.info('nut-js loaded successfully') + } catch (error) { + logger.error(`Failed to load nut-js: ${error instanceof Error ? error.message : String(error)}`) + throw new D3ROError( + ErrorCode.TextInsertKeySimulationFailed, + 'nut-js 로드 실패. 키보드 시뮬레이션을 사용할 수 없습니다.' + ) + } + })() + } + + await this._nutLoadPromise + if (!this._nutKeyboard) { + throw new D3ROError(ErrorCode.TextInsertKeySimulationFailed, 'nut-js not available') + } + return this._nutKeyboard + } +} diff --git a/apps/desktop/tests/main/services/textinsert-redteam-r1-2.test.ts b/apps/desktop/tests/main/services/textinsert-redteam-r1-2.test.ts new file mode 100644 index 0000000..191a8b6 --- /dev/null +++ b/apps/desktop/tests/main/services/textinsert-redteam-r1-2.test.ts @@ -0,0 +1,338 @@ +// tests/main/services/textinsert-redteam-r1-2.test.ts +// 회귀: 붙여넣기 삽입 뒤 클립보드 복원이 포맷 하나만 되돌리거나(서식 손실), +// 표준 포맷이 없는 클립보드(탐색기에서 복사한 파일)를 clear() 로 지우던 버그. +// 그리고 동시 삽입이 서로의 save/restore 를 엇갈리게 하던 문제. + +import { describe, it, expect, vi } from 'vitest' +import { TextInsertService } from '../../../src/main/services/TextInsertService' +import { + captureClipboard, + planClipboardRestore, + isStandardClipboardFormat, + UNKNOWN_FORMATS, + type ClipboardImage, + type ClipboardPort, + type ClipboardWriteData +} from '../../../src/main/services/text-insert/clipboard-snapshot' +import type { KeyboardPort } from '../../../src/main/services/text-insert/nut-keyboard-port' + +class FakeImage implements ClipboardImage { + constructor(private readonly _empty: boolean, readonly tag = 'img') {} + isEmpty(): boolean { + return this._empty + } +} + +/** + * Electron 처럼 write()/writeText()/writeBuffer() 가 매번 클립보드 전체를 교체하는 + * 메모리 클립보드. + */ +class MemoryClipboard implements ClipboardPort { + text = '' + html = '' + rtf = '' + image: ClipboardImage = new FakeImage(true) + buffers = new Map() + /** 표준 API 로 읽을 수 없는 네이티브 포맷 (예: CF_HDROP → 'text/uri-list') */ + opaque: string[] = [] + clearCalls = 0 + + availableFormats(): string[] { + const f: string[] = [] + if (this.text) f.push('text/plain') + if (this.html) f.push('text/html') + if (this.rtf) f.push('text/rtf') + if (!this.image.isEmpty()) f.push('image/png') + f.push(...this.buffers.keys(), ...this.opaque) + return f + } + readText(): string { + return this.text + } + readHTML(): string { + return this.html + } + readRTF(): string { + return this.rtf + } + readImage(): ClipboardImage { + return this.image + } + readBuffer(format: string): Buffer { + return this.buffers.get(format) ?? Buffer.alloc(0) + } + private _reset(): void { + this.text = '' + this.html = '' + this.rtf = '' + this.image = new FakeImage(true) + this.buffers = new Map() + this.opaque = [] + } + writeText(text: string): void { + this._reset() + this.text = text + } + write(data: ClipboardWriteData): void { + this._reset() + this.text = data.text ?? '' + this.html = data.html ?? '' + this.rtf = data.rtf ?? '' + if (data.image) this.image = data.image + } + writeBuffer(format: string, buffer: Buffer): void { + this._reset() + this.buffers.set(format, buffer) + } + clear(): void { + this.clearCalls++ + this._reset() + } +} + +function makeKeyboard(clip: MemoryClipboard, pasted: string[], typed: string[]): KeyboardPort { + return { + paste: vi.fn(async () => { + pasted.push(clip.readText()) + }), + type: vi.fn(async (text: string) => { + typed.push(text) + }) + } +} + +function makeService(clip: MemoryClipboard, pasted: string[] = [], typed: string[] = []) { + const keyboard = makeKeyboard(clip, pasted, typed) + const service = new TextInsertService({ + clipboard: clip, + keyboard, + sleep: () => new Promise((resolve) => setImmediate(resolve)), + platform: 'win32' + }) + return { service, keyboard, pasted, typed } +} + +describe('TextInsertService 클립보드 복원 (redteam r1-2)', () => { + it('탐색기에서 복사한 파일(표준 포맷 없음)을 지우지 않고 키보드로 넣는다', async () => { + const clip = new MemoryClipboard() + clip.opaque = ['text/uri-list'] // CF_HDROP — readBuffer 로 읽을 수 없음 + const { service, pasted, typed } = makeService(clip) + + const result = await service.insertText('안녕하세요', 'clipboard') + + expect(clip.clearCalls).toBe(0) + expect(clip.availableFormats()).toEqual(['text/uri-list']) + expect(pasted).toEqual([]) + expect(typed).toEqual(['안녕하세요']) + expect(result.method).toBe('keyboard') + expect(result.success).toBe(true) + }) + + it('Word/브라우저 서식(텍스트+HTML+RTF+이미지)을 모두 되돌린다', async () => { + const clip = new MemoryClipboard() + const img = new FakeImage(false, 'table-picture') + clip.text = 'plain' + clip.html = 'bold' + clip.rtf = '{\\rtf1 bold}' + clip.image = img + const { service, pasted } = makeService(clip) + + const result = await service.insertText('dictated', 'clipboard') + + expect(pasted).toEqual(['dictated']) + expect(result.method).toBe('clipboard') + expect(clip.text).toBe('plain') + expect(clip.html).toBe('bold') + expect(clip.rtf).toBe('{\\rtf1 bold}') + expect(clip.image).toBe(img) + }) + + it('HTML 만 있는 클립보드도 HTML 로 되돌린다', async () => { + const clip = new MemoryClipboard() + clip.html = 'only html' + const { service } = makeService(clip) + + await service.insertText('x') + + expect(clip.html).toBe('only html') + expect(clip.text).toBe('') + }) + + it('사용자 정의 포맷 하나뿐이면 writeBuffer 로 되돌린다', async () => { + const clip = new MemoryClipboard() + clip.buffers.set('FileNameW', Buffer.from('C:\\a.txt', 'utf16le')) + const { service, pasted } = makeService(clip) + + await service.insertText('hi') + + expect(pasted).toEqual(['hi']) + expect(clip.clearCalls).toBe(0) + expect(clip.readBuffer('FileNameW').toString('utf16le')).toBe('C:\\a.txt') + }) + + it('원래 비어 있던 클립보드는 비운 상태로 되돌린다', async () => { + const clip = new MemoryClipboard() + const { service, pasted } = makeService(clip) + + await service.insertText('hi') + + expect(pasted).toEqual(['hi']) + expect(clip.availableFormats()).toEqual([]) + expect(clip.clearCalls).toBe(1) + }) + + it('붙여넣기 도중 사용자가 새로 복사한 내용은 덮어쓰지 않는다', async () => { + const clip = new MemoryClipboard() + clip.text = 'old' + const keyboard: KeyboardPort = { + paste: vi.fn(async () => { + clip.writeText('user copied meanwhile') + }), + type: vi.fn(async () => undefined) + } + const service = new TextInsertService({ + clipboard: clip, + keyboard, + sleep: async () => undefined, + platform: 'win32' + }) + + await service.insertText('hi') + + expect(clip.text).toBe('user copied meanwhile') + }) + + it('붙여넣기 실패 시에도 원래 클립보드를 되돌리고 에러를 던진다', async () => { + const clip = new MemoryClipboard() + clip.text = 'keep me' + clip.html = '

keep me

' + const keyboard: KeyboardPort = { + paste: vi.fn(async () => { + throw new Error('SendInput failed') + }), + type: vi.fn(async () => undefined) + } + const service = new TextInsertService({ + clipboard: clip, + keyboard, + sleep: async () => undefined, + platform: 'win32' + }) + + await expect(service.insertText('hi')).rejects.toThrow(/SendInput failed/) + expect(clip.text).toBe('keep me') + expect(clip.html).toBe('

keep me

') + }) + + it('동시 삽입은 직렬화되어 서로의 텍스트를 원래 클립보드로 되돌리지 않는다', async () => { + const clip = new MemoryClipboard() + clip.text = 'original' + const pasted: string[] = [] + const { service } = makeService(clip, pasted) + + await Promise.all([ + service.insertText('first'), + service.insertText('second'), + service.insertText('third') + ]) + + expect(pasted).toEqual(['first', 'second', 'third']) + expect(clip.text).toBe('original') + }) + + it('앞선 삽입이 실패해도 다음 삽입은 진행된다', async () => { + const clip = new MemoryClipboard() + clip.text = 'original' + let calls = 0 + const keyboard: KeyboardPort = { + paste: vi.fn(async () => { + calls++ + if (calls === 1) throw new Error('boom') + }), + type: vi.fn(async () => undefined) + } + const service = new TextInsertService({ + clipboard: clip, + keyboard, + sleep: async () => undefined, + platform: 'win32' + }) + + const first = service.insertText('a') + const second = service.insertText('b') + await expect(first).rejects.toThrow() + await expect(second).resolves.toMatchObject({ success: true, method: 'clipboard' }) + expect(clip.text).toBe('original') + }) + + it('keyboard 요청은 클립보드를 건드리지 않는다', async () => { + const clip = new MemoryClipboard() + clip.text = 'orig' + const writeSpy = vi.spyOn(clip, 'writeText') + const { service, typed } = makeService(clip) + + const result = await service.insertText('typed', 'keyboard') + + expect(result.method).toBe('keyboard') + expect(typed).toEqual(['typed']) + expect(writeSpy).not.toHaveBeenCalled() + }) +}) + +describe('clipboard-snapshot 정책', () => { + it('표준 포맷 판정', () => { + expect(isStandardClipboardFormat('text/plain')).toBe(true) + expect(isStandardClipboardFormat('text/plain;charset=utf-8')).toBe(true) + expect(isStandardClipboardFormat('TEXT/HTML')).toBe(true) + expect(isStandardClipboardFormat('image/png')).toBe(true) + expect(isStandardClipboardFormat('text/uri-list')).toBe(false) + expect(isStandardClipboardFormat('FileNameW')).toBe(false) + }) + + it('표준 내용이 있으면 함께 쓰고, 같이 쓸 수 없는 포맷은 dropped 로 보고한다', () => { + const clip = new MemoryClipboard() + clip.text = 't' + clip.html = 'h' + clip.buffers.set('vscode-editor-data', Buffer.from('{}')) + const plan = planClipboardRestore(captureClipboard(clip)) + expect(plan).toEqual({ + kind: 'write', + data: { text: 't', html: 'h' }, + dropped: ['vscode-editor-data'] + }) + }) + + it('사용자 정의 포맷이 여럿이면 되돌릴 수 없다고 판정한다 (clear 금지)', () => { + const clip = new MemoryClipboard() + clip.buffers.set('A', Buffer.from('a')) + clip.buffers.set('B', Buffer.from('b')) + expect(planClipboardRestore(captureClipboard(clip)).kind).toBe('unrestorable') + }) + + it('readBuffer 가 예외를 던지는 포맷은 읽지 못한 포맷으로 분류한다', () => { + const clip = new MemoryClipboard() + clip.opaque = ['weird'] + vi.spyOn(clip, 'readBuffer').mockImplementation(() => { + throw new Error('nope') + }) + const snap = captureClipboard(clip) + expect(snap.unreadable).toEqual(['weird']) + expect(planClipboardRestore(snap).kind).toBe('unrestorable') + }) + + it('availableFormats 가 실패하면 비어 있다고 가정하지 않는다', () => { + const clip = new MemoryClipboard() + vi.spyOn(clip, 'availableFormats').mockImplementation(() => { + throw new Error('locked') + }) + const snap = captureClipboard(clip) + expect(snap.unreadable).toEqual([UNKNOWN_FORMATS]) + expect(planClipboardRestore(snap).kind).toBe('unrestorable') + }) + + it('표준 포맷만 광고되고 내용이 비었으면 clear 로 되돌린다', () => { + const clip = new MemoryClipboard() + vi.spyOn(clip, 'availableFormats').mockReturnValue(['text/plain']) + expect(planClipboardRestore(captureClipboard(clip))).toEqual({ kind: 'clear' }) + }) +})