Browser bridge in the core (shared by the Mac and desktop apps); webpki security update
ci / test (push) Canceled after 0s
ci / wasm (push) Canceled after 0s
ci / deny (push) Canceled after 0s
ci / fuzz (push) Canceled after 0s

- crates/kv-ipc: native-messaging framing and the local IPC socket/pipe (moved from kestrove-desktop),
  plus Listener::bind_in/connect_in and, on macOS, the App Group socket for the kestrove-nmh bundled
  in Kestrove.app (Contents/Resources/kestrove-app-group).
- crates/kv-nmh: the native-messaging host, with an embedded Info.plist on macOS so it can run
  sandboxed (KV_NMH_BUNDLE_ID).
- kv-ffi feature browser-bridge: broker, pairing channel and word list (moved from kestrove-desktop)
  and BrowserBridge for Swift. build-apple.sh enables it and builds a universal kestrove-nmh.
- RUSTSEC-2026-0381/0382: rustls-webpki 0.103.16, webpki-roots 1.0.10 (distrusted emSign roots).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-21 10:02:45 +09:00
co-authored by Claude Opus 5.5
parent d45033f424
commit 9044b00a2b
17 changed files with 2079 additions and 18 deletions
+65
View File
@@ -0,0 +1,65 @@
//! Native messaging host for Chrome/Edge/Brave/Firefox (kestrove-spec/docs/05).
//!
//! The browser starts this process and talks over stdio: each message is a u32 length (native
//! byte order) followed by UTF-8 JSON. This process holds **no** secrets and can't read the
//! traffic: frames are end-to-end encrypted between the extension and the app (pairing channel,
//! see `channel.rs`). It relays each frame to the running app over local IPC (`ipc.rs`) and
//! relays the app's reply back, strictly one reply per request.
//!
//! When the app isn't running, every request gets `{t:"error", code:"unavailable"}`; it
//! reconnects on the next request, so starting the app later just works.
use std::io::{self, Write};
use kv_ipc::framing::{read_frame, read_json, write_frame, write_json};
use kv_ipc::ipc::{self, Conn};
use serde_json::json;
/// Chrome passes the caller origin (`chrome-extension://<id>/`) as the first argument; Firefox
/// passes the manifest path and then the extension id. Informational only (shown when pairing):
/// the browser's `allowed_origins` check and the pairing key are what actually gate access.
fn caller() -> String {
let args: Vec<String> = std::env::args().skip(1).collect();
match args.as_slice() {
[a, ..] if a.starts_with("chrome-extension://") => a.clone(),
[_, id, ..] if !id.starts_with("--") => id.clone(),
_ => String::new(),
}
}
fn connect(caller: &str) -> io::Result<Conn> {
let mut c = ipc::connect()?;
write_json(&mut c, &json!({ "t": "nmh_hello", "caller": caller }))?;
match read_json(&mut c)? {
Some(v) if v["t"] == "ok" => Ok(c),
_ => Err(io::Error::new(io::ErrorKind::InvalidData, "unexpected hello reply")),
}
}
fn relay(conn: &mut Conn, frame: &[u8]) -> io::Result<Vec<u8>> {
write_frame(conn, frame)?;
read_frame(conn)?.ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "app closed the connection"))
}
fn main() -> io::Result<()> {
let caller = caller();
let (mut stdin, mut stdout) = (io::stdin().lock(), io::stdout().lock());
let unavailable = serde_json::to_vec(&json!({ "t": "error", "code": "unavailable" }))?;
let mut conn: Option<Conn> = None;
while let Some(frame) = read_frame(&mut stdin)? {
if conn.is_none() {
conn = connect(&caller).ok();
}
let reply = match conn.as_mut().map(|c| relay(c, &frame)) {
Some(Ok(r)) => r,
Some(Err(_)) => {
conn = None;
unavailable.clone()
}
None => unavailable.clone(),
};
write_frame(&mut stdout, &reply)?;
stdout.flush()?;
}
Ok(())
}