fix(text-insert): restore every clipboard format after paste and never wipe non-text clipboards
This commit is contained in:
parent
190b6db284
commit
0adedf5e7b
5 changed files with 704 additions and 113 deletions
|
|
@ -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
|
||||
|
|
|
|||
178
apps/desktop/src/main/services/text-insert/clipboard-snapshot.ts
Normal file
178
apps/desktop/src/main/services/text-insert/clipboard-snapshot.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
338
apps/desktop/tests/main/services/textinsert-redteam-r1-2.test.ts
Normal file
338
apps/desktop/tests/main/services/textinsert-redteam-r1-2.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue