Skip to content
C.W.K.
Lesson 02 of 05 · published

The Wire Speaks cwkPippa's Words

~14 min · pippa, protocol, postmessage, sidekick

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

A panel that asks, a host that answers

My Sidekick is cwkPippa's own page, /embed/panel, and it does not know which browser it is in. It asks its host questions with window.postMessage, and whoever hosts it answers. Its first host was the family's Chrome extension. Voyager is the second, and it answers in cwkPippa's words. Voyager's house sign states the rule for every future session: a name on this wire changes only after it has changed in cwkPippa. That is what lets one panel serve two browsers without a fork.

What the panel can ask

PippaPanelRequest lists every question, and its JSON initializer returns nil for anything else, so an unknown message gets no answer at all. A request id is echoed back into the panel, so it is kept only while it is 200 characters or fewer.

The panel asksVoyager answers
pippa:request-contextpippa:host-context with the payload for the tab in front, or pippa:no-context with a reason Dad can read
pippa:capture-documentpippa:web-page-document: the whole page as text, with its images
pippa:capture-screenshotpippa:viewport-screenshot: a PNG of what Dad sees, with the view's payload beside it
pippa:insert-textpippa:insert-text-result, after typing into the field Dad last had focus in
pippa:set-display-modepippa:display-mode-result: only sidepanel succeeds, because dock and float are not built
pippa:close-embedpippa:close-embed-result, and then the window closes the panel
pippa:request-mic-permissionpippa:mic-permission-result with opened: false: there is nothing to open, because my origin already has the microphone (lesson 4)

Insert is the one request that changes a page, which is why invariant 8 counts it as an action that happens only on Dad's word. The panel sends it when Dad presses Insert, and it names the page it expects. The script refuses if the page changed, if no field has the cursor, or if the field is a password, a code or a card number.

How a message crosses

Loaded as the top document, the panel is its own parent, so whatever it posts to its parent lands on its own window. Voyager injects a small bridge script at document start, into the main frame only. It listens for message events whose source is that same window and whose type is one of the seven requests, and forwards them through a WKScriptMessageHandler named voyagerPanel. Only requests go through. Voyager's answers travel back on the same window, as window.postMessage(json, window.location.origin), and the bridge must never hand them back to Voyager as if the panel had asked. The gates on that handler are lesson 4.

The payload, and a field named for Chrome

The answer to a context request is a WebPagePayload. Its fields carry cwkPippa's names, which is why some of them look odd in Swift:

  • host_kind is always web-page, and browser_session_id is voyager.
  • chrome_tab_key and host_id both hold voyager:tab: followed by the tab's UUID in lower case.
  • source, frameUrl, human_label (the title, or the host when there is none), favicon, language and wordCount describe the page.
  • viewportText, selection, the viewport's size and scroll, and the page's full height describe the view.
  • readableText repeats viewportText for cwkPippa's older readers; the comment beside it warns that it holds the view, and the name promises more.
  • account_id and account_label name the identity the tab lives in, and related_sources carries the companions of lesson 4.

The tab key is the field the panel cares about most. The web-page kind has to know which tab a context belongs to, and because its first host was Chrome, the field is called chrome_tab_key. cwkPippa's panel refuses a context with no tab identity: such a context keys to the URL, and in Chrome that once made the panel flip to background tabs that were refreshing. Voyager sends no chrome_tab_id and fills chrome_tab_key with its own key. cwkPippa files each tab's Sidekick conversations under it, so switching tabs switches conversations.

Told once per page

The Sidekick does not poll. Voyager tells it when the tab in front changes, goes somewhere or finishes loading, and only once per page. frontTabChanged builds a stamp of the tab, its address, the page object, its content revision, the selection, whether sharing is on and the chosen companions. If the stamp equals the last one announced, nothing is sent. A title that changes by itself is not a new page, and cwkPippa keeps no timed refresh of its own. The read is asynchronous, so the stamp is checked again on both sides of it. If Dad moved on while the page was being read, the stale answer is dropped and the next stamp wins.

Nothing injected, ever

None of this reaches my prompt by itself. The panel stores what it is told as a snapshot in cwkPippa, and I ask for it when I want it: sidekick_host_read with the latest window for the current snapshot or the document window for the captured whole page in chunks, sidekick_host_search for matching excerpts, and sidekick_host_status for what is attached. At that boundary cwkPippa frames the page's free text, the selection and the viewport text, as untrusted evidence. A page can put any sentence it likes in front of Dad, and what reaches me is still marked as something a page said.

