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

아이덴티티는 저장소다

~15 min · webkit, 아이덴티티, 격리, 데이터 저장소

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

아빠가 브라우저를 여러 개 띄워 둔 이유

Voyager 전에는 한 사이트에 계정 두 개로 들어가 있으려면 브라우저도 두 개가 필요했어. 하나는 첫 번째 계정으로, 다른 하나는 두 번째 계정으로 로그인해 두고, 누구로 있을지 바꾸려면 창을 바꿨지. Voyager 집 간판은 이걸 브라우저가 없애려는 불편 목록에 올려 둬. 한 사이트에 계정 둘로 있으려고 브라우저를 여러 개 동시에 띄운다는 항목이야.

브라우저는 사이트 로그인을 쿠키와 사이트 저장소에 담아 두고, 브라우저 프로필 하나엔 그 통이 하나뿐이야. 같은 사이트에 계정이 둘이면 통도 둘이어야 해. Voyager는 이 통을 아이덴티티라고 불러. 아키텍처 불변식 7번은 규칙을 한 줄로 말해. 아이덴티티는 격리된 저장소고, 탭 하나는 그걸 정확히 하나 가진다.

작은 타입 셋

부모 타깃은 Sources/VoyagerCore/Identity.swift에 아이덴티티를 평범한 값으로 들고 있어. 파일 머리 주석의 마지막 문장은 기억해 둘 만해. 여기 있는 건 하나도 자격 증명이 아니라는 문장이야.

  • IdentityID는 UUID를 감싼 값이야. UUID 문자열로 인코딩되고, UUID가 아닌 건 디코딩을 거부해.
  • Identity는 그 id에 이름과 #RRGGBB 색을 붙인 거야. 기본색은 회색 #8A8F98.
  • IdentityBook은 identities.json 파일이야. 목록, 스키마 버전, 그리고 아무도 정해 주지 않을 때 탭이 받는 기본 아이덴티티가 들어 있어. 처음 실행하면 "Main"이라는 아이덴티티 하나를 새로 만들어. 그래서 그 저장소는 빈 채로 시작해.

예약된 id가 하나 있어. IdentityID.privateSession은 어느 책에도 없는 고정 UUID야. 시크릿 모드가 이걸 쓰는데, 레슨 3에서 열어 볼 거야.

id가 곧 저장소 이름

엔진 자식은 호출 하나로 id를 WebKit 저장소로 바꿔. WKWebsiteDataStore(forIdentifier:). Apple 문서 설명은 담백해. 그 식별자로 된 저장소가 없으면 시스템이 만들어 주고, 이 메서드는 프로필용 저장소를 얻는 방법이래. 엔진은 만든 저장소를 전부 딕셔너리에 담아 둬. 그래서 같은 아이덴티티의 페이지는 전부 같은 객체를 써. 그리고 엔진이 만드는 WKWebViewConfiguration마다 websiteDataStore를 그 페이지 아이덴티티의 저장소로 정해.

격리 장치는 이게 전부야. Voyager는 쿠키를 나누는 코드도, 요청 필터도, 자기만의 프로필 폴더도 안 써. 쿠키, 로컬 스토리지, 그 밖의 사이트 데이터는 WebKit이 그 저장소를 두는 곳에 살아. 설정에 다른 저장소가 적힌 페이지는 거기 닿을 길이 없고.

질문WebKitChromium
탭이 자기 계정을 어떻게 갖지?공개 API. WKWebsiteDataStore(forIdentifier:), macOS 14와 iOS 17부터요청 컨텍스트를 따로 줘. 그게 프로필 통째라 확장이랑 기록도 같이 갈라져. 로그인만 떼려면 포크에 얹어 다니는 패치 묶음이 필요했어
이미 누가 쓰고 있어?Orion, 같은 데이터 저장소 API로Brave, 패치 묶음을 8개월 들고 다닌 끝에 들어감

엔진 평가서의 이 줄이 Voyager가 WebKit 위에 선 이유 중 하나야. 나머지 이야기는 트랙 2에 있어. Firefox는 컨테이너라는 방식으로 같은 결과에 닿아.

같은 Voyager 창을 찍은 두 장을 나란히 놓은 거야. 둘 다 creativeworksofknowledge.com인데 앞에 나온 탭이 달라. 왼쪽은 첫 탭으로, ADMIN 배지가 붙은 아빠 계정이야. 오른쪽은 둘째 탭으로, 관리자 배지 없는 Pippa 계정이야. 탭마다 왼쪽 끝에 신원을 나타내는 색 막대가 있어.
한 창에 같은 사이트, 탭 둘, 신원 둘. 왼쪽은 관리자로 로그인한 아빠 탭이고, 오른쪽은 Pippa로 로그인한 둘째 계정 탭이야. 탭 왼쪽 끝 막대가 어느 신원인지 알려줘.

한 사이트, 탭 둘, 아이덴티티 둘, 창은 하나. 웹뷰마다 태어날 때 다른 저장소를 받았으니까 탭마다 제 계정으로 로그인돼 있는 거야.

그걸 증명하는 테스트

실제 WebKit 테스트 묶음에 twoIdentitiesShareNothing이 있어. 페이지를 셋 열어. 둘은 아이덴티티 A, 하나는 B. 페이지 A가 쿠키 하나와 로컬 스토리지 값 하나를 써. A의 두 번째 페이지는 둘 다 봐야 해. 그건 다른 웹 콘텐츠 프로세스라서 테스트가 최대 3초 동안 다시 물어봐(50 ms 간격으로 60번). 페이지 B는 아무것도 못 봐야 해. 답은 null|. 끝으로 아이덴티티마다 저장소를 지우는데, 여기엔 재시도를 붙여. 그 저장소의 웹뷰가 하나라도 살아 있으면 WebKit이 지우기를 거부하거든. 집 간판도 이걸 함정으로 적어 뒀어. 아이덴티티의 데이터 저장소는 그 아이덴티티의 웹뷰가 하나도 안 남았을 때만 지워진다고.

