Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 55 additions & 5 deletions crates/box-host/src/active_tab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ pub struct ActiveTab {
/// When the extension last reported, in ms since the epoch.
#[serde(rename = "atMs", skip_serializing_if = "Option::is_none")]
pub at_ms: Option<u64>,
/// What kind of box has focus on that page, as its page said (`docker/active-tab/focus.js`):
/// `password`, `text`, `other` or `none`; `None` while the page has not said. The server
/// types a password only into a `password` box.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub focus: Option<String>,
/// How many reports this browser has made: a reader that clicked a field waits for it to
/// move before it trusts `focus`, so an older report is not taken for the click's.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seq: Option<u64>,
}

/// One native-messaging message: a 4-byte length in native byte order, then that much JSON.
Expand All @@ -58,16 +67,20 @@ pub fn read_message(input: &mut impl Read) -> io::Result<Option<serde_json::Valu
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
}

/// Keep `url` as the page in front: a new file renamed over the old, so a reader never sees
/// half of one.
pub fn record(path: &Path, url: &str) -> io::Result<()> {
/// Keep `url` as the page in front, with the kind of box focused on it and the report's number:
/// a new file renamed over the old, so a reader never sees half of one.
pub fn record(path: &Path, url: &str, focus: Option<&str>, seq: u64) -> io::Result<()> {
let at_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
// Only the words the extension uses: anything else is not the extension speaking.
let focus = focus.filter(|f| matches!(*f, "password" | "text" | "other" | "none"));
let tab = ActiveTab {
url: Some(url.to_string()),
at_ms: Some(at_ms),
focus: focus.map(str::to_string),
seq: Some(seq),
};
let tmp = path.with_extension("json.tmp");
let mut file = std::fs::File::create(&tmp)?;
Expand All @@ -84,16 +97,21 @@ pub fn read(path: &Path) -> ActiveTab {
.unwrap_or(ActiveTab {
url: None,
at_ms: None,
focus: None,
seq: None,
})
}

/// The host's whole life: record every report until Chromium closes the port, then forget,
/// so a browser that is gone is never taken for one showing its last page.
pub fn serve(input: &mut impl Read, path: &Path) -> io::Result<()> {
let result = (|| {
let mut seq = 0;
while let Some(message) = read_message(input)? {
if let Some(url) = message.get("url").and_then(serde_json::Value::as_str) {
record(path, url)?;
seq += 1;
let focus = message.get("focus").and_then(serde_json::Value::as_str);
record(path, url, focus, seq)?;
}
}
Ok(())
Expand Down Expand Up @@ -123,7 +141,7 @@ mod tests {
// Read as it arrives: after both reports, the second is in front.
let mut reader = &input[..];
while let Some(message) = read_message(&mut reader).unwrap() {
record(&path, message["url"].as_str().unwrap()).unwrap();
record(&path, message["url"].as_str().unwrap(), None, 1).unwrap();
}
assert_eq!(
read(&path).url.as_deref(),
Expand All @@ -135,6 +153,38 @@ mod tests {
assert_eq!(read(&path).url, None);
}

/// The focused box's kind comes through with each report, numbered, and only in the
/// extension's own words.
#[test]
fn each_report_says_what_kind_of_box_has_focus() {
let dir = std::env::temp_dir().join(format!("active-tab-focus-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("tab.json");
let fb = "https://www.facebook.com/login";
let mut input = framed(&format!(r#"{{"url":"{fb}","focus":"text"}}"#));
input.extend(framed(&format!(r#"{{"url":"{fb}","focus":"password"}}"#)));
input.extend(framed(&format!(r#"{{"url":"{fb}","focus":"<script>"}}"#)));

let mut reader = &input[..];
let mut seq = 0;
let mut seen = Vec::new();
while let Some(message) = read_message(&mut reader).unwrap() {
seq += 1;
let focus = message["focus"].as_str();
record(&path, message["url"].as_str().unwrap(), focus, seq).unwrap();
let now = read(&path);
seen.push((now.focus, now.seq));
}
assert_eq!(
seen,
[
(Some("text".to_string()), Some(1)),
(Some("password".to_string()), Some(2)),
(None, Some(3)),
]
);
}

#[test]
fn a_message_longer_than_any_url_is_refused() {
let input = (MAX_MESSAGE + 1).to_ne_bytes();
Expand Down
24 changes: 20 additions & 4 deletions docker/active-tab/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
// login, and types nothing when the page in front is not the login's own site: on a computer
// several Bots share, another Bot may have brought its own page forward.
//
// Only the `tabs` permission: the URL and nothing of the page, its cookies or its storage.
// An open native port also keeps this service worker alive.
// With it, what kind of box has focus on that page (`focus.js`: password, text, other or none),
// so a password is typed only into a password box. Only the `tabs` permission and that kind:
// nothing of the page, its cookies or its storage. An open native port also keeps this service
// worker alive.
const HOST = "dev.opengrok.active_tab";
let port = null;
// Each tab's focused box, as its page last said; a tab that has not said is unknown.
const focusByTab = new Map();

function connect() {
port = chrome.runtime.connectNative(HOST);
Expand All @@ -21,16 +25,28 @@ async function report() {
if (!port) return;
const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
try {
port.postMessage({ url: tab ? tab.url || tab.pendingUrl || "" : "" });
const focus = tab && focusByTab.has(tab.id) ? focusByTab.get(tab.id) : null;
port.postMessage({ url: tab ? tab.url || tab.pendingUrl || "" : "", focus });
} catch (_) {
// The port closed between the check and the send; onDisconnect reconnects.
}
}

chrome.tabs.onActivated.addListener(report);
chrome.tabs.onUpdated.addListener((_id, change, tab) => {
// A new page has said nothing of its focus yet.
if (change.url) focusByTab.delete(_id);
if (tab.active && (change.url || change.status)) report();
});
chrome.tabs.onRemoved.addListener(report);
chrome.tabs.onRemoved.addListener((id) => {
focusByTab.delete(id);
report();
});
chrome.runtime.onMessage.addListener((message, sender) => {
// Only a page's own top frame speaks for its focus.
if (!sender.tab || sender.frameId !== 0 || typeof message.focus !== "string") return;
focusByTab.set(sender.tab.id, message.focus);
if (sender.tab.active) report();
});
chrome.windows.onFocusChanged.addListener(report);
connect();
34 changes: 34 additions & 0 deletions docker/active-tab/focus.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Tells the extension what kind of box has focus on this page: a password box, another place
// to type text, or none. The server reads it (with the page in front) before it types each
// value of a login, and types a password only into a password box: on 10 Oct 2026 a fill
// whose click missed typed a password into Facebook's email box and pressed Log in.
//
// Only the kind is sent, never what is in the box or anything else of the page.
function kind() {
const el = document.activeElement;
if (!el || el === document.body || el === document.documentElement) return "none";
if (el.tagName === "INPUT") {
const type = (el.getAttribute("type") || "text").toLowerCase();
if (type === "password") return "password";
const text = ["text", "email", "tel", "search", "url", "number"];
return text.includes(type) ? "text" : "other";
}
if (el.tagName === "TEXTAREA" || el.isContentEditable) return "text";
return "other";
}

function tell() {
try {
chrome.runtime.sendMessage({ focus: kind() });
} catch (_) {
// The extension was reloaded under this page; the next page load reconnects.
}
}

// After focus moves, not during: on focusout the new element is not focused yet.
const later = () => setTimeout(tell, 0);
document.addEventListener("focusin", later, true);
document.addEventListener("focusout", later, true);
// A type changed in place (a "show password" toggle) is a different kind of box.
document.addEventListener("input", later, true);
tell();
22 changes: 19 additions & 3 deletions docker/active-tab/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,24 @@
"manifest_version": 3,
"name": "Box active tab",
"version": "1.0",
"description": "Tells the box which page is in front, so a saved login is typed only into its own site.",
"description": "Tells the box which page is in front and what kind of box has focus there, so a saved login is typed only into its own site and a password only into a password box.",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtOWylc0UvRLh2wU3OEVDxJFzXgl5wc4dKOzeLDYYgf86/+7+2IUD4bo3VgPcA29xeg+MgEFYadEGvxDgdjwI/k1MDN3glA+ERP6ou/xOLnUPZUg3No0B1ElZ7HfmHl/Ia5BM96D9tRhLQUw+5ggv0v3StzjlJa0Vp+/5Lrwd6A0XbKgGUkcslJKw3uJeqX1NjcO4vwjZjWQJRcAA9UD26mH5NqtjaxJcPnsbd7fJxR3121ll/GBsZItMnE/63LKWz34At/0IrViX5apXGsJnJ81wCg4ahgsQJ9XLiYT5K9BRK81xGz+NNM/NkN1Bv7W1v2aXoKWMtJjaG1DFHdwKfwIDAQAB",
"permissions": ["tabs", "nativeMessaging"],
"background": { "service_worker": "background.js" }
"permissions": [
"tabs",
"nativeMessaging"
],
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": [
"<all_urls>"
],
"js": [
"focus.js"
],
"run_at": "document_idle"
}
]
}
Loading