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

확장엔 막고, 이름으로 열고

~14 min · web-extensions, widgetkit, deep-links, honest-gaps

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

같은 목록, 거꾸로 읽기

마이크를 허가해 주는 그 카탈로그가 울타리도 쳐. Mac에선 신뢰를 설치하는 바로 그 코드(레슨 4)가 확장 호스트한테 가족 호스트 묶음을 넘겨. Settings의 허브와 피파 호스트, 그리고 카탈로그 웹 대상의 호스트 전부. 확장 호스트는 거기에 어느 Mac에서나 가족인 패턴 셋을 보태. *://localhost/*, *://127.0.0.1/*, *://*.ts.net/*. 그러고는 확장이 뭘 요청했든 상관없이, 설치된 확장 전부한테 이걸 명시적으로 거부해. 첫 번째 블록이 그 장치야.

잘 돌아가게 하는 디테일이 셋 있어. 거부한 패턴이 허가한 패턴을 이겨. 2026-10-04에 <all_urls>를 요청한 패키지로 재 봤더니, 거부된 호스트에 대해 WebKit의 hasAccess가 아니라고 답했어. 그래서 확장은 다른 데서 받은 건 전부 그대로 가져. 확장 호스트는 확장마다 어떤 가족 패턴을 거부했는지 정확히 기억해. 그래서 목록이 바뀔 때 그것만 지우고, 확장 자기 패턴은 안 건드려. 그리고 Settings에서 확장 하나를 id로 들여보낼 수 있어. 그러면 아빠가 되돌릴 때까지 가족 사이트가 그 확장한테 열려. 테스트는 모든 사이트를 허가받은 패키지를 설치해. 그리고 example.com엔 닿지만 loopback, ts.net 호스트, 카탈로그 호스트엔 못 닿는지 확인해. 이어서 카탈로그가 나중에 적은 호스트를 더하고, 확장을 들여보냈다가 다시 내보내 봐.

이유는 실제로 재 본 실패였어. 1Password 확장이 cwkPippa 위에서 돌 때, 그 콘텐츠 스크립트가 navigator.credentials를 가로챘어. 그래서 데스크톱 연결이 열려 있는 동안 Touch ID 패스키를 저장할 수 없었어(876ea6a, 2026-10-04). 1Password 얘기 나머지는 트랙 8이 하고, Chrome 확장 모델 자체는 Pippa Embed Quest가 직접 만들어 보면서 가르쳐.

막을 땐 넓게, 열 땐 좁게

가족 목록의 두 쓰임새를 나란히 놓고 보면, 틀리는 방향이 서로 반대야.

마이크와 카메라 허가확장 울타리
단위정확한 출처: 스킴, 호스트, 포트호스트 패턴: 스킴은 http나 https(WebKit의 * 스킴은 이 둘만 맞춰), 포트는 아무거나
테일넷카탈로그나 Settings에 적힌 출처만*.ts.net 호스트 전부
loopbackloopback 표기 전부localhost와 127.0.0.1
너무 넓으면 생기는 일아무도 보증 안 한 페이지가 아빠 목소리를 들어확장 하나가 가족 사이트를 못 써

울타리는 tailscaled한테 안 묻고도 *.ts.net을 통째로 막을 수 있어. 이름 패턴만으로도 막기엔 충분하지만, 들여보내기엔 부족하거든. 그래서 허가는 정확한 출처를 기다려. 두 목록 다, 틀려도 자기가 지키는 것한테 덜 비싼 쪽으로 틀리는 거야.

오늘의 "이름으로"

아키텍처는 주소창이 URL, 검색어, 가족 앱 이름, 피파한테 하는 질문을 받는다고 적어. 넷 중 둘만 만들어졌어. AddressResolver 맨 위 주석은 2단계에서 가족 앱 이름을 그 둘보다 앞에 세운다고 해. 하지만 resolve(_:)가 아는 건 완전한 스킴, 호스트처럼 읽히는 글자, 검색 템플릿뿐이야. 주소창도, 그 제안 목록도, 툴바도 카탈로그를 안 봐. 띄어쓰기 없는 단어 하나는 검색어라서, 지금 주소창에 가족 앱 이름을 치면 그 단어로 웹 검색을 해. 피파한테 하는 질문도 주소창에선 아직 안 돼. 계획엔 둘 다 열린 항목으로 남아 있고, 아빠는 아직 안 만들어진 걸로 가르치라고 정했어.

