fix(text-insert): restore every clipboard format after paste and never wipe non-text clipboards

This commit is contained in:
Yun Chan 2026-09-28 00:53:42 +09:00
parent 190b6db284
commit 0adedf5e7b
5 changed files with 704 additions and 113 deletions

View file

@ -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<string, never>) => void
}
/** 의존성 주입 (테스트·다른 플랫폼 어댑터용). 생략하면 Electron + nut-js 를 쓴다. */
export interface TextInsertDeps {
clipboard?: ClipboardPort<ClipboardImage>
keyboard?: KeyboardPort
sleep?: (ms: number) => Promise<void>
platform?: NodeJS.Platform
}
// ============================================================
// TextInsertService
// ============================================================
class TextInsertService extends EventEmitter {
private _nutKeyboard: NutKeyboard | null = null
private _nutLoadPromise: Promise<void> | null = null
export class TextInsertService extends EventEmitter {
private readonly _clipboard: ClipboardPort<ClipboardImage>
private readonly _keyboard: KeyboardPort
private readonly _sleep: (ms: number) => Promise<void>
private readonly _platform: NodeJS.Platform
/**
* nut-js는 ESM + 네이티브 모듈이므로 lazy dynamic import 한다.
* 삽입 직렬화 꼬리. 받아쓰기·음성 액션·제안 수락이 동시에 삽입하면 서로의
* save/restore 가 엇갈려 다른 삽입의 텍스트를 "원래 클립보드"로 되돌리게 된다.
*/
private async _ensureNut(): Promise<NutKeyboard> {
if (this._nutKeyboard) return this._nutKeyboard
private _tail: Promise<void> = 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<InsertResult> {
insertText(text: string, method: InsertMethod = 'clipboard'): Promise<InsertResult> {
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<InsertResult> {
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<void> {
// 1. 기존 클립보드 저장
private async _insertViaClipboard(text: string): Promise<InsertMethod> {
// 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)
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<ClipboardImage>): 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<void> {
const nut = await this._ensureNut()
await nut.type(text)
private async _insertViaKeyboard(text: string): Promise<InsertMethod> {
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<ClipboardImage> {
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<ClipboardImage>): 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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// ── EventEmitter 타입 오버라이드 ───────────────────────
override on<K extends keyof TextInsertEvents>(
@ -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

View file

@ -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<TImage extends ClipboardImage> {
text?: string
html?: string
rtf?: string
image?: TImage
}
/** 클립보드 IO 포트. 운영은 Electron clipboard, 테스트는 메모리 구현을 꽂는다. */
export interface ClipboardPort<TImage extends ClipboardImage = ClipboardImage> {
availableFormats(): string[]
readText(): string
readHTML(): string
readRTF(): string
readImage(): TImage
readBuffer(format: string): Buffer
writeText(text: string): void
write(data: ClipboardWriteData<TImage>): void
writeBuffer(format: string, buffer: Buffer): void
clear(): void
}
export interface ClipboardBufferEntry {
format: string
buffer: Buffer
}
export interface ClipboardSnapshot<TImage extends ClipboardImage = ClipboardImage> {
/** 캡처 시점 availableFormats() 결과 */
formats: string[]
text: string | null
html: string | null
rtf: string | null
image: TImage | null
/** 표준 포맷이 아닌 포맷 중 readBuffer 로 읽어 낸 것 */
buffers: ClipboardBufferEntry[]
/** 표준 포맷이 아닌 포맷 중 읽지 못한 것 (빈 버퍼 또는 예외) */
unreadable: string[]
}
export type ClipboardRestorePlan<TImage extends ClipboardImage = ClipboardImage> =
/** 원래 비어 있었다 — clear() 로 되돌린다 */
| { kind: 'clear' }
/** 표준 포맷을 함께 되돌린다. dropped 는 함께 쓸 수 없어 잃는 포맷 */
| { kind: 'write'; data: ClipboardWriteData<TImage>; 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 = '<unknown>'
/** 현재 클립보드의 모든 포맷을 캡처한다. */
export function captureClipboard<TImage extends ClipboardImage>(
port: ClipboardPort<TImage>
): ClipboardSnapshot<TImage> {
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<TImage extends ClipboardImage>(
snapshot: ClipboardSnapshot<TImage>
): ClipboardRestorePlan<TImage> {
const data: ClipboardWriteData<TImage> = {}
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<TImage extends ClipboardImage>(
port: ClipboardPort<TImage>,
plan: ClipboardRestorePlan<TImage>
): 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
}
}

View file

@ -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<Electron.NativeImage> = {
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()
}

View file

@ -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<void>
/** 글자 단위 타이핑 (클립보드 비파괴) */
type(text: string): Promise<void>
}
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<void> | null = null
constructor(private readonly _platform: NodeJS.Platform = process.platform) {}
async paste(): Promise<void> {
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<void> {
const nut = await this._ensureNut()
await nut.type(text)
}
private async _ensureNut(): Promise<NutKeyboard> {
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
}
}

View file

@ -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<ClipboardImage> {
text = ''
html = ''
rtf = ''
image: ClipboardImage = new FakeImage(true)
buffers = new Map<string, Buffer>()
/** 표준 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<ClipboardImage>): 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 = '<b>bold</b>'
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('<b>bold</b>')
expect(clip.rtf).toBe('{\\rtf1 bold}')
expect(clip.image).toBe(img)
})
it('HTML 만 있는 클립보드도 HTML 로 되돌린다', async () => {
const clip = new MemoryClipboard()
clip.html = '<i>only html</i>'
const { service } = makeService(clip)
await service.insertText('x')
expect(clip.html).toBe('<i>only html</i>')
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 = '<p>keep me</p>'
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('<p>keep me</p>')
})
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' })
})
})