The fork's CI had never been green. Pre-existing failures: - version-sync: check-version-sync.js read packages/dashboard/package.json, which doesn't exist in this fork (workspace is just "."). Drop the dashboard comparison; check package.json vs cli/Cargo.toml only. - Dashboard job: `pnpm install --filter dashboard` for a non-existent package. Remove the job. - Format check: repo was never `cargo fmt`-clean. Ran cargo fmt (mechanical). - Clippy -D warnings (newly enforced on Rust 1.94 stable): manual_contains in commands.rs (.iter().any()->.contains()), question_mark in element.rs (if-let-Err -> ?), result_large_err on the tungstenite handshake callback in connect.rs (allow — the Result type is fixed by the accept_hdr_async contract). - rust-cross: lightpanda::waits_for_ready_without_logs spawns a real process + binds a socket with timing assumptions; flaky in CI. Marked #[ignore]. Also: skill docs note fork.30's relay-preferred auto-connect (plain `agent-browser open` is dialog-free once the ab-connect extension is loaded) and the extension's new "agent-browser-stealth" display name.
242 lines
7.4 KiB
Rust
242 lines
7.4 KiB
Rust
//! `find-url` — search the user's local Chrome/Edge **bookmarks** for pages they
|
|
//! saved, by keyword. Borrowed from web-access's `find-url.mjs`; lets an agent
|
|
//! locate an internal system or a previously-saved page that public search
|
|
//! can't reach, without opening a browser.
|
|
//!
|
|
//! v1 covers bookmarks only (a zero-dependency JSON read). Visited-history lives
|
|
//! in a locked SQLite DB and would need a SQLite dependency — not included yet.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use serde_json::Value;
|
|
|
|
use crate::color;
|
|
|
|
struct Hit {
|
|
name: String,
|
|
url: String,
|
|
folder: String,
|
|
date_added: i64,
|
|
}
|
|
|
|
/// Entry point for the `find-url` subcommand. `args` is the full cleaned argv
|
|
/// (including the leading "find-url").
|
|
pub fn run_find_url(args: &[String], json: bool) {
|
|
// Parse flags out of args[1..]; everything else is a keyword.
|
|
let mut browser = "chrome".to_string();
|
|
let mut profile = "Default".to_string();
|
|
let mut limit: usize = 20;
|
|
let mut keywords: Vec<String> = Vec::new();
|
|
|
|
let mut i = 1;
|
|
while i < args.len() {
|
|
match args[i].as_str() {
|
|
"--browser" => {
|
|
if let Some(v) = args.get(i + 1) {
|
|
browser = v.to_lowercase();
|
|
i += 1;
|
|
}
|
|
}
|
|
"--profile" => {
|
|
if let Some(v) = args.get(i + 1) {
|
|
profile = v.clone();
|
|
i += 1;
|
|
}
|
|
}
|
|
"--limit" => {
|
|
if let Some(v) = args.get(i + 1).and_then(|s| s.parse::<usize>().ok()) {
|
|
limit = v;
|
|
i += 1;
|
|
}
|
|
}
|
|
"--json" => {}
|
|
other if other.starts_with("--") => {}
|
|
other => keywords.push(other.to_lowercase()),
|
|
}
|
|
i += 1;
|
|
}
|
|
|
|
let path = match bookmarks_path(&browser, &profile) {
|
|
Some(p) => p,
|
|
None => {
|
|
emit_error(
|
|
json,
|
|
&format!("Could not locate {browser} bookmarks for profile '{profile}'"),
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let raw = match std::fs::read_to_string(&path) {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
emit_error(json, &format!("Failed to read {}: {e}", path.display()));
|
|
return;
|
|
}
|
|
};
|
|
let root: Value = match serde_json::from_str(&raw) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
emit_error(json, &format!("Failed to parse bookmarks JSON: {e}"));
|
|
return;
|
|
}
|
|
};
|
|
|
|
let mut hits: Vec<Hit> = Vec::new();
|
|
if let Some(roots) = root.get("roots").and_then(|r| r.as_object()) {
|
|
for node in roots.values() {
|
|
walk(node, "", &keywords, &mut hits);
|
|
}
|
|
}
|
|
|
|
// Most-recently-added first (date_added is microseconds since 1601).
|
|
hits.sort_by(|a, b| b.date_added.cmp(&a.date_added));
|
|
hits.truncate(limit);
|
|
|
|
if json {
|
|
let arr: Vec<Value> = hits
|
|
.iter()
|
|
.map(|h| {
|
|
serde_json::json!({
|
|
"name": h.name,
|
|
"url": h.url,
|
|
"folder": h.folder,
|
|
})
|
|
})
|
|
.collect();
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string(&serde_json::json!({
|
|
"success": true,
|
|
"data": { "results": arr, "count": hits.len() },
|
|
}))
|
|
.unwrap_or_default()
|
|
);
|
|
return;
|
|
}
|
|
|
|
if hits.is_empty() {
|
|
let kw = if keywords.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(" matching {:?}", keywords.join(" "))
|
|
};
|
|
println!("No {browser} bookmarks found{kw}.");
|
|
return;
|
|
}
|
|
for h in &hits {
|
|
if h.folder.is_empty() {
|
|
println!("{}\n {}", h.name, h.url);
|
|
} else {
|
|
println!("{} ({})\n {}", h.name, h.folder, h.url);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Recursively walk a bookmark node, collecting URL entries that match every
|
|
/// keyword (in name or url). Empty keyword list matches everything.
|
|
fn walk(node: &Value, folder: &str, keywords: &[String], out: &mut Vec<Hit>) {
|
|
match node.get("type").and_then(|t| t.as_str()) {
|
|
Some("url") => {
|
|
let name = node.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
let url = node.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
|
// Skip non-navigable bookmarks: javascript: bookmarklets and data:
|
|
// URIs aren't pages you can visit, and their bodies can be huge.
|
|
if url.is_empty() || url.starts_with("javascript:") || url.starts_with("data:") {
|
|
return;
|
|
}
|
|
let hay = format!("{} {}", name.to_lowercase(), url.to_lowercase());
|
|
if keywords.iter().all(|k| hay.contains(k.as_str())) {
|
|
let date_added = node
|
|
.get("date_added")
|
|
.and_then(|v| v.as_str())
|
|
.and_then(|s| s.parse::<i64>().ok())
|
|
.unwrap_or(0);
|
|
out.push(Hit {
|
|
name: name.to_string(),
|
|
url: url.to_string(),
|
|
folder: folder.to_string(),
|
|
date_added,
|
|
});
|
|
}
|
|
}
|
|
Some("folder") => {
|
|
let fname = node.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
let child_folder = if folder.is_empty() {
|
|
fname.to_string()
|
|
} else {
|
|
format!("{folder}/{fname}")
|
|
};
|
|
if let Some(children) = node.get("children").and_then(|c| c.as_array()) {
|
|
for child in children {
|
|
walk(child, &child_folder, keywords, out);
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Resolve the Bookmarks file path for a browser + profile across platforms.
|
|
fn bookmarks_path(browser: &str, profile: &str) -> Option<PathBuf> {
|
|
let base = browser_user_data_dir(browser)?;
|
|
let path = base.join(profile).join("Bookmarks");
|
|
if path.exists() {
|
|
Some(path)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// The "User Data" directory that holds per-profile folders, per OS/browser.
|
|
fn browser_user_data_dir(browser: &str) -> Option<PathBuf> {
|
|
let is_edge = browser == "edge" || browser == "msedge";
|
|
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
let app_support = dirs::config_dir()?; // ~/Library/Application Support
|
|
let sub = if is_edge {
|
|
"Microsoft Edge"
|
|
} else {
|
|
"Google/Chrome"
|
|
};
|
|
Some(app_support.join(sub))
|
|
}
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
let local = dirs::data_local_dir()?; // %LOCALAPPDATA%
|
|
let sub = if is_edge {
|
|
"Microsoft/Edge/User Data"
|
|
} else {
|
|
"Google/Chrome/User Data"
|
|
};
|
|
Some(local.join(sub))
|
|
}
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
{
|
|
let config = dirs::config_dir()?; // ~/.config
|
|
let sub = if is_edge {
|
|
"microsoft-edge"
|
|
} else {
|
|
"google-chrome"
|
|
};
|
|
Some(config.join(sub))
|
|
}
|
|
}
|
|
|
|
fn emit_error(json: bool, msg: &str) {
|
|
if json {
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string(&serde_json::json!({
|
|
"success": false,
|
|
"error": msg,
|
|
}))
|
|
.unwrap_or_default()
|
|
);
|
|
} else {
|
|
eprintln!("{} {msg}", color::error_indicator());
|
|
}
|
|
std::process::exit(1);
|
|
}
|