AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
// 保險庫 storage: logins, cards and contacts in one encrypted file on this computer
|
|
|
|
|
|
// (vault.bin = 12-byte nonce + AES-256-GCM of the JSON). The key is random and lives in the
|
|
|
|
|
|
// system keychain (macOS Keychain / Linux Secret Service); without one, a 0600 key file.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Like the original (whose vault is server-side and never sends secrets back), secrets are
|
|
|
|
|
|
// write-only: `list()` returns items with passwords, card numbers and security codes blanked.
|
|
|
|
|
|
// Only the fill step (vault.rs, typing into AnyBot's browser after the person allows it) reads
|
|
|
|
|
|
// them, through `login_secret` / `card_secret`.
|
|
|
|
|
|
|
|
|
|
|
|
use ring::aead;
|
|
|
|
|
|
use ring::rand::{SecureRandom, SystemRandom};
|
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
use std::process::Command;
|
|
|
|
|
|
|
|
|
|
|
|
pub const MAX_SITES: usize = 10;
|
|
|
|
|
|
pub const MAX_NOTES: usize = 2000;
|
|
|
|
|
|
pub const MAX_TITLE: usize = 200;
|
|
|
|
|
|
pub const MAX_USERNAME: usize = 512;
|
|
|
|
|
|
pub const MAX_PASSWORD_BYTES: usize = 4096;
|
|
|
|
|
|
pub const MAX_ROWS: usize = 10;
|
|
|
|
|
|
pub const MAX_IMPORT_BYTES: u64 = 5 * 1024 * 1024;
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug, Default)]
|
|
|
|
|
|
pub enum Kind {
|
|
|
|
|
|
#[default]
|
|
|
|
|
|
Login,
|
|
|
|
|
|
Card,
|
|
|
|
|
|
Contact,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq)]
|
|
|
|
|
|
pub struct Address {
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub line1: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub line2: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub city: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub region: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub postal: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub country: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Address {
|
|
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
|
|
|
|
[&self.line1, &self.line2, &self.city, &self.region, &self.postal, &self.country].iter().all(|s| s.trim().is_empty())
|
|
|
|
|
|
}
|
|
|
|
|
|
pub fn one_line(&self) -> String {
|
|
|
|
|
|
[&self.line1, &self.line2, &self.city, &self.region, &self.postal, &self.country].iter().map(|s| s.trim()).filter(|s| !s.is_empty()).collect::<Vec<_>>().join(", ")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Default, Debug)]
|
|
|
|
|
|
pub struct Item {
|
|
|
|
|
|
pub id: String,
|
|
|
|
|
|
pub kind: Kind,
|
|
|
|
|
|
/// Login: the site's name; card: 暱稱 or brand; contact: 標籤.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub title: String,
|
|
|
|
|
|
/// 提示: tells the bot when to use this item.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub notes: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub created_ms: i64,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub updated_ms: i64,
|
|
|
|
|
|
// Login
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub sites: Vec<String>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub username: String,
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
|
|
|
|
pub password: String,
|
|
|
|
|
|
// Card (number and csc are secret; brand/last4 are what anyone sees)
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
|
|
|
|
pub number: String,
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
|
|
|
|
pub csc: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub brand: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub last4: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub exp_month: u32,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub exp_year: u32,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub holder: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub nickname: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub billing: Address,
|
|
|
|
|
|
// Contact
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub name: String,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub address: Address,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub phones: Vec<String>,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub emails: Vec<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Item {
|
|
|
|
|
|
/// The same item without its secrets.
|
|
|
|
|
|
pub fn public(&self) -> Item {
|
|
|
|
|
|
Item { password: String::new(), number: String::new(), csc: String::new(), ..self.clone() }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// What a row shows as its name.
|
|
|
|
|
|
pub fn display_title(&self) -> String {
|
|
|
|
|
|
match self.kind {
|
|
|
|
|
|
Kind::Login => {
|
|
|
|
|
|
if !self.title.trim().is_empty() {
|
|
|
|
|
|
self.title.clone()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
self.sites.first().map(|s| registrable_domain(&host_of(s))).unwrap_or_default()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Kind::Card => {
|
|
|
|
|
|
if !self.nickname.trim().is_empty() {
|
|
|
|
|
|
self.nickname.clone()
|
|
|
|
|
|
} else if !self.title.trim().is_empty() {
|
|
|
|
|
|
self.title.clone()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
self.brand.clone()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Kind::Contact => {
|
|
|
|
|
|
if !self.title.trim().is_empty() {
|
|
|
|
|
|
self.title.clone()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
self.name.clone()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The line under the title.
|
|
|
|
|
|
pub fn subtitle(&self) -> String {
|
|
|
|
|
|
match self.kind {
|
|
|
|
|
|
Kind::Login => self.username.clone(),
|
|
|
|
|
|
Kind::Card => format!("{} •••• {}", self.brand, self.last4).trim().to_string(),
|
|
|
|
|
|
Kind::Contact => [self.name.clone(), self.address.one_line(), self.phones.first().cloned().unwrap_or_default()].into_iter().filter(|s| !s.is_empty()).collect::<Vec<_>>().join(" · "),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Lowercased text the search box matches.
|
|
|
|
|
|
pub fn haystack(&self) -> String {
|
|
|
|
|
|
let mut v = vec![self.display_title(), self.notes.clone(), self.username.clone(), self.name.clone(), self.nickname.clone(), self.holder.clone(), self.brand.clone(), self.last4.clone(), self.address.one_line()];
|
|
|
|
|
|
v.extend(self.sites.iter().cloned());
|
|
|
|
|
|
v.extend(self.phones.iter().cloned());
|
|
|
|
|
|
v.extend(self.emails.iter().cloned());
|
|
|
|
|
|
v.join("\n").to_lowercase()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// This login fills on `host` (its sites' registrable domains and their subdomains).
|
|
|
|
|
|
pub fn fills_on(&self, host: &str) -> bool {
|
|
|
|
|
|
let h = host.trim().to_lowercase();
|
|
|
|
|
|
let h = h.strip_prefix("www.").unwrap_or(&h);
|
|
|
|
|
|
self.sites.iter().any(|s| {
|
|
|
|
|
|
let d = registrable_domain(&host_of(s));
|
|
|
|
|
|
!d.is_empty() && (h == d || h.ends_with(&format!(".{d}")))
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Serialize, Deserialize, Default, Clone)]
|
|
|
|
|
|
pub struct Store {
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub items: Vec<Item>,
|
|
|
|
|
|
/// AnyBot's old Keychain logins were brought in.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub imported_keychain: bool,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- helpers ----------
|
|
|
|
|
|
|
|
|
|
|
|
pub fn now_ms() -> i64 {
|
|
|
|
|
|
chrono::Utc::now().timestamp_millis()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn new_id() -> String {
|
|
|
|
|
|
let mut b = [0u8; 8];
|
|
|
|
|
|
let _ = SystemRandom::new().fill(&mut b);
|
|
|
|
|
|
b.iter().map(|x| format!("{x:02x}")).collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// "https://Accounts.Example.com/login" or "example.com" → "accounts.example.com".
|
|
|
|
|
|
pub fn host_of(s: &str) -> String {
|
|
|
|
|
|
let s = s.trim();
|
|
|
|
|
|
let rest = s.split("://").nth(1).unwrap_or(s);
|
Clippy is clean on macOS and Linux, for the app and the relay
Every warning `cargo clippy --all-targets` gave (50 on macOS, 8 more only on
Linux, 1 in the relay), fixed without changing what anything does:
- long types get a name (Peer, Members, Imported, Joining, Mention, Stream…);
- the two big enum variants are boxed (the vault's login plan, joining an
account);
- a test now waits for the copy it relaunched (it left a zombie behind);
- a hint's format had a dead `if` whose two branches were both "";
- the rest is what clippy suggests: sort_by_key, as_chunks, is_none_or,
is_multiple_of, while let, from_ref, next_back, struct init, no casts to
the same type.
Checked: cargo test (177 macOS, 181 Linux), relay tests (11), remote_e2e
28/28, and the live Linux test (X11; Wayland clicks, keys, Chinese typing,
scroll and screenshots).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-07 02:10:28 +00:00
|
|
|
|
let h = rest.split(['/', '?', '#']).next().unwrap_or("").split('@').next_back().unwrap_or("").split(':').next().unwrap_or("").to_lowercase();
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
h.strip_prefix("www.").map(String::from).unwrap_or(h)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Second-level suffixes where the registrable domain has three labels.
|
|
|
|
|
|
const TWO_PART_SUFFIXES: &[&str] = &[
|
|
|
|
|
|
"com.tw", "org.tw", "net.tw", "edu.tw", "gov.tw", "idv.tw", "co.uk", "org.uk", "ac.uk", "gov.uk", "co.jp", "ne.jp", "or.jp", "ac.jp", "go.jp", "com.cn", "net.cn", "org.cn", "com.hk", "org.hk",
|
|
|
|
|
|
"com.sg", "com.au", "net.au", "org.au", "co.nz", "co.kr", "or.kr", "com.br", "com.mx", "co.in", "co.za", "com.my", "com.ph", "co.id", "co.th", "com.vn", "github.io", "vercel.app", "netlify.app",
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
/// "login.shop.example.co.uk" → "example.co.uk"; IP addresses and single labels stay as they are.
|
|
|
|
|
|
pub fn registrable_domain(host: &str) -> String {
|
|
|
|
|
|
let h = host.trim().trim_end_matches('.').to_lowercase();
|
|
|
|
|
|
if h.parse::<std::net::IpAddr>().is_ok() {
|
|
|
|
|
|
return h;
|
|
|
|
|
|
}
|
|
|
|
|
|
let parts: Vec<&str> = h.split('.').filter(|p| !p.is_empty()).collect();
|
|
|
|
|
|
if parts.len() <= 2 {
|
|
|
|
|
|
return parts.join(".");
|
|
|
|
|
|
}
|
|
|
|
|
|
let last2 = parts[parts.len() - 2..].join(".");
|
|
|
|
|
|
let n = if TWO_PART_SUFFIXES.contains(&last2.as_str()) { 3 } else { 2 };
|
|
|
|
|
|
parts[parts.len() - n.min(parts.len())..].join(".")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn digits(s: &str) -> String {
|
|
|
|
|
|
s.chars().filter(|c| c.is_ascii_digit()).collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn luhn_ok(number: &str) -> bool {
|
|
|
|
|
|
let d = digits(number);
|
|
|
|
|
|
if d.len() < 12 || d.len() > 19 {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
let sum: u32 = d.chars().rev().enumerate().map(|(i, c)| {
|
|
|
|
|
|
let mut n = c.to_digit(10).unwrap_or(0);
|
|
|
|
|
|
if i % 2 == 1 {
|
|
|
|
|
|
n *= 2;
|
|
|
|
|
|
if n > 9 {
|
|
|
|
|
|
n -= 9;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
n
|
|
|
|
|
|
}).sum();
|
Clippy is clean on macOS and Linux, for the app and the relay
Every warning `cargo clippy --all-targets` gave (50 on macOS, 8 more only on
Linux, 1 in the relay), fixed without changing what anything does:
- long types get a name (Peer, Members, Imported, Joining, Mention, Stream…);
- the two big enum variants are boxed (the vault's login plan, joining an
account);
- a test now waits for the copy it relaunched (it left a zombie behind);
- a hint's format had a dead `if` whose two branches were both "";
- the rest is what clippy suggests: sort_by_key, as_chunks, is_none_or,
is_multiple_of, while let, from_ref, next_back, struct init, no casts to
the same type.
Checked: cargo test (177 macOS, 181 Linux), relay tests (11), remote_e2e
28/28, and the live Linux test (X11; Wayland clicks, keys, Chinese typing,
scroll and screenshots).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-07 02:10:28 +00:00
|
|
|
|
sum.is_multiple_of(10)
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn card_brand(number: &str) -> &'static str {
|
|
|
|
|
|
let d = digits(number);
|
|
|
|
|
|
let p2: u32 = d.get(..2).and_then(|x| x.parse().ok()).unwrap_or(0);
|
|
|
|
|
|
let p4: u32 = d.get(..4).and_then(|x| x.parse().ok()).unwrap_or(0);
|
|
|
|
|
|
if d.starts_with('4') {
|
|
|
|
|
|
"Visa"
|
|
|
|
|
|
} else if (51..=55).contains(&p2) || (2221..=2720).contains(&p4) {
|
|
|
|
|
|
"Mastercard"
|
|
|
|
|
|
} else if p2 == 34 || p2 == 37 {
|
|
|
|
|
|
"American Express"
|
|
|
|
|
|
} else if d.starts_with("6011") || d.starts_with("65") || (644..=649).contains(&(p4 / 10)) {
|
|
|
|
|
|
"Discover"
|
|
|
|
|
|
} else if (3528..=3589).contains(&p4) {
|
|
|
|
|
|
"JCB"
|
|
|
|
|
|
} else if d.starts_with("62") {
|
|
|
|
|
|
"UnionPay"
|
|
|
|
|
|
} else if p2 == 36 || p2 == 38 || (300..=305).contains(&(p4 / 10)) {
|
|
|
|
|
|
"Diners Club"
|
|
|
|
|
|
} else {
|
|
|
|
|
|
"卡片"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// "MM/YY", "MM/YYYY", "MM-YY" → (month, 4-digit year).
|
|
|
|
|
|
pub fn parse_expiry(s: &str) -> Option<(u32, u32)> {
|
|
|
|
|
|
let parts: Vec<&str> = s.split(['/', '-', ' ', '.']).filter(|p| !p.is_empty()).collect();
|
|
|
|
|
|
let (m, y) = match parts.as_slice() {
|
|
|
|
|
|
[m, y] => (*m, *y),
|
|
|
|
|
|
[one] if one.len() == 4 => (&one[..2], &one[2..]),
|
|
|
|
|
|
_ => return None,
|
|
|
|
|
|
};
|
|
|
|
|
|
let m: u32 = m.trim().parse().ok()?;
|
|
|
|
|
|
let mut y: u32 = y.trim().parse().ok()?;
|
|
|
|
|
|
if y < 100 {
|
|
|
|
|
|
y += 2000;
|
|
|
|
|
|
}
|
|
|
|
|
|
((1..=12).contains(&m) && (2000..=2099).contains(&y)).then_some((m, y))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn expired(month: u32, year: u32) -> bool {
|
|
|
|
|
|
use chrono::Datelike;
|
|
|
|
|
|
let now = chrono::Local::now();
|
|
|
|
|
|
(year as i32, month) < (now.year(), now.month())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn valid_email(s: &str) -> bool {
|
|
|
|
|
|
let s = s.trim();
|
|
|
|
|
|
let Some((a, b)) = s.split_once('@') else { return false };
|
|
|
|
|
|
!a.is_empty() && b.contains('.') && !b.starts_with('.') && !b.ends_with('.') && !s.contains(' ')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn valid_phone(s: &str) -> bool {
|
|
|
|
|
|
let d = digits(s);
|
|
|
|
|
|
(6..=16).contains(&d.len()) && s.chars().all(|c| c.is_ascii_digit() || " +-().#".contains(c))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- the key and the file ----------
|
|
|
|
|
|
|
|
|
|
|
|
const KEY_SERVICE: &str = "AnyBot vault key";
|
|
|
|
|
|
|
|
|
|
|
|
fn data_dir() -> std::path::PathBuf {
|
|
|
|
|
|
if let Ok(d) = std::env::var("ANYBOT_VAULT_DIR") {
|
|
|
|
|
|
return std::path::PathBuf::from(d);
|
|
|
|
|
|
}
|
|
|
|
|
|
crate::model::State::path().parent().map(std::path::PathBuf::from).unwrap_or_else(std::env::temp_dir)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn file_path() -> std::path::PathBuf {
|
|
|
|
|
|
data_dir().join("vault.bin")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn hex(b: &[u8]) -> String {
|
|
|
|
|
|
b.iter().map(|x| format!("{x:02x}")).collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn unhex(s: &str) -> Option<Vec<u8>> {
|
|
|
|
|
|
let s = s.trim();
|
Clippy is clean on macOS and Linux, for the app and the relay
Every warning `cargo clippy --all-targets` gave (50 on macOS, 8 more only on
Linux, 1 in the relay), fixed without changing what anything does:
- long types get a name (Peer, Members, Imported, Joining, Mention, Stream…);
- the two big enum variants are boxed (the vault's login plan, joining an
account);
- a test now waits for the copy it relaunched (it left a zombie behind);
- a hint's format had a dead `if` whose two branches were both "";
- the rest is what clippy suggests: sort_by_key, as_chunks, is_none_or,
is_multiple_of, while let, from_ref, next_back, struct init, no casts to
the same type.
Checked: cargo test (177 macOS, 181 Linux), relay tests (11), remote_e2e
28/28, and the live Linux test (X11; Wayland clicks, keys, Chinese typing,
scroll and screenshots).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-07 02:10:28 +00:00
|
|
|
|
s.len().is_multiple_of(2).then_some(())?;
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
(0..s.len() / 2).map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()).collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Read the key from wherever it lives (None = no key yet).
|
|
|
|
|
|
fn read_key() -> Option<Vec<u8>> {
|
|
|
|
|
|
if let Ok(f) = std::env::var("ANYBOT_VAULT_KEY_FILE") {
|
|
|
|
|
|
return unhex(&std::fs::read_to_string(f).ok()?);
|
|
|
|
|
|
}
|
|
|
|
|
|
#[cfg(target_os = "macos")]
|
|
|
|
|
|
{
|
|
|
|
|
|
let o = Command::new("security").args(["find-generic-password", "-s", KEY_SERVICE, "-a", "anybot", "-w"]).output().ok()?;
|
|
|
|
|
|
if o.status.success() {
|
|
|
|
|
|
return unhex(&String::from_utf8_lossy(&o.stdout));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
#[cfg(target_os = "linux")]
|
|
|
|
|
|
{
|
|
|
|
|
|
let o = Command::new("secret-tool").env("LC_ALL", "C.UTF-8").args(["lookup", "application", "anybot", "kind", "vault-key"]).output().ok();
|
|
|
|
|
|
if let Some(o) = o.filter(|o| o.status.success() && !o.stdout.is_empty()) {
|
|
|
|
|
|
return unhex(&String::from_utf8_lossy(&o.stdout));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let f = data_dir().join("vault.key");
|
|
|
|
|
|
unhex(&std::fs::read_to_string(f).ok()?)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn store_key(key: &[u8]) -> Result<(), String> {
|
|
|
|
|
|
let h = hex(key);
|
|
|
|
|
|
if let Ok(f) = std::env::var("ANYBOT_VAULT_KEY_FILE") {
|
|
|
|
|
|
return std::fs::write(f, h).map_err(|e| e.to_string());
|
|
|
|
|
|
}
|
|
|
|
|
|
#[cfg(target_os = "macos")]
|
|
|
|
|
|
{
|
|
|
|
|
|
let o = Command::new("security").args(["add-generic-password", "-U", "-s", KEY_SERVICE, "-a", "anybot", "-l", KEY_SERVICE, "-w", &h]).output().map_err(|e| e.to_string())?;
|
|
|
|
|
|
if o.status.success() {
|
|
|
|
|
|
return Ok(());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
#[cfg(target_os = "linux")]
|
|
|
|
|
|
{
|
|
|
|
|
|
use std::io::Write;
|
|
|
|
|
|
if let Ok(mut c) = Command::new("secret-tool").env("LC_ALL", "C.UTF-8").args(["store", "--label", KEY_SERVICE, "application", "anybot", "kind", "vault-key"]).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() {
|
|
|
|
|
|
let ok = c.stdin.take().map(|mut s| s.write_all(h.as_bytes()).is_ok()).unwrap_or(false);
|
|
|
|
|
|
if ok && c.wait().map(|s| s.success()).unwrap_or(false) {
|
|
|
|
|
|
return Ok(());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// No keychain (Windows for now, or a Linux box without one): a file only this user can read.
|
|
|
|
|
|
let f = data_dir().join("vault.key");
|
|
|
|
|
|
let _ = std::fs::create_dir_all(data_dir());
|
|
|
|
|
|
std::fs::write(&f, h).map_err(|e| e.to_string())?;
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
|
{
|
|
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
|
let _ = std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o600));
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn key(create: bool) -> Result<aead::LessSafeKey, String> {
|
|
|
|
|
|
let k = match read_key() {
|
|
|
|
|
|
Some(k) if k.len() == 32 => k,
|
2026-11-05 07:48:20 +00:00
|
|
|
|
// An entry exists but is not a key: generating a fresh one (and -U overwriting the
|
|
|
|
|
|
// entry) would destroy the only copy of the old key — every saved password would be
|
|
|
|
|
|
// undecryptable forever. Stop and tell the person instead.
|
|
|
|
|
|
Some(_) => return Err("保險庫金鑰損毀:鑰匙圈裡的「AnyBot vault key」內容不正確。已停止動作,避免覆蓋後舊密碼永遠解不開;若你確定不需要舊密碼,可手動刪除該鑰匙圈項目再試。".into()),
|
|
|
|
|
|
None if !create => return Err("找不到保險庫的金鑰(系統鑰匙圈裡的「AnyBot vault key」)".into()),
|
|
|
|
|
|
None => {
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
let mut k = vec![0u8; 32];
|
|
|
|
|
|
SystemRandom::new().fill(&mut k).map_err(|_| "產生金鑰失敗")?;
|
|
|
|
|
|
store_key(&k)?;
|
|
|
|
|
|
k
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
let unbound = aead::UnboundKey::new(&aead::AES_256_GCM, &k).map_err(|_| "金鑰格式不對")?;
|
|
|
|
|
|
Ok(aead::LessSafeKey::new(unbound))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static CACHE: std::sync::Mutex<Option<Store>> = std::sync::Mutex::new(None);
|
|
|
|
|
|
|
|
|
|
|
|
/// The whole store (decrypted, secrets included) — only for this module and the fill step.
|
|
|
|
|
|
fn load() -> Result<Store, String> {
|
|
|
|
|
|
if let Some(s) = CACHE.lock().unwrap().as_ref() {
|
|
|
|
|
|
return Ok(s.clone());
|
|
|
|
|
|
}
|
|
|
|
|
|
let path = file_path();
|
|
|
|
|
|
let store = if !path.exists() {
|
|
|
|
|
|
Store::default()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
let mut bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
|
|
|
|
|
|
if bytes.len() < 12 {
|
|
|
|
|
|
return Err("保險庫檔案損壞".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
let k = key(false)?;
|
|
|
|
|
|
let nonce = aead::Nonce::try_assume_unique_for_key(&bytes[..12]).map_err(|_| "保險庫檔案損壞")?;
|
|
|
|
|
|
let mut body = bytes.split_off(12);
|
|
|
|
|
|
let plain = k.open_in_place(nonce, aead::Aad::from(b"anybot-vault-v1"), &mut body).map_err(|_| "保險庫解不開(金鑰不符)")?;
|
|
|
|
|
|
serde_json::from_slice(plain).map_err(|e| e.to_string())?
|
|
|
|
|
|
};
|
|
|
|
|
|
*CACHE.lock().unwrap() = Some(store.clone());
|
|
|
|
|
|
Ok(store)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn save(store: &Store) -> Result<(), String> {
|
|
|
|
|
|
let k = key(true)?;
|
|
|
|
|
|
let mut nonce = [0u8; 12];
|
|
|
|
|
|
SystemRandom::new().fill(&mut nonce).map_err(|_| "產生亂數失敗")?;
|
|
|
|
|
|
let mut body = serde_json::to_vec(store).map_err(|e| e.to_string())?;
|
|
|
|
|
|
k.seal_in_place_append_tag(aead::Nonce::assume_unique_for_key(nonce), aead::Aad::from(b"anybot-vault-v1"), &mut body).map_err(|_| "加密失敗")?;
|
|
|
|
|
|
let mut out = nonce.to_vec();
|
|
|
|
|
|
out.extend(body);
|
|
|
|
|
|
let _ = std::fs::create_dir_all(data_dir());
|
|
|
|
|
|
let tmp = file_path().with_extension("bin.tmp");
|
|
|
|
|
|
std::fs::write(&tmp, &out).map_err(|e| e.to_string())?;
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
|
{
|
|
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
|
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
|
|
|
|
|
|
}
|
|
|
|
|
|
std::fs::rename(&tmp, file_path()).map_err(|e| e.to_string())?;
|
|
|
|
|
|
*CACHE.lock().unwrap() = Some(store.clone());
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn edit<R>(f: impl FnOnce(&mut Store) -> Result<R, String>) -> Result<R, String> {
|
|
|
|
|
|
let mut s = load()?;
|
|
|
|
|
|
let r = f(&mut s)?;
|
|
|
|
|
|
save(&s)?;
|
|
|
|
|
|
Ok(r)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Bring in logins AnyBot saved in the Keychain before the vault file existed (once).
|
|
|
|
|
|
fn import_keychain_once() {
|
|
|
|
|
|
let Ok(s) = load() else { return };
|
|
|
|
|
|
if s.imported_keychain {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let old = crate::vault::saved_sites();
|
|
|
|
|
|
let mut logins = vec![];
|
|
|
|
|
|
for (site, account) in old {
|
|
|
|
|
|
if let Some(l) = crate::vault::keychain_login(&site, &account) {
|
|
|
|
|
|
logins.push((site, l.username, l.password));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let _ = edit(|s| {
|
|
|
|
|
|
for (site, user, pw) in logins {
|
|
|
|
|
|
let dup = s.items.iter().any(|i| i.kind == Kind::Login && i.fills_on(&site) && i.username.eq_ignore_ascii_case(&user));
|
|
|
|
|
|
if !dup {
|
|
|
|
|
|
let t = now_ms();
|
|
|
|
|
|
s.items.push(Item { id: new_id(), kind: Kind::Login, title: registrable_domain(&site), sites: vec![site], username: user, password: pw, created_ms: t, updated_ms: t, ..Default::default() });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
s.imported_keychain = true;
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- what the window and the bot use ----------
|
|
|
|
|
|
|
|
|
|
|
|
/// Every item without secrets, newest first.
|
|
|
|
|
|
pub fn list() -> Result<Vec<Item>, String> {
|
|
|
|
|
|
import_keychain_once();
|
|
|
|
|
|
let mut v: Vec<Item> = load()?.items.iter().map(Item::public).collect();
|
Clippy is clean on macOS and Linux, for the app and the relay
Every warning `cargo clippy --all-targets` gave (50 on macOS, 8 more only on
Linux, 1 in the relay), fixed without changing what anything does:
- long types get a name (Peer, Members, Imported, Joining, Mention, Stream…);
- the two big enum variants are boxed (the vault's login plan, joining an
account);
- a test now waits for the copy it relaunched (it left a zombie behind);
- a hint's format had a dead `if` whose two branches were both "";
- the rest is what clippy suggests: sort_by_key, as_chunks, is_none_or,
is_multiple_of, while let, from_ref, next_back, struct init, no casts to
the same type.
Checked: cargo test (177 macOS, 181 Linux), relay tests (11), remote_e2e
28/28, and the live Linux test (X11; Wayland clicks, keys, Chinese typing,
scroll and screenshots).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
2026-11-07 02:10:28 +00:00
|
|
|
|
v.sort_by_key(|i| std::cmp::Reverse(i.updated_ms));
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
Ok(v)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn get(id: &str) -> Option<Item> {
|
|
|
|
|
|
load().ok()?.items.iter().find(|i| i.id == id).map(Item::public)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub struct LoginInput {
|
|
|
|
|
|
pub id: Option<String>,
|
|
|
|
|
|
pub sites: Vec<String>,
|
|
|
|
|
|
pub username: String,
|
|
|
|
|
|
/// Empty when editing = keep the saved password.
|
|
|
|
|
|
pub password: String,
|
|
|
|
|
|
pub notes: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Check and save a login; returns its id.
|
|
|
|
|
|
pub fn put_login(input: LoginInput) -> Result<String, String> {
|
|
|
|
|
|
let sites: Vec<String> = input.sites.iter().map(|s| host_of(s)).filter(|s| !s.is_empty()).collect();
|
|
|
|
|
|
if sites.is_empty() {
|
|
|
|
|
|
return Err("請至少填一個網站".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
if sites.len() > MAX_SITES {
|
|
|
|
|
|
return Err(format!("網站最多 {MAX_SITES} 個"));
|
|
|
|
|
|
}
|
|
|
|
|
|
if input.username.chars().count() > MAX_USERNAME || input.notes.chars().count() > MAX_NOTES || input.password.len() > MAX_PASSWORD_BYTES {
|
|
|
|
|
|
return Err("某個欄位過長".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
edit(|s| {
|
|
|
|
|
|
let t = now_ms();
|
|
|
|
|
|
let title = registrable_domain(&sites[0]);
|
|
|
|
|
|
match input.id.as_ref().and_then(|id| s.items.iter_mut().find(|i| &i.id == id)) {
|
|
|
|
|
|
Some(it) => {
|
|
|
|
|
|
it.sites = sites;
|
|
|
|
|
|
it.username = input.username.trim().to_string();
|
|
|
|
|
|
if !input.password.is_empty() {
|
|
|
|
|
|
it.password = input.password;
|
|
|
|
|
|
}
|
|
|
|
|
|
it.notes = input.notes;
|
|
|
|
|
|
it.title = title;
|
|
|
|
|
|
it.updated_ms = t;
|
|
|
|
|
|
Ok(it.id.clone())
|
|
|
|
|
|
}
|
|
|
|
|
|
None => {
|
|
|
|
|
|
if input.password.is_empty() {
|
|
|
|
|
|
return Err("請填密碼".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
let id = new_id();
|
|
|
|
|
|
s.items.push(Item { id: id.clone(), kind: Kind::Login, title, sites, username: input.username.trim().to_string(), password: input.password, notes: input.notes, created_ms: t, updated_ms: t, ..Default::default() });
|
|
|
|
|
|
Ok(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Add another site to a login (the person allowed it on a new domain).
|
|
|
|
|
|
pub fn add_site(id: &str, site: &str) -> Result<(), String> {
|
|
|
|
|
|
let host = host_of(site);
|
|
|
|
|
|
edit(|s| {
|
|
|
|
|
|
let it = s.items.iter_mut().find(|i| i.id == id).ok_or("找不到這筆登入資訊")?;
|
|
|
|
|
|
if !it.fills_on(&host) && it.sites.len() < MAX_SITES {
|
|
|
|
|
|
it.sites.push(registrable_domain(&host));
|
|
|
|
|
|
it.updated_ms = now_ms();
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub struct CardInput {
|
|
|
|
|
|
pub id: Option<String>,
|
|
|
|
|
|
/// New cards only (the number is locked once saved).
|
|
|
|
|
|
pub number: String,
|
|
|
|
|
|
pub expiry: String,
|
|
|
|
|
|
pub csc: String,
|
|
|
|
|
|
pub holder: String,
|
|
|
|
|
|
pub nickname: String,
|
|
|
|
|
|
pub billing: Address,
|
|
|
|
|
|
pub notes: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn put_card(input: CardInput) -> Result<String, String> {
|
|
|
|
|
|
let (m, y) = parse_expiry(&input.expiry).ok_or("有效期限要寫成 月 / 年,例如 08/29")?;
|
|
|
|
|
|
if input.id.is_none() && expired(m, y) {
|
|
|
|
|
|
return Err("卡片已過期".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
if input.holder.chars().count() > MAX_TITLE || input.nickname.chars().count() > MAX_TITLE || input.notes.chars().count() > MAX_NOTES {
|
|
|
|
|
|
return Err("某個欄位過長".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
edit(|s| {
|
|
|
|
|
|
let t = now_ms();
|
|
|
|
|
|
match input.id.as_ref() {
|
|
|
|
|
|
Some(id) => {
|
|
|
|
|
|
let it = s.items.iter_mut().find(|i| &i.id == id).ok_or("找不到這張卡片")?;
|
|
|
|
|
|
it.exp_month = m;
|
|
|
|
|
|
it.exp_year = y;
|
|
|
|
|
|
it.holder = input.holder.trim().to_string();
|
|
|
|
|
|
it.nickname = input.nickname.trim().to_string();
|
|
|
|
|
|
it.billing = input.billing;
|
|
|
|
|
|
it.notes = input.notes;
|
|
|
|
|
|
it.updated_ms = t;
|
|
|
|
|
|
Ok(it.id.clone())
|
|
|
|
|
|
}
|
|
|
|
|
|
None => {
|
|
|
|
|
|
let number = digits(&input.number);
|
|
|
|
|
|
if !luhn_ok(&number) {
|
|
|
|
|
|
return Err("請輸入有效的卡號".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
let csc = digits(&input.csc);
|
|
|
|
|
|
if !csc.is_empty() && !(3..=4).contains(&csc.len()) {
|
|
|
|
|
|
return Err("安全碼是 3 或 4 位數字".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
if s.items.iter().any(|i| i.kind == Kind::Card && i.number == number) {
|
|
|
|
|
|
return Err("這張卡片已在你的保險庫中".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
let id = new_id();
|
|
|
|
|
|
let brand = card_brand(&number).to_string();
|
|
|
|
|
|
s.items.push(Item {
|
|
|
|
|
|
id: id.clone(),
|
|
|
|
|
|
kind: Kind::Card,
|
|
|
|
|
|
title: brand.clone(),
|
|
|
|
|
|
last4: number[number.len() - 4..].to_string(),
|
|
|
|
|
|
brand,
|
|
|
|
|
|
number,
|
|
|
|
|
|
csc,
|
|
|
|
|
|
exp_month: m,
|
|
|
|
|
|
exp_year: y,
|
|
|
|
|
|
holder: input.holder.trim().to_string(),
|
|
|
|
|
|
nickname: input.nickname.trim().to_string(),
|
|
|
|
|
|
billing: input.billing,
|
|
|
|
|
|
notes: input.notes,
|
|
|
|
|
|
created_ms: t,
|
|
|
|
|
|
updated_ms: t,
|
|
|
|
|
|
..Default::default()
|
|
|
|
|
|
});
|
|
|
|
|
|
Ok(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub struct ContactInput {
|
|
|
|
|
|
pub id: Option<String>,
|
|
|
|
|
|
pub label: String,
|
|
|
|
|
|
pub name: String,
|
|
|
|
|
|
pub address: Address,
|
|
|
|
|
|
pub phones: Vec<String>,
|
|
|
|
|
|
pub emails: Vec<String>,
|
|
|
|
|
|
pub notes: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn put_contact(input: ContactInput) -> Result<String, String> {
|
|
|
|
|
|
let phones: Vec<String> = input.phones.iter().map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect();
|
|
|
|
|
|
let emails: Vec<String> = input.emails.iter().map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect();
|
|
|
|
|
|
if let Some(p) = phones.iter().find(|p| !valid_phone(p)) {
|
|
|
|
|
|
return Err(format!("請輸入有效的電話號碼:{p}"));
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(e) = emails.iter().find(|e| !valid_email(e)) {
|
|
|
|
|
|
return Err(format!("請輸入有效的電子郵件地址:{e}"));
|
|
|
|
|
|
}
|
|
|
|
|
|
if phones.len() > MAX_ROWS || emails.len() > MAX_ROWS {
|
|
|
|
|
|
return Err(format!("電話和電子郵件各最多 {MAX_ROWS} 個"));
|
|
|
|
|
|
}
|
|
|
|
|
|
if input.name.trim().is_empty() && input.address.is_empty() && phones.is_empty() && emails.is_empty() {
|
|
|
|
|
|
return Err("請至少填姓名、地址、電話或電子郵件其中一項".into());
|
|
|
|
|
|
}
|
|
|
|
|
|
edit(|s| {
|
|
|
|
|
|
let t = now_ms();
|
|
|
|
|
|
let it = Item { kind: Kind::Contact, title: input.label.trim().to_string(), name: input.name.trim().to_string(), address: input.address, phones, emails, notes: input.notes, updated_ms: t, ..Default::default() };
|
|
|
|
|
|
match input.id.as_ref().and_then(|id| s.items.iter_mut().find(|i| &i.id == id)) {
|
|
|
|
|
|
Some(old) => {
|
|
|
|
|
|
*old = Item { id: old.id.clone(), created_ms: old.created_ms, ..it };
|
|
|
|
|
|
Ok(old.id.clone())
|
|
|
|
|
|
}
|
|
|
|
|
|
None => {
|
|
|
|
|
|
let id = new_id();
|
|
|
|
|
|
s.items.push(Item { id: id.clone(), created_ms: t, ..it });
|
|
|
|
|
|
Ok(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn remove(id: &str) -> Result<(), String> {
|
|
|
|
|
|
edit(|s| {
|
|
|
|
|
|
s.items.retain(|i| i.id != id);
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// For the fill step only: (username, password).
|
|
|
|
|
|
pub fn login_secret(id: &str) -> Option<(String, String)> {
|
|
|
|
|
|
load().ok()?.items.into_iter().find(|i| i.id == id && i.kind == Kind::Login).map(|i| (i.username, i.password))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// For the fill step only: the whole card.
|
|
|
|
|
|
pub fn card_secret(id: &str) -> Option<Item> {
|
|
|
|
|
|
load().ok()?.items.into_iter().find(|i| i.id == id && i.kind == Kind::Card)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Logins that fill on `host`, newest first (no secrets).
|
|
|
|
|
|
pub fn logins_for(host: &str) -> Vec<Item> {
|
|
|
|
|
|
list().unwrap_or_default().into_iter().filter(|i| i.kind == Kind::Login && i.fills_on(host)).collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// For tests: forget the cached copy.
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
pub fn reset_cache() {
|
|
|
|
|
|
*CACHE.lock().unwrap() = None;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- CSV import ----------
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
|
|
|
|
pub enum CsvFormat {
|
|
|
|
|
|
OnePassword,
|
|
|
|
|
|
Chrome,
|
|
|
|
|
|
Safari,
|
|
|
|
|
|
Firefox,
|
|
|
|
|
|
Bitwarden,
|
|
|
|
|
|
LastPass,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl CsvFormat {
|
|
|
|
|
|
pub fn banner(self) -> &'static str {
|
|
|
|
|
|
match self {
|
|
|
|
|
|
CsvFormat::OnePassword => "偵測到 1Password 匯出檔案",
|
|
|
|
|
|
CsvFormat::Chrome => "偵測到 Chrome 或 Edge 匯出檔案",
|
|
|
|
|
|
CsvFormat::Safari => "偵測到 Safari 或 Apple Passwords 匯出檔案",
|
|
|
|
|
|
CsvFormat::Firefox => "偵測到 Firefox 匯出檔案",
|
|
|
|
|
|
CsvFormat::Bitwarden => "偵測到 Bitwarden 匯出檔案",
|
|
|
|
|
|
CsvFormat::LastPass => "偵測到 LastPass 匯出檔案",
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// RFC 4180 CSV: quoted fields, doubled quotes, newlines inside quotes.
|
|
|
|
|
|
pub fn parse_csv(text: &str) -> Vec<Vec<String>> {
|
|
|
|
|
|
let text = text.trim_start_matches('\u{feff}');
|
|
|
|
|
|
let mut rows = vec![];
|
|
|
|
|
|
let mut row = vec![];
|
|
|
|
|
|
let mut field = String::new();
|
|
|
|
|
|
let mut quoted = false;
|
|
|
|
|
|
let mut chars = text.chars().peekable();
|
|
|
|
|
|
while let Some(c) = chars.next() {
|
|
|
|
|
|
if quoted {
|
|
|
|
|
|
if c == '"' {
|
|
|
|
|
|
if chars.peek() == Some(&'"') {
|
|
|
|
|
|
field.push('"');
|
|
|
|
|
|
chars.next();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
quoted = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
field.push(c);
|
|
|
|
|
|
}
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
match c {
|
|
|
|
|
|
'"' => quoted = true,
|
|
|
|
|
|
',' => row.push(std::mem::take(&mut field)),
|
|
|
|
|
|
'\r' => {}
|
|
|
|
|
|
'\n' => {
|
|
|
|
|
|
row.push(std::mem::take(&mut field));
|
|
|
|
|
|
if !(row.len() == 1 && row[0].is_empty()) {
|
|
|
|
|
|
rows.push(std::mem::take(&mut row));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
row.clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
c => field.push(c),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if !field.is_empty() || !row.is_empty() {
|
|
|
|
|
|
row.push(field);
|
|
|
|
|
|
rows.push(row);
|
|
|
|
|
|
}
|
|
|
|
|
|
rows
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub fn detect_format(header: &[String]) -> Option<CsvFormat> {
|
|
|
|
|
|
let h: Vec<String> = header.iter().map(|s| s.trim().to_lowercase()).collect();
|
|
|
|
|
|
let has = |k: &str| h.iter().any(|x| x == k);
|
|
|
|
|
|
if has("login_uri") && has("login_username") && has("login_password") {
|
|
|
|
|
|
Some(CsvFormat::Bitwarden)
|
|
|
|
|
|
} else if has("url") && has("username") && has("password") && has("extra") && has("name") && has("grouping") {
|
|
|
|
|
|
Some(CsvFormat::LastPass)
|
|
|
|
|
|
} else if has("url") && has("username") && has("password") && (has("httprealm") || has("formactionorigin") || has("guid")) {
|
|
|
|
|
|
Some(CsvFormat::Firefox)
|
|
|
|
|
|
} else if has("title") && has("url") && has("username") && has("password") {
|
|
|
|
|
|
if has("favorite") || has("archived") || has("tags") || (has("notes") && !has("otpauth")) { Some(CsvFormat::OnePassword) } else { Some(CsvFormat::Safari) }
|
|
|
|
|
|
} else if has("name") && has("url") && has("username") && has("password") {
|
|
|
|
|
|
Some(CsvFormat::Chrome)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
None
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
|
|
|
|
pub enum RowStatus {
|
|
|
|
|
|
Ready,
|
|
|
|
|
|
AlreadySaved,
|
|
|
|
|
|
Error(&'static str),
|
|
|
|
|
|
Skipped(&'static str),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug)]
|
|
|
|
|
|
pub struct ImportRow {
|
|
|
|
|
|
pub kind: Kind,
|
|
|
|
|
|
pub label: String,
|
|
|
|
|
|
pub secondary: String,
|
|
|
|
|
|
pub status: RowStatus,
|
|
|
|
|
|
// What would be saved.
|
|
|
|
|
|
pub site: String,
|
|
|
|
|
|
pub username: String,
|
|
|
|
|
|
pub password: String,
|
|
|
|
|
|
pub notes: String,
|
|
|
|
|
|
pub card: Option<CardInput>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Clone for CardInput {
|
|
|
|
|
|
fn clone(&self) -> Self {
|
|
|
|
|
|
CardInput { id: self.id.clone(), number: self.number.clone(), expiry: self.expiry.clone(), csc: self.csc.clone(), holder: self.holder.clone(), nickname: self.nickname.clone(), billing: self.billing.clone(), notes: self.notes.clone() }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl std::fmt::Debug for CardInput {
|
|
|
|
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
|
|
|
|
write!(f, "CardInput(•••• {})", digits(&self.number).chars().rev().take(4).collect::<String>().chars().rev().collect::<String>())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub struct ImportPlan {
|
|
|
|
|
|
pub format: CsvFormat,
|
|
|
|
|
|
pub rows: Vec<ImportRow>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl ImportPlan {
|
|
|
|
|
|
pub fn count(&self, f: impl Fn(&RowStatus) -> bool) -> usize {
|
|
|
|
|
|
self.rows.iter().filter(|r| f(&r.status)).count()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A LastPass "extra" field: "NoteType:Credit Card\nNumber:4111…\n…" → key → value.
|
|
|
|
|
|
fn lastpass_fields(extra: &str) -> std::collections::HashMap<String, String> {
|
|
|
|
|
|
extra.lines().filter_map(|l| l.split_once(':')).map(|(k, v)| (k.trim().to_string(), v.trim().to_string())).collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Read a password-manager export and decide, row by row, what would be imported.
|
|
|
|
|
|
pub fn plan_import(text: &str) -> Result<ImportPlan, String> {
|
|
|
|
|
|
let rows = parse_csv(text);
|
|
|
|
|
|
let Some((header, body)) = rows.split_first() else { return Err("這看起來不是密碼匯出檔案。請選擇從支援的應用程式匯出的 CSV 檔案。".into()) };
|
|
|
|
|
|
let format = detect_format(header).ok_or("這看起來不是密碼匯出檔案。請選擇從支援的應用程式匯出的 CSV 檔案。")?;
|
|
|
|
|
|
let col = |name: &str| header.iter().position(|h| h.trim().eq_ignore_ascii_case(name));
|
|
|
|
|
|
let (c_url, c_user, c_pw, c_title, c_notes) = match format {
|
|
|
|
|
|
CsvFormat::Bitwarden => (col("login_uri"), col("login_username"), col("login_password"), col("name"), col("notes")),
|
|
|
|
|
|
CsvFormat::LastPass => (col("url"), col("username"), col("password"), col("name"), col("extra")),
|
|
|
|
|
|
CsvFormat::Firefox => (col("url"), col("username"), col("password"), None, None),
|
|
|
|
|
|
CsvFormat::OnePassword | CsvFormat::Safari => (col("url"), col("username"), col("password"), col("title"), col("notes")),
|
|
|
|
|
|
CsvFormat::Chrome => (col("url"), col("username"), col("password"), col("name"), col("note")),
|
|
|
|
|
|
};
|
|
|
|
|
|
let existing = list().unwrap_or_default();
|
|
|
|
|
|
let mut seen: std::collections::HashSet<String> = existing
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.flat_map(|i| match i.kind {
|
|
|
|
|
|
Kind::Login => i.sites.iter().map(|s| format!("{}\n{}", registrable_domain(&host_of(s)), i.username.to_lowercase())).collect::<Vec<_>>(),
|
|
|
|
|
|
Kind::Card => vec![format!("{}\n{}\n{}", i.last4, i.exp_month, i.exp_year)],
|
|
|
|
|
|
Kind::Contact => vec![],
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
let get = |r: &Vec<String>, c: Option<usize>| c.and_then(|i| r.get(i)).cloned().unwrap_or_default();
|
|
|
|
|
|
let mut out = vec![];
|
|
|
|
|
|
for (n, r) in body.iter().enumerate() {
|
|
|
|
|
|
let line = n + 2;
|
|
|
|
|
|
let url = get(r, c_url);
|
|
|
|
|
|
let title = get(r, c_title);
|
|
|
|
|
|
// LastPass cards: url http://sn and an extra that starts with NoteType:Credit Card.
|
|
|
|
|
|
if format == CsvFormat::LastPass && url.trim() == "http://sn" {
|
|
|
|
|
|
let extra = get(r, c_notes);
|
|
|
|
|
|
if !extra.starts_with("NoteType:Credit Card") {
|
Remove what nothing uses and keep one copy of what was written several times
Unused (found by taking away the dead-code allowances; nothing else used them, tests aside):
computer::specs, quiet_button, the never-made Poke::Spin, ten icons and their SVGs, the
theme's chrome/bubble_agent colours, ImportRow.line, voice's multipart/transcribe/speak
wrappers, and the old keychain writers vault::save/remove/index_add with their live test
(the vault store replaced them; reading old keychain entries for the import stays).
One copy each: percent-encoding (httpd::url_encode, was four), tool arguments (tools::args_of,
was three), the WebSocket handshake (httpd::accept_websocket, was three), the read pacing
(wire::Pace, was two: relay and app), docker with input and a time limit (dk_with_input, was
two). Docs: FEATURES §看畫面 (VNC), protocol (v* frames, /screen, tickets), architecture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:21:47 +00:00
|
|
|
|
out.push(ImportRow { kind: Kind::Login, label: if title.is_empty() { format!("第 {line} 列") } else { title }, secondary: String::new(), status: RowStatus::Skipped("不是登入資訊或卡片"), site: String::new(), username: String::new(), password: String::new(), notes: String::new(), card: None });
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
let f = lastpass_fields(&extra);
|
|
|
|
|
|
let number = digits(f.get("Number").map(String::as_str).unwrap_or(""));
|
|
|
|
|
|
let exp_raw = f.get("Expiration Date").cloned().unwrap_or_default();
|
|
|
|
|
|
// LastPass writes "January,2029" or "01/2029".
|
|
|
|
|
|
let exp = parse_expiry(&exp_raw).or_else(|| {
|
|
|
|
|
|
let (m, y) = exp_raw.split_once(',')?;
|
|
|
|
|
|
let months = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"];
|
|
|
|
|
|
let m = months.iter().position(|x| *x == m.trim().to_lowercase())? as u32 + 1;
|
|
|
|
|
|
Some((m, y.trim().parse().ok()?))
|
|
|
|
|
|
});
|
|
|
|
|
|
let csc = digits(f.get("Security Code").map(String::as_str).unwrap_or(""));
|
|
|
|
|
|
let last4: String = number.chars().rev().take(4).collect::<String>().chars().rev().collect();
|
|
|
|
|
|
let status = if !luhn_ok(&number) {
|
|
|
|
|
|
RowStatus::Error("卡號無效")
|
|
|
|
|
|
} else if exp.is_none() {
|
|
|
|
|
|
RowStatus::Error("有效期限無效")
|
|
|
|
|
|
} else if exp.map(|(m, y)| expired(m, y)).unwrap_or(false) {
|
|
|
|
|
|
RowStatus::Error("卡片已過期")
|
|
|
|
|
|
} else if !csc.is_empty() && !(3..=4).contains(&csc.len()) {
|
|
|
|
|
|
RowStatus::Error("安全碼無效")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
let (m, y) = exp.unwrap_or_default();
|
|
|
|
|
|
let key = format!("{last4}\n{m}\n{y}");
|
|
|
|
|
|
if seen.contains(&key) {
|
|
|
|
|
|
RowStatus::AlreadySaved
|
|
|
|
|
|
} else {
|
|
|
|
|
|
seen.insert(key);
|
|
|
|
|
|
RowStatus::Ready
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
let expiry = exp.map(|(m, y)| format!("{m:02}/{}", y % 100)).unwrap_or_default();
|
|
|
|
|
|
out.push(ImportRow {
|
|
|
|
|
|
kind: Kind::Card,
|
|
|
|
|
|
label: if title.is_empty() { card_brand(&number).to_string() } else { title.clone() },
|
|
|
|
|
|
secondary: format!("•••• {last4}"),
|
|
|
|
|
|
status,
|
|
|
|
|
|
site: String::new(),
|
|
|
|
|
|
username: String::new(),
|
|
|
|
|
|
password: String::new(),
|
|
|
|
|
|
notes: String::new(),
|
|
|
|
|
|
card: Some(CardInput { id: None, number, expiry, csc, holder: f.get("Name on Card").cloned().unwrap_or_default(), nickname: title, billing: Address::default(), notes: String::new() }),
|
|
|
|
|
|
});
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
let user = get(r, c_user);
|
|
|
|
|
|
let pw = get(r, c_pw);
|
|
|
|
|
|
let notes = if format == CsvFormat::LastPass { String::new() } else { get(r, c_notes) };
|
|
|
|
|
|
let scheme = url.trim().split("://").next().unwrap_or("").to_lowercase();
|
|
|
|
|
|
let host = host_of(&url);
|
|
|
|
|
|
let label = if !title.trim().is_empty() { title.clone() } else if !host.is_empty() { registrable_domain(&host) } else { format!("第 {line} 列") };
|
|
|
|
|
|
let status = if url.trim().is_empty() {
|
|
|
|
|
|
RowStatus::Error("缺少網站")
|
|
|
|
|
|
} else if url.contains("://") && scheme != "https" && scheme != "http" {
|
|
|
|
|
|
// android://, ios-app:// and the like.
|
|
|
|
|
|
RowStatus::Skipped("不是登入資訊或卡片")
|
|
|
|
|
|
} else if scheme == "http" {
|
|
|
|
|
|
RowStatus::Error("網站未使用 https")
|
|
|
|
|
|
} else if host.is_empty() || !host.contains('.') {
|
|
|
|
|
|
RowStatus::Error("網站無效")
|
|
|
|
|
|
} else if pw.is_empty() {
|
|
|
|
|
|
RowStatus::Error("缺少密碼")
|
|
|
|
|
|
} else if title.chars().count() > MAX_TITLE || user.chars().count() > MAX_USERNAME || pw.len() > MAX_PASSWORD_BYTES || notes.chars().count() > MAX_NOTES {
|
|
|
|
|
|
RowStatus::Error("某個欄位過長")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
let key = format!("{}\n{}", registrable_domain(&host), user.to_lowercase());
|
|
|
|
|
|
if seen.contains(&key) {
|
|
|
|
|
|
RowStatus::AlreadySaved
|
|
|
|
|
|
} else {
|
|
|
|
|
|
seen.insert(key);
|
|
|
|
|
|
RowStatus::Ready
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
Remove what nothing uses and keep one copy of what was written several times
Unused (found by taking away the dead-code allowances; nothing else used them, tests aside):
computer::specs, quiet_button, the never-made Poke::Spin, ten icons and their SVGs, the
theme's chrome/bubble_agent colours, ImportRow.line, voice's multipart/transcribe/speak
wrappers, and the old keychain writers vault::save/remove/index_add with their live test
(the vault store replaced them; reading old keychain entries for the import stays).
One copy each: percent-encoding (httpd::url_encode, was four), tool arguments (tools::args_of,
was three), the WebSocket handshake (httpd::accept_websocket, was three), the read pacing
(wire::Pace, was two: relay and app), docker with input and a time limit (dk_with_input, was
two). Docs: FEATURES §看畫面 (VNC), protocol (v* frames, /screen, tickets), architecture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:21:47 +00:00
|
|
|
|
out.push(ImportRow { kind: Kind::Login, label, secondary: if user.is_empty() { "••••••••".into() } else { format!("{user} · ••••••••") }, status, site: host, username: user, password: pw, notes, card: None });
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
}
|
|
|
|
|
|
Ok(ImportPlan { format, rows: out })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Save one ready row.
|
|
|
|
|
|
pub fn import_row(row: &ImportRow) -> Result<(), String> {
|
|
|
|
|
|
match row.kind {
|
|
|
|
|
|
Kind::Card => put_card(row.card.clone().ok_or("卡號無效")?).map(|_| ()),
|
|
|
|
|
|
_ => put_login(LoginInput { id: None, sites: vec![row.site.clone()], username: row.username.clone(), password: row.password.clone(), notes: row.notes.clone() }).map(|_| ()),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn domains_cards_and_csv() {
|
|
|
|
|
|
assert_eq!(registrable_domain("login.shop.example.co.uk"), "example.co.uk");
|
|
|
|
|
|
assert_eq!(registrable_domain("www.ctbcbank.com.tw"), "ctbcbank.com.tw");
|
|
|
|
|
|
assert_eq!(registrable_domain("accounts.google.com"), "google.com");
|
|
|
|
|
|
assert_eq!(registrable_domain("127.0.0.1"), "127.0.0.1");
|
|
|
|
|
|
assert_eq!(host_of("https://WWW.Example.com:8443/x?y"), "example.com");
|
|
|
|
|
|
let it = Item { kind: Kind::Login, sites: vec!["https://example.com/login".into()], ..Default::default() };
|
|
|
|
|
|
assert!(it.fills_on("app.example.com") && it.fills_on("example.com") && !it.fills_on("example.com.evil.io") && !it.fills_on("notexample.com"));
|
|
|
|
|
|
assert!(luhn_ok("4111 1111 1111 1111") && !luhn_ok("4111 1111 1111 1112"));
|
|
|
|
|
|
assert_eq!(card_brand("5555555555554444"), "Mastercard");
|
|
|
|
|
|
assert_eq!(card_brand("378282246310005"), "American Express");
|
|
|
|
|
|
assert_eq!(parse_expiry("08/29"), Some((8, 2029)));
|
|
|
|
|
|
assert_eq!(parse_expiry("8/2031"), Some((8, 2031)));
|
|
|
|
|
|
assert_eq!(parse_expiry("13/29"), None);
|
|
|
|
|
|
assert!(valid_email("a@b.co") && !valid_email("a@b") && valid_phone("+886 912-345-678") && !valid_phone("12ab"));
|
|
|
|
|
|
assert_eq!(parse_csv("a,\"b,\"\"c\"\"\nd\",e\r\n\r\nf,g,h\n"), vec![vec!["a".to_string(), "b,\"c\"\nd".into(), "e".into()], vec!["f".into(), "g".into(), "h".into()]]);
|
|
|
|
|
|
let h = |s: &str| s.split(',').map(String::from).collect::<Vec<_>>();
|
|
|
|
|
|
assert_eq!(detect_format(&h("name,url,username,password,note")), Some(CsvFormat::Chrome));
|
|
|
|
|
|
assert_eq!(detect_format(&h("Title,URL,Username,Password,Notes,OTPAuth")), Some(CsvFormat::Safari));
|
|
|
|
|
|
assert_eq!(detect_format(&h("Title,Url,Username,Password,Notes,Favorite,Archived,Tags")), Some(CsvFormat::OnePassword));
|
|
|
|
|
|
assert_eq!(detect_format(&h("url,username,password,httpRealm,formActionOrigin,guid")), Some(CsvFormat::Firefox));
|
|
|
|
|
|
assert_eq!(detect_format(&h("folder,favorite,type,name,notes,fields,reprompt,login_uri,login_username,login_password,login_totp")), Some(CsvFormat::Bitwarden));
|
|
|
|
|
|
assert_eq!(detect_format(&h("url,username,password,totp,extra,name,grouping,fav")), Some(CsvFormat::LastPass));
|
|
|
|
|
|
assert_eq!(detect_format(&h("a,b,c")), None);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The store round trip with a scratch key file and data folder.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn store_keeps_secrets_inside() {
|
|
|
|
|
|
let dir = std::env::temp_dir().join(format!("anybot-vault-test-{}", std::process::id()));
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
|
|
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
|
|
|
|
std::env::set_var("ANYBOT_VAULT_DIR", &dir);
|
|
|
|
|
|
std::env::set_var("ANYBOT_VAULT_KEY_FILE", dir.join("k"));
|
|
|
|
|
|
reset_cache();
|
|
|
|
|
|
// Pretend the old Keychain logins were already brought in (no Keychain in this test).
|
|
|
|
|
|
save(&Store { imported_keychain: true, ..Default::default() }).unwrap();
|
|
|
|
|
|
let id = put_login(LoginInput { id: None, sites: vec!["https://www.github.com/login".into()], username: "me".into(), password: "pa\"ss 你好".into(), notes: "工作用".into() }).unwrap();
|
|
|
|
|
|
assert!(put_login(LoginInput { id: None, sites: vec![], username: "x".into(), password: "y".into(), notes: String::new() }).is_err());
|
|
|
|
|
|
let card = put_card(CardInput { id: None, number: "4111 1111 1111 1111".into(), expiry: "12/39".into(), csc: "123".into(), holder: "WANG".into(), nickname: "旅行".into(), billing: Address::default(), notes: String::new() }).unwrap();
|
|
|
|
|
|
assert!(put_card(CardInput { id: None, number: "4111 1111 1111 1112".into(), expiry: "12/39".into(), csc: String::new(), holder: String::new(), nickname: String::new(), billing: Address::default(), notes: String::new() }).is_err());
|
|
|
|
|
|
put_contact(ContactInput { id: None, label: "住家".into(), name: "王小明".into(), address: Address { line1: "信義路 1 號".into(), city: "台北市".into(), ..Default::default() }, phones: vec!["0912345678".into()], emails: vec!["a@b.co".into()], notes: String::new() }).unwrap();
|
|
|
|
|
|
// The file is ciphertext.
|
|
|
|
|
|
let raw = std::fs::read(file_path()).unwrap();
|
|
|
|
|
|
assert!(!String::from_utf8_lossy(&raw).contains("你好") && !String::from_utf8_lossy(&raw).contains("4111"));
|
|
|
|
|
|
// Reading back from disk (not the cache) works, and the list carries no secrets.
|
|
|
|
|
|
reset_cache();
|
|
|
|
|
|
let items = list().unwrap();
|
|
|
|
|
|
assert_eq!(items.len(), 3);
|
|
|
|
|
|
assert!(items.iter().all(|i| i.password.is_empty() && i.number.is_empty() && i.csc.is_empty()));
|
|
|
|
|
|
let c = items.iter().find(|i| i.id == card).unwrap();
|
|
|
|
|
|
assert_eq!((c.brand.as_str(), c.last4.as_str(), c.exp_month, c.exp_year), ("Visa", "1111", 12, 2039));
|
|
|
|
|
|
assert_eq!(login_secret(&id).unwrap().1, "pa\"ss 你好");
|
|
|
|
|
|
assert_eq!(logins_for("gist.github.com").len(), 1);
|
|
|
|
|
|
// Editing with an empty password keeps it.
|
|
|
|
|
|
put_login(LoginInput { id: Some(id.clone()), sites: vec!["github.com".into(), "githubusercontent.com".into()], username: "me2".into(), password: String::new(), notes: String::new() }).unwrap();
|
|
|
|
|
|
assert_eq!(login_secret(&id).unwrap(), ("me2".to_string(), "pa\"ss 你好".to_string()));
|
|
|
|
|
|
add_site(&id, "https://login.example.org").unwrap();
|
|
|
|
|
|
assert!(get(&id).unwrap().fills_on("example.org"));
|
|
|
|
|
|
// Import: duplicates, http, cards from LastPass.
|
|
|
|
|
|
let csv = "url,username,password,totp,extra,name,grouping,fav\nhttps://github.com,me2,x,,,GitHub,,0\nhttp://old.example.com,u,p,,,Old,,0\nhttps://new.example.net/a,u,p,,,New,,0\nhttp://sn,,,,\"NoteType:Credit Card\nName on Card:WANG\nNumber:5555555555554444\nSecurity Code:321\nExpiration Date:January,2040\",Master,,0\n";
|
|
|
|
|
|
let plan = plan_import(csv).unwrap();
|
|
|
|
|
|
assert_eq!(plan.format, CsvFormat::LastPass);
|
|
|
|
|
|
let st: Vec<_> = plan.rows.iter().map(|r| r.status.clone()).collect();
|
|
|
|
|
|
assert_eq!(st, vec![RowStatus::AlreadySaved, RowStatus::Error("網站未使用 https"), RowStatus::Ready, RowStatus::Ready]);
|
|
|
|
|
|
for r in plan.rows.iter().filter(|r| r.status == RowStatus::Ready) {
|
|
|
|
|
|
import_row(r).unwrap();
|
|
|
|
|
|
}
|
|
|
|
|
|
assert_eq!(list().unwrap().len(), 5);
|
|
|
|
|
|
remove(&card).unwrap();
|
|
|
|
|
|
assert!(get(&card).is_none());
|
|
|
|
|
|
std::env::remove_var("ANYBOT_VAULT_KEY_FILE");
|
|
|
|
|
|
std::env::remove_var("ANYBOT_VAULT_DIR");
|
|
|
|
|
|
reset_cache();
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The real keychain key (macOS Keychain / Secret Service). Creates the key item; the
|
|
|
|
|
|
/// caller removes it afterwards.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
#[ignore]
|
|
|
|
|
|
fn real_keychain_key() {
|
|
|
|
|
|
let dir = std::env::temp_dir().join(format!("anybot-vault-kc-{}", std::process::id()));
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
|
|
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
|
|
|
|
std::env::set_var("ANYBOT_VAULT_DIR", &dir);
|
|
|
|
|
|
reset_cache();
|
|
|
|
|
|
save(&Store { imported_keychain: true, ..Default::default() }).unwrap();
|
|
|
|
|
|
put_contact(ContactInput { id: None, label: "x".into(), name: "測試".into(), address: Address::default(), phones: vec![], emails: vec![], notes: String::new() }).unwrap();
|
|
|
|
|
|
reset_cache();
|
|
|
|
|
|
assert_eq!(list().unwrap().len(), 1);
|
|
|
|
|
|
assert!(!dir.join("vault.key").exists(), "the key should be in the keychain, not a file");
|
|
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|