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)
|
||||
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<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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue