Architecture
Overview
TabCtl uses a D-Bus-based architecture to enable command-line control of browser tabs. The system consists of three main components that communicate through well-defined interfaces.
Component Architecture
Browser Extension ← Native Messaging → tabctl-mediator ← D-Bus → tabctl CLI
Components
1. Browser Extension (extensions/)
Purpose: Interface with browser’s tab API and communicate with mediator.
Key Files:
background.js- Main extension logicmanifest.json- Extension configuration
Responsibilities:
- Listen for native messaging connections (JSON-RPC 2.0 subset)
- Execute tab operations (list, activate, navigate, close, open)
- Return tab data as structured JSON
- Handle browser-specific APIs (Chrome vs Firefox)
Communication:
- Input: JSON commands via stdin from mediator
- Output: JSON responses via stdout to mediator
2. Mediator (cmd/tabctl-mediator/)
Purpose: Bridge between browser extension (native messaging) and CLI (D-Bus).
Key Files:
main.go- Entry point, lifecycle managementinternal/mediator/mediator.go- Core orchestrationinternal/mediator/browser_api.go- Extension communicationinternal/mediator/browser_handler.go- D-Bus interface adapterinternal/mediator/transport.go- Native messaging protocol
Responsibilities:
- Claim the first free D-Bus instance name for its browser
- Translate between native messaging and D-Bus protocols
- Handle browser lifecycle (exit when browser closes)
- Log errors to
$XDG_STATE_HOME/tabctl/mediator-<browser>.log, rotated to.log.1once it passes 8MB (default~/.local/state/tabctl/). Profiles of one browser share a file, so each line is prefixed with the instance that wrote it.
Communication:
- Stdin/Stdout: Native messaging with browser extension
- D-Bus: Service at
dev.slastra.TabCtl.<Instance>, where instance is the browser name for the first profile to connect and a numbered variant for each one after it
3. CLI (cmd/tabctl/)
Purpose: User interface for tab control commands.
Key Files:
main.go- Entry pointinternal/cli/*.go- Command implementationsinternal/client/browser_manager.go- Multi-browser orchestrationinternal/client/dbus_client.go- D-Bus communicationinternal/dbus/client.go- Low-level D-Bus operations
Responsibilities:
- Parse command-line arguments
- Discover available browsers via D-Bus
- Route commands to appropriate browser
- Format output (TSV, JSON, simple)
Data Flow
List Tabs Example
1. User executes: tabctl list
2. CLI discovers D-Bus services (Firefox, Chrome, Chrome2)
3. For each instance:
- CLI calls ListTabsWithIcons() via D-Bus, falling back to ListTabs()
against a mediator that predates it
- Mediator receives D-Bus call
- Mediator sends {"jsonrpc":"2.0","id":N,"method":"list_tabs"} to extension
- Extension queries browser tabs API
- Extension returns structured tab objects, each with favIconUrl
- Mediator maps to TabInfoWithIcon[] for D-Bus
- CLI composes IDs and formats output
4. CLI displays combined results to user
Activate Tab Example
1. User executes: tabctl activate firefox.1.2
2. CLI parses tab ID: browser=firefox, tab=2
3. CLI sends ActivateTab(2, false) via D-Bus
4. Mediator receives call and forwards to extension:
{"jsonrpc":"2.0","id":N,"method":"activate_tab","params":{"tab_id":2,"focused":false}}
5. Extension activates tab:
- tabs.update(2, {active: true})
- windows.update(windowId, {focused: true}) // only when focused is set
6. Window focus behavior:
- Firefox: Window manager switches desktop and focuses
- Chrome/Brave: Focuses only if on current desktop
7. Extension returns success to mediator
8. Mediator returns success via D-Bus
9. CLI reports: "Activated tab firefox.1.2"
Navigate Tab Example
navigate loads a URL in a tab that already exists. The tab keeps its index,
its pinned state and its history, which open followed by close cannot
offer. It does not activate the tab; compose it with activate for that.
1. User executes: tabctl navigate firefox.1.2 https://example.com
2. CLI parses tab ID: browser=firefox, tab=2
3. CLI sends NavigateTab(2, "https://example.com") via D-Bus
4. Mediator forwards to extension:
{"jsonrpc":"2.0","id":N,"method":"navigate_tab","params":{"tab_id":2,"url":"https://example.com"}}
5. Extension loads the page: tabs.update(2, {url: "https://example.com"})
6. Extension returns success to mediator
7. Mediator returns success via D-Bus
8. CLI reports: "Navigated tab firefox.1.2 to https://example.com"
A CLI talking to a mediator that predates NavigateTab gets an “UnknownMethod”
reply from D-Bus. The client turns that into an instruction to restart the
browser, since the usual cause is a mediator process that outlived the package
upgrade.
D-Bus Interface
Service Names
dev.slastra.TabCtl.Firefoxdev.slastra.TabCtl.Bravedev.slastra.TabCtl.Chrome
One mediator process runs per browser profile, and the profile is invisible
from the mediator’s side: Chrome passes only the extension origin in argv, all
profiles share one browser process, and the native-host manifest directory is
per-user rather than per-profile. So each mediator claims the first free
instance name for its browser instead of insisting on one: …TabCtl.Chrome,
then …TabCtl.Chrome2, up to MaxInstances. RequestName is atomic on the
bus, so mediators racing for a slot cannot both win it.
The suffix is a bare digit because it has to be legal in a D-Bus object path
element (letters, digits, underscore) and must not contain the . that
delimits a user-facing tab ID.
Object Path
/dev/slastra/TabCtl/Browser/<BrowserName>
Interface
dev.slastra.TabCtl.Browser
Methods
type BrowserHandler interface {
ListTabs() ([]TabInfoWithIcon, error)
ActivateTab(tabID int32, focused bool) error
NavigateTab(tabID int32, url string) error
CloseTabs(tabIDs []int32) error
OpenTab(url string) (windowID, tabID int32, err error)
GetInfo() Info
}
Methods return only an error (no redundant success bool). GetInfo reports
mediator/extension versions and protocol compatibility for tabctl status.
TabInfo Structure
type TabInfo struct { // ListTabs -> a(iissibb)
WindowID int32
TabID int32
Title string
URL string
Index int32
Active bool
Pinned bool
}
type TabInfoWithIcon struct { // ListTabsWithIcons -> a(iissibbs)
// ...same fields, plus:
FavIconURL string
}
The D-Bus layer carries raw browser-assigned numeric IDs; the CLI composes the user-facing token from them.
TabInfo’s field set is frozen. D-Bus matches struct signatures by arity,
so appending a field to it would break every consumer compiled against
a(iissibb) at the call, including out-of-tree ones. New per-tab data goes on
TabInfoWithIcon behind a new method instead, and the handler always returns
the richer shape with the server projecting it down for ListTabs. The same
rule applies to the next field added: extend ListTabsWithIcons’s successor,
don’t mutate a published signature.
ListTabs and ListTabsWithIcons share one round trip to the extension:
list_tabs already returns favIconUrl for every tab, so the richer method
costs nothing extra and the cheaper one just drops the field.
Tab ID Format
The user-facing tab ID is <browser>.<window_id>.<tab_id>, prefixed with the
lowercased browser name so multiple browsers (e.g. Brave + Helium) can be
addressed unambiguously:
- Examples:
firefox.1.2,brave.999.42,helium.1874583011.1874583012
A second profile of the same browser carries its instance suffix into the
token (chrome2.5.678), so profiles are addressable separately. --browser
chrome matches every Chrome profile; --browser chrome2 narrows to one.
The CLI’s D-Bus client composes this token from the raw windowId/tabId
fields in TabInfo; the mediator and extension speak only the numeric IDs.
IDs are ephemeral (generated by list, consumed by activate, navigate and
close).
Native Messaging Protocol
A JSON-RPC 2.0 subset. Message kind is disambiguated by shape: a method
field marks a request; a result/error field marks a response. Requests
and responses correlate by id.
Handshake
On connect the extension announces itself; the mediator replies with its own
version. A protocol-version mismatch is surfaced to the CLI (tabctl status
and a clear error on any command) rather than failing silently.
// extension → mediator
{"jsonrpc":"2.0","id":0,"method":"hello",
"params":{"extensionVersion":"2.0.0","protocolVersion":2}}
// mediator → extension
{"jsonrpc":"2.0","id":0,"result":{"mediatorVersion":"2.0.0","protocolVersion":2}}
Request / Response
// mediator → extension (request)
{"jsonrpc":"2.0","id":42,"method":"activate_tab","params":{"tab_id":123,"focused":false}}
// extension → mediator (response)
{"jsonrpc":"2.0","id":42,"result":null}
// or, on failure
{"jsonrpc":"2.0","id":42,"error":{"code":-32000,"message":"no tab with id 123"}}
list_tabs returns an array of structured tab objects (windowId, tabId,
title, url, index, active, pinned), not TSV.
Message Framing
Native messaging uses length-prefixed JSON:
- 4-byte little-endian integer (message length)
- JSON message body
Process Lifecycle
Mediator Startup
- Browser launches mediator via native messaging
- Mediator detects browser from command-line args
- Claims the first free instance name for that browser, so a second profile takes the next one rather than failing on the collision
- Logs startup to
$XDG_STATE_HOME/tabctl/mediator-<browser>.log
Mediator Shutdown
- Browser closes → stdin EOF
- Transport reader goroutine reports the disconnect on its error channel
- Unregisters from D-Bus
- Process exits cleanly
Multi-Browser Support
- One mediator process per browser profile, not per browser. A browser with three profiles runs three mediators, each on its own instance name.
- Mediators run independently
- CLI discovers all via D-Bus name listing
- Commands can target one instance, every profile of a browser, or all
Supported-Browser Table (internal/browsers)
One table defines every browser tabctl knows. Two consumers read it:
tabctl install to decide where to write native-messaging manifests, and the
mediator to decide what to call itself on the bus. They used to keep separate
hand-written lists and drifted apart.
Each row carries a display Name, an identity Key, a Family
(firefox or chromium), the profile ConfigDir, and the executable names to
probe on PATH. The manifest directory is derived from ConfigDir, never
stored, so a path cannot be spelled two ways.
Name and Key are separate on purpose. Name is for humans and may contain
spaces. Key is the identity token that becomes a D-Bus name element, an
object path element, and the lowercased tab-ID prefix, so it is restricted to
[A-Za-z_][A-Za-z0-9_]* and must not end in a digit, which is reserved for the
profile-instance suffix.
Browser detection matches the parent process executable against the whole path,
longest name first. Matching the basename is not enough: every Brave channel
package ships its binary as brave under a channel-specific directory, so
/opt/brave-origin-bin/brave is Brave Origin and /opt/brave-bin/brave is
Brave. Longest-first also removes the unrecorded ordering the previous
hand-written switch depended on, where google-chrome had to be tested before
chrome.
Brave’s eight profile directories (Browser and Origin editions, each across stable, Beta, Nightly, and Development) are generated rather than typed out.
Directory Structure
tabctl/
├── cmd/
│ ├── tabctl/ # CLI entry point
│ └── tabctl-mediator/ # Mediator entry point
├── internal/
│ ├── browsers/ # Supported-browser table & detection
│ ├── cli/ # Command implementations
│ ├── client/ # D-Bus client & browser manager
│ ├── config/ # Timeouts & extension IDs
│ ├── dbus/ # D-Bus primitives
│ ├── errors/ # Error types
│ ├── mediator/ # Mediator core logic
│ └── utils/ # Shared utilities
├── pkg/
│ ├── api/ # Public interfaces
│ └── types/ # Shared types
├── extensions/
│ ├── firefox/ # Firefox extension (generated background.js)
│ ├── chrome/ # Chrome/Brave extension (generated background.js)
│ └── shared/ # Extension source (core.js + adapters)
└── scripts/
├── build-extensions.sh # Assemble extensions from shared source
├── integration-test.py # End-to-end mediator+CLI test over D-Bus
├── rofi-tabctl-wmctrl.sh # X11 integration
└── rofi-tabctl-hyprland.sh # Hyprland (Wayland) integration
Error Handling
Connection Errors
- D-Bus registration conflicts logged and handled
- Browser disconnection triggers clean shutdown
- Network errors bubble up to CLI with context
Command Errors
- Invalid tab IDs return error to CLI
- Browser API failures logged in mediator
- Timeout protection on all operations
Security Considerations
- Native Messaging: Only registered extensions can launch mediator
- D-Bus: Session bus only (user isolation)
- No Network: All communication is local IPC
- No Elevated Privileges: Runs as user process
- Minimal Permissions: Extensions use only necessary browser APIs
Performance
- D-Bus: ~1ms latency for local calls
- Tab List: <50ms for 100 tabs
- Tab Activation: <100ms including window focus
- Memory: ~10MB per mediator process
- CPU: Near zero when idle
Window Focus Behavior
The activate command’s window focusing behavior varies by browser:
Firefox
Firefox’s browser.windows.update() API with {focused: true} triggers automatic desktop switching:
// Firefox extension calls
browser.windows.update(windowId, {focused: true})
Modern window managers (KDE, GNOME, etc.) respond by:
- Switching to the virtual desktop containing the window
- Raising the window to the top
- Giving it keyboard focus
Chromium Family
Chromium-based browsers have more limited window focus capabilities:
chrome.windows.update()focuses the window if on current desktop- Does not trigger automatic desktop/workspace switching
- User must manually switch to the appropriate desktop first
This difference is due to browser API implementations, not TabCtl limitations.
Future Enhancements
- WebSocket support for remote control
- Tab search and filtering in mediator
- Batch operations optimization
- Persistent mediator mode for faster operations