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
|
|
|
|
// 設定 → 保險庫: logins, cards and contact info the bots can fill for the person (never seeing
|
|
|
|
|
|
// passwords, card numbers or security codes). Layout follows the original's vault pane:
|
|
|
|
|
|
// 「新項目」 menu (登入資訊 / 卡 / 聯絡資訊 · 匯入), tabs with counts, 搜尋保險庫, rows with a
|
|
|
|
|
|
// tile, title and subtitle, per-type empty states, and a footer note.
|
|
|
|
|
|
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
use super::sidebar_ui::shorten_width;
|
|
|
|
|
|
use crate::icons::Icon;
|
|
|
|
|
|
use crate::vault_store::{self as store, Address, ImportPlan, Item, Kind, RowStatus};
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
|
|
|
|
|
pub enum VaultTab {
|
|
|
|
|
|
#[default]
|
|
|
|
|
|
All,
|
|
|
|
|
|
Login,
|
|
|
|
|
|
Card,
|
|
|
|
|
|
Contact,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Default, Clone)]
|
|
|
|
|
|
pub struct LoginForm {
|
|
|
|
|
|
pub id: Option<String>,
|
|
|
|
|
|
pub sites: Vec<String>,
|
|
|
|
|
|
pub username: String,
|
|
|
|
|
|
pub password: String,
|
|
|
|
|
|
pub notes: String,
|
|
|
|
|
|
pub err: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Default, Clone)]
|
|
|
|
|
|
pub struct CardForm {
|
|
|
|
|
|
pub id: Option<String>,
|
|
|
|
|
|
pub number: String,
|
|
|
|
|
|
pub expiry: String,
|
|
|
|
|
|
pub csc: String,
|
|
|
|
|
|
pub show_csc: bool,
|
|
|
|
|
|
pub holder: String,
|
|
|
|
|
|
pub nickname: String,
|
|
|
|
|
|
/// "" = 無帳單地址, "new" = typed below, else a contact's id.
|
|
|
|
|
|
pub billing_from: String,
|
|
|
|
|
|
pub billing: Address,
|
|
|
|
|
|
pub notes: String,
|
|
|
|
|
|
pub locked: String,
|
|
|
|
|
|
pub err: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Default, Clone)]
|
|
|
|
|
|
pub struct ContactForm {
|
|
|
|
|
|
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 err: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Default)]
|
|
|
|
|
|
pub struct ImportState {
|
|
|
|
|
|
pub picking: Option<Arc<Mutex<Option<Vec<PathBuf>>>>>,
|
|
|
|
|
|
pub file: String,
|
|
|
|
|
|
pub plan: Option<ImportPlan>,
|
|
|
|
|
|
pub err: String,
|
|
|
|
|
|
/// Rows still to save (indexes into plan.rows), and how many there were.
|
|
|
|
|
|
pub queue: Vec<usize>,
|
|
|
|
|
|
pub total: usize,
|
|
|
|
|
|
pub stop: bool,
|
|
|
|
|
|
pub done: Option<(usize, usize, Vec<(String, String)>)>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub enum VaultDialog {
|
|
|
|
|
|
ViewLogin(String),
|
|
|
|
|
|
Login(LoginForm),
|
|
|
|
|
|
Card(CardForm),
|
|
|
|
|
|
Contact(ContactForm),
|
|
|
|
|
|
Remove(String, String, Kind),
|
|
|
|
|
|
Import(ImportState),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Default)]
|
|
|
|
|
|
pub struct VaultUi {
|
|
|
|
|
|
pub tab: VaultTab,
|
|
|
|
|
|
pub search: String,
|
|
|
|
|
|
pub items: Option<Result<Vec<Item>, String>>,
|
|
|
|
|
|
pub dialog: Option<VaultDialog>,
|
|
|
|
|
|
pub note: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn kind_label(k: Kind) -> &'static str {
|
|
|
|
|
|
match k {
|
|
|
|
|
|
Kind::Login => "登入資訊",
|
|
|
|
|
|
Kind::Card => "卡片",
|
|
|
|
|
|
Kind::Contact => "聯絡資訊",
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn kind_icon(k: Kind) -> Icon {
|
|
|
|
|
|
match k {
|
|
|
|
|
|
Kind::Login => Icon::Key,
|
|
|
|
|
|
Kind::Card => Icon::CreditCard,
|
|
|
|
|
|
Kind::Contact => Icon::Person,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl App {
|
|
|
|
|
|
fn vault_items(&mut self) -> Vec<Item> {
|
|
|
|
|
|
if self.vault.items.is_none() {
|
|
|
|
|
|
self.vault.items = Some(if self.demo { Ok(demo_items()) } else { store::list() });
|
|
|
|
|
|
}
|
|
|
|
|
|
match self.vault.items.as_ref() {
|
|
|
|
|
|
Some(Ok(v)) => v.clone(),
|
|
|
|
|
|
_ => vec![],
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn vault_refresh(&mut self) {
|
|
|
|
|
|
self.vault.items = None;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(super) fn vault_page(&mut self, ui: &mut egui::Ui) {
|
|
|
|
|
|
let p = self.pal;
|
|
|
|
|
|
let items = self.vault_items();
|
|
|
|
|
|
// Header: title and 「新項目」.
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
ui.label(RichText::new(t("保險庫")).strong().size(FONT_XL));
|
|
|
|
|
|
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
|
|
|
|
|
let open = self.vault.dialog.is_some();
|
|
|
|
|
|
let b = ui.add_enabled(!open, primary_button("新項目", p));
|
|
|
|
|
|
egui::Popup::menu(&b).show(|ui| {
|
|
|
|
|
|
ui.set_min_width(menu_ui::MENU_W);
|
|
|
|
|
|
ui.spacing_mut().item_spacing.y = 0.0;
|
|
|
|
|
|
if menu_ui::item(ui, p, Icon::Key, "登入資訊").clicked() {
|
|
|
|
|
|
self.vault.dialog = Some(VaultDialog::Login(LoginForm { sites: vec![String::new()], ..Default::default() }));
|
|
|
|
|
|
ui.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
if menu_ui::item(ui, p, Icon::CreditCard, "卡").clicked() {
|
|
|
|
|
|
self.vault.dialog = Some(VaultDialog::Card(CardForm::default()));
|
|
|
|
|
|
ui.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
if menu_ui::item(ui, p, Icon::Person, "聯絡資訊").clicked() {
|
|
|
|
|
|
self.vault.dialog = Some(VaultDialog::Contact(ContactForm { phones: vec![String::new()], emails: vec![String::new()], ..Default::default() }));
|
|
|
|
|
|
ui.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
menu_ui::sep(ui, p);
|
|
|
|
|
|
if menu_ui::item(ui, p, Icon::FileText, "匯入").clicked() {
|
|
|
|
|
|
self.vault.dialog = Some(VaultDialog::Import(ImportState::default()));
|
|
|
|
|
|
ui.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
hint(ui, "Bot 可以看到登入資訊和聯絡資訊,以便替你填寫", p);
|
|
|
|
|
|
ui.add_space(10.0);
|
|
|
|
|
|
if let Some(Err(e)) = &self.vault.items {
|
|
|
|
|
|
crate::icons::label(ui, Icon::Alert, &format!("{}:{e}", t("讀不到保險庫")), 13.0, p.danger);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Tabs with counts, and the search box.
|
|
|
|
|
|
let count = |k: Option<Kind>| items.iter().filter(|i| k.map(|k| i.kind == k).unwrap_or(true)).count();
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
ui.spacing_mut().item_spacing.x = 4.0;
|
|
|
|
|
|
for (tab, label, k) in [(VaultTab::All, "全部", None), (VaultTab::Login, "登入資訊", Some(Kind::Login)), (VaultTab::Card, "卡片", Some(Kind::Card)), (VaultTab::Contact, "聯絡資訊", Some(Kind::Contact))] {
|
|
|
|
|
|
let on = self.vault.tab == tab;
|
|
|
|
|
|
let text = format!("{} {}", t(label), count(k));
|
|
|
|
|
|
let b = egui::Button::new(RichText::new(text).size(FONT_SM).color(if on { p.text } else { p.text2 })).fill(if on { p.active } else { Color32::TRANSPARENT }).corner_radius(8).min_size(vec2(0.0, 28.0));
|
|
|
|
|
|
if ui.add(b).clicked() {
|
|
|
|
|
|
self.vault.tab = tab;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
|
|
|
|
|
Frame::new().fill(p.hover).corner_radius(8).inner_margin(Margin::symmetric(8, 4)).show(ui, |ui| {
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
let (r, _) = ui.allocate_exact_size(vec2(14.0, 18.0), egui::Sense::hover());
|
|
|
|
|
|
crate::icons::paint(ui.painter(), egui::Rect::from_center_size(r.center(), vec2(13.0, 13.0)), Icon::Search, p.text3);
|
|
|
|
|
|
let resp = ui.add(egui::TextEdit::singleline(&mut self.vault.search).hint_text(t("搜尋保險庫")).frame(Frame::NONE).desired_width(150.0));
|
|
|
|
|
|
if resp.has_focus() && ui.input(|i| i.key_pressed(Key::Escape)) {
|
|
|
|
|
|
self.vault.search.clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
if !self.vault.search.is_empty() && ui.add(egui::Button::new(RichText::new("×").color(p.text3)).frame(false)).on_hover_text(t("清除搜尋")).clicked() {
|
|
|
|
|
|
self.vault.search.clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
ui.add_space(8.0);
|
|
|
|
|
|
let q = self.vault.search.trim().to_lowercase();
|
|
|
|
|
|
let want = |i: &&Item| match self.vault.tab {
|
|
|
|
|
|
VaultTab::All => true,
|
|
|
|
|
|
VaultTab::Login => i.kind == Kind::Login,
|
|
|
|
|
|
VaultTab::Card => i.kind == Kind::Card,
|
|
|
|
|
|
VaultTab::Contact => i.kind == Kind::Contact,
|
|
|
|
|
|
};
|
|
|
|
|
|
let shown: Vec<&Item> = items.iter().filter(want).filter(|i| q.is_empty() || i.haystack().contains(&q)).collect();
|
|
|
|
|
|
let mut open = None;
|
|
|
|
|
|
setting_card(ui, p, |ui| {
|
|
|
|
|
|
if shown.is_empty() {
|
|
|
|
|
|
let text = if !q.is_empty() {
|
|
|
|
|
|
format!("找不到與「{}」相符的結果", self.vault.search.trim())
|
|
|
|
|
|
} else {
|
|
|
|
|
|
match self.vault.tab {
|
|
|
|
|
|
VaultTab::All => "尚未儲存任何內容。透過新項目新增登入資訊、卡片或聯絡資訊。",
|
|
|
|
|
|
VaultTab::Login => "尚無登入資訊。透過新項目新增一筆,或從 CSV 匯入。",
|
|
|
|
|
|
VaultTab::Card => "尚無已儲存的卡片。透過新項目新增一張。",
|
|
|
|
|
|
VaultTab::Contact => "還沒有聯絡資訊。透過新項目新增。",
|
|
|
|
|
|
}
|
|
|
|
|
|
.to_string()
|
|
|
|
|
|
};
|
|
|
|
|
|
ui.add_space(6.0);
|
|
|
|
|
|
ui.label(RichText::new(t(&text)).color(p.text3).size(FONT_SM));
|
|
|
|
|
|
ui.add_space(6.0);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// While searching 「全部」, results are grouped by kind.
|
|
|
|
|
|
let grouped = !q.is_empty() && self.vault.tab == VaultTab::All;
|
|
|
|
|
|
let mut last_kind = None;
|
|
|
|
|
|
for it in shown {
|
|
|
|
|
|
if grouped && last_kind != Some(it.kind) {
|
|
|
|
|
|
ui.add_space(4.0);
|
|
|
|
|
|
ui.label(RichText::new(t(kind_label(it.kind))).size(FONT_XS).color(p.text3));
|
|
|
|
|
|
last_kind = Some(it.kind);
|
|
|
|
|
|
}
|
|
|
|
|
|
if self.vault_row(ui, it, p).clicked() {
|
|
|
|
|
|
open = Some(it.clone());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
if let Some(it) = open {
|
|
|
|
|
|
self.vault.dialog = Some(match it.kind {
|
|
|
|
|
|
Kind::Login => VaultDialog::ViewLogin(it.id.clone()),
|
|
|
|
|
|
Kind::Card => VaultDialog::Card(CardForm {
|
|
|
|
|
|
id: Some(it.id.clone()),
|
|
|
|
|
|
expiry: format!("{:02}/{:02}", it.exp_month, it.exp_year % 100),
|
|
|
|
|
|
holder: it.holder.clone(),
|
|
|
|
|
|
nickname: it.nickname.clone(),
|
|
|
|
|
|
billing_from: if it.billing.is_empty() { String::new() } else { "new".into() },
|
|
|
|
|
|
billing: it.billing.clone(),
|
|
|
|
|
|
notes: it.notes.clone(),
|
|
|
|
|
|
locked: format!("{} •••• {}", it.brand, it.last4),
|
|
|
|
|
|
..Default::default()
|
|
|
|
|
|
}),
|
|
|
|
|
|
Kind::Contact => VaultDialog::Contact(ContactForm {
|
|
|
|
|
|
id: Some(it.id.clone()),
|
|
|
|
|
|
label: it.title.clone(),
|
|
|
|
|
|
name: it.name.clone(),
|
|
|
|
|
|
address: it.address.clone(),
|
|
|
|
|
|
phones: if it.phones.is_empty() { vec![String::new()] } else { it.phones.clone() },
|
|
|
|
|
|
emails: if it.emails.is_empty() { vec![String::new()] } else { it.emails.clone() },
|
|
|
|
|
|
notes: it.notes.clone(),
|
|
|
|
|
|
..Default::default()
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
ui.add_space(8.0);
|
|
|
|
|
|
let has_cards = items.iter().any(|i| i.kind == Kind::Card);
|
|
|
|
|
|
hint(
|
|
|
|
|
|
ui,
|
|
|
|
|
|
if has_cards { "Bot 絕對看不到已儲存卡片的卡號或安全碼。它可以看到登入資訊和聯絡資訊,以便替你填寫。" } else { "Bot 可以看到登入資訊和聯絡資訊,以便替你填寫" },
|
|
|
|
|
|
p,
|
|
|
|
|
|
);
|
|
|
|
|
|
hint(ui, "每次填入前都會先問你。資料加密存在這台電腦上,金鑰放在系統鑰匙圈。登入時保險庫沒有的網站,也會找 macOS 鑰匙圈和 1Password(裝了 op 指令並登入時)。", p);
|
|
|
|
|
|
if !self.vault.note.is_empty() {
|
|
|
|
|
|
ui.label(RichText::new(&self.vault.note).size(FONT_XS).color(p.text2));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn vault_row(&self, ui: &mut egui::Ui, it: &Item, p: Palette) -> egui::Response {
|
|
|
|
|
|
let (rect, r) = ui.allocate_exact_size(vec2(ui.available_width(), 48.0), egui::Sense::click());
|
|
|
|
|
|
if r.hovered() {
|
|
|
|
|
|
ui.painter().rect_filled(rect, 10, p.hover);
|
|
|
|
|
|
}
|
|
|
|
|
|
let tile = egui::Rect::from_min_size(rect.left_center() + vec2(6.0, -16.0), vec2(32.0, 32.0));
|
|
|
|
|
|
ui.painter().rect_filled(tile, 8, p.active);
|
|
|
|
|
|
crate::icons::paint(ui.painter(), tile.shrink(8.0), kind_icon(it.kind), p.text2);
|
|
|
|
|
|
let title = shorten_width(ui, &it.display_title(), rect.width() - 90.0, FONT_SM);
|
|
|
|
|
|
let sub = if it.subtitle().is_empty() && !it.notes.is_empty() { format!("{}{}", t("提示:"), it.notes) } else { it.subtitle() };
|
|
|
|
|
|
let sub = shorten_width(ui, &sub, rect.width() - 90.0, FONT_XS);
|
|
|
|
|
|
let x = tile.right() + 12.0;
|
|
|
|
|
|
ui.painter().text(egui::pos2(x, rect.center().y - 9.0), egui::Align2::LEFT_CENTER, title, egui::FontId::proportional(FONT_SM), p.text);
|
|
|
|
|
|
ui.painter().text(egui::pos2(x, rect.center().y + 10.0), egui::Align2::LEFT_CENTER, sub, egui::FontId::proportional(FONT_XS), p.text3);
|
|
|
|
|
|
crate::icons::paint(ui.painter(), egui::Rect::from_center_size(rect.right_center() - vec2(16.0, 0.0), vec2(14.0, 14.0)), Icon::ChevronRight, p.text3);
|
|
|
|
|
|
r.on_hover_cursor(egui::CursorIcon::PointingHand)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The open vault dialog, if any (called every frame from the settings window).
|
|
|
|
|
|
pub(super) fn vault_dialogs(&mut self, ctx: &egui::Context) {
|
|
|
|
|
|
let p = self.pal;
|
|
|
|
|
|
let Some(d) = self.vault.dialog.take() else { return };
|
|
|
|
|
|
let items = self.vault_items();
|
|
|
|
|
|
let mut next: Option<VaultDialog> = None;
|
|
|
|
|
|
let mut saved = false;
|
|
|
|
|
|
match d {
|
|
|
|
|
|
VaultDialog::ViewLogin(id) => {
|
|
|
|
|
|
let Some(it) = items.iter().find(|i| i.id == id).cloned() else { return };
|
|
|
|
|
|
let title = it.sites.first().map(|s| store::registrable_domain(&store::host_of(s))).unwrap_or_else(|| it.display_title());
|
|
|
|
|
|
let mut keep = true;
|
|
|
|
|
|
let close = dialog(ctx, "vault-view", &title, 440.0, true, p, |ui| {
|
|
|
|
|
|
dialog_body(ui, "vault-view-body", dialog_max_height(ctx) - 120.0, |ui| {
|
|
|
|
|
|
field_label(ui, "網站", p);
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
ui.label(it.sites.join("、"));
|
|
|
|
|
|
if let Some(first) = it.sites.first() {
|
|
|
|
|
|
if ui.add(egui::Button::new(RichText::new(t("開啟")).size(FONT_XS)).corner_radius(6)).on_hover_text(format!("{} {}", t("開啟"), first)).clicked() {
|
|
|
|
|
|
let url = if first.contains("://") { first.clone() } else { format!("https://{first}") };
|
|
|
|
|
|
let _ = crate::preview::system_open(&url).spawn();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
if !it.username.is_empty() {
|
|
|
|
|
|
field_label(ui, "使用者名稱", p);
|
|
|
|
|
|
ui.label(&it.username);
|
|
|
|
|
|
}
|
|
|
|
|
|
field_label(ui, "密碼", p);
|
|
|
|
|
|
ui.label("••••••••••••••").on_hover_text(t("已儲存的密碼,已隱藏"));
|
|
|
|
|
|
if !it.notes.is_empty() {
|
|
|
|
|
|
field_label(ui, "提示", p);
|
|
|
|
|
|
ui.label(&it.notes);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
dialog_footer(ui, p, |ui| {
|
|
|
|
|
|
if ui.add(danger_button("移除", p)).clicked() {
|
|
|
|
|
|
next = Some(VaultDialog::Remove(it.id.clone(), it.display_title(), Kind::Login));
|
|
|
|
|
|
}
|
|
|
|
|
|
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
|
|
|
|
|
if ui.add(primary_button("完成", p)).clicked() {
|
|
|
|
|
|
keep = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ui.add(secondary_button("編輯")).clicked() {
|
|
|
|
|
|
next = Some(VaultDialog::Login(LoginForm { id: Some(it.id.clone()), sites: it.sites.clone(), username: it.username.clone(), notes: it.notes.clone(), ..Default::default() }));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
if next.is_none() && keep && !close {
|
|
|
|
|
|
next = Some(VaultDialog::ViewLogin(id));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
VaultDialog::Login(mut f) => {
|
|
|
|
|
|
let title = match &f.id {
|
|
|
|
|
|
Some(_) => format!("{} {} {}", t("編輯"), f.sites.first().map(|s| store::registrable_domain(&store::host_of(s))).unwrap_or_default(), t("的登入資訊")),
|
|
|
|
|
|
None => t("新登入資訊"),
|
|
|
|
|
|
};
|
|
|
|
|
|
let mut keep = true;
|
|
|
|
|
|
let close = dialog(ctx, "vault-login", &title, 480.0, false, p, |ui| {
|
|
|
|
|
|
dialog_body(ui, "vault-login-body", dialog_max_height(ctx) - 120.0, |ui| {
|
|
|
|
|
|
field_label(ui, "網站", p);
|
|
|
|
|
|
let mut drop = None;
|
|
|
|
|
|
let n = f.sites.len();
|
|
|
|
|
|
for (i, s) in f.sites.iter_mut().enumerate() {
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
ui.add(egui::TextEdit::singleline(s).hint_text("example.com").desired_width(ui.available_width() - if n > 1 { 34.0 } else { 0.0 }));
|
|
|
|
|
|
if n > 1 && ui.add(egui::Button::new("×").frame(false)).on_hover_text(t("移除")).clicked() {
|
|
|
|
|
|
drop = Some(i);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
let d = store::registrable_domain(&store::host_of(s));
|
|
|
|
|
|
if !d.is_empty() {
|
|
|
|
|
|
ui.label(RichText::new(format!("在 {d} 及其子網域上填入")).size(FONT_XS).color(p.text3));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(i) = drop {
|
|
|
|
|
|
f.sites.remove(i);
|
|
|
|
|
|
}
|
|
|
|
|
|
if f.sites.len() < store::MAX_SITES && ui.add(egui::Button::new(RichText::new(t("新增網站")).size(FONT_XS)).frame(false)).clicked() {
|
|
|
|
|
|
f.sites.push(String::new());
|
|
|
|
|
|
}
|
|
|
|
|
|
field_label(ui, "使用者名稱或電子郵件(選填)", p);
|
|
|
|
|
|
ui.add(egui::TextEdit::singleline(&mut f.username).hint_text("name@example.com").desired_width(f32::INFINITY));
|
|
|
|
|
|
field_label(ui, "密碼", p);
|
|
|
|
|
|
ui.add(egui::TextEdit::singleline(&mut f.password).password(true).hint_text(if f.id.is_some() { "••••••••••••••(留空就不變)" } else { "" }).desired_width(f32::INFINITY));
|
|
|
|
|
|
field_label(ui, "提示(選填)", p);
|
|
|
|
|
|
ui.add(egui::TextEdit::multiline(&mut f.notes).desired_rows(2).hint_text(t("告訴 Bot 何時使用此項目")).char_limit(store::MAX_NOTES).desired_width(f32::INFINITY));
|
|
|
|
|
|
if f.id.is_none() {
|
|
|
|
|
|
let host = f.sites.first().map(|s| store::host_of(s)).unwrap_or_default();
|
|
|
|
|
|
hint(ui, if host.is_empty() { t("Bot 可以使用此登入資訊替你登入") } else { format!("Bot 可以使用此登入資訊替你登入 {host}") }, p);
|
|
|
|
|
|
}
|
|
|
|
|
|
if !f.err.is_empty() {
|
|
|
|
|
|
ui.label(RichText::new(&f.err).size(FONT_XS).color(p.danger));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
dialog_footer(ui, p, |ui| {
|
|
|
|
|
|
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
|
|
|
|
|
let ok = f.sites.iter().any(|s| !s.trim().is_empty()) && (f.id.is_some() || !f.password.is_empty());
|
|
|
|
|
|
if ui.add_enabled(ok, primary_button("儲存登入資訊", p)).clicked() {
|
|
|
|
|
|
let r = if self.demo { Ok(String::new()) } else { store::put_login(store::LoginInput { id: f.id.clone(), sites: f.sites.clone(), username: f.username.clone(), password: f.password.clone(), notes: f.notes.clone() }) };
|
|
|
|
|
|
match r {
|
|
|
|
|
|
Ok(_) => {
|
|
|
|
|
|
keep = false;
|
|
|
|
|
|
saved = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(e) => f.err = e,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if ui.add(secondary_button("取消")).clicked() {
|
|
|
|
|
|
keep = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
if keep && !close {
|
|
|
|
|
|
next = Some(VaultDialog::Login(f));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
VaultDialog::Card(mut f) => {
|
|
|
|
|
|
let editing = f.id.is_some();
|
|
|
|
|
|
let title = if editing { format!("{} {}", t("編輯"), f.locked) } else { t("新卡片") };
|
|
|
|
|
|
let contacts: Vec<Item> = items.iter().filter(|i| i.kind == Kind::Contact && !i.address.is_empty()).cloned().collect();
|
|
|
|
|
|
let mut keep = true;
|
|
|
|
|
|
let close = dialog(ctx, "vault-card", &title, 480.0, false, p, |ui| {
|
|
|
|
|
|
dialog_body(ui, "vault-card-body", dialog_max_height(ctx) - 120.0, |ui| {
|
|
|
|
|
|
field_label(ui, "卡號", p);
|
|
|
|
|
|
if editing {
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
crate::icons::label(ui, Icon::Lock, &f.locked, 13.0, p.text2);
|
|
|
|
|
|
ui.label(RichText::new(t("已鎖定")).size(FONT_XS).color(p.text3));
|
|
|
|
|
|
});
|
|
|
|
|
|
ui.label(RichText::new(t("若要變更卡號,請移除此卡片後重新新增")).size(FONT_XS).color(p.text3));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
ui.add(egui::TextEdit::singleline(&mut f.number).hint_text("1234 5678 9012 3456").desired_width(f32::INFINITY));
|
|
|
|
|
|
let d = store::digits(&f.number);
|
|
|
|
|
|
if d.len() >= 12 {
|
|
|
|
|
|
let (msg, col) = if store::luhn_ok(&d) { (store::card_brand(&d).to_string(), p.text3) } else { (t("請輸入有效的卡號"), p.danger) };
|
|
|
|
|
|
ui.label(RichText::new(msg).size(FONT_XS).color(col));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
ui.vertical(|ui| {
|
|
|
|
|
|
field_label(ui, "有效期限", p);
|
|
|
|
|
|
ui.add(egui::TextEdit::singleline(&mut f.expiry).hint_text(t("月 / 年")).desired_width(110.0));
|
|
|
|
|
|
});
|
|
|
|
|
|
if !editing {
|
|
|
|
|
|
ui.vertical(|ui| {
|
|
|
|
|
|
field_label(ui, "安全碼(選填)", p);
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
ui.add(egui::TextEdit::singleline(&mut f.csc).password(!f.show_csc).hint_text(t("3 或 4 位數字")).desired_width(110.0));
|
|
|
|
|
|
let icon = if f.show_csc { Icon::EyeOff } else { Icon::Eye };
|
|
|
|
|
|
if crate::icons::button(ui, icon, 16.0, p.text2, p.hover).on_hover_text(t(if f.show_csc { "隱藏安全碼" } else { "顯示安全碼" })).clicked() {
|
|
|
|
|
|
f.show_csc = !f.show_csc;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
field_label(ui, "持卡人姓名(選填)", p);
|
|
|
|
|
|
ui.add(egui::TextEdit::singleline(&mut f.holder).char_limit(store::MAX_TITLE).desired_width(f32::INFINITY));
|
|
|
|
|
|
field_label(ui, "暱稱(選填)", p);
|
|
|
|
|
|
ui.add(egui::TextEdit::singleline(&mut f.nickname).hint_text(t("例如:旅行")).char_limit(store::MAX_TITLE).desired_width(f32::INFINITY));
|
|
|
|
|
|
field_label(ui, "帳單地址", p);
|
|
|
|
|
|
let current = if f.billing_from.is_empty() { t("無帳單地址") } else if f.billing_from == "new" { t("輸入地址") } else { contacts.iter().find(|c| c.id == f.billing_from).map(|c| c.display_title()).unwrap_or_default() };
|
|
|
|
|
|
egui::ComboBox::from_id_salt("vault-billing").selected_text(current).width(240.0).show_ui(ui, |ui| {
|
|
|
|
|
|
ui.selectable_value(&mut f.billing_from, String::new(), t("無帳單地址"));
|
|
|
|
|
|
for c in &contacts {
|
|
|
|
|
|
if ui.selectable_label(f.billing_from == c.id, format!("{} · {}", c.display_title(), c.address.one_line())).clicked() {
|
|
|
|
|
|
f.billing_from = c.id.clone();
|
|
|
|
|
|
f.billing = c.address.clone();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if ui.selectable_label(f.billing_from == "new", t("新增帳單地址")).clicked() {
|
|
|
|
|
|
f.billing_from = "new".into();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
if f.billing_from == "new" {
|
|
|
|
|
|
address_fields(ui, &mut f.billing, p);
|
|
|
|
|
|
}
|
|
|
|
|
|
field_label(ui, "提示(選填)", p);
|
|
|
|
|
|
ui.add(egui::TextEdit::multiline(&mut f.notes).desired_rows(2).hint_text(t("告訴 Bot 何時使用此項目")).char_limit(store::MAX_NOTES).desired_width(f32::INFINITY));
|
|
|
|
|
|
hint(ui, "Bot 永遠不會看到卡號和安全碼。每次付款填卡前都會問你。", p);
|
|
|
|
|
|
if !f.err.is_empty() {
|
|
|
|
|
|
ui.label(RichText::new(&f.err).size(FONT_XS).color(p.danger));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
dialog_footer(ui, p, |ui| {
|
|
|
|
|
|
if let Some(id) = f.id.clone() {
|
|
|
|
|
|
if ui.add(danger_button("移除", p)).clicked() {
|
|
|
|
|
|
next = Some(VaultDialog::Remove(id, f.locked.clone(), Kind::Card));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
|
|
|
|
|
if ui.add(primary_button("儲存卡片", p)).clicked() {
|
|
|
|
|
|
let billing = if f.billing_from.is_empty() { Address::default() } else { f.billing.clone() };
|
|
|
|
|
|
let r = if self.demo {
|
|
|
|
|
|
Ok(String::new())
|
|
|
|
|
|
} else {
|
|
|
|
|
|
store::put_card(store::CardInput { id: f.id.clone(), number: f.number.clone(), expiry: f.expiry.clone(), csc: f.csc.clone(), holder: f.holder.clone(), nickname: f.nickname.clone(), billing, notes: f.notes.clone() })
|
|
|
|
|
|
};
|
|
|
|
|
|
match r {
|
|
|
|
|
|
Ok(_) => {
|
|
|
|
|
|
keep = false;
|
|
|
|
|
|
saved = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(e) => f.err = e,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if ui.add(secondary_button("取消")).clicked() {
|
|
|
|
|
|
keep = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
if next.is_none() && keep && !close {
|
|
|
|
|
|
next = Some(VaultDialog::Card(f));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
VaultDialog::Contact(mut f) => {
|
|
|
|
|
|
let title = if f.id.is_some() { t("編輯聯絡資訊") } else { t("新增聯絡資訊") };
|
|
|
|
|
|
let mut keep = true;
|
|
|
|
|
|
let close = dialog(ctx, "vault-contact", &title, 500.0, false, p, |ui| {
|
|
|
|
|
|
dialog_body(ui, "vault-contact-body", dialog_max_height(ctx) - 120.0, |ui| {
|
|
|
|
|
|
field_label(ui, "姓名", p);
|
|
|
|
|
|
ui.add(egui::TextEdit::singleline(&mut f.name).desired_width(f32::INFINITY));
|
|
|
|
|
|
address_fields(ui, &mut f.address, p);
|
|
|
|
|
|
rows_editor(ui, "手機", "新增手機號碼", &mut f.phones, store::valid_phone, "請輸入有效的電話號碼", p);
|
|
|
|
|
|
rows_editor(ui, "電子郵件地址", "新增電子郵件", &mut f.emails, store::valid_email, "請輸入有效的電子郵件地址", p);
|
|
|
|
|
|
field_label(ui, "標籤(選填)", p);
|
|
|
|
|
|
ui.add(egui::TextEdit::singleline(&mut f.label).hint_text(t("例如:住家、公司")).desired_width(f32::INFINITY));
|
|
|
|
|
|
field_label(ui, "提示(選填)", p);
|
|
|
|
|
|
ui.add(egui::TextEdit::multiline(&mut f.notes).desired_rows(2).hint_text(t("告訴 Bot 何時使用此項目")).char_limit(store::MAX_NOTES).desired_width(f32::INFINITY));
|
|
|
|
|
|
if !f.err.is_empty() {
|
|
|
|
|
|
ui.label(RichText::new(&f.err).size(FONT_XS).color(p.danger));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
dialog_footer(ui, p, |ui| {
|
|
|
|
|
|
if let Some(id) = f.id.clone() {
|
|
|
|
|
|
if ui.add(danger_button("移除", p)).clicked() {
|
|
|
|
|
|
let name = if f.label.trim().is_empty() { f.name.clone() } else { f.label.clone() };
|
|
|
|
|
|
next = Some(VaultDialog::Remove(id, name, Kind::Contact));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
|
|
|
|
|
if ui.add(primary_button("儲存聯絡資訊", p)).clicked() {
|
|
|
|
|
|
let r = if self.demo {
|
|
|
|
|
|
Ok(String::new())
|
|
|
|
|
|
} else {
|
|
|
|
|
|
store::put_contact(store::ContactInput { id: f.id.clone(), label: f.label.clone(), name: f.name.clone(), address: f.address.clone(), phones: f.phones.clone(), emails: f.emails.clone(), notes: f.notes.clone() })
|
|
|
|
|
|
};
|
|
|
|
|
|
match r {
|
|
|
|
|
|
Ok(_) => {
|
|
|
|
|
|
keep = false;
|
|
|
|
|
|
saved = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(e) => f.err = e,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if ui.add(secondary_button("取消")).clicked() {
|
|
|
|
|
|
keep = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
if next.is_none() && keep && !close {
|
|
|
|
|
|
next = Some(VaultDialog::Contact(f));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
VaultDialog::Remove(id, name, kind) => {
|
|
|
|
|
|
let (q, body) = match kind {
|
|
|
|
|
|
Kind::Login => (t("要移除這筆登入資訊嗎?"), format!("Bot 將無法再用它登入 {name}")),
|
|
|
|
|
|
Kind::Card => (format!("要移除「{name}」嗎?"), t("Bot 將無法再使用這張卡片付款")),
|
|
|
|
|
|
Kind::Contact => (format!("要移除「{name}」嗎?"), t("Bot 將無法再使用此聯絡資訊")),
|
|
|
|
|
|
};
|
|
|
|
|
|
let mut keep = true;
|
|
|
|
|
|
let close = dialog(ctx, "vault-remove", &q, 420.0, true, p, |ui| {
|
|
|
|
|
|
dialog_body(ui, "vault-remove-body", 200.0, |ui| {
|
|
|
|
|
|
ui.label(body);
|
|
|
|
|
|
});
|
|
|
|
|
|
dialog_footer(ui, p, |ui| {
|
|
|
|
|
|
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
|
|
|
|
|
if ui.add(danger_button("移除", p)).clicked() {
|
|
|
|
|
|
if !self.demo {
|
|
|
|
|
|
if let Err(e) = store::remove(&id) {
|
|
|
|
|
|
self.vault.note = format!("{}:{e}", t("無法移除。請重試。"));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
keep = false;
|
|
|
|
|
|
saved = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ui.add(secondary_button("取消")).clicked() {
|
|
|
|
|
|
keep = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
if keep && !close {
|
|
|
|
|
|
next = Some(VaultDialog::Remove(id, name, kind));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
VaultDialog::Import(mut st) => {
|
|
|
|
|
|
if self.vault_import(ctx, &mut st) {
|
|
|
|
|
|
next = Some(VaultDialog::Import(st));
|
2026-11-05 07:48:22 +00:00
|
|
|
|
} else {
|
|
|
|
|
|
// Only when it closed: marking saved every frame reloaded the whole
|
|
|
|
|
|
// vault list on every repaint while the import dialog was open.
|
|
|
|
|
|
saved = true;
|
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
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
self.vault.dialog = next;
|
|
|
|
|
|
if saved {
|
|
|
|
|
|
self.vault_refresh();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 從 CSV 匯入. Returns false when the dialog should close.
|
|
|
|
|
|
fn vault_import(&mut self, ctx: &egui::Context, st: &mut ImportState) -> bool {
|
|
|
|
|
|
let p = self.pal;
|
|
|
|
|
|
// A file was picked (the picker runs off the window's thread).
|
|
|
|
|
|
if let Some(slot) = st.picking.clone() {
|
|
|
|
|
|
if let Some(files) = slot.lock().unwrap().take() {
|
|
|
|
|
|
st.picking = None;
|
|
|
|
|
|
if let Some(f) = files.into_iter().next() {
|
|
|
|
|
|
st.file = f.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
|
|
|
|
|
st.err.clear();
|
|
|
|
|
|
st.plan = None;
|
|
|
|
|
|
match std::fs::metadata(&f).map(|m| m.len()) {
|
|
|
|
|
|
Ok(n) if n > store::MAX_IMPORT_BYTES => st.err = t("該檔案太大。請選擇小於 5 MB 的 CSV 檔案。"),
|
|
|
|
|
|
Ok(_) => match std::fs::read(&f).map(|b| String::from_utf8_lossy(&b).to_string()) {
|
|
|
|
|
|
Ok(text) => match store::plan_import(&text) {
|
|
|
|
|
|
Ok(plan) => st.plan = Some(plan),
|
|
|
|
|
|
Err(e) => st.err = e,
|
|
|
|
|
|
},
|
|
|
|
|
|
Err(_) => st.err = t("無法讀取該檔案。請再試一次"),
|
|
|
|
|
|
},
|
|
|
|
|
|
Err(_) => st.err = t("無法讀取該檔案。請再試一次"),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
ctx.request_repaint_after(std::time::Duration::from_millis(200));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Saving: a few rows per frame so the progress bar moves and 停止匯入 works.
|
|
|
|
|
|
if !st.queue.is_empty() {
|
|
|
|
|
|
if st.stop {
|
|
|
|
|
|
let left = st.queue.len();
|
|
|
|
|
|
st.queue.clear();
|
|
|
|
|
|
if let Some(d) = st.done.as_mut() {
|
|
|
|
|
|
d.2.push((String::new(), format!("匯入在所有項目儲存完成前就已停止({left} 個沒有匯入)")));
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if let Some(plan) = st.plan.as_ref() {
|
|
|
|
|
|
let take: Vec<usize> = st.queue.drain(..st.queue.len().min(20)).collect();
|
|
|
|
|
|
for i in take {
|
|
|
|
|
|
let row = &plan.rows[i];
|
|
|
|
|
|
let d = st.done.get_or_insert((0, 0, vec![]));
|
|
|
|
|
|
match if self.demo { Ok(()) } else { store::import_row(row) } {
|
|
|
|
|
|
Ok(()) if row.kind == Kind::Card => d.1 += 1,
|
|
|
|
|
|
Ok(()) => d.0 += 1,
|
|
|
|
|
|
Err(e) => d.2.push((row.label.clone(), e)),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
ctx.request_repaint();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut keep = true;
|
|
|
|
|
|
let close = dialog(ctx, "vault-import", "從 CSV 匯入", 560.0, false, p, |ui| {
|
|
|
|
|
|
dialog_body(ui, "vault-import-body", dialog_max_height(ctx) - 140.0, |ui| {
|
|
|
|
|
|
let importing = !st.queue.is_empty();
|
|
|
|
|
|
if let (Some((logins, cards, fails)), false) = (&st.done, importing) {
|
|
|
|
|
|
// Summary.
|
|
|
|
|
|
let line = if *cards > 0 { format!("已匯入 {logins} 筆登入資訊和 {cards} 張卡片") } else { format!("已匯入 {logins} 筆登入資訊") };
|
|
|
|
|
|
crate::icons::label(ui, Icon::CircleCheck, &line, 14.0, p.text);
|
|
|
|
|
|
if let Some(plan) = &st.plan {
|
|
|
|
|
|
let dup = plan.count(|s| *s == RowStatus::AlreadySaved);
|
|
|
|
|
|
if dup > 0 {
|
|
|
|
|
|
ui.label(RichText::new(format!("已略過保險庫中已有的 {dup} 個項目")).size(FONT_SM).color(p.text2));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if !fails.is_empty() {
|
|
|
|
|
|
ui.label(RichText::new(format!("{} 個項目無法匯入", fails.len())).size(FONT_SM).color(p.danger));
|
|
|
|
|
|
for (name, e) in fails.iter().take(30) {
|
|
|
|
|
|
ui.label(RichText::new(if name.is_empty() { e.clone() } else { format!("{name}:{e}") }).size(FONT_XS).color(p.text3));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if importing {
|
|
|
|
|
|
let done = st.total - st.queue.len();
|
|
|
|
|
|
ui.label(format!("正在匯入第 {done} 個,共 {} 個…", st.total));
|
|
|
|
|
|
ui.add(egui::ProgressBar::new(done as f32 / st.total.max(1) as f32));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
match &st.plan {
|
|
|
|
|
|
None => {
|
|
|
|
|
|
ui.label(t("從 1Password、Chrome、Edge、Safari、Apple Passwords、Firefox、Bitwarden 或 LastPass 匯出的 CSV 檔案匯入登入資訊"));
|
|
|
|
|
|
ui.label(RichText::new(t("從 LastPass 匯出的檔案會一併匯入卡片")).size(FONT_XS).color(p.text3));
|
|
|
|
|
|
ui.label(RichText::new(t("檔案只會在這部裝置上讀取,絕不會上傳")).size(FONT_XS).color(p.text3));
|
|
|
|
|
|
if st.picking.is_some() {
|
|
|
|
|
|
ui.label(RichText::new(t("正在等你選檔案…")).size(FONT_XS).color(p.text3));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Some(plan) => {
|
|
|
|
|
|
crate::icons::label(ui, Icon::FileText, &format!("{}:{}", t(plan.format.banner()), st.file), 13.0, p.text2);
|
|
|
|
|
|
ui.add_space(4.0);
|
|
|
|
|
|
let ready = plan.count(|s| *s == RowStatus::Ready);
|
|
|
|
|
|
let dup = plan.count(|s| *s == RowStatus::AlreadySaved);
|
|
|
|
|
|
let bad = plan.count(|s| matches!(s, RowStatus::Error(_)));
|
|
|
|
|
|
let skip = plan.count(|s| matches!(s, RowStatus::Skipped(_)));
|
|
|
|
|
|
ui.label(RichText::new(format!("{ready} 個項目已就緒 · {dup} 個項目已在你的保險庫中 · {bad} 個項目有錯誤 · 已略過 {skip} 個項目")).size(FONT_SM));
|
|
|
|
|
|
ui.add_space(6.0);
|
|
|
|
|
|
ui.label(RichText::new(t("此檔案中的項目")).size(FONT_XS).color(p.text3));
|
|
|
|
|
|
for r in plan.rows.iter().take(300) {
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
let (rr, _) = ui.allocate_exact_size(vec2(16.0, 18.0), egui::Sense::hover());
|
|
|
|
|
|
crate::icons::paint(ui.painter(), rr.shrink(2.0), kind_icon(r.kind), p.text3);
|
|
|
|
|
|
ui.label(RichText::new(&r.label).size(FONT_SM));
|
|
|
|
|
|
ui.label(RichText::new(&r.secondary).size(FONT_XS).color(p.text3));
|
|
|
|
|
|
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
|
|
|
|
|
let (s, c) = match &r.status {
|
|
|
|
|
|
RowStatus::Ready => (t("已就緒"), p.text3),
|
|
|
|
|
|
RowStatus::AlreadySaved => (t("已在你的保險庫中"), p.text3),
|
|
|
|
|
|
RowStatus::Error(e) => (t(e), p.danger),
|
|
|
|
|
|
RowStatus::Skipped(e) => (t(e), p.text3),
|
|
|
|
|
|
};
|
|
|
|
|
|
ui.label(RichText::new(s).size(FONT_XS).color(c));
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
if plan.rows.len() > 300 {
|
|
|
|
|
|
ui.label(RichText::new(format!("…還有 {} 列", plan.rows.len() - 300)).size(FONT_XS).color(p.text3));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if !st.err.is_empty() {
|
|
|
|
|
|
ui.label(RichText::new(&st.err).size(FONT_XS).color(p.danger));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
dialog_footer(ui, p, |ui| {
|
|
|
|
|
|
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
|
|
|
|
|
if !st.queue.is_empty() {
|
|
|
|
|
|
if ui.add(secondary_button("停止匯入")).clicked() {
|
|
|
|
|
|
st.stop = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if st.done.is_some() {
|
|
|
|
|
|
if ui.add(primary_button("完成", p)).clicked() {
|
|
|
|
|
|
keep = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let ready: Vec<usize> = st.plan.as_ref().map(|pl| pl.rows.iter().enumerate().filter(|(_, r)| r.status == RowStatus::Ready).map(|(i, _)| i).collect()).unwrap_or_default();
|
|
|
|
|
|
if st.plan.is_some() && ui.add_enabled(!ready.is_empty(), primary_button(&format!("匯入 {} 個項目", ready.len()), p)).clicked() {
|
|
|
|
|
|
st.total = ready.len();
|
|
|
|
|
|
st.queue = ready;
|
|
|
|
|
|
st.done = Some((0, 0, vec![]));
|
|
|
|
|
|
}
|
|
|
|
|
|
let pick = if st.plan.is_some() { "選擇其他檔案" } else { "選擇 CSV 檔案" };
|
|
|
|
|
|
if ui.add_enabled(st.picking.is_none(), if st.plan.is_some() { secondary_button(pick) } else { primary_button(pick, p) }).clicked() {
|
|
|
|
|
|
let slot = Arc::new(Mutex::new(None));
|
|
|
|
|
|
st.picking = Some(slot.clone());
|
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
|
let files = crate::attach::pick_files();
|
|
|
|
|
|
*slot.lock().unwrap() = Some(files);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
if ui.add(secondary_button("取消")).clicked() {
|
|
|
|
|
|
keep = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
keep && !close
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The six address fields.
|
|
|
|
|
|
fn address_fields(ui: &mut egui::Ui, a: &mut Address, p: Palette) {
|
|
|
|
|
|
field_label(ui, "街道地址", p);
|
|
|
|
|
|
ui.add(egui::TextEdit::singleline(&mut a.line1).desired_width(f32::INFINITY));
|
|
|
|
|
|
field_label(ui, "公寓、套房或單位(選填)", p);
|
|
|
|
|
|
ui.add(egui::TextEdit::singleline(&mut a.line2).desired_width(f32::INFINITY));
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
let w = (ui.available_width() - 8.0) / 2.0;
|
|
|
|
|
|
ui.vertical(|ui| {
|
|
|
|
|
|
field_label(ui, "城市", p);
|
|
|
|
|
|
ui.add(egui::TextEdit::singleline(&mut a.city).desired_width(w));
|
|
|
|
|
|
});
|
|
|
|
|
|
ui.vertical(|ui| {
|
|
|
|
|
|
field_label(ui, "州/省/地區", p);
|
|
|
|
|
|
ui.add(egui::TextEdit::singleline(&mut a.region).desired_width(w));
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
let w = (ui.available_width() - 8.0) / 2.0;
|
|
|
|
|
|
ui.vertical(|ui| {
|
|
|
|
|
|
field_label(ui, "郵遞區號", p);
|
|
|
|
|
|
ui.add(egui::TextEdit::singleline(&mut a.postal).desired_width(w));
|
|
|
|
|
|
});
|
|
|
|
|
|
ui.vertical(|ui| {
|
|
|
|
|
|
field_label(ui, "國家/地區", p);
|
|
|
|
|
|
ui.add(egui::TextEdit::singleline(&mut a.country).desired_width(w));
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Phone or email rows: the first is the main one; others can become it or be removed.
|
|
|
|
|
|
fn rows_editor(ui: &mut egui::Ui, label: &str, add: &str, rows: &mut Vec<String>, valid: fn(&str) -> bool, bad: &str, p: Palette) {
|
|
|
|
|
|
if rows.is_empty() {
|
|
|
|
|
|
rows.push(String::new());
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut drop = None;
|
|
|
|
|
|
let mut main = None;
|
|
|
|
|
|
for i in 0..rows.len() {
|
|
|
|
|
|
field_label(ui, &if i == 0 { t(label) } else { format!("{} {}", t(label), i + 1) }, p);
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
let extra = if i > 0 { 110.0 } else { 0.0 };
|
|
|
|
|
|
ui.add(egui::TextEdit::singleline(&mut rows[i]).desired_width(ui.available_width() - extra));
|
|
|
|
|
|
if i > 0 {
|
|
|
|
|
|
if ui.add(egui::Button::new(RichText::new(t("設為主要")).size(FONT_XS)).frame(false)).clicked() {
|
|
|
|
|
|
main = Some(i);
|
|
|
|
|
|
}
|
|
|
|
|
|
if ui.add(egui::Button::new("×").frame(false)).on_hover_text(t("移除")).clicked() {
|
|
|
|
|
|
drop = Some(i);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
let v = rows[i].trim();
|
|
|
|
|
|
if !v.is_empty() && !valid(v) {
|
|
|
|
|
|
ui.label(RichText::new(t(bad)).size(FONT_XS).color(p.danger));
|
|
|
|
|
|
} else if !v.is_empty() && rows[..i].iter().any(|r| r.trim() == v) {
|
|
|
|
|
|
ui.label(RichText::new(t("上方已列出")).size(FONT_XS).color(p.text3));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(i) = main {
|
|
|
|
|
|
let v = rows.remove(i);
|
|
|
|
|
|
rows.insert(0, v);
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(i) = drop {
|
|
|
|
|
|
rows.remove(i);
|
|
|
|
|
|
}
|
|
|
|
|
|
if rows.len() < store::MAX_ROWS && ui.add(egui::Button::new(RichText::new(t(add)).size(FONT_XS)).frame(false)).clicked() {
|
|
|
|
|
|
rows.push(String::new());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Screenshot mode: a few made-up items.
|
|
|
|
|
|
fn demo_items() -> Vec<Item> {
|
|
|
|
|
|
vec![
|
|
|
|
|
|
Item { id: "d1".into(), kind: Kind::Login, title: "github.com".into(), sites: vec!["github.com".into()], username: "me@example.com".into(), notes: "工作用的帳號".into(), ..Default::default() },
|
|
|
|
|
|
Item { id: "d2".into(), kind: Kind::Login, title: "momoshop.com.tw".into(), sites: vec!["momoshop.com.tw".into()], username: "0912xxx678".into(), ..Default::default() },
|
|
|
|
|
|
Item { id: "d3".into(), kind: Kind::Card, title: "Visa".into(), brand: "Visa".into(), last4: "4242".into(), nickname: "旅行".into(), exp_month: 8, exp_year: 2029, ..Default::default() },
|
|
|
|
|
|
Item { id: "d4".into(), kind: Kind::Contact, title: "住家".into(), name: "王小明".into(), address: Address { line1: "信義路五段 7 號".into(), city: "台北市".into(), postal: "110".into(), country: "台灣".into(), ..Default::default() }, phones: vec!["0912-345-678".into()], ..Default::default() },
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|