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

루트 계약

~15 min · 프로토콜, 다형성, 구조, 계약

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

엔진이 뭔지 말해주는 파일 하나

Sources/VoyagerCore/EngineContract.swift는 첫 줄부터 자기 일을 밝혀. '부모가 엔진한테 물을 수 있는 전부, 그리고 엔진이 부모한테 넘기는 결정 전부.' 탭, 세션, 신뢰, 아이덴티티, 피파 호스트는 여기 있는 타입만 알고 그 아래는 몰라. 주석은 파일 안의 모든 게 메인 액터에서 돈다고 해. 이유도 짧게 대. '페이지는 화면 위에 있는 물건이다.' 프로토콜까지는 맞는 말이야. 페이지, 그 델리게이트들, 패널, 엔진은 전부 @MainActor가 붙어 있거든. 그런데 평범한 값들은 아니야. 액터에 묶이지 않은 Sendable 구조체와 열거형이라서, 세션 기록기가 PageState를 자기 백그라운드 큐에서 직렬화할 수 있는 거야.

파일엔 두 종류가 들어 있어. 이 둘을 가르는 게 설계의 대부분이야.

부모가 따져 보는 평범한 값

  • PageStatus: 주소, 제목, 로딩 여부와 진행률, 뒤로·앞으로 갈 수 있는지, 보안 연결인지, 아이콘, 소리가 나는지. Equatable이라서 테스트가 상태 전체를 한 줄로 비교해.
  • PageState: 페이지가 있던 자리(주소, 방문 기록, 스크롤 위치)를 바이트 덩어리로 담은 값이야. 그 바이트를 쓴 엔진 이름표가 붙어 있어. 부모는 저장했다가 돌려줄 뿐, 속은 안 들여다봐.
  • PageOrigin: 스킴, 호스트, 포트. 들어올 때 소문자로 바뀌어. 주석은 이걸 신뢰를 정하는 기준이라고 불러.
  • PermissionKind, PermissionRequest, PermissionDecision: 첫째는 페이지가 받을 수 있는 것의 닫힌 목록이야. 둘째는 요청하는 프레임과 그 프레임이 들어 있는 페이지를 함께 담은 요청이고, 셋째는 허용·거부·묻기 중 하나로 하는 답이야.

여기엔 WebKit이 한 글자도 안 나와. 브라우저가 다루는 걸 Swift 자기 말로 적었을 뿐이야. 값이니까 테스트에도 세션 파일에도 정수만큼 쉽게 들어가.

엔진이 만들고, 부모는 프로토콜로 쥐는 객체

프로토콜정체구현하는 쪽
BrowserEngine아이덴티티를 페이지로 바꾸는 물건. 페이지와 패널을 만들고, 아이덴티티 데이터를 지우고, 요청받으면 쿠키 헤더 하나를 만들어 줘WebKitEngine, 그리고 테스트의 FakeEngine
EnginePage아이덴티티 하나에 속한 페이지 하나. 불러오기, 찾기, 확대, 음소거, 상태 담기, 피파용으로 읽기, 닫기WebKitPage
EnginePageDelegateVoyager가 내릴 결정과 페이지가 알리는 사실부모의 BrowserTab
EngineDownloadDelegate다운로드가 어디로 가는지, 어디까지 왔는지부모의 DownloadsModel
PanelSurface와 그 delegatecwkPippa 자기 패널. 탭이 아니고, 아빠 아이덴티티도 없고, 자기 origin을 절대 안 벗어나WebKitPanel, 부모의 PippaPanelHost

방향을 봐. 질문은 내려가. 부모가 페이지한테 불러오라고 하고, 탭이 잠들기 전에 상태를 달라고 하고, 피파가 페이지를 봐야 할 때 아빠가 뭘 보고 있는지 물어. 결정은 올라와. 페이지가 마이크든 새 창이든 대화 상자든 파일이든 원하면, 엔진은 혼자 정하지 않아. delegate한테 묻고, 그 delegate가 부모야.

다운로드에 프로토콜이 따로 있는 이유도 파일에 적혀 있어. '다운로드는 탭이 닫힌 뒤에도 계속된다.' 탭을 닫아도 다운로드는 이어져야 하거든. 페이지에 딸린 다운로드였다면 페이지랑 같이 죽었을 거야.

결정 하나를 끝까지 따라가 보기

마이크 요청 하나만 따라가 봐. WebKit이 자식한테 물어. 자식은 WebKit의 보안 origin과 프레임을 PermissionRequest로 바꾸는데, 요청한 프레임의 origin이랑 맨 위 페이지의 origin을 둘 다 담아. BrowserTab은 한 줄로 답해. window?.services.trust.decision(for: request) ?? .ask. 자식은 허용·거부·묻기를 다시 WebKit 자기 답 타입으로 옮기고. 신뢰 규칙은 WebKit 타입을 한 번도 안 만져. 그래서 그 테스트는 PermissionRequest를 손으로 만들 뿐, 페이지는 아예 안 만들어.

못 하는 엔진을 위한 기본값

파일 아래쪽 프로토콜 확장들은 능력이 모자란 엔진한테도 할 말을 쥐여 줘. '알 수 없는 엔진은 바쁘지 않은 걸로 친다.' 확장 호스트가 없는 엔진엔 확장 페이지도 없어. 일치 개수를 못 세는 패널은 nil로 답해. 이 기본값 덕분에 계약이 자라도 구현이 한꺼번에 깨지지 않고, 테스트용 가짜도 짧게 남을 수 있어.

