Electron 앱에서 CGEventPost 로 다른 앱에 붙여넣기

Electron 런처나 클립보드 매니저에서 고른 항목을 직전에 쓰던 앱에 붙여 넣으려면 대상 창을 기록해 두었다가 되돌린 뒤 CGEventPost 로 ⌘V 를 합성합니다.

클립보드 매니저나 Raycast 같은 런처는 항목을 고르면 창을 닫고 직전에 쓰던 앱의 입력칸에 내용을 넣습니다. Electron 에는 다른 앱으로 키 입력을 보내는 API 가 없습니다. webContents.sendInputEvent 는 자기 창에만 동작합니다. 그래서 macOS 에서는 작은 Swift 명령줄 도구를 앱에 함께 넣고 그 도구가 CGEventPost 로 ⌘V 를 보냅니다.

키를 보내는 코드는 열 줄 남짓입니다. 실패는 대부분 그 앞 단계에서 납니다. 붙여 넣을 대상을 잘못 기억하거나 대상이 앞으로 나오기 전에 ⌘V 가 나가면 로그에는 성공으로 남고 화면에는 아무것도 붙지 않습니다.

시작하기 전에

항목조건
운영체제macOS. 아래 API 는 AppKit, ApplicationServices, CoreGraphics 기준
네이티브 도구Swift 로 만든 명령줄 실행 파일. Electron 메인 프로세스가 execFile 로 부르고 결과를 JSON 한 줄로 받음
권한손쉬운 사용(Accessibility). 키 이벤트 합성과 다른 앱 창 조작에 필요
코드 서명재빌드해도 바뀌지 않는 서명 인증서. ad-hoc 서명이면 빌드할 때마다 권한이 풀림

전체 흐름

붙여넣기 한 번은 다섯 단계를 차례로 거칩니다.

  1. 런처를 띄우기 직전에 최전면 앱과 그 앱의 포커스 창 제목을 기록합니다.
  2. 사용자가 항목을 고르면 Electron clipboard 로 내용을 시스템 클립보드에 씁니다.
  3. 런처 창을 숨깁니다.
  4. 기록해 둔 앱을 활성화하고 같은 제목의 창을 앞으로 올립니다. 그 앱이 실제로 최전면이 될 때까지 기다립니다.
  5. CGEventPost 로 Command 키와 V 키를 순서대로 눌렀다 뗍니다.

각 단계의 대기 시간은 아래 값으로 맞췄습니다. 실제 앱에서 간헐 실패가 사라진 값이며 기기마다 조금씩 다를 수 있습니다.

구간값이유
대상 기록 상한300ms네이티브 도구가 늦어도 런처가 늦게 뜨지 않게 함
런처 숨긴 뒤150ms직전 앱의 키 포커스 복원
활성화 요청 뒤180ms창 전환 애니메이션 도중 ⌘V 유실 방지
최전면 확인최대 600ms, 40ms 간격활성화는 비동기라 요청 직후 최전면이 바뀌어 있지 않음
최전면 확인 뒤60ms포커스가 입력칸까지 내려갈 시간
키 이벤트 사이12ms대상 앱이 수정자 키와 V 키의 순서를 처리할 시간

런처 창 설정

런처는 비활성 패널로 만듭니다. 일반 BrowserWindow 가 키 포커스를 받으려면 앱 전체가 활성화되어야 하는데, 그러면 메인 창이 함께 앞으로 나오고 화면이 다른 Space 로 넘어가면서 직전 앱도 비활성이 됩니다. 비활성 패널이라면 키 포커스만 가져가므로 직전 앱은 뒤에서 계속 활성 상태입니다.

예제: 비활성 패널 런처
electron/launcher.cjs
const win = new BrowserWindow({
  width: 750,
  height: 480,
  frame: false,
  show: false,
  skipTaskbar: true,
  type: "panel", // NSWindowStyleMaskNonactivatingPanel 대응
  webPreferences: {
    preload: path.join(__dirname, "preload.cjs"),
    paintWhenInitiallyHidden: true, // 첫 노출 때 검은 빈 창 방지
  },
});

win.setAlwaysOnTop(true, "screen-saver");
win.setVisibleOnAllWorkspaces(true, {
  visibleOnFullScreen: true,
  skipTransformProcessType: true, // 노출 순간 앱 활성화와 Space 전환 억제
});
옵션설명
type: "panel"macOS 에서 비활성 패널로 생성. 앱 활성화 없이 키 포커스를 받음
paintWhenInitiallyHidden숨김 상태에서도 렌더링 유지. 빠지면 패널이 처음 뜰 때 검은 창으로 보임
skipTransformProcessType모든 Space 에 띄울 때 프로세스 종류 전환을 막음. 빠지면 앱이 강제로 활성화되어 메인 창이 같이 뜸

비활성 패널은 숨기기만 해도 직전 앱의 포커스가 돌아옵니다. app.hide() 를 함께 부르면 엉뚱한 앱이 앞으로 나올 수 있으므로 부르지 않습니다.

붙여넣을 대상 기록

대상 앱은 런처를 띄우기 전에 기록합니다. 런처가 뜬 다음에 물으면 최전면 앱이 런처 자신으로 나옵니다.

최전면 앱은 NSWorkspace.shared.frontmostApplication 으로 읽습니다. 이 값은 권한 없이 읽을 수 있습니다. AppleScript 의 System Events 로 같은 값을 물으면 런처 패널이 screen-saver 레벨에 떠 있거나 창이 전환되는 중에 -1728 오류로 빈 값이 나올 수 있습니다.

창 제목도 함께 기록합니다. 브라우저처럼 창이 여러 개인 앱은 앱만 활성화하면 마지막으로 쓴 다른 창이 앞으로 나올 수 있습니다.

예제: 최전면 앱과 포커스 창 제목 읽기
Sources/helper/main.swift
import AppKit
import ApplicationServices

func focusedWindowTitle(pid: pid_t) -> String {
    let axApp = AXUIElementCreateApplication(pid)
    var focused: CFTypeRef?
    guard AXUIElementCopyAttributeValue(axApp, kAXFocusedWindowAttribute as CFString, &focused) == .success,
          let window = focused else { return "" }
    var title: CFTypeRef?
    guard AXUIElementCopyAttributeValue(window as! AXUIElement, kAXTitleAttribute as CFString, &title) == .success,
          let s = title as? String else { return "" }
    return s
}

func runFrontmost() {
    guard let app = NSWorkspace.shared.frontmostApplication else { fail("프런트 앱 없음") }
    emit([
        "ok": true,
        "name": app.localizedName ?? "",
        "bundle": app.bundleIdentifier ?? "",
        "pid": app.processIdentifier,
        "windowTitle": focusedWindowTitle(pid: app.processIdentifier),
    ])
}

창 제목은 손쉬운 사용 권한이 없으면 빈 문자열입니다. 이때는 앱 단위 복귀로 넘어갑니다.

Electron 쪽에서는 기록이 끝난 뒤에 런처를 띄웁니다. 기록과 노출을 동시에 시작하면 기록 시점의 최전면이 런처로 잡힙니다. 기다리는 시간에는 상한을 둡니다.

예제: 런처를 띄우기 전에 대상 기록
electron/launcher.cjs
async function toggleLauncher() {
  const visible = launcherWindow?.isVisible() ?? false;
  await Promise.race([
    captureFocusReturn({ launcherVisible: visible }).catch(() => {}),
    new Promise((r) => setTimeout(r, 300)),
  ]);
  showLauncher();
}
electron/clipboardHistory.cjs
async function captureFocusReturn({ launcherVisible }) {
  // 런처가 이미 떠 있으면 최전면이 런처라 직전 대상 유지
  if (launcherVisible) return;
  const f = await frontApp();
  const self = /electron|myapp/i.test(f.bundle) || /electron|myapp/i.test(f.name);
  pasteReturn = {
    bundle: self ? "" : f.bundle,
    pid: self ? 0 : f.pid,
    windowTitle: self ? "" : f.windowTitle,
    self,
    at: Date.now(),
  };
}

기록한 앱이 자기 앱이면 복귀하지 않고 현재 입력칸에 바로 붙여 넣습니다. 앱 안의 메모 화면에서 런처를 열었을 때가 이 경우입니다.

대상 창으로 복귀

복귀는 프로세스 ID 와 창 제목으로 합니다. NSRunningApplication.activate() 로 앱을 활성화하고 제목이 같은 창에 kAXRaiseAction 을 보냅니다.

예제: pid 와 창 제목으로 활성화
Sources/helper/main.swift
func runActivate(pid: pid_t, title: String) {
    guard let app = NSRunningApplication(processIdentifier: pid) else { fail("프로세스 없음") }
    let activated = app.activate()
    var raised = false
    if !title.isEmpty {
        let axApp = AXUIElementCreateApplication(pid)
        var value: CFTypeRef?
        if AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &value) == .success,
           let windows = value as? [AXUIElement] {
            for window in windows {
                var t: CFTypeRef?
                guard AXUIElementCopyAttributeValue(window, kAXTitleAttribute as CFString, &t) == .success,
                      let s = t as? String, s == title else { continue }
                AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, kCFBooleanTrue)
                AXUIElementPerformAction(window, kAXRaiseAction as CFString)
                raised = true
                break
            }
        }
    }
    emit(["ok": activated, "raised": raised])
}