오늘 "이름으로"는 두 군데야.

  • Mac에선 네이티브 Home의 Find a family app(가족 앱 찾기) 칸이야. 아빠가 치는 대로 카탈로그 그리드를 걸러. 대상의 제목과 라벨을 같이 보고, 대소문자는 안 가려.
  • iPhone과 iPad에선 가족 앱을 이름으로 보여주는 홈 화면 위젯.

주소를 안 들고 있는 위젯

폰 앱은 위젯용 선반을 따로 둬. 선반엔 웹 대상마다 카탈로그 id, 제목, 즐겨찾기 여부가 들어가고, 카탈로그 리비전과 읽은 시각도 같이 들어가. 주소는 절대 없어. 선반은 키트의 가족 문서 저장소를 거쳐, 위젯과 같이 쓰는 앱의 팀 접두사 keychain 그룹에 살아. 이런 데엔 보통 App Group을 써. 그런데 가족의 키 기반 TestFlight 파이프라인으론 새 App Group을 등록할 수 없고, keychain 그룹은 이미 모든 프로파일에 들어 있거든.

타일은 링크야. cwkvoyager://family/<target id>(두 번째 블록). 위젯은 어디에도 요청을 안 보내. 타임라인 정책은 .never고, 선반의 앱 목록이 바뀔 때만 앱이 다시 불러. 작은 크기는 4개, 중간은 8개, 큰 크기는 16개를 즐겨찾기부터 보여줘.

아이폰 홈 화면에 놓인 작은 Voyager 위젯. 가족 앱 타일 넷, Almanac, Anvil, Avatars, Bard가 글자 아이콘과 이름으로 놓여 있어.
작은 크기는 타일 넷이고, 타일마다 가족 앱 이름이 붙어. 위젯은 아무 데도 접속하지 않아. 탭하면 앱이 풀어 볼 id만 넘겨.

타일을 누르면 앱이 이미 들고 있는 카탈로그에 대고 id를 풀어(세 번째 블록). 목록에 없거나 아직 카탈로그를 한 번도 안 읽었으면 먼저 읽어. 그러고 나서 그 카탈로그에 적힌 주소를 열어. 같은 출처 탭이 이미 있으면 새로 안 열고 그 탭을 앞으로 가져와. 다시 읽어도 없으면 Firelink가 이제 그 앱을 안 싣는다고 솔직하게 말해. 그래서 가족이 앱을 다른 Mac으로 옮기면 Firelink의 포인터가 바뀌고, 홈 화면은 하나도 안 바뀌어. 탭이 새 주소를 따라가는 건 앱이 카탈로그를 다시 읽은 뒤야. 앱이 앞으로 나올 때 읽는데, 1분에 한 번까지만. 그 읽기보다 먼저 들어온 탭이나 허브에 닿을 수 없을 때 누른 탭은 옛 카탈로그에 적힌 주소를 그대로 열어. 목록에 있는 id는 새로 읽을 때까지 기다려 주지 않거든.

폰 앱이 선언하는 스킴은 그거 하나야. iOS에선 Voyager가 기본 브라우저가 아니라서(아빠, 2026-10-26) http도 https도 안 잡아. 테스트 함정 하나. simctl openurl은 항상 Open in Voyager?(Voyager에서 열까?) 확인 창에서 멈추는데, 폰에서 위젯을 누를 땐 그런 창이 안 떠. 그래서 링크 경로는 앱의 호스티드 테스트가 지키고, 거기서 테스트 7개가 링크와 선반을 다뤄.

원칙: 위젯이든 단축어든 북마크든, 앱 바깥에 사는 것엔 대상의 안 바뀌는 이름만 둬. 그리고 쓰는 순간에 앱이 그 이름을 풀게 해. 바깥에 저장한 주소는 사본이고, 사본이 어떻게 되는지는 레슨 1에서 이미 봤어.

Code

