다른 앱에 ⌘V 를 보내는 Electron 네이티브 도구
목차7

클립보드 매니저나 Raycast 같은 런처는 항목을 고르면 창을 닫고, 방금까지 쓰던 앱의 입력칸에 그 내용을 넣는다. 그런데 같은 걸 Electron 으로 만들려고 하면 첫 줄에서 막힌다. Electron 에는 다른 앱으로 키 입력을 보내는 API 가 없다. webContents.sendInputEvent 가 있지만 이 함수는 자기 창 안에서만 동작한다.
그래서 macOS 에서는 앱 안에 Swift 로 만든 작은 명령줄 실행 파일을 하나 넣는다. 앞으로 네이티브 도구라고 부를 이 파일을 Electron 메인 프로세스가 execFile 로 부르고 결과는 JSON 한 줄로 돌려받는다. ⌘V 를 실제로 누르는 쪽은 네이티브 도구이고, 누르는 방법은 CGEventPost 다. 쓰는 API 는 AppKit, ApplicationServices, CoreGraphics 쪽에 있다. 키 이벤트를 합성하고 다른 앱의 창을 만지려면 손쉬운 사용(Accessibility) 권한이 있어야 한다. 이 권한을 유지하려면 다시 빌드해도 바뀌지 않는 서명 인증서도 필요한데, ad-hoc 서명으로 두면 빌드할 때마다 권한이 풀린다. 서명 이야기는 뒤에서 다시 꺼낸다.
키를 보내는 방법은 이것으로 해결된다. 하지만 이것만으로 붙여넣기가 늘 성공하지는 않는다.
내가 만든 앱에는 ⌥V 로 여는 클립보드 기록 기능이 있다. 2026-07-16 부터 2026-07-19 까지 "붙여넣기가 되다 안 되다 함" 증상 하나를 고친 커밋이 10개를 넘었다. 원인은 한 곳에 있지 않았다. 대상 기록 순서, 최전면 대기, 키 합성 방식, 권한 요청 창에 따로따로 있었고 그중 앞의 두 곳은 키를 보내기도 전 단계였다. 대상 앱을 잘못 기록하거나 대상 앱이 최전면이 되기 전에 ⌘V 를 보내면 붙여넣기는 실패한다. 그래도 로그에는 성공이 남는다.
붙여넣기 한 번은 다음 순서로 진행된다.
- 런처를 띄우기 직전에 최전면 앱과 그 앱의 포커스 창 제목을 기록한다.
- 사용자가 항목을 고르면 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 에 띄울 때 프로세스 종류 전환을 막음. 옵션이 없으면 앱이 활성화되어 메인 창이 같이 뜸 |
Electron BrowserWindow 문서에서 type 의 panel 값과 paintWhenInitiallyHidden 을 찾아볼 수 있다.
런처를 숨기면 키 포커스는 대상 앱으로 돌아간다. 이때 app.hide() 까지 부르고 싶어지는데, 부르지 않는 편이 맞다. app.hide() 를 호출하면 대상 앱이 아닌 다른 앱이 최전면에 올라올 수 있다.
누구에게 돌아갈지는 언제 적어 둘까
돌아갈 곳은 런처를 띄우기 전에 적어 둬야 한다. 런처가 뜬 뒤에 최전면 앱을 조회하면 자기 앱이 결과로 나오기 때문이다. 이 순서가 네 원인 가운데 첫 번째였다.
진단 로그는 홈 폴더에 플래그 파일이 있을 때만 남기게 해 두었다. 대상 기록, 활성화, 키 합성 결과를 단계마다 한 줄씩 적는 로그였는데, 거기서 대상 앱 이름이 비어 있는 줄이 나왔다. 그때까지는 AppleScript 의 System Events 로 최전면 앱을 물었다. 이 조회는 빈 값과 함께 -1728 에러를 돌려줄 수 있고 그 에러는 런처가 screen-saver 레벨에 떠 있거나 창이 전환되는 중에 난다. 그 로그를 보고 조회를 NSWorkspace frontmostApplication 으로 바꿨다. NSWorkspace.shared.frontmostApplication 은 권한 없이 읽힌다.
앱 이름만 적어서는 부족하다. 브라우저처럼 창이 여러 개인 앱은 앱만 활성화하면 마지막으로 쓴 다른 창이 올라올 수 있어서 포커스 창의 제목도 함께 적는다.
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),
])
}손쉬운 사용 권한이 없으면 창 제목은 빈 문자열로 온다. 그럴 때는 앱 단위로만 돌아간다.
기록과 런처 띄우기를 동시에 시작하면 최전면 앱이 자기 앱으로 적히고 ⌘V 는 다른 앱에 붙는다. 그래서 메인 프로세스는 기록이 끝난 다음에 런처를 띄운다. 다만 네이티브 도구가 늦는다고 런처까지 늦게 뜨면 곤란하니 기록은 최대 300ms 까지만 기다린다.
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(),
};
}기록할 때 최전면 앱이 자기 앱이었다면 복귀 단계를 건너뛴다. ⌘V 는 자기 앱의 현재 입력칸에 들어간다. 앱 안의 메모 화면에서 런처를 열었을 때가 이 경우다.
다시 올렸는데 왜 아직 앞에 없을까
돌아갈 곳을 알았으니 이제 그 앱을 다시 올릴 차례다. 복귀는 프로세스 ID(pid)와 창 제목으로 한다. NSRunningApplication.activate() 로 대상 앱을 활성화하고 제목이 같은 창을 찾아 kAXRaiseAction 을 보낸다. 다른 앱 창의 속성을 읽고 동작을 실행하는 일은 AXUIElement 가 맡는다.
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])
}창 제목까지 쓰게 된 건 2026-08-06 이다. 그날 브라우저 창이 여러 개일 때 다른 창에 붙는 문제를 확인했고 pid 와 창 제목으로 창을 찾아 올리는 방식으로 고쳤다. 앱 단위 AppleScript 활성화는 폴백으로만 남겼다.
창을 올린 뒤에도 확인할 것이 있다. activate() 는 활성화를 요청한 즉시 돌아온다. 활성화는 비동기라서 함수가 돌아온 순간 대상 앱이 최전면이라는 보장이 없다. 이걸 확인하지 않고 ⌘V 를 보내면 붙여넣기가 간헐적으로 실패하는데, 될 때도 있고 안 될 때도 있는 증상이 여기서 생긴다. 두 번째 원인인 최전면 대기가 여기다.
그래서 최전면 앱이 바뀔 때까지 40ms 간격으로 다시 묻고 최대 600ms 까지 기다린다. 확인이 끝나도 포커스가 입력칸까지 내려가려면 시간이 드니 60ms 를 더 쉰다.
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();
}paste 함수의 순서는 앞의 대기 시간 표와 같다. 클립보드에 쓰고, 런처를 숨기고, 150ms 를 쉬고, 활성화 요청 뒤 180ms, 최전면 확인, 다시 60ms. 키는 그다음이다.
키 네 개를 어떤 순서로 누를까
이제야 키를 보낸다. ⌘V 하나에 이벤트가 네 개 든다. Command 누름, V 누름, V 뗌, 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 | 하드웨어 입력과 같은 위치에 이벤트를 넣음. 현재 최전면 앱의 키 포커스로 전달됨 |
이벤트를 넣는 위치마다 무엇이 다른지는 CGEvent post(tap:) 문서에 정리되어 있다.
세 번째 원인인 키 합성 방식은 이 표의 두 번째 줄에 있었다. AppleScript 의 System Events 로도 keystroke "v" using command down 이라고 쓰면 같은 키 입력이 간다. 다만 AppleScript keystroke 의 결과는 자동화(Automation) 권한과 실행 맥락에 따라 달라지고 종료 코드가 0 이어도 붙여넣기는 실패할 수 있다. 그래서 이 방식을 빼고 네이티브 키 합성으로 옮겼는데, 옮긴 뒤에도 "로그는 매번 ok, 첫 번째만 붙음" 상태가 남았다.
로그가 매번 ok 였던 데는 이유가 있다. AppleScript 와 CGEventPost 는 둘 다 이벤트를 보낸 시점에 성공을 돌려줄 뿐, 대상 앱이 그 이벤트를 받았는지는 알려 주지 않는다. V 이벤트에 Command 플래그만 얹어 보낸 것이 원인이었다. 수정자 상태가 원래대로 돌아오지 않아 첫 번째만 붙었다. Command 키를 따로 눌렀다 떼게 바꾸자 두 번째부터도 붙었다.
그래서 기본 경로는 CGEventPost 로 두었다. 네이티브 도구가 없거나 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 데이터베이스에 저장하고 TCC 는 앱을 코드 서명으로 구분한다. ad-hoc 서명은 빌드할 때마다 코드 해시가 바뀐다. 그래서 TCC 는 다시 빌드한 앱을 매번 다른 앱으로 판단한다.
고정 인증서로 옮기려면 이렇게 한다.
- 로그인 키체인에 코드 서명용 self-signed 인증서를 한 번 만든다.
- 로컬 배포 스크립트의 서명 인증서를 1단계 인증서로 지정한다.
- 아래 명령으로 이전 서명의 권한 항목을 지운다.
- 앱을 실행한다.
- 손쉬운 사용 권한을 다시 허용한다.
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 앱이 다른 앱에 붙여 넣지 못하는 이유는 Electron 이 보내는 키 입력이 자기 창 밖으로 나가지 않기 때문이다. 이 부분은 Swift 로 만든 네이티브 도구와 CGEventPost 로 해결했다.
커밋 10개 넘게 고친 것은 나머지 네 가지였다. 돌아갈 앱을 런처보다 먼저 기록하기, 그 앱이 최전면이 될 때까지 기다리기, Command 키를 따로 눌렀다 떼기, 권한 창을 한 번만 띄우기다. 앞의 셋은 틀려도 로그에 성공이 남는다.
표에 적은 ms 값은 내 기기에서 맞았던 값이다. 다른 Mac 에서 같은 값이 맞는지는 아직 확인하지 않았다.