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:)는 그 컨텍스트 설정으로 페이지를 만들고 스크립트 앞에 호환 레이어를 세워. 그 셋 중 프래그먼트가 왜 중요했는지는 다음 레슨에서 볼 거야.