실제로 많이 자랐어. 첫 벽돌 땐 251줄이었는데 지금은 746줄이고, 커밋 17개를 거쳤어. 뭔가 늘 때마다 그걸 필요로 한 기능이 같이 왔어. PanelSurface는 창 옆의 피파와 함께 들어왔어(a449ddd). reloadFromOrigin()은 강력 새로고침과(aaa8bcf), cookiesDidChange는 가족 카탈로그 로그인과(ac9bfc3) 함께 왔고. isBusy()는 놀고 있는 탭을 재우는 기능과(3638f97), FindMatches는 찾기 막대의 "3 of 17" 표시와(24cd64b), isMuted는 탭의 스피커 아이콘과(9e3f985) 함께 왔어.

설계도가 된 다형성

아빠는 세상을 객체로 읽어. 여기선 그 읽기가 말 그대로야. 계약이 부모고, 엔진은 그걸 채우는 자식이야. BrowserServices는 엔진을 any BrowserEngine으로 쥐고 있어서, 그 위에 있는 건 어느 자식을 받았는지 몰라. 첫 설계(224e256)는 루트 하나 아래 자식을 둘 그렸어. Mac엔 Chromium, 아이폰과 아이패드엔 WebKit. 같은 날 밤 아빠가 WebKit으로 정하면서 자식 하나가 빠졌고, 구조 문서는 문장 하나로 루트를 남겼어. '엔진은 갈아 끼울 수 있는 자식이라는 아빠 결정은 그대로고, 두 번째 자식이 온다면 여기로 들어온다. 계획된 건 없다.'

원칙: 구현이 하나뿐인 추상화는 오늘 당장 밥값을 해야 해. 이 추상화는 두 번 밥값을 해. 부모를 평범한 값으로 테스트하게 해 주고, Mac과 폰의 차이를 선 아래로 몰아넣어.

Code

cwkVoyager, Sources/VoyagerCore/EngineContract.swift: 부모 눈에 보이는 엔진 (원문 그대로, 619-657행)·swift
/// An engine: the thing that turns an identity into pages.
@MainActor
public protocol BrowserEngine: AnyObject {
    /// A short stable name; it tags the page states this engine writes.
    var name: String { get }
    /// For diagnostics. The engine belongs to the operating system; Voyager
    /// reports its version and never pins it.
    var version: String { get }

    var downloadDelegate: (any EngineDownloadDelegate)? { get set }

    /// A new page for `identity`, at the place `state` describes when it is
    /// this engine's own.
    func makePage(identity: IdentityID, restoring state: PageState?) -> any EnginePage

    /// A page for an extension's own address, loaded with the extension
    /// context's own web view configuration — the only one WebKit serves
    /// those pages to. Nil when the engine hosts no such extension.
    /// 1Password's Add browser opens its extension's app page by a
    /// `chrome-extension://` address, routed here.
    func makePage(forExtensionPageAt url: URL, identity: IdentityID) -> (any EnginePage)?
    func extensionPageURL(for url: URL) -> URL?
    func canonicalExtensionPageURL(for url: URL) -> URL?

    /// A panel that may load `origin` and nothing else. Its page may use the
    /// microphone without a prompt: it is Pippa's own voice surface, on the
    /// origin Dad configured for her.
    func makePanel(origin: PageOrigin) -> any PanelSurface

    /// Removes one site's cookies and storage from one identity's store.
    func removeData(of identity: IdentityID, host: String) async

    /// Removes an identity's whole store: every cookie and sign-in. No page
    /// of that identity may still be open.
    func removeData(of identity: IdentityID) async throws

    /// The last private window closed. Drop only the ephemeral session's
    /// store synchronously, before another private window can open.
    func endPrivateSession()
cwkVoyager, Sources/VoyagerCore/EngineContract.swift: 페이지가 받을 수 있는 것, 그리고 누구와 함께 판단되는지 (원문 그대로, 89-118행)·swift
/// The closed list of things a page may be granted (invariant 6). A new kind
/// is added here by name, with its reason, and nowhere else.
public enum PermissionKind: String, Sendable, Codable, CaseIterable {
    case microphone
    case camera
    case cameraAndMicrophone
}

public struct PermissionRequest: Sendable, Equatable {
    /// The frame that asks.
    public var origin: PageOrigin
    /// The page Dad sees in the tab. A frame inside another site's page is
    /// judged with that page: content never raises its own zone by being
    /// embedded, or by embedding.
    public var topOrigin: PageOrigin
    public var kind: PermissionKind

    public init(origin: PageOrigin, topOrigin: PageOrigin? = nil, kind: PermissionKind) {
        self.origin = origin
        self.topOrigin = topOrigin ?? origin
        self.kind = kind
    }
}

public enum PermissionDecision: Sendable, Equatable {
    case grant
    case deny
    /// The engine asks Dad itself, as Safari would.
    case ask
}

External links

Exercise

앱이 제일 많이 기대는 프레임워크 하나(웹 뷰, 지도, 결제 SDK, 데이터베이스 드라이버)를 골라서 그 루트 계약을 Swift 파일로 써 봐. 내 코드가 그 프레임워크한테 묻는 질문, 그리고 그 프레임워크가 내 코드한테 넘겨야 하는 결정을 적어. 질문은 프레임워크 래퍼가 구현하는 프로토콜의 메서드로, 결정은 내 코드가 구현하는 delegate의 메서드로 만들어. 파일 안의 타입은 전부 내 값이거나 Foundation 타입이어야 해.
Hint
시그니처에 프레임워크 타입(보안 origin, 지도 항목, 드라이버의 행 타입)이 보이면 그 타입을 선 아래로 내려. 내 값을 하나 정의하고, 변환은 구현 쪽이 하게 해. 확인법은 간단해. 계약 파일이 Foundation 하나만 import하고도 컴파일되면 돼.

Progress

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

댓글 0

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

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