cwkVoyager, Sources/VoyagerWebKit/WebKitExtensionHost.swift: 아빠가 들여보내지 않는 한 모든 확장에 막힌 가족 호스트 (원문 그대로, 50-85행)·swift
    /// The family's hosts, kept out of every extension's reach unless Dad
    /// let that extension in. Loopback and the tailnet are always family.
    public var familyHosts: Set<String> = [] {
        didSet { if familyHosts != oldValue { applyFamilyAccess() } }
    }
    public var allowedOnFamilySites: Set<String> = [] {
        didSet { if allowedOnFamilySites != oldValue { applyFamilyAccess() } }
    }
    /// The family patterns each context was denied, so a change clears
    /// exactly those and no pattern of the extension's own.
    private var familyDenials: [String: [WKWebExtension.MatchPattern]] = [:]
    /// What is family on every Mac: this Mac by either name, and the tailnet.
    static let alwaysFamilyPatterns = ["*://localhost/*", "*://127.0.0.1/*", "*://*.ts.net/*"]

    /// The match patterns that stand for the family's hosts now.
    func familyPatterns() -> [WKWebExtension.MatchPattern] {
        var texts = Self.alwaysFamilyPatterns
        for host in familyHosts.sorted() where !host.isEmpty && !host.contains("/") && !host.contains("*") {
            texts.append("*://\(host)/*")
        }
        return texts.compactMap { try? WKWebExtension.MatchPattern(string: $0) }
    }

    private func applyFamilyAccess() {
        for (id, context) in contexts { applyFamilyAccess(to: context, id: id) }
    }

    /// A denied pattern wins over a granted one (measured 2026-10-04 against
    /// a package granted `<all_urls>`: `hasAccess` says no for a denied
    /// host), so an extension keeps what it asked for everywhere else.
    private func applyFamilyAccess(to context: WKWebExtensionContext, id: String) {
        for pattern in familyDenials[id] ?? [] { context.setPermissionStatus(.unknown, for: pattern) }
        let denied = allowedOnFamilySites.contains(id) ? [] : familyPatterns()
        for pattern in denied { context.setPermissionStatus(.deniedExplicitly, for: pattern) }
        familyDenials[id] = denied
    }
cwkVoyager, clients/apple/VoyagerMobile/Shared/FamilyShelf.swift: 위젯 타일이 여는 링크는 카탈로그 대상 이름만 담아 (원문 그대로, 52-70행)·swift
/// `cwkvoyager://family/<target id>`: what a widget tile opens.
enum FamilyLink {
    static let scheme = "cwkvoyager"

    static func url(forTarget id: String) -> URL? {
        var components = URLComponents()
        components.scheme = scheme
        components.host = "family"
        components.path = "/" + id
        return components.url
    }

    /// The target id a family link names; nil for anything else.
    static func targetID(in url: URL) -> String? {
        guard url.scheme?.lowercased() == scheme, url.host(percentEncoded: false) == "family" else { return nil }
        let id = String(url.path(percentEncoded: false).dropFirst())
        return id.isEmpty || id.contains("/") ? nil : id
    }
}
cwkVoyager, clients/apple/VoyagerMobile/Sources/MobileBrowser.swift: 타일을 누르는 순간 앱이 카탈로그에 대고 id를 풀어 (원문 그대로, 190-214행)·swift
    // MARK: Addresses from outside

    /// A link the widget opened. A family link names a catalog target, read
    /// against the catalog now; Voyager declares no web scheme on iOS.
    func handle(_ url: URL) {
        if let id = FamilyLink.targetID(in: url) {
            Task { await openFamilyTarget(id) }
        }
    }

    func openFamilyTarget(_ id: String) async {
        if MobileFamily.resolve(id, in: services.familyCatalog.projection) == .notListed || services.familyCatalog.projection == nil {
            await refreshCatalog()
        }
        switch MobileFamily.resolve(id, in: services.familyCatalog.projection) {
        case .open(let url):
            openFamily(url)
        case .notListed:
            showHome()
            post("Firelink's catalog no longer lists that app. The widget shows the current list after Voyager reads it.")
        case .noCatalog:
            showHome()
            if let problem = services.familyCatalog.problem { post(problem) }
        }
    }

External links

Exercise

서버가 주인인 목록에서 항목을 여는 작은 홈 화면 위젯을 만들어 봐. 메뉴 막대 항목이나 단축어 목록이어도 돼. 위젯이 읽는 자리엔 항목마다 안 바뀌는 id와 제목만 두고, 주소는 절대 두지 마. 앱엔 id만 싣는 커스텀 URL 스킴을 주고, 열릴 때 목록을 새로 읽어서 id를 풀어. 있으면 열고, 없으면 한 번 다시 읽고, 그래도 없으면 사라졌다고 사용자한테 분명히 말해. 마지막으로 서버에서 항목 주소를 바꾸고, 위젯은 하나도 안 건드린 채로 여전히 열리는지 확인해.
Hint
들어오는 링크는 엄격하게 검사해. 스킴 맞고, 호스트 맞고, 비어 있지 않은 경로 조각이 딱 하나여야 해. 아니면 무시해. 위젯 타임라인은 저장된 목록이 실제로 바뀔 때만 다시 불러. 그래야 위젯이 자기 네트워크 접근 없이도 버텨.

Progress

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

댓글 0

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

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