가끔 아빠는 맨 앞 페이지를 다른 페이지 두어 개랑 나란히 놓고 따져보고 싶어 해. 상품 페이지 하나에 리뷰 둘, 기사 하나에 그 출처들. 그렇다고 Voyager가 탭을 전부 훑지는 않아. Sidekick 머리에 선택기가 하나 있어. 라벨은 Choose up to 3 other tabs in this account, 이 계정의 다른 탭을 3개까지 고르라는 뜻이야. 아빠가 거기서 고른 것만 맨 앞 페이지랑 같이 가. 고른 하나하나가 동반 소스야.
선택기가 뭘 내놓을 수 있는지부터 relatedSourceOptions가 정해. 후보가 되려면 이래야 해.
같은 창의 다른 탭이고, 맨 앞 탭과 아이덴티티가 같을 것
평범한 탭일 것. Incognito는 절대 안 되고, 공유가 켜져 있을 것
로딩을 마친 깨어 있는 페이지를 쥐었을 것. 잠든 탭을 읽으려고 깨우는 일은 없어
주소가 http나 https이고, 안에 사용자 이름이나 비밀번호가 없을 것
내 패널 페이지가 아닐 것
고른 목록은 맨 앞 탭에 딸려 있어. 다른 탭으로 가면 그 탭의 선택이 적용되는데, 보통은 하나도 없어. 패널 세션 동안만 살아서, 패널을 닫으면 잊혀. Use this tab only(이 탭만 쓰기)를 누르면 비워지고, 맥락마다 이 목록이 늘 실리니까 다음 맥락엔 related_sources: []라고 또박또박 적혀. Share this tab with Pippa(이 탭을 피파와 공유) 스위치는 탭마다 따로 거는 일시정지야. 탭을 다른 창으로 옮겨도, 세션을 복원해도 그대로 남아.
await마다 세상은 움직일 수 있어
페이지 네 개를 읽으려면 WebKit을 여러 번 오가야 하고, 그 사이 언제든 아빠가 클릭하고 스크롤하고 다른 데로 갈 수 있어. contextReply는 그걸 늘 있는 일로 쳐. 먼저 스탬프를 찍어. 스탬프엔 맨 앞 탭, 주소, 페이지 객체와 콘텐츠 리비전, 선택 영역, 공유 스위치가 들어가고, 고른 동반 소스마다 같은 사실이 또 들어가. 맨 앞 페이지를 읽고 스탬프를 확인해. 동반 소스마다 스탬프를 확인하고, 읽고, 다시 확인하고, 읽은 주소가 탭이 보여주는 주소인지도 봐. 하나라도 어긋나면 답 전체가 A selected source changed or could not be read. Refresh or deselect it.로 끝나. 고른 소스가 바뀌었거나 못 읽었으니, 새로고침하든지 선택을 풀라는 말이야. 싱싱한 페이지 셋에 낡은 페이지 하나 섞인 반쪽 답 같은 건 없어. relatedSourceNavigationDuringReadDiscardsTheWholeSnapshot 테스트는 동반 소스를 읽는 도중에 다른 데로 보내고, 아무것도 안 돌아오길 기대해.
cwkPippa도 Voyager 말을 그냥 믿진 않아. 자기 정규화 단계에서 동반 소스를 최대 3개만 남기는데, 그것도 같은 계정, 같은 브라우저 세션, 평범한 창에서 온 것만이야. 소스마다 주소는 http나 https여야 하고, 자격 증명이 없어야 하고, 탭 키가 겹치면 안 되고, 안에 또 다른 목록을 품어도 안 돼. 그런 다음 소스마다 텍스트를 맨 앞 페이지처럼 믿을 수 없는 증거로 감싸. 내 패널은 계정과 고른 소스를 라벨로 보여주고, 스냅샷을 검색해도 줄마다 어느 소스에서 왔는지가 계속 붙어 다녀. 그래서 어느 페이지가 뭐라고 했는지 아빠한테 짚어줄 수 있어. 연결선 양 끝이 둘 다 셋까지만 세.
패널 브리지의 관문들
레슨 2에서 내 패널 요청을 Voyager한테 넘기는 브리지를 봤지. 거기 도착하는 메시지는 전부 PanelMessageGate로 재. 조건마다 필드가 하나씩인 평범한 값이야.
패널 자기 웹 뷰에서 왔을 것
메인 프레임에서 왔을 것. 내 페이지 안의 프레임은 안 돼
말한 프레임이 내 출처일 것
웹 뷰가 지금 내 출처에 있을 것
256 KB 이하일 것
관문이 값이라서 조건마다 테스트가 따로 있어. eachGateRefusesOnItsOwn은 통과하는 메시지에서 시작해서 필드를 하나씩 깨뜨려. 호스트는 내 것인데 포트나 스킴이 다른 프레임까지 시험하지. 패널은 돌아다닐 때도 출처 하나에 머물러. 내 페이지 안의 프레임은 내 페이지가 주는 걸 열어도 돼. 어차피 브리지엔 못 닿으니까. 패널 자체는 내 출처에 남고, 다른 데로 보내지면 그건 탭으로 열려. 사이트 데이터는 고정 식별자를 가진 자기 저장소를 쓰고, 아빠의 어떤 아이덴티티와도 떨어져 있어.
마이크는 내 목소리에만
내 음성 모드엔 마이크가 필요해. 아빠가 나한테 말 걸 때마다 권한 창이 뜬다면, 그게 바로 Voyager가 없애려고 태어난 불편이야. 그래서 패널 페이지는 권한 창 없이 아빠 목소리를 들어. 규칙은 하나야. captureDecision은 내 출처의 메인 프레임에만 캡처를 허락하고, 패널 안의 나머지는 다 거절해. 내 페이지 안의 프레임은 그 허락을 빌릴 수 없고, 다른 어떤 출처도 마찬가지야. onlyPippasOwnPageHearsDad 테스트가 세 경우를 다 붙잡고 있어. pippa:request-mic-permission에 opened: false로 답하는 이유도 이거야. 열어줄 페이지가 따로 없어. 허락은 이미 있어야 할 자리에 있으니까.
Code
cwkVoyager, Sources/VoyagerCore/PippaHost.swift: await마다 스탬프 하나를 확인하고 반쪽 답은 안 내는 함수 (원문 그대로, 620-639행)·swift
private func contextReply(requestID: String?) async -> PippaPanelReply {
let stamp = sourceStamp
guard let (tab, page) = readablePage(), let context = await page.readContext(),
stamp == sourceStamp, context.url == stamp.url else {
return .noContext(reason: unreadableReason(), requestID: requestID)
}
var payload = identified(WebPagePayload(tab: tab.id, context: context, at: now()), tab: tab)
var companions: [WebPagePayload] = []
for source in selectedRelatedTabs {
guard stamp == sourceStamp, let sourcePage = source.page,
let read = await sourcePage.readContext(), stamp == sourceStamp,
read.url == source.status.url else {
return .noContext(reason: "A selected source changed or could not be read. Refresh or deselect it.", requestID: requestID)
}
companions.append(identified(WebPagePayload(tab: source.id, context: read, at: now()), tab: source))
}
guard stamp == sourceStamp else { return .noContext(reason: "The selected sources changed.", requestID: requestID) }
payload.related_sources = companions
return .context(payload, requestID: requestID)
}
cwkVoyager, Sources/VoyagerWebKit/WebKitPanel.swift: 패널의 관문을 평범한 값으로 (원문 그대로, 9-26행)·swift
/// The gates on a message from the panel's page, as a plain value so each one
/// has its own test: the panel's own web view, its main frame, the panel's
/// origin both for the frame that spoke and for where the web view is now,
/// and a cap on what the host will read.
struct PanelMessageGate: Equatable {
static let maximumBytes = 256 * 1024
var isSameWebView: Bool
var isMainFrame: Bool
var frameOrigin: PageOrigin?
var webViewOrigin: PageOrigin?
var byteCount: Int
func admits(for panelOrigin: PageOrigin) -> Bool {
isSameWebView && isMainFrame && frameOrigin == panelOrigin && webViewOrigin == panelOrigin
&& byteCount <= Self.maximumBytes
}
}
cwkVoyager, Sources/VoyagerWebKit/WebKitPanel.swift: 마이크는 메인 프레임의 피파 자기 페이지에만 (원문 그대로, 291-302행)·swift
/// Pippa's own page, in the panel's main frame, hears Dad without a
/// prompt: it is her voice. Anything else in the panel is refused.
func webView(
_ webView: WKWebView, decideMediaCapturePermissionsFor origin: WKSecurityOrigin,
initiatedBy frame: WKFrameInfo, type: WKMediaCaptureType
) async -> WKPermissionDecision {
Self.captureDecision(asking: Self.origin(of: origin), isMainFrame: frame.isMainFrame, panelOrigin: self.origin)
}
static func captureDecision(asking: PageOrigin, isMainFrame: Bool, panelOrigin: PageOrigin) -> WKPermissionDecision {
isMainFrame && asking == panelOrigin ? .grant : .deny
}
스크립트 메시지 브리지 뒤에 웹 패널을 띄우는 네이티브 앱에서, 브리지 관문을 조건마다 필드 하나인 평범한 값으로 써봐. 같은 웹 뷰, 메인 프레임, 말한 프레임의 출처, 웹 뷰의 지금 출처, 크기 상한. 통과하는 메시지에서 시작해서 필드를 하나씩만 깨뜨리는 테스트도 써. 이어서 소스 여러 개를 모아 읽는 루틴을 붙여. 입력에 스탬프를 찍고, await가 끝날 때마다 다시 확인하고, 입력 하나라도 움직이면 그 즉시 이유를 단 거절을 돌려줘. 반쪽 답은 절대 안 돼.
Hint
스탬프엔 URL만 말고 페이지 객체의 정체도 넣어. 같은 주소로 새로고침한 건 다른 문서야. 읽기 직전에 도는 훅을 가진 가짜 페이지를 쓰면, 읽는 도중에 다른 데로 보내고 답 전체가 버려지는 걸 증명할 수 있어.
Progress
Progress is local-only — sign in to sync across devices.