Code

cwkVoyager, Sources/VoyagerCore/PippaHost.swift: the file's contract, and the tab key cwkPippa files conversations under (verbatim, lines 1-20)·swift
// Voyager as a Sidekick host (architecture invariant 8). cwkPippa's
// `web-page` host kind has a panel that asks its host what the page in front
// is; Voyager answers. Nothing is injected into Pippa's prompt: the panel
// stores the answer as a snapshot she reads through `sidekick_host_*` tools.
//
// The vocabulary below is cwkPippa's (docs/PIPPA-CHROME-EMBED.md, "Web Page
// payload kind", and frontend/src/routes/embed/Panel.tsx). Its first host was
// the Chrome extension, so the tab-identity field is named `chrome_tab_key`;
// Voyager fills it with its own tab's key. The field is the kind's "which
// tab", and cwkPippa requires it: a payload without it is refused.

import Foundation
import Observation

public enum PippaWire {
    /// The key cwkPippa files a tab's Sidekick conversations under. The part
    /// after `:tab:` is the tab's identity there.
    public static func tabKey(_ tab: TabID) -> String {
        "voyager:tab:\(tab.raw.uuidString.lowercased())"
    }
cwkVoyager, Sources/VoyagerCore/PippaHost.swift: the seven requests, and nil for anything else (verbatim, lines 33-68)·swift
/// What the panel asks of its host.
public enum PippaPanelRequest: Equatable, Sendable {
    case context(requestID: String?)
    case document(requestID: String?)
    case screenshot(requestID: String?)
    case displayMode(mode: String, requestID: String?)
    case insertText(text: String, expectedSource: String?, requestID: String?)
    case close(requestID: String?)
    case microphone

    /// Nil for anything that is not one of the panel's requests.
    public init?(json: Data) {
        struct Raw: Decodable {
            struct Expected: Decodable { var source: String? }
            var type: String
            var requestId: String?
            var mode: String?
            var text: String?
            var expected: Expected?
        }
        guard let raw = try? JSONDecoder().decode(Raw.self, from: json) else { return nil }
        // A request id is echoed back into the panel; keep it a short token.
        let id = raw.requestId.flatMap { $0.count <= 200 ? $0 : nil }
        switch raw.type {
        case "pippa:request-context": self = .context(requestID: id)
        case "pippa:capture-document": self = .document(requestID: id)
        case "pippa:capture-screenshot": self = .screenshot(requestID: id)
        case "pippa:set-display-mode": self = .displayMode(mode: raw.mode ?? "", requestID: id)
        case "pippa:insert-text":
            self = .insertText(text: raw.text ?? "", expectedSource: raw.expected?.source, requestID: id)
        case "pippa:close-embed": self = .close(requestID: id)
        case "pippa:request-mic-permission": self = .microphone
        default: return nil
        }
    }
}
cwkVoyager, Sources/VoyagerCore/PippaHost.swift: the Sidekick hears about the front tab once per page (verbatim, lines 550-570)·swift
    /// The tab in front changed, went somewhere, or finished loading. The
    /// panel hears it once per page.
    public func frontTabChanged() {
        // Pippa herself asks nothing about the tab in front; only her
        // Sidekick panel is told.
        guard kind == .sidekick, state == .ready, let panel else { return }
        let tab = window.selectedTab
        // A page still loading has no text yet; it is announced when it lands.
        if tab?.status.isLoading == true { return }
        // A tab and where it is; a title that changes on its own is not a
        // new page, and cwkPippa keeps no timed refresh either.
        let current = sourceStamp
        guard current != announced else { return }
        announced = current
        Task {
            guard current == sourceStamp, panel === self.panel else { return }
            let reply = await contextReply(requestID: nil)
            guard current == sourceStamp, panel === self.panel else { return }
            panel.deliver(reply.json)
        }
    }

External links

Exercise

Design the message protocol between an embedded web panel and the native host of an app of yours. Write the request set as an enum whose JSON initializer returns nil for anything unknown, cap the request id you echo back, and give every reply a type name the panel already knows. Then write three tests: a message with an unknown type gets no answer at all, a page whose title changes by itself is not announced again, and an answer computed while the user switched tabs is dropped.
Hint
Decode into a private Raw struct with optional fields and switch on its type, so a new field in the panel never breaks the host. For announce-once, compare a stamp of everything that makes it a different document (tab, address, page object, a content revision bumped on reload) instead of running a timer, and check the stamp again after every await.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.