activate() 는 요청만 하고 바로 돌아옵니다. 대상이 실제로 최전면이 됐는지는 따로 확인합니다. 확인 없이 ⌘V 를 보내면 붙여넣기가 될 때도 있고 안 될 때도 있습니다.

예제: 최전면이 될 때까지 대기
electron/clipboardHistory.cjs
async function waitFrontmost(bundle, timeoutMs) {
  const target = bundle.toLowerCase();
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const f = await frontApp();
    if (f.bundle.toLowerCase() === target) return true;
    await sleep(40);
  }
  return false;
}

async function paste(id) {
  writeToClipboard(id);
  hideLauncher();
  await sleep(150);
  const reactivated = await reactivateReturnApp(); // 활성화 요청 뒤 180ms 대기 포함
  if (reactivated) {
    await waitFrontmost(pasteReturn.bundle, 600);
    await sleep(60);
  }
  return keystrokePaste();
}

CGEventPost 로 ⌘V 합성

키 이벤트는 네 개를 따로 만들어 보냅니다. Command 누름, V 누름, V 뗌, Command 뗌 순서입니다.

예제: Command 키 시퀀스와 이벤트 간격
Sources/helper/main.swift
func runPaste() {
    guard AXIsProcessTrusted() else { fail("손쉬운 사용 권한 필요") }
    let src = CGEventSource(stateID: .combinedSessionState)
    let vKey: CGKeyCode = 0x09   // ANSI V
    let cmdKey: CGKeyCode = 0x37 // 왼쪽 Command
    guard let cmdDown = CGEvent(keyboardEventSource: src, virtualKey: cmdKey, keyDown: true),
          let vDown = CGEvent(keyboardEventSource: src, virtualKey: vKey, keyDown: true),
          let vUp = CGEvent(keyboardEventSource: src, virtualKey: vKey, keyDown: false),
          let cmdUp = CGEvent(keyboardEventSource: src, virtualKey: cmdKey, keyDown: false) else {
        fail("키 이벤트 생성 실패")
    }
    vDown.flags = .maskCommand
    vUp.flags = .maskCommand
    let gap: useconds_t = 12_000
    for event in [cmdDown, vDown, vUp, cmdUp] {
        event.post(tap: .cghidEventTap)
        usleep(gap)
    }
    emit(["ok": true])
}
부분설명
virtualKey: 0x09ANSI 배열의 V 키. 문자 대신 물리 키 코드라 한글 입력 상태에서도 ⌘V 로 인식됨
Command 키 down/upV 이벤트에 .maskCommand 만 얹으면 첫 번째는 붙고 두 번째부터는 붙지 않음. Command 키를 실제로 눌렀다 떼어 수정자 상태를 원래대로 남김
usleep(12_000)네 이벤트를 간격 없이 보내면 대상 앱이 ⌘V 조합으로 읽지 못할 때가 있음
.cghidEventTap하드웨어 입력과 같은 위치에 이벤트를 넣음. 현재 최전면 앱의 키 포커스로 전달됨

AppleScript 의 tell application "System Events" to keystroke "v" using command down 도 같은 일을 합니다. 다만 자동화(Automation) 권한과 실행 맥락의 영향을 받아서 종료 코드 0 으로 끝나도 실제로는 붙지 않는 경우가 있었습니다. CGEventPost 를 먼저 쓰고 네이티브 도구가 없거나 실패할 때만 AppleScript 로 넘어갑니다.

손쉬운 사용 권한

권한은 붙여넣기 전에 확인합니다. Electron 에서는 systemPreferences.isTrustedAccessibilityClient(prompt) 를 씁니다. prompt 가 true 이고 권한이 없으면 macOS 가 시스템 설정 창을 띄웁니다.

매 붙여넣기마다 prompt 를 true 로 부르면 권한이 없는 동안 같은 창이 계속 뜹니다. 시스템 창은 프로세스당 한 번으로 제한합니다. 두 번째부터는 앱 안의 안내 토스트가 대신 뜹니다.

예제: 권한 창은 한 번만
electron/clipboardHistory.cjs
const { systemPreferences } = require("electron");

let accessibilityPrompted = false;

function ensureAccessibility() {
  if (systemPreferences.isTrustedAccessibilityClient(false)) return true;
  if (!accessibilityPrompted) {
    accessibilityPrompted = true;
    return systemPreferences.isTrustedAccessibilityClient(true);
  }
  return false;
}

