Skip to content

Native Messaging

GrotTrack reads the active Chrome tab through Chrome's native messaging protocol rather than macOS Automation (Apple Events). This page documents the actual wire format and the host/extension discovery mechanism as implemented in GrotTrack/Services/NativeMessageHost.swift, GrotTrackNativeHost/main.swift, GrotTrack/Utilities/ChromeExtensionInstaller.swift and grot-track-extension/entrypoints/background.ts.

Why native messaging instead of Automation

A JXA (JavaScript for Automation) based tab reader needs the Automation entitlement, which prompts the user for cross-app control every time a new controlling process is involved. Native messaging needs no such permission: Chrome itself launches the host as a subprocess and talks to it over stdio, so GrotTrack only needs the extension to be installed and connected.

Components

PieceFileRole
Extension background workergrot-track-extension/entrypoints/background.tsWatches Chrome tab/window events, opens a native messaging port, posts tab updates
Native messaging hostGrotTrack/Services/NativeMessageHost.swift, compiled into the GrotTrackNativeHost binarySubprocess Chrome launches; reads stdin, decodes messages, relays them locally
Host manifest installerGrotTrack/Utilities/ChromeExtensionInstaller.swiftWrites/checks/removes the native host manifest JSON that lets Chrome find the host
Tab cacheGrotTrack/Services/BrowserTabService.swiftRuns inside GrotTrack.app; receives the relayed tab data and caches it for ActivityTracker

Message shape (extension to host)

The extension's background.ts builds this object and sends it with port.postMessage(message) over the native messaging port:

interface TabMessage {
  type: 'activeTab';
  title: string;
  url: string;
  tabId: number;
  windowId: number;
  timestamp: number;   // Date.now(), i.e. milliseconds since epoch
}

It is sent whenever chrome.tabs.onActivated, a title/URL change on the active tab via chrome.tabs.onUpdated, or chrome.windows.onFocusChanged fires, debounced to one send per 300ms.

On the Swift side, NativeMessageHost.BrowserTabMessage decodes the same JSON with every field except title and url optional:

struct BrowserTabMessage: Codable, Sendable {
    let type: String?
    let title: String
    let url: String
    let tabId: Int?
    let windowId: Int?
    let timestamp: Double?
}

Wire format (Chrome's native messaging framing)

Chrome's native messaging protocol frames every message as a 4-byte little-endian UInt32 length prefix followed by that many bytes of UTF-8 JSON — this is Chrome's own protocol, not something GrotTrack invented, and NativeMessageHost.readMessage() implements exactly this:

[4 bytes: little-endian UInt32 length][N bytes: UTF-8 JSON payload]

readMessage() rejects a length of 0 or >= 1_000_000 as invalid, and treats a short read on either the length prefix or the payload as stdinClosed / incompleteRead respectively. On stdinClosed the host's message loop exits cleanly; any other decode error is swallowed and the loop continues to the next message.

From host to the app: local relay, not a second network hop

NativeMessageHost does not write to SwiftData or talk to GrotTrack.app directly — it is a separate process. Instead, postToMainApp(_:) broadcasts the decoded tab data with NSDistributedNotificationCenter, a same-machine, same-user, no-network IPC mechanism built into macOS:

DistributedNotificationCenter.default().postNotificationName(
    NSNotification.Name(GrotTrackIPC.browserTabNotification),
    object: nil,
    userInfo: userInfo as [AnyHashable: Any],
    deliverImmediately: true
)

The notification name is the single constant com.grottrack.browserTab, defined once in GrotTrack/Utilities/SharedConstants.swift (GrotTrackIPC.browserTabNotification) and shared by both the host and app targets so there is exactly one place the name can drift.

BrowserTabService, running inside GrotTrack.app, registers an observer for that notification name on launch and caches the most recent title/url/windowId plus a lastUpdated timestamp. It reports data as stale — isConnected returns false and activeTabTitle/activeTabURL return nil — once more than 10 seconds have passed since the last update. ActivityTracker reads these accessors on its normal ~3 second poll cycle; there is no push path directly from the host into the tracker.

Process model

GrotTrackNativeHost/main.swift is a small synchronous wrapper: it creates a NativeMessageHost actor, starts its runMessageLoop() in a Task, and blocks the process on a DispatchSemaphore until that loop exits (on stdinClosed, i.e. Chrome tearing down the port). Chrome owns the subprocess lifecycle entirely — it starts the host when the extension calls chrome.runtime.connectNative, and terminates it when the port disconnects.

The host manifest: how Chrome finds the host binary

Chrome discovers native messaging hosts through a JSON manifest file, one per host name, at a fixed OS-specific path. ChromeExtensionInstaller manages this file for GrotTrack:

  • Location: ~/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.grottrack.tabtracker.json (ChromeExtensionInstaller.chromeNativeHostsDir + manifestFileName).
  • Contents (ChromeHostManifest, written with .prettyPrinted, .sortedKeys JSON encoding):
{
  "allowed_origins": ["chrome-extension://<extension-id>/"],
  "description": "GrotTrack native messaging host for browser tab tracking",
  "name": "com.grottrack.tabtracker",
  "path": "/Applications/GrotTrack.app/Contents/MacOS/GrotTrackNativeHost",
  "type": "stdio"
}
  • name (com.grottrack.tabtracker) is the identifier background.ts passes to chrome.runtime.connectNative(NATIVE_HOST) — the two must match exactly, and both are the same literal string kept independently in the Swift installer and the extension's TypeScript source.
  • path is computed at install time as Bundle.main.bundlePath + "/Contents/MacOS/GrotTrackNativeHost" — i.e. wherever the currently-running GrotTrack.app was launched from, not a hardcoded /Applications path.
  • type is always stdio, the only transport Chrome's native messaging protocol supports for desktop hosts.

Extension ID and the placeholder state

Chrome only allows the extension listed in allowed_origins to connect to a given host name, so the manifest must contain the real, installed extension's ID. ChromeExtensionInstaller handles this with an explicit placeholder state rather than silently installing a manifest that can never match:

  • Calling installNativeHost(extensionID:) with nil or an empty string writes chrome-extension://EXTENSION_ID_PLACEHOLDER/ as the sole allowed origin.
  • checkInstallation() returns .needsExtensionID whenever any allowed origin still contains the string "PLACEHOLDER".
  • updateExtensionID(_:) re-installs the manifest with the real ID, replacing the placeholder.

checkInstallation()'s other states are .notInstalled (no manifest file), .corruptManifest (file exists but fails to decode as ChromeHostManifest), .binaryMissing(expectedPath:) (the manifest decodes but manifest.path is not an executable file), and .installed (manifest valid, binary present, no placeholder origin remaining).

uninstallNativeHost() deletes the manifest file if present; it does not touch the extension itself, which is removed independently from chrome://extensions.

Where this is configured in the app

Settings → Browser exposes install/update/uninstall for the native host and lets the user paste in the extension ID once it has been loaded — either the locally-loaded developer ID (from chrome://extensions with Developer Mode on) or the published Chrome Web Store ID once the extension has gone through review. See Extension for the extension side of the same handshake and Building for how to load the extension unpacked during development.