같은 확인이 WebKit 관문의 W7 항목이기도 해. 창 하나의 탭 두 개에 한 사이트의 계정 두 개를 띄우고, 팝업은 자기 탭의 아이덴티티에 남는다는 항목이야. 관문은 이 테스트 묶음을 증거로 적어 뒀어. 합성 페이지로 한 테스트고, 그 줄에 실제 계정 두 개로 로그인해 본 기록은 없어. 그러니 나도 그렇게 말하진 않을 거야.

책을 못 읽을 때

id가 저장소의 유일한 이름이니까, id를 잃으면 저장소로 돌아갈 길도 잃어. Voyager는 시작할 때 이걸 지켜. identities.json을 못 읽으면 메모리에 새 책을 만들어 그걸로 돌고, 알림으로 그렇다고 말해. 알림 문구는 This run uses a fresh one and leaves the file alone, 이번 실행은 새 책으로 돌고 파일은 건드리지 않는다는 뜻이야. 그 실행 동안엔 그 파일에 절대 안 써. 망가진 파일이 옛 id를 그대로 들고 있어서 고치는 사람이 쓸 수 있어. IdentityBook 주석도 이 파일은 손으로 다시 만들어도 안전하다고 적어 뒀어.

원칙: 격리에는 내가 가진 이름을 붙여. Voyager는 페이지가 어느 저장소를 쓰는지 WebKit한테 묻지 않아. 탭이 id를 들고 있고, 그 id가 곧 저장소야.

Code

cwkVoyager, Sources/VoyagerWebKit/WebKitEngine.swift: 아이덴티티마다 저장소 하나, 이름은 그 id (원문 그대로, 78-93행)·swift
    /// One data store per identity, named by the identity's id: its cookies
    /// and site storage are in no other identity's reach.
    func store(for identity: IdentityID) -> WKWebsiteDataStore {
        if let store = stores[identity] { return store }
        // The private identity's store lives in memory for this run only.
        let store = identity == .privateSession ? WKWebsiteDataStore.nonPersistent() : WKWebsiteDataStore(forIdentifier: identity.raw)
        stores[identity] = store
        if identity != .privateSession {
            // The cookie store holds its observers weakly; the engine keeps
            // the watcher, and the watcher keeps no store.
            let watcher = CookieWatcher(identity: identity, engine: self)
            store.httpCookieStore.add(watcher)
            cookieWatchers[identity] = watcher
        }
        return store
    }
cwkVoyager, Tests/VoyagerWebKitTests/WebKitPageTests.swift: 실제 WebKit에서 두 아이덴티티는 아무것도 나누지 않는다 (원문 그대로, 384-410행)·swift
    @Test func twoIdentitiesShareNothing() async throws {
        let a = IdentityID(), b = IdentityID()

        // The pages live inside this function: a store can be removed only
        // once no web view of its identity exists.
        func exercise() async throws {
            let pageA = makePage(identity: a)
            let pageB = makePage(identity: b)
            let pageA2 = makePage(identity: a)
            for page in [pageA, pageB, pageA2] { await secure(page, "<p>store</p>") }

            let wrote = await js(pageA, "localStorage.setItem('who', 'identity-a'); document.cookie = 'who=identity-a; path=/'; return localStorage.getItem('who') + '|' + document.cookie")
            #expect(wrote == "identity-a|who=identity-a")
            // A second page of the same identity sees both; it is another
            // process, so give the store a moment to hand them over.
            var sameIdentity = ""
            for _ in 0..<60 {
                sameIdentity = await js(pageA2, "return String(localStorage.getItem('who')) + '|' + document.cookie")
                if sameIdentity == "identity-a|who=identity-a" { break }
                try await Task.sleep(for: .milliseconds(50))
            }
            #expect(sameIdentity == "identity-a|who=identity-a")
            let otherIdentity = await js(pageB, "return String(localStorage.getItem('who')) + '|' + document.cookie")
            #expect(otherIdentity == "null|")
            for page in [pageA, pageB, pageA2] { page.close() }
        }
        try await exercise()

External links

Exercise

작은 macOS나 iOS 앱에서 WKWebView를 둘 만들어. 각 설정은 서로 다른 UUID로 WKWebsiteDataStore(forIdentifier:)를 쓰게 하고, 그 UUID 둘은 파일에 저장해 둬. 둘 다 https://example.com을 열어. 첫 번째에서 document.cookie로 쿠키를, localStorage.setItem으로 값을 쓰고, 두 번째에서 둘 다 읽어 봐. 이어서 첫 번째 UUID로 세 번째 웹뷰를 열어서 값이 보이는지 확인해. 마지막으로 첫 아이덴티티의 웹뷰를 전부 닫고 WKWebsiteDataStore.remove(forIdentifier:)로 저장소를 지워 봐.
Hint
같은 저장소의 두 번째 웹뷰는 다른 프로세스에서 돌아. 한 번만 읽지 말고 짧은 반복으로 다시 물어봐. 지우기가 사용 중이라며 실패하면 아직 그 저장소의 웹뷰를 뭔가 붙잡고 있는 거야. 지우기 전에 웹뷰를 전부 놓아줘.

Progress

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

댓글 0

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

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