본문 바로가기
C.W.K.
Lesson 03 of 05 · published

네이티브 메시징과 띄우기 규칙

~14 min · 네이티브 메시징, posix-spawn, 프로세스 그룹, 확장

Level 0새 탭
0 XP0/44 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

Chrome 방식의 네이티브 메시징

확장은 혼자서 데스크톱 앱에 소켓을 못 열어. Chrome이 주는 건 더 좁은 문이야. 데스크톱 앱이 브라우저의 NativeMessagingHosts 폴더에 호스트 매니페스트라는 작은 JSON을 등록해. 거기엔 실행할 프로그램과 stdio 타입, 그리고 닿아도 되는 확장 출처가 chrome-extension://<id>/ 꼴로 적혀. 확장이 그 호스트 이름으로 connectNative를 부르면, 브라우저는 매니페스트를 찾아 확장 출처를 목록과 대 봐. 통과하면 그 출처 하나만 인자로 넘겨서 프로그램을 띄워. 그 뒤로는 프로그램의 표준 입출력으로 JSON을 주고받는데, 메시지마다 앞에 길이를 리틀엔디언 32비트 정수로 붙여.

Voyager도 딱 그렇게 말해. 레지스트리는 Chrome과 Chromium이 보는 폴더를 사용자별로도, 시스템 전체로도 똑같이 봐. 그래서 이미 Chrome과 말하는 데스크톱 앱은 Voyager와 말하려고 새로 할 게 없어. 호스트 이름엔 글자, 숫자, 점, 밑줄만 받아서 이름으로 그 폴더 밖을 가리킬 수가 없어. 레슨 1의 선택이 여기서 제값을 해. 매니페스트는 확장을 Chrome id로 허용하고, Voyager는 그 id를 지켰거든.

첫째 코드 블록이 프레이밍이야. 훔쳐 갈 만한 게 둘 있어. 나가는 메시지는 4 MB를 넘으면 프레임을 짜기 전에 거절하고, 들어오는 메시지는 1 MB를 넘으면 4바이트 길이가 도착하자마자 거절해. 나머지를 기다리지도, 메시지를 떼어 내지도 않아. 그렇다고 할당을 하나도 안 한 건 아니야. 나가는 JSON은 크기를 잴 때 이미 직렬화돼 있고, 들어오는 쪽도 파이프가 넘겨준 바이트는 벌써 버퍼에 쌓여 있어. 상한이 막는 건 너무 큰 프레임을 짓는 일과 거대한 메시지를 끝까지 기다리는 일이야. 들어오는 길이가 상한을 넘으면 연결도 끝나. 그리고 언프레이밍 주석에 Foundation 함정이 하나 적혀 있어. Data는 앞을 잘라 내면 인덱스가 0에서 시작하지 않아서, 모든 인덱스를 startIndex에서 계산해.

1분마다 오는 거절

2026-10-03 오후쯤엔 1Password 헬퍼가 이미 Voyager 서명을 받아들인 뒤였어(그 얘긴 트랙 2의 레슨 5에 있고, 서명 자체는 트랙 9가 설명해). 그래서 브리지가 Chrome처럼 앱 안에서 헬퍼를 띄웠어. 그래도 거절당했어. 확장을 설치한 그 분부터 1분마다 헬퍼 하나가 거절당했고, 하나하나 13 ms쯤 만에 죽으면서 로그 파일을 하나씩 남겼어.

거절 문구는 호스트가 not part of the main browser's tree라는 거였어. 주 브라우저의 트리에 속하지 않는다는 말이지. 헬퍼의 브라우저 검증 코드를 역어셈블하고, 죽어 가는 헬퍼의 부모를 실시간으로 잡아 보니 "tree"가 뭔지 나왔어. 프로세스 그룹이야. 헬퍼는 자기 getpgrp()를 브라우저 프로세스의 그룹과 비교해서 다르면 거절해. Foundation의 Process는 자식마다 그룹을 새로 줘. 그 Mac에서 재 보니 Process 자식의 그룹 id는 자기 pid였고, posix_spawn 자식은 부모 걸 물려받았어. 같은 Mac에서 살아 있던 Chrome과 Comet의 헬퍼는 자기 브라우저의 그룹 id를 달고 있었고.