권한을 허용했는데도 다시 빌드할 때마다 풀린다면 서명을 확인합니다. macOS 의 개인정보 보호 권한 데이터베이스(TCC)는 앱을 코드 서명으로 구분합니다. ad-hoc 서명은 빌드할 때마다 코드 해시가 바뀌므로 TCC 가 매번 다른 앱으로 봅니다.

  1. 로그인 키체인에 코드 서명용 self-signed 인증서를 한 번 만듭니다.
  2. 로컬 배포 스크립트가 그 인증서로 앱을 서명하게 합니다.
  3. 이전 서명으로 남은 항목을 지웁니다.
  4. 앱을 실행해 권한을 한 번 다시 허용합니다.
터미널
tccutil reset Accessibility com.example.myapp

인증서가 같으면 앱의 지정 요구 사항(designated requirement)이 바뀌지 않습니다. 그 뒤로는 다시 빌드해도 권한이 유지됩니다.

문제 해결

증상원인해결
런처가 뜨면서 메인 창이 같이 나오고 다른 Space 로 넘어감일반 창이라 키 포커스를 받을 때 앱 전체가 활성화됨type: "panel" 과 skipTransformProcessType: true
패널이 처음 뜰 때 검은 창숨김 패널에 GPU backing 이 없음paintWhenInitiallyHidden: true
패널은 떴는데 Enter 와 화살표가 듣지 않음다른 앱이 활성인 상태라 key window 확보가 노출과 겹침노출 뒤 40ms, 120ms 에 win.focus() 와 webContents.focus() 를 한 번 더 호출
엉뚱한 앱에 붙음대상 기록이 런처 노출과 동시에 실행되어 최전면이 런처로 잡힘기록을 마친 뒤 노출. 런처가 이미 떠 있으면 기록하지 않음
대상 앱 이름이 빈 값AppleScript System Events 질의가 -1728 로 실패NSWorkspace.shared.frontmostApplication
같은 앱의 다른 창에 붙음앱 단위 활성화는 마지막으로 쓴 창을 올림pid 로 활성화한 뒤 창 제목이 같은 창에 kAXRaiseAction
될 때도 있고 안 될 때도 있음활성화 요청 직후 대상이 아직 최전면이 아님최전면 확인 폴링 뒤 60ms 대기
로그는 성공인데 붙지 않음AppleScript keystroke 가 자동화 권한과 맥락에 영향을 받음CGEventPost 우선, AppleScript 는 폴백
첫 붙여넣기만 되고 두 번째부터 안 됨V 이벤트에 Command 플래그만 얹어 수정자 상태가 꼬임Command 키 down/up 을 따로 보냄
두 번째 이후 간헐적으로 안 됨네 이벤트를 간격 없이 보냄이벤트 사이 12ms
붙여넣을 때마다 권한 창이 뜸매번 prompt: true 로 권한 확인시스템 창은 프로세스당 한 번
재배포마다 권한이 풀림ad-hoc 서명으로 코드 해시가 바뀜고정 인증서로 서명한 뒤 tccutil reset 으로 이전 항목 정리

실무 메모

개인 작업용 Electron 데스크톱 앱에 ⌥V 클립보드 기록을 붙이면서 확인한 내용입니다.

  • 한 증상에 원인이 여럿이었습니다: 2026-07-16 부터 2026-07-19 까지 "붙여넣기가 되다 안 되다 함" 한 가지 증상을 고친 커밋이 열 개를 넘었습니다. 대상 기록 순서, 최전면 대기, 키 합성 방식, 권한 창이 각각 따로 실패했습니다.
  • 진단 로그를 파일 플래그로 켰습니다: 홈 폴더에 플래그 파일이 있을 때만 대상 기록, 활성화, 키 합성 결과를 한 줄씩 남겼습니다. 대상 앱 이름이 비어 있는 로그를 보고 AppleScript 질의를 NSWorkspace 로 바꿨습니다.
  • 성공 응답은 도착을 보장하지 않습니다: AppleScript 와 CGEventPost 모두 이벤트를 보낸 시점에 성공을 돌려줍니다. 대상 앱이 실제로 받았는지는 알려 주지 않습니다. 네이티브 키 합성으로 바꾼 뒤에도 "로그는 매번 ok, 첫 번째만 붙음" 상태가 남아 있었고 Command 키 시퀀스로 고쳤습니다.
  • 창 단위 복귀는 나중에 붙였습니다: 2026-08-06 에 브라우저 창이 여러 개일 때 다른 창에 붙는 문제를 보고 pid 와 창 제목 복귀를 추가했습니다. 앱 단위 AppleScript 활성화는 폴백으로만 남겼습니다.

더 보기