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

첫 벽돌 전의 게이트, 그리고 공개 API만

~14 min · 엔진 게이트, 공개 API, 유저 에이전트, 테스트

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

결정도 확인을 받아야 해

아빠의 "웹키트로 가자"로 엔진은 정해졌어. 그렇다고 아빠한테 필요한 브라우저를 그 위에 진짜로 지을 수 있는지까지 정해진 건 아니야. 그래서 계획은 껍데기 코드를 쓰기 전에 0단계(Phase 0)를 뒀어. Mac에서 케이스 열한 개짜리 게이트를 돌리는, 쓰고 버릴 스파이크야. 케이스마다 칸이 셋이야. 무엇을 보는지, 언제 통과인지, 실패하면 무슨 뜻인지. 대부분의 계획이 빼먹는 게 셋째 칸인데, 테스트 목록을 게이트로 바꾸는 게 바로 그 칸이야. 어떤 실패가 계획을 바꾸는지 미리 정해 두는 거지.

내려받을 것도 없었어. 엔진이 이미 Mac에 있었으니까. 그것만으로도 WebKit 편을 드는 작은 근거야.

케이스 열한 개와 그 결과

케이스2026-10-03 결과
W1 1PasswordChrome 패키지가 매니페스트 오류 없이 올라와. 설치된 그대로면 백그라운드 스크립트가 WebKit에 정의되지 않은 첫 Chrome 호출에서 멈춰. 빠진 이름들에 대역을 세워 주면 시작해서 데스크톱 앱을 찾아. 1Password 데스크톱 앱은 앞선 서명을 전부 거절했고, 설치된 번들이 유료 팀의 Apple Development 아이덴티티와 hardened runtime으로 서명된 뒤에야 Voyager를 연결된 브라우저로 올렸어. 페어링, 팝업, 자동 채우기는 미결로 남았어. 그 이야기는 확장 트랙에서 해
W2 다른 확장아빠가 쓰는 다른 확장 셋은 시작해. 그중 하나는 WebKit이 정의해 둔 멤버 두 개를 가려 준 뒤에야 시작했어. debugger와 tabGroups로 페이지를 조종하는 어시스턴트 확장은 예상대로 실패했어
W3 Google 로그인, W4 봇 검사Safari 유저 에이전트로 페이지가 막힘이나 챌린지 없이 열려. 로그인 자체는 설치된 앱에서 사람이 직접 해 봤고, 흉내 낸 게 아니야. 통과했어
W5 미디어H.264/AAC, HEVC, VP9, AV1, Opus가 다 재생돼. YouTube도 재생됐어. 페이지에서 FairPlay는 쓸 수 있고 Widevine은 없어
W6 신뢰마이크 요청이 프롬프트 전에 출처와 함께 호스트로 오고, 거절하면 그걸로 끝이야. HTTPS 가족 이름과 평문 http 루프백은 보안 컨텍스트야. 평문 http 테일넷 이름은 아니라서 아예 물어볼 수조차 없어
W7 아이덴티티쿠키와 저장값이 아이덴티티마다 격리돼. 팝업은 자기를 연 페이지의 저장소를 같이 써
W8 입력페이지와 주소창 모두 한국어 입력이 잘 돼. 아빠가 직접 쳐 봤어
W9 잠들기파일에 써 둔 624바이트만으로도 interactionState가 주소, 스크롤, 뒤로 가기 목록을 되살렸어
W10 백그라운드 메모리메모리 레슨의 567초 결과
W11 Web Inspector안 돌렸어. 앱이 inspectable로 설정해 둔 페이지에 붙는 건데, 아빠가 아직 안 열어 봤어

이 표에서 따라 할 만한 습관이 둘 있어. 하나, 결과마다 뭘 돌려 봤고 뭘 안 돌려 봤는지 적었어. 확장 프로브엔 탭도 팝업도 네이티브 메시징도 없었으니까, 평가 문서는 "시작한다"가 "동작한다"는 뜻이 아니라고 분명히 적어 둬. 둘, 사람이 있어야 하는 케이스는 사람 몫으로 표시하고 흉내 내지 않았어. 진짜 로그인, 한국어 타이핑, 마이크 허용 같은 거.

0단계를 마치는 조건은 이 한 문장이야. '기계가 판정할 수 있는 케이스는 전부 돌았고, 엔진 선택을 다시 열 만큼 실패한 건 하나도 없다.' 사람 손이 필요한 케이스는 첫 벽돌로 넘어갔어. 그런 시험엔 스파이크보다 진짜 창이 더 맞으니까. 스파이크는 버렸어. 거기서 나온 건 아키텍처에 맞춰 다시 쓰지 않는 한 하나도 안 들어갔어.

원칙: 게이트는 실패마다 그 결과가 이미 옆에 적혀 있는 케이스 목록이야. 그 칸이 없으면 그냥 테스트고, 있으면 기계한테 맡길 수 있는 결정이야.

공개 API만

아키텍처 불변식 3은 짧아. '엔진은 시스템의 WebKit이고, 공개 API로만 쓴다.' Voyager는 엔진을 싣지도 고치지도 않아. 비공개 WebKit 인터페이스는 아빠의 결정으로만 들어올 수 있고, 그때는 Apple이 바꾸면 뭐가 깨지는지 아키텍처에 이름을 적어야 해. 지금 쓰는 건 하나도 없어.

유혹은 진짜 있고, 레포가 이름까지 적어 놨어. 인라인 인스펙터, 텍스트 추출, 올린 메모리 한도. 하나같이 기능 하나를 쉽게 만들어 줘. 그리고 하나같이, 아빠가 매일 기대는 브라우저를 OS 업데이트 한 번에 아무 경고 없이 깨뜨릴 수 있어. 그래서 앞 레슨의 빈틈들은 비공개 호출 대신 Voyager 자체 코드로 메웠어. 찾기 개수는 페이지에 보이는 텍스트로 세고, 음소거는 자기 콘텐츠 월드에서 해. 패리티 계약의 Never 표도 같은 선을 지켜. 엔진을 싣거나 고치지 않고, 아빠의 결정 없이 비공개 WebKit 인터페이스를 쓰지 않아. Apple이 바꾸면 소리 없이 깨지니까.

Safari로, 일관되게

공개 API만 써도 모든 사이트에 영향을 주는 결정이 하나 남아. 브라우저가 자기를 뭐라고 소개하느냐야. WebKit 자체 유저 에이전트엔 브라우저 이름이 없고, 사이트는 그걸 임베디드 웹 뷰로 읽어. Google은 그런 데선 로그인을 막아. CEF한테 불리하게 작용한 바로 그 정책이야. applicationNameForUserAgent는 WebKit 유저 에이전트 문자열 안의 앱 이름을 정하는 공개 설정 속성이야. Voyager는 여기에 같은 시스템에서 Safari가 내미는 토큰을 넣어. Version/ 뒤에 Safari 버전, 그리고 Safari/605.1.15야. Mac에선 Safari 번들에서 버전을 직접 읽어. iPhone, iPad에선 Safari 버전이 곧 시스템 버전이라 그걸 써. (Mac에서 Safari 번들을 못 읽으면 같은 대체 경로를 타는데, Mac에선 시스템 버전과 Safari 버전이 같은 숫자가 아니야.)

왜 여기저기 살짝 고치지 않고 Safari 토큰을 통째로 쓰는지는 Never 표가 설명해. '사이트가 잡아내는 건 어설픈 위장이다. 일관된 정체 하나로 가든지, 아예 안 하든지.' 페이지들도, 피파의 패널도 같은 토큰을 받아. 실제 WebKit 테스트 묶음의 itPresentsSafarisToken은 페이지를 띄우고 navigator.userAgent가 실제로 뭐라고 하는지 확인해.

이 이야기의 마지막 공개 API는 속성 하나야. 모든 페이지가 isInspectable = true를 켜 둬서 Safari의 Web Inspector가 붙을 수 있어. 이게 Chrome DevTools에 대한 Voyager의 답이야. 비공개 인라인 인스펙터 없이, Apple이 문서로 열어 둔 문으로 시스템 자체 도구를 쓰는 거지.

Code

cwkVoyager, Sources/VoyagerWebKit/WebKitEngine.swift: 시스템에서 읽어 오는 Safari 토큰 (원문 그대로, 164-179행)·swift
    /// WebKit's own user agent names no browser, and sites take that for an
    /// embedded web view (Google refuses sign-in to those). Voyager presents
    /// the token Safari presents on this system.
    static let userAgentApplication = "Version/\(safariVersion) Safari/605.1.15"

    private static var safariVersion: String {
        #if os(macOS)
        if let version = Bundle(path: "/Applications/Safari.app")?
            .object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String {
            return version
        }
        #endif
        // On iPhone and iPad, Safari's version is the system's.
        let system = ProcessInfo.processInfo.operatingSystemVersion
        return "\(system.majorVersion).\(system.minorVersion)"
    }
cwkVoyager, Sources/VoyagerWebKit/WebKitEngine.swift: 모든 페이지 설정에 아이덴티티 저장소와 토큰이 실려 (원문 그대로, 106-110행)·swift
    func configuration(for identity: IdentityID) -> WKWebViewConfiguration {
        let configuration = WKWebViewConfiguration()
        configuration.websiteDataStore = store(for: identity)
        configuration.applicationNameForUserAgent = Self.userAgentApplication
        configuration.mediaTypesRequiringUserActionForPlayback = []
cwkVoyager, Tests/VoyagerWebKitTests/WebKitPageTests.swift: 페이지가 직접 토큰을 보고해 (원문 그대로, 137-144행)·swift
    @Test func itPresentsSafarisToken() async {
        let page = makePage()
        defer { page.close() }
        await secure(page, "<p>ua</p>")
        let agent = await js(page, "return navigator.userAgent")
        #expect(agent.contains("Version/"))
        #expect(agent.hasSuffix("Safari/605.1.15"))
    }

External links

Exercise

다음번 큰 작업을 짓기 전에, 케이스 다섯에서 열한 개짜리 게이트를 네 칸 표로 써 봐. 케이스, 통과하는 정확한 조건, 실패하면 계획에 무슨 뜻인지, 누가 돌리는지(기계인지, 이름 붙은 사람인지). 실패하면 핵심 기술 결정을 뒤집게 될 케이스를 최소 하나 표시해. 기계가 판정할 수 있는 케이스는 전부 쓰고 버릴 스파이크에서 돌리고, 케이스마다 결과를 적되 이번 실행이 건드리지 못한 것도 같이 적어. 그리고 스파이크는 버려.
Hint
어떤 케이스의 "실패하면" 칸을 못 채우겠다면, 그건 아직 게이트 케이스가 아니라 호기심이야. 사람 본인의 계정이나 손이 필요한 케이스는 그 사람 몫으로 남기고, 흉내 내지 말고 결과에 그렇다고 적어.

Progress

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

댓글 0

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

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