첫 규칙이 다가 아니었어. 실험 하나로 Voyager 바이너리를 중계 자식으로 다시 띄워서, 브라우저 그룹에 들어간 다음 그 밑에서 호스트를 띄우게 했어. 거절이 다른 줄로 옮겨 갔어. required parent browser path was not available, 있어야 할 부모 브라우저 경로가 없다는 거야. 이게 둘째 규칙이야. 헬퍼의 직접 부모가 Launch Services가 이름을 댈 수 있는 브라우저여야 해서, 중간에 누가 끼어서 파이프를 쥘 수가 없어. 중계는 계측기로서 할 일을 했어. 질문 하나에 답했고, 실제로 쓰는 길은 중계를 안 거쳐.

고친 건 Chrome 모양 그대로야. 앱 프로세스가 평범한 posix_spawn으로 호스트를 띄우고, 그룹을 따로 달라고 안 해. 둘째 코드 블록이 그 호출이고, 잰 결과가 주석에 있어. 이렇게 띄우자 헬퍼 로그에 Voyager를 검증한다는 줄이 찍혔고, 이어서 파트너 승인을 기다린다는 줄도 찍혔어. 1분마다 오던 거절도 사라졌고.

포트가 적는 것

비밀번호 관리자로 가는 네이티브 포트엔 그 관리자의 프로토콜이 실려. 그래서 포트는 누가 보냈는지와 몇 바이트인지만 적고 내용은 절대 안 적어. 호스트 주석이 딱 잘라 말해. 프로토콜 본문엔 첫 답장부터 비밀이 들어 있을 수 있다고. 호스트 프로그램의 표준 에러도 /dev/null로 보내. 헬퍼가 답했는지 보는 데는 방향과 크기면 충분했고, 로그에 그 이상이 필요했던 적은 없어.

반대 방향, chrome-extension:// 주소

네이티브 메시징은 확장을 데스크톱 앱까지 데려가. 페어링엔 반대 방향도 필요해. 1Password의 Add browser는 고른 브라우저에서 확장의 앱 페이지를 chrome-extension:// 주소로 열어. WebKit은 확장 페이지를 자기 webkit-extension 스킴으로, 컨텍스트마다 만드는 기본 URL 아래에서 내줘. 그러니 Voyager가 통역해야 해. 벽이 셋 있었고, 그날 하나씩 재서 치웠어. 첫째, 앱이 열 때 주소를 버렸어. 둘째, 엔진의 스킴 목록이 webkit-extension을 취소했어. 셋째, WebKit은 그 컨텍스트 고유의 webViewConfiguration으로 만든 웹 뷰에만 확장 페이지를 내줘. 평범한 설정이면 NSURLError -1008로 거절이야.

이제 pageURL(for:)가 주소의 호스트로 설치된 컨텍스트를 찾아. 여기서도 호스트는 Chrome id야. 찾으면 컨텍스트 기본 URL에 경로, 쿼리, 프래그먼트를 그대로 옮겨 붙여. makePage(forExtensionPageAt:)는 그 컨텍스트 설정으로 페이지를 만들고 스크립트 앞에 호환 레이어를 세워. 그 셋 중 프래그먼트가 왜 중요했는지는 다음 레슨에서 볼 거야.

Code

cwkVoyager, Sources/VoyagerCore/NativeMessaging.swift: Chrome 프레이밍, 리틀엔디언 길이와 JSON 바이트 (원문 그대로, 76-102행)·swift
/// Chrome's framing: a little-endian UInt32 length, then the JSON bytes.
public enum NativeMessageFraming {
    public static let maximumIncoming = 1024 * 1024
    public static let maximumOutgoing = 4 * 1024 * 1024

    public static func frame(_ json: Data) throws -> Data {
        guard json.count <= maximumOutgoing else { throw NativeMessagingError.tooLarge(json.count) }
        var length = UInt32(json.count).littleEndian
        var frame = Data(bytes: &length, count: 4)
        frame.append(json)
        return frame
    }

    /// Takes every whole message off the front of `buffer`. Data's indices
    /// are not zero-based after a removal, so every index is relative.
    public static func unframe(_ buffer: inout Data) throws -> [Data] {
        var messages: [Data] = []
        while buffer.count >= 4 {
            let start = buffer.startIndex
            let length = buffer[start..<(start + 4)].withUnsafeBytes { Int(UInt32(littleEndian: $0.loadUnaligned(as: UInt32.self))) }
            guard length <= maximumIncoming else { throw NativeMessagingError.tooLarge(length) }
            guard buffer.count >= 4 + length else { break }
            messages.append(Data(buffer[(start + 4)..<(start + 4 + length)]))
            buffer = Data(buffer[(start + 4 + length)...])
        }
        return messages
    }
cwkVoyager, Sources/VoyagerCore/NativeMessaging.swift: 그룹을 따로 만들지 않고 브라우저의 프로세스 그룹 안에서 호스트 띄우기 (원문 그대로, 152-175행)·swift
    public func start() throws {
        let launch = launch
        let argv: [UnsafeMutablePointer<CChar>?] = [strdup(launch.program)] + launch.arguments.map { strdup($0) } + [nil]
        defer { for case let argument? in argv { free(argument) } }
        var actions: posix_spawn_file_actions_t? = nil
        posix_spawn_file_actions_init(&actions)
        posix_spawn_file_actions_adddup2(&actions, input.fileHandleForReading.fileDescriptor, 0)
        posix_spawn_file_actions_adddup2(&actions, output.fileHandleForWriting.fileDescriptor, 1)
        posix_spawn_file_actions_adddup2(&actions, FileHandle.nullDevice.fileDescriptor, 2)
        var attributes: posix_spawnattr_t? = nil
        posix_spawnattr_init(&attributes)
        // No POSIX_SPAWN_SETPGROUP on purpose: the host must stay in the
        // browser's process group. 1Password's helper refuses a host that
        // left it ("not part of the main browser's tree", apple.rs:133), and
        // Foundation's Process puts every child in a group of its own —
        // measured 2026-10-03, the reason every host it started was refused.
        let spawned = argv.withUnsafeBufferPointer { buffer in
            posix_spawn(&pid, launch.program, &actions, &attributes, buffer.baseAddress, nil)
        }
        posix_spawn_file_actions_destroy(&actions)
        posix_spawnattr_destroy(&attributes)
        guard spawned == 0, pid > 0 else {
            throw NativeMessagingError.couldNotStart("posix_spawn answered \(spawned)")
        }

External links

Exercise

표준 입력에서 4바이트 리틀엔디언 길이와 JSON 메시지를 읽고, 같은 프레이밍으로 메아리를 돌려주는 작은 네이티브 메시징 호스트를 써 봐. 그걸 작은 macOS 프로그램에서 두 번 띄워. 한 번은 Foundation의 Process로, 한 번은 posix_spawn으로. 호스트가 답장에 자기 프로세스 그룹 id와 부모 pid를 담게 하고, 둘을 부모 프로그램의 프로세스 그룹과 비교해.
Hint
Python 호스트라면 os.getpgrp()와 os.getppid()가 그 두 숫자야. 길이는 struct.unpack('<I', raw)로 읽어. 셸에선 ps -o pid,ppid,pgid,comm이 돌고 있는 프로세스의 같은 사실을 보여 줘. 띄울 때 속성에 POSIX_SPAWN_SETPGROUP을 안 넣으면 posix_spawn 자식은 부모 그룹에 남아.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.