A batteries-included Rust crate for browser Native Messaging:
- Build a Native Messaging host that talks to your extension over stdin/stdout
- Install, verify, and remove the native host manifest for multiple browsers
- Safe framing + size caps + structured errors (
NmError) - Cross-platform: Windows / macOS / Linux
This crate aims to be the “it just works” choice for native messaging.
Native Messaging is how a browser extension talks to a local native process (your “host”). The protocol is:
- A 4-byte length prefix (
u32, native endianness) - Followed by that many bytes of UTF-8 JSON
Your host reads from stdin and writes to stdout.
- Disconnect is normal: when the extension disconnects or browser exits, stdin usually closes.
Treat
NmError::Disconnectedas a normal shutdown. - Never log to stdout: stdout is reserved for framed messages. Logging to stdout corrupts the stream. Log to stderr or a file.
- Size limits are real:
- host → browser: 1 MiB (enforced)
- browser → host: 64 MiB (enforced)
cargo add native_messagingTokio is required for the async host helpers (recommended). Use these features:
tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread", "sync"] }This is the easiest correct way to run a host continuously and reply to messages.
use native_messaging::host::{event_loop, NmError, Sender};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct In {
ping: String,
}
#[derive(Serialize)]
struct Out {
pong: String,
}
#[tokio::main]
async fn main() -> Result<(), NmError> {
event_loop(|raw: String, send: Sender| async move {
let incoming: In = serde_json::from_str(&raw).map_err(NmError::DeserializeJson)?;
send.send(&Out { pong: incoming.ping }).await?;
Ok(())
})
.await
}Do not use println!() in a host. It writes to stdout and breaks the protocol.
Use stderr:
eprintln!("host starting"); // ✅ safe
// println!("host starting"); // ❌ unsafeThis is what the extension side typically looks like:
const port = chrome.runtime.connectNative("com.example.host");
port.onMessage.addListener((msg) => {
console.log("native reply:", msg);
});
port.onDisconnect.addListener(() => {
console.log("native disconnected:", chrome.runtime.lastError);
});
port.postMessage({ ping: "hello" });You can test framing without stdin/stdout using an in-memory buffer:
use native_messaging::host::{encode_message, decode_message, MAX_FROM_BROWSER};
use serde_json::json;
use std::io::Cursor;
let msg = json!({"hello": "world"});
let frame = encode_message(&msg).unwrap();
let mut cur = Cursor::new(frame);
let raw = decode_message(&mut cur, MAX_FROM_BROWSER).unwrap();
let back: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(back, msg);These helpers read one message from stdin and write one reply to stdout.
For production hosts, prefer event_loop.
use native_messaging::{get_message, send_message};
use native_messaging::host::NmError;
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct In { ping: String }
#[derive(Serialize)]
struct Out { pong: String }
#[tokio::main]
async fn main() -> Result<(), NmError> {
let raw = get_message().await?;
let incoming: In = serde_json::from_str(&raw).map_err(NmError::DeserializeJson)?;
send_message(&Out { pong: incoming.ping }).await?;
Ok(())
}This crate includes an installer for writing/verifying/removing manifests for supported browsers.
Browser allowlists differ by family:
- Chromium-family uses
allowed_originslike:chrome-extension://<EXT_ID>/ - Firefox-family uses
allowed_extensionslike:[email protected]
use std::path::Path;
use native_messaging::{install, Scope};
let host_name = "com.example.host";
let description = "Example native messaging host";
// On macOS/Linux, this must be an absolute path.
let exe_path = Path::new("/absolute/path/to/host-binary");
// Chromium-family allow-list:
let allowed_origins = vec![
"chrome-extension://your_extension_id/".to_string(),
];
// Firefox-family allow-list:
let allowed_extensions = vec![
"[email protected]".to_string(),
];
// Install for selected browsers by key:
let browsers = &["chrome", "firefox", "edge"];
install(
host_name,
description,
exe_path,
&allowed_origins,
&allowed_extensions,
browsers,
Scope::User,
).unwrap();use native_messaging::{verify_installed, Scope};
let ok = verify_installed("com.example.host", None, Scope::User).unwrap();
assert!(ok);use native_messaging::{remove, Scope};
remove("com.example.host", &["chrome", "firefox", "edge"], Scope::User).unwrap();Native messaging failures are usually manifest issues, not code.
Check:
- The extension calls the exact same
host_nameyou installed (case-sensitive). - The manifest exists at the expected location (User vs System scope).
- The manifest JSON is valid.
Check:
- Chromium-family:
allowed_originscontains exactchrome-extension://<id>/ - Firefox-family:
allowed_extensionscontains your addon ID
Check:
- The manifest
pathpoints to a real executable. - On macOS/Linux, manifest
pathis absolute. - Your host does not log to stdout.
- Your host handles disconnect cleanly (EOF →
NmError::Disconnected).
This almost always means stdout was corrupted by logs. Switch logging to stderr/file.
Most users only need:
-
Host:
native_messaging::host::event_loopnative_messaging::host::Sendernative_messaging::host::NmError
-
Installer:
native_messaging::installnative_messaging::verify_installednative_messaging::removenative_messaging::Scope
cargo test
cargo test --doccargo clippy -- -D warningsMIT