클립보드 매니저나 Raycast 같은 런처는 항목을 고르면 창을 닫고 직전에 쓰던 앱의 입력칸에 내용을 넣습니다. Electron 에는 다른 앱으로 키 입력을 보내는 API 가 없습니다. webContents.sendInputEvent 는 자기 창에만 동작합니다. 그래서 macOS 에서는 작은 Swift 명령줄 도구를 앱에 함께 넣고 그 도구가 CGEventPost 로 ⌘V 를 보냅니다.
키를 보내는 코드는 열 줄 남짓입니다. 실패는 대부분 그 앞 단계에서 납니다. 붙여 넣을 대상을 잘못 기억하거나 대상이 앞으로 나오기 전에 ⌘V 가 나가면 로그에는 성공으로 남고 화면에는 아무것도 붙지 않습니다.
시작하기 전에
| 항목 | 조건 |
|---|---|
| 운영체제 | macOS. 아래 API 는 AppKit, ApplicationServices, CoreGraphics 기준 |
| 네이티브 도구 | Swift 로 만든 명령줄 실행 파일. Electron 메인 프로세스가 execFile 로 부르고 결과를 JSON 한 줄로 받음 |
| 권한 | 손쉬운 사용(Accessibility). 키 이벤트 합성과 다른 앱 창 조작에 필요 |
| 코드 서명 | 재빌드해도 바뀌지 않는 서명 인증서. ad-hoc 서명이면 빌드할 때마다 권한이 풀림 |
전체 흐름
붙여넣기 한 번은 다섯 단계를 차례로 거칩니다.
- 런처를 띄우기 직전에 최전면 앱과 그 앱의 포커스 창 제목을 기록합니다.
- 사용자가 항목을 고르면 Electron
clipboard로 내용을 시스템 클립보드에 씁니다. - 런처 창을 숨깁니다.
- 기록해 둔 앱을 활성화하고 같은 제목의 창을 앞으로 올립니다. 그 앱이 실제로 최전면이 될 때까지 기다립니다.
- CGEventPost 로 Command 키와 V 키를 순서대로 눌렀다 뗍니다.
각 단계의 대기 시간은 아래 값으로 맞췄습니다. 실제 앱에서 간헐 실패가 사라진 값이며 기기마다 조금씩 다를 수 있습니다.
| 구간 | 값 | 이유 |
|---|---|---|
| 대상 기록 상한 | 300ms | 네이티브 도구가 늦어도 런처가 늦게 뜨지 않게 함 |
| 런처 숨긴 뒤 | 150ms | 직전 앱의 키 포커스 복원 |
| 활성화 요청 뒤 | 180ms | 창 전환 애니메이션 도중 ⌘V 유실 방지 |
| 최전면 확인 | 최대 600ms, 40ms 간격 | 활성화는 비동기라 요청 직후 최전면이 바뀌어 있지 않음 |
| 최전면 확인 뒤 | 60ms | 포커스가 입력칸까지 내려갈 시간 |
| 키 이벤트 사이 | 12ms | 대상 앱이 수정자 키와 V 키의 순서를 처리할 시간 |
런처 창 설정
런처는 비활성 패널로 만듭니다. 일반 BrowserWindow 가 키 포커스를 받으려면 앱 전체가 활성화되어야 하는데, 그러면 메인 창이 함께 앞으로 나오고 화면이 다른 Space 로 넘어가면서 직전 앱도 비활성이 됩니다. 비활성 패널이라면 키 포커스만 가져가므로 직전 앱은 뒤에서 계속 활성 상태입니다.
예제: 비활성 패널 런처
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 오류로 빈 값이 나올 수 있습니다.
창 제목도 함께 기록합니다. 브라우저처럼 창이 여러 개인 앱은 앱만 활성화하면 마지막으로 쓴 다른 창이 앞으로 나올 수 있습니다.
예제: 최전면 앱과 포커스 창 제목 읽기
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 쪽에서는 기록이 끝난 뒤에 런처를 띄웁니다. 기록과 노출을 동시에 시작하면 기록 시점의 최전면이 런처로 잡힙니다. 기다리는 시간에는 상한을 둡니다.
예제: 런처를 띄우기 전에 대상 기록
async function toggleLauncher() {
const visible = launcherWindow?.isVisible() ?? false;
await Promise.race([
captureFocusReturn({ launcherVisible: visible }).catch(() => {}),
new Promise((r) => setTimeout(r, 300)),
]);
showLauncher();
}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 와 창 제목으로 활성화
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 를 보내면 붙여넣기가 될 때도 있고 안 될 때도 있습니다.
예제: 최전면이 될 때까지 대기
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 키 시퀀스와 이벤트 간격
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: 0x09 | ANSI 배열의 V 키. 문자 대신 물리 키 코드라 한글 입력 상태에서도 ⌘V 로 인식됨 |
| Command 키 down/up | V 이벤트에 .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 로 부르면 권한이 없는 동안 같은 창이 계속 뜹니다. 시스템 창은 프로세스당 한 번으로 제한합니다. 두 번째부터는 앱 안의 안내 토스트가 대신 뜹니다.
예제: 권한 창은 한 번만
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 가 매번 다른 앱으로 봅니다.
- 로그인 키체인에 코드 서명용 self-signed 인증서를 한 번 만듭니다.
- 로컬 배포 스크립트가 그 인증서로 앱을 서명하게 합니다.
- 이전 서명으로 남은 항목을 지웁니다.
- 앱을 실행해 권한을 한 번 다시 허용합니다.
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 활성화는 폴백으로만 남겼습니다.
더 보기
- CGEvent post(tap:): 이벤트를 넣는 위치별 차이
- NSWorkspace frontmostApplication: 최전면 앱 조회
- AXUIElement: 다른 앱 창의 속성 읽기와 동작 실행
- Electron BrowserWindow:
type: "panel"과paintWhenInitiallyHidden - Electron systemPreferences:
isTrustedAccessibilityClient