From a11bde26a68f2b1730acdeca2552cc778168b63b Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Mon, 10 Aug 2026 19:36:59 +0530 Subject: [PATCH] refactor(scanners): remove the CDP webview-account surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1 of 3 for #5478. Removes the six CDP-driven provider scanners and the webview-account surface they ran inside. Pure dead-code removal: the frontend entry points (SidebarAppRail, WebviewHost, webviewAccountService) were deleted in #5457 and never restored, so none of this was reachable by a user. Deleted: - app/src-tauri/src/{whatsapp,discord,slack,telegram,gmessages,wechat}_scanner - app/src-tauri/src/webview_accounts (4,809 lines) - src/openhuman/channels/webview_accounts (WeChat ingest normalisation, whose only producer was wechat_scanner; already had zero other consumers) - app/src-tauri/src/cdp/{session,snapshot}.rs — the per-account session opener and DOM-snapshot parser. session.rs imports webview_accounts directly, so it cannot compile without it; both had no other consumer. - The CEF cold-start prewarm and the webview-close drain machinery, whose only producers were the prewarm webview and webview_accounts. Unregisters all 15 webview_account_* IPC commands, so the shell no longer exposes commands whose implementation is gone. imessage_scanner is untouched and still registered: it reads chat.db natively, has zero CDP references, and its only crate-internal dependency is core_rpc. cdp/ itself stays — after this its only consumers are the Meet stack (meet_scanner, meet_audio, meet_video), removed in PR 2 of 3. i18n keys and user-facing provider copy are deliberately left in place; they are split and removed in PR 3 pending the #5423 decision on whether users with a connected web app get a removal notice. --- AGENTS.md | 14 +- app/src-tauri/src/cdp/conn.rs | 9 +- app/src-tauri/src/cdp/in_process.rs | 6 +- app/src-tauri/src/cdp/mod.rs | 33 +- app/src-tauri/src/cdp/session.rs | 977 ----- app/src-tauri/src/cdp/snapshot.rs | 339 -- app/src-tauri/src/cdp/target.rs | 75 +- .../src/discord_scanner/dom_snapshot.rs | 158 - app/src-tauri/src/discord_scanner/mod.rs | 1241 ------- .../src/discord_scanner/mod_tests.rs | 431 --- .../src/gmessages_scanner/cdp_walk.rs | 205 - app/src-tauri/src/gmessages_scanner/idb.rs | 313 -- app/src-tauri/src/gmessages_scanner/mod.rs | 239 -- app/src-tauri/src/lib.rs | 432 +-- app/src-tauri/src/lib_tests.rs | 54 - app/src-tauri/src/meet_audio/mod.rs | 3 +- .../src/slack_scanner/dom_snapshot.rs | 147 - app/src-tauri/src/slack_scanner/extract.rs | 352 -- app/src-tauri/src/slack_scanner/idb.rs | 326 -- app/src-tauri/src/slack_scanner/mod.rs | 913 ----- .../src/telegram_scanner/dom_snapshot.rs | 176 - app/src-tauri/src/telegram_scanner/extract.rs | 406 -- app/src-tauri/src/telegram_scanner/idb.rs | 326 -- app/src-tauri/src/telegram_scanner/mod.rs | 777 ---- app/src-tauri/src/webview_accounts/mod.rs | 3303 ----------------- .../src/webview_accounts/mod_tests.rs | 1351 ------- app/src-tauri/src/webview_accounts/runtime.js | 155 - .../src/wechat_scanner/dom_snapshot.rs | 348 -- app/src-tauri/src/wechat_scanner/mod.rs | 247 -- .../src/whatsapp_scanner/dom_snapshot.rs | 903 ----- .../whatsapp_scanner/dom_snapshot_tests.rs | 131 - app/src-tauri/src/whatsapp_scanner/idb.rs | 461 --- .../src/whatsapp_scanner/idb_tests.rs | 152 - app/src-tauri/src/whatsapp_scanner/mod.rs | 1294 ------- .../src/whatsapp_scanner/mod_tests.rs | 609 --- .../test_fixtures/dom_snapshot_2026_05.json | 53 - app/src/utils/tauriCommands/common.test.ts | 10 +- src/openhuman/channels/mod.rs | 3 - .../channels/webview_accounts/README.md | 76 - .../channels/webview_accounts/mod.rs | 23 - .../webview_accounts/wechat_ingest.rs | 358 -- .../webview_accounts/wechat_ingest_tests.rs | 54 - 42 files changed, 53 insertions(+), 17430 deletions(-) delete mode 100644 app/src-tauri/src/cdp/session.rs delete mode 100644 app/src-tauri/src/cdp/snapshot.rs delete mode 100644 app/src-tauri/src/discord_scanner/dom_snapshot.rs delete mode 100644 app/src-tauri/src/discord_scanner/mod.rs delete mode 100644 app/src-tauri/src/discord_scanner/mod_tests.rs delete mode 100644 app/src-tauri/src/gmessages_scanner/cdp_walk.rs delete mode 100644 app/src-tauri/src/gmessages_scanner/idb.rs delete mode 100644 app/src-tauri/src/gmessages_scanner/mod.rs delete mode 100644 app/src-tauri/src/slack_scanner/dom_snapshot.rs delete mode 100644 app/src-tauri/src/slack_scanner/extract.rs delete mode 100644 app/src-tauri/src/slack_scanner/idb.rs delete mode 100644 app/src-tauri/src/slack_scanner/mod.rs delete mode 100644 app/src-tauri/src/telegram_scanner/dom_snapshot.rs delete mode 100644 app/src-tauri/src/telegram_scanner/extract.rs delete mode 100644 app/src-tauri/src/telegram_scanner/idb.rs delete mode 100644 app/src-tauri/src/telegram_scanner/mod.rs delete mode 100644 app/src-tauri/src/webview_accounts/mod.rs delete mode 100644 app/src-tauri/src/webview_accounts/mod_tests.rs delete mode 100644 app/src-tauri/src/webview_accounts/runtime.js delete mode 100644 app/src-tauri/src/wechat_scanner/dom_snapshot.rs delete mode 100644 app/src-tauri/src/wechat_scanner/mod.rs delete mode 100644 app/src-tauri/src/whatsapp_scanner/dom_snapshot.rs delete mode 100644 app/src-tauri/src/whatsapp_scanner/dom_snapshot_tests.rs delete mode 100644 app/src-tauri/src/whatsapp_scanner/idb.rs delete mode 100644 app/src-tauri/src/whatsapp_scanner/idb_tests.rs delete mode 100644 app/src-tauri/src/whatsapp_scanner/mod.rs delete mode 100644 app/src-tauri/src/whatsapp_scanner/mod_tests.rs delete mode 100644 app/src-tauri/src/whatsapp_scanner/test_fixtures/dom_snapshot_2026_05.json delete mode 100644 src/openhuman/channels/webview_accounts/README.md delete mode 100644 src/openhuman/channels/webview_accounts/mod.rs delete mode 100644 src/openhuman/channels/webview_accounts/wechat_ingest.rs delete mode 100644 src/openhuman/channels/webview_accounts/wechat_ingest_tests.rs diff --git a/AGENTS.md b/AGENTS.md index 1716b8306b..ed3a3534d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -163,9 +163,11 @@ No `UserProvider`/`AIProvider`/`SkillProvider` — auth lives in `CoreStateProvi ## Tauri shell (`app/src-tauri/`) -Thin desktop host. Key modules: `core_process`, `core_rpc`, `cdp`, `cef_preflight`, `cef_profile`, `dictation_hotkeys`, `file_logging`, `mascot_native_window`, `window_state`, per-provider scanners (`discord_scanner`, `slack_scanner`, `telegram_scanner`, `whatsapp_scanner`, `wechat_scanner`, `gmessages_scanner`, `imessage_scanner`, `meet_scanner`), `meet_audio`/`meet_call`/`meet_video`, `fake_camera`, `webview_accounts`, `webview_apis`. +Thin desktop host. Key modules: `core_process`, `core_rpc`, `cdp`, `dictation_hotkeys`, `file_logging`, `mascot_native_window`, `window_state`, `imessage_scanner`, `meet_audio`/`meet_call`/`meet_video`/`meet_scanner`, `fake_camera`, `webview_apis`. -IPC commands (authoritative list: `generate_handler!` in `app/src-tauri/src/lib.rs`): `core_rpc::relay_http_rpc`, `core_rpc_url`, `core_rpc_token`, `start_core_process`/`restart_core_process`, update commands (`check_app_update`, `apply_core_update`, …), window commands (`activate_main_window`, `mascot_window_*`, `notch_window_*`), `webview_accounts::*`, `workspace_paths::*`, `artifact_commands::*`, hotkeys (dictation/PTT/companion), `meet_call::*`, `native_notifications::*`, `mcp_commands::*`, `loopback_oauth::*`. +The CDP-driven provider scanners (`discord_scanner`, `slack_scanner`, `telegram_scanner`, `whatsapp_scanner`, `wechat_scanner`, `gmessages_scanner`) and the `webview_accounts` surface they ran inside were removed in #5478 — CDP only exists under a Chromium engine, and the app moved to Wry in #5456. `imessage_scanner` is unaffected: it reads `chat.db` natively and never used CDP. + +IPC commands (authoritative list: `generate_handler!` in `app/src-tauri/src/lib.rs`): `core_rpc::relay_http_rpc`, `core_rpc_url`, `core_rpc_token`, `start_core_process`/`restart_core_process`, update commands (`check_app_update`, `apply_core_update`, …), window commands (`activate_main_window`, `mascot_window_*`, `notch_window_*`), `workspace_paths::*`, `artifact_commands::*`, hotkeys (dictation/PTT/companion), `meet_call::*`, `native_notifications::*`, `mcp_commands::*`, `loopback_oauth::*`. ### CEF child webviews — no new JS injection @@ -286,9 +288,9 @@ two paths' equivalence — keep that as call sites migrate. ### Domain layout (`src/openhuman/`) -~31 domain directories — authoritative list: `ls -d src/openhuman/*/`. Major families: agent (`agent` — with `agent/{agentbox,artifacts,context,experience,file_state,harness_init,learning,orchestration,plan_review,profiles,registry,session_db,session_import,tinyagents}`), memory (`memory` — with `memory/{agent,conversations,diff,goals,people,queue,search,sources,store,sync,tinycortex,tool_memory,tree}`), skills/flows (`skills` — with `skills/{catalog,runtime,webhooks}` —, `flows` — with `flows/{tinyflows,rhai}`), inference/AI (`inference` — with `inference/{embeddings,tokenjuice}` —, `routing`), MCP (`mcp` — with `mcp/{server,registry,audit,config_servers,http_client}`), runtimes (`runtime` — with `runtime/{node,python,python_server,pool,javascript}` —, `sandbox` — with `sandbox/cwd_jail`), channels/webviews (`channels` — with `channels/{whatsapp_data,webview_accounts}`), meet (`meet` — with `meet/agent`, `meet/backend_bot`), web3 (`web3` — with `web3/{wallet,x402}`), plus kernel domains (`platform` — with `platform/{about_app,connectivity,cost,doctor,health,proc_metrics,service,socket,startup,update}` —, `config` — with `config/{migrations,migration_helpers,workspace}` —, `cron` — with `cron/scheduler_gate` —, `integrations`, `security` — with `security/{approval,credentials,keyring,keyring_consent,encryption,prompt_injection,devices}` —, `threads` — with `threads/{goals,todos}` —, `tools` — with `tools/{registry,status,timeout,agent_policy}` —, `util` — with `util/{text,retry,tls,types}` —, `voice`, …). +~31 domain directories — authoritative list: `ls -d src/openhuman/*/`. Major families: agent (`agent` — with `agent/{agentbox,artifacts,context,experience,file_state,harness_init,learning,orchestration,plan_review,profiles,registry,session_db,session_import,tinyagents}`), memory (`memory` — with `memory/{agent,conversations,diff,goals,people,queue,search,sources,store,sync,tinycortex,tool_memory,tree}`), skills/flows (`skills` — with `skills/{catalog,runtime,webhooks}` —, `flows` — with `flows/{tinyflows,rhai}`), inference/AI (`inference` — with `inference/{embeddings,tokenjuice}` —, `routing`), MCP (`mcp` — with `mcp/{server,registry,audit,config_servers,http_client}`), runtimes (`runtime` — with `runtime/{node,python,python_server,pool,javascript}` —, `sandbox` — with `sandbox/cwd_jail`), channels (`channels` — with `channels/whatsapp_data`), meet (`meet` — with `meet/agent`, `meet/backend_bot`), web3 (`web3` — with `web3/{wallet,x402}`), plus kernel domains (`platform` — with `platform/{about_app,connectivity,cost,doctor,health,proc_metrics,service,socket,startup,update}` —, `config` — with `config/{migrations,migration_helpers,workspace}` —, `cron` — with `cron/scheduler_gate` —, `integrations`, `security` — with `security/{approval,credentials,keyring,keyring_consent,encryption,prompt_injection,devices}` —, `threads` — with `threads/{goals,todos}` —, `tools` — with `tools/{registry,status,timeout,agent_policy}` —, `util` — with `util/{text,retry,tls,types}` —, `voice`, …). -**Family directories (in progress).** The flat tree is being collapsed so that **one directory equals one feature gate**: a capability spread across sibling top-level dirs costs a `#[cfg]` per dir plus five parallel registries to keep in sync. Landed so far (124 → 31 top-level dirs, 0 root-level `*.rs`): `meet/`, `util/` (incl. `util/sanitize`), `mcp/{server,registry,audit,config_servers,http_client}`, `sandbox/cwd_jail`, `cron/scheduler_gate`, `runtime/`, `media/`, `voice/audio_toolkit`, `web3/{wallet,x402}`, `medulla/chat`, `flows/{tinyflows,rhai}`, `channels/{whatsapp_data,webview_accounts}`, `desktop/` (accessibility, app_state, dashboard, notifications, overlay, provider_surfaces), `hosted/` (announcements, billing, orchestration, referral, team — all thin proxies to the TinyHumans backend), `subconscious/{triggers,monitors}`, `threads/{goals,todos}`, `tools/{registry,status,timeout,agent_policy}`, `platform/` (about_app, connectivity, cost, doctor, health, proc_metrics, service, socket, startup, update), `config/{migrations,migration_helpers,workspace}`, `integrations/{composio,recall_calendar,file_storage,task_sources}`, `skills/{catalog,runtime,webhooks}`, `inference/{embeddings,tokenjuice}`, `security/{approval,credentials,keyring,keyring_consent,encryption,prompt_injection,devices}` (the kernel security family — never gated), and `agent/{experience,orchestration,registry,agentbox,harness_init,session_db,session_import,context,profiles,learning,plan_review,file_state,artifacts,tinyagents}` (the agent harness is kernel and is never gated; `agent/` stayed put as the parent rather than becoming `agent/core`, which would have cost ~999 extra import rewrites for no gate benefit), and `memory/{store,sync,tree,search,sources,queue,diff,goals,conversations,tool_memory,tinycortex,agent,people}` (the largest family, moved last; `memory/` stayed put as the parent — a `memory → memory/core` rename would have cost ~545 extra rewrites — with the pre-existing `memory/sync.rs` renamed to `memory/sync_events.rs` to free the name for `memory_sync`, and `memory_tools` landing as `memory/tool_memory` to avoid the pre-existing `memory/tools/` agent-tool directory). The `heartbeat/` re-export shim is deleted; use `subconscious::heartbeat` directly. Plan, target tree, and move-PR rules: [`docs/specs/2026-08-02-core-kernel-domain-reorg.md`](docs/specs/2026-08-02-core-kernel-domain-reorg.md). +**Family directories (in progress).** The flat tree is being collapsed so that **one directory equals one feature gate**: a capability spread across sibling top-level dirs costs a `#[cfg]` per dir plus five parallel registries to keep in sync. Landed so far (124 → 31 top-level dirs, 0 root-level `*.rs`): `meet/`, `util/` (incl. `util/sanitize`), `mcp/{server,registry,audit,config_servers,http_client}`, `sandbox/cwd_jail`, `cron/scheduler_gate`, `runtime/`, `media/`, `voice/audio_toolkit`, `web3/{wallet,x402}`, `medulla/chat`, `flows/{tinyflows,rhai}`, `channels/whatsapp_data`, `desktop/` (accessibility, app_state, dashboard, notifications, overlay, provider_surfaces), `hosted/` (announcements, billing, orchestration, referral, team — all thin proxies to the TinyHumans backend), `subconscious/{triggers,monitors}`, `threads/{goals,todos}`, `tools/{registry,status,timeout,agent_policy}`, `platform/` (about_app, connectivity, cost, doctor, health, proc_metrics, service, socket, startup, update), `config/{migrations,migration_helpers,workspace}`, `integrations/{composio,recall_calendar,file_storage,task_sources}`, `skills/{catalog,runtime,webhooks}`, `inference/{embeddings,tokenjuice}`, `security/{approval,credentials,keyring,keyring_consent,encryption,prompt_injection,devices}` (the kernel security family — never gated), and `agent/{experience,orchestration,registry,agentbox,harness_init,session_db,session_import,context,profiles,learning,plan_review,file_state,artifacts,tinyagents}` (the agent harness is kernel and is never gated; `agent/` stayed put as the parent rather than becoming `agent/core`, which would have cost ~999 extra import rewrites for no gate benefit), and `memory/{store,sync,tree,search,sources,queue,diff,goals,conversations,tool_memory,tinycortex,agent,people}` (the largest family, moved last; `memory/` stayed put as the parent — a `memory → memory/core` rename would have cost ~545 extra rewrites — with the pre-existing `memory/sync.rs` renamed to `memory/sync_events.rs` to free the name for `memory_sync`, and `memory_tools` landing as `memory/tool_memory` to avoid the pre-existing `memory/tools/` agent-tool directory). The `heartbeat/` re-export shim is deleted; use `subconscious::heartbeat` directly. Plan, target tree, and move-PR rules: [`docs/specs/2026-08-02-core-kernel-domain-reorg.md`](docs/specs/2026-08-02-core-kernel-domain-reorg.md). A move never changes the wire surface — RPC namespaces are string literals in `ControllerSchema`, not derived from module paths — so **do not rename namespace strings to match new paths**. @@ -412,7 +414,7 @@ whole cohort or expect a delta of 0. | `flows` | ON | `openhuman::flows` (saved automation graphs — create/run/schedule, the `workflow_builder` + `flow_discovery` agents), `openhuman::flows::tinyflows` (engine seam), `openhuman::flows::rhai` (`.ragsh` language-workflow tool) | `tinyflows`, `jaq-core`, `jaq-std`, `jaq-json`, `rhai` | | `mcp` | ON | `openhuman::mcp::server` (the `openhuman mcp` stdio/HTTP server), `openhuman::mcp::registry` (dynamic Smithery installs — `mcp_clients` RPC namespace, SQLite, boot spawn, supervisor, OAuth), `openhuman::mcp::audit` (write-audit log), and the static config-declared server set in `openhuman::mcp::config_servers`. ~19 agent tools, ~20k LOC | **none** (see scope note) | | `tui` | ON | `openhuman::tui` — the tabbed ratatui/crossterm CLI UI (Logs, Chat, Config, Settings), auto-opened by bare `openhuman` on interactive non-container hosts and forced with `openhuman tui` (alias `chat`). Runs the core in-process. No controllers, no agent tools. **Intentionally NOT forwarded to the desktop shell** (allowlisted in `check-feature-forwarding.mjs`). | `ratatui`, `crossterm` | -| `channels` | ON | `openhuman::channels` (external-messaging providers — Telegram/Discord/Slack/Signal/WhatsApp/iMessage/IRC/… — plus the channel runtime, controllers, host, proactive messaging + inbound dispatch) and the `channels::webview_accounts` / `webview_apis` / `webview_notifications` / `channels::whatsapp_data` webview-bridge domains (incl. the 3 `whatsapp_data_*` agent tools). **Carve-outs `channels::{traits, cli}` stay ungated.** | **28** via `tinychannels/{email,lark}` — the crate itself stays (load-bearing), its two heavy providers do not | +| `channels` | ON | `openhuman::channels` (external-messaging providers — Telegram/Discord/Slack/Signal/WhatsApp/iMessage/IRC/… — plus the channel runtime, controllers, host, proactive messaging + inbound dispatch) and the `webview_apis` / `webview_notifications` / `channels::whatsapp_data` webview-bridge domains (incl. the 3 `whatsapp_data_*` agent tools). **Carve-outs `channels::{traits, cli}` stay ungated.** | **28** via `tinychannels/{email,lark}` — the crate itself stays (load-bearing), its two heavy providers do not | | `contacts` | ON | `memory::people::address_book`'s macOS CNContactStore reader — the address-book seeding path for the people domain. Leaf gate over a **pre-existing** off-state: the module already shipped a non-macOS `imp` stub returning an empty contact list, so the gate only widens that stub's cfg. `read`/`read_with`/`AddressBookError`/`SystemContactsSource` and the whole `people` RPC surface stay compiled in every build; off ⇒ a refresh seeds nothing instead of failing. | **6** on macOS (`objc2`, `objc2-foundation`, `objc2-contacts`, `block2` + 2 transitive). **No-op on Linux/Windows** — never in those graphs, so the kernel-floor ratchet does not move. Verify cross-target: `cargo tree --target aarch64-apple-darwin -e normal -i objc2-contacts --no-default-features` (294 → 288 packages). | | `runtime-node` | ON | `runtime::node` (download / verify / extract / install a pinned Node.js toolchain), the `runtime::javascript` language slot, `runtime::pool::node`, the `node_exec` / `npm_exec` agent tools, and the `node_runtime` harness-init step. **Facade + stub** — `ShellTool` holds `Option>` and `shell.rs` is kernel, so the module cannot simply vanish; `runtime/node/stub.rs` carries the `NodeBootstrap` type surface while registration sites are leaf-gated. **The generic native-tool dispatcher (`runtime::node::ops` / `runtime::node::types`) is NOT gated** — it backs both the gated `javascript.*` controllers and the ungated `flows` `oh:` `NativeToolBackend`, so native flow tools (`memory_search`, file, shell, …) keep working when the managed Node runtime is off. Off ⇒ `try_cached`/`probe_installed` return `None` and the shell never prepends a managed bin dir, identical to today's `node.enabled = false` path. | **`xz2` + its static liblzma C build.** First gate to remove a NATIVE toolchain build: `lzma-sys` leaves the list, 6 → 5. `tar`/`zip` are NOT shed — shared with `inference` (install_piper), `runtime::python`, and the document tools. | @@ -519,7 +521,7 @@ Leaf-gate pattern with **two ungated carve-outs and no stub file** — the reach - **Two ungated carve-outs.** `pub mod traits;` (a one-line `tinychannels` `Channel`/`SendMessage` re-export) and `pub mod cli;` (`CliChannel`, a dependency-free local stdin/stdout REPL) stay compiled in **all** builds — both are reached by the always-on agent-harness interactive loop (`agent::harness::session::runtime::run_interactive`). Same shape as the `meet::agent::wav` carve-out. `channels::mod.rs` `#[cfg(feature = "channels")]`s everything else; nothing inside the gated submodules changes. - **The in-app web chat is NOT gated.** `openhuman::web_chat` (RPC namespace `channel`, decoupled from `channels/` in #5002 + #5003 which also moved `learning` out) is core product surface and stays always-compiled even though its runtime tag is `DomainGroup::Channels`. Its registration push in `src/core/all.rs` is deliberately left ungated; the both-ways test pins `channel` present with the feature OFF. - **Three mis-housed imports were retargeted to `tinychannels` (no stub needed).** `cron/bus.rs` (`Channel`/`SendMessage`/`ChannelMessage`), `memory_conversations/bus.rs` (`ChannelMessage` + `context::conversation_history_key`), and `voice/audio_toolkit/ops.rs` (`providers::email_channel::EmailChannel`) reached the gated domain only to pick up symbols that actually live in `tinychannels`; pointing them straight at the crate removes the always-on → gated edge (and the voice→channels cross-gate edge). The old `channels::` paths were 1-line delegations / `pub use` re-exports of exactly these. -- **Leaf-gated call sites** (each carries its own `#[cfg]`): the 5 controller-registration pushes in `src/core/all.rs` (channels controllers, `webview_apis`, `webview_notifications`, public + internal `whatsapp_data`), the `ChannelInboundSubscriber` + web-only-proactive block in `src/core/jsonrpc.rs`, `spawn_channels_service` in `src/core/runtime/services.rs`, the `whatsapp_data::global::init` block in `src/core/runtime/context.rs`, and the `whatsapp_data::tools::*` glob + 3 `WhatsAppData*Tool` registrations in `src/openhuman/tools/{mod,ops}.rs`. The `webview_accounts` / `whatsapp_data` `pub mod` declarations now live in `channels/mod.rs` (still `#[cfg(feature = "channels")]` each, because the parent stays ungated for the `traits`/`cli` carve-outs); `webview_apis` / `webview_notifications` moved under `desktop/` in the family reorg and stay leaf-gated there. String-match arms (`"channels" =>` descriptions, `whatsapp_data_` in `group_for_namespace`) stay **ungated** — they are data. +- **Leaf-gated call sites** (each carries its own `#[cfg]`): the 5 controller-registration pushes in `src/core/all.rs` (channels controllers, `webview_apis`, `webview_notifications`, public + internal `whatsapp_data`), the `ChannelInboundSubscriber` + web-only-proactive block in `src/core/jsonrpc.rs`, `spawn_channels_service` in `src/core/runtime/services.rs`, the `whatsapp_data::global::init` block in `src/core/runtime/context.rs`, and the `whatsapp_data::tools::*` glob + 3 `WhatsAppData*Tool` registrations in `src/openhuman/tools/{mod,ops}.rs`. The `whatsapp_data` `pub mod` declaration now lives in `channels/mod.rs` (still `#[cfg(feature = "channels")]`, because the parent stays ungated for the `traits`/`cli` carve-outs); `webview_apis` / `webview_notifications` moved under `desktop/` in the family reorg and stay leaf-gated there. String-match arms (`"channels" =>` descriptions, `whatsapp_data_` in `group_for_namespace`) stay **ungated** — they are data. - **`start_bootstrap_jobs`' `services.channels` block keeps running slim** — it drives composio sync / workspace-memory sync / orchestration drain and names **no** `channels::` symbol, so it stays ungated by design. - **No CLI change.** There is no `openhuman channels` subcommand; generic namespace resolution yields "unknown namespace" when off (the `flows` precedent — acceptable). - **Both-ways tests.** `channels_controllers_{registered_when_feature_on,absent_when_feature_off}` in `src/core/all_tests.rs` pin the controller surface (the OFF half also asserts `channel`/web_chat survives), and `whatsapp_data_tools_{present_when_channels_on,absent_when_channels_off}` in `src/openhuman/tools/ops_tests.rs` pin the 3 agent tools (that module has the full-tool-list machinery). CI's smoke lane runs `cargo check` only, so run `cargo test --lib --no-default-features core::all::tests` locally after touching any gated surface. diff --git a/app/src-tauri/src/cdp/conn.rs b/app/src-tauri/src/cdp/conn.rs index 1bfb7dc00b..f7f799325a 100644 --- a/app/src-tauri/src/cdp/conn.rs +++ b/app/src-tauri/src/cdp/conn.rs @@ -1,10 +1,9 @@ //! [`CdpConn`] — per-attach handle on top of the in-process CDP transport. //! //! Wraps an [`Arc`](super::in_process::WebviewCdpTransport) -//! with the same `call` / `pump_events` surface scanners and the per-account -//! session opener use. All attaches for a given webview share the same -//! in-process channel, and a [`CdpConn`] is just a cheap session-scoped -//! view. +//! with the same `call` / `pump_events` surface its consumers use. All +//! attaches for a given webview share the same in-process channel, and a +//! [`CdpConn`] is just a cheap session-scoped view. use std::sync::Arc; use std::time::Duration; @@ -26,7 +25,7 @@ pub struct CdpConn { impl CdpConn { /// Wrap an already-installed in-process transport. Callers obtain /// the transport from the per-app [`super::CdpRegistry`] - /// (`app.state()`) — typically via [`super::conn_for_account`] or + /// (`app.state()`) — typically via /// [`super::conn_for_label`]. pub fn new(transport: Arc) -> Self { let label = transport.label().to_string(); diff --git a/app/src-tauri/src/cdp/in_process.rs b/app/src-tauri/src/cdp/in_process.rs index bb69f3fb76..15be0c5025 100644 --- a/app/src-tauri/src/cdp/in_process.rs +++ b/app/src-tauri/src/cdp/in_process.rs @@ -1,4 +1,4 @@ -//! Compatibility surface for account scanners. +//! Compatibility surface for the remaining CDP consumers. //! //! Upstream Tauri's native WebView runtime intentionally does not expose the //! Chromium DevTools Protocol transport used by the removed CEF runtime. Keep @@ -85,10 +85,6 @@ impl CdpRegistry { pub fn set_cef_app_handle(_app: tauri::AppHandle) {} -pub fn install_for_account(account_id: &str) -> Result, String> { - install_for_label(&format!("acct_{account_id}")) -} - pub fn install_for_label(label: &str) -> Result, String> { Err(format!( "CDP is unavailable with the upstream Tauri WebView runtime (webview={label})" diff --git a/app/src-tauri/src/cdp/mod.rs b/app/src-tauri/src/cdp/mod.rs index 17cede9959..2417a0c1fe 100644 --- a/app/src-tauri/src/cdp/mod.rs +++ b/app/src-tauri/src/cdp/mod.rs @@ -1,26 +1,23 @@ -//! Shared Chrome DevTools Protocol client for the CEF-backed scanners. +//! Shared Chrome DevTools Protocol client for the Meet call window. //! -//! All CDP traffic flows through the in-process transport in -//! [`in_process`]: CDP messages travel directly between the Tauri shell -//! and the embedded CEF browser via `Webview::send_dev_tools_message` -//! and `Webview::on_dev_tools_protocol`. There is no listener and no -//! network surface; any same-UID process is shut out by construction. +//! CDP traffic flows through the in-process transport in [`in_process`], +//! which is a permanent unavailable-error stub: upstream Tauri's Wry +//! runtime uses WKWebView (macOS) and WebKitGTK (Linux), neither of which +//! speaks CDP. See #5478 — this module and its remaining consumers are +//! being removed; nothing here can succeed at runtime. //! -//! Scanners pick up a [`CdpConn`] either via [`target::conn_for_account`] (for -//! `acct_`-labelled webviews) or [`target::conn_for_label`] / -//! [`target::connect_and_attach_matching_in_process_by_label`] (for other -//! surfaces such as the Meet call window). +//! The per-account session opener and the DOM-snapshot parser were removed +//! alongside the webview-account surface they served. + +// Transitional. With the account scanners gone the only remaining consumer +// is the Meet stack, which uses a narrow slice of the transport, so the rest +// of `CdpConn` / `WebviewCdpTransport` / `CdpRegistry` is unreferenced. +// Pruning it here would be churn: PR 3 of #5478 deletes this whole module. +#![allow(dead_code)] pub mod conn; pub mod in_process; -pub mod session; -pub mod snapshot; pub mod target; pub use conn::CdpConn; -pub use in_process::{install_for_account, install_for_label, set_cef_app_handle, CdpRegistry}; -pub use session::{ - placeholder_marker, placeholder_url, spawn_session, target_url_fragment, SpawnedSession, -}; -pub use snapshot::Snapshot; -pub use target::{detach_session, find_page_target_where}; +pub use in_process::{install_for_label, set_cef_app_handle, CdpRegistry}; diff --git a/app/src-tauri/src/cdp/session.rs b/app/src-tauri/src/cdp/session.rs deleted file mode 100644 index 7e581de384..0000000000 --- a/app/src-tauri/src/cdp/session.rs +++ /dev/null @@ -1,977 +0,0 @@ -//! Per-account CDP session opener. One long-lived task per webview account -//! that keeps a session attached to the target for the lifetime of the -//! webview. -//! -//! Why long-lived: the session subscribes to `Page.loadEventFired` (used as -//! a belt-and-braces signal for `webview-account:load`). If we attached -//! once and dropped, the load signal would never reach the frontend. -//! -//! Pairs with the placeholder URL the webview is created with — the opener -//! finds the target by its unique `openhuman:{account_id}` marker in the -//! initial URL, injects the notification-permission shim before the page's -//! own JS runs, then navigates the target to the real provider URL with a -//! `#openhuman-account-{id}` fragment appended so other scanners -//! (discord/telegram/slack/whatsapp) can disambiguate multi-account setups -//! without title-marker injection. - -use std::time::Duration; - -use serde_json::json; -use tauri::{AppHandle, Runtime}; -use tokio::sync::mpsc; -use tokio::task::JoinHandle; -// `tokio::time::Instant` (not `std::time::Instant`) so the hard-ceiling -// elapsed check honours `tokio::time::pause()` / `advance()` in unit tests. -use tokio::time::{sleep, Instant}; - -use super::find_page_target_where; -use super::target::conn_for_account; -use crate::webview_accounts::{emit_load_finished, redact_url_for_log, RevealTrigger}; - -/// Backoff between failed attach attempts / reconnects. Intentionally -/// short — once the webview is open, the target usually shows up within -/// 500ms. -const ATTACH_BACKOFF: Duration = Duration::from_secs(2); - -/// Retry schedule used on the very first attach pass after the webview is -/// spawned. The target usually appears almost immediately, but the CEF -/// browser host can take a few hundred ms on cold start. We try at t=0 -/// (in case the target is already up — common after the CEF prewarm), then -/// escalate quickly so the worst case before the [`ATTACH_BACKOFF`] kicks -/// in is ~600ms — saving ~500ms on the warm path versus the previous fixed -/// `sleep(500ms)`. Issue #1233. -const INITIAL_ATTACH_SCHEDULE: [Duration; 4] = [ - Duration::from_millis(0), - Duration::from_millis(50), - Duration::from_millis(150), - Duration::from_millis(400), -]; - -/// How long the page must be **idle** (no CDP progress signal) before the -/// watchdog gives up and synthesises a `webview-account:load{state:"timeout"}` -/// event so the frontend can switch from an empty loading state to explicit -/// retry/help UI on flaky networks. See issue #1213. -/// -/// Replaces the previous wall-clock `LOAD_TIMEOUT` (15 s after spawn): a -/// fast initial paint followed by slow subresources would needlessly fire -/// timeout, while a genuinely stuck page would not get more than 15 s of -/// runway. The idle watchdog resets on every `Page.frameStartedLoading` / -/// `Page.frameStoppedLoading` / `Page.lifecycleEvent` / -/// `Page.frameNavigated` / `Page.loadEventFired` so it only fires after a -/// true silence — letting providers like Google Meet take 20–30 s to fully -/// hydrate without spurious timeouts, while still surfacing genuine stalls -/// quickly. -const IDLE_BUDGET: Duration = Duration::from_secs(8); - -/// Hard ceiling on total watchdog runtime. If the page is *continuously* -/// emitting progress signals (e.g. an infinite redirect loop, a busy -/// long-poll, a streaming load that never settles) the watchdog must still -/// release the loading spinner so the frontend doesn't hang forever. -/// Picked roughly 2× the slowest provider's observed cold-load tail. -const HARD_CEILING: Duration = Duration::from_secs(60); - -/// Returns the unique marker substring that the account's initial -/// placeholder URL contains so `Target.getTargets` can identify it. -pub fn placeholder_marker(account_id: &str) -> String { - format!("openhuman-acct-{account_id}") -} - -/// Fragment appended to the real provider URL so scanners can match this -/// account uniquely even when several accounts share an origin. -pub fn target_url_fragment(account_id: &str) -> String { - format!("#openhuman-account-{account_id}") -} - -/// Build the placeholder URL used as the webview's initial location. -/// `about:blank` is sufficient for the short holding page we need while CDP -/// attaches and applies overrides before the first real HTTP request. -/// -/// We store the account marker in the fragment so `TargetInfo.url` stays -/// unique per account without depending on Tauri's optional `data:` support. -pub fn placeholder_url(account_id: &str) -> String { - format!("about:blank#{}", placeholder_marker(account_id)) -} - -/// Extract the origin (`scheme://host[:port]`) from an absolute URL string. -/// Used to scope `Browser.grantPermissions` — the CDP method requires an -/// origin (no path / no fragment / no query) and rejects malformed input. -/// -/// Returns `None` for non-`http(s)://` schemes (e.g. `about:blank`, -/// `data:`, `blob:`) where the grant has no meaningful target, and for -/// any input that fails to parse as an absolute URL. -/// -/// Implementation note: uses Tauri's re-exported `url::Url` so query -/// strings, fragments, userinfo, and IPv6 hosts are handled correctly -/// instead of relying on raw byte counting. -fn origin_of(url: &str) -> Option { - let parsed = tauri::Url::parse(url).ok()?; - let scheme = parsed.scheme(); - if scheme != "http" && scheme != "https" { - return None; - } - // `Url::host_str` is the canonical lowercased host. We only emit a - // bare `scheme://host[:port]` triple — no userinfo, no path, no - // query, no fragment — since `Browser.grantPermissions` rejects - // anything else as a malformed origin. - let host = parsed.host_str()?; - if let Some(port) = parsed.port() { - Some(format!("{scheme}://{host}:{port}")) - } else { - Some(format!("{scheme}://{host}")) - } -} - -/// Does `origin` (a `scheme://host[:port]` string from [`origin_of`]) match -/// a specific host? Tolerates an explicit port suffix on `origin` so the -/// callers can pass canonical hosts without hard-coding default ports. -fn origin_host_is(origin: &str, host: &str) -> bool { - let Some(rest) = origin - .strip_prefix("https://") - .or_else(|| origin.strip_prefix("http://")) - else { - return false; - }; - let host_part = rest.split(':').next().unwrap_or(rest); - host_part.eq_ignore_ascii_case(host) -} - -fn target_matches_account_url(target_url: &str, account_id: &str) -> bool { - let marker = placeholder_marker(account_id); - let marker_fragment = format!("#{marker}"); - let fragment = target_url_fragment(account_id); - target_url.ends_with(&marker_fragment) || target_url.ends_with(&fragment) -} - -/// Per-account spawn result. Both handles are owned by `WebviewAccountsState` -/// (see `cdp_sessions` and `load_watchdogs`) so close/purge can abort each one -/// without leaking tasks across reopen cycles. -pub struct SpawnedSession { - pub session: JoinHandle<()>, - pub watchdog: JoinHandle<()>, -} - -/// Spawn the per-account CDP session. Returns immediately; the background -/// task keeps the session alive and retries on disconnect. Also spawns an -/// idle-watchdog task that fires a `webview-account:load{state:"timeout"}` -/// event when the page has been silent (no CDP progress signal) for -/// [`IDLE_BUDGET`] OR has been continuously loading for [`HARD_CEILING`]. -/// -/// The session task and the watchdog communicate over a small mpsc channel: -/// the `pump_events` callback inside `run_session_cycle` sends a `()` ping on -/// every progress-relevant CDP method, which resets the watchdog's idle -/// timer. When the session task exits cleanly the sender drops, the -/// watchdog's `recv()` returns `None`, and it terminates without emitting -/// a stale timeout. -/// -/// Both `JoinHandle`s inside the returned [`SpawnedSession`] must be stored -/// by the caller and aborted on account close/purge to prevent task leaks -/// across reopen cycles. -pub fn spawn_session( - app: AppHandle, - account_id: String, - real_url: String, -) -> SpawnedSession { - // 64 is generous — pump_events processes events one at a time, so a - // backlog only builds if the watchdog itself is starved. We use - // `try_send` on the producer side so a hypothetical full channel never - // blocks the CDP event loop. The sender is held inside an - // `Arc>>` slot so the pump_events callback can drop it - // on terminal `Page.loadEventFired` — once the slot is `None` no other - // sender clones exist anywhere in the session pipeline, the channel - // closes, and the watchdog exits via `WatchdogOutcome::SenderDropped` - // instead of waiting out the idle budget after a successful load. - let (progress_tx, progress_rx) = mpsc::channel::<()>(64); - let progress_slot: ProgressSlot = std::sync::Arc::new(std::sync::Mutex::new(Some(progress_tx))); - - let watchdog = { - let app = app.clone(); - let account_id = account_id.clone(); - let real_url = real_url.clone(); - tokio::spawn(async move { - log::debug!( - "[cdp-session][{}][watchdog] start idle_budget={:?} hard_ceiling={:?} url={}", - account_id, - IDLE_BUDGET, - HARD_CEILING, - redact_url_for_log(&real_url) - ); - let outcome = run_idle_watchdog(progress_rx, IDLE_BUDGET, HARD_CEILING).await; - match outcome { - WatchdogOutcome::Idle | WatchdogOutcome::HardCeiling => { - log::info!( - "[cdp-session][{}][watchdog] firing timeout reason={} url={}", - account_id, - outcome.reason_str(), - redact_url_for_log(&real_url) - ); - // `emit_load_finished` dedups timeouts that arrive after a - // terminal `finished` event — see `loaded_accounts` in - // `webview_accounts/mod.rs`. So it is safe to call - // unconditionally even if the page actually loaded fine. - emit_load_finished( - &app, - &account_id, - "timeout", - &real_url, - RevealTrigger::Watchdog, - ); - } - WatchdogOutcome::SenderDropped => { - log::debug!( - "[cdp-session][{}][watchdog] clean exit reason=sender_dropped url={}", - account_id, - redact_url_for_log(&real_url) - ); - } - } - }) - }; - - let session = - tokio::spawn( - async move { run_session_forever(app, account_id, real_url, progress_slot).await }, - ); - - SpawnedSession { session, watchdog } -} - -/// Slot for the progress-channel sender, shared between `run_session_forever`, -/// `run_session_cycle`, and the `pump_events` callback. `take()`-on-terminal-load -/// drops the sender so the watchdog can exit clean — see issue #1213. -type ProgressSlot = std::sync::Arc>>>; - -/// Returns `true` for CDP method names we treat as "the page is still -/// making progress" — i.e. a signal that the watchdog's idle timer should -/// be reset. Restricted to Page-domain methods so we do not need to enable -/// `Network.enable` in this session (which would be a behaviour change for -/// every existing webview account). -/// -/// Whether a method counts as progress is a *behavioural* decision, so it -/// lives in this dedicated helper that the unit tests can exercise without -/// standing up a real CDP connection. -pub(crate) fn is_progress_signal(method: &str) -> bool { - matches!( - method, - "Page.frameStartedLoading" - | "Page.frameStoppedLoading" - | "Page.frameNavigated" - | "Page.lifecycleEvent" - | "Page.loadEventFired" - | "Page.domContentEventFired" - ) -} - -/// Outcome of [`run_idle_watchdog`]. Returned (instead of an inline -/// `FnOnce` callback) so the caller can log the *reason* for a timeout -/// — `idle_silence` vs `hard_ceiling` — and distinguish either from a -/// clean sender-dropped exit. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum WatchdogOutcome { - /// `IDLE_BUDGET` of true silence elapsed without a progress ping. - Idle, - /// Total runtime exceeded `HARD_CEILING` even though pings kept arriving. - HardCeiling, - /// The session task dropped its sender — clean exit, no timeout fired. - SenderDropped, -} - -impl WatchdogOutcome { - pub(crate) fn reason_str(self) -> &'static str { - match self { - WatchdogOutcome::Idle => "idle_silence", - WatchdogOutcome::HardCeiling => "hard_ceiling", - WatchdogOutcome::SenderDropped => "sender_dropped", - } - } -} - -/// Drives the idle-watchdog state machine. Public-in-crate so the unit -/// tests can exercise it with a mock channel. -/// -/// Behaviour: -/// -/// 1. On every `()` received from `progress_rx`, restart the -/// [`IDLE_BUDGET`] sleep. The page is still progressing. -/// 2. If the [`IDLE_BUDGET`] sleep elapses with no ping, return -/// [`WatchdogOutcome::Idle`] — the page has gone silent without -/// finishing. -/// 3. If total runtime since spawn exceeds [`HARD_CEILING`] regardless of -/// progress, return [`WatchdogOutcome::HardCeiling`] — prevents an -/// infinite-redirect or chatty long-poll from keeping the spinner up -/// forever. -/// 4. If the sender side drops (`recv()` returns `None`) without a timeout -/// having fired, return [`WatchdogOutcome::SenderDropped`] — the -/// session task ended on its own and the watchdog should NOT emit a -/// stale timeout. -/// -/// The `tokio::select!` is `biased;` so the recv arm is polled first -/// each iteration. This prevents a false-positive timeout when both the -/// `IDLE_BUDGET` sleep and a progress ping become ready in the same -/// poll cycle (without `biased`, select picks pseudo-randomly). -pub(crate) async fn run_idle_watchdog( - mut progress_rx: mpsc::Receiver<()>, - idle_budget: Duration, - hard_ceiling: Duration, -) -> WatchdogOutcome { - let started = Instant::now(); - loop { - let elapsed = started.elapsed(); - let remaining_ceiling = hard_ceiling.saturating_sub(elapsed); - if remaining_ceiling.is_zero() { - return WatchdogOutcome::HardCeiling; - } - let wake_after = idle_budget.min(remaining_ceiling); - tokio::select! { - biased; - recv = progress_rx.recv() => { - match recv { - // Progress ping — reset by looping back into select. - Some(()) => continue, - // Sender dropped (session task ended) — exit clean. - None => return WatchdogOutcome::SenderDropped, - } - } - _ = sleep(wake_after) => { - // No ping inside the wake budget. If we hit the cap because - // of `hard_ceiling.min(idle_budget)`, classify as hard - // ceiling so the caller log line is accurate; else idle. - if wake_after >= remaining_ceiling { - return WatchdogOutcome::HardCeiling; - } - return WatchdogOutcome::Idle; - } - } - } -} - -async fn run_session_forever( - app: AppHandle, - account_id: String, - real_url: String, - progress_slot: ProgressSlot, -) { - log::info!( - "[cdp-session][{}] up real_url={} marker={}", - account_id, - real_url, - placeholder_marker(&account_id) - ); - // Issue #1233 — first-pass retry schedule replaces the previous fixed - // `sleep(500ms)` warmup. Try at t=0 (often succeeds when the target was - // already up via CEF prewarm), then escalate quickly. Each schedule slot - // sleeps THEN tries, so a target up at t≈0ms attaches without waiting - // for the old 500ms grace. - // - // The steady-state reconnect loop below sleeps `ATTACH_BACKOFF` BEFORE - // each attempt. That ordering matters: it means an exhausted initial - // schedule (all four attach attempts failed) gets a proper 2s backoff - // before the fifth attempt, instead of the original "drop straight in - // and try immediately" bug that effectively fired five back-to-back - // attaches in <1s and then waited 2s. After a successful session that - // ends cleanly we also wait the backoff before reconnecting so we - // don't tight-loop against a target that just torched its renderer. - for (idx, delay) in INITIAL_ATTACH_SCHEDULE.iter().enumerate() { - sleep(*delay).await; - match run_session_cycle(&app, &account_id, &real_url, &progress_slot).await { - Ok(()) => { - log::info!( - "[cdp-session][{}] initial session ended cleanly attempt={} reconnecting", - account_id, - idx - ); - break; - } - Err(e) => { - log::debug!( - "[cdp-session][{}] initial attach attempt={} delay={:?} failed: {}", - account_id, - idx, - delay, - e - ); - } - } - } - loop { - sleep(ATTACH_BACKOFF).await; - match run_session_cycle(&app, &account_id, &real_url, &progress_slot).await { - Ok(()) => { - log::info!( - "[cdp-session][{}] session ended cleanly, reconnecting", - account_id - ); - } - Err(e) => { - log::debug!("[cdp-session][{}] cycle failed: {}", account_id, e); - } - } - } -} - -async fn run_session_cycle( - app: &AppHandle, - account_id: &str, - real_url: &str, - progress_slot: &ProgressSlot, -) -> Result<(), String> { - let mut cdp = conn_for_account(app, account_id)?; - - // Account-unique match. Each webview is itself scoped to one - // account, but a webview can host popups (OAuth, attachment - // previews, …) that also surface as `kind=page` targets. The - // placeholder URL and the real provider URL both carry - // account-specific fragments, so we filter explicitly to pick the - // primary frame and ignore popups. - let fragment = target_url_fragment(account_id); - let target = - find_page_target_where(&mut cdp, |t| target_matches_account_url(&t.url, account_id)) - .await?; - log::info!( - "[cdp-session][{}] attaching to target {} url={}", - account_id, - target.id, - target.url - ); - - let attach = cdp - .call( - "Target.attachToTarget", - json!({ "targetId": target.id, "flatten": true }), - None, - ) - .await?; - let session_id = attach - .get("sessionId") - .and_then(|x| x.as_str()) - .ok_or_else(|| "attach missing sessionId".to_string())? - .to_string(); - - // Stub the Web Notifications permission API before any provider JS - // runs. Without this, providers like Slack and Gmail show in-app - // "please enable notifications" banners because Notification.permission - // returns "default" in the CEF context. The real notification path runs - // through the CEF IPC hook registered in webview_accounts — this just - // makes the page's permission check pass. - cdp.call( - "Page.addScriptToEvaluateOnNewDocument", - json!({ - "source": "(function(){\ - function ensureNotificationGranted(){\ - try {\ - var NativeNotification = window.Notification;\ - if (typeof NativeNotification === 'function') {\ - var OpenHumanNotification = function(title, options){\ - try { return new NativeNotification(title, options); }\ - catch (_) { return {}; }\ - };\ - OpenHumanNotification.prototype = NativeNotification.prototype;\ - try {\ - Object.defineProperty(OpenHumanNotification, 'permission', {\ - get: function(){ return 'granted'; },\ - configurable: true\ - });\ - } catch (_) {}\ - OpenHumanNotification.requestPermission = function(){\ - return Promise.resolve('granted');\ - };\ - window.Notification = OpenHumanNotification;\ - }\ - } catch (_) {}\ - try {\ - var p = navigator && navigator.permissions;\ - if (p && typeof p.query === 'function') {\ - var q = p.query.bind(p);\ - var fp = {\ - query: function(d){\ - if (d && d.name === 'notifications') {\ - return Promise.resolve({ state: 'granted', onchange: null });\ - }\ - return q(d);\ - }\ - };\ - Object.defineProperty(navigator, 'permissions', {\ - get: function(){ return fp; },\ - configurable: true\ - });\ - }\ - } catch (_) {}\ - }\ - ensureNotificationGranted();\ - try { setInterval(ensureNotificationGranted, 1000); } catch (_) {}\ - })();" - }), - Some(&session_id), - ) - .await?; - log::debug!( - "[cdp-session][{}] notification permission stub injected", - account_id - ); - - // The JS shim above masks `Notification.permission` so providers stop - // showing "enable notifications" banners, but it does NOT cause CEF's - // real native-toast pipeline to fire. For that we have to actually grant - // `notifications` for the provider's origin via the browser-level - // `Browser.grantPermissions` CDP method (sessionId = None routes to the - // browser target). With this grant, `new Notification(...)` from the - // page reaches the CEF helper's notify-IPC, which posts back to - // `forward_native_notification` in `webview_accounts`. Without it, - // the constructor silently no-ops and no toast ever fires (#1016). - if let Some(origin) = origin_of(real_url) { - // Default permission set every embedded provider needs. Origin-scoped - // so we don't leak grants across providers running in the same CEF - // browser process. - let mut perms: Vec<&str> = vec!["notifications"]; - - // Google Meet additionally needs: - // - audioCapture / videoCapture: getUserMedia for cam/mic so the - // pre-call greenroom auto-grants instead of falling back to - // Meet's "Use microphone and camera" consent dialog - // - clipboardReadWrite: copy meeting link / paste join code - // Without these, Meet sits on the consent dialog forever and cam/mic - // never enumerate (verified during #1022 smoke). - // - if origin_host_is(&origin, "meet.google.com") { - perms.extend_from_slice(&["audioCapture", "videoCapture", "clipboardReadWrite"]); - } - - // Slack Huddles need the same media-capture set as Meet: - // - audioCapture / videoCapture: getUserMedia for huddle voice + - // optional camera tile. Without these, the huddle pre-flight - // enumerateDevices returns empty and the join button silently - // no-ops. - // - clipboardReadWrite: huddle invite-link copy + slash-command - // paste flows. - // Mirrors the gmeet pattern from #1054. The huddle popup paint - // lifecycle bug is tracked separately under #1074 / the CEF - // tracking issue — granting these perms now means once the paint - // bug clears, the huddle is functional immediately rather than - // requiring a follow-up perms wire-up. - if origin_host_is(&origin, "app.slack.com") { - perms.extend_from_slice(&["audioCapture", "videoCapture", "clipboardReadWrite"]); - } - - if let Err(e) = cdp - .call( - "Browser.grantPermissions", - json!({ - "origin": origin, - "permissions": perms, - }), - None, - ) - .await - { - log::warn!( - "[cdp-session][{}] Browser.grantPermissions({:?}) for {} failed: {}", - account_id, - perms, - origin, - e - ); - } else { - log::info!( - "[cdp-session][{}] granted {:?} for origin={}", - account_id, - perms, - origin - ); - } - } - - // Enable the Page domain so `Page.loadEventFired` reaches our - // `pump_events` callback below. Must happen BEFORE `Page.navigate` so - // the first top-level load event for the real provider URL isn't missed. - cdp.call("Page.enable", json!({}), Some(&session_id)) - .await?; - - // Subscribe to lifecycle events too — they carry sub-load progress - // signals (`init`, `firstPaint`, `DOMContentLoaded`, `load`, - // `networkAlmostIdle`, `networkIdle`) that the idle-watchdog uses to - // distinguish a still-progressing load from a stalled one. See - // [`run_idle_watchdog`] / issue #1213. Best-effort — if it fails, the - // watchdog still has frameStarted/Stopped + loadEventFired to work with. - if let Err(e) = cdp - .call( - "Page.setLifecycleEventsEnabled", - json!({ "enabled": true }), - Some(&session_id), - ) - .await - { - log::debug!( - "[cdp-session][{}] Page.setLifecycleEventsEnabled failed: {} — watchdog falls back to frame-only signals", - account_id, - e - ); - } - - // Drive the webview from the placeholder to the real provider URL. - // Fragment survives same-origin navigations so scanners can match on - // it indefinitely. Skip navigation if the target is already on the - // real URL (e.g. we reconnected after a ws drop). Boundary-check - // the prefix so `https://discord.com` doesn't spuriously match - // `https://discord.com.evil/…`. - let at_real_url = target.url.starts_with(real_url) - && target.url[real_url.len()..] - .chars() - .next() - .is_none_or(|c| matches!(c, '/' | '?' | '#')); - if !at_real_url { - let dest = if real_url.contains('#') { - real_url.to_string() - } else { - format!("{real_url}{fragment}") - }; - log::info!("[cdp-session][{}] navigating to {}", account_id, dest); - cdp.call("Page.navigate", json!({ "url": dest }), Some(&session_id)) - .await?; - } - - // Hold the session open for the lifetime of the webview. The UA - // override reverts when we detach, so we intentionally block here. - // pump_events returns when the CDP ws closes (browser process exits - // or `Target.detachFromTarget` is called from elsewhere). - // - // The callback emits `webview-account:load{state:"finished"}` on the - // first `Page.loadEventFired` as a belt-and-braces fallback to the - // native `WebviewBuilder::on_page_load` handler wired in - // `webview_account_open`. `emit_load_finished` dedups across both paths - // so the frontend only sees one signal per cold open. - let cb_app = app.clone(); - let cb_account_id = account_id.to_string(); - let cb_real_url = real_url.to_string(); - let cb_progress_slot = progress_slot.clone(); - cdp.pump_events(&session_id, move |method, _params| { - // Keep the idle-watchdog (#1213) alive on every progress signal. - // `try_send` so a hypothetical full channel never blocks the CDP - // event loop — pings are fungible, dropping one is fine. - if is_progress_signal(method) { - if let Ok(guard) = cb_progress_slot.lock() { - if let Some(tx) = guard.as_ref() { - let _ = tx.try_send(()); - } - } - } - if method == "Page.loadEventFired" { - emit_load_finished( - &cb_app, - &cb_account_id, - "finished", - &cb_real_url, - RevealTrigger::Load, - ); - // Terminal load: drop the watchdog's sender so it exits - // immediately via SenderDropped instead of waiting out the - // full idle budget. The sender lives ONLY inside this slot - // (the original Sender from `spawn_session` was moved in at - // construction), so `take()` here closes the channel for the - // receiver — there are no other Sender clones outstanding. - // `take()` is idempotent — repeat fires (e.g. SPA route - // changes after the first load) leave the slot at `None`. - if let Ok(mut guard) = cb_progress_slot.lock() { - guard.take(); - } - } - }) - .await -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn placeholder_url_uses_about_blank_fragment_marker() { - assert_eq!( - placeholder_url("acct-42"), - "about:blank#openhuman-acct-acct-42" - ); - } - - #[test] - fn origin_of_strips_path_query_and_fragment() { - assert_eq!( - origin_of("https://app.slack.com/client/T123/C456?foo=bar#frag"), - Some("https://app.slack.com".to_string()) - ); - } - - #[test] - fn origin_of_preserves_explicit_port() { - assert_eq!( - origin_of("http://localhost:7788/health"), - Some("http://localhost:7788".to_string()) - ); - } - - #[test] - fn origin_of_returns_none_for_non_http_schemes() { - assert_eq!(origin_of("about:blank"), None); - assert_eq!(origin_of("data:text/plain,hello"), None); - assert_eq!(origin_of("blob:https://app.slack.com/abc"), None); - assert_eq!(origin_of("file:///etc/hosts"), None); - } - - #[test] - fn origin_of_returns_none_for_malformed_input() { - assert_eq!(origin_of(""), None); - assert_eq!(origin_of("not-a-url"), None); - assert_eq!(origin_of("http://"), None); - } - - #[test] - fn origin_of_lowercases_host() { - // tauri::Url normalises to lowercase host so we never grant - // permissions twice for `Slack.com` vs `slack.com`. - assert_eq!( - origin_of("https://APP.SLACK.COM/client"), - Some("https://app.slack.com".to_string()) - ); - } - - #[test] - fn origin_host_is_matches_canonical_origin() { - assert!(origin_host_is("https://meet.google.com", "meet.google.com")); - assert!(origin_host_is( - "http://meet.google.com:8080", - "meet.google.com" - )); - assert!(origin_host_is("https://MEET.GOOGLE.COM", "meet.google.com")); - } - - #[test] - fn origin_host_is_rejects_non_match() { - // Different host - assert!(!origin_host_is( - "https://workspace.google.com", - "meet.google.com" - )); - // Subdomain mismatch - assert!(!origin_host_is( - "https://chat.meet.google.com", - "meet.google.com" - )); - // Non-http scheme - assert!(!origin_host_is("about:blank", "meet.google.com")); - assert!(!origin_host_is("file:///etc/hosts", "meet.google.com")); - } - - /// The slack-huddle media-perm grant is host-gated by - /// `origin_host_is(origin, "app.slack.com")`. Lock the matcher so a - /// future refactor can't silently widen / narrow the set of origins - /// that get `audioCapture`/`videoCapture`/`displayCapture` etc. - #[test] - fn origin_host_is_matches_app_slack_com_for_huddle_grant() { - // canonical slack web origin - assert!(origin_host_is("https://app.slack.com", "app.slack.com")); - // case-insensitive (matches Url-normalised input + raw header) - assert!(origin_host_is("https://APP.SLACK.COM", "app.slack.com")); - // explicit port tolerated - assert!(origin_host_is("https://app.slack.com:443", "app.slack.com")); - - // marketing site / files CDN must NOT receive media perms — only - // the huddle-bearing app origin - assert!(!origin_host_is("https://slack.com", "app.slack.com")); - assert!(!origin_host_is("https://files.slack.com", "app.slack.com")); - // unrelated provider - assert!(!origin_host_is("https://meet.google.com", "app.slack.com")); - // non-http schemes never match (e.g. about:blank popup placeholder) - assert!(!origin_host_is("about:blank", "app.slack.com")); - } - - #[test] - fn target_match_accepts_placeholder_and_real_provider_fragments_only_for_same_account() { - assert!(target_matches_account_url( - "about:blank#openhuman-acct-acct-42", - "acct-42" - )); - assert!(target_matches_account_url( - "https://discord.com/channels/@me#openhuman-account-acct-42", - "acct-42" - )); - - assert!(!target_matches_account_url( - "about:blank#openhuman-acct-acct-420", - "acct-42" - )); - assert!(!target_matches_account_url( - "https://example.com/openhuman-acct-acct-42", - "acct-42" - )); - assert!(!target_matches_account_url( - "https://discord.com/channels/@me#openhuman-account-acct-420", - "acct-42" - )); - } - - /// Issue #1233 — initial attach retry schedule must finish well under - /// the previous fixed 500ms warmup so the warm path saves wall-clock - /// on cold opens. Locked at 4 attempts summing to ≤ 600ms. - #[test] - fn initial_attach_schedule_under_600ms_total() { - let total: Duration = INITIAL_ATTACH_SCHEDULE.iter().sum(); - assert_eq!( - INITIAL_ATTACH_SCHEDULE.len(), - 4, - "schedule should have 4 attempts; got {:?}", - INITIAL_ATTACH_SCHEDULE - ); - assert!( - total <= Duration::from_millis(600), - "schedule total {:?} exceeds 600ms budget", - total - ); - assert_eq!( - INITIAL_ATTACH_SCHEDULE[0], - Duration::ZERO, - "first attempt must run immediately (CEF prewarm hits)", - ); - } - - // -- idle-watchdog (#1213) --------------------------------------------- - - #[test] - fn is_progress_signal_recognises_known_page_methods() { - assert!(is_progress_signal("Page.frameStartedLoading")); - assert!(is_progress_signal("Page.frameStoppedLoading")); - assert!(is_progress_signal("Page.frameNavigated")); - assert!(is_progress_signal("Page.lifecycleEvent")); - assert!(is_progress_signal("Page.loadEventFired")); - assert!(is_progress_signal("Page.domContentEventFired")); - } - - #[test] - fn is_progress_signal_rejects_unrelated_methods() { - // Non-progress Page methods (we want to ignore window-level chatter) - assert!(!is_progress_signal("Page.javascriptDialogOpening")); - assert!(!is_progress_signal("Page.fileChooserOpened")); - // Other domains - assert!(!is_progress_signal("Network.requestWillBeSent")); - assert!(!is_progress_signal("Runtime.consoleAPICalled")); - assert!(!is_progress_signal("")); - assert!(!is_progress_signal("nonsense")); - } - - #[tokio::test(start_paused = true)] - async fn idle_watchdog_fires_after_idle_budget_with_no_progress() { - let (tx, rx) = mpsc::channel::<()>(8); - let handle = tokio::spawn(async move { - run_idle_watchdog(rx, Duration::from_secs(8), Duration::from_secs(60)).await - }); - - // Hold the sender alive so the watchdog can't exit via channel-closed. - let _hold = tx; - // Advance past the idle budget. - tokio::time::advance(Duration::from_secs(9)).await; - let outcome = handle.await.expect("watchdog task panicked"); - - assert_eq!( - outcome, - WatchdogOutcome::Idle, - "watchdog must surface Idle after silence inside hard ceiling" - ); - } - - #[tokio::test(start_paused = true)] - async fn idle_watchdog_resets_on_each_progress_ping() { - let (tx, rx) = mpsc::channel::<()>(8); - let handle = tokio::spawn(async move { - run_idle_watchdog(rx, Duration::from_secs(8), Duration::from_secs(60)).await - }); - - // Drip pings every 5s for 25s total. Idle budget is 8s, so as long - // as we ping at <8s intervals the watchdog must NOT fire. - for _ in 0..5 { - tokio::time::advance(Duration::from_secs(5)).await; - tx.send(()).await.expect("send ping"); - } - - // Drop the sender → watchdog exits clean. - drop(tx); - let outcome = handle.await.expect("watchdog task panicked"); - assert_eq!( - outcome, - WatchdogOutcome::SenderDropped, - "drip-ping then sender-drop path must be classified as clean exit, not timeout" - ); - } - - #[tokio::test(start_paused = true)] - async fn idle_watchdog_exits_clean_when_sender_dropped_before_idle() { - let (tx, rx) = mpsc::channel::<()>(8); - let handle = tokio::spawn(async move { - run_idle_watchdog(rx, Duration::from_secs(8), Duration::from_secs(60)).await - }); - - // Session ends quickly — drop sender well before idle budget. - tokio::time::advance(Duration::from_secs(1)).await; - drop(tx); - let outcome = handle.await.expect("watchdog task panicked"); - - assert_eq!( - outcome, - WatchdogOutcome::SenderDropped, - "sender-dropped path is a clean exit, not a timeout" - ); - } - - #[tokio::test(start_paused = true)] - async fn idle_watchdog_hard_ceiling_caps_runaway_progress() { - let (tx, rx) = mpsc::channel::<()>(64); - let handle = tokio::spawn(async move { - run_idle_watchdog(rx, Duration::from_secs(8), Duration::from_secs(60)).await - }); - - // Send a chatty stream of pings every 1s for 65s — under idle - // budget every time, but past the 60s hard ceiling. - for _ in 0..70 { - tokio::time::advance(Duration::from_secs(1)).await; - let _ = tx.try_send(()); - } - let _hold = tx; // keep sender alive so close-path doesn't short-circuit - // Allow the spawned task to observe the ceiling. - tokio::time::advance(Duration::from_secs(1)).await; - let outcome = handle.await.expect("watchdog task panicked"); - - assert_eq!( - outcome, - WatchdogOutcome::HardCeiling, - "hard ceiling must override progress pings once total runtime > ceiling" - ); - } - - /// Regression for the `biased; recv-first` reordering. With `recv` polled - /// first each iteration, a ping that lands at exactly the same poll as - /// the idle-budget sleep must keep the watchdog alive (no false-positive - /// timeout). Without `biased;` `tokio::select!` picks pseudo-randomly. - #[tokio::test(start_paused = true)] - async fn idle_watchdog_biased_recv_wins_over_concurrent_idle_wake() { - let (tx, rx) = mpsc::channel::<()>(8); - let handle = tokio::spawn(async move { - run_idle_watchdog(rx, Duration::from_secs(8), Duration::from_secs(60)).await - }); - - // Park exactly on the boundary: advance the full idle budget AND - // queue a ping. Without `biased;` the timeout branch could win the - // race; with `biased;` the recv branch is polled first so the loop - // resets cleanly. - tx.send(()).await.expect("send ping"); - tokio::time::advance(Duration::from_secs(8)).await; - // Drop sender so the watchdog exits clean — if it had fired Idle on - // the previous wake we'd see Idle instead of SenderDropped here. - drop(tx); - let outcome = handle.await.expect("watchdog task panicked"); - assert_eq!(outcome, WatchdogOutcome::SenderDropped); - } - - #[test] - fn watchdog_outcome_reason_str_distinguishes_idle_and_ceiling() { - assert_eq!(WatchdogOutcome::Idle.reason_str(), "idle_silence"); - assert_eq!(WatchdogOutcome::HardCeiling.reason_str(), "hard_ceiling"); - assert_eq!( - WatchdogOutcome::SenderDropped.reason_str(), - "sender_dropped" - ); - } -} diff --git a/app/src-tauri/src/cdp/snapshot.rs b/app/src-tauri/src/cdp/snapshot.rs deleted file mode 100644 index dc33a1b3ce..0000000000 --- a/app/src-tauri/src/cdp/snapshot.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Generic wrapper around `DOMSnapshot.captureSnapshot`. Parses the -//! flat-array node tree CDP returns into indexable helpers each provider -//! can use to extract chat / channel / message rows without executing any -//! page JavaScript. -//! -//! The raw CDP response is a pair of parallel arrays keyed by node index: -//! * `parentIndex[i]` — parent node index (-1 for roots) -//! * `nodeType[i]` — 1 = element, 3 = text, etc. -//! * `nodeName[i]` — index into `strings` (element tag name) -//! * `nodeValue[i]` — index into `strings` (text content for text nodes) -//! * `attributes[i]` — flat `[nameIdx, valueIdx, …]` string-table indices -//! -//! `Snapshot` owns these arrays plus a lazily-computed children map so -//! subtree walks are O(subtree) instead of O(total). - -use serde::Deserialize; -use serde_json::json; - -use super::CdpConn; - -pub const NODE_TYPE_ELEMENT: i32 = 1; -pub const NODE_TYPE_TEXT: i32 = 3; - -#[derive(Deserialize, Debug, Default)] -struct CaptureSnapshot { - #[serde(default)] - documents: Vec, - #[serde(default)] - strings: Vec, -} - -#[derive(Deserialize, Debug, Default)] -struct DocumentSnap { - #[serde(default)] - nodes: NodeTreeSnap, -} - -#[derive(Deserialize, Debug, Default)] -struct NodeTreeSnap { - #[serde(rename = "parentIndex", default)] - parent_index: Vec, - #[serde(rename = "nodeType", default)] - node_type: Vec, - #[serde(rename = "nodeName", default)] - node_name: Vec, - #[serde(rename = "nodeValue", default)] - node_value: Vec, - #[serde(default)] - attributes: Vec>, -} - -pub struct Snapshot { - strings: Vec, - nodes: NodeTreeSnap, - children: Vec>, -} - -impl Snapshot { - /// Run `DOMSnapshot.captureSnapshot` on an attached session and return - /// one parsed tree containing the main document and any iframe documents. - pub async fn capture(cdp: &mut CdpConn, session: &str) -> Result { - log::debug!("[cdp::snapshot] capture start session={session}"); - let raw = cdp - .call( - "DOMSnapshot.captureSnapshot", - capture_request(), - Some(session), - ) - .await - .map_err(|error| { - log::warn!("[cdp::snapshot] capture call failed session={session} error={error}"); - error - })?; - log::debug!("[cdp::snapshot] capture call complete session={session}"); - let snap: CaptureSnapshot = serde_json::from_value(raw).map_err(|error| { - log::warn!("[cdp::snapshot] decode failed session={session} error={error}"); - format!("decode DOMSnapshot: {error}") - })?; - let snapshot = Self::from_capture(snap); - log::debug!( - "[cdp::snapshot] decode complete session={session} nodes={}", - snapshot.len() - ); - Ok(snapshot) - } - - fn from_capture(snap: CaptureSnapshot) -> Self { - let strings = snap.strings; - // Merge every document (main frame + all iframes) into a single - // flat node array. CDP returns each frame as its own document - // with its own indices; we offset child node ids by the running - // total so cross-document attr/tag/children lookups stay - // consistent. - // - // Gmail email bodies render inside an iframe so without this - // merge our scrapers couldn't see message HTML at all. The cost - // is a slightly larger flat tree, but the snapshot is - // throwaway per call. - let mut merged_parent_index: Vec = Vec::new(); - let mut merged_node_type: Vec = Vec::new(); - let mut merged_node_name: Vec = Vec::new(); - let mut merged_node_value: Vec = Vec::new(); - let mut merged_attributes: Vec> = Vec::new(); - for document in snap.documents { - let doc_offset = merged_node_type.len() as i32; - let doc_nodes = document.nodes; - for &p in &doc_nodes.parent_index { - merged_parent_index.push(if p < 0 { -1 } else { p + doc_offset }); - } - merged_node_type.extend(doc_nodes.node_type); - merged_node_name.extend(doc_nodes.node_name); - merged_node_value.extend(doc_nodes.node_value); - merged_attributes.extend(doc_nodes.attributes); - // Pad short vectors so they all match doc_count length — - // CDP is sparse when no attributes / values exist. - while merged_node_name.len() < merged_node_type.len() { - merged_node_name.push(-1); - } - while merged_node_value.len() < merged_node_type.len() { - merged_node_value.push(-1); - } - while merged_attributes.len() < merged_node_type.len() { - merged_attributes.push(Vec::new()); - } - } - let nodes = NodeTreeSnap { - parent_index: merged_parent_index, - node_type: merged_node_type, - node_name: merged_node_name, - node_value: merged_node_value, - attributes: merged_attributes, - }; - let count = nodes.node_type.len(); - let mut children: Vec> = vec![Vec::new(); count]; - for (i, &p) in nodes.parent_index.iter().enumerate() { - if p >= 0 && (p as usize) < count { - children[p as usize].push(i); - } - } - Self { - strings, - nodes, - children, - } - } - - pub fn len(&self) -> usize { - self.nodes.node_type.len() - } - - pub fn node_type(&self, idx: usize) -> i32 { - self.nodes.node_type.get(idx).copied().unwrap_or(0) - } - - pub fn is_element(&self, idx: usize) -> bool { - self.node_type(idx) == NODE_TYPE_ELEMENT - } - - pub fn tag(&self, idx: usize) -> &str { - self.str_at(*self.nodes.node_name.get(idx).unwrap_or(&-1)) - } - - pub fn text_value(&self, idx: usize) -> &str { - self.str_at(*self.nodes.node_value.get(idx).unwrap_or(&-1)) - } - - pub fn attr(&self, idx: usize, name: &str) -> Option<&str> { - let flat = self.nodes.attributes.get(idx)?; - let mut i = 0; - while i + 1 < flat.len() { - if self.str_at(flat[i]) == name { - return Some(self.str_at(flat[i + 1])); - } - i += 2; - } - None - } - - /// Classes split on whitespace. Empty for elements with no `class` attr. - pub fn classes(&self, idx: usize) -> impl Iterator { - self.attr(idx, "class").unwrap_or("").split_whitespace() - } - - pub fn has_class(&self, idx: usize, name: &str) -> bool { - self.classes(idx).any(|c| c == name) - } - - /// Discord renders hashed class names (e.g. `name__abcde`). Callers - /// check for the unhashed prefix. - pub fn class_starts_with(&self, idx: usize, prefix: &str) -> bool { - self.classes(idx).any(|c| c.starts_with(prefix)) - } - - pub fn children(&self, idx: usize) -> &[usize] { - self.children.get(idx).map(|v| v.as_slice()).unwrap_or(&[]) - } - - /// Depth-first pre-order walk of every descendant of `root` (including - /// `root` itself). Cheap enough for chat-list scrapes that run every - /// 2 seconds — DOM has thousands of nodes, not millions. - pub fn descendants(&self, root: usize) -> Vec { - let mut out = Vec::new(); - let mut stack = vec![root]; - while let Some(idx) = stack.pop() { - out.push(idx); - for &k in self.children(idx).iter().rev() { - stack.push(k); - } - } - out - } - - /// Concatenate every TEXT_NODE under `root` in document order. Runs of - /// whitespace collapse to a single space and the result is trimmed. - pub fn text_content(&self, root: usize) -> String { - let mut out = String::new(); - for idx in self.descendants(root) { - if self.node_type(idx) == NODE_TYPE_TEXT { - out.push_str(self.text_value(idx)); - } - } - collapse_ws(&out) - } - - /// First descendant (or `root` itself) matching `pred`. Depth-first. - pub fn find_descendant(&self, root: usize, pred: F) -> Option - where - F: Fn(&Snapshot, usize) -> bool, - { - self.descendants(root).into_iter().find(|&i| pred(self, i)) - } - - /// Every element (anywhere in the document) matching `pred`. Returned - /// in document order. - pub fn find_all(&self, pred: F) -> Vec - where - F: Fn(&Snapshot, usize) -> bool, - { - let mut out = Vec::new(); - for i in 0..self.len() { - if self.is_element(i) && pred(self, i) { - out.push(i); - } - } - out - } - - fn str_at(&self, idx: i32) -> &str { - if idx < 0 { - return ""; - } - self.strings - .get(idx as usize) - .map(String::as_str) - .unwrap_or("") - } -} - -fn capture_request() -> serde_json::Value { - json!({ - "computedStyles": [], - "includePaintOrder": false, - "includeDOMRects": false, - }) -} - -fn collapse_ws(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - let mut last_space = true; - for ch in s.chars() { - if ch.is_whitespace() { - if !last_space { - out.push(' '); - last_space = true; - } - } else { - out.push(ch); - last_space = false; - } - } - out.trim().to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn capture_request_disables_dom_rects() { - let request = capture_request(); - assert_eq!(request["includeDOMRects"], false); - assert_eq!(request["includePaintOrder"], false); - assert_eq!(request["computedStyles"], json!([])); - } - - #[test] - fn from_capture_offsets_documents_and_builds_child_adjacency_without_layout() { - let capture: CaptureSnapshot = serde_json::from_value(json!({ - "strings": ["DIV", "first", "SPAN", "second"], - "documents": [ - { - "nodes": { - "parentIndex": [-1, 0], - "nodeType": [1, 3], - "nodeName": [0, -1], - "nodeValue": [-1, 1] - } - }, - { - "nodes": { - "parentIndex": [-1, 0], - "nodeType": [1, 3], - "nodeName": [2, -1], - "nodeValue": [-1, 3] - } - } - ] - })) - .expect("snapshot fixture should decode without layout data"); - - let snapshot = Snapshot::from_capture(capture); - - assert_eq!(snapshot.len(), 4); - assert_eq!(snapshot.children(0), &[1]); - assert_eq!(snapshot.children(2), &[3]); - assert!(snapshot.children(1).is_empty()); - assert_eq!(snapshot.tag(0), "DIV"); - assert_eq!(snapshot.tag(2), "SPAN"); - assert_eq!(snapshot.text_content(0), "first"); - assert_eq!(snapshot.text_content(2), "second"); - } - - #[test] - fn collapse_ws_collapses_and_trims() { - assert_eq!(collapse_ws(" hello world "), "hello world"); - assert_eq!(collapse_ws("\n\tfoo\n\n"), "foo"); - assert_eq!(collapse_ws(""), ""); - } -} diff --git a/app/src-tauri/src/cdp/target.rs b/app/src-tauri/src/cdp/target.rs index 7a57c8502e..8b4665c310 100644 --- a/app/src-tauri/src/cdp/target.rs +++ b/app/src-tauri/src/cdp/target.rs @@ -44,39 +44,8 @@ pub fn parse_targets(v: &Value) -> Vec { .unwrap_or_default() } -/// Get a [`CdpConn`] for an account-keyed webview, looking up the -/// pre-installed in-process transport from the [`CdpRegistry`] managed -/// on `app`. -/// -/// On a cache miss, falls back to -/// [`super::in_process::install_for_account`] so a transient install -/// failure during `webview_accounts::open` (logged as a warning by the -/// account-open path, not fatal) doesn't permanently lock the account -/// out of CDP. The install call is idempotent and cheap on the cached -/// path. Still returns `Err` when the webview itself has not yet been -/// created — caller backs off and retries. -pub fn conn_for_account( - app: &AppHandle, - account_id: &str, -) -> Result { - let registry = app - .try_state::() - .ok_or_else(|| "CdpRegistry not managed by app".to_string())?; - if let Some(transport) = registry.by_account(account_id) { - return Ok(CdpConn::new(transport)); - } - // Retry — the install path is idempotent. The most common cause of - // a cache miss here is an earlier non-fatal `install_for_account` - // failure in `webview_accounts::open` (warn-logged) that left the - // webview alive without a transport. - let transport = super::in_process::install_for_account(account_id) - .map_err(|e| format!("no cdp transport for account {account_id} (install retry: {e})"))?; - Ok(CdpConn::new(transport)) -} - /// Get a [`CdpConn`] for a webview keyed by its concrete label -/// (e.g. `"meet-call-"`). Generic counterpart of -/// [`conn_for_account`] for webviews that aren't account scanners. +/// (e.g. `"meet-call-"`). /// /// Falls back to [`super::in_process::install_for_label`] on a cache /// miss so a transient install race at window creation doesn't @@ -93,30 +62,10 @@ pub fn conn_for_label(app: &AppHandle, label: &str) -> Result( - app: &AppHandle, - account_id: &str, - pred: F, -) -> Result<(CdpConn, String), String> -where - R: Runtime, - F: Fn(&CdpTarget) -> bool, -{ - let cdp = conn_for_account(app, account_id)?; - attach_matching_on_conn(cdp, pred).await -} - -/// Same as [`connect_and_attach_matching_in_process`] but keyed by the -/// webview's concrete label rather than an account id. Used by Meet -/// (window label `meet-call-{request_id}`) and any other CEF surface -/// that isn't an account scanner. +/// Full short-lived attach sequence keyed by the webview's concrete +/// label: look up the [`CdpRegistry`] transport, find the matching page +/// target via `Target.getTargets`, then attach with `flatten: true`. +/// Used by the Meet call window (label `meet-call-{request_id}`). pub async fn connect_and_attach_matching_in_process_by_label( app: &AppHandle, label: &str, @@ -150,20 +99,8 @@ where Ok((cdp, session)) } -pub async fn detach_session(cdp: &mut CdpConn, session_id: &str) { - let _ = cdp - .call( - "Target.detachFromTarget", - json!({ "sessionId": session_id }), - None, - ) - .await; -} - /// Generalised target search — caller supplies the predicate -/// (url-hash marker, title marker, etc). Used by the per-account -/// session opener, which matches on `#openhuman-account-{id}` so -/// multiple webviews on the same origin don't collide. +/// (url-hash marker, title marker, etc). pub async fn find_page_target_where(cdp: &mut CdpConn, pred: F) -> Result where F: Fn(&CdpTarget) -> bool, diff --git a/app/src-tauri/src/discord_scanner/dom_snapshot.rs b/app/src-tauri/src/discord_scanner/dom_snapshot.rs deleted file mode 100644 index 4a7d18d509..0000000000 --- a/app/src-tauri/src/discord_scanner/dom_snapshot.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! Discord sidebar scrape via `DOMSnapshot.captureSnapshot`. Replaces the -//! old recipe.js scraper. Discord uses hashed class names (`name__abcde`) -//! so selectors rely on stable ARIA roles + `data-list-item-id` -//! attributes + class-name prefixes. -//! -//! * rows: `[role="treeitem"][data-list-item-id]` or -//! `data-list-item-id^="channels"|"private-channels"` -//! * name: class prefix `name_` / `channelName_` / first link text -//! * badge: class prefix `numberBadge_` / `unread_` / `aria-label*=unread` - -use serde_json::{json, Value}; - -use crate::cdp::{CdpConn, Snapshot}; - -#[derive(Debug, Clone)] -pub struct ChannelRow { - pub name: String, - pub unread: u32, -} - -pub struct DomScan { - pub rows: Vec, - pub total_unread: u32, - pub hash: u64, -} - -pub async fn scan(cdp: &mut CdpConn, session: &str) -> Result { - let snap = Snapshot::capture(cdp, session).await?; - let row_nodes = snap.find_all(is_channel_row); - let mut rows = Vec::with_capacity(row_nodes.len()); - let mut total_unread: u32 = 0; - for idx in row_nodes { - let name = find_name(&snap, idx).unwrap_or_default(); - if name.is_empty() { - continue; - } - let badge = find_badge(&snap, idx).unwrap_or(0); - total_unread = total_unread.saturating_add(badge); - rows.push(ChannelRow { - name, - unread: badge, - }); - } - let hash = hash_rows(&rows, total_unread); - Ok(DomScan { - rows, - total_unread, - hash, - }) -} - -pub fn ingest_payload(scan: &DomScan) -> Value { - let messages: Vec = scan - .rows - .iter() - .enumerate() - .map(|(idx, r)| { - json!({ - "id": format!("dc:{}:{idx}", r.name), - "from": r.name, - "body": Value::Null, - "unread": r.unread, - }) - }) - .collect(); - let snapshot_key = format!("{:x}", scan.hash); - json!({ - "messages": messages, - "unread": scan.total_unread, - "snapshotKey": snapshot_key, - }) -} - -fn is_channel_row(snap: &Snapshot, idx: usize) -> bool { - let Some(dlii) = snap.attr(idx, "data-list-item-id") else { - return false; - }; - // Primary: any treeitem carrying a list-item id (current Discord DOM). - // Fallback: legacy rows without `role` but with a well-known id prefix. - snap.attr(idx, "role") == Some("treeitem") - || dlii.starts_with("channels") - || dlii.starts_with("private-channels") -} - -fn find_name(snap: &Snapshot, root: usize) -> Option { - if let Some(n) = snap.find_descendant(root, |s, i| { - s.is_element(i) && s.class_starts_with(i, "name_") - }) { - let t = snap.text_content(n); - if !t.is_empty() { - return Some(t); - } - } - if let Some(n) = snap.find_descendant(root, |s, i| { - s.is_element(i) && s.class_starts_with(i, "channelName_") - }) { - let t = snap.text_content(n); - if !t.is_empty() { - return Some(t); - } - } - // Fallback: first anchor's text. - let a = snap.find_descendant(root, |s, i| { - s.is_element(i) && s.tag(i).eq_ignore_ascii_case("A") - })?; - let t = snap.text_content(a); - if t.is_empty() { - None - } else { - Some(t) - } -} - -fn find_badge(snap: &Snapshot, root: usize) -> Option { - // Numeric badge — class prefix `numberBadge_`. - if let Some(n) = snap.find_descendant(root, |s, i| { - s.is_element(i) && s.class_starts_with(i, "numberBadge_") - }) { - if let Ok(n_parsed) = snap.text_content(n).trim().parse::() { - return Some(n_parsed); - } - } - // Pure marker (no numeric count): row is included in `rows` with - // unread=0 but `total_unread` is not incremented. - if snap - .find_descendant(root, |s, i| { - s.is_element(i) && s.class_starts_with(i, "unread_") - }) - .is_some() - { - return Some(0); - } - None -} - -fn hash_rows(rows: &[ChannelRow], total_unread: u32) -> u64 { - let mut h: u64 = 0xcbf29ce484222325; - fn mix(h: &mut u64, b: u8) { - *h ^= b as u64; - *h = h.wrapping_mul(0x100000001b3); - } - for b in (rows.len() as u32).to_le_bytes() { - mix(&mut h, b); - } - for b in total_unread.to_le_bytes() { - mix(&mut h, b); - } - for r in rows { - for b in r.name.as_bytes() { - mix(&mut h, *b); - } - mix(&mut h, 0x7c); - for b in r.unread.to_le_bytes() { - mix(&mut h, b); - } - } - h -} diff --git a/app/src-tauri/src/discord_scanner/mod.rs b/app/src-tauri/src/discord_scanner/mod.rs deleted file mode 100644 index 2d2cdcfd1c..0000000000 --- a/app/src-tauri/src/discord_scanner/mod.rs +++ /dev/null @@ -1,1241 +0,0 @@ -//! Discord HTTP + WebSocket MITM driven over the Chrome DevTools Protocol. -//! -//! Attaches to the embedded CEF webview via the in-process CDP transport -//! installed by `webview_accounts::open` (no TCP listener). One persistent -//! task per tracked Discord account that: -//! -//! 1. Discovers the page target whose URL starts with `https://discord.com` -//! 2. Attaches with `flatten: true`, enables `Network.*` -//! 3. Streams every `Network.requestWillBeSent`, `Network.responseReceived`, -//! `Network.webSocketCreated`, `Network.webSocketFrameSent` / -//! `Network.webSocketFrameReceived` event for that session -//! 4. Filters to `discord.com/api/...` HTTP traffic and gateway WS frames, -//! then turns gateway message events into per-channel transcript updates -//! that are emitted to the UI and written straight into core memory. -//! -//! V1 parses live gateway events only. Outbound HTTP request bodies -//! (`request.postData`) are observed for debugging, but transcript ingest is -//! driven by `MESSAGE_CREATE` / `MESSAGE_UPDATE` frames from the gateway. -//! Inbound HTTP response bodies still require a `Network.getResponseBody` -//! round-trip and are left as a future backfill upgrade. -//! -//! NOTE: only built with the `cef` feature — wry has no remote-debugging -//! port and never gets compiled in. - -use std::collections::HashMap; -use std::sync::{Arc, OnceLock}; -use std::time::Duration; - -use chrono::{DateTime, Utc}; -use parking_lot::Mutex; -use serde_json::{json, Value}; -use tauri::{AppHandle, Emitter, Runtime}; -use tokio::sync::watch; -use tokio::task::AbortHandle; -use tokio::time::sleep; - -mod dom_snapshot; - -/// How long to wait between reconnect attempts when the CDP WebSocket drops -/// or the page target disappears (e.g. Discord refresh, navigation). -const RECONNECT_BACKOFF: Duration = Duration::from_secs(3); -const MAX_CHANNEL_MESSAGES: usize = 400; -/// Idle window after which the event pump assumes the attached page target is -/// stale/destroyed (reload, renderer crash, hard navigation) and returns so -/// the outer loop re-attaches. Chosen at >2x Discord's ~41s gateway heartbeat: -/// a live session always emits gateway WS frames within this window, so a -/// longer silence means the session is dead, not merely quiet. -const PUMP_IDLE_TIMEOUT: Duration = Duration::from_secs(90); - -#[derive(Clone, Debug, PartialEq, Eq)] -struct DiscordPersistMessage { - id: String, - author: String, - author_id: String, - body: String, - timestamp_ms: i64, - source_ref: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct DiscordChannelSnapshot { - channel_id: String, - channel_name: String, - guild_id: Option, - messages: Vec, -} - -#[derive(Default)] -struct DiscordChannelState { - name: Option, - guild_id: Option, - messages: Vec, -} - -#[derive(Default)] -struct MemoryUpsertRegistry { - workers: Mutex>>, -} - -#[derive(Default)] -struct DiscordIngestState { - channels: HashMap, -} - -static MEMORY_UPSERT_REGISTRY: OnceLock = OnceLock::new(); - -impl DiscordIngestState { - fn apply_gateway_payload(&mut self, payload: &str) -> Vec { - let event: Value = match serde_json::from_str(payload) { - Ok(v) => v, - Err(_) => return Vec::new(), - }; - if event.get("op").and_then(|v| v.as_i64()) != Some(0) { - return Vec::new(); - } - let kind = event.get("t").and_then(|v| v.as_str()).unwrap_or(""); - let data = event.get("d").cloned().unwrap_or(Value::Null); - match kind { - "READY" => { - if let Some(channels) = data.get("private_channels").and_then(|v| v.as_array()) { - for channel in channels { - self.apply_channel_meta(channel, None); - } - } - Vec::new() - } - "GUILD_CREATE" => { - let guild_id = data - .get("id") - .and_then(|v| v.as_str()) - .map(ToOwned::to_owned); - if let Some(channels) = data.get("channels").and_then(|v| v.as_array()) { - for channel in channels { - self.apply_channel_meta(channel, guild_id.clone()); - } - } - if let Some(threads) = data.get("threads").and_then(|v| v.as_array()) { - for thread in threads { - self.apply_channel_meta(thread, guild_id.clone()); - } - } - Vec::new() - } - "CHANNEL_CREATE" | "CHANNEL_UPDATE" | "THREAD_CREATE" | "THREAD_UPDATE" => { - let channel_id = data - .get("id") - .and_then(|v| v.as_str()) - .map(ToOwned::to_owned); - self.apply_channel_meta( - &data, - data.get("guild_id") - .and_then(|v| v.as_str()) - .map(ToOwned::to_owned), - ); - channel_id - .and_then(|id| self.snapshot_for_channel(&id)) - .into_iter() - .collect() - } - "MESSAGE_CREATE" => self.apply_message_event(&data, false).into_iter().collect(), - "MESSAGE_UPDATE" => self.apply_message_event(&data, true).into_iter().collect(), - _ => Vec::new(), - } - } - - fn apply_channel_meta(&mut self, value: &Value, fallback_guild_id: Option) { - let Some(channel_id) = value.get("id").and_then(|v| v.as_str()) else { - return; - }; - let state = self.channels.entry(channel_id.to_string()).or_default(); - if let Some(name) = channel_label(value) { - state.name = Some(name); - } - if state.guild_id.is_none() { - state.guild_id = value - .get("guild_id") - .and_then(|v| v.as_str()) - .map(ToOwned::to_owned) - .or(fallback_guild_id); - } - } - - fn apply_message_event( - &mut self, - value: &Value, - is_update: bool, - ) -> Option { - let channel_id = value - .get("channel_id") - .and_then(|v| v.as_str())? - .to_string(); - let message_id = value.get("id").and_then(|v| v.as_str())?.to_string(); - let body = discord_message_body(value); - let timestamp_ms = value - .get("timestamp") - .and_then(|v| v.as_str()) - .and_then(parse_discord_timestamp_ms) - .unwrap_or_else(chrono_now_millis); - let guild_id = value - .get("guild_id") - .and_then(|v| v.as_str()) - .map(ToOwned::to_owned); - let author_id = value - .get("author") - .and_then(|v| v.get("id")) - .and_then(|v| v.as_str()) - .map(ToOwned::to_owned); - let author = discord_author_label(value); - let source_ref = discord_message_permalink(value, &channel_id, &message_id); - - let state = self.channels.entry(channel_id.clone()).or_default(); - if let Some(name) = channel_label(value) { - state.name = Some(name); - } - if state.guild_id.is_none() { - state.guild_id = guild_id; - } - - if let Some(existing) = state.messages.iter_mut().find(|m| m.id == message_id) { - if discord_message_body_should_replace(value) { - if let Some(next_body) = body { - existing.body = next_body; - } - } else if !is_update && body.is_none() { - return None; - } else if body.is_none() && discord_message_body_fields_present(value) { - log::warn!( - "[discord][{}] message update omitted transcript body fields for id={}", - channel_id, - message_id - ); - } - if let Some(next_author_id) = author_id { - existing.author_id = next_author_id; - if !author.is_empty() && author != "?" { - existing.author = author; - } - } - if value.get("timestamp").is_some() { - existing.timestamp_ms = timestamp_ms; - } - existing.source_ref = source_ref; - } else { - let next = DiscordPersistMessage { - id: message_id.clone(), - author: if author.is_empty() { - "?".to_string() - } else { - author - }, - author_id: author_id.unwrap_or_default(), - body: body?, - timestamp_ms, - source_ref, - }; - state.messages.push(next); - } - state - .messages - .sort_by_key(|m| (m.timestamp_ms, m.id.clone())); - if state.messages.len() > MAX_CHANNEL_MESSAGES { - let drop_n = state.messages.len() - MAX_CHANNEL_MESSAGES; - state.messages.drain(0..drop_n); - } - - Some(DiscordChannelSnapshot { - channel_id: channel_id.clone(), - channel_name: state - .name - .clone() - .unwrap_or_else(|| format!("channel-{channel_id}")), - guild_id: state.guild_id.clone(), - messages: state.messages.clone(), - }) - } - - fn snapshot_for_channel(&self, channel_id: &str) -> Option { - let state = self.channels.get(channel_id)?; - if state.messages.is_empty() { - return None; - } - Some(DiscordChannelSnapshot { - channel_id: channel_id.to_string(), - channel_name: state - .name - .clone() - .unwrap_or_else(|| format!("channel-{channel_id}")), - guild_id: state.guild_id.clone(), - messages: state.messages.clone(), - }) - } -} - -/// Spawn the per-account MITM task. Idempotent at call site — caller guards -/// double-spawn via `ScannerRegistry::ensure_scanner`. -pub fn spawn_scanner( - app: AppHandle, - account_id: String, - url_prefix: String, -) -> Vec { - let mut handles = Vec::with_capacity(2); - handles.push(spawn_dom_poll( - app.clone(), - account_id.clone(), - url_prefix.clone(), - )); - let task = tokio::spawn(async move { - let fragment = crate::cdp::target_url_fragment(&account_id); - log::info!( - "[discord][{}] mitm up url_prefix={} fragment={} (in-process CDP)", - account_id, - url_prefix, - fragment, - ); - // Let Discord's bootstrap (auth + gateway handshake) settle before - // we attach — `Network.enable` issued during the cold-start burst - // tends to race with the renderer's own initialization and we miss - // the first few frames anyway. - sleep(Duration::from_secs(4)).await; - // Lock onto the page target once a strict fragment match succeeds, so - // re-attaches after a reload survive Discord stripping the URL hash - // (see `attach_account_target`). Persists across reconnects. - let mut pinned_target_id: Option = None; - loop { - match run_mitm_session( - &app, - &account_id, - &url_prefix, - &fragment, - &mut pinned_target_id, - ) - .await - { - Ok(()) => { - log::info!( - "[discord][{}] session ended cleanly, reconnecting", - account_id - ); - } - Err(e) => { - log::warn!( - "[discord][{}] session failed: {} — reconnecting in {:?}", - account_id, - e, - RECONNECT_BACKOFF - ); - } - } - sleep(RECONNECT_BACKOFF).await; - } - }); - handles.push(task.abort_handle()); - handles -} - -/// Run one CDP attach → enable → stream-events lifecycle. Returns when the -/// in-process transport closes (webview torn down) or when the pump's idle -/// watchdog trips after `PUMP_IDLE_TIMEOUT` of no frames — i.e. the attached -/// page target went stale (Discord reload, renderer crash, hard navigation). -/// The caller's outer loop then re-attaches. `pinned_target_id` carries the -/// pin/strict/relaxed resolution state across reconnects (see -/// [`attach_account_target`]). -async fn run_mitm_session( - app: &AppHandle, - account_id: &str, - url_prefix: &str, - url_fragment: &str, - pinned_target_id: &mut Option, -) -> Result<(), String> { - let (mut cdp, session_id) = - attach_account_target(app, account_id, url_prefix, url_fragment, pinned_target_id) - .await - .map_err(|e| format!("attach: {e}"))?; - log::info!( - "[discord][{}] attached label={} session={}", - account_id, - cdp.label(), - session_id - ); - - // Enable the Network domain on the page session — this is what unlocks - // the `requestWillBeSent` / `webSocketFrame*` event stream we care about. - cdp.call("Network.enable", json!({}), Some(&session_id)) - .await?; - log::info!( - "[discord][{}] Network.enable ok session={}", - account_id, - session_id - ); - - // Drop into the event read loop. It returns when the in-process transport - // closes (webview gone) OR when the idle watchdog fires after - // `PUMP_IDLE_TIMEOUT` of no frames (stale/destroyed page target) — either - // way the outer loop re-attaches. The resilient pump also buffers bursts - // into an unbounded queue so a flood that overflows the broadcast ring - // isn't silently dropped. V1 doesn't issue any in-stream calls (responses - // table from the previous TCP impl is gone — re-introduce a - // request/response API here when V1.5 backfills `Network.getResponseBody`). - log::info!("[discord][{}] event pump started", account_id); - let mut ingest_state = DiscordIngestState::default(); - let pump_result = cdp - .pump_events_resilient(&session_id, PUMP_IDLE_TIMEOUT, |method, params| { - dispatch_event(app, account_id, method, params, &mut ingest_state); - }) - .await; - // Detach the now-stale session before the outer loop re-attaches, so idle / - // lag-forced reconnects don't accumulate orphaned CDP sessions on the - // transport (mirrors the DOM-scan cleanup). - crate::cdp::detach_session(&mut cdp, &session_id).await; - pump_result -} - -/// Pure pin → strict → relaxed target-selection core of -/// [`attach_account_target`]. Returns the chosen page target and whether it was -/// a strict fragment match (the caller pins only on `true`). Split out so the -/// resolution hierarchy is unit-testable without a live CDP transport. -fn resolve_page_target<'a>( - targets: &'a [crate::cdp::target::CdpTarget], - url_prefix: &str, - url_fragment: &str, - pinned_target_id: Option<&str>, -) -> Option<(&'a crate::cdp::target::CdpTarget, bool)> { - // 1. Pinned id (locked on a prior strict match) — survives the hash strip. - // Still require the prefix: a pinned tab can navigate off Discord while - // keeping its target id, and we must not keep scanning an off-prefix page. - if let Some(pid) = pinned_target_id { - if let Some(t) = targets - .iter() - .find(|t| t.id == pid && t.kind == "page" && t.url.starts_with(url_prefix)) - { - return Some((t, false)); - } - } - // 2. Strict fragment match — the only result that proves account ownership. - if let Some(t) = targets.iter().find(|t| { - t.kind == "page" && t.url.starts_with(url_prefix) && t.url.ends_with(url_fragment) - }) { - return Some((t, true)); - } - // 3. Relaxed prefix-only — last resort; safe under per-account data-dir isolation. - targets - .iter() - .find(|t| t.kind == "page" && t.url.starts_with(url_prefix)) - .map(|t| (t, false)) -} - -/// Resolve this account's page target, attach, and return the live -/// [`CdpConn`](crate::cdp::CdpConn) plus session id. -/// -/// Discord's web client `replaceState`s to its canonical `/channels/...` URL -/// on boot, stripping the `#openhuman-account-` fragment the webview was -/// opened with — so a strict `ends_with(fragment)` match only holds for the -/// first instant after navigation and fails forever after (the 4s settle delay -/// alone guarantees we attach *after* the strip). Mirrors the Slack scanner's -/// resolution hierarchy (`slack_scanner::scan_once`) via [`resolve_page_target`]: -/// -/// 1. **Pinned target id** — once a strict match locked the id, prefer it -/// (still constrained to `url_prefix`). Survives the fragment strip and -/// keeps multi-account sessions from cross-wiring scanner A onto B's tab. -/// 2. **Strict fragment match** (`url_prefix` + `#openhuman-account-`). -/// On hit, (re)pin the id into `pinned_target_id`. -/// 3. **Relaxed prefix-only match** — last resort. Per-account -/// `data_directory` isolation makes this safe for single-account setups; -/// never persisted into the pin (only a strict match proves ownership). -async fn attach_account_target( - app: &AppHandle, - account_id: &str, - url_prefix: &str, - url_fragment: &str, - pinned_target_id: &mut Option, -) -> Result<(crate::cdp::CdpConn, String), String> { - let mut cdp = crate::cdp::target::conn_for_account(app, account_id)?; - let targets_v = cdp.call("Target.getTargets", json!({}), None).await?; - let targets = crate::cdp::target::parse_targets(&targets_v); - - let (page_target, is_strict) = resolve_page_target( - &targets, - url_prefix, - url_fragment, - pinned_target_id.as_deref(), - ) - .ok_or_else(|| format!("no page target matching {url_prefix} fragment={url_fragment}"))?; - - // (Re)pin on every live strict-fragment match — the one signal that proves - // this target is *this* account's. Refreshing (not just setting-once) lets a - // stale pin recover: after a renderer swap gives a new target id, the next - // strict match re-pins instead of being stuck on relaxed forever. Relaxed - // matches never feed the pin. - if is_strict && pinned_target_id.as_deref() != Some(page_target.id.as_str()) { - log::info!( - "[discord][{}] pinned to target_id={} (strict fragment match)", - account_id, - page_target.id - ); - *pinned_target_id = Some(page_target.id.clone()); - } - - let target_id = page_target.id.clone(); - let attach = cdp - .call( - "Target.attachToTarget", - json!({ "targetId": target_id, "flatten": true }), - None, - ) - .await?; - let session = attach - .get("sessionId") - .and_then(|x| x.as_str()) - .ok_or_else(|| "page attach missing sessionId".to_string())? - .to_string(); - Ok((cdp, session)) -} - -// ---------- Event filter & emit ---------------------------------------------- - -/// Dispatch one CDP event. Filters down to: -/// * `Network.requestWillBeSent` for `discord.com/api/` URLs (captures -/// outbound POST/PATCH/DELETE bodies — sent messages, edits, reactions) -/// * `Network.responseReceived` for `discord.com/api/` URLs (captures -/// status + meta; body is a TODO — see V1.5 note above) -/// * `Network.webSocketCreated` for `gateway.discord` URLs (logs only) -/// * `Network.webSocketFrameSent` / `Network.webSocketFrameReceived` for -/// gateway connections (gateway op codes 0/1/etc — Discord's live -/// message stream) -/// -/// Everything else (image loads, css, telemetry pings, voice WS, ...) is -/// dropped silently to keep noise out of the event stream. -fn dispatch_event( - app: &AppHandle, - account_id: &str, - method: &str, - params: &Value, - ingest_state: &mut DiscordIngestState, -) { - match method { - "Network.requestWillBeSent" => { - let url = params - .pointer("/request/url") - .and_then(|v| v.as_str()) - .unwrap_or(""); - if !is_discord_api(url) { - return; - } - let req_method = params - .pointer("/request/method") - .and_then(|v| v.as_str()) - .unwrap_or("GET") - .to_string(); - // postData isn't always present on GETs — that's fine, just - // null it out. For POST/PATCH/PUT it's the JSON Discord is - // about to send, which is the bit we actually want. - let post_data = params - .pointer("/request/postData") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let request_id = params - .get("requestId") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - log::debug!( - "[discord][{}] http→ {} {} req_id={} body_len={}", - account_id, - req_method, - url, - request_id, - post_data.as_ref().map(|s| s.len()).unwrap_or(0) - ); - } - "Network.responseReceived" => { - let url = params - .pointer("/response/url") - .and_then(|v| v.as_str()) - .unwrap_or(""); - if !is_discord_api(url) { - return; - } - let status = params - .pointer("/response/status") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - let mime = params - .pointer("/response/mimeType") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let request_id = params - .get("requestId") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - log::debug!( - "[discord][{}] http← {} {} status={} mime={}", - account_id, - request_id, - url, - status, - mime - ); - // TODO: fetch response bodies with `Network.getResponseBody` if - // we need backfill beyond what the live gateway stream gives us. - } - "Network.webSocketCreated" => { - let url = params.get("url").and_then(|v| v.as_str()).unwrap_or(""); - if !is_discord_gateway(url) { - return; - } - let request_id = params - .get("requestId") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - log::info!( - "[discord][{}] ws-open req_id={} url={}", - account_id, - request_id, - url - ); - emit( - app, - account_id, - "log", - json!({ - "level": "info", - "msg": format!("discord gateway opened: {url}"), - "request_id": request_id, - }), - ); - } - m @ ("Network.webSocketFrameSent" | "Network.webSocketFrameReceived") => { - // We don't have URL on frame events — only the requestId. We - // emit unconditionally; consumers can drop frames whose - // request_id never appeared in a `webSocketCreated` for the - // gateway. Cheap, and avoids missing the very first frames - // (which fire before our event filter sees the create event - // sometimes, depending on attach-vs-handshake timing). - let direction = if m.ends_with("Sent") { - "sent" - } else { - "received" - }; - let opcode = params - .pointer("/response/opcode") - .and_then(|v| v.as_i64()) - .unwrap_or(-1); - let payload = params - .pointer("/response/payloadData") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let mask = params - .pointer("/response/mask") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let request_id = params - .get("requestId") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - log::trace!( - "[discord][{}] ws-{} req_id={} opcode={} bytes={} mask={}", - account_id, - direction, - request_id, - opcode, - payload.len(), - mask - ); - if direction == "received" && opcode == 1 { - for snapshot in ingest_state.apply_gateway_payload(&payload) { - emit_channel_transcript(app, account_id, snapshot); - } - } - } - _ => {} // ignore everything else - } -} - -fn is_discord_api(url: &str) -> bool { - // Match `https://discord.com/api/v9/...`, `/api/v10/...`, etc. Filter - // out the static asset CDN (`cdn.discordapp.com`, `media.discordapp.net`) - // and the analytics pings — those would drown the event stream with - // useless noise. - url.starts_with("https://discord.com/api/") - || url.starts_with("https://canary.discord.com/api/") - || url.starts_with("https://ptb.discord.com/api/") -} - -fn is_discord_gateway(url: &str) -> bool { - // Real-time message stream lives on `gateway.discord.gg`; voice/RTC - // negotiation lives on `*.discord.media` and isn't useful for message - // mirroring. - url.starts_with("wss://gateway.discord.gg") || url.starts_with("wss://gateway-") -} - -fn emit(app: &AppHandle, account_id: &str, kind: &str, payload: Value) { - let envelope = json!({ - "account_id": account_id, - "provider": "discord", - "kind": kind, - "payload": payload, - "ts": chrono_now_millis(), - }); - if let Err(e) = app.emit("webview:event", &envelope) { - log::warn!("[discord][{}] emit failed: {}", account_id, e); - } -} - -fn chrono_now_millis() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) -} - -fn parse_discord_timestamp_ms(raw: &str) -> Option { - DateTime::parse_from_rfc3339(raw) - .ok() - .map(|ts| ts.with_timezone(&Utc).timestamp_millis()) -} - -fn discord_author_label(value: &Value) -> String { - value - .get("member") - .and_then(|v| v.get("nick")) - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .or_else(|| { - value - .get("author") - .and_then(|v| v.get("global_name")) - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - }) - .or_else(|| { - value - .get("author") - .and_then(|v| v.get("username")) - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - }) - .or_else(|| { - value - .get("author") - .and_then(|v| v.get("id")) - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - }) - .unwrap_or("?") - .to_string() -} - -fn discord_message_body(value: &Value) -> Option { - let content = value - .get("content") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - if !content.is_empty() { - return Some(content); - } - - let attachment_names = value - .get("attachments") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|item| item.get("filename").and_then(|v| v.as_str())) - .filter(|name| !name.is_empty()) - .collect::>() - }) - .unwrap_or_default(); - if !attachment_names.is_empty() { - return Some(format!("[attachments] {}", attachment_names.join(", "))); - } - - let embed_titles = value - .get("embeds") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|item| { - item.get("title") - .and_then(|v| v.as_str()) - .or_else(|| item.get("description").and_then(|v| v.as_str())) - }) - .filter(|text| !text.is_empty()) - .collect::>() - }) - .unwrap_or_default(); - if !embed_titles.is_empty() { - return Some(format!("[embed] {}", embed_titles.join(" | "))); - } - - None -} - -fn discord_message_body_fields_present(value: &Value) -> bool { - value.get("content").is_some() - || value.get("attachments").is_some() - || value.get("embeds").is_some() -} - -fn discord_message_body_should_replace(value: &Value) -> bool { - value.get("content").is_some() || value.get("attachments").is_some() -} - -fn channel_label(value: &Value) -> Option { - let direct = value - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim(); - if !direct.is_empty() { - return Some(direct.to_string()); - } - let recipients = value - .get("recipients") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|user| { - user.get("global_name") - .and_then(|v| v.as_str()) - .or_else(|| user.get("username").and_then(|v| v.as_str())) - }) - .filter(|name| !name.is_empty()) - .collect::>() - }) - .unwrap_or_default(); - if recipients.is_empty() { - None - } else { - Some(recipients.join(", ")) - } -} - -fn discord_message_permalink(value: &Value, channel_id: &str, message_id: &str) -> String { - let guild_or_me = value - .get("guild_id") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .unwrap_or("@me"); - format!("https://discord.com/channels/{guild_or_me}/{channel_id}/{message_id}") -} - -fn discord_memory_payload(snapshot: &DiscordChannelSnapshot) -> Value { - let messages = snapshot - .messages - .iter() - .map(|message| { - json!({ - "id": message.id, - "sender": message.author, - "sender_id": message.author_id, - "body": message.body, - "date": message.timestamp_ms.div_euclid(1000), - "source_ref": message.source_ref, - }) - }) - .collect::>(); - json!({ - "provider": "discord", - "source": "cdp-gateway-chat", - "channelId": snapshot.channel_id, - "channelName": snapshot.channel_name, - "guildId": snapshot.guild_id, - "messages": messages, - }) -} - -fn seconds_to_ymd(secs: i64) -> String { - let days = secs.div_euclid(86_400); - let z = days + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = (z - era * 146_097) as u64; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; - let y_real = (if m <= 2 { y + 1 } else { y }) as i32; - format!("{:04}-{:02}-{:02}", y_real, m, d) -} - -async fn post_memory_doc_ingest(account_id: &str, ingest: &Value) -> Result<(), String> { - let channel_id = ingest - .get("channelId") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - let channel_name = ingest - .get("channelName") - .and_then(|v| v.as_str()) - .unwrap_or(channel_id); - let guild_id = ingest - .get("guildId") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - let empty: Vec = Vec::new(); - let messages = ingest - .get("messages") - .and_then(|v| v.as_array()) - .unwrap_or(&empty); - if channel_id.is_empty() || messages.is_empty() { - return Ok(()); - } - - let mut sorted: Vec<&Value> = messages.iter().collect(); - sorted.sort_by_key(|m| { - ( - m.get("date").and_then(|v| v.as_i64()).unwrap_or(0), - m.get("id") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - ) - }); - - let first_ts = sorted - .first() - .and_then(|m| m.get("date")) - .and_then(|v| v.as_i64()) - .unwrap_or(0); - let last_ts = sorted - .last() - .and_then(|m| m.get("date")) - .and_then(|v| v.as_i64()) - .unwrap_or(0); - let transcript = sorted - .iter() - .map(|m| { - let ts = m.get("date").and_then(|v| v.as_i64()).unwrap_or(0); - let stamp = if ts > 0 { - let day = seconds_to_ymd(ts); - let secs_of_day = ts.rem_euclid(86_400) as u32; - format!( - "{} {:02}:{:02}Z", - day, - secs_of_day / 3600, - (secs_of_day / 60) % 60 - ) - } else { - "?".to_string() - }; - let who = m - .get("sender") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .unwrap_or("?"); - let body = m - .get("body") - .and_then(|v| v.as_str()) - .unwrap_or("") - .replace(['\r', '\n'], " "); - format!("[{stamp}] {who}: {body}") - }) - .collect::>() - .join("\n"); - - let first_day = if first_ts > 0 { - seconds_to_ymd(first_ts) - } else { - String::new() - }; - let last_day = if last_ts > 0 { - seconds_to_ymd(last_ts) - } else { - String::new() - }; - let header = format!( - "# Discord — {channel}\nchannel_id: {channel_id}\nguild_id: {guild_id}\naccount_id: {account_id}\nmessages: {count}\nrange: {first_day} → {last_day}\n\n", - channel = channel_name, - channel_id = channel_id, - guild_id = if guild_id.is_empty() { "@me" } else { guild_id }, - account_id = account_id, - count = sorted.len(), - first_day = first_day, - last_day = last_day, - ); - let doc_key = discord_channel_doc_key(guild_id, channel_id); - let params = json!({ - "namespace": format!("discord-web:{account_id}"), - "key": doc_key, - "title": format!("Discord · {channel_name}"), - "content": format!("{header}{transcript}"), - "source_type": "discord-web", - "priority": "medium", - "tags": ["discord", "channel-transcript"], - "metadata": { - "provider": "discord", - "account_id": account_id, - "channel_id": channel_id, - "channel_name": channel_name, - "guild_id": guild_id, - "first_day": first_day, - "last_day": last_day, - "message_count": sorted.len(), - }, - "category": "core", - }); - let body = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "openhuman.memory_doc_ingest", - "params": params, - }); - let url = crate::core_rpc::core_rpc_url_value(); - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(15)) - .build() - .map_err(|e| format!("http client: {e}"))?; - let req = crate::core_rpc::apply_auth(client.post(&url)) - .map_err(|e| format!("prepare {url}: {e}"))?; - let resp = req - .json(&body) - .send() - .await - .map_err(|e| format!("POST {url}: {e}"))?; - let status = resp.status(); - if !status.is_success() { - let body = resp.text().await.unwrap_or_default(); - return Err(format!("{status}: {body}")); - } - let v: Value = resp.json().await.map_err(|e| format!("decode: {e}"))?; - if let Some(err) = v.get("error") { - return Err(format!("rpc error: {err}")); - } - log::info!( - "[discord][{}] memory upsert ok channel={} key={} msgs={} range={}→{}", - account_id, - channel_id, - discord_channel_doc_key(guild_id, channel_id), - sorted.len(), - first_day, - last_day, - ); - Ok(()) -} - -fn discord_channel_doc_key(guild_id: &str, channel_id: &str) -> String { - if guild_id.is_empty() { - format!("@me:{channel_id}") - } else { - format!("{guild_id}:{channel_id}") - } -} - -fn queue_memory_doc_ingest(account_id: String, payload: Value) { - let worker_key = memory_worker_key(&account_id, &payload); - let registry = MEMORY_UPSERT_REGISTRY.get_or_init(MemoryUpsertRegistry::default); - let sender = { - let mut workers = registry.workers.lock(); - if let Some(existing) = workers.get(&worker_key) { - existing.clone() - } else { - let (tx, mut rx) = watch::channel(payload.clone()); - let worker_key_for_task = worker_key.clone(); - let account_id_for_task = account_id.clone(); - tokio::spawn(async move { - let mut first = true; - loop { - if !first && rx.changed().await.is_err() { - break; - } - first = false; - let next_payload = rx.borrow().clone(); - if let Err(e) = - post_memory_doc_ingest(&account_id_for_task, &next_payload).await - { - log::warn!( - "[discord][{}] memory write failed worker={} err={}", - account_id_for_task, - worker_key_for_task, - e - ); - } - } - }); - workers.insert(worker_key.clone(), tx.clone()); - tx - } - }; - if let Err(e) = sender.send(payload) { - log::warn!( - "[discord][{}] memory ingest queue send failed worker={} err={}", - account_id, - worker_key, - e - ); - } -} - -fn memory_worker_key(account_id: &str, payload: &Value) -> String { - let channel_id = payload - .get("channelId") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - let guild_id = payload - .get("guildId") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - format!( - "{account_id}:{}", - discord_channel_doc_key(guild_id, channel_id) - ) -} - -fn emit_channel_transcript( - app: &AppHandle, - account_id: &str, - snapshot: DiscordChannelSnapshot, -) { - let payload = discord_memory_payload(&snapshot); - let envelope = json!({ - "account_id": account_id, - "provider": "discord", - "kind": "discord_memory_ingest", - "payload": payload.clone(), - "ts": chrono_now_millis(), - }); - if let Err(e) = app.emit("webview:event", &envelope) { - log::warn!("[discord][{}] memory ingest emit failed: {}", account_id, e); - } - queue_memory_doc_ingest(account_id.to_string(), payload); -} - -// ---------- DOM chat-list poll ---------------------------------------------- - -const DOM_POLL_INTERVAL: Duration = Duration::from_secs(2); - -fn spawn_dom_poll( - app: AppHandle, - account_id: String, - url_prefix: String, -) -> AbortHandle { - let task = tokio::spawn(async move { - let fragment = crate::cdp::target_url_fragment(&account_id); - sleep(Duration::from_secs(6)).await; - let mut last_hash: Option = None; - let mut pinned_target_id: Option = None; - loop { - match dom_scan_once( - &app, - &account_id, - &url_prefix, - &fragment, - &mut pinned_target_id, - ) - .await - { - Ok(scan) => { - if Some(scan.hash) != last_hash { - log::info!( - "[discord][{}] dom scan rows={} unread={} hash={:x}", - account_id, - scan.rows.len(), - scan.total_unread, - scan.hash - ); - last_hash = Some(scan.hash); - let envelope = json!({ - "account_id": account_id, - "provider": "discord", - "kind": "ingest", - "payload": dom_snapshot::ingest_payload(&scan), - "ts": chrono_now_millis(), - }); - if let Err(e) = app.emit("webview:event", &envelope) { - log::warn!("[discord][{}] dom ingest emit failed: {}", account_id, e); - } - } - } - Err(e) => log::debug!("[discord][{}] dom scan: {}", account_id, e), - } - sleep(DOM_POLL_INTERVAL).await; - } - }); - task.abort_handle() -} - -async fn dom_scan_once( - app: &AppHandle, - account_id: &str, - url_prefix: &str, - url_fragment: &str, - pinned_target_id: &mut Option, -) -> Result { - let (mut cdp, session) = - attach_account_target(app, account_id, url_prefix, url_fragment, pinned_target_id).await?; - let scan = dom_snapshot::scan(&mut cdp, &session).await; - crate::cdp::detach_session(&mut cdp, &session).await; - scan -} - -// ---------- Registry --------------------------------------------------------- - -/// Tracks which accounts already have a MITM task running so the webview -/// open-lifecycle can call `ensure_scanner` repeatedly without -/// double-spawning. Same shape as the WhatsApp / Slack registries so the -/// `webview_accounts` wiring is uniform. -#[derive(Default)] -pub struct ScannerRegistry { - started: Mutex>>, -} - -impl ScannerRegistry { - pub fn new() -> Arc { - Arc::new(Self::default()) - } - - pub fn ensure_scanner( - &self, - app: AppHandle, - account_id: String, - url_prefix: String, - ) { - let mut g = self.started.lock(); - if g.contains_key(&account_id) { - log::debug!("[discord] mitm already running for {}", account_id); - return; - } - let handles = spawn_scanner(app, account_id.clone(), url_prefix); - g.insert(account_id, handles); - } - - pub fn forget(&self, account_id: &str) { - let handles = self.started.lock().remove(account_id); - if let Some(handles) = handles { - let count = handles.len(); - for handle in handles { - handle.abort(); - } - log::info!( - "[discord] aborted {} scanner task(s) for {}", - count, - account_id - ); - } - } - - pub fn forget_all(&self) -> usize { - let entries: Vec<_> = self.started.lock().drain().collect(); - let task_count = entries.iter().map(|(_, handles)| handles.len()).sum(); - for (account_id, handles) in entries { - for handle in handles { - handle.abort(); - } - log::debug!("[discord] aborted scanner tasks for {}", account_id); - } - if task_count > 0 { - log::info!("[discord] aborted {} scanner task(s)", task_count); - } - task_count - } -} - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod tests; diff --git a/app/src-tauri/src/discord_scanner/mod_tests.rs b/app/src-tauri/src/discord_scanner/mod_tests.rs deleted file mode 100644 index 7278419079..0000000000 --- a/app/src-tauri/src/discord_scanner/mod_tests.rs +++ /dev/null @@ -1,431 +0,0 @@ -use super::*; - -#[test] -fn gateway_guild_create_and_message_create_build_channel_snapshot() { - let mut state = DiscordIngestState::default(); - state.apply_gateway_payload( - r#"{ - "op":0, - "t":"GUILD_CREATE", - "d":{ - "id":"guild-1", - "channels":[{"id":"chan-1","name":"general"}], - "threads":[] - } - }"#, - ); - - let snapshots = state.apply_gateway_payload( - r#"{ - "op":0, - "t":"MESSAGE_CREATE", - "d":{ - "id":"msg-1", - "channel_id":"chan-1", - "guild_id":"guild-1", - "content":"hello discord", - "timestamp":"2026-05-17T12:34:56.000Z", - "author":{"id":"user-1","username":"alice","global_name":"Alice"}, - "member":{"nick":"Ali"} - } - }"#, - ); - - assert_eq!(snapshots.len(), 1); - let snapshot = &snapshots[0]; - assert_eq!(snapshot.channel_id, "chan-1"); - assert_eq!(snapshot.channel_name, "general"); - assert_eq!(snapshot.guild_id.as_deref(), Some("guild-1")); - assert_eq!(snapshot.messages.len(), 1); - assert_eq!(snapshot.messages[0].author, "Ali"); - assert_eq!(snapshot.messages[0].body, "hello discord"); - assert_eq!( - snapshot.messages[0].source_ref, - "https://discord.com/channels/guild-1/chan-1/msg-1" - ); -} - -#[test] -fn channel_create_dm_recipients_become_channel_name() { - let mut state = DiscordIngestState::default(); - let snapshots = state.apply_gateway_payload( - r#"{ - "op":0, - "t":"CHANNEL_CREATE", - "d":{ - "id":"dm-1", - "type":1, - "recipients":[ - {"id":"u1","username":"alice"}, - {"id":"u2","global_name":"Bob Builder"} - ] - } - }"#, - ); - - assert!(snapshots.is_empty()); - let channel = state.channels.get("dm-1").expect("dm channel cached"); - assert_eq!(channel.name.as_deref(), Some("alice, Bob Builder")); -} - -#[test] -fn channel_update_emits_snapshot_when_messages_are_already_cached() { - let mut state = DiscordIngestState::default(); - let _ = state.apply_gateway_payload( - r#"{ - "op":0, - "t":"MESSAGE_CREATE", - "d":{ - "id":"msg-1", - "channel_id":"chan-1", - "guild_id":"guild-1", - "content":"hello", - "timestamp":"2026-05-17T12:34:56.000Z", - "author":{"id":"user-1","username":"alice"} - } - }"#, - ); - - let snapshots = state.apply_gateway_payload( - r#"{ - "op":0, - "t":"CHANNEL_UPDATE", - "d":{ - "id":"chan-1", - "guild_id":"guild-1", - "name":"renamed-general" - } - }"#, - ); - - assert_eq!(snapshots.len(), 1); - assert_eq!(snapshots[0].channel_name, "renamed-general"); - assert_eq!(snapshots[0].messages.len(), 1); -} - -#[test] -fn message_update_replaces_existing_message_body() { - let mut state = DiscordIngestState::default(); - let _ = state.apply_gateway_payload( - r#"{ - "op":0, - "t":"MESSAGE_CREATE", - "d":{ - "id":"msg-1", - "channel_id":"chan-1", - "content":"before", - "timestamp":"2026-05-17T12:34:56.000Z", - "author":{"id":"user-1","username":"alice"} - } - }"#, - ); - - let snapshots = state.apply_gateway_payload( - r#"{ - "op":0, - "t":"MESSAGE_UPDATE", - "d":{ - "id":"msg-1", - "channel_id":"chan-1", - "content":"after", - "timestamp":"2026-05-17T12:34:56.000Z", - "author":{"id":"user-1","username":"alice"} - } - }"#, - ); - - assert_eq!(snapshots.len(), 1); - assert_eq!(snapshots[0].messages.len(), 1); - assert_eq!(snapshots[0].messages[0].body, "after"); -} - -#[test] -fn message_update_preserves_missing_fields_from_cached_message() { - let mut state = DiscordIngestState::default(); - let _ = state.apply_gateway_payload( - r#"{ - "op":0, - "t":"MESSAGE_CREATE", - "d":{ - "id":"msg-1", - "channel_id":"chan-1", - "guild_id":"guild-1", - "content":"before", - "timestamp":"2026-05-17T12:34:56.000Z", - "author":{"id":"user-1","username":"alice"} - } - }"#, - ); - - let snapshots = state.apply_gateway_payload( - r#"{ - "op":0, - "t":"MESSAGE_UPDATE", - "d":{ - "id":"msg-1", - "channel_id":"chan-1", - "guild_id":"guild-1", - "edited_timestamp":"2026-05-17T12:35:56.000Z" - } - }"#, - ); - - assert_eq!(snapshots.len(), 1); - let message = &snapshots[0].messages[0]; - assert_eq!(message.body, "before"); - assert_eq!(message.author, "alice"); - assert_eq!(message.author_id, "user-1"); - assert_eq!( - message.timestamp_ms, - parse_discord_timestamp_ms("2026-05-17T12:34:56.000Z").unwrap() - ); -} - -#[test] -fn message_update_with_embed_only_keeps_existing_body_text() { - let mut state = DiscordIngestState::default(); - let _ = state.apply_gateway_payload( - r#"{ - "op":0, - "t":"MESSAGE_CREATE", - "d":{ - "id":"msg-1", - "channel_id":"chan-1", - "content":"before", - "timestamp":"2026-05-17T12:34:56.000Z", - "author":{"id":"user-1","username":"alice"} - } - }"#, - ); - - let snapshots = state.apply_gateway_payload( - r#"{ - "op":0, - "t":"MESSAGE_UPDATE", - "d":{ - "id":"msg-1", - "channel_id":"chan-1", - "embeds":[{"title":"preview card"}] - } - }"#, - ); - - assert_eq!(snapshots.len(), 1); - assert_eq!(snapshots[0].messages[0].body, "before"); -} - -#[test] -fn discord_channel_doc_key_scopes_same_channel_name_by_guild() { - assert_eq!( - discord_channel_doc_key("guild-1", "chan-1"), - "guild-1:chan-1" - ); - assert_eq!( - discord_channel_doc_key("guild-2", "chan-1"), - "guild-2:chan-1" - ); - assert_eq!(discord_channel_doc_key("", "chan-1"), "@me:chan-1"); -} - -fn insert_pending_tasks( - registry: &ScannerRegistry, - account_id: &str, - count: usize, -) -> Vec> { - let mut tasks = Vec::with_capacity(count); - let mut abort_handles = Vec::with_capacity(count); - for _ in 0..count { - let task = tokio::spawn(async { - std::future::pending::<()>().await; - }); - abort_handles.push(task.abort_handle()); - tasks.push(task); - } - registry - .started - .lock() - .insert(account_id.to_string(), abort_handles); - tasks -} - -async fn assert_cancelled(task: tokio::task::JoinHandle<()>) { - let err = tokio::time::timeout(Duration::from_secs(1), task) - .await - .expect("aborted scanner task should finish") - .expect_err("scanner task should be cancelled"); - assert!(err.is_cancelled()); -} - -async fn assert_all_cancelled(tasks: Vec>) { - for task in tasks { - assert_cancelled(task).await; - } -} - -#[tokio::test] -async fn registry_forget_aborts_all_handles_for_account_only() { - let registry = ScannerRegistry::default(); - let account_tasks = insert_pending_tasks(®istry, "acct-1", 2); - let survivor_tasks = insert_pending_tasks(®istry, "acct-2", 1); - - registry.forget("acct-1"); - - { - let guard = registry.started.lock(); - assert_eq!(guard.len(), 1); - assert!(guard.contains_key("acct-2")); - } - assert_all_cancelled(account_tasks).await; - assert!( - !survivor_tasks[0].is_finished(), - "forget(acct-1) must not abort acct-2" - ); - - assert_eq!(registry.forget_all(), 1); - assert_all_cancelled(survivor_tasks).await; -} - -#[tokio::test] -async fn registry_forget_missing_account_is_noop() { - let registry = ScannerRegistry::default(); - let mut tasks = insert_pending_tasks(®istry, "acct-1", 1); - - registry.forget("missing"); - - { - let guard = registry.started.lock(); - assert_eq!(guard.len(), 1); - assert!(guard.contains_key("acct-1")); - } - assert!( - !tasks[0].is_finished(), - "forget(missing) must not abort existing scanners" - ); - - registry.forget("acct-1"); - assert_cancelled(tasks.pop().expect("task")).await; -} - -#[tokio::test] -async fn registry_forget_all_aborts_all_tasks_and_reports_handle_count() { - let registry = ScannerRegistry::default(); - let task_a = insert_pending_tasks(®istry, "acct-1", 2); - let task_b = insert_pending_tasks(®istry, "acct-2", 3); - - assert_eq!(registry.forget_all(), 5); - - assert!(registry.started.lock().is_empty()); - assert_all_cancelled(task_a).await; - assert_all_cancelled(task_b).await; -} - -#[tokio::test] -async fn registry_forget_all_is_repeatable_noop_after_drain() { - let registry = ScannerRegistry::default(); - assert_eq!(registry.forget_all(), 0); - - let tasks = insert_pending_tasks(®istry, "acct-1", 1); - assert_eq!(registry.forget_all(), 1); - assert_eq!(registry.forget_all(), 0); - - assert!(registry.started.lock().is_empty()); - assert_all_cancelled(tasks).await; -} - -// ---------- attach target resolution (pin → strict → relaxed) ---------------- - -fn page(id: &str, url: &str) -> crate::cdp::target::CdpTarget { - crate::cdp::target::CdpTarget { - id: id.to_string(), - kind: "page".to_string(), - url: url.to_string(), - } -} - -const PFX: &str = "https://discord.com/"; -const FRAG: &str = "#openhuman-account-acct-1"; - -#[test] -fn resolve_strict_fragment_match_is_pinnable() { - let targets = vec![ - page("t-other", "https://discord.com/channels/@me"), - page( - "t-1", - "https://discord.com/channels/@me#openhuman-account-acct-1", - ), - ]; - let (t, strict) = super::resolve_page_target(&targets, PFX, FRAG, None).unwrap(); - assert_eq!(t.id, "t-1"); - assert!( - strict, - "strict fragment match must report strict=true so caller pins" - ); -} - -#[test] -fn resolve_falls_back_to_relaxed_when_fragment_stripped() { - // Discord replaceState'd the hash away — only a prefix match remains. - let targets = vec![page("t-1", "https://discord.com/channels/@me/12345")]; - let (t, strict) = super::resolve_page_target(&targets, PFX, FRAG, None).unwrap(); - assert_eq!(t.id, "t-1"); - assert!( - !strict, - "relaxed match must report strict=false so caller never pins it" - ); -} - -#[test] -fn resolve_prefers_pinned_id_over_strict_sibling() { - // Pin already locked to t-1 (fragment since stripped); a sibling tab still - // carries a strict fragment — the pin must win to avoid cross-wiring. - let targets = vec![ - page( - "t-2", - "https://discord.com/channels/@me#openhuman-account-acct-1", - ), - page("t-1", "https://discord.com/channels/@me/12345"), - ]; - let (t, strict) = super::resolve_page_target(&targets, PFX, FRAG, Some("t-1")).unwrap(); - assert_eq!(t.id, "t-1"); - assert!(!strict); -} - -#[test] -fn resolve_ignores_stale_pin_and_recovers() { - // Pinned id no longer present (renderer crash → new target id). Resolution - // must skip the dead pin and fall through to strict/relaxed. - let targets = vec![page("t-new", "https://discord.com/channels/@me/9")]; - let (t, strict) = super::resolve_page_target(&targets, PFX, FRAG, Some("t-gone")).unwrap(); - assert_eq!(t.id, "t-new"); - assert!(!strict); -} - -#[test] -fn resolve_none_when_no_prefix_target() { - let targets = vec![ - page("t-1", "https://slack.com/client/x"), - crate::cdp::target::CdpTarget { - id: "iframe".to_string(), - kind: "iframe".to_string(), - url: "https://discord.com/channels/@me".to_string(), - }, - ]; - assert!( - super::resolve_page_target(&targets, PFX, FRAG, None).is_none(), - "no page-kind target under the prefix → None (non-page kinds excluded)" - ); -} - -#[test] -fn resolve_rejects_pinned_target_that_navigated_off_prefix() { - // A pinned tab can keep its target id while navigating away from Discord. - // The pin branch must still honor url_prefix, so an off-prefix pinned page is - // rejected and resolution falls through to the real Discord page. - let targets = vec![ - page("t-1", "https://example.com/somewhere-else"), // pinned id, off-prefix - page("t-2", "https://discord.com/channels/@me/9"), // real discord page - ]; - let (t, strict) = super::resolve_page_target(&targets, PFX, FRAG, Some("t-1")).unwrap(); - assert_eq!(t.id, "t-2"); - assert!(!strict); -} diff --git a/app/src-tauri/src/gmessages_scanner/cdp_walk.rs b/app/src-tauri/src/gmessages_scanner/cdp_walk.rs deleted file mode 100644 index 321a6b8c75..0000000000 --- a/app/src-tauri/src/gmessages_scanner/cdp_walk.rs +++ /dev/null @@ -1,205 +0,0 @@ -//! CDP-driven walk of the Google Messages Web `bugle_db` IndexedDB. -//! -//! Pairs with `idb.rs` (schema + normalization). This module does the -//! `IndexedDB.requestData` paging + `Runtime.callFunctionOn` serialisation -//! dance, then hands the raw JSON rows to `idb::normalize_*` for shape -//! checking. - -use serde_json::{json, Value}; - -use super::idb::{ - self, Conversation, Message, ParticipantMap, DATABASE_NAME, STORE_CONVERSATIONS, - STORE_MESSAGES, STORE_PARTICIPANTS, -}; -use crate::cdp::CdpConn; - -/// IndexedDB security origin for the Google Messages Web app. -const ORIGIN: &str = "https://messages.google.com"; -/// Rows per `IndexedDB.requestData` call — matches the WhatsApp scanner. -const PAGE_SIZE: i64 = 500; -/// Hard cap per store to bound full-scan cost on huge histories. -const MAX_RECORDS_PER_STORE: usize = 20_000; -/// `Runtime.callFunctionOn` batch size for RemoteObject serialisation. -const SERIALIZE_BATCH: usize = 100; - -pub struct WalkResult { - pub messages: Vec, - pub conversations: Vec, - pub participants: ParticipantMap, -} - -/// Walk `bugle_db`: messages, conversations, participants. Per-store -/// failures are logged and swallowed so one bad store doesn't nuke the -/// cycle — the caller still gets whatever did come back. -pub async fn walk(cdp: &mut CdpConn, session: &str) -> Result { - // `IndexedDB.enable` is a no-op on modern Chromium but older CEF - // builds refuse `requestData` without it. Cost is trivial. - if let Err(e) = cdp.call("IndexedDB.enable", json!({}), Some(session)).await { - log::debug!("[gmessages][idb] enable: {}", e); - } - - let messages_raw = match read_store(cdp, session, STORE_MESSAGES).await { - Ok(v) => v, - Err(e) => { - log::warn!("[gmessages][idb] read {} failed: {}", STORE_MESSAGES, e); - Vec::new() - } - }; - let convos_raw = match read_store(cdp, session, STORE_CONVERSATIONS).await { - Ok(v) => v, - Err(e) => { - log::warn!("[gmessages][idb] read {} failed: {}", STORE_CONVERSATIONS, e); - Vec::new() - } - }; - let parts_raw = match read_store(cdp, session, STORE_PARTICIPANTS).await { - Ok(v) => v, - Err(e) => { - log::warn!("[gmessages][idb] read {} failed: {}", STORE_PARTICIPANTS, e); - Vec::new() - } - }; - - let messages: Vec = messages_raw - .iter() - .filter_map(idb::normalize_message) - .collect(); - let conversations: Vec = convos_raw - .iter() - .filter_map(idb::normalize_conversation) - .collect(); - let mut participants = ParticipantMap::default(); - for raw in &parts_raw { - if let Some((id, name)) = idb::normalize_participant(raw) { - participants.insert(id, name); - } - } - - log::info!( - "[gmessages][idb] walk messages={} conversations={} participants={}", - messages.len(), - conversations.len(), - participants.len() - ); - Ok(WalkResult { - messages, - conversations, - participants, - }) -} - -async fn read_store(cdp: &mut CdpConn, session: &str, store: &str) -> Result, String> { - let mut out: Vec = Vec::new(); - let mut skip: i64 = 0; - loop { - let remaining = MAX_RECORDS_PER_STORE.saturating_sub(out.len()); - if remaining == 0 { - break; - } - let page = (remaining as i64).min(PAGE_SIZE); - let resp = cdp - .call( - "IndexedDB.requestData", - json!({ - "securityOrigin": ORIGIN, - "databaseName": DATABASE_NAME, - "objectStoreName": store, - "indexName": "", - "skipCount": skip, - "pageSize": page, - }), - Some(session), - ) - .await?; - let entries = resp - .get("objectStoreDataEntries") - .and_then(|x| x.as_array()) - .cloned() - .unwrap_or_default(); - if entries.is_empty() { - break; - } - let value_refs: Vec<&Value> = entries - .iter() - .map(|e| e.get("value").unwrap_or(&Value::Null)) - .collect(); - let materialised = serialize_values(cdp, session, &value_refs).await?; - out.extend(materialised); - - let has_more = resp - .get("hasMore") - .and_then(|x| x.as_bool()) - .unwrap_or(false); - skip += entries.len() as i64; - if !has_more { - break; - } - } - log::debug!("[gmessages][idb] store={} records={}", store, out.len()); - Ok(out) -} - -async fn serialize_values( - cdp: &mut CdpConn, - session: &str, - values: &[&Value], -) -> Result, String> { - let mut result: Vec = vec![Value::Null; values.len()]; - let mut pending: Vec<(usize, String)> = Vec::new(); - for (i, v) in values.iter().enumerate() { - if let Some(inline) = v.get("value") { - result[i] = inline.clone(); - continue; - } - if let Some(oid) = v.get("objectId").and_then(|x| x.as_str()) { - pending.push((i, oid.to_string())); - } - } - for chunk in pending.chunks(SERIALIZE_BATCH) { - let oids: Vec<&str> = chunk.iter().map(|(_, oid)| oid.as_str()).collect(); - let serialised = call_function_batch(cdp, session, &oids).await?; - if serialised.len() != chunk.len() { - return Err(format!( - "serialise batch length mismatch: got {}, expected {}", - serialised.len(), - chunk.len() - )); - } - for ((idx, _), val) in chunk.iter().zip(serialised.into_iter()) { - result[*idx] = val; - } - } - Ok(result) -} - -async fn call_function_batch( - cdp: &mut CdpConn, - session: &str, - object_ids: &[&str], -) -> Result, String> { - if object_ids.is_empty() { - return Ok(Vec::new()); - } - let (first, rest) = object_ids.split_first().unwrap(); - let args: Vec = rest.iter().map(|oid| json!({ "objectId": oid })).collect(); - let resp = cdp - .call( - "Runtime.callFunctionOn", - json!({ - "objectId": first, - "functionDeclaration": "function(){return [this].concat(Array.prototype.slice.call(arguments));}", - "arguments": args, - "returnByValue": true, - "silent": true, - }), - Some(session), - ) - .await?; - if let Some(exc) = resp.get("exceptionDetails") { - return Err(format!("callFunctionOn threw: {exc}")); - } - resp.pointer("/result/value") - .and_then(|v| v.as_array()) - .cloned() - .ok_or_else(|| format!("callFunctionOn result not array: {resp}")) -} diff --git a/app/src-tauri/src/gmessages_scanner/idb.rs b/app/src-tauri/src/gmessages_scanner/idb.rs deleted file mode 100644 index c7755394c5..0000000000 --- a/app/src-tauri/src/gmessages_scanner/idb.rs +++ /dev/null @@ -1,313 +0,0 @@ -//! Google Messages Web `bugle_db` IndexedDB schema + normalization. -//! -//! Schema knowledge is taken from publicly documented reverse-engineering -//! of the Google Messages Web client (the `mautrix-gmessages` project and -//! the Google Messages Web source itself). No code is copied — -//! only the factual store / key shape, which is not copyrightable. -//! -//! Stores we care about: -//! * `conversations` — thread metadata (id, participant ids, name) -//! * `messages` — individual SMS/RCS rows -//! * `participants` — participant id → contact name resolution -//! -//! Stores we deliberately skip: -//! * `settings`, `drafts`, `attachments-cache` — not needed for recall. -//! -//! This module only holds schema + normalization. The CDP walk that -//! actually calls `IndexedDB.requestData` will live alongside the WhatsApp -//! scanner's CDP plumbing once we lift a shared `cdp` module — see the -//! TODO in `mod.rs`. - -use std::collections::HashMap; - -use serde_json::Value; - -/// `bugle_db` database name. Stable since ~2022 per mautrix-gmessages -/// history; Google has not shipped a schema rename in the tracked window. -pub const DATABASE_NAME: &str = "bugle_db"; -pub const STORE_CONVERSATIONS: &str = "conversations"; -pub const STORE_MESSAGES: &str = "messages"; -pub const STORE_PARTICIPANTS: &str = "participants"; - -/// Normalized message row emitted to the memory-doc pipeline. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct Message { - pub id: String, - pub thread_id: Option, - /// `None` when the message is outbound (sent by the user). - pub sender_id: Option, - pub from_me: bool, - /// Plain UTF-8 body. Attachments / reactions collapse to empty string - /// at normalization — callers render them as `[non-text]`. - pub text: String, - pub timestamp_unix: i64, - /// "sms", "rcs", "mms", etc. Preserved for downstream filters. - pub message_type: Option, -} - -/// Normalized conversation (thread) metadata. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct Conversation { - pub thread_id: String, - pub display_name: Option, - pub participant_ids: Vec, -} - -/// Participant-id → display-name map. Populated from the `participants` -/// store; used by `format_transcript` to render human-readable senders. -#[derive(Debug, Default, Clone)] -pub struct ParticipantMap { - inner: HashMap, -} - -impl ParticipantMap { - pub fn insert(&mut self, id: String, name: String) { - self.inner.insert(id, name); - } - - pub fn display_name(&self, id: &str) -> Option { - self.inner.get(id).cloned() - } - - pub fn len(&self) -> usize { - self.inner.len() - } - - pub fn is_empty(&self) -> bool { - self.inner.is_empty() - } -} - -/// Convert a raw JSON row from the `messages` object store into our -/// normalized shape. Returns `None` if required fields are missing or -/// malformed — we log + skip rather than failing the entire walk. -/// -/// Expected bugle_db fields (observed, documented in mautrix-gmessages): -/// * `messageId` (string) — primary key -/// * `conversationId` (string) -/// * `senderId` (string, absent for outgoing) -/// * `messageStatus` (object with `status` int; outgoing statuses 2/4/6) -/// * `text` (string; may be absent for attachment-only) -/// * `timestamp` (int; microseconds since unix epoch) -/// * `messageType` (string: "SMS", "RCS", etc.) -pub fn normalize_message(raw: &Value) -> Option { - let id = raw.get("messageId")?.as_str()?.to_string(); - let thread_id = raw - .get("conversationId") - .and_then(|v| v.as_str()) - .map(str::to_string); - let sender_id = raw - .get("senderId") - .and_then(|v| v.as_str()) - .map(str::to_string); - let from_me = is_outgoing(raw); - let text = raw - .get("text") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - // bugle_db timestamps are microseconds since unix epoch. Guard against - // the legacy-seconds form (< 10^12 = before year 33700 in micros, - // practically any real timestamp is in the 10^15 range). - let timestamp_unix = raw.get("timestamp").and_then(|v| v.as_i64()).map(|t| { - if t > 1_000_000_000_000 { - t / 1_000_000 - } else { - t - } - })?; - let message_type = raw - .get("messageType") - .and_then(|v| v.as_str()) - .map(|s| s.to_ascii_lowercase()); - - Some(Message { - id, - thread_id, - sender_id: if from_me { None } else { sender_id }, - from_me, - text, - timestamp_unix, - message_type, - }) -} - -/// Heuristic: bugle_db marks outgoing messages with a `messageStatus` -/// object whose `status` is in {2 (OUTGOING_DELIVERED), 4 (OUTGOING_READ), -/// 6 (OUTGOING_FAILED)} or an explicit boolean `isOutgoing` on newer -/// schemas. Fall back to `senderId == null` which is also a reliable -/// signal on older writes. -fn is_outgoing(raw: &Value) -> bool { - if let Some(b) = raw.get("isOutgoing").and_then(|v| v.as_bool()) { - return b; - } - if let Some(status) = raw - .get("messageStatus") - .and_then(|s| s.get("status")) - .and_then(|v| v.as_i64()) - { - // Status codes 1-9 are outgoing; 10+ are incoming (OUTGOING_* vs - // INCOMING_* in the bugle_db protobuf enum). Exact values per - // mautrix-gmessages' `libgm/events/types.go`. - return (1..=9).contains(&status); - } - raw.get("senderId") - .map(|v| v.is_null() || v.as_str().is_some_and(str::is_empty)) - .unwrap_or(false) -} - -/// Normalize a `conversations` store row. -pub fn normalize_conversation(raw: &Value) -> Option { - let thread_id = raw.get("conversationId")?.as_str()?.to_string(); - let display_name = raw.get("name").and_then(|v| v.as_str()).map(str::to_string); - let participant_ids = raw - .get("participantIds") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(str::to_string)) - .collect() - }) - .unwrap_or_default(); - Some(Conversation { - thread_id, - display_name, - participant_ids, - }) -} - -/// Normalize a `participants` store row into `(id, name)`. -pub fn normalize_participant(raw: &Value) -> Option<(String, String)> { - let id = raw.get("participantId")?.as_str()?.to_string(); - let name = raw - .get("fullName") - .or_else(|| raw.get("firstName")) - .or_else(|| raw.get("displayName")) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - if name.is_empty() { - None - } else { - Some((id, name)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn normalize_incoming_sms_row() { - let raw = json!({ - "messageId": "msg-1", - "conversationId": "thread-1", - "senderId": "+15551234567", - "text": "hello", - "timestamp": 1_700_000_000_000_000i64, - "messageType": "SMS", - "messageStatus": { "status": 100 }, - }); - let m = normalize_message(&raw).expect("normalize ok"); - assert_eq!(m.id, "msg-1"); - assert_eq!(m.thread_id.as_deref(), Some("thread-1")); - assert_eq!(m.sender_id.as_deref(), Some("+15551234567")); - assert!(!m.from_me); - assert_eq!(m.text, "hello"); - assert_eq!(m.timestamp_unix, 1_700_000_000); - assert_eq!(m.message_type.as_deref(), Some("sms")); - } - - #[test] - fn normalize_outgoing_row_sets_from_me_and_blanks_sender() { - let raw = json!({ - "messageId": "msg-2", - "conversationId": "thread-1", - "senderId": "+15559998888", - "text": "yo", - "timestamp": 1_700_000_005_000_000i64, - "messageStatus": { "status": 4 }, - }); - let m = normalize_message(&raw).expect("normalize ok"); - assert!(m.from_me, "status=4 is OUTGOING_READ"); - assert!(m.sender_id.is_none(), "outgoing rows blank the sender"); - } - - #[test] - fn normalize_accepts_legacy_second_precision_timestamp() { - let raw = json!({ - "messageId": "msg-3", - "conversationId": "thread-1", - "senderId": "+15551234567", - "text": "hi", - "timestamp": 1_700_000_000i64, - "messageStatus": { "status": 100 }, - }); - let m = normalize_message(&raw).expect("normalize ok"); - assert_eq!(m.timestamp_unix, 1_700_000_000); - } - - #[test] - fn normalize_skips_row_missing_required_fields() { - let raw = json!({ - "conversationId": "thread-1", - "text": "no id", - "timestamp": 1_700_000_000_000_000i64, - }); - assert!(normalize_message(&raw).is_none()); - } - - #[test] - fn normalize_conversation_row_with_participants() { - let raw = json!({ - "conversationId": "thread-1", - "name": "Family Group", - "participantIds": ["+15551234567", "+15559998888"], - }); - let c = normalize_conversation(&raw).expect("normalize ok"); - assert_eq!(c.thread_id, "thread-1"); - assert_eq!(c.display_name.as_deref(), Some("Family Group")); - assert_eq!(c.participant_ids.len(), 2); - } - - #[test] - fn normalize_participant_row_prefers_full_name() { - let raw = json!({ - "participantId": "+15551234567", - "fullName": "Alice Example", - "firstName": "Alice", - }); - let (id, name) = normalize_participant(&raw).expect("normalize ok"); - assert_eq!(id, "+15551234567"); - assert_eq!(name, "Alice Example"); - } - - #[test] - fn normalize_participant_falls_back_to_first_name() { - let raw = json!({ - "participantId": "+15551234567", - "firstName": "Alice", - }); - let (_, name) = normalize_participant(&raw).expect("normalize ok"); - assert_eq!(name, "Alice"); - } - - #[test] - fn normalize_participant_returns_none_for_empty_name() { - let raw = json!({ - "participantId": "+15551234567", - }); - assert!(normalize_participant(&raw).is_none()); - } - - #[test] - fn participant_map_roundtrip() { - let mut pm = ParticipantMap::default(); - assert!(pm.is_empty()); - pm.insert("+15551234567".into(), "Alice".into()); - assert_eq!(pm.len(), 1); - assert_eq!(pm.display_name("+15551234567").as_deref(), Some("Alice")); - assert!(pm.display_name("unknown").is_none()); - } -} diff --git a/app/src-tauri/src/gmessages_scanner/mod.rs b/app/src-tauri/src/gmessages_scanner/mod.rs deleted file mode 100644 index 4ddf5d008d..0000000000 --- a/app/src-tauri/src/gmessages_scanner/mod.rs +++ /dev/null @@ -1,239 +0,0 @@ -//! Google Messages Web scanner — Windows-focused, read-only IndexedDB walk. -//! -//! Scope for Stage 1: -//! * Read-only scan of `bugle_db` (the IndexedDB database used by -//! `messages.google.com/web`) via CDP on the embedded CEF webview. -//! * One ingest call per `(thread_id, day)` group — same -//! `openhuman.memory_doc_ingest` shape the iMessage and WhatsApp -//! scanners already use. -//! * No DOM automation. No send path. Send is deferred to a separate -//! PR that will use OS Accessibility APIs (macOS AX / Windows UIA) — -//! indistinguishable from a screen reader, so ToS-clean. -//! -//! Targeted at Windows + Android (the only practical combo for Google -//! Messages — iPhone owners use iMessage, mac users typically use -//! Messages Web in a browser tab that the CEF shell doesn't own). The -//! code is windows-gated at module-scope; on other targets the public -//! surface compiles to no-op stubs so `lib.rs` stays clean. -//! -//! History model differs from iMessage: -//! * iMessage (#724) reads `chat.db` which holds FULL history locally. -//! * Google Messages Web only caches in `bugle_db` what the web client -//! has already synced. If the user never scrolled to older -//! conversations, those pages aren't in IDB. Document this behavior -//! in the UI — "scroll to backfill older history." -//! -//! CDP wiring TODO: -//! * Reuse the shared in-process CDP transport (`crate::cdp::conn_for_account` / -//! `crate::cdp::connect_and_attach_matching_in_process`) the way the -//! whatsapp, telegram, slack, discord, wechat, and meet scanners do. -//! When this module is promoted from scaffold to running scanner, -//! point it at the Google Messages Web target -//! (`messages.google.com/web`). Until then `run_scanner` is a -//! stub that logs and exits — the PR ships the normalization + -//! memory-doc shape so downstream can iterate without the full CDP -//! loop landed. - -// Scaffold PR — orchestrator loop is a stub pending the shared CDP lift -// from `whatsapp_scanner`. Once that lands and this module actually -// drives `idb::walk` + `memory_doc_ingest`, drop the blanket allow below. -#![allow(dead_code)] - -#[cfg(target_os = "windows")] -use std::sync::Arc; -#[cfg(target_os = "windows")] -use std::time::Duration; - -#[cfg(target_os = "windows")] -use parking_lot::Mutex; -#[cfg(target_os = "windows")] -use tauri::{AppHandle, Runtime}; - -pub mod idb; - -#[cfg(target_os = "windows")] -const SCAN_INTERVAL: Duration = Duration::from_secs(60); - -/// Per-account scanner registry. Google Messages Web supports one paired -/// phone per browser session; the registry shape is kept symmetric with -/// the iMessage / WhatsApp scanners for future multi-account expansion. -#[cfg(target_os = "windows")] -pub struct ScannerRegistry { - inner: Mutex>>, -} - -#[cfg(target_os = "windows")] -impl ScannerRegistry { - pub fn new() -> Self { - Self { - inner: Mutex::new(None), - } - } - - pub fn ensure_scanner(self: Arc, app: AppHandle, account_id: String) { - let mut guard = self.inner.lock(); - if guard.as_ref().map_or(false, |h| !h.is_finished()) { - return; - } - let handle = tokio::spawn(run_scanner(app, account_id)); - *guard = Some(handle); - } -} - -/// Stub loop — logs and exits. Wire CDP target discovery + `idb::walk` -/// here once the shared `cdp` module is lifted from `whatsapp_scanner`. -/// See module-level TODO. -#[cfg(target_os = "windows")] -async fn run_scanner(_app: AppHandle, account_id: String) { - log::info!( - "[gmessages] scanner scaffold loaded account={} interval={:?} — CDP wiring pending", - account_id, - SCAN_INTERVAL - ); -} - -// Non-Windows stub so the rest of the app compiles unchanged on mac/linux. -#[cfg(not(target_os = "windows"))] -pub struct ScannerRegistry; - -#[cfg(not(target_os = "windows"))] -impl ScannerRegistry { - pub fn new() -> Self { - Self - } - pub fn ensure_scanner( - self: std::sync::Arc, - _app: tauri::AppHandle, - _account_id: String, - ) { - } -} - -/// Format a list of normalized messages into a transcript string suitable -/// for `memory_doc_ingest.content`. Matches the iMessage scanner output -/// shape so Neocortex sees a uniform format across channels. -pub fn format_transcript(messages: &[idb::Message], participants: &idb::ParticipantMap) -> String { - let mut out = String::new(); - for m in messages { - let sender = if m.from_me { - "me".to_string() - } else { - m.sender_id - .as_deref() - .and_then(|sid| participants.display_name(sid)) - .unwrap_or_else(|| m.sender_id.clone().unwrap_or_else(|| "unknown".into())) - }; - let text = m.text.replace('\n', " "); - let body = if text.is_empty() { - "[non-text]".to_string() - } else { - text - }; - out.push_str(&format!("[{}] {}: {}\n", m.timestamp_unix, sender, body)); - } - out -} - -/// Group a flat list of messages into `(thread_id, YYYY-MM-DD) -> Vec`. -/// Day bucketing uses the local timezone — users inspect memory docs by -/// their calendar day, not UTC (same policy as iMessage #724 after the -/// CodeRabbit local-TZ fix). -pub fn group_by_thread_day( - messages: Vec, -) -> Vec<((String, String), Vec)> { - use std::collections::BTreeMap; - let mut groups: BTreeMap<(String, String), Vec> = BTreeMap::new(); - for m in messages { - let Some(thread_id) = m.thread_id.clone() else { - continue; - }; - let day = seconds_to_ymd(m.timestamp_unix); - groups.entry((thread_id, day)).or_default().push(m); - } - groups.into_iter().collect() -} - -/// Local-timezone day bucket for a unix-second timestamp. Returns -/// "YYYY-MM-DD" or "unknown" for values that fall outside chrono's range. -pub fn seconds_to_ymd(secs: i64) -> String { - use chrono::{Local, TimeZone}; - Local - .timestamp_opt(secs, 0) - .single() - .map(|dt| dt.format("%Y-%m-%d").to_string()) - .unwrap_or_else(|| "unknown".into()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn msg(id: &str, thread: &str, ts: i64, text: &str, from_me: bool) -> idb::Message { - idb::Message { - id: id.into(), - thread_id: Some(thread.into()), - sender_id: if from_me { - None - } else { - Some("+15551234567".into()) - }, - from_me, - text: text.into(), - timestamp_unix: ts, - message_type: Some("sms".into()), - } - } - - #[test] - fn group_by_thread_day_buckets_messages_correctly() { - // Two messages ~5s apart in the same thread should fall into one - // group; a third in a different thread into its own group. - let base = 1_700_000_000; - let msgs = vec![ - msg("1", "t1", base, "hi", false), - msg("2", "t1", base + 5, "yo", true), - msg("3", "t2", base, "other", false), - ]; - let groups = group_by_thread_day(msgs); - assert_eq!(groups.len(), 2); - let t1 = groups.iter().find(|((t, _), _)| t == "t1").unwrap(); - assert_eq!(t1.1.len(), 2); - } - - #[test] - fn format_transcript_includes_sender_and_body() { - let msgs = vec![ - msg("1", "t1", 1_700_000_000, "hi", false), - msg("2", "t1", 1_700_000_005, "yo", true), - ]; - let participants = idb::ParticipantMap::default(); - let t = format_transcript(&msgs, &participants); - assert!(t.contains("hi")); - assert!(t.contains("me: yo")); - assert!(t.contains("+15551234567: hi")); - } - - #[test] - fn format_transcript_resolves_display_name_from_participants() { - let mut participants = idb::ParticipantMap::default(); - participants.insert("+15551234567".into(), "Alice".into()); - let msgs = vec![msg("1", "t1", 1_700_000_000, "hi", false)]; - let t = format_transcript(&msgs, &participants); - assert!(t.contains("Alice: hi"), "got {:?}", t); - } - - #[test] - fn format_transcript_marks_empty_body_as_non_text() { - let msgs = vec![msg("1", "t1", 1_700_000_000, "", false)]; - let t = format_transcript(&msgs, &idb::ParticipantMap::default()); - assert!(t.contains("[non-text]"), "got {:?}", t); - } - - #[test] - fn seconds_to_ymd_shape() { - let out = seconds_to_ymd(1_700_000_000); - assert_eq!(out.len(), 10); - assert_eq!(&out[4..5], "-"); - assert_eq!(&out[7..8], "-"); - } -} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 7e34021c25..f0cf39e632 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -51,11 +51,9 @@ mod deep_link_ipc_windows; // developer host covers them. mod deep_link_registration_check; mod dictation_hotkeys; -mod discord_scanner; #[cfg(any(test, feature = "e2e-test-support"))] mod fake_camera; mod file_logging; -mod gmessages_scanner; mod imessage_scanner; mod local_data_reset; mod loopback_oauth; @@ -76,14 +74,9 @@ mod ptt_hotkeys; mod ptt_overlay; #[cfg(target_os = "windows")] mod reset_reboot_schedule; -mod slack_scanner; mod stderr_panic_hook; -mod telegram_scanner; -mod webview_accounts; mod webview_apis; -mod wechat_scanner; mod whatsapp_data; -mod whatsapp_scanner; mod window_state; mod workspace_paths; @@ -1647,153 +1640,23 @@ fn setup_tray(app: &AppHandle) -> tauri::Result<()> { Ok(()) } -const CEF_PREWARM_LABEL: &str = "cef-prewarm"; - -/// Decide whether to spawn the CEF cold-start prewarm webview. -/// -/// Testable pure function — callers pass the relevant env values directly. -/// -/// Decision matrix: -/// - `env_override` = `Some("0"|"false"|"no"|"off")` → disabled (explicit) -/// - `env_override` = `Some()` → enabled (explicit opt-in; -/// overrides even the Wayland guard so ops can re-enable if CEF subprocess -/// X handling improves) -/// - `env_override` = `None` (env var unset, default path): -/// - `wayland_display_set` = `true` → **disabled** — auto-guard against the -/// fatal `X_ConfigureWindow BadWindow` crash that fires in CEF render -/// subprocesses on Wayland/XWayland sessions (issue #2463). The main-process -/// silent X error handler (`install_silent_x_error_handler`) does not reach -/// CEF subprocesses; until subprocess-level coverage is available, skipping -/// the prewarm child webview is the safest mitigation. -/// - `wayland_display_set` = `false` → enabled -fn cef_prewarm_enabled(env_override: Option<&str>, wayland_display_set: bool) -> bool { - if let Some(v) = env_override { - let v = v.trim().to_ascii_lowercase(); - return !(v == "0" || v == "false" || v == "no" || v == "off"); - } - !wayland_display_set -} - -/// Spawn a hidden 1×1 child webview at `about:blank` on the main window so -/// CEF's child-webview render path is hot before the user clicks an -/// account. The first `webview_account_open` then skips the cold -/// renderer-process spinup. Idempotent — bails if the prewarm webview -/// already exists. -fn spawn_cef_prewarm(app: &AppHandle) -> Result<(), String> { - use tauri::webview::WebviewBuilder; - use tauri::WebviewUrl; - - if app.get_webview(CEF_PREWARM_LABEL).is_some() { - return Ok(()); - } - let parent = app - .get_window("main") - .ok_or_else(|| "main window not found".to_string())?; - let url: tauri::Url = "about:blank" - .parse() - .map_err(|e| format!("about:blank parse: {e}"))?; - let builder = WebviewBuilder::new(CEF_PREWARM_LABEL, WebviewUrl::External(url)); - parent - .add_child( - builder, - tauri::LogicalPosition::new(-20000.0, -20000.0), - tauri::LogicalSize::new(1.0, 1.0), - ) - .map_err(|e| format!("add_child failed: {e}"))?; - log::info!("[cef-prewarm] hidden warmup webview spawned"); - Ok(()) -} - -/// Drop the prewarm webview if still alive. Called from `RunEvent::Exit` -/// so its CEF browser is torn down before `cef::shutdown()` runs. -fn teardown_cef_prewarm(app: &AppHandle) -> Result<(), String> { - let Some(wv) = app.get_webview(CEF_PREWARM_LABEL) else { - return Err("no prewarm webview".into()); - }; - wv.close().map_err(|e| e.to_string())?; - log::info!("[cef-prewarm] teardown ok"); - Ok(()) -} - -const CEF_CLOSE_FIXED_YIELD: std::time::Duration = std::time::Duration::from_millis(20); -const CEF_CLOSE_POLL_BUDGET: std::time::Duration = std::time::Duration::from_millis(300); -const CEF_CLOSE_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(20); - -fn close_early_cef_webviews(app: &AppHandle) -> Vec { - let mut closed_labels = Vec::new(); - if teardown_cef_prewarm(app).is_ok() { - closed_labels.push(CEF_PREWARM_LABEL.to_string()); - } - if let Some(state) = app.try_state::() { - closed_labels.extend(state.shutdown_all(app)); - } - closed_labels -} - fn shutdown_imessage_scanner(app: &AppHandle) { if let Some(registry) = app.try_state::>() { registry.inner().shutdown(); } } -fn pending_cef_webview_labels( - app: &AppHandle, - labels: &[String], -) -> Vec { - let mut seen = std::collections::HashSet::new(); - labels - .iter() - .filter(|label| seen.insert((*label).clone())) - .filter(|label| app.get_webview(label.as_str()).is_some()) - .cloned() - .collect() -} - -async fn wait_for_cef_webviews_to_close_async( - app: &AppHandle, - labels: &[String], -) { - if labels.is_empty() { - return; - } - log::info!( - "[app] waiting for CEF webview close requests labels={:?}", - labels - ); - tokio::time::sleep(CEF_CLOSE_FIXED_YIELD).await; - let start = std::time::Instant::now(); - let mut pending = pending_cef_webview_labels(app, labels); - while !pending.is_empty() && start.elapsed() < CEF_CLOSE_POLL_BUDGET { - tokio::time::sleep(CEF_CLOSE_POLL_INTERVAL).await; - pending = pending_cef_webview_labels(app, labels); - } - if pending.is_empty() { - log::info!( - "[app] CEF webview close poll drained labels={:?} elapsed_ms={}", - labels, - start.elapsed().as_millis() - ); - } else { - log::info!( - "[app] CEF webview close poll still pending labels={:?} elapsed_ms={} (will continue in runtime shutdown)", - pending, - start.elapsed().as_millis() - ); - } -} - -/// Shared early teardown logic before CEF's shutdown to prevent races and zombie processes. +/// Shared early teardown logic run before the runtime shuts down, to prevent +/// races and zombie processes. /// /// Synchronous entry used from `RunEvent::ExitRequested` and tray quit. We intentionally -/// **do not** poll here with `std::thread::sleep` — that would block the Tauri / CEF main -/// event loop and prevent close messages from being processed. Close requests are issued -/// in [`close_early_cef_webviews`]; the exit pump drains them. Use -/// [`perform_early_teardown_async`] when an async caller can await -/// [`wait_for_cef_webviews_to_close_async`] without starving the UI loop. +/// **do not** poll here with `std::thread::sleep` — that would block the Tauri main +/// event loop and prevent close messages from being processed. Use +/// [`perform_early_teardown_async`] when an async caller can await without +/// starving the UI loop. fn perform_early_teardown_sync(app_handle: &AppHandle) { log::info!("[app] perform_early_teardown_sync — early teardown"); - let closed_labels = close_early_cef_webviews(app_handle); shutdown_imessage_scanner(app_handle); webview_apis::server::stop(); @@ -1807,13 +1670,6 @@ fn perform_early_teardown_sync(app_handle: &AppHandle) { }); } - if !closed_labels.is_empty() { - log::info!( - "[app] sync early teardown: close requested for labels={:?} — skipping main-thread poll so the event loop can drain CEF", - closed_labels - ); - } - log::info!("[app] perform_early_teardown_sync — early teardown complete"); } @@ -1829,12 +1685,12 @@ fn perform_early_teardown_sync_once(app_handle: &AppHandle, reason: perform_early_teardown_sync(app_handle); } -/// Shared early teardown logic before CEF's shutdown to prevent races and zombie processes. +/// Shared early teardown logic run before the runtime shuts down, to prevent +/// races and zombie processes. /// Asynchronous version to be called from async Tauri commands (e.g. `restart_app`, updates). async fn perform_early_teardown_async(app_handle: &AppHandle) { log::info!("[app] perform_early_teardown_async — early teardown"); - let closed_labels = close_early_cef_webviews(app_handle); shutdown_imessage_scanner(app_handle); webview_apis::server::stop(); @@ -1844,8 +1700,6 @@ async fn perform_early_teardown_async(app_handle: &AppHandle) { core.send_terminate_signal().await; } - wait_for_cef_webviews_to_close_async(app_handle, &closed_labels).await; - log::info!("[app] perform_early_teardown_async — early teardown complete"); } @@ -2049,8 +1903,7 @@ fn linux_is_root_uid(uid: u32) -> bool { /// driver passthrough, Fedora, etc.) can opt back into hardware /// acceleration by setting `OPENHUMAN_FORCE_GPU=1`. /// -/// Uses the same recognized truthy tokens as [`cef_prewarm_enabled`], but -/// this control is explicit opt-in: `1` / `true` / `yes` / `on` +/// This control is explicit opt-in: `1` / `true` / `yes` / `on` /// (case-insensitive) enables the override, and anything else (including /// unset) preserves the default disable. #[cfg(any(test, feature = "e2e-test-support"))] @@ -3050,14 +2903,11 @@ pub fn run() { // Explicitly disable `open_js_links_on_click`: tauri-plugin-opener // defaults to injecting `init-iife.js` into *every* webview — a // global click listener that invokes `plugin:opener|open_url` via - // HTTP-IPC. That violates our "no JS injection into CEF child + // HTTP-IPC. That violates our "no JS injection into child // webviews" rule (see CLAUDE.md) and also fails in practice - // because third-party origins (web.telegram.org, linkedin, …) - // trip Tauri's Origin header check and return 500. External link - // handling for `acct_*` webviews runs natively via - // `on_navigation` / `on_new_window` in webview_accounts/mod.rs; - // the main window uses `openUrl()` from `utils/openUrl.ts` when - // it needs to hand off a URL. + // because third-party origins trip Tauri's Origin header check + // and return 500. The main window uses `openUrl()` from + // `utils/openUrl.ts` when it needs to hand off a URL. .plugin( tauri_plugin_opener::Builder::default() .open_js_links_on_click(false) @@ -3078,19 +2928,10 @@ pub fn run() { .manage(companion_commands::CompanionHotkeyState( std::sync::Mutex::new(Vec::new()), )) - .manage(webview_accounts::WebviewAccountsState::default()) .manage(cdp::CdpRegistry::default()) .manage(notification_settings::NotificationSettingsState::new()) .manage(PendingAppUpdateState::default()); let builder = builder.manage(std::sync::Arc::new(imessage_scanner::ScannerRegistry::new())); - let builder = builder.manage(std::sync::Arc::new( - gmessages_scanner::ScannerRegistry::new(), - )); - let builder = builder.manage(whatsapp_scanner::ScannerRegistry::new()); - let builder = builder.manage(slack_scanner::ScannerRegistry::new()); - let builder = builder.manage(discord_scanner::ScannerRegistry::new()); - let builder = builder.manage(telegram_scanner::ScannerRegistry::new()); - let builder = builder.manage(wechat_scanner::ScannerRegistry::new()); let builder = builder.manage(meet_call::MeetCallState::new()); let builder = builder.manage(meet_audio::MeetAudioState::new()); let builder = builder.manage(meet_video::frame_bus::MeetVideoFrameBusState::new()); @@ -3476,238 +3317,6 @@ pub fn run() { // Linux with "GTK has not been initialized". log::info!("[tray] deferring tray setup to RunEvent::Ready"); - // CEF cold-start warmup. Spawns a 1×1 hidden child webview on - // the main window at `about:blank` so CEF's render-process / - // compositor for child webviews is hot before the user clicks - // an account — first cold open of a real provider drops from - // "spin up renderer + navigate" to just "navigate". - // - // Earlier builds had this disabled because of a "blank webview - // on first onboarding open" report; we now park the warmup at - // a far off-screen position and never reveal it (matching the - // 1×1-on-screen pattern used for cold account spawns), and - // tear it down in the shutdown sequence below. Disable at - // runtime with `OPENHUMAN_CEF_PREWARM=0` if it regresses. - { - #[cfg(target_os = "linux")] - let wayland_display_set = has_non_empty_env("WAYLAND_DISPLAY"); - #[cfg(not(target_os = "linux"))] - let wayland_display_set = false; - let env_override = std::env::var("OPENHUMAN_CEF_PREWARM").ok(); - if cef_prewarm_enabled(env_override.as_deref(), wayland_display_set) { - let app_handle = app.handle().clone(); - tauri::async_runtime::spawn(async move { - // Defer one tick so the main window finishes its - // first paint before we attach a sibling webview. - tokio::time::sleep(std::time::Duration::from_millis(300)).await; - if let Err(e) = spawn_cef_prewarm(&app_handle) { - log::warn!("[cef-prewarm] failed (non-fatal): {e}"); - } - }); - } else if wayland_display_set && env_override.is_none() { - log::info!( - "[cef-prewarm] auto-disabled: WAYLAND_DISPLAY is set (Wayland/XWayland \ - session) — prevents X_ConfigureWindow BadWindow crash in CEF \ - subprocesses (issue #2463); set OPENHUMAN_CEF_PREWARM=1 to override" - ); - } else { - log::info!("[cef-prewarm] disabled via OPENHUMAN_CEF_PREWARM"); - } - } - - // Dev convenience: if OPENHUMAN_DEV_AUTO_WHATSAPP= - // is set, spawn that account's webview at startup so the - // CDP/IndexedDB scanner can iterate without manual UI clicks. - // The same account-id reuses the persistent data dir, so a - // previously-logged-in WhatsApp session stays logged in. - if let Ok(account_id) = std::env::var("OPENHUMAN_DEV_AUTO_WHATSAPP") { - let account_id = account_id.trim().to_string(); - if !account_id.is_empty() { - let app_handle = app.handle().clone(); - tauri::async_runtime::spawn(async move { - // Wait for the window to be fully ready. - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - let state = app_handle.state::(); - let args = webview_accounts::OpenArgs { - account_id: account_id.clone(), - provider: "whatsapp".to_string(), - url: None, - bounds: Some(webview_accounts::Bounds { - x: 100.0, - y: 100.0, - width: 900.0, - height: 700.0, - }), - prewarm: false, - }; - match webview_accounts::webview_account_open( - app_handle.clone(), - state, - args, - ) - .await - { - Ok(label) => log::info!( - "[dev-auto-whatsapp] spawned label={} account={}", - label, - account_id - ), - Err(e) => log::error!( - "[dev-auto-whatsapp] failed: {} (account={})", - e, - account_id - ), - } - }); - } - } - - // Same dev helper, Slack flavour. OPENHUMAN_DEV_AUTO_SLACK= - // opens the Slack account webview on startup so the CDP scanner - // can iterate without manual UI clicks. - if let Ok(account_id) = std::env::var("OPENHUMAN_DEV_AUTO_SLACK") { - let account_id = account_id.trim().to_string(); - if !account_id.is_empty() { - let app_handle = app.handle().clone(); - tauri::async_runtime::spawn(async move { - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - let state = app_handle.state::(); - let args = webview_accounts::OpenArgs { - account_id: account_id.clone(), - provider: "slack".to_string(), - url: None, - bounds: Some(webview_accounts::Bounds { - x: 100.0, - y: 100.0, - width: 900.0, - height: 700.0, - }), - prewarm: false, - }; - match webview_accounts::webview_account_open( - app_handle.clone(), - state, - args, - ) - .await - { - Ok(label) => log::info!( - "[dev-auto-slack] spawned label={} account={}", - label, - account_id - ), - Err(e) => log::error!( - "[dev-auto-slack] failed: {} (account={})", - e, - account_id - ), - } - }); - } - } - - // Same dev helper, Telegram flavour. OPENHUMAN_DEV_AUTO_TELEGRAM= - // opens the Telegram Web K account webview on startup so the CDP - // scanner can iterate without manual UI clicks. - if let Ok(account_id) = std::env::var("OPENHUMAN_DEV_AUTO_TELEGRAM") { - let account_id = account_id.trim().to_string(); - if !account_id.is_empty() { - let app_handle = app.handle().clone(); - tauri::async_runtime::spawn(async move { - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - let state = app_handle.state::(); - let args = webview_accounts::OpenArgs { - account_id: account_id.clone(), - provider: "telegram".to_string(), - url: None, - bounds: Some(webview_accounts::Bounds { - x: 100.0, - y: 100.0, - width: 900.0, - height: 700.0, - }), - prewarm: false, - }; - match webview_accounts::webview_account_open( - app_handle.clone(), - state, - args, - ) - .await - { - Ok(label) => log::info!( - "[dev-auto-telegram] spawned label={} account={}", - label, - account_id - ), - Err(e) => log::error!( - "[dev-auto-telegram] failed: {} (account={})", - e, - account_id - ), - } - }); - } - } - // Same dev helper, Google Meet flavour. - // OPENHUMAN_DEV_AUTO_GOOGLE_MEET= opens the gmeet account - // webview at startup so the caption-capture recipe runs - // without manual UI clicks. Use in combination with: - // tail -F /tmp/oh-cef.log | grep -E --line-buffered \ - // "\[gmeet\]|memory_doc_ingest|orchestrator" - // to verify captions flow → transcript persist → thread handoff. - if let Ok(account_id) = std::env::var("OPENHUMAN_DEV_AUTO_GOOGLE_MEET") { - let account_id = account_id.trim().to_string(); - if !account_id.is_empty() { - let app_handle = app.handle().clone(); - tauri::async_runtime::spawn(async move { - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - let state = app_handle.state::(); - // Dev mode: size the child webview to the parent - // window's inner bounds so Meet controls (CC toggle, - // mic/cam, leave) are reachable without overflowing. - let (w, h) = app_handle - .get_webview_window("main") - .and_then(|main| { - let scale = main.scale_factor().unwrap_or(1.0); - main.inner_size() - .ok() - .map(|s| ((s.width as f64) / scale, (s.height as f64) / scale)) - }) - .unwrap_or((1100.0, 780.0)); - let args = webview_accounts::OpenArgs { - account_id: account_id.clone(), - provider: "google-meet".to_string(), - url: None, - bounds: Some(webview_accounts::Bounds { - x: 0.0, - y: 0.0, - width: w, - height: h, - }), - prewarm: false, - }; - match webview_accounts::webview_account_open( - app_handle.clone(), - state, - args, - ) - .await - { - Ok(label) => log::info!( - "[dev-auto-gmeet] spawned label={} account={}", - label, - account_id - ), - Err(e) => log::error!( - "[dev-auto-gmeet] failed: {} (account={})", - e, - account_id - ), - } - }); - } - } // Dev helper: OPENHUMAN_DEV_AUTO_MEET_CALL= // auto-spawns a meet-call window at startup so the camera + // audio bridges + frame-bus + producer pipeline can be @@ -3812,21 +3421,6 @@ pub fn run() { register_ptt_hotkey, unregister_ptt_hotkey, ptt_overlay::show_ptt_overlay, - webview_accounts::webview_account_open, - webview_accounts::webview_account_prewarm, - webview_accounts::webview_account_close, - webview_accounts::webview_account_purge, - webview_accounts::webview_account_bounds, - webview_accounts::webview_account_reveal, - webview_accounts::webview_account_hide, - webview_accounts::webview_account_show, - webview_accounts::webview_recipe_event, - webview_accounts::webview_notification_permission_state, - webview_accounts::webview_notification_permission_request, - webview_accounts::webview_notification_set_dnd, - webview_accounts::webview_notification_mute_account, - webview_accounts::webview_notification_get_bypass_prefs, - webview_accounts::webview_set_focused_account, notification_settings::notification_settings_get, notification_settings::notification_settings_set, native_notifications::notification_permission_state, diff --git a/app/src-tauri/src/lib_tests.rs b/app/src-tauri/src/lib_tests.rs index 0b9b1ec0a1..2afa3e1602 100644 --- a/app/src-tauri/src/lib_tests.rs +++ b/app/src-tauri/src/lib_tests.rs @@ -598,60 +598,6 @@ fn platform_arch_is_aarch64_on_apple_silicon_build() { assert_eq!(std::env::consts::ARCH, "aarch64"); } -// ------------------------------------------------------------------------- -// cef_prewarm_enabled (issue #2463 — Wayland/XWayland BadWindow guard) -// ------------------------------------------------------------------------- - -#[test] -fn prewarm_enabled_by_default_on_non_wayland() { - assert!(cef_prewarm_enabled(None, false)); -} - -#[test] -fn prewarm_auto_disabled_on_wayland_when_env_unset() { - assert!(!cef_prewarm_enabled(None, true)); -} - -#[test] -fn prewarm_explicit_disable_respected_on_non_wayland() { - assert!(!cef_prewarm_enabled(Some("0"), false)); - assert!(!cef_prewarm_enabled(Some("false"), false)); - assert!(!cef_prewarm_enabled(Some("no"), false)); - assert!(!cef_prewarm_enabled(Some("off"), false)); -} - -#[test] -fn prewarm_explicit_disable_respected_on_wayland() { - assert!(!cef_prewarm_enabled(Some("0"), true)); - assert!(!cef_prewarm_enabled(Some("false"), true)); -} - -#[test] -fn prewarm_explicit_enable_overrides_wayland_guard() { - // OPENHUMAN_CEF_PREWARM=1 (or any non-disable value) lets ops - // force prewarm even on Wayland sessions. - assert!(cef_prewarm_enabled(Some("1"), true)); - assert!(cef_prewarm_enabled(Some("true"), true)); - assert!(cef_prewarm_enabled(Some("yes"), true)); - assert!(cef_prewarm_enabled(Some("on"), true)); -} - -#[test] -fn prewarm_disable_flags_are_case_insensitive() { - assert!(!cef_prewarm_enabled(Some("FALSE"), false)); - assert!(!cef_prewarm_enabled(Some("OFF"), true)); - assert!(!cef_prewarm_enabled(Some(" 0 "), false)); - assert!(!cef_prewarm_enabled(Some(" No "), true)); -} - -#[test] -fn prewarm_unknown_env_value_treated_as_enable() { - // Any string that is not a recognised disable token → treat as enable. - assert!(cef_prewarm_enabled(Some("enabled"), false)); - assert!(cef_prewarm_enabled(Some("yes"), false)); - assert!(cef_prewarm_enabled(Some(""), false)); -} - // ------------------------------------------------------------------------- // build_sentry_release_tag // ------------------------------------------------------------------------- diff --git a/app/src-tauri/src/meet_audio/mod.rs b/app/src-tauri/src/meet_audio/mod.rs index 5af6674b49..b8cb413daa 100644 --- a/app/src-tauri/src/meet_audio/mod.rs +++ b/app/src-tauri/src/meet_audio/mod.rs @@ -298,8 +298,7 @@ pub async fn stop( } /// Minimal JSON-RPC helper used by both this module and the speak pump -/// loop. Mirrors the call shape used by other shell scanners (see -/// `telegram_scanner::mod.rs`). +/// loop. pub(crate) async fn rpc_call( method: &str, params: serde_json::Value, diff --git a/app/src-tauri/src/slack_scanner/dom_snapshot.rs b/app/src-tauri/src/slack_scanner/dom_snapshot.rs deleted file mode 100644 index a064cd5187..0000000000 --- a/app/src-tauri/src/slack_scanner/dom_snapshot.rs +++ /dev/null @@ -1,147 +0,0 @@ -//! Slack channel-sidebar scrape via `DOMSnapshot.captureSnapshot`. Replaces -//! the old recipe.js scraper. Selectors mirror the old recipe: -//! * rows: `[data-qa="virtual-list-item"]` or `.p-channel_sidebar__channel` -//! * name: `[data-qa="channel_sidebar_name_button"]` / `.p-channel_sidebar__name` / first `span` -//! * badge: `.p-channel_sidebar__badge` / `[data-qa="mention_badge"]` - -use serde_json::{json, Value}; - -use crate::cdp::{CdpConn, Snapshot}; - -#[derive(Debug, Clone)] -pub struct ChannelRow { - pub name: String, - pub unread: u32, -} - -pub struct DomScan { - pub rows: Vec, - pub total_unread: u32, - pub hash: u64, -} - -pub async fn scan(cdp: &mut CdpConn, session: &str) -> Result { - let snap = Snapshot::capture(cdp, session).await?; - let row_nodes = snap.find_all(is_channel_row); - let mut rows = Vec::with_capacity(row_nodes.len()); - let mut total_unread: u32 = 0; - for idx in row_nodes { - let name = find_channel_name(&snap, idx).unwrap_or_default(); - if name.is_empty() { - continue; - } - let badge = find_badge(&snap, idx).unwrap_or(0); - total_unread = total_unread.saturating_add(badge); - rows.push(ChannelRow { - name, - unread: badge, - }); - } - let hash = hash_rows(&rows, total_unread); - Ok(DomScan { - rows, - total_unread, - hash, - }) -} - -pub fn ingest_payload(scan: &DomScan) -> Value { - let messages: Vec = scan - .rows - .iter() - .enumerate() - .map(|(idx, r)| { - json!({ - "id": format!("sl:{}:{idx}", r.name), - "from": r.name, - "body": Value::Null, - "unread": r.unread, - }) - }) - .collect(); - let snapshot_key = format!("{:x}", scan.hash); - json!({ - "messages": messages, - "unread": scan.total_unread, - "snapshotKey": snapshot_key, - }) -} - -fn is_channel_row(snap: &Snapshot, idx: usize) -> bool { - if snap.attr(idx, "data-qa") == Some("virtual-list-item") { - return true; - } - snap.has_class(idx, "p-channel_sidebar__channel") -} - -fn find_channel_name(snap: &Snapshot, root: usize) -> Option { - // 1. [data-qa="channel_sidebar_name_button"] - if let Some(n) = snap.find_descendant(root, |s, i| { - s.is_element(i) && s.attr(i, "data-qa") == Some("channel_sidebar_name_button") - }) { - let t = snap.text_content(n); - if !t.is_empty() { - return Some(t); - } - } - // 2. .p-channel_sidebar__name - if let Some(n) = snap.find_descendant(root, |s, i| { - s.is_element(i) && s.has_class(i, "p-channel_sidebar__name") - }) { - let t = snap.text_content(n); - if !t.is_empty() { - return Some(t); - } - } - // 3. first span - let span = snap.find_descendant(root, |s, i| { - s.is_element(i) && s.tag(i).eq_ignore_ascii_case("SPAN") - })?; - let t = snap.text_content(span); - if t.is_empty() { - None - } else { - Some(t) - } -} - -fn find_badge(snap: &Snapshot, root: usize) -> Option { - let n = snap.find_descendant(root, |s, i| { - s.is_element(i) - && (s.has_class(i, "p-channel_sidebar__badge") - || s.attr(i, "data-qa") == Some("mention_badge")) - })?; - // Matches the Discord scraper: a present-but-empty badge (generic - // unread marker) returns Some(0) so the row is still included in - // the ingest, but `total_unread` isn't bumped. - let txt = snap.text_content(n); - let trimmed = txt.trim(); - if trimmed.is_empty() { - return Some(0); - } - trimmed.parse::().ok() -} - -fn hash_rows(rows: &[ChannelRow], total_unread: u32) -> u64 { - let mut h: u64 = 0xcbf29ce484222325; - fn mix(h: &mut u64, b: u8) { - *h ^= b as u64; - *h = h.wrapping_mul(0x100000001b3); - } - for b in (rows.len() as u32).to_le_bytes() { - mix(&mut h, b); - } - for b in total_unread.to_le_bytes() { - mix(&mut h, b); - } - for r in rows { - for b in r.name.as_bytes() { - mix(&mut h, *b); - } - mix(&mut h, 0x7c); - for b in r.unread.to_le_bytes() { - mix(&mut h, b); - } - } - h -} diff --git a/app/src-tauri/src/slack_scanner/extract.rs b/app/src-tauri/src/slack_scanner/extract.rs deleted file mode 100644 index 9b8600b1d7..0000000000 --- a/app/src-tauri/src/slack_scanner/extract.rs +++ /dev/null @@ -1,352 +0,0 @@ -//! Message / user / channel extraction from raw Slack IDB records. -//! -//! Slack's Redux-persist snapshots nest arbitrarily — message arrays live -//! inside `messages[channelId]` objects inside a `state` record inside a -//! store record. Rather than pin the walk to a specific schema (which -//! moves across Slack versions), we recurse depth-first and match shapes. -//! -//! Matchers: -//! * **Message** — an object with a Slack-shaped `ts` (`<10d>.<1-8d>`), -//! a non-empty `text`, and a `user`/`bot_id`/`username`. Records with -//! `type == "message"` are preferred when available. -//! * **User** — any record with an `id` starting with `U`/`W` and a -//! non-empty `profile.real_name` / `profile.display_name` / `real_name` -//! / `name`. -//! * **Channel** — any record with an `id` starting with `C` / `G` / `D` -//! and a non-empty `name_normalized` / `name`. -//! * **Workspace name** — the first record with an `id` starting with -//! `T` that carries a non-empty `name`. -//! -//! Redux-persist sometimes stores serialised state as JSON-encoded strings; -//! if we hit a string that looks JSON-ish we parse it and recurse. Depth -//! is capped at 40 so pathological graphs can't loop. - -use std::collections::HashMap; - -use serde_json::Value; - -use super::{idb::IdbDump, looks_like_slack_ts}; - -#[derive(Debug, Default)] -pub struct ExtractedMessage { - pub channel: String, - pub user: String, - pub text: String, - pub ts: String, -} - -type HarvestResult = ( - Vec, - HashMap, - HashMap, - Option, -); - -/// Main entry: walks every record in the dump and returns -/// `(messages, user_id → display_name, channel_id → name, workspace_name)`. -pub fn harvest(dump: &IdbDump) -> HarvestResult { - let mut messages: Vec = Vec::new(); - let mut users: HashMap = HashMap::new(); - let mut channels: HashMap = HashMap::new(); - let mut workspace: Option = None; - - for db in &dump.dbs { - for store in &db.stores { - for rec in &store.records { - // Context from parent key: many Slack message arrays live - // under `messages["C12345"] = [...]`, so we seed the - // recursion with the store's enclosing channel hint when - // available. - walk( - rec, - None, - &mut messages, - &mut users, - &mut channels, - &mut workspace, - 0, - ); - } - } - } - (messages, users, channels, workspace) -} - -fn walk( - v: &Value, - channel_hint: Option<&str>, - messages: &mut Vec, - users: &mut HashMap, - channels: &mut HashMap, - workspace: &mut Option, - depth: u32, -) { - if depth > 40 { - return; - } - match v { - Value::Object(map) => { - // 1) Message-shape check. - if let Some(ts) = map.get("ts").and_then(|v| v.as_str()) { - if looks_like_slack_ts(ts) { - let text = map - .get("text") - .and_then(|v| v.as_str()) - .map(str::to_string) - .unwrap_or_default(); - let user = map - .get("user") - .and_then(|v| v.as_str()) - .or_else(|| map.get("bot_id").and_then(|v| v.as_str())) - .or_else(|| map.get("username").and_then(|v| v.as_str())) - .unwrap_or("") - .to_string(); - let channel = map - .get("channel") - .and_then(|v| v.as_str()) - .or_else(|| map.get("channel_id").and_then(|v| v.as_str())) - .map(str::to_string) - .or_else(|| channel_hint.map(str::to_string)) - .unwrap_or_default(); - let is_message = map - .get("type") - .and_then(|v| v.as_str()) - .map(|s| s == "message") - .unwrap_or(false) - || (!text.trim().is_empty() && !user.is_empty()); - if is_message && !text.trim().is_empty() { - messages.push(ExtractedMessage { - channel, - user: user.clone(), - text, - ts: ts.to_string(), - }); - // Inline user profile scrape. - if let Some(prof) = map.get("user_profile").and_then(|v| v.as_object()) { - if !user.is_empty() { - if let Some(name) = prof - .get("real_name") - .and_then(|v| v.as_str()) - .or_else(|| prof.get("display_name").and_then(|v| v.as_str())) - .filter(|s| !s.is_empty()) - { - users - .entry(user.clone()) - .or_insert_with(|| name.to_string()); - } - } - } - } - } - } - - // 2) User / channel / team shape checks via leading id char. - if let Some(id) = map.get("id").and_then(|v| v.as_str()) { - let first = id.chars().next().unwrap_or('\0'); - match first { - 'U' | 'W' => { - let name = map - .get("profile") - .and_then(|p| p.get("real_name")) - .and_then(|v| v.as_str()) - .or_else(|| { - map.get("profile") - .and_then(|p| p.get("display_name")) - .and_then(|v| v.as_str()) - }) - .or_else(|| map.get("real_name").and_then(|v| v.as_str())) - .or_else(|| map.get("name").and_then(|v| v.as_str())) - .filter(|s| !s.is_empty()); - if let Some(n) = name { - users.entry(id.to_string()).or_insert_with(|| n.to_string()); - } - } - 'C' | 'G' | 'D' => { - let name = map - .get("name_normalized") - .and_then(|v| v.as_str()) - .or_else(|| map.get("name").and_then(|v| v.as_str())) - .filter(|s| !s.is_empty()); - if let Some(n) = name { - channels - .entry(id.to_string()) - .or_insert_with(|| n.to_string()); - } - } - 'T' if workspace.is_none() => { - if let Some(n) = map - .get("name") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - { - *workspace = Some(n.to_string()); - } - } - _ => {} - } - } - - // 3) Recurse into children. If the current key looks like a - // channel id (C…/G…/D…), pass it down as a hint so messages - // nested under it without a `channel` field still get grouped - // correctly. - for (k, vv) in map.iter() { - let next_hint = if is_channel_id(k) { - Some(k.as_str()) - } else { - channel_hint - }; - walk( - vv, - next_hint, - messages, - users, - channels, - workspace, - depth + 1, - ); - } - } - Value::Array(arr) => { - for vv in arr.iter() { - walk( - vv, - channel_hint, - messages, - users, - channels, - workspace, - depth + 1, - ); - } - } - Value::String(s) - if s.len() > 20 - && (s.starts_with('{') || s.starts_with('[')) - && (s.ends_with('}') || s.ends_with(']')) => - { - // Redux-persist default: values are JSON-encoded strings. If - // this string is plausibly JSON, parse and recurse. - if let Ok(inner) = serde_json::from_str::(s) { - walk( - &inner, - channel_hint, - messages, - users, - channels, - workspace, - depth + 1, - ); - } - } - _ => {} - } -} - -fn is_channel_id(s: &str) -> bool { - let mut chars = s.chars(); - let first = match chars.next() { - Some(c) => c, - None => return false, - }; - if !matches!(first, 'C' | 'G' | 'D') { - return false; - } - // Slack ids are uppercase alphanumeric, typically 9-11 chars. - s.len() >= 9 && s.len() <= 12 && s.chars().all(|c| c.is_ascii_alphanumeric()) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn empty_dump() -> IdbDump { - IdbDump::default() - } - - #[test] - fn extracts_message_shape() { - let mut dump = empty_dump(); - dump.dbs.push(super::super::idb::IdbDb { - name: "ReduxPersistIDB:T123_U456".into(), - stores: vec![super::super::idb::IdbStore { - name: "state".into(), - count: 1, - records: vec![json!({ - "messages": { - "C0000000A1": [ - { - "type": "message", - "ts": "1712345678.000200", - "user": "U111", - "text": "hello", - } - ] - } - })], - error: None, - }], - error: None, - }); - let (msgs, _users, _chans, _ws) = harvest(&dump); - assert_eq!(msgs.len(), 1); - assert_eq!(msgs[0].channel, "C0000000A1"); - assert_eq!(msgs[0].user, "U111"); - assert_eq!(msgs[0].text, "hello"); - assert_eq!(msgs[0].ts, "1712345678.000200"); - } - - #[test] - fn picks_up_user_and_channel_directories() { - let mut dump = empty_dump(); - dump.dbs.push(super::super::idb::IdbDb { - name: "ReduxPersistIDB:T123".into(), - stores: vec![super::super::idb::IdbStore { - name: "state".into(), - count: 1, - records: vec![json!({ - "users": [ - { "id": "U111", "profile": { "real_name": "Ada Lovelace" }} - ], - "channels": [ - { "id": "C0000000A1", "name": "general" } - ], - "team": { "id": "T123", "name": "Acme Inc." } - })], - error: None, - }], - error: None, - }); - let (_msgs, users, chans, ws) = harvest(&dump); - assert_eq!(users.get("U111").map(String::as_str), Some("Ada Lovelace")); - assert_eq!(chans.get("C0000000A1").map(String::as_str), Some("general")); - assert_eq!(ws.as_deref(), Some("Acme Inc.")); - } - - #[test] - fn recurses_into_json_encoded_strings() { - let mut dump = empty_dump(); - let inner = json!({ - "ts": "1712345678.000200", - "text": "nested", - "user": "U111", - "channel": "C0000000A1", - "type": "message", - }) - .to_string(); - dump.dbs.push(super::super::idb::IdbDb { - name: "ReduxPersistIDB:T123".into(), - stores: vec![super::super::idb::IdbStore { - name: "state".into(), - count: 1, - records: vec![json!({ "slice": inner })], - error: None, - }], - error: None, - }); - let (msgs, _, _, _) = harvest(&dump); - assert_eq!(msgs.len(), 1); - assert_eq!(msgs[0].text, "nested"); - } -} diff --git a/app/src-tauri/src/slack_scanner/idb.rs b/app/src-tauri/src/slack_scanner/idb.rs deleted file mode 100644 index d4f5a9031f..0000000000 --- a/app/src-tauri/src/slack_scanner/idb.rs +++ /dev/null @@ -1,326 +0,0 @@ -//! Slack IndexedDB walk driven purely through the CDP `IndexedDB` domain. -//! -//! No JavaScript runs in the page — `IndexedDB.requestDatabaseNames`, -//! `IndexedDB.requestDatabase`, and `IndexedDB.requestData` page through -//! every store at the browser's C++ layer. `Runtime.callFunctionOn` with a -//! fixed, Slack-agnostic serializer (`function(){return [this].concat(arguments);}`) -//! turns each batch of `Runtime.RemoteObject`s into JSON via `returnByValue`. -//! The serializer is structural; it can't read anything the page doesn't -//! already hold. It runs once per batch of ~100 records, not once per scan. -//! -//! Slack persists its Redux state tree to a database named -//! `ReduxPersistIDB:_` with a single object store -//! (`state`) whose records are redux-persist snapshots. We also pick up -//! other Slack-owned databases (session, calls, etc.) opportunistically. -//! -//! Harvested JSON is walked recursively in `extract` to pull message-, -//! user-, and channel-shaped records. Unlike WhatsApp we can't hit a -//! single known (database, store) pair because Slack namespaces DBs per -//! workspace and the actual schema has moved across Slack versions — -//! enumeration is cheap and gives us future-proofing for free. - -use serde_json::{json, Value}; - -use crate::cdp::CdpConn; - -/// CDP-known origin for the Slack web app. -const ORIGIN: &str = "https://app.slack.com"; -/// Row window per `IndexedDB.requestData` call. Slack's individual Redux -/// snapshot records can be multi-megabyte, so we keep the page small. -const PAGE_SIZE: i64 = 50; -/// Per-store ceiling. Slack workspaces can legitimately exceed this; the -/// cap is a safety net against runaway stores, not a hard limit. -const MAX_RECORDS_PER_STORE: usize = 5_000; -/// Max `Runtime.RemoteObject`s to materialise in a single -/// `Runtime.callFunctionOn`. Smaller than WhatsApp's 100 because each -/// Slack record can carry dozens of KB. -const SERIALIZE_BATCH: usize = 32; -/// Skip databases we know aren't useful (and would waste scan budget). -const SKIP_DB_PREFIXES: &[&str] = &[ - "SlackDesktopSettings", - "webpack", - "databases", // Chromium's own metadata DB -]; - -/// Product of one full walk — raw records grouped by (database, store) -/// so downstream extraction can log per-source counts. Debug-only fields -/// (`error`, `count`, `name`) are kept for log/inspection even though the -/// extractor only reads `records`. -#[derive(Debug, Default)] -pub struct IdbDump { - pub dbs: Vec, -} - -#[derive(Debug, Default)] -#[allow(dead_code)] -pub struct IdbDb { - pub name: String, - pub stores: Vec, - pub error: Option, -} - -#[derive(Debug, Default)] -#[allow(dead_code)] -pub struct IdbStore { - pub name: String, - pub records: Vec, - pub count: i64, - pub error: Option, -} - -/// Walk every Slack-relevant IndexedDB database on `ORIGIN`. Returns a -/// flat dump — no per-record normalisation happens here; that lives in -/// `extract::walk_extract` because the record shapes vary across stores. -pub async fn walk(cdp: &mut CdpConn, session: &str) -> Result { - if let Err(e) = cdp.call("IndexedDB.enable", json!({}), Some(session)).await { - log::debug!("[sl][idb] enable: {}", e); - } - - let names_v = cdp - .call( - "IndexedDB.requestDatabaseNames", - json!({ "securityOrigin": ORIGIN }), - Some(session), - ) - .await?; - let names: Vec = names_v - .get("databaseNames") - .and_then(|x| x.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - - log::info!( - "[sl][idb] found {} databases at origin {}: {:?}", - names.len(), - ORIGIN, - names - ); - - let mut dump = IdbDump::default(); - for name in names { - if SKIP_DB_PREFIXES.iter().any(|p| name.starts_with(p)) { - log::debug!("[sl][idb] skipping db {}", name); - continue; - } - match walk_database(cdp, session, &name).await { - Ok(db) => { - log::info!( - "[sl][idb] db={} stores={} total_records={}", - db.name, - db.stores.len(), - db.stores.iter().map(|s| s.records.len()).sum::() - ); - dump.dbs.push(db); - } - Err(e) => { - log::warn!("[sl][idb] db={} failed: {}", name, e); - dump.dbs.push(IdbDb { - name, - error: Some(e), - ..Default::default() - }); - } - } - } - Ok(dump) -} - -async fn walk_database(cdp: &mut CdpConn, session: &str, db_name: &str) -> Result { - let meta = cdp - .call( - "IndexedDB.requestDatabase", - json!({ - "securityOrigin": ORIGIN, - "databaseName": db_name, - }), - Some(session), - ) - .await?; - - let store_names: Vec = meta - .pointer("/databaseWithObjectStores/objectStores") - .and_then(|x| x.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|s| s.get("name").and_then(|n| n.as_str()).map(String::from)) - .collect() - }) - .unwrap_or_default(); - - let mut db = IdbDb { - name: db_name.to_string(), - ..Default::default() - }; - for store_name in store_names { - match read_store(cdp, session, db_name, &store_name).await { - Ok((records, count)) => { - log::debug!( - "[sl][idb] db={} store={} count={} fetched={}", - db_name, - store_name, - count, - records.len() - ); - db.stores.push(IdbStore { - name: store_name, - records, - count, - error: None, - }); - } - Err(e) => { - log::warn!( - "[sl][idb] db={} store={} failed: {}", - db_name, - store_name, - e - ); - db.stores.push(IdbStore { - name: store_name, - error: Some(e), - ..Default::default() - }); - } - } - } - Ok(db) -} - -/// Page through `objectStoreName` via `IndexedDB.requestData`, materialising -/// each value RemoteObject into JSON. Stops at `MAX_RECORDS_PER_STORE` or -/// when `hasMore: false`. Returns `(records, total_fetched_count)`. -async fn read_store( - cdp: &mut CdpConn, - session: &str, - database_name: &str, - store: &str, -) -> Result<(Vec, i64), String> { - let mut out: Vec = Vec::new(); - let mut skip: i64 = 0; - loop { - let remaining = MAX_RECORDS_PER_STORE.saturating_sub(out.len()); - if remaining == 0 { - break; - } - let page = (remaining as i64).min(PAGE_SIZE); - // NB: `indexName` is deliberately omitted — passing an empty - // string makes this CEF build reject the request with - // "Could not get index". The CDP spec says empty string means - // "primary key index", but the C++ backend here only accepts an - // unset field. Confirmed against CEF 146 (Chrome 146.0.7680.165). - let resp = cdp - .call( - "IndexedDB.requestData", - json!({ - "securityOrigin": ORIGIN, - "databaseName": database_name, - "objectStoreName": store, - "skipCount": skip, - "pageSize": page, - }), - Some(session), - ) - .await?; - let entries = resp - .get("objectStoreDataEntries") - .and_then(|x| x.as_array()) - .cloned() - .unwrap_or_default(); - if entries.is_empty() { - break; - } - let value_refs: Vec<&Value> = entries - .iter() - .map(|e| e.get("value").unwrap_or(&Value::Null)) - .collect(); - let materialised = serialize_values(cdp, session, &value_refs).await?; - out.extend(materialised); - let has_more = resp - .get("hasMore") - .and_then(|x| x.as_bool()) - .unwrap_or(false); - skip += entries.len() as i64; - if !has_more { - break; - } - } - Ok((out, skip)) -} - -/// Convert a list of `Runtime.RemoteObject` references (as returned inside -/// `ObjectStoreDataEntry.value`) into JSON. Primitives are read off the -/// RemoteObject's inline `value`; complex objects are batched through -/// `Runtime.callFunctionOn` with a generic serializer. Same pattern as -/// `whatsapp_scanner::idb::serialize_values`. -async fn serialize_values( - cdp: &mut CdpConn, - session: &str, - values: &[&Value], -) -> Result, String> { - let mut result: Vec = vec![Value::Null; values.len()]; - let mut pending: Vec<(usize, String)> = Vec::new(); - for (i, v) in values.iter().enumerate() { - if let Some(inline) = v.get("value") { - result[i] = inline.clone(); - continue; - } - if let Some(oid) = v.get("objectId").and_then(|x| x.as_str()) { - pending.push((i, oid.to_string())); - continue; - } - } - for chunk in pending.chunks(SERIALIZE_BATCH) { - let oids: Vec<&str> = chunk.iter().map(|(_, oid)| oid.as_str()).collect(); - let serialised = call_function_batch(cdp, session, &oids).await?; - if serialised.len() != chunk.len() { - return Err(format!( - "serialise batch length mismatch: got {}, expected {}", - serialised.len(), - chunk.len() - )); - } - for ((idx, _), val) in chunk.iter().zip(serialised) { - result[*idx] = val; - } - } - Ok(result) -} - -async fn call_function_batch( - cdp: &mut CdpConn, - session: &str, - object_ids: &[&str], -) -> Result, String> { - if object_ids.is_empty() { - return Ok(Vec::new()); - } - let (first, rest) = object_ids.split_first().unwrap(); - let args: Vec = rest.iter().map(|oid| json!({ "objectId": oid })).collect(); - let resp = cdp - .call_with_timeout( - "Runtime.callFunctionOn", - json!({ - "objectId": first, - "functionDeclaration": "function(){return [this].concat(Array.prototype.slice.call(arguments));}", - "arguments": args, - "returnByValue": true, - "silent": true, - }), - Some(session), - std::time::Duration::from_secs(60), - ) - .await?; - if let Some(exc) = resp.get("exceptionDetails") { - return Err(format!("callFunctionOn threw: {exc}")); - } - let arr = resp - .pointer("/result/value") - .and_then(|v| v.as_array()) - .cloned() - .ok_or_else(|| format!("callFunctionOn result not array: {resp}"))?; - Ok(arr) -} diff --git a/app/src-tauri/src/slack_scanner/mod.rs b/app/src-tauri/src/slack_scanner/mod.rs deleted file mode 100644 index 43eeb23b0d..0000000000 --- a/app/src-tauri/src/slack_scanner/mod.rs +++ /dev/null @@ -1,913 +0,0 @@ -//! Slack Web scanner driven purely over the Chrome DevTools Protocol (CDP). -//! -//! Attaches to the embedded CEF webview via the in-process CDP transport -//! installed by `webview_accounts::open` (no TCP listener). One polling -//! loop per tracked Slack account: -//! -//! * **IDB tick** (`IDB_SCAN_INTERVAL`, 30s) — walks every Slack-owned -//! IndexedDB database via CDP (`IndexedDB.requestDatabaseNames`, -//! `IndexedDB.requestDatabase`, `IndexedDB.requestData`), materialises -//! `Runtime.RemoteObject` records into JSON with a fixed, Slack-agnostic -//! serializer (`function(){return [this].concat(arguments);}`), and -//! recursively extracts message / user / channel records from the -//! Redux-persist snapshots Slack stores there. No in-page JavaScript -//! runs beyond that one fixed serializer, and no DOM scraping. -//! -//! Emits `webview:event` ingest events (for any listening React UI) AND -//! POSTs `openhuman.memory_doc_ingest` directly to the core so memory is -//! populated whether or not the main window is open. Messages are grouped -//! by `channel_id` (one doc per channel; the transcript carries each -//! message's date inline so chronology stays readable). Per-day grouping -//! was specified for #1016 but is deferred — see #1016 follow-ups. -//! -//! Only built with the `cef` feature — wry has no remote-debugging port. - -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Duration; - -use parking_lot::Mutex; -use serde_json::{json, Value}; -use tauri::{AppHandle, Emitter, Runtime}; -use tokio::task::AbortHandle; -use tokio::time::sleep; - -mod dom_snapshot; -mod extract; -mod idb; - -/// How often we walk IDB. Tune down for faster iteration during dev; the -/// walk itself is bounded by per-store record caps in `idb.rs`. -const IDB_SCAN_INTERVAL: Duration = Duration::from_secs(30); - -/// Spawn a per-account CDP poller. Caller is expected to guard against -/// double-spawning via `ScannerRegistry`. -pub fn spawn_scanner( - app: AppHandle, - account_id: String, - url_prefix: String, -) -> Vec { - let mut handles = Vec::with_capacity(2); - handles.push(spawn_dom_poll( - app.clone(), - account_id.clone(), - url_prefix.clone(), - )); - let task = tokio::spawn(async move { - let fragment = crate::cdp::target_url_fragment(&account_id); - log::info!( - "[sl] scanner up account={} url_prefix={} fragment={} interval={:?}", - account_id, - url_prefix, - fragment, - IDB_SCAN_INTERVAL, - ); - // Let Slack hydrate Redux from IDB before the first scan — - // otherwise we'd race an empty store on cold start. - sleep(Duration::from_secs(10)).await; - - // Account-stable target identifier discovered on the first tick - // where the strict `#openhuman-account-` fragment is still - // present. Once set, subsequent ticks resolve the page target - // by this id first so the relaxed same-origin fallback can - // never bind us to a sibling Slack account's page in a - // multi-account session (CodeRabbit #3162652711). - let mut pinned_target_id: Option = None; - loop { - match scan_once( - &app, - &account_id, - &url_prefix, - &fragment, - &mut pinned_target_id, - ) - .await - { - Ok(dump) => { - let team_id = infer_team_id(&dump); - let (messages, users, channels, workspace_name) = extract::harvest(&dump); - log::info!( - "[sl][{}] idb extract: {} msgs, {} users, {} channels, team={} workspace={}", - account_id, - messages.len(), - users.len(), - channels.len(), - team_id.as_deref().unwrap_or("?"), - workspace_name.as_deref().unwrap_or("?"), - ); - if !messages.is_empty() { - emit_and_persist( - &app, - &account_id, - &messages, - &users, - &channels, - team_id.as_deref().unwrap_or(""), - workspace_name.as_deref().unwrap_or(""), - ); - } - } - Err(e) => { - log::warn!("[sl][{}] idb scan failed: {}", account_id, e); - } - } - sleep(IDB_SCAN_INTERVAL).await; - } - }); - handles.push(task.abort_handle()); - handles -} - -/// Single scan cycle: open CDP, attach to the Slack page, walk IDB, detach. -/// -/// `pinned_target_id` lets the caller persist the CDP `targetId` from the -/// first strict-fragment match across subsequent ticks. Once set, this -/// function resolves by id first so multi-account Slack sessions can't -/// accidentally cross-wire scanner A onto scanner B's page target after -/// Slack's router strips the `#openhuman-account-` fragment. -async fn scan_once( - app: &AppHandle, - account_id: &str, - url_prefix: &str, - url_fragment: &str, - pinned_target_id: &mut Option, -) -> Result { - // Look up the in-process transport for this account, enumerate targets, - // then attach to the chosen target via the canonical CdpConn. The - // attach is manual (not via `connect_and_attach_matching_in_process`) - // so the pin / strict-fragment / relaxed fallback hierarchy stays - // intact. - let mut cdp = crate::cdp::target::conn_for_account(app, account_id)?; - let targets_v = cdp.call("Target.getTargets", json!({}), None).await?; - let targets = crate::cdp::target::parse_targets(&targets_v); - // Slack's client-side router does pushState to `/client//` - // shortly after first load, which strips the `#openhuman-account-` fragment. - // The fragment is only reliable on the FIRST scan tick (immediately after - // navigation) — by tick 2 it's gone. - // - // Resolution order: - // 1. If we previously locked onto a `targetId` via a strict fragment - // match, prefer that exact id. This pins the scanner to the same - // account-tab even after the fragment is gone. - // 2. Strict fragment match (`url_prefix` + `#openhuman-account-`). - // On hit, persist the `targetId` for future ticks. - // 3. Relaxed prefix-only match. Per-account `data_directory` - // isolation makes this safe in single-account setups, but in a - // multi-account Slack session it can bind to a sibling account's - // tab — only used as a last resort and never persisted. - let page_target = pinned_target_id - .as_ref() - .and_then(|pid| targets.iter().find(|t| &t.id == pid && t.kind == "page")) - .or_else(|| { - targets.iter().find(|t| { - t.kind == "page" && t.url.starts_with(url_prefix) && t.url.ends_with(url_fragment) - }) - }) - .or_else(|| { - targets - .iter() - .find(|t| t.kind == "page" && t.url.starts_with(url_prefix)) - }) - .ok_or_else(|| format!("no page target matching {url_prefix} fragment={url_fragment}"))?; - - // Persist the target id only when the strict fragment is still present - // — that's the only signal that proves this target really belongs to - // *this* account. Relaxed matches must never feed back into the pin. - if pinned_target_id.is_none() - && page_target.url.starts_with(url_prefix) - && page_target.url.ends_with(url_fragment) - { - log::info!( - "[sl][{}] pinned to target_id={} (strict fragment match)", - account_id, - page_target.id - ); - *pinned_target_id = Some(page_target.id.clone()); - } - - let attach = cdp - .call( - "Target.attachToTarget", - json!({ "targetId": page_target.id, "flatten": true }), - None, - ) - .await?; - let session = attach - .get("sessionId") - .and_then(|x| x.as_str()) - .ok_or_else(|| "page attach missing sessionId".to_string())? - .to_string(); - - let result = idb::walk(&mut cdp, &session).await; - - let _ = cdp - .call( - "Target.detachFromTarget", - json!({ "sessionId": session }), - None, - ) - .await; - - let dump = result?; - log::info!( - "[sl][{}] scan ok dbs={} total_records={}", - account_id, - dump.dbs.len(), - dump.dbs - .iter() - .flat_map(|d| d.stores.iter()) - .map(|s| s.records.len()) - .sum::(), - ); - Ok(dump) -} - -/// Slack names its per-workspace DB `objectStore--`. -/// Pull the `T…` token from the middle. Returns None if no such DB -/// exists — in which case we fall back to the `id`-shape match in -/// `extract::walk` (any record with `id.starts_with('T')`). -fn infer_team_id(dump: &idb::IdbDump) -> Option { - for db in &dump.dbs { - if let Some(rest) = db.name.strip_prefix("objectStore-") { - // e.g. "T01CWHNCJ9Z-U01CT9ADP6H" - let team = rest.split('-').next().unwrap_or(""); - if team.starts_with('T') - && team.len() >= 9 - && team.chars().all(|c| c.is_ascii_alphanumeric()) - { - return Some(team.to_string()); - } - } - } - None -} - -/// Group messages by channel (no per-day split), emit one -/// `webview:event` per channel, and POST the same payload to -/// `openhuman.memory_doc_ingest`. One memory doc per channel — the -/// transcript inside can be long, each message line still carries its -/// date so the full chronology stays readable. -#[allow(clippy::too_many_arguments)] -fn emit_and_persist( - app: &AppHandle, - account_id: &str, - messages: &[extract::ExtractedMessage], - users: &HashMap, - channels: &HashMap, - team_id: &str, - workspace_name: &str, -) { - #[derive(Default)] - struct Group { - rows: Vec, - } - let mut groups: HashMap = HashMap::new(); - for m in messages { - if m.channel.is_empty() || m.ts.is_empty() { - continue; - } - let ts_secs = parse_slack_ts(&m.ts).unwrap_or(0); - if ts_secs <= 0 { - continue; - } - let sender = users - .get(&m.user) - .cloned() - .or_else(|| { - if m.user.is_empty() { - None - } else { - Some(m.user.clone()) - } - }) - .unwrap_or_default(); - let row = json!({ - "ts": m.ts, - "ts_secs": ts_secs, - "sender": sender, - "user_id": m.user, - "body": m.text, - }); - groups.entry(m.channel.clone()).or_default().rows.push(row); - } - - let mut emitted = 0usize; - for (channel_id, group) in groups { - let mut rows = group.rows; - rows.sort_by(|a, b| { - a.get("ts_secs") - .and_then(|v| v.as_i64()) - .unwrap_or(0) - .cmp(&b.get("ts_secs").and_then(|v| v.as_i64()).unwrap_or(0)) - }); - // De-duplicate within the channel by `ts` (Slack messages are - // unique per-channel per-ts). The walker can see the same record - // in multiple Redux snapshots, so dedupe is not optional. - let mut seen: std::collections::HashSet = std::collections::HashSet::new(); - rows.retain(|r| { - let ts = r - .get("ts") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - !ts.is_empty() && seen.insert(ts) - }); - if rows.is_empty() { - continue; - } - let channel_name = channels - .get(&channel_id) - .cloned() - .unwrap_or_else(|| channel_id.clone()); - - let payload = json!({ - "provider": "slack", - "source": "cdp-idb", - "teamId": team_id, - "workspaceName": workspace_name, - "channelId": channel_id, - "channelName": channel_name, - "messages": rows, - }); - let envelope = json!({ - "account_id": account_id, - "provider": "slack", - "kind": "ingest", - "payload": payload.clone(), - "ts": chrono_now_millis(), - }); - if let Err(e) = app.emit("webview:event", &envelope) { - log::warn!("[sl][{}] ingest emit failed: {}", account_id, e); - } else { - emitted += 1; - } - let acct = account_id.to_string(); - tokio::spawn(async move { - if let Err(e) = post_memory_doc_ingest(&acct, &payload).await { - log::warn!("[sl][{}] memory write failed: {}", acct, e); - } - }); - } - log::info!("[sl][{}] emitted {} channel doc(s)", account_id, emitted); -} - -/// Parse Slack's `"unix_seconds.microseconds"` ts string to unix seconds. -pub(crate) fn parse_slack_ts(s: &str) -> Option { - let s = s.trim(); - if s.is_empty() { - return None; - } - s.split('.').next()?.parse::().ok() -} - -/// Slack ts shape check: `<10 digits>.<1-8 digits>`. -pub(crate) fn looks_like_slack_ts(s: &str) -> bool { - let bytes = s.as_bytes(); - let dot = match s.find('.') { - Some(i) => i, - None => return false, - }; - if !(9..=11).contains(&dot) { - return false; - } - if !bytes[..dot].iter().all(|b| b.is_ascii_digit()) { - return false; - } - let frac = &bytes[dot + 1..]; - if frac.is_empty() || frac.len() > 8 { - return false; - } - frac.iter().all(|b| b.is_ascii_digit()) -} - -/// Unix seconds → UTC `YYYY-MM-DD` (Howard Hinnant civil-from-days). -fn seconds_to_ymd(secs: i64) -> String { - let days = secs.div_euclid(86_400); - let z = days + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = (z - era * 146_097) as u64; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; - let y_real = (if m <= 2 { y + 1 } else { y }) as i32; - format!("{:04}-{:02}-{:02}", y_real, m, d) -} - -fn chrono_now_millis() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) -} - -/// Build and POST the `openhuman.memory_doc_ingest` payload for a single -/// (channel, day) group. Mirrors `whatsapp_scanner::post_memory_doc_ingest`. -async fn post_memory_doc_ingest(account_id: &str, ingest: &Value) -> Result<(), String> { - let channel_id = ingest - .get("channelId") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - let channel_name = ingest - .get("channelName") - .and_then(|v| v.as_str()) - .unwrap_or(channel_id); - let team_id = ingest - .get("teamId") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - let workspace_name = ingest - .get("workspaceName") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - let empty: Vec = Vec::new(); - let msgs = ingest - .get("messages") - .and_then(|v| v.as_array()) - .unwrap_or(&empty); - if channel_id.is_empty() || msgs.is_empty() { - return Ok(()); - } - - let mut sorted: Vec<&Value> = msgs.iter().collect(); - sorted.sort_by_key(|m| m.get("ts_secs").and_then(|v| v.as_i64()).unwrap_or(0)); - - let first_ts = sorted - .first() - .and_then(|m| m.get("ts_secs")) - .and_then(|v| v.as_i64()) - .unwrap_or(0); - let last_ts = sorted - .last() - .and_then(|m| m.get("ts_secs")) - .and_then(|v| v.as_i64()) - .unwrap_or(0); - - // Full-channel transcript — every line carries its own date + time so - // the reader can scan chronology without needing per-day splits. - let transcript: String = sorted - .iter() - .map(|m| { - let ts = m.get("ts_secs").and_then(|v| v.as_i64()).unwrap_or(0); - let stamp = if ts > 0 { - let day = seconds_to_ymd(ts); - let secs_of_day = (ts.rem_euclid(86_400)) as u32; - format!( - "{} {:02}:{:02}Z", - day, - secs_of_day / 3600, - (secs_of_day / 60) % 60 - ) - } else { - "?".to_string() - }; - let who = m - .get("sender") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .unwrap_or("?"); - let body = m - .get("body") - .and_then(|v| v.as_str()) - .unwrap_or("") - .replace(['\r', '\n'], " "); - format!("[{stamp}] {who}: {body}") - }) - .collect::>() - .join("\n"); - - let first_day = if first_ts > 0 { - seconds_to_ymd(first_ts) - } else { - String::new() - }; - let last_day = if last_ts > 0 { - seconds_to_ymd(last_ts) - } else { - String::new() - }; - let header = format!( - "# Slack — {workspace} · #{channel}\nchannel_id: {channel_id}\nteam_id: {team_id}\naccount_id: {account_id}\nmessages: {n}\nrange: {first_day} → {last_day}\n\n", - workspace = if workspace_name.is_empty() { - "workspace" - } else { - workspace_name - }, - channel = channel_name, - channel_id = channel_id, - team_id = team_id, - account_id = account_id, - n = sorted.len(), - first_day = first_day, - last_day = last_day, - ); - let content = format!("{header}{transcript}"); - - // Key = channel name when available (what the user asked for), - // falling back to the channel id for anonymous DMs / unnamed rooms. - // `:` is reserved by the memory layer (it rewrites to `_`), other - // characters pass through. Slack channel names are already lowercase - // letters/digits/dashes/underscores, so no further sanitisation needed. - let namespace = format!("slack-web:{account_id}"); - let key = if channels_key_looks_clean(channel_name) { - channel_name.to_string() - } else { - channel_id.to_string() - }; - let title = format!("Slack · #{channel_name}"); - - let params = json!({ - "namespace": namespace, - "key": key, - "title": title, - "content": content, - "source_type": "slack-web", - "priority": "medium", - "tags": ["slack", "channel-transcript"], - "metadata": { - "provider": "slack", - "account_id": account_id, - "team_id": team_id, - "workspace_name": workspace_name, - "channel_id": channel_id, - "channel_name": channel_name, - "first_day": first_day, - "last_day": last_day, - "message_count": sorted.len(), - }, - "category": "core", - }); - let body = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "openhuman.memory_doc_ingest", - "params": params, - }); - - let url = crate::core_rpc::core_rpc_url_value(); - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(15)) - .build() - .map_err(|e| format!("http client: {e}"))?; - let req = crate::core_rpc::apply_auth(client.post(&url)) - .map_err(|e| format!("prepare {url}: {e}"))?; - let resp = req - .json(&body) - .send() - .await - .map_err(|e| format!("POST {url}: {e}"))?; - let status = resp.status(); - if !status.is_success() { - let body = resp.text().await.unwrap_or_default(); - return Err(format!("{status}: {body}")); - } - let v: Value = resp.json().await.map_err(|e| format!("decode: {e}"))?; - if let Some(err) = v.get("error") { - return Err(format!("rpc error: {err}")); - } - log::info!( - "[sl][{}] memory upsert ok namespace={} key={} msgs={} range={}→{}", - account_id, - namespace, - key, - sorted.len(), - first_day, - last_day, - ); - Ok(()) -} - -/// Allow a channel name as a memory-doc key only if it looks like a -/// Slack-style slug — lowercase letters, digits, `-`, `_`. Reject -/// anything with `:` (reserved by the memory layer), spaces, or other -/// surprises; those fall back to the stable channel id. -fn channels_key_looks_clean(name: &str) -> bool { - if name.is_empty() { - return false; - } - name.chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') -} - -const DOM_POLL_INTERVAL: Duration = Duration::from_secs(2); - -fn spawn_dom_poll( - app: AppHandle, - account_id: String, - url_prefix: String, -) -> AbortHandle { - let task = tokio::spawn(async move { - let fragment = crate::cdp::target_url_fragment(&account_id); - sleep(Duration::from_secs(8)).await; - let mut last_hash: Option = None; - let mut last_unread_by_channel: Option> = None; - // Same pin-on-strict-match contract as the IDB scanner — see - // `scan_once` for rationale. - let mut pinned_target_id: Option = None; - loop { - match dom_scan_once( - &app, - &account_id, - &url_prefix, - &fragment, - &mut pinned_target_id, - ) - .await - { - Ok(scan) => { - let current_unread_by_channel: HashMap = scan - .rows - .iter() - .map(|row| (row.name.clone(), row.unread)) - .collect(); - if let Some(prev) = &last_unread_by_channel { - for row in &scan.rows { - let before = prev.get(&row.name).copied().unwrap_or(0); - if row.unread > before && row.unread > 0 { - let delta = row.unread - before; - let body = if delta == 1 { - "1 new unread message".to_string() - } else { - format!("{delta} new unread messages") - }; - log::info!( - "[sl][{}] notifying channel={} unread_before={} unread_after={}", - account_id, - row.name, - before, - row.unread - ); - crate::webview_accounts::forward_synthetic_notification( - &app, - &account_id, - "slack", - format!("#{}", row.name), - body, - ); - } - } - } - last_unread_by_channel = Some(current_unread_by_channel); - if Some(scan.hash) != last_hash { - log::info!( - "[sl][{}] dom scan rows={} unread={} hash={:x}", - account_id, - scan.rows.len(), - scan.total_unread, - scan.hash - ); - last_hash = Some(scan.hash); - let envelope = json!({ - "account_id": account_id, - "provider": "slack", - "kind": "ingest", - "payload": dom_snapshot::ingest_payload(&scan), - "ts": chrono_now_millis(), - }); - if let Err(e) = app.emit("webview:event", &envelope) { - log::warn!("[sl][{}] dom ingest emit failed: {}", account_id, e); - } - } - } - Err(e) => log::debug!("[sl][{}] dom scan: {}", account_id, e), - } - sleep(DOM_POLL_INTERVAL).await; - } - }); - task.abort_handle() -} - -async fn dom_scan_once( - app: &AppHandle, - account_id: &str, - url_prefix: &str, - url_fragment: &str, - pinned_target_id: &mut Option, -) -> Result { - // Same pin-on-strict-match contract as `scan_once`. Resolution order: - // pinned id → strict fragment → relaxed `/client` fallback. Pin is - // only persisted when the strict fragment is still present so a - // relaxed match can never feed back into the lock. - // - // We reuse the account's in-process CDP transport to enumerate - // targets, then attach to the chosen target via the same handle — - // no separate probe connection is needed. - let mut cdp = crate::cdp::target::conn_for_account(app, account_id)?; - let targets_v = cdp.call("Target.getTargets", json!({}), None).await?; - let candidates = crate::cdp::target::parse_targets(&targets_v); - - let chosen = pinned_target_id - .as_ref() - .and_then(|pid| candidates.iter().find(|t| &t.id == pid && t.kind == "page")) - .or_else(|| { - candidates.iter().find(|t| { - t.kind == "page" && t.url.starts_with(url_prefix) && t.url.ends_with(url_fragment) - }) - }) - .or_else(|| { - // Slack's router strips the fragment after `pushState` to - // `/client/...`. Restrict the relaxed fallback to the - // `/client` path so we never pick up the marketing page or - // a login redirect for a sibling account. - candidates.iter().find(|t| { - t.kind == "page" && t.url.starts_with(url_prefix) && t.url.contains("/client") - }) - }) - .ok_or_else(|| format!("no page target matching {url_prefix} fragment={url_fragment}"))?; - - let chosen_id = chosen.id.clone(); - let chosen_url = chosen.url.clone(); - - if pinned_target_id.is_none() - && chosen_url.starts_with(url_prefix) - && chosen_url.ends_with(url_fragment) - { - log::info!( - "[sl][{}] dom pinned to target_id={} (strict fragment match)", - account_id, - chosen_id - ); - *pinned_target_id = Some(chosen_id.clone()); - } - - let attach = cdp - .call( - "Target.attachToTarget", - json!({ "targetId": chosen_id, "flatten": true }), - None, - ) - .await?; - let session = attach - .get("sessionId") - .and_then(|x| x.as_str()) - .ok_or_else(|| "page attach missing sessionId".to_string())? - .to_string(); - let scan = dom_snapshot::scan(&mut cdp, &session).await; - crate::cdp::detach_session(&mut cdp, &session).await; - scan -} - -/// Registry to prevent double-spawning scanners for the same account. -#[derive(Default)] -pub struct ScannerRegistry { - started: Mutex>>, -} - -impl ScannerRegistry { - pub fn new() -> Arc { - Arc::new(Self::default()) - } - - pub fn ensure_scanner( - &self, - app: AppHandle, - account_id: String, - url_prefix: String, - ) { - let mut g = self.started.lock(); - if g.contains_key(&account_id) { - log::debug!("[sl] scanner already running for {}", account_id); - return; - } - let handles = spawn_scanner(app, account_id.clone(), url_prefix); - g.insert(account_id, handles); - } - - pub fn forget(&self, account_id: &str) { - let handles = self.started.lock().remove(account_id); - if let Some(handles) = handles { - let count = handles.len(); - for handle in handles { - handle.abort(); - } - log::info!("[sl] aborted {} scanner task(s) for {}", count, account_id); - } - } - - pub fn forget_all(&self) -> usize { - let entries: Vec<_> = self.started.lock().drain().collect(); - let task_count = entries.iter().map(|(_, handles)| handles.len()).sum(); - for (account_id, handles) in entries { - for handle in handles { - handle.abort(); - } - log::debug!("[sl] aborted scanner tasks for {}", account_id); - } - if task_count > 0 { - log::info!("[sl] aborted {} scanner task(s)", task_count); - } - task_count - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn insert_pending_tasks( - registry: &ScannerRegistry, - account_id: &str, - count: usize, - ) -> Vec> { - let mut tasks = Vec::with_capacity(count); - let mut abort_handles = Vec::with_capacity(count); - for _ in 0..count { - let task = tokio::spawn(async { - std::future::pending::<()>().await; - }); - abort_handles.push(task.abort_handle()); - tasks.push(task); - } - registry - .started - .lock() - .insert(account_id.to_string(), abort_handles); - tasks - } - - async fn assert_cancelled(task: tokio::task::JoinHandle<()>) { - let err = tokio::time::timeout(Duration::from_secs(1), task) - .await - .expect("aborted scanner task should finish") - .expect_err("scanner task should be cancelled"); - assert!(err.is_cancelled()); - } - - async fn assert_all_cancelled(tasks: Vec>) { - for task in tasks { - assert_cancelled(task).await; - } - } - - #[tokio::test] - async fn registry_forget_aborts_all_handles_for_account_only() { - let registry = ScannerRegistry::default(); - let account_tasks = insert_pending_tasks(®istry, "acct-1", 2); - let survivor_tasks = insert_pending_tasks(®istry, "acct-2", 1); - - registry.forget("acct-1"); - - { - let guard = registry.started.lock(); - assert_eq!(guard.len(), 1); - assert!(guard.contains_key("acct-2")); - } - assert_all_cancelled(account_tasks).await; - assert!( - !survivor_tasks[0].is_finished(), - "forget(acct-1) must not abort acct-2" - ); - - assert_eq!(registry.forget_all(), 1); - assert_all_cancelled(survivor_tasks).await; - } - - #[tokio::test] - async fn registry_forget_missing_account_is_noop() { - let registry = ScannerRegistry::default(); - let mut tasks = insert_pending_tasks(®istry, "acct-1", 1); - - registry.forget("missing"); - - { - let guard = registry.started.lock(); - assert_eq!(guard.len(), 1); - assert!(guard.contains_key("acct-1")); - } - assert!( - !tasks[0].is_finished(), - "forget(missing) must not abort existing scanners" - ); - - registry.forget("acct-1"); - assert_cancelled(tasks.pop().expect("task")).await; - } - - #[tokio::test] - async fn registry_forget_all_aborts_all_tasks_and_reports_handle_count() { - let registry = ScannerRegistry::default(); - let task_a = insert_pending_tasks(®istry, "acct-1", 2); - let task_b = insert_pending_tasks(®istry, "acct-2", 3); - - assert_eq!(registry.forget_all(), 5); - - assert!(registry.started.lock().is_empty()); - assert_all_cancelled(task_a).await; - assert_all_cancelled(task_b).await; - } - - #[tokio::test] - async fn registry_forget_all_is_repeatable_noop_after_drain() { - let registry = ScannerRegistry::default(); - assert_eq!(registry.forget_all(), 0); - - let tasks = insert_pending_tasks(®istry, "acct-1", 1); - assert_eq!(registry.forget_all(), 1); - assert_eq!(registry.forget_all(), 0); - - assert!(registry.started.lock().is_empty()); - assert_all_cancelled(tasks).await; - } -} diff --git a/app/src-tauri/src/telegram_scanner/dom_snapshot.rs b/app/src-tauri/src/telegram_scanner/dom_snapshot.rs deleted file mode 100644 index 3c3331ae2a..0000000000 --- a/app/src-tauri/src/telegram_scanner/dom_snapshot.rs +++ /dev/null @@ -1,176 +0,0 @@ -//! Telegram chat-list scrape via `DOMSnapshot.captureSnapshot`. Replaces -//! the old recipe.js `setInterval` scraper. Pure CDP — no JS runs in the -//! page world. -//! -//! Selectors mirror the old recipe: -//! * rows: `.chatlist .chatlist-chat` or `ul.chatlist > li` -//! * name: `.user-title` / `.peer-title` / `.dialog-title span` -//! * preview: `.dialog-subtitle` / `.user-last-message` -//! * badge: `.badge-unread` / `.dialog-subtitle-badge-unread` - -use serde_json::{json, Value}; - -use crate::cdp::{CdpConn, Snapshot}; - -#[derive(Debug, Clone)] -pub struct ChatRow { - pub name: String, - pub preview: Option, - pub unread: u32, -} - -pub struct DomScan { - pub rows: Vec, - pub total_unread: u32, - pub hash: u64, -} - -pub async fn scan(cdp: &mut CdpConn, session: &str) -> Result { - let snap = Snapshot::capture(cdp, session).await?; - let row_nodes = snap.find_all(is_chat_row); - let mut rows = Vec::with_capacity(row_nodes.len()); - let mut total_unread: u32 = 0; - for idx in row_nodes { - let name = find_text_by_class(&snap, idx, &["user-title", "peer-title"]) - .or_else(|| find_dialog_title(&snap, idx)) - .unwrap_or_default(); - let preview = find_text_by_class(&snap, idx, &["dialog-subtitle", "user-last-message"]); - let badge = find_text_by_class( - &snap, - idx, - &["badge-unread", "dialog-subtitle-badge-unread"], - ) - .and_then(|s| s.trim().parse::().ok()) - .unwrap_or(0); - if name.is_empty() && preview.as_deref().map(str::is_empty).unwrap_or(true) { - continue; - } - total_unread = total_unread.saturating_add(badge); - rows.push(ChatRow { - name, - preview, - unread: badge, - }); - } - let hash = hash_rows(&rows, total_unread); - Ok(DomScan { - rows, - total_unread, - hash, - }) -} - -/// Build the ingest-shape payload the React layer already consumes (via -/// `persistIngestToMemory` in `webviewAccountService.ts`). Matches the -/// previous recipe `api.ingest` envelope so no frontend changes required. -pub fn ingest_payload(scan: &DomScan) -> Value { - let messages: Vec = scan - .rows - .iter() - .enumerate() - .map(|(idx, r)| { - // Always include `idx` so two chats with the same display - // name don't collapse into one id (memory-doc dedupe keys - // downstream use this id). - let id = if r.name.is_empty() { - format!("tg:row:{idx}") - } else { - format!("tg:{idx}:{}", r.name) - }; - json!({ - "id": id, - "from": if r.name.is_empty() { Value::Null } else { json!(r.name) }, - "body": r.preview.clone().map(Value::String).unwrap_or(Value::Null), - "unread": r.unread, - }) - }) - .collect(); - let snapshot_key = format!("{:x}", scan.hash); - json!({ - "messages": messages, - "unread": scan.total_unread, - "snapshotKey": snapshot_key, - }) -} - -fn is_chat_row(snap: &Snapshot, idx: usize) -> bool { - if snap.has_class(idx, "chatlist-chat") { - return true; - } - // `ul.chatlist > li` — match `LI` whose parent has class `chatlist`. - if snap.tag(idx).eq_ignore_ascii_case("LI") { - // Parent-index walk through the precomputed tree. - if let Some(parent) = parent_of(snap, idx) { - if snap.has_class(parent, "chatlist") { - return true; - } - } - } - false -} - -fn parent_of(snap: &Snapshot, idx: usize) -> Option { - (0..snap.len()).find(|&i| snap.children(i).contains(&idx)) -} - -fn find_text_by_class(snap: &Snapshot, root: usize, classes: &[&str]) -> Option { - let node = snap.find_descendant(root, |s, i| { - s.is_element(i) && classes.iter().any(|c| s.has_class(i, c)) - })?; - let t = snap.text_content(node); - if t.is_empty() { - None - } else { - Some(t) - } -} - -fn find_dialog_title(snap: &Snapshot, root: usize) -> Option { - // `.dialog-title span` — find any descendant `SPAN` whose ancestor has - // class `dialog-title`. Cheap heuristic: find `.dialog-title` and take - // its first SPAN descendant's text. - let container = snap.find_descendant(root, |s, i| { - s.is_element(i) && s.has_class(i, "dialog-title") - })?; - let span = snap.find_descendant(container, |s, i| { - s.is_element(i) && s.tag(i).eq_ignore_ascii_case("SPAN") - })?; - let t = snap.text_content(span); - if t.is_empty() { - None - } else { - Some(t) - } -} - -fn hash_rows(rows: &[ChatRow], total_unread: u32) -> u64 { - // Same fingerprint the recipe used: count, total unread, and the first - // five rows' (name, body, unread). Tiny FNV-1a over the concatenation. - let mut h: u64 = 0xcbf29ce484222325; - fn mix(h: &mut u64, b: u8) { - *h ^= b as u64; - *h = h.wrapping_mul(0x100000001b3); - } - for b in (rows.len() as u32).to_le_bytes() { - mix(&mut h, b); - } - for b in total_unread.to_le_bytes() { - mix(&mut h, b); - } - for r in rows { - for b in r.name.as_bytes() { - mix(&mut h, *b); - } - mix(&mut h, 0x7c); - if let Some(p) = &r.preview { - for b in p.as_bytes() { - mix(&mut h, *b); - } - } - mix(&mut h, 0x7c); - for b in r.unread.to_le_bytes() { - mix(&mut h, b); - } - } - h -} diff --git a/app/src-tauri/src/telegram_scanner/extract.rs b/app/src-tauri/src/telegram_scanner/extract.rs deleted file mode 100644 index 6f9d02e4b7..0000000000 --- a/app/src-tauri/src/telegram_scanner/extract.rs +++ /dev/null @@ -1,406 +0,0 @@ -//! Message / user / chat extraction from raw Telegram Web K IDB records. -//! -//! Telegram Web K persists messages, dialogs, users, and chats into the -//! `tweb` IndexedDB. Exact schema names have moved across tweb versions, -//! so rather than pin the walk to specific (database, store) pairs we -//! recurse depth-first and match record shapes — same pattern as the -//! Slack extractor. -//! -//! Matchers: -//! * **Message** — an object with a plausible unix-seconds `date` -//! (10-digit int in the 2000s/current era), a non-empty `message` -//! (or `text`) string, and either a `peerId` / `peer_id` identifier -//! or an inherited channel/peer hint from an enclosing key. -//! * **User** — any record with an integer `id` and at least one of -//! `first_name`, `last_name`, `username`. -//! * **Chat / channel** — any record with an integer `id` and a -//! non-empty `title`. Telegram uses the same `chats` table for -//! groups and channels; we flatten to a single (id → name) map. -//! * **Own user / session** — the first record carrying `self: true` -//! or `is_self: true` populates the "me" identity. -//! -//! Peer IDs in tweb can appear in two shapes: -//! * Integer — positive for users, the app applies a prefix shift to -//! distinguish chats vs channels internally. We treat any integer as -//! the raw key and resolve names via the users/chats maps. -//! * Object — `{ _: "peerUser" | "peerChat" | "peerChannel", user_id | -//! chat_id | channel_id: }` (TL schema style). -//! -//! Depth is capped at 40 so pathological graphs can't loop. - -use std::collections::HashMap; - -use serde_json::Value; - -/// Plausibility window for unix-second `date` values — 2015-01-01 to -/// roughly year 2100. Anything outside is noise (file sizes, version -/// numbers, ids, etc.). -const DATE_MIN: i64 = 1_420_070_400; -const DATE_MAX: i64 = 4_102_444_800; - -#[derive(Debug, Default, Clone)] -pub struct ExtractedMessage { - pub peer: String, - pub sender: String, - pub text: String, - pub date: i64, -} - -#[derive(Debug, Default)] -pub struct Harvest { - pub messages: Vec, - pub users: HashMap, - pub chats: HashMap, - pub self_id: Option, -} - -/// Main entry: walks every record in the dump and returns the grouped -/// harvest. -pub fn harvest(dump: &super::idb::IdbDump) -> Harvest { - let mut out = Harvest::default(); - - for db in &dump.dbs { - for store in &db.stores { - for rec in &store.records { - walk(rec, None, &mut out, 0); - } - } - } - out -} - -fn walk(v: &Value, peer_hint: Option<&str>, out: &mut Harvest, depth: u32) { - if depth > 40 { - return; - } - match v { - Value::Object(map) => { - // 1) Message-shape check: needs (date, message|text, peer). - if let Some(date) = map.get("date").and_then(|v| v.as_i64()) { - if (DATE_MIN..=DATE_MAX).contains(&date) { - let text = map - .get("message") - .and_then(|v| v.as_str()) - .or_else(|| map.get("text").and_then(|v| v.as_str())) - .map(str::to_string) - .unwrap_or_default(); - if !text.trim().is_empty() { - let peer = extract_peer(map).or_else(|| peer_hint.map(str::to_string)); - let sender = extract_sender(map).unwrap_or_default(); - if let Some(peer) = peer { - out.messages.push(ExtractedMessage { - peer, - sender, - text, - date, - }); - } - } - } - } - - // 2) User / chat directory entries (have a numeric `id`). - if let Some(id) = map.get("id").and_then(num_to_str) { - // User: `first_name` / `last_name` / `username` present. - let user_name = map - .get("first_name") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(|first| { - let last = map - .get("last_name") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim(); - if last.is_empty() { - first.to_string() - } else { - format!("{first} {last}") - } - }) - .or_else(|| { - map.get("username") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(str::to_string) - }); - if let Some(name) = user_name { - out.users.entry(id.clone()).or_insert(name); - - // Track the "self" user if the record marks itself. - let is_self = map.get("self").and_then(|v| v.as_bool()).unwrap_or(false) - || map - .get("is_self") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if is_self && out.self_id.is_none() { - out.self_id = Some(id.clone()); - } - } - - // Chat / channel: `title` present. - if let Some(title) = map - .get("title") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - { - out.chats - .entry(id.clone()) - .or_insert_with(|| title.to_string()); - } - } - - // 3) Recurse. If the current key looks like a peer id we pass - // it down as a hint so nested message arrays group correctly. - for (k, vv) in map.iter() { - let next_hint = if looks_like_peer_key(k) { - Some(k.as_str()) - } else { - peer_hint - }; - walk(vv, next_hint, out, depth + 1); - } - } - Value::Array(arr) => { - for vv in arr.iter() { - walk(vv, peer_hint, out, depth + 1); - } - } - Value::String(s) - if s.len() > 20 - && (s.starts_with('{') || s.starts_with('[')) - && (s.ends_with('}') || s.ends_with(']')) => - { - // Some tweb stores persist state as JSON-encoded strings. - // Recurse when the shape looks plausibly JSON. - if let Ok(inner) = serde_json::from_str::(s) { - walk(&inner, peer_hint, out, depth + 1); - } - } - _ => {} - } -} - -/// Pull the peer identifier out of a message record. Handles both the -/// integer and TL-object (`{ _: "peerUser", user_id: N }`) shapes. -fn extract_peer(map: &serde_json::Map) -> Option { - for key in [ - "peerId", - "peer_id", - "peer", - "dialog_peer_id", - "dialogPeerId", - ] { - if let Some(v) = map.get(key) { - if let Some(s) = num_to_str(v) { - return Some(s); - } - if let Some(obj) = v.as_object() { - for id_key in [ - "user_id", - "userId", - "chat_id", - "chatId", - "channel_id", - "channelId", - ] { - if let Some(id) = obj.get(id_key).and_then(num_to_str) { - return Some(id); - } - } - } - } - } - None -} - -/// Pull the sender identifier. Falls back to empty when not present (e.g. -/// service messages, channel posts without an explicit author). -fn extract_sender(map: &serde_json::Map) -> Option { - for key in ["fromId", "from_id", "fromID", "sender_id", "senderId"] { - if let Some(v) = map.get(key) { - if let Some(s) = num_to_str(v) { - return Some(s); - } - if let Some(obj) = v.as_object() { - for id_key in ["user_id", "userId", "channel_id", "channelId"] { - if let Some(id) = obj.get(id_key).and_then(num_to_str) { - return Some(id); - } - } - } - } - } - None -} - -/// A JSON `Value` viewed as an integer-ish id, serialised as a string so -/// it keys maps uniformly regardless of original encoding (int vs string). -fn num_to_str(v: &Value) -> Option { - match v { - Value::Number(n) => { - if let Some(i) = n.as_i64() { - Some(i.to_string()) - } else { - n.as_f64().map(|f| format!("{f}")) - } - } - Value::String(s) => { - let trimmed = s.trim(); - if trimmed.is_empty() { - None - } else if trimmed.chars().all(|c| c.is_ascii_digit() || c == '-') { - Some(trimmed.to_string()) - } else { - None - } - } - _ => None, - } -} - -/// Heuristic: a map key that's all digits (optionally negative) and 4+ -/// chars long is plausibly a peer id (Telegram ids are large). -fn looks_like_peer_key(k: &str) -> bool { - let bytes = k.as_bytes(); - if bytes.len() < 4 { - return false; - } - let (first, rest) = bytes.split_first().unwrap(); - let starts_ok = first.is_ascii_digit() || *first == b'-'; - starts_ok && rest.iter().all(|b| b.is_ascii_digit()) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn empty_dump() -> super::super::idb::IdbDump { - super::super::idb::IdbDump::default() - } - - #[test] - fn extracts_message_shape() { - let mut dump = empty_dump(); - dump.dbs.push(super::super::idb::IdbDb { - name: "tweb".into(), - stores: vec![super::super::idb::IdbStore { - name: "messages".into(), - count: 1, - records: vec![json!({ - "id": 42, - "date": 1_712_345_678_i64, - "message": "hello world", - "peerId": 123456789_i64, - "fromId": 987654321_i64, - })], - error: None, - }], - error: None, - }); - let h = harvest(&dump); - assert_eq!(h.messages.len(), 1); - assert_eq!(h.messages[0].peer, "123456789"); - assert_eq!(h.messages[0].sender, "987654321"); - assert_eq!(h.messages[0].text, "hello world"); - assert_eq!(h.messages[0].date, 1_712_345_678); - } - - #[test] - fn extracts_message_with_tl_peer_shape() { - let mut dump = empty_dump(); - dump.dbs.push(super::super::idb::IdbDb { - name: "tweb".into(), - stores: vec![super::super::idb::IdbStore { - name: "messages".into(), - count: 1, - records: vec![json!({ - "date": 1_712_345_678_i64, - "message": "channel post", - "peerId": { "_": "peerChannel", "channel_id": 555 }, - "fromId": { "_": "peerUser", "user_id": 777 }, - })], - error: None, - }], - error: None, - }); - let h = harvest(&dump); - assert_eq!(h.messages.len(), 1); - assert_eq!(h.messages[0].peer, "555"); - assert_eq!(h.messages[0].sender, "777"); - } - - #[test] - fn picks_up_user_and_chat_directories() { - let mut dump = empty_dump(); - dump.dbs.push(super::super::idb::IdbDb { - name: "tweb".into(), - stores: vec![super::super::idb::IdbStore { - name: "state".into(), - count: 1, - records: vec![json!({ - "users": [ - { "id": 111, "first_name": "Ada", "last_name": "Lovelace" }, - { "id": 222, "username": "babbage" }, - { "id": 333, "first_name": "Me", "self": true } - ], - "chats": [ - { "id": 444, "title": "Rust Lang" } - ] - })], - error: None, - }], - error: None, - }); - let h = harvest(&dump); - assert_eq!(h.users.get("111").map(String::as_str), Some("Ada Lovelace")); - assert_eq!(h.users.get("222").map(String::as_str), Some("babbage")); - assert_eq!(h.users.get("333").map(String::as_str), Some("Me")); - assert_eq!(h.chats.get("444").map(String::as_str), Some("Rust Lang")); - assert_eq!(h.self_id.as_deref(), Some("333")); - } - - #[test] - fn groups_messages_under_peer_key_hint() { - let mut dump = empty_dump(); - dump.dbs.push(super::super::idb::IdbDb { - name: "tweb".into(), - stores: vec![super::super::idb::IdbStore { - name: "dialogs".into(), - count: 1, - records: vec![json!({ - "999888777": [ - { "date": 1_712_345_678_i64, "message": "hi", "fromId": 111 } - ] - })], - error: None, - }], - error: None, - }); - let h = harvest(&dump); - assert_eq!(h.messages.len(), 1); - assert_eq!(h.messages[0].peer, "999888777"); - } - - #[test] - fn rejects_implausible_dates() { - let mut dump = empty_dump(); - dump.dbs.push(super::super::idb::IdbDb { - name: "tweb".into(), - stores: vec![super::super::idb::IdbStore { - name: "weird".into(), - count: 1, - records: vec![json!({ - "date": 42, - "message": "nope", - "peerId": 1, - })], - error: None, - }], - error: None, - }); - let h = harvest(&dump); - assert_eq!(h.messages.len(), 0); - } -} diff --git a/app/src-tauri/src/telegram_scanner/idb.rs b/app/src-tauri/src/telegram_scanner/idb.rs deleted file mode 100644 index 5fd1fe173b..0000000000 --- a/app/src-tauri/src/telegram_scanner/idb.rs +++ /dev/null @@ -1,326 +0,0 @@ -//! Telegram Web K IndexedDB walk driven purely through the CDP `IndexedDB` -//! domain. -//! -//! No JavaScript runs in the page — `IndexedDB.requestDatabaseNames`, -//! `IndexedDB.requestDatabase`, and `IndexedDB.requestData` page through -//! every store at the browser's C++ layer. `Runtime.callFunctionOn` with a -//! fixed, Telegram-agnostic serializer -//! (`function(){return [this].concat(Array.prototype.slice.call(arguments));}`) -//! materialises each batch of `Runtime.RemoteObject`s into JSON via -//! `returnByValue`. The serializer is structural; it can't read anything -//! the page doesn't already hold. It runs once per batch of ~32 records, -//! not once per scan. -//! -//! Telegram Web K persists its entity tables to a database called `tweb` -//! with object stores like `users`, `chats`, `dialogs`, `messages`, etc. -//! Schema details move across tweb versions, so we enumerate all stores -//! in every non-skipped database the origin owns rather than pinning to -//! a single (database, store) pair. Extraction happens in `extract.rs`. - -use serde_json::{json, Value}; - -use crate::cdp::CdpConn; - -/// CDP-known origin for the Telegram Web K app (`https://web.telegram.org/k/`). -const ORIGIN: &str = "https://web.telegram.org"; -/// Row window per `IndexedDB.requestData` call. Telegram's message blobs -/// tend to be small, but some stores (stickers, cached media) can be -/// huge — keeping the page modest avoids big RemoteObject batches. -const PAGE_SIZE: i64 = 50; -/// Per-store ceiling — safety net against runaway stores, not a hard limit. -const MAX_RECORDS_PER_STORE: usize = 5_000; -/// Max `Runtime.RemoteObject`s to materialise in a single -/// `Runtime.callFunctionOn`. -const SERIALIZE_BATCH: usize = 32; -/// Skip databases that are not useful for message extraction. -const SKIP_DB_PREFIXES: &[&str] = &[ - "webpack", - "databases", // Chromium's own metadata DB - "tweb-files", // blob cache — no text - "tweb-thumbs", // thumbnails - "tweb-stickers", // sticker caches - "localforage", // opaque serialised blobs -]; - -/// Product of one full walk — raw records grouped by (database, store) -/// so downstream extraction can log per-source counts. Debug-only fields -/// (`error`, `count`, `name`) are kept for log/inspection even though the -/// extractor only reads `records`. -#[derive(Debug, Default)] -pub struct IdbDump { - pub dbs: Vec, -} - -#[derive(Debug, Default)] -#[allow(dead_code)] -pub struct IdbDb { - pub name: String, - pub stores: Vec, - pub error: Option, -} - -#[derive(Debug, Default)] -#[allow(dead_code)] -pub struct IdbStore { - pub name: String, - pub records: Vec, - pub count: i64, - pub error: Option, -} - -/// Walk every Telegram-relevant IndexedDB database on `ORIGIN`. Returns a -/// flat dump — no per-record normalisation happens here; that lives in -/// `extract::harvest` because the record shapes vary across stores. -pub async fn walk(cdp: &mut CdpConn, session: &str) -> Result { - if let Err(e) = cdp.call("IndexedDB.enable", json!({}), Some(session)).await { - log::debug!("[tg][idb] enable: {}", e); - } - - let names_v = cdp - .call( - "IndexedDB.requestDatabaseNames", - json!({ "securityOrigin": ORIGIN }), - Some(session), - ) - .await?; - let names: Vec = names_v - .get("databaseNames") - .and_then(|x| x.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); - - log::info!( - "[tg][idb] found {} databases at origin {}: {:?}", - names.len(), - ORIGIN, - names - ); - - let mut dump = IdbDump::default(); - for name in names { - if SKIP_DB_PREFIXES.iter().any(|p| name.starts_with(p)) { - log::debug!("[tg][idb] skipping db {}", name); - continue; - } - match walk_database(cdp, session, &name).await { - Ok(db) => { - log::info!( - "[tg][idb] db={} stores={} total_records={}", - db.name, - db.stores.len(), - db.stores.iter().map(|s| s.records.len()).sum::() - ); - dump.dbs.push(db); - } - Err(e) => { - log::warn!("[tg][idb] db={} failed: {}", name, e); - dump.dbs.push(IdbDb { - name, - error: Some(e), - ..Default::default() - }); - } - } - } - Ok(dump) -} - -async fn walk_database(cdp: &mut CdpConn, session: &str, db_name: &str) -> Result { - let meta = cdp - .call( - "IndexedDB.requestDatabase", - json!({ - "securityOrigin": ORIGIN, - "databaseName": db_name, - }), - Some(session), - ) - .await?; - - let store_names: Vec = meta - .pointer("/databaseWithObjectStores/objectStores") - .and_then(|x| x.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|s| s.get("name").and_then(|n| n.as_str()).map(String::from)) - .collect() - }) - .unwrap_or_default(); - - let mut db = IdbDb { - name: db_name.to_string(), - ..Default::default() - }; - for store_name in store_names { - match read_store(cdp, session, db_name, &store_name).await { - Ok((records, count)) => { - log::debug!( - "[tg][idb] db={} store={} count={} fetched={}", - db_name, - store_name, - count, - records.len() - ); - db.stores.push(IdbStore { - name: store_name, - records, - count, - error: None, - }); - } - Err(e) => { - log::warn!( - "[tg][idb] db={} store={} failed: {}", - db_name, - store_name, - e - ); - db.stores.push(IdbStore { - name: store_name, - error: Some(e), - ..Default::default() - }); - } - } - } - Ok(db) -} - -/// Page through `objectStoreName` via `IndexedDB.requestData`, materialising -/// each value RemoteObject into JSON. Stops at `MAX_RECORDS_PER_STORE` or -/// when `hasMore: false`. Returns `(records, total_fetched_count)`. -async fn read_store( - cdp: &mut CdpConn, - session: &str, - database_name: &str, - store: &str, -) -> Result<(Vec, i64), String> { - let mut out: Vec = Vec::new(); - let mut skip: i64 = 0; - loop { - let remaining = MAX_RECORDS_PER_STORE.saturating_sub(out.len()); - if remaining == 0 { - break; - } - let page = (remaining as i64).min(PAGE_SIZE); - // NB: `indexName` is deliberately omitted — passing an empty - // string makes this CEF build reject the request with - // "Could not get index". The CDP spec says empty string means - // "primary key index", but the C++ backend here only accepts an - // unset field. Confirmed against CEF 146 (Chrome 146.0.7680.165). - let resp = cdp - .call( - "IndexedDB.requestData", - json!({ - "securityOrigin": ORIGIN, - "databaseName": database_name, - "objectStoreName": store, - "skipCount": skip, - "pageSize": page, - }), - Some(session), - ) - .await?; - let entries = resp - .get("objectStoreDataEntries") - .and_then(|x| x.as_array()) - .cloned() - .unwrap_or_default(); - if entries.is_empty() { - break; - } - let value_refs: Vec<&Value> = entries - .iter() - .map(|e| e.get("value").unwrap_or(&Value::Null)) - .collect(); - let materialised = serialize_values(cdp, session, &value_refs).await?; - out.extend(materialised); - let has_more = resp - .get("hasMore") - .and_then(|x| x.as_bool()) - .unwrap_or(false); - skip += entries.len() as i64; - if !has_more { - break; - } - } - Ok((out, skip)) -} - -/// Convert a list of `Runtime.RemoteObject` references (as returned inside -/// `ObjectStoreDataEntry.value`) into JSON. Primitives are read off the -/// RemoteObject's inline `value`; complex objects are batched through -/// `Runtime.callFunctionOn` with a generic serializer. Same pattern as -/// `slack_scanner::idb::serialize_values`. -async fn serialize_values( - cdp: &mut CdpConn, - session: &str, - values: &[&Value], -) -> Result, String> { - let mut result: Vec = vec![Value::Null; values.len()]; - let mut pending: Vec<(usize, String)> = Vec::new(); - for (i, v) in values.iter().enumerate() { - if let Some(inline) = v.get("value") { - result[i] = inline.clone(); - continue; - } - if let Some(oid) = v.get("objectId").and_then(|x| x.as_str()) { - pending.push((i, oid.to_string())); - continue; - } - } - for chunk in pending.chunks(SERIALIZE_BATCH) { - let oids: Vec<&str> = chunk.iter().map(|(_, oid)| oid.as_str()).collect(); - let serialised = call_function_batch(cdp, session, &oids).await?; - if serialised.len() != chunk.len() { - return Err(format!( - "serialise batch length mismatch: got {}, expected {}", - serialised.len(), - chunk.len() - )); - } - for ((idx, _), val) in chunk.iter().zip(serialised) { - result[*idx] = val; - } - } - Ok(result) -} - -async fn call_function_batch( - cdp: &mut CdpConn, - session: &str, - object_ids: &[&str], -) -> Result, String> { - if object_ids.is_empty() { - return Ok(Vec::new()); - } - let (first, rest) = object_ids.split_first().unwrap(); - let args: Vec = rest.iter().map(|oid| json!({ "objectId": oid })).collect(); - let resp = cdp - .call_with_timeout( - "Runtime.callFunctionOn", - json!({ - "objectId": first, - "functionDeclaration": "function(){return [this].concat(Array.prototype.slice.call(arguments));}", - "arguments": args, - "returnByValue": true, - "silent": true, - }), - Some(session), - std::time::Duration::from_secs(60), - ) - .await?; - if let Some(exc) = resp.get("exceptionDetails") { - return Err(format!("callFunctionOn threw: {exc}")); - } - let arr = resp - .pointer("/result/value") - .and_then(|v| v.as_array()) - .cloned() - .ok_or_else(|| format!("callFunctionOn result not array: {resp}"))?; - Ok(arr) -} diff --git a/app/src-tauri/src/telegram_scanner/mod.rs b/app/src-tauri/src/telegram_scanner/mod.rs deleted file mode 100644 index 92a1ad9e38..0000000000 --- a/app/src-tauri/src/telegram_scanner/mod.rs +++ /dev/null @@ -1,777 +0,0 @@ -//! Telegram Web K scanner driven purely over the Chrome DevTools Protocol. -//! -//! Attaches to the embedded CEF webview via the in-process CDP transport -//! installed by `webview_accounts::open` (no TCP listener). One polling -//! loop per tracked Telegram account: -//! -//! * **IDB tick** (`IDB_SCAN_INTERVAL`, 30s) — walks every Telegram-owned -//! IndexedDB database via CDP (`IndexedDB.requestDatabaseNames`, -//! `IndexedDB.requestDatabase`, `IndexedDB.requestData`), materialises -//! `Runtime.RemoteObject` records into JSON with a fixed, Telegram- -//! agnostic serializer (`function(){return [this].concat(arguments);}`), -//! and recursively extracts message / user / chat records from the -//! `tweb` snapshot. No in-page JavaScript runs beyond that one fixed -//! serializer, and no DOM scraping. -//! -//! Emits `webview:event` ingest events (for any listening React UI) AND -//! POSTs `openhuman.memory_doc_ingest` directly to the core so memory is -//! populated whether or not the main window is open. Messages are grouped -//! by peer so each peer's transcript upserts a single doc. -//! -//! Only built with the `cef` feature — wry has no remote-debugging port. - -use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::time::Duration; - -use parking_lot::Mutex; -use serde_json::{json, Value}; -use tauri::{AppHandle, Emitter, Runtime}; -use tokio::task::AbortHandle; -use tokio::time::sleep; - -mod dom_snapshot; -mod extract; -mod idb; - -/// How often we walk IDB. Tune down for faster iteration during dev; the -/// walk itself is bounded by per-store record caps in `idb.rs`. -const IDB_SCAN_INTERVAL: Duration = Duration::from_secs(30); - -/// Max concurrent `memory_doc_ingest` POSTs during a bulk-history drain. A large -/// Telegram account is hundreds of peers; firing them all at once saturated the -/// single local core RPC (`127.0.0.1:7788`) and starved interactive UI calls -/// (`threads_messages_list`, …) — issue #4714. Keep only a few in flight. -const MAX_CONCURRENT_INGESTS: usize = 3; -/// Small pause between launching bulk writes, leaving the single local core RPC -/// server headroom to serve interactive UI calls between ingests. -const INGEST_PACE: Duration = Duration::from_millis(50); -/// True while a bulk-history drain is in flight. The IDB scan loop re-emits the -/// FULL peer set every `IDB_SCAN_INTERVAL` (30s), but a drain of a large account -/// takes far longer than that; without this guard each cycle would stack a fresh -/// flood on top of the previous one. We skip launching a new drain while one is -/// running — the next cycle re-emits everything once it finishes (issue #4714). -static INGEST_IN_FLIGHT: AtomicBool = AtomicBool::new(false); - -/// Spawn a per-account CDP poller. Caller is expected to guard against -/// double-spawning via `ScannerRegistry`. -pub fn spawn_scanner( - app: AppHandle, - account_id: String, - url_prefix: String, -) -> Vec { - let mut handles = Vec::with_capacity(2); - // Independent fast-tick task for the DOM chat-list scrape (replaces - // the old recipe.js setInterval). Decoupled from the slow IDB loop so - // an IDB failure doesn't stall the UI's unread-badge updates. - handles.push(spawn_dom_poll( - app.clone(), - account_id.clone(), - url_prefix.clone(), - )); - let task = tokio::spawn(async move { - let fragment = crate::cdp::target_url_fragment(&account_id); - log::info!( - "[tg] scanner up account={} url_prefix={} fragment={} interval={:?}", - account_id, - url_prefix, - fragment, - IDB_SCAN_INTERVAL, - ); - // Let tweb hydrate IDB before the first scan — otherwise we'd - // race empty stores on cold start. - sleep(Duration::from_secs(10)).await; - - loop { - match scan_once(&app, &account_id, &url_prefix, &fragment).await { - Ok(dump) => { - let harvest = extract::harvest(&dump); - log::info!( - "[tg][{}] idb extract: {} msgs, {} users, {} chats, self={}", - account_id, - harvest.messages.len(), - harvest.users.len(), - harvest.chats.len(), - harvest.self_id.as_deref().unwrap_or("?"), - ); - if !harvest.messages.is_empty() { - emit_and_persist(&app, &account_id, &harvest); - } - } - Err(e) => { - log::warn!("[tg][{}] idb scan failed: {}", account_id, e); - } - } - sleep(IDB_SCAN_INTERVAL).await; - } - }); - handles.push(task.abort_handle()); - handles -} - -/// Single scan cycle: attach to the Telegram page via the account's -/// in-process CDP transport, walk IDB, detach. -async fn scan_once( - app: &AppHandle, - account_id: &str, - url_prefix: &str, - url_fragment: &str, -) -> Result { - let url_prefix_owned = url_prefix.to_string(); - let url_fragment_owned = url_fragment.to_string(); - let pred = move |t: &crate::cdp::target::CdpTarget| -> bool { - t.url.starts_with(&url_prefix_owned) && t.url.ends_with(&url_fragment_owned) - }; - let (mut cdp, session) = - crate::cdp::target::connect_and_attach_matching_in_process::(app, account_id, pred) - .await - .map_err(|e| format!("attach: {e} (prefix={url_prefix} fragment={url_fragment})"))?; - - let result = idb::walk(&mut cdp, &session).await; - - let _ = cdp - .call( - "Target.detachFromTarget", - json!({ "sessionId": session }), - None, - ) - .await; - - let dump = result?; - log::info!( - "[tg][{}] scan ok dbs={} total_records={}", - account_id, - dump.dbs.len(), - dump.dbs - .iter() - .flat_map(|d| d.stores.iter()) - .map(|s| s.records.len()) - .sum::(), - ); - Ok(dump) -} - -/// Group messages by peer, emit one `webview:event` per peer, and POST -/// the same payload to `openhuman.memory_doc_ingest`. One memory doc per -/// peer — the transcript inside can be long, each message line still -/// carries its own date + time so the full chronology stays readable. -fn emit_and_persist(app: &AppHandle, account_id: &str, harvest: &extract::Harvest) { - #[derive(Default)] - struct Group { - rows: Vec, - } - let mut groups: HashMap = HashMap::new(); - for m in &harvest.messages { - if m.peer.is_empty() || m.date <= 0 { - continue; - } - let sender_name = if !m.sender.is_empty() { - harvest - .users - .get(&m.sender) - .cloned() - .unwrap_or_else(|| m.sender.clone()) - } else { - String::new() - }; - let row = json!({ - "date": m.date, - "sender": sender_name, - "sender_id": m.sender, - "body": m.text, - }); - groups.entry(m.peer.clone()).or_default().rows.push(row); - } - - let mut emitted = 0usize; - let mut pending: Vec = Vec::new(); - for (peer_id, group) in groups { - let mut rows = group.rows; - rows.sort_by_key(|r| r.get("date").and_then(|v| v.as_i64()).unwrap_or(0)); - // De-duplicate by (date, sender_id, body) — the walker can see the - // same record in multiple store snapshots, so dedupe is not optional. - let mut seen: std::collections::HashSet<(i64, String, String)> = - std::collections::HashSet::new(); - rows.retain(|r| { - let k = ( - r.get("date").and_then(|v| v.as_i64()).unwrap_or(0), - r.get("sender_id") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - r.get("body") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - ); - seen.insert(k) - }); - if rows.is_empty() { - continue; - } - let peer_name = harvest - .users - .get(&peer_id) - .cloned() - .or_else(|| harvest.chats.get(&peer_id).cloned()) - .unwrap_or_else(|| peer_id.clone()); - - let payload = json!({ - "provider": "telegram", - "source": "cdp-idb", - "peerId": peer_id, - "peerName": peer_name, - "selfId": harvest.self_id.clone().unwrap_or_default(), - "messages": rows, - }); - let envelope = json!({ - "account_id": account_id, - "provider": "telegram", - "kind": "ingest", - "payload": payload.clone(), - "ts": chrono_now_millis(), - }); - if let Err(e) = app.emit("webview:event", &envelope) { - log::warn!("[tg][{}] ingest emit failed: {}", account_id, e); - } else { - emitted += 1; - } - pending.push(payload); - } - log::info!("[tg][{}] emitted {} peer doc(s)", account_id, emitted); - - if pending.is_empty() { - return; - } - // Back-pressure the bulk ingest (issue #4714): a large account is hundreds of - // peers and the scan loop re-emits the full set every 30s. Skip if a previous - // drain is still running so cycles can't stack, then drain with bounded - // concurrency + pacing so interactive UI RPCs are never starved. - if INGEST_IN_FLIGHT - .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) - .is_err() - { - log::info!( - "[tg][{}] bulk ingest already in flight; skipping {} doc(s) this cycle", - account_id, - pending.len() - ); - return; - } - let acct = account_id.to_string(); - tokio::spawn(async move { - drain_ingests(&acct, pending).await; - INGEST_IN_FLIGHT.store(false, Ordering::SeqCst); - }); -} - -/// Drain `payloads` to `openhuman.memory_doc_ingest` with bounded concurrency -/// and pacing, so a bulk Telegram history import cannot monopolize the single -/// local core RPC (issue #4714). -async fn drain_ingests(account_id: &str, payloads: Vec) { - let total = payloads.len(); - bounded_drain(payloads, MAX_CONCURRENT_INGESTS, INGEST_PACE, |payload| { - let acct = account_id.to_string(); - async move { - if let Err(e) = post_memory_doc_ingest(&acct, &payload).await { - log::warn!("[tg][{}] memory write failed: {}", acct, e); - } - } - }) - .await; - log::info!("[tg][{}] bulk ingest drained {} doc(s)", account_id, total); -} - -/// Run `op(item)` for every item with at most `max_concurrency` futures in -/// flight and a `pace` pause between launches. Bounds a burst of work so it -/// can't monopolize a shared resource (here, the single local core RPC). -/// Extracted so the concurrency bound is unit-testable without a live server. -async fn bounded_drain(items: Vec, max_concurrency: usize, pace: Duration, op: F) -where - T: Send + 'static, - F: Fn(T) -> Fut, - Fut: std::future::Future + Send + 'static, -{ - let sem = Arc::new(tokio::sync::Semaphore::new(max_concurrency.max(1))); - let mut set = tokio::task::JoinSet::new(); - for item in items { - // Block until a permit frees, capping the number of in-flight writes. - let Ok(permit) = Arc::clone(&sem).acquire_owned().await else { - break; - }; - let fut = op(item); - set.spawn(async move { - let _permit = permit; - fut.await; - }); - if !pace.is_zero() { - sleep(pace).await; - } - } - while set.join_next().await.is_some() {} -} - -/// Unix seconds → UTC `YYYY-MM-DD` (Howard Hinnant civil-from-days). -fn seconds_to_ymd(secs: i64) -> String { - let days = secs.div_euclid(86_400); - let z = days + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = (z - era * 146_097) as u64; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; - let y_real = (if m <= 2 { y + 1 } else { y }) as i32; - format!("{:04}-{:02}-{:02}", y_real, m, d) -} - -fn chrono_now_millis() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) -} - -/// Build and POST the `openhuman.memory_doc_ingest` payload for a single -/// peer transcript. Mirrors `slack_scanner::post_memory_doc_ingest`. -async fn post_memory_doc_ingest(account_id: &str, ingest: &Value) -> Result<(), String> { - let peer_id = ingest - .get("peerId") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - let peer_name = ingest - .get("peerName") - .and_then(|v| v.as_str()) - .unwrap_or(peer_id); - let self_id = ingest - .get("selfId") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - let empty: Vec = Vec::new(); - let msgs = ingest - .get("messages") - .and_then(|v| v.as_array()) - .unwrap_or(&empty); - if peer_id.is_empty() || msgs.is_empty() { - return Ok(()); - } - - let mut sorted: Vec<&Value> = msgs.iter().collect(); - sorted.sort_by_key(|m| m.get("date").and_then(|v| v.as_i64()).unwrap_or(0)); - - let first_ts = sorted - .first() - .and_then(|m| m.get("date")) - .and_then(|v| v.as_i64()) - .unwrap_or(0); - let last_ts = sorted - .last() - .and_then(|m| m.get("date")) - .and_then(|v| v.as_i64()) - .unwrap_or(0); - - let transcript: String = sorted - .iter() - .map(|m| { - let ts = m.get("date").and_then(|v| v.as_i64()).unwrap_or(0); - let stamp = if ts > 0 { - let day = seconds_to_ymd(ts); - let secs_of_day = (ts.rem_euclid(86_400)) as u32; - format!( - "{} {:02}:{:02}Z", - day, - secs_of_day / 3600, - (secs_of_day / 60) % 60 - ) - } else { - "?".to_string() - }; - let who = m - .get("sender") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .unwrap_or("?"); - let body = m - .get("body") - .and_then(|v| v.as_str()) - .unwrap_or("") - .replace(['\r', '\n'], " "); - format!("[{stamp}] {who}: {body}") - }) - .collect::>() - .join("\n"); - - let first_day = if first_ts > 0 { - seconds_to_ymd(first_ts) - } else { - String::new() - }; - let last_day = if last_ts > 0 { - seconds_to_ymd(last_ts) - } else { - String::new() - }; - let header = format!( - "# Telegram — {peer}\npeer_id: {peer_id}\naccount_id: {account_id}\nmessages: {n}\nrange: {first_day} → {last_day}\n\n", - peer = peer_name, - peer_id = peer_id, - account_id = account_id, - n = sorted.len(), - first_day = first_day, - last_day = last_day, - ); - let content = format!("{header}{transcript}"); - - // Key = peer name when clean, falling back to the raw peer id. - // `:` is reserved by the memory layer (it rewrites to `_`). - let namespace = format!("telegram-web:{account_id}"); - let key = if peer_key_looks_clean(peer_name) { - peer_name.to_string() - } else { - peer_id.to_string() - }; - let title = format!("Telegram · {peer_name}"); - - let params = json!({ - "namespace": namespace, - "key": key, - "title": title, - "content": content, - "source_type": "telegram-web", - "priority": "medium", - "tags": ["telegram", "peer-transcript"], - "metadata": { - "provider": "telegram", - "account_id": account_id, - "peer_id": peer_id, - "peer_name": peer_name, - "self_id": self_id, - "first_day": first_day, - "last_day": last_day, - "message_count": sorted.len(), - }, - "category": "core", - }); - let body = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "openhuman.memory_doc_ingest", - "params": params, - }); - - let url = crate::core_rpc::core_rpc_url_value(); - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(15)) - .build() - .map_err(|e| format!("http client: {e}"))?; - let req = crate::core_rpc::apply_auth(client.post(&url)) - .map_err(|e| format!("prepare {url}: {e}"))?; - let resp = req - .json(&body) - .send() - .await - .map_err(|e| format!("POST {url}: {e}"))?; - let status = resp.status(); - if !status.is_success() { - let body = resp.text().await.unwrap_or_default(); - return Err(format!("{status}: {body}")); - } - let v: Value = resp.json().await.map_err(|e| format!("decode: {e}"))?; - if let Some(err) = v.get("error") { - return Err(format!("rpc error: {err}")); - } - log::info!( - "[tg][{}] memory upsert ok namespace={} key={} msgs={} range={}→{}", - account_id, - namespace, - key, - sorted.len(), - first_day, - last_day, - ); - Ok(()) -} - -/// Allow a peer name as a memory-doc key only if it stays within a -/// conservative ASCII-ish slug shape. Reject anything with `:` (reserved -/// by the memory layer), spaces, or non-ASCII; those fall back to the -/// stable peer id. Telegram titles are often unicode / contain spaces, so -/// this will frequently return false — that's the safe default. -fn peer_key_looks_clean(name: &str) -> bool { - if name.is_empty() { - return false; - } - name.chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') -} - -const DOM_POLL_INTERVAL: Duration = Duration::from_secs(2); - -/// Fast DOM-only poll — runs every 2s, emits an `ingest` webview:event -/// only when the row-set hash changes. Pure CDP: DOMSnapshot.captureSnapshot -/// runs at the browser's C++ layer, no JS executes in the page world. -fn spawn_dom_poll( - app: AppHandle, - account_id: String, - url_prefix: String, -) -> AbortHandle { - let task = tokio::spawn(async move { - let fragment = crate::cdp::target_url_fragment(&account_id); - // Wait long enough for tweb to populate the chatlist — polling - // before that would just emit empty ingests. - sleep(Duration::from_secs(8)).await; - let mut last_hash: Option = None; - loop { - match dom_scan_once(&app, &account_id, &url_prefix, &fragment).await { - Ok(scan) => { - if Some(scan.hash) != last_hash { - log::info!( - "[tg][{}] dom scan rows={} unread={} hash={:x}", - account_id, - scan.rows.len(), - scan.total_unread, - scan.hash - ); - last_hash = Some(scan.hash); - let envelope = json!({ - "account_id": account_id, - "provider": "telegram", - "kind": "ingest", - "payload": dom_snapshot::ingest_payload(&scan), - "ts": chrono_now_millis(), - }); - if let Err(e) = app.emit("webview:event", &envelope) { - log::warn!("[tg][{}] dom ingest emit failed: {}", account_id, e); - } - } - } - Err(e) => { - log::debug!("[tg][{}] dom scan: {}", account_id, e); - } - } - sleep(DOM_POLL_INTERVAL).await; - } - }); - task.abort_handle() -} - -async fn dom_scan_once( - app: &AppHandle, - account_id: &str, - url_prefix: &str, - url_fragment: &str, -) -> Result { - let prefix = url_prefix.to_string(); - let fragment = url_fragment.to_string(); - let pred = move |t: &crate::cdp::target::CdpTarget| -> bool { - t.url.starts_with(&prefix) && t.url.ends_with(&fragment) - }; - let (mut cdp, session) = - crate::cdp::target::connect_and_attach_matching_in_process::(app, account_id, pred) - .await?; - let scan = dom_snapshot::scan(&mut cdp, &session).await; - crate::cdp::detach_session(&mut cdp, &session).await; - scan -} - -/// Registry to prevent double-spawning scanners for the same account. -#[derive(Default)] -pub struct ScannerRegistry { - started: Mutex>>, -} - -impl ScannerRegistry { - pub fn new() -> Arc { - Arc::new(Self::default()) - } - - pub fn ensure_scanner( - &self, - app: AppHandle, - account_id: String, - url_prefix: String, - ) { - let mut g = self.started.lock(); - if g.contains_key(&account_id) { - log::debug!("[tg] scanner already running for {}", account_id); - return; - } - let handles = spawn_scanner(app, account_id.clone(), url_prefix); - g.insert(account_id, handles); - } - - pub fn forget(&self, account_id: &str) { - let handles = self.started.lock().remove(account_id); - if let Some(handles) = handles { - let count = handles.len(); - for handle in handles { - handle.abort(); - } - log::info!("[tg] aborted {} scanner task(s) for {}", count, account_id); - } - } - - pub fn forget_all(&self) -> usize { - let entries: Vec<_> = self.started.lock().drain().collect(); - let task_count = entries.iter().map(|(_, handles)| handles.len()).sum(); - for (account_id, handles) in entries { - for handle in handles { - handle.abort(); - } - log::debug!("[tg] aborted scanner tasks for {}", account_id); - } - if task_count > 0 { - log::info!("[tg] aborted {} scanner task(s)", task_count); - } - task_count - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn insert_pending_tasks( - registry: &ScannerRegistry, - account_id: &str, - count: usize, - ) -> Vec> { - let mut tasks = Vec::with_capacity(count); - let mut abort_handles = Vec::with_capacity(count); - for _ in 0..count { - let task = tokio::spawn(async { - std::future::pending::<()>().await; - }); - abort_handles.push(task.abort_handle()); - tasks.push(task); - } - registry - .started - .lock() - .insert(account_id.to_string(), abort_handles); - tasks - } - - async fn assert_cancelled(task: tokio::task::JoinHandle<()>) { - let err = tokio::time::timeout(Duration::from_secs(1), task) - .await - .expect("aborted scanner task should finish") - .expect_err("scanner task should be cancelled"); - assert!(err.is_cancelled()); - } - - async fn assert_all_cancelled(tasks: Vec>) { - for task in tasks { - assert_cancelled(task).await; - } - } - - #[tokio::test] - async fn bounded_drain_caps_concurrency_and_runs_every_item() { - use std::sync::atomic::AtomicUsize; - - let inflight = Arc::new(AtomicUsize::new(0)); - let max_seen = Arc::new(AtomicUsize::new(0)); - let done = Arc::new(AtomicUsize::new(0)); - let (inflight_c, max_c, done_c) = (inflight.clone(), max_seen.clone(), done.clone()); - - let items: Vec = (0..20).collect(); - bounded_drain(items, 3, Duration::from_millis(0), move |_item| { - let (inflight, max_seen, done) = (inflight_c.clone(), max_c.clone(), done_c.clone()); - async move { - let cur = inflight.fetch_add(1, Ordering::SeqCst) + 1; - max_seen.fetch_max(cur, Ordering::SeqCst); - tokio::time::sleep(Duration::from_millis(10)).await; - inflight.fetch_sub(1, Ordering::SeqCst); - done.fetch_add(1, Ordering::SeqCst); - } - }) - .await; - - assert_eq!(done.load(Ordering::SeqCst), 20, "every item must run"); - let peak = max_seen.load(Ordering::SeqCst); - assert!(peak >= 2, "work should actually overlap (peak {peak})"); - assert!( - peak <= 3, - "concurrency must stay bounded to 3 (peak {peak})" - ); - assert_eq!( - inflight.load(Ordering::SeqCst), - 0, - "no in-flight tasks should leak" - ); - } - - #[tokio::test] - async fn registry_forget_aborts_all_handles_for_account_only() { - let registry = ScannerRegistry::default(); - let account_tasks = insert_pending_tasks(®istry, "acct-1", 2); - let survivor_tasks = insert_pending_tasks(®istry, "acct-2", 1); - - registry.forget("acct-1"); - - { - let guard = registry.started.lock(); - assert_eq!(guard.len(), 1); - assert!(guard.contains_key("acct-2")); - } - assert_all_cancelled(account_tasks).await; - assert!( - !survivor_tasks[0].is_finished(), - "forget(acct-1) must not abort acct-2" - ); - - assert_eq!(registry.forget_all(), 1); - assert_all_cancelled(survivor_tasks).await; - } - - #[tokio::test] - async fn registry_forget_missing_account_is_noop() { - let registry = ScannerRegistry::default(); - let mut tasks = insert_pending_tasks(®istry, "acct-1", 1); - - registry.forget("missing"); - - { - let guard = registry.started.lock(); - assert_eq!(guard.len(), 1); - assert!(guard.contains_key("acct-1")); - } - assert!( - !tasks[0].is_finished(), - "forget(missing) must not abort existing scanners" - ); - - registry.forget("acct-1"); - assert_cancelled(tasks.pop().expect("task")).await; - } - - #[tokio::test] - async fn registry_forget_all_aborts_all_tasks_and_reports_handle_count() { - let registry = ScannerRegistry::default(); - let task_a = insert_pending_tasks(®istry, "acct-1", 2); - let task_b = insert_pending_tasks(®istry, "acct-2", 3); - - assert_eq!(registry.forget_all(), 5); - - assert!(registry.started.lock().is_empty()); - assert_all_cancelled(task_a).await; - assert_all_cancelled(task_b).await; - } - - #[tokio::test] - async fn registry_forget_all_is_repeatable_noop_after_drain() { - let registry = ScannerRegistry::default(); - assert_eq!(registry.forget_all(), 0); - - let tasks = insert_pending_tasks(®istry, "acct-1", 1); - assert_eq!(registry.forget_all(), 1); - assert_eq!(registry.forget_all(), 0); - - assert!(registry.started.lock().is_empty()); - assert_all_cancelled(tasks).await; - } -} diff --git a/app/src-tauri/src/webview_accounts/mod.rs b/app/src-tauri/src/webview_accounts/mod.rs deleted file mode 100644 index 5d78829f11..0000000000 --- a/app/src-tauri/src/webview_accounts/mod.rs +++ /dev/null @@ -1,3303 +0,0 @@ -//! Franz-style embedded webview accounts. -//! -//! Hosts third-party web apps (WhatsApp Web, Slack, …) as a child Tauri -//! `Webview` positioned inside the main React window at a rect chosen by the -//! UI. A small per-provider "recipe" JS file is injected via -//! `initialization_script` to scrape the DOM and pipe state back to Rust as -//! `webview_recipe_event` invocations. Rust forwards each event up to the -//! React UI as a `webview:event` Tauri event; React is responsible for -//! persisting interesting payloads to memory via the existing core RPC. -//! -//! Architecture: -//! React → invoke('webview_account_open', …) → spawn child Webview -//! React → invoke('webview_account_bounds', …) → reposition / resize -//! recipe → invoke('webview_recipe_event', …) → emit('webview:event', …) -//! -//! Per-account session isolation: each account gets its own -//! `data_directory` under `{app_local_data_dir}/webview_accounts/{id}` so -//! cookies and storage don't bleed between accounts (best-effort on -//! WKWebView — see Tauri docs on `data_store_identifier` for the macOS path). - -use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; -use std::sync::Mutex; -#[cfg(target_os = "linux")] -use std::sync::{mpsc::sync_channel, OnceLock}; -use std::time::{Duration, Instant}; - -use crate::cdp; -use chrono::{TimeZone, Utc}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use tauri::{ - webview::NewWindowResponse, AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, Runtime, - Url, WebviewBuilder, WebviewUrl, -}; -#[cfg(windows)] -use tauri_plugin_notification::NotificationExt; - -const RUNTIME_JS: &str = include_str!("runtime.js"); -const LINKEDIN_RECIPE_JS: &str = include_str!("../../recipes/linkedin/recipe.js"); -const GOOGLE_MEET_RECIPE_JS: &str = include_str!("../../recipes/google-meet/recipe.js"); - -/// Registered providers and their service URLs. Add a new arm here plus a -/// recipe.js file under `recipes//` to support another provider. -fn provider_url(provider: &str) -> Option<&'static str> { - match provider { - "whatsapp" => Some("https://web.whatsapp.com/"), - "wechat" => Some("https://web.wechat.com/"), - "telegram" => Some("https://web.telegram.org/k/"), - "linkedin" => Some("https://www.linkedin.com/messaging/"), - "slack" => Some("https://app.slack.com/client/"), - "discord" => Some("https://discord.com/channels/@me"), - "gmail" => Some("https://mail.google.com/mail/u/0/"), - "outlook" => Some("https://outlook.live.com/mail/"), - "instagram" => Some("https://www.instagram.com/direct/inbox/"), - "twitter" => Some("https://x.com/messages/"), - "google-meet" => Some("https://meet.google.com/"), - "zoom" => Some("https://zoom.us/"), - "browserscan" => Some("https://www.browserscan.net/bot-detection"), - _ => None, - } -} - -/// Returns the injected recipe.js for providers that still rely on the -/// JS-bridge ingest path. Migrated providers (whatsapp, wechat, telegram, -/// slack, discord, browserscan) return `None` — their scraping runs natively -/// via CDP in the per-provider scanner modules. -fn provider_recipe_js(provider: &str) -> Option<&'static str> { - match provider { - "linkedin" => Some(LINKEDIN_RECIPE_JS), - "google-meet" => Some(GOOGLE_MEET_RECIPE_JS), - _ => None, - } -} - -/// Whether this provider is supported at all. Derived from -/// `provider_url` so there's one canonical list — new providers added -/// to the `provider_url` match automatically become "supported" here. -fn provider_is_supported(provider: &str) -> bool { - provider_url(provider).is_some() -} - -/// Host suffixes the embedded webview is allowed to navigate within. Any -/// navigation to a host outside this set is cancelled and opened in the -/// user's default browser instead. Meet includes Google's auth and -/// static asset hosts so the OAuth redirect loop works; Discord includes -/// its CDN subdomains for the same reason. -fn provider_allowed_hosts(provider: &str) -> &'static [&'static str] { - match provider { - "whatsapp" => &["whatsapp.com", "whatsapp.net", "wa.me"], - "wechat" => &[ - "wechat.com", - "wx.qq.com", - "weixin.qq.com", - "login.weixin.qq.com", - ], - "telegram" => &["telegram.org", "t.me"], - "linkedin" => &[ - "linkedin.com", - "licdn.com", - "accounts.google.com", - "accounts.googleusercontent.com", - "ssl.gstatic.com", - "fonts.gstatic.com", - "lh3.googleusercontent.com", - "oauth2.googleapis.com", - "www.googleapis.com", - ], - "slack" => &[ - "slack.com", - "slack-edge.com", - "slackb.com", - "accounts.google.com", - "accounts.googleusercontent.com", - "ssl.gstatic.com", - "fonts.gstatic.com", - "lh3.googleusercontent.com", - "oauth2.googleapis.com", - "www.googleapis.com", - ], - "discord" => &[ - "discord.com", - "discord.gg", - "discordapp.com", - "discordapp.net", - ], - "gmail" => &[ - "google.com", - "googleusercontent.com", - "gstatic.com", - "googleapis.com", - "accounts.google.com", - "accounts.googleusercontent.com", - ], - "outlook" => &[ - "outlook.live.com", - "outlook.office.com", - "outlook.office365.com", - "live.com", - "login.live.com", - "microsoft.com", - "microsoftonline.com", - "office.com", - "office365.com", - "msftauth.net", - "msauth.net", - "office.net", - ], - "instagram" => &[ - "instagram.com", - "cdninstagram.com", - "fbcdn.net", - "facebook.com", - ], - "twitter" => &[ - "x.com", - "twitter.com", - "twimg.com", - "t.co", - // X offers "Continue with Google" — keep the OAuth - // account-chooser popup inside this account's CEF profile. - "accounts.google.com", - "accounts.googleusercontent.com", - "ssl.gstatic.com", - "fonts.gstatic.com", - "lh3.googleusercontent.com", - "oauth2.googleapis.com", - "www.googleapis.com", - ], - "google-meet" => &[ - "google.com", - "googleusercontent.com", - "gstatic.com", - "googleapis.com", - ], - "zoom" => &[ - "zoom.us", - "zoom.com", - "zoomgov.com", - "zdassets.com", - "accounts.google.com", - "accounts.googleusercontent.com", - "ssl.gstatic.com", - "fonts.gstatic.com", - "lh3.googleusercontent.com", - "oauth2.googleapis.com", - "www.googleapis.com", - ], - "browserscan" => &["browserscan.net"], - _ => &[], - } -} - -/// Rewrite a provider-specific native-app deep link (e.g. Zoom's -/// `zoomus://zoom.us/join?...`) into a web-client URL so the meeting stays -/// inside the embedded webview instead of failing with -/// ERR_UNKNOWN_URL_SCHEME (CEF has no handler for these schemes). -/// -/// Returns `Some(rewritten)` when the provider claims the scheme and a -/// valid web-client URL can be built; `None` otherwise (caller should -/// leave the navigation alone). -fn rewrite_provider_deep_link(provider: &str, url: &Url) -> Option { - if provider != "zoom" { - return None; - } - if !matches!(url.scheme(), "zoomus" | "zoommtg") { - return None; - } - // Pull the meeting id out of the query string. Zoom uses `confno` on - // both `action=join` (joining) and `action=start` (hosting) flows. - let confno = url - .query_pairs() - .find(|(k, _)| k == "confno") - .map(|(_, v)| v.into_owned()); - let pwd = url - .query_pairs() - .find(|(k, _)| k == "pwd" || k == "tk") - .map(|(_, v)| v.into_owned()); - // Build the rewritten URL via `Url` so `confno` and `pwd` are - // percent-encoded — inbound Zoom tokens can contain reserved chars - // (`&`, `#`, `%`, `+`, …) that would corrupt a hand-rolled - // `format!(…)` string and silently break the join/host flow. - match confno { - Some(id) if !id.is_empty() => { - // Base without trailing slash; `path_segments_mut().push(id)` - // appends `/id` cleanly. A trailing `/` on the base would yield - // `/wc/join//id` (empty segment preserved by the Url spec). - let mut rewritten = Url::parse("https://app.zoom.us/wc/join").ok()?; - rewritten.path_segments_mut().ok()?.push(&id); - if let Some(p) = pwd.filter(|p| !p.is_empty()) { - rewritten.query_pairs_mut().append_pair("pwd", &p); - } - Some(rewritten) - } - _ => Url::parse("https://app.zoom.us/wc/home").ok(), - } -} - -/// `true` if `url` is considered in-app for `provider`. Non-HTTP(S) -/// schemes (`about:blank`, `data:`, `blob:`) have no host and are always -/// allowed so the webview's own internal navigations keep working. -/// Unknown providers are also permissive — better to accidentally keep a -/// link in-app than to leak it to the system browser. -fn url_is_internal(provider: &str, url: &Url) -> bool { - let Some(host) = url.host_str() else { - return true; - }; - // Google services route the post-2FA `SetSID` cookie-setting hop - // through `accounts.youtube.com` and ccTLD `accounts.google.` - // hosts that aren't covered by the suffix-based allowlist. Without - // this, the auth chain breaks mid-flight and leaks to the system - // browser (#1053 sign-in leak surfaced in dev:app log line: - // "external navigation https://accounts.youtube.com/accounts/SetSID?... - // → system browser"). Whitelist the full Google SSO host family for - // any provider that uses Google identity. - if (provider == "gmail" || provider_supports_google_sso(provider)) && is_google_sso_host(host) { - return true; - } - let allowed = provider_allowed_hosts(provider); - if allowed.is_empty() { - return true; - } - allowed - .iter() - .any(|suffix| host == *suffix || host.ends_with(&format!(".{}", suffix))) -} - -/// `true` if the provider needs `window.open(url)` to return a live -/// window-handle (i.e. the calling site reads the return value and aborts -/// on falsey). Slack Huddles go through `openManagedChildWindow` which -/// calls `window.open("about:blank", …)` and then programmatically -/// navigates the returned popup to the huddle UI. Denying the popup -/// makes the huddle call fail silently with a `beacon/error`. For these -/// cases we allow the default popup so CEF spawns an in-app child window -/// and returns a real handle to the caller. -/// -/// Match is intentionally narrow — only the popup URLs the provider -/// actually needs in-app pass. Cmd/Ctrl-click and `target="_blank"` -/// on ordinary links (which carry a concrete URL) still route out to -/// the user's default browser. -fn popup_should_stay_in_app(provider: &str, url: &Url) -> bool { - match provider { - "slack" => { - // Slack's huddle flow opens `about:blank` first, then navigates - // the popup to the huddle URL — at popup-creation time there is - // no host yet. Also accept same-origin slack.com hosts so direct - // `window.open("https://app.slack.com/...")` calls stay in-app. - if url.scheme() == "about" { - return true; - } - match url.host_str() { - Some(host) => host == "app.slack.com" || host.ends_with(".slack.com"), - None => false, - } - } - "zoom" => { - // Zoom's "Join from browser" / WebClient launch can go through a - // `window.open("https://app.zoom.us/wc/...")` popup instead of an - // in-page navigation. Keep those (and any deep-link-rewritten - // popup targeting the same path) inside the embedded webview so - // the meeting doesn't pop out to the system browser. - match url.host_str() { - Some(host) => { - (host == "app.zoom.us" || host == "zoom.us") && url.path().starts_with("/wc/") - } - None => false, - } - } - // LinkedIn (#1021) and X/Twitter (#5009) render "Sign in with Google" - // as a Google Identity Services (GSI) popup that the GIS SDK drives via - // window.open() and completes by postMessage-ing the credential back to - // window.opener (the provider page). Every leg of that popup — the - // account chooser and the /o/oauth2 auth hop — MUST stay as a real - // in-app child window: routing it to the system browser leaves the - // embedded webview on a dead page, and navigating the parent to it - // destroys the opener so the credential can never post back. See - // `is_google_gsi_popup`. - "linkedin" | "twitter" => is_google_gsi_popup(url), - _ => false, - } -} - -/// `true` for a Google Identity Services (GSI) **popup-mode** "Sign in with -/// Google" window — the opener-dependent flow the GIS SDK drives via -/// `window.open(...)` and completes by `postMessage`-ing the credential back to -/// `window.opener` (the provider page). Covers both the account-chooser -/// (`accounts.google.com/gsi/select`, #1021) and the `/o/oauth2/v2/auth` leg the -/// same popup navigates through (#5009). -/// -/// Matched by Google SSO host (`is_google_sso_host`) AND either a `gsi` path -/// segment or one of the GIS-SDK popup-mode query markers. Deliberately narrow -/// — a redirect-mode Google sign-in (a real https `redirect_uri`, no GSI -/// markers) does NOT match, so it still replaces the parent. NOT a blanket -/// popup allow. -/// -/// Keeping these popups in-app is what lets the postMessage handshake complete -/// inside the account's isolated CEF profile instead of leaking to the system -/// browser (#5009) or being dropped when the parent (the opener) is navigated -/// away (#5009 / #1021). -fn is_google_gsi_popup(url: &Url) -> bool { - if !url.host_str().is_some_and(is_google_sso_host) { - return false; - } - if url.path().to_ascii_lowercase().contains("gsi") { - return true; - } - // GIS-SDK popup-mode markers. These appear on the popup's auth URLs (incl. - // `/o/oauth2/v2/auth`) and never on a plain redirect-mode sign-in. - url.query_pairs().any(|(key, value)| { - let k = key.to_ascii_lowercase(); - let v = value.to_ascii_lowercase(); - ((k == "ux_mode" || k == "display") && v == "popup") - || k == "gsiwebsdk" - || (k == "redirect_uri" && v == "gis_transform") - }) -} - -/// `true` if `scheme` is a known provider native-desktop-app deep-link -/// scheme. We suppress these instead of routing them to the system -/// browser because macOS hands them to the native provider app -/// (e.g. `slack://magic-login/` signs the native Slack app into -/// the workspace, breaking embedded-webview isolation: the workspace's -/// session ends up inside the native client even though the user only -/// signed in via OpenHuman's embedded webview). -/// -/// The HTTPS fallback in each provider's web flow handles sign-in -/// without the deep link, so suppression is safe — the page just -/// continues on the next link in the sequence. -/// -/// Caller contract: only suppress when [`rewrite_provider_deep_link`] -/// has already returned `None` for the URL. Schemes we DO know how to -/// rewrite into a web-client URL (e.g. `zoomus://`) must take the -/// rewrite path first; those flows expect to stay in-app, not be -/// silently dropped. -fn is_provider_native_deep_link_scheme(scheme: &str) -> bool { - matches!( - scheme, - "slack" | "discord" | "tg" | "msteams" | "zoomus" | "zoommtg" - ) -} - -/// `true` if this provider lets users sign in with their Google -/// account from inside the embedded webview. -/// -/// Slack workspaces commonly enable "Sign in with Google" SSO, so the -/// Google OAuth popup flow (`window.open("https://accounts.google.com/...")`) -/// must stay in the per-account CEF session — exactly the same way it -/// has to for Google Meet. Routing it to the system browser leaks the -/// auth cookie into the wrong jar and breaks sign-in (#1036). -/// -/// Keep this list narrow: only providers that actually need to issue -/// `accounts.google.com` popups should be listed. Other providers -/// continue to fall through to the default popup-handling path. -fn provider_supports_google_sso(provider: &str) -> bool { - matches!( - provider, - "google-meet" | "slack" | "zoom" | "linkedin" | "gmail" | "twitter" - ) -} - -/// `true` if a popup request should be denied AND the parent webview -/// should be navigated to the popup URL instead. -/// -/// Used for Google's "Sign in" / "Use another account" flow on embedded -/// providers that support Google SSO: clicking the link issues -/// `window.open("https://accounts.google.com/...")`. We can't route -/// that to the system browser (the auth cookie would land in the -/// wrong jar) and we don't want to let CEF spawn an unmanaged child -/// window (it has no host rect, so it renders blank/black). The safe -/// option is to deny the popup and replace the parent's URL so the -/// in-app webview finishes the auth flow inside the embedded session. -fn popup_should_navigate_parent(provider: &str, url: &Url) -> Option { - if !provider_supports_google_sso(provider) { - return None; - } - if url.scheme() == "about" { - return None; - } - // #5009: X/Twitter (and LinkedIn) sign in with Google via the GSI popup — - // an opener-dependent flow where the popup postMessages the credential back - // to `window.opener` (the provider page). Navigating the parent to any leg - // of that popup (chooser or the `/o/oauth2` hop) destroys the opener, so the - // sign-in never completes and the pane paints blank. Keep it in-app instead - // (`popup_should_stay_in_app` catches it for these providers). Redirect-mode - // Google sign-in (a real https `redirect_uri`, no GSI markers) is NOT a GSI - // popup, so it still falls through to the parent-navigation below. - if matches!(provider, "linkedin" | "twitter") && is_google_gsi_popup(url) { - return None; - } - if is_google_auth_popup(url) { - return Some(url.clone()); - } - // Gmeet: "Start an instant meeting" / "New meeting" / clicking - // a meeting code link calls `window.open(meet.google.com/)` - // to launch the room. Default popup handling would route the - // URL to the user's system browser, leaking the Meet session - // out of OpenHuman entirely. Deny the popup and navigate the - // embedded parent into the room URL instead — matches the - // user's expectation that the meeting stays in-app. - if provider == "google-meet" { - if let Some(host) = url.host_str() { - if host == "meet.google.com" { - return Some(url.clone()); - } - } - } - None -} - -/// `true` if `host` is a Google SSO / account-handoff host that may -/// participate in the OAuth flow for any Google service (Meet, Gmail, -/// Drive, etc.). Google rotates the post-2FA `SetSID` cookie-setting hop -/// across `accounts.google.` and `accounts.youtube.com` (sic — the -/// YouTube subdomain is part of the Google identity infra), so a literal -/// `accounts.google.com` match misses real auth popups and leaks them to -/// the system browser. -/// -/// Match family: -/// - `accounts.google.com` -/// - `accounts.google.` — e.g. `accounts.google.co.in`, `accounts.google.co.uk`, -/// `accounts.google.de` -/// - `accounts.googleusercontent.com` -/// - `accounts.youtube.com` (post-2FA `SetSID` hop) -/// - `myaccount.google.com` -fn is_google_sso_host(host: &str) -> bool { - let host = host.to_ascii_lowercase(); - if host == "accounts.google.com" - || host == "accounts.googleusercontent.com" - || host == "accounts.youtube.com" - || host == "myaccount.google.com" - { - return true; - } - // ccTLD variants: `accounts.google.`. We must reject phishing - // shapes like `accounts.google.com.evil` and `accounts.google.co.attacker` - // — the dots-only check we used previously accepted both because - // `com.evil` and `co.attacker` each have one dot. Anchor the suffix - // against a real ccTLD shape: either a single 2-letter cc tld - // (`accounts.google.de`, `accounts.google.fr`) OR a 2-label form - // `.` where sld ∈ {co, com, net, org} (`accounts.google.co.in`, - // `accounts.google.com.au`). - if let Some(rest) = host.strip_prefix("accounts.google.") { - let labels: Vec<&str> = rest.split('.').collect(); - let is_cc = |s: &str| s.len() == 2 && s.chars().all(|c| c.is_ascii_alphabetic()); - return match labels.as_slice() { - [tld] => is_cc(tld), - [sld, tld] => matches!(*sld, "co" | "com" | "net" | "org") && is_cc(tld), - _ => false, - }; - } - false -} - -fn is_google_auth_popup(url: &Url) -> bool { - let Some(host) = url.host_str() else { - return false; - }; - if !is_google_sso_host(host) { - return false; - } - - let path = url.path().to_ascii_lowercase(); - if path.contains("signin") - || path.contains("servicelogin") - || path.contains("accountchooser") - || path.contains("chooseaccount") - || path.contains("setsid") - || path.contains("oauth2") - { - return true; - } - - url.query_pairs().any(|(key, value)| { - let k = key.to_ascii_lowercase(); - let v = value.to_ascii_lowercase(); - matches!(k.as_str(), "flowname" | "service" | "continue") - && (v.contains("signin") - || v.contains("servicelogin") - || v.contains("accountchooser") - || v.contains("chooseaccount") - || v.contains("meet.google.com") - || v.contains("mail.google.com") - || v.contains("linkedin.com")) - }) -} - -/// `true` if a gmeet navigation lands on Google's Workspace marketing page -/// for Meet — the host bounce that fires when an unauthenticated webview hits -/// `meet.google.com`. -/// -/// The `on_navigation` rewrite is scoped to this exact path family so we -/// don't hijack legitimate `workspace.google.com` pages a user might reach -/// from inside Meet (admin console links, Workspace Status, support pages, -/// etc.). Matches `workspace.google.com` (and any subdomain) AND a path -/// starting with `/products/meet` — empirically the only path Google's -/// edge bounces unauthenticated Meet GETs to (`/products/meet/` or -/// `/products/meet/`). -fn is_gmeet_marketing_redirect(host: &str, path: &str) -> bool { - let host = host.to_ascii_lowercase(); - let host_matches = host == "workspace.google.com" || host.ends_with(".workspace.google.com"); - if !host_matches { - return false; - } - let p = path.to_ascii_lowercase(); - p == "/products/meet" || p == "/products/meet/" || p.starts_with("/products/meet/") -} - -fn redact_navigation_url(url: &Url) -> String { - let mut safe = url.clone(); - safe.set_query(None); - safe.set_fragment(None); - safe.to_string() -} - -fn redact_native_deep_link_url(url: &Url) -> String { - format!("{}://", url.scheme()) -} -/// Unwrap provider-side "link safety" redirects so the system browser -/// lands on the real destination. -/// -/// These wrappers (LinkedIn's `/safety/go/?url=…`, etc.) require the -/// user to be logged into the provider in the destination browser. In -/// our setup the session lives inside the embedded CEF webview's cookie -/// jar, not the user's default browser — opening the wrapper URL there -/// shows a broken safety page instead of completing the redirect. -/// Extract the `url` query param and return the resolved destination. -fn unwrap_provider_redirect(url: &Url) -> Option { - let host = url.host_str()?; - let path = url.path(); - let matches_linkedin = (host == "www.linkedin.com" || host == "linkedin.com") - && (path == "/safety/go/" || path == "/safety/go" || path == "/redir/redirect"); - if !matches_linkedin { - return None; - } - let (_, raw) = url.query_pairs().find(|(k, _)| k == "url")?; - Url::parse(&raw).ok() -} - -/// Fire-and-forget handoff to the OS default URL handler. Any error is -/// logged but not propagated — we've already cancelled the in-app -/// navigation so there's nowhere to surface a failure to. -/// -/// On macOS we shell out to `/usr/bin/open` directly rather than via -/// `tauri_plugin_opener::open_url`: the plugin returned Ok but no browser -/// actually launched in the CEF runtime (suspected sandbox/launch-service -/// interaction with the `open` crate's detached spawn). The direct -/// Command call is equivalent to what a user would type in Terminal and -/// works reliably. -fn open_in_system_browser(url: &str) { - #[cfg(target_os = "macos")] - { - match std::process::Command::new("/usr/bin/open").arg(url).spawn() { - Ok(_) => log::info!("[webview-accounts] opened externally (macos open): {}", url), - Err(e) => log::warn!( - "[webview-accounts] /usr/bin/open {} failed: {} — falling back to opener plugin", - url, - e - ), - } - } - #[cfg(not(target_os = "macos"))] - { - match tauri_plugin_opener::open_url(url, None::<&str>) { - Ok(()) => log::info!("[webview-accounts] opened externally: {}", url), - Err(e) => log::warn!("[webview-accounts] open_url({}) failed: {}", url, e), - } - } -} - -fn payload_string(payload: &serde_json::Value, key: &str) -> Option { - payload - .get(key) - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) -} - -fn payload_bool(payload: &serde_json::Value, key: &str) -> Option { - payload.get(key).and_then(|v| v.as_bool()) -} - -fn payload_i64(payload: &serde_json::Value, key: &str) -> Option { - payload.get(key).and_then(|v| v.as_i64()) -} - -fn first_message_field(payload: &serde_json::Value, key: &str) -> Option { - payload - .get("messages") - .and_then(|v| v.as_array()) - .and_then(|messages| messages.first()) - .and_then(|message| message.get(key)) - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) -} - -fn event_timestamp_rfc3339(ts_ms: Option) -> String { - ts_ms - .and_then(|ts| Utc.timestamp_millis_opt(ts).single()) - .unwrap_or_else(Utc::now) - .to_rfc3339() -} - -fn normalize_provider_surfaces_event(args: &RecipeEventArgs) -> Option { - if args.kind != "ingest" { - return None; - } - - let entity_id = payload_string(&args.payload, "entity_id") - .or_else(|| payload_string(&args.payload, "threadId")) - .or_else(|| payload_string(&args.payload, "chatId")) - .or_else(|| payload_string(&args.payload, "snapshotKey")) - .unwrap_or_else(|| { - format!( - "{}:{}:{}", - args.provider, - args.account_id, - args.ts.unwrap_or_else(|| Utc::now().timestamp_millis()) - ) - }); - - let thread_id = payload_string(&args.payload, "threadId") - .or_else(|| payload_string(&args.payload, "chatId")) - .or_else(|| payload_string(&args.payload, "conversationId")); - let title = payload_string(&args.payload, "title") - .or_else(|| payload_string(&args.payload, "chatName")) - .or_else(|| payload_string(&args.payload, "channelName")); - let snippet = payload_string(&args.payload, "snippet") - .or_else(|| first_message_field(&args.payload, "body")); - let sender_name = payload_string(&args.payload, "senderName") - .or_else(|| first_message_field(&args.payload, "from")); - let sender_handle = payload_string(&args.payload, "senderHandle"); - let deep_link = payload_string(&args.payload, "deepLink"); - let unread = payload_i64(&args.payload, "unread").unwrap_or(0); - let requires_attention = payload_bool(&args.payload, "requires_attention") - .unwrap_or(unread > 0 || sender_name.is_some() || snippet.is_some()); - - Some(json!({ - "provider": args.provider, - "account_id": args.account_id, - "event_kind": args.kind, - "entity_id": entity_id, - "thread_id": thread_id, - "title": title, - "snippet": snippet, - "sender_name": sender_name, - "sender_handle": sender_handle, - "timestamp": event_timestamp_rfc3339(args.ts), - "deep_link": deep_link, - "requires_attention": requires_attention, - "raw_payload": args.payload, - })) -} - -async fn post_provider_surfaces_event(args: &RecipeEventArgs) -> Result<(), String> { - let Some(params) = normalize_provider_surfaces_event(args) else { - return Ok(()); - }; - - let body = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "openhuman.provider_surfaces_ingest_event", - "params": params, - }); - - let url = std::env::var("OPENHUMAN_CORE_RPC_URL") - .unwrap_or_else(|_| "http://127.0.0.1:7788/rpc".to_string()); - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .build() - .map_err(|e| format!("http client: {e}"))?; - let resp = client - .post(&url) - .json(&body) - .send() - .await - .map_err(|e| format!("POST {url}: {e}"))?; - let status = resp.status(); - if !status.is_success() { - let body = resp.text().await.unwrap_or_default(); - return Err(format!("{status}: {body}")); - } - let v: serde_json::Value = resp.json().await.map_err(|e| format!("decode: {e}"))?; - if let Some(err) = v.get("error") { - return Err(format!("rpc error: {err}")); - } - Ok(()) -} - -/// Human-readable label used as the title prefix on native notifications -/// so users can tell which provider fired the ping. Matches the labels -/// in the frontend `PROVIDERS` registry. -pub fn provider_display_name(provider: &str) -> &'static str { - match provider { - "whatsapp" => "WhatsApp", - "wechat" => "WeChat", - "telegram" => "Telegram", - "linkedin" => "LinkedIn", - "slack" => "Slack", - "discord" => "Discord", - "gmail" => "Gmail", - "outlook" => "Outlook", - "instagram" => "Instagram", - "twitter" => "X", - "google-meet" => "Google Meet", - "zoom" => "Zoom", - "browserscan" => "BrowserScan", - _ => "OpenHuman", - } -} - -#[derive(Default)] -pub struct WebviewAccountsState { - /// account_id -> webview label (we use `acct_` as the label). - inner: Mutex>, - /// account_id -> provider id. Kept so late reveal/close paths can log - /// provider-scoped diagnostics without trusting frontend echo fields. - account_providers: Mutex>, - /// account_id -> CEF `Browser::identifier()`. Populated asynchronously - /// inside the `with_webview` callback once the renderer hands us the - /// browser handle, and consumed at close/purge time so we can call - /// `tauri_runtime_cef::notification::unregister` without leaking - /// per-browser handler entries across account churn. - browser_ids: Mutex>, - /// account_id -> CDP session task. One long-lived task per account - /// keeps the UA override resident (see `cdp::session`); aborted on - /// close/purge so reopen cycles don't stack multiple live loops. - cdp_sessions: Mutex>>, - /// account_id -> 15s `webview-account:load{state:"timeout"}` watchdog. - /// Aborted in close/purge so a watchdog spawned for a now-closed - /// account can't fire a stale timeout against a freshly-reused id. - load_watchdogs: Mutex>>, - /// account_id of webviews that have already emitted their first - /// `webview-account:load{state:"finished"}` event. Used to dedup - /// triple-signal fires (native on_page_load, CDP `Page.loadEventFired`, - /// 15 s watchdog) so the frontend only reveals once per cold open. - loaded_accounts: Mutex>, - /// Last bounds requested by the frontend for a given account, captured at - /// `webview_account_open` time so the off-screen-spawned webview can be - /// revealed at the right rect without the frontend having to round-trip - /// them again. - requested_bounds: Mutex>, - /// account_id -> `Instant` captured at the moment the cold spawn returns - /// from `add_child`. Consumed by `webview_account_reveal` to compute - /// `elapsed_ms` (spawn -> frontend reveal call) for the diagnostic log - /// instrumented for the Slack first-load investigation (#1036). Cleared - /// alongside `loaded_accounts` on close/purge so a subsequent reopen - /// starts fresh. - spawn_started_at: Mutex>, - /// Runtime notification-bypass controls used by the settings UI. - notification_bypass: Mutex, - /// Per-label rewrite counter for the gmeet `workspace.google.com` - /// marketing intercept. Google's edge SSR-redirects unauthenticated - /// `meet.google.com` GETs back to `workspace.google.com/products/meet/`, - /// so an unguarded rewrite (see `:1534-1559`) ping-pongs forever and - /// the page never lands. We track `(last_attempt, attempts_in_window)` - /// per webview label and bail to the Google sign-in flow after a - /// threshold so the user breaks out of the loop. - gmeet_marketing_rewrites: Mutex>, - /// Per-label "awaiting post-auth handoff" flag. Set when the gmeet - /// rewrite-loop bail navigates to `accounts.google.com/ServiceLogin`, - /// consumed (single-shot) by the `myaccount.google.com` intercept so - /// only the immediate post-auth bounce gets rewritten back to Meet — - /// legitimate user-initiated `myaccount.google.com` navigations (e.g. - /// "Manage your Google Account" from the avatar menu) are passed - /// through unchanged. - gmeet_awaiting_handoff: Mutex>, - /// account_ids spawned via `webview_account_prewarm` that have not yet - /// been opened by the user. Issue #1233 — emit_load_finished suppresses - /// `webview-account:load` events for these so the React UI never sees - /// load/timeout signals for an account it didn't ask to open. The flag - /// is cleared on the first user-initiated `webview_account_open` - /// (warm-reopen branch) and on close/purge so subsequent reopens flow - /// through the normal cold-load lifecycle. - prewarm_accounts: Mutex>, -} - -/// Threshold and window for the gmeet workspace-marketing rewrite loop -/// breaker. After `GMEET_REWRITE_MAX_ATTEMPTS` rewrites within -/// `GMEET_REWRITE_WINDOW`, we bail to the Google sign-in URL instead of -/// rewriting again. -pub(crate) const GMEET_REWRITE_MAX_ATTEMPTS: u32 = 3; -pub(crate) const GMEET_REWRITE_WINDOW: Duration = Duration::from_secs(5); - -/// Result of consulting the gmeet marketing-redirect counter. -#[derive(Debug, PartialEq, Eq)] -pub(crate) enum GmeetRewriteAction { - /// Allow the rewrite — fewer than `GMEET_REWRITE_MAX_ATTEMPTS` attempts in - /// the current window. - Rewrite, - /// Loop detected — caller should navigate to the Google sign-in URL - /// instead of rewriting back to `meet.google.com`. - Bail, -} - -impl WebviewAccountsState { - /// Drop the gmeet marketing-rewrite counter for `label`. Called after - /// the post-auth handoff so a future workspace-marketing bounce (which - /// shouldn't occur post-auth, but could on session expiry) gets a fresh - /// counter window instead of inheriting half-saturated state from the - /// pre-auth loop. - pub(crate) fn clear_gmeet_marketing_rewrite(&self, label: &str) { - if let Ok(mut g) = self.gmeet_marketing_rewrites.lock() { - g.remove(label); - } - } - - /// Mark `label` as awaiting the post-auth `myaccount.google.com` → - /// `meet.google.com` handoff. Set by the rewrite-loop bail right - /// before navigating to `accounts.google.com/ServiceLogin?continue=`, - /// so the next `myaccount.google.com` commit on this label is treated - /// as the auth chain's terminal hop (the `?utm_source=sign_in_no_continue` - /// dump-page) and gets force-redirected to Meet. - pub(crate) fn mark_awaiting_gmeet_handoff(&self, label: &str) { - if let Ok(mut g) = self.gmeet_awaiting_handoff.lock() { - g.insert(label.to_string()); - } - } - - /// Single-shot consume of the post-auth handoff flag for `label`. - /// Returns `true` exactly once after `mark_awaiting_gmeet_handoff`, - /// then resets so subsequent `myaccount.google.com` navigations - /// (e.g. user-initiated profile/settings visits) pass through as - /// normal and aren't hijacked back to Meet. - pub(crate) fn take_awaiting_gmeet_handoff(&self, label: &str) -> bool { - match self.gmeet_awaiting_handoff.lock() { - Ok(mut g) => g.remove(label), - Err(_) => false, - } - } - - /// Increment the per-label gmeet marketing-rewrite counter for `now` and - /// decide whether to rewrite or bail. Resets the counter when the last - /// attempt was outside `GMEET_REWRITE_WINDOW` so a future genuine - /// `workspace.google.com` navigation (e.g. user clicks a link inside Meet - /// after sign-in) gets intercepted normally. - pub(crate) fn track_gmeet_marketing_rewrite( - &self, - label: &str, - now: Instant, - ) -> GmeetRewriteAction { - let mut map = match self.gmeet_marketing_rewrites.lock() { - Ok(g) => g, - Err(poisoned) => poisoned.into_inner(), - }; - let entry = map.entry(label.to_string()).or_insert((now, 0)); - if now.duration_since(entry.0) > GMEET_REWRITE_WINDOW { - *entry = (now, 0); - } - entry.1 += 1; - entry.0 = now; - if entry.1 > GMEET_REWRITE_MAX_ATTEMPTS { - GmeetRewriteAction::Bail - } else { - GmeetRewriteAction::Rewrite - } - } - - /// Drain every per-account resource owned by this state and abort the - /// associated background tasks. Returns the `(account_id, label)` - /// pairs of webviews that still need closing — the caller does the - /// actual `wv.close()` because that needs an `AppHandle`. Splitting - /// it out keeps the rest of the teardown unit-testable without - /// constructing a Tauri runtime. - /// - /// Aborts CDP session tasks and load watchdogs, unregisters CEF - /// notification handlers, and clears the loaded-accounts / - /// requested-bounds bookkeeping. All collections are drained — a - /// repeat call returns an empty `Vec` and is a safe no-op. - fn drain_for_shutdown(&self) -> Vec<(String, String)> { - let cdp_tasks: Vec<_> = self - .cdp_sessions - .lock() - .ok() - .map(|mut g| g.drain().collect()) - .unwrap_or_default(); - for (acct, task) in cdp_tasks { - task.abort(); - log::debug!("[webview-accounts] shutdown abort cdp account={}", acct); - } - let watchdogs: Vec<_> = self - .load_watchdogs - .lock() - .ok() - .map(|mut g| g.drain().collect()) - .unwrap_or_default(); - for (acct, task) in watchdogs { - task.abort(); - log::debug!( - "[webview-accounts] shutdown abort watchdog account={}", - acct - ); - } - let browser_ids: Vec<_> = self - .browser_ids - .lock() - .ok() - .map(|mut g| g.drain().collect()) - .unwrap_or_default(); - for (acct, _browser_id) in browser_ids { - log::debug!("[notifications] shutdown cleared account={}", acct); - } - if let Ok(mut g) = self.loaded_accounts.lock() { - g.clear(); - } - if let Ok(mut g) = self.requested_bounds.lock() { - g.clear(); - } - if let Ok(mut g) = self.spawn_started_at.lock() { - g.clear(); - } - if let Ok(mut g) = self.account_providers.lock() { - g.clear(); - } - // Per-label gmeet rewrite counter must clear too — `label_for()` - // reuses the same label on reopen, so a stale saturated entry - // would jump a fresh open straight to the bail URL. - if let Ok(mut g) = self.gmeet_marketing_rewrites.lock() { - g.clear(); - } - // Drop the post-auth handoff flag too — a stale flag would - // hijack the first user-initiated `myaccount.google.com` visit - // after a relaunch back to Meet. - if let Ok(mut g) = self.gmeet_awaiting_handoff.lock() { - g.clear(); - } - // Issue #1233 — clear prewarm flags so a relaunch can't suppress - // load events for accounts that were prewarmed in the previous - // session. - if let Ok(mut g) = self.prewarm_accounts.lock() { - g.clear(); - } - self.inner - .lock() - .ok() - .map(|mut g| g.drain().collect()) - .unwrap_or_default() - } - - /// Tear down every per-account resource owned by this state — used by - /// the app's `RunEvent::ExitRequested` path so nothing outlives the - /// tokio runtime / `AppHandle` (issue #920). - /// - /// On top of [`drain_for_shutdown`], this closes every `acct_*` child - /// webview so CEF browsers tear down before `cef::shutdown()` runs, - /// and tells the per-account scanner registries to forget the - /// account so a future open of the same id starts from a clean slate. - /// All collections are drained — repeat calls are cheap no-ops. - pub fn shutdown_all(&self, app: &AppHandle) -> Vec { - teardown_all_account_scanners(app); - let labels = self.drain_for_shutdown(); - let mut closed_labels = Vec::with_capacity(labels.len()); - for (acct, label) in labels { - teardown_account_scanners(app, &acct); - if let Some(wv) = app.get_webview(&label) { - // Track the label as soon as the webview exists so a failed - // `close()` still participates in the post-close drain poll - // (issue #1120 / CodeRabbit). - closed_labels.push(label.clone()); - if let Err(e) = wv.close() { - log::warn!( - "[webview-accounts] shutdown close({label}) failed account={acct}: {e}" - ); - } - } else { - log::debug!( - "[webview-accounts] shutdown label already gone account={} label={}", - acct, - label - ); - } - } - log::info!( - "[webview-accounts] shutdown_all complete closed_labels={:?}", - closed_labels - ); - closed_labels - } -} - -/// Abort every provider scanner task tracked by the per-provider -/// registries. Used by full-app shutdown before the per-account state is -/// drained so CDP loops stop even if an account label was already removed -/// from `WebviewAccountsState`. -fn teardown_all_account_scanners(app: &AppHandle) { - let mut total = 0usize; - if let Some(registry) = - app.try_state::>() - { - total += registry.inner().forget_all(); - } - if let Some(registry) = app.try_state::>() - { - total += registry.inner().forget_all(); - } - if let Some(registry) = - app.try_state::>() - { - total += registry.inner().forget_all(); - } - if let Some(registry) = - app.try_state::>() - { - total += registry.inner().forget_all(); - } - if let Some(registry) = - app.try_state::>() - { - total += registry.inner().forget_all(); - } - if total > 0 { - log::info!( - "[webview-accounts] aborted {} provider scanner task(s) for shutdown", - total - ); - } -} - -/// Tell the per-account scanner registries (whatsapp / slack / discord / -/// telegram) to forget `account_id`. Shared by `webview_account_close`, -/// `webview_account_purge`, and `WebviewAccountsState::shutdown_all` so -/// every exit path goes through the same teardown. -fn teardown_account_scanners(app: &AppHandle, account_id: &str) { - if let Some(registry) = - app.try_state::>() - { - registry.inner().forget(account_id); - } - if let Some(registry) = app.try_state::>() - { - registry.inner().forget(account_id); - } - if let Some(registry) = - app.try_state::>() - { - registry.inner().forget(account_id); - } - if let Some(registry) = - app.try_state::>() - { - registry.inner().forget(account_id); - } - if let Some(registry) = - app.try_state::>() - { - registry.inner().forget(account_id); - } - // Drop the in-process CDP transport for this account so a reopen - // installs a fresh observer instead of re-using the dead one tied - // to the closed webview. - if let Some(registry) = app.try_state::() { - registry.inner().forget_account(account_id); - } -} - -#[derive(Debug, Clone)] -struct NotificationBypassPrefs { - global_dnd: bool, - muted_accounts: HashSet, - bypass_when_focused: bool, - focused_account: Option, -} - -impl Default for NotificationBypassPrefs { - fn default() -> Self { - Self { - global_dnd: false, - muted_accounts: HashSet::new(), - // Match the existing UI copy: focused account may suppress toast. - bypass_when_focused: true, - focused_account: None, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct NotificationBypassPrefsPayload { - pub global_dnd: bool, - pub muted_accounts: Vec, - pub bypass_when_focused: bool, -} - -impl From<&NotificationBypassPrefs> for NotificationBypassPrefsPayload { - fn from(value: &NotificationBypassPrefs) -> Self { - let mut muted_accounts = value.muted_accounts.iter().cloned().collect::>(); - muted_accounts.sort(); - Self { - global_dnd: value.global_dnd, - muted_accounts, - bypass_when_focused: value.bypass_when_focused, - } - } -} - -/// Title prefix applied to every OS toast fired from an embedded webview. -/// Owned by the shell crate so it doesn't take a build-time dep on the core -/// library. Disambiguates from natively-installed apps (Slack, Discord, -/// Telegram desktop) firing the same message twice. -const OPENHUMAN_TITLE_PREFIX: &str = "OpenHuman: "; - -fn slack_scanner_enabled() -> bool { - std::env::var("OPENHUMAN_DISABLE_SLACK_SCANNER") - .map(|v| { - let v = v.trim().to_ascii_lowercase(); - !(v == "1" || v == "true" || v == "yes" || v == "on") - }) - .unwrap_or(true) -} - -/// Serialised fire-event payload shipped to the frontend over the -/// `webview-notification:fired` Tauri event. Carries `account_id` + -/// `provider` so the React side can route a subsequent click back to -/// the originating webview via Redux. -#[derive(Debug, Clone, Serialize)] -struct WebviewNotificationFired { - account_id: String, - provider: String, - title: String, - body: String, - #[serde(skip_serializing_if = "Option::is_none")] - tag: Option, -} - -/// Linux: one worker thread + bounded queue so a burst of toasts does not -/// spawn unbounded `std::thread` handles (each would block in `wait_for_action`). -#[cfg(target_os = "linux")] -const LINUX_NOTIFY_QUEUE_CAP: usize = 16; - -#[cfg(target_os = "linux")] -static LINUX_NOTIFY_TX: OnceLock>> = - OnceLock::new(); - -#[cfg(target_os = "linux")] -fn enqueue_linux_notification(job: Box) { - let tx = LINUX_NOTIFY_TX.get_or_init(|| { - let (tx, rx) = sync_channel::>(LINUX_NOTIFY_QUEUE_CAP); - std::thread::Builder::new() - .name("openhuman-linux-notify".to_string()) - .spawn(move || { - while let Ok(j) = rx.recv() { - j(); - } - }) - .expect("spawn openhuman-linux-notify"); - tx - }); - if let Err(e) = tx.try_send(job) { - log::warn!( - "[notify-cef] linux notification queue full (cap={}), dropping toast: {}", - LINUX_NOTIFY_QUEUE_CAP, - e - ); - } -} - -/// Translate a `tauri-runtime-cef` notification payload into a native OS -/// toast via `tauri-plugin-notification`, and mirror the fire to the -/// React frontend so it can drive click-to-focus routing. -/// -/// Gated on the runtime `NotificationSettings` flag (OFF by default) so -/// v1 ships the plumbing without surprising users with a toast storm the -/// first time they open a busy Slack tab. -struct NotificationPayload { - title: String, - body: Option, - tag: Option, - silent: bool, -} - -fn forward_native_notification( - app: &AppHandle, - account_id: &str, - provider: &str, - payload: &NotificationPayload, -) { - if let Some(state) = app.try_state::() { - let prefs = state.notification_bypass.lock().unwrap().clone(); - if prefs.global_dnd { - log::debug!( - "[notify-bypass][{}] suppressed global_dnd provider={}", - account_id, - provider - ); - return; - } - if prefs.muted_accounts.contains(account_id) { - log::debug!( - "[notify-bypass][{}] suppressed muted_account provider={}", - account_id, - provider - ); - return; - } - if prefs.bypass_when_focused && prefs.focused_account.as_deref() == Some(account_id) { - log::debug!( - "[notify-bypass][{}] suppressed focused_account provider={}", - account_id, - provider - ); - return; - } - } - - // Feature flag — bail early when the user hasn't opted in. - if let Some(settings) = - app.try_state::() - { - if !settings.enabled() { - log::debug!( - "[notify-cef][{}] suppressed (feature flag off) provider={}", - account_id, - provider - ); - return; - } - } - - let provider_label = provider_display_name(provider); - let raw_title = payload.title.as_str().trim(); - let notify_title = if raw_title.is_empty() { - format!("{OPENHUMAN_TITLE_PREFIX}{provider_label}") - } else { - format!("{OPENHUMAN_TITLE_PREFIX}{provider_label} — {raw_title}") - }; - let body = payload.body.as_deref().unwrap_or(""); - log::info!( - "[notifications][{}] tag={:?} silent={} title_chars={} body_chars={}", - account_id, - payload.tag, - payload.silent, - raw_title.chars().count(), - body.chars().count() - ); - log::debug!("[notify-cef][{}] raw_title={:?}", account_id, raw_title); - - // Mirror to the frontend BEFORE firing the OS toast so the Redux - // store has the routing context ready by the time the user clicks. - let fired = WebviewNotificationFired { - account_id: account_id.to_string(), - provider: provider.to_string(), - title: notify_title.clone(), - body: body.to_string(), - tag: payload.tag.clone(), - }; - if let Err(err) = app.emit("webview-notification:fired", &fired) { - log::warn!( - "[notify-cef][{}] emit webview-notification:fired failed: {}", - account_id, - err - ); - } - - // Respect the Web Notification `silent` flag — the mirror event above - // still updates the in-app notification center, but the OS toast is - // suppressed so the user is not audibly/visually interrupted for - // notifications the page explicitly marked as silent. - if payload.silent { - log::debug!( - "[notify-cef][{}] silent=true, suppressing OS toast", - account_id - ); - return; - } - - // Fire the OS toast and wire a click callback that emits `notification:click` - // so the frontend can bring the originating account into focus. - // - // macOS: mac-notification-sys blocks in wait_for_click mode — run on a - // blocking thread so the async executor is not stalled. - // Linux: notify_rust's wait_for_action hooks D-Bus action delivery. - // Windows: no click callback available; fall back to fire-and-forget. - let acct_for_click = account_id.to_string(); - let prov_for_click = provider.to_string(); - let app_for_click = app.clone(); - - #[cfg(target_os = "macos")] - { - use std::sync::atomic::{AtomicUsize, Ordering}; - // Each `wait_for_click` thread blocks at ~100% CPU until the user - // clicks or the toast auto-dismisses. Under notification bursts this - // can pin many cores; cap concurrent click-wait threads and fall back - // to fire-and-forget (no click callback) once the budget is reached. - const MAX_CLICK_WAIT_THREADS: usize = 8; - static IN_FLIGHT: AtomicUsize = AtomicUsize::new(0); - - let title_c = notify_title.clone(); - let body_c = body.to_string(); - let app_id = app.config().identifier.clone(); - let prev = IN_FLIGHT.fetch_add(1, Ordering::AcqRel); - if prev >= MAX_CLICK_WAIT_THREADS { - IN_FLIGHT.fetch_sub(1, Ordering::AcqRel); - log::debug!( - "[notify-cef][{}] click-wait budget exhausted ({}), firing without click callback", - account_id, - prev - ); - std::thread::spawn(move || { - let _ = mac_notification_sys::set_application(if tauri::is_dev() { - "com.apple.Terminal" - } else { - &app_id - }); - use mac_notification_sys::Notification as MacNotif; - let mut n = MacNotif::new(); - n.title(&title_c).message(&body_c); - let _ = n.send(); - }); - return; - } - - std::thread::spawn(move || { - struct Guard; - impl Drop for Guard { - fn drop(&mut self) { - IN_FLIGHT.fetch_sub(1, Ordering::AcqRel); - } - } - let _guard = Guard; - - let _ = mac_notification_sys::set_application(if tauri::is_dev() { - "com.apple.Terminal" - } else { - &app_id - }); - use mac_notification_sys::{Notification as MacNotif, NotificationResponse}; - let t = title_c; - let b = body_c; - let mut n = MacNotif::new(); - n.title(&t).message(&b).wait_for_click(true); - match n.send() { - Ok(NotificationResponse::Click) | Ok(NotificationResponse::ActionButton(_)) => { - log::info!( - "[notify-click][{}] clicked provider={}", - acct_for_click, - prov_for_click - ); - if let Err(e) = app_for_click.emit( - "notification:click", - serde_json::json!({ - "account_id": acct_for_click, - "provider": prov_for_click, - }), - ) { - log::warn!( - "[notify-click][{}] emit notification:click failed: {}", - acct_for_click, - e - ); - } - } - Ok(other) => { - log::info!("[notify-click][{}] response={:?}", acct_for_click, other); - } - Err(e) => { - log::warn!("[notify-click][{}] send error: {}", acct_for_click, e); - } - } - }); - } - - #[cfg(target_os = "linux")] - { - let title_c = notify_title.clone(); - let body_c = body.to_string(); - enqueue_linux_notification(Box::new(move || { - let t = title_c; - let b = body_c; - let mut n = notify_rust::Notification::new(); - n.summary(&t).body(&b); - match n.show() { - Ok(handle) => { - handle.wait_for_action(|action| { - // "__closed" is the synthetic dismiss action; skip it. - if action != "__closed" && !action.is_empty() { - log::info!( - "[notify-click][{}] action={} provider={}", - acct_for_click, - action, - prov_for_click - ); - if let Err(e) = app_for_click.emit( - "notification:click", - serde_json::json!({ - "account_id": acct_for_click, - "provider": prov_for_click, - }), - ) { - log::warn!( - "[notify-click][{}] emit notification:click failed: {}", - acct_for_click, - e - ); - } - } - }); - } - Err(e) => { - log::warn!("[notify-click][{}] show failed: {}", acct_for_click, e); - } - } - })); - } - - #[cfg(windows)] - { - let mut builder = app.notification().builder().title(¬ify_title); - if !body.is_empty() { - builder = builder.body(body); - } - if let Err(e) = builder.show() { - log::warn!( - "[notify-cef][{}] notification show failed: {}", - account_id, - e - ); - } - } -} - -pub(crate) fn forward_synthetic_notification( - app: &AppHandle, - account_id: &str, - provider: &str, - title: impl Into, - body: impl Into, -) { - let payload = NotificationPayload { - title: title.into(), - body: Some(body.into()), - tag: None, - silent: false, - }; - forward_native_notification(app, account_id, provider, &payload); -} - -#[derive(Debug, Clone, Copy, Deserialize)] -pub struct Bounds { - pub x: f64, - pub y: f64, - pub width: f64, - pub height: f64, -} - -#[derive(Debug, Deserialize)] -pub struct OpenArgs { - pub account_id: String, - pub provider: String, - /// Optional URL override (debug tooling) — falls back to `provider_url`. - pub url: Option, - pub bounds: Option, - /// Issue #1233 — when true, spawn the webview off-screen and route the - /// load through the prewarm-suppression path. The full handler/scanner/ - /// notification setup is identical to a normal cold open; only the - /// initial position and the load-event emit are different. Defaults - /// to false so the field is forwards-compatible with frontends that - /// don't pass it. - #[serde(default)] - pub prewarm: bool, -} - -/// Issue #1233 — args for the background `webview_account_prewarm` command. -/// No bounds — prewarm always spawns at a fixed off-screen 1×1 rect; the -/// user-initiated open later supplies the visible rect via the warm-reopen -/// branch in `webview_account_open`. -#[derive(Debug, Deserialize)] -pub struct PrewarmArgs { - pub account_id: String, - pub provider: String, - /// Optional URL override (debug tooling) — falls back to `provider_url`. - #[serde(default)] - pub url: Option, -} - -#[derive(Debug, Deserialize)] -pub struct BoundsArgs { - pub account_id: String, - pub bounds: Bounds, -} - -#[derive(Debug, Deserialize)] -pub struct RevealArgs { - pub account_id: String, - pub bounds: Bounds, - #[serde(default)] - pub trigger: Option, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum RevealTrigger { - Load, - Watchdog, -} - -impl RevealTrigger { - fn as_str(self) -> &'static str { - match self { - Self::Load => "load", - Self::Watchdog => "watchdog", - } - } - - fn from_ipc(raw: Option<&str>) -> Self { - match raw { - Some("load") | None => Self::Load, - Some("watchdog") => Self::Watchdog, - Some(other) => { - log::warn!( - "[webview-accounts] unknown reveal trigger {:?}; defaulting to load", - other - ); - Self::Load - } - } - } -} - -#[derive(Debug, Deserialize)] -pub struct AccountIdArgs { - pub account_id: String, -} - -#[derive(Debug, Deserialize)] -pub struct RecipeEventArgs { - pub account_id: String, - pub provider: String, - pub kind: String, - pub payload: serde_json::Value, - pub ts: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub struct WebviewEvent { - pub account_id: String, - pub provider: String, - pub kind: String, - pub payload: serde_json::Value, - pub ts: Option, -} - -/// Strip query string and fragment from a URL before emitting to the log. -/// Provider URLs occasionally embed auth material (Telegram WebApp data, -/// OAuth callback codes, sometimes session tokens) and we don't want those -/// to land in the long-lived shell log file. Returns the original input on -/// parse failure so we still surface *something* useful for debugging. -pub(crate) fn redact_url_for_log(raw: &str) -> String { - match Url::parse(raw) { - Ok(mut u) => { - u.set_query(None); - u.set_fragment(None); - u.to_string() - } - Err(_) => { - // Fallback: drop everything from the first '?' or '#'. - raw.split(['?', '#']).next().unwrap_or(raw).to_string() - } - } -} - -/// Grow the first-cold-open webview back to its full requested bounds and -/// notify the frontend once the page is actually loaded. Called from three -/// signals (native `WebviewBuilder::on_page_load`, CDP `Page.loadEventFired`, -/// and the 15 s watchdog). -/// -/// Timeout is a non-terminal state: we emit `webview-account:load{state: -/// "timeout"}` so the frontend can show retry/help UI, but we deliberately do -/// NOT reveal or mark the account as loaded yet. If a later `finished` signal -/// arrives, that call still reveals and emits `state:"finished"`. -/// -/// Resetting the terminal loaded marker happens in `webview_account_close` / -/// `webview_account_purge` so a reopen fires again. -/// -/// Doing the `set_size` server-side (instead of waiting for the frontend to -/// invoke `webview_account_reveal`) avoids an extra IPC round-trip and the -/// brief blank frame that would otherwise sit between the load event and -/// the frontend's reveal call. -pub(crate) fn emit_load_finished( - app: &AppHandle, - account_id: &str, - state: &str, - url: &str, - trigger: RevealTrigger, -) { - let Some(app_state) = app.try_state::() else { - // No state => emit anyway so the frontend doesn't hang; best-effort. - log::warn!( - "[webview-accounts][{}] WebviewAccountsState missing — emitting without reveal", - account_id - ); - let _ = app.emit( - "webview-account:load", - serde_json::json!({ - "account_id": account_id, - "state": state, - "trigger": trigger.as_str(), - "url": url, - }), - ); - return; - }; - - // Issue #1233 — accounts in prewarm mode have no React UI listening - // for their load events; the user hasn't clicked the rail icon yet. - // Suppress emit + reveal so the prewarm cycle finishes silently. The - // page is still painted in the off-screen 1×1 webview so the eventual - // user click hits the warm-reopen branch and emits `state:"reused"`. - if app_state - .prewarm_accounts - .lock() - .unwrap() - .contains(account_id) - { - log::info!( - "[webview-accounts][{}] prewarm load suppressed state={} url={}", - account_id, - state, - redact_url_for_log(url) - ); - // Mark the account as loaded so any later signals from the same - // cold-load (native on_page_load + CDP Page.loadEventFired both - // arriving) don't double-fire if the prewarm flag flips off in - // between. - if state != "timeout" { - app_state - .loaded_accounts - .lock() - .unwrap() - .insert(account_id.to_string()); - } - return; - } - - if state == "timeout" { - // If we've already observed a terminal load, ignore late watchdogs. - let already_loaded = app_state - .loaded_accounts - .lock() - .unwrap() - .contains(account_id); - if already_loaded { - log::debug!( - "[webview-accounts][{}] timeout deduped after terminal load url={}", - account_id, - url - ); - return; - } - - log::info!( - "[webview-accounts][{}] load timeout event trigger={} url={}", - account_id, - trigger.as_str(), - redact_url_for_log(url) - ); - if let Err(err) = app.emit( - "webview-account:load", - serde_json::json!({ - "account_id": account_id, - "state": state, - "trigger": trigger.as_str(), - "url": url, - }), - ) { - log::warn!( - "[webview-accounts][{}] emit webview-account:load(timeout) failed: {}", - account_id, - err - ); - } - return; - } - - let is_first_terminal = app_state - .loaded_accounts - .lock() - .unwrap() - .insert(account_id.to_string()); - if !is_first_terminal { - log::debug!( - "[webview-accounts][{}] load event deduped state={} url={}", - account_id, - state, - url - ); - return; - } - - // Restore the webview to its full requested size. The spawn path created - // it at 1×1 so the React loading spinner wasn't covered; now that the page - // is painted we can grow it into the placeholder rect. - let label = app_state.inner.lock().unwrap().get(account_id).cloned(); - let bounds = app_state - .requested_bounds - .lock() - .unwrap() - .get(account_id) - .copied(); - match (label, bounds) { - (Some(label), Some(b)) => { - if let Some(wv) = app.get_webview(&label) { - if let Err(e) = wv.set_size(LogicalSize::new(b.width, b.height)) { - log::warn!( - "[webview-accounts][{}] reveal set_size failed: {}", - account_id, - e - ); - } - if let Err(e) = wv.set_position(LogicalPosition::new(b.x, b.y)) { - log::warn!( - "[webview-accounts][{}] reveal set_position failed: {}", - account_id, - e - ); - } - let _ = wv.show(); - log::info!( - "[webview-accounts][{}] revealed label={} bounds={:?} state={}", - account_id, - label, - b, - state - ); - } else { - log::warn!( - "[webview-accounts][{}] reveal: webview {} missing", - account_id, - label - ); - } - } - _ => { - log::info!( - "[webview-accounts][{}] reveal skipped (account closed before load) state={}", - account_id, - state - ); - } - } - - // Redact the URL in the log: providers like Telegram (`#tgWebAppData=…`) - // and OAuth callbacks embed auth material in the query/fragment. The full - // URL still flows to the frontend listener over the Tauri event so any - // consumer that needs it has access; we just don't persist it to the - // shell's log file. - log::info!( - "[webview-accounts][{}] load event state={} trigger={} url={}", - account_id, - state, - trigger.as_str(), - redact_url_for_log(url) - ); - if let Err(err) = app.emit( - "webview-account:load", - serde_json::json!({ - "account_id": account_id, - "state": state, - "trigger": trigger.as_str(), - "url": url, - }), - ) { - log::warn!( - "[webview-accounts][{}] emit webview-account:load failed: {}", - account_id, - err - ); - } -} - -/// Reject any `account_id` that isn't strictly `[A-Za-z0-9_-]+`. The ID comes -/// from IPC (React shell, but also from injected recipe code running inside -/// third-party origins via `webview_recipe_event`), so treat it as untrusted. -/// Enforcing this early prevents `../` sequences from escaping the per-account -/// data directory in `data_directory_for` (which feeds `create_dir_all` and -/// `remove_dir_all`). -fn sanitize_account_id(account_id: &str) -> Result<&str, String> { - if account_id.is_empty() - || !account_id - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') - { - return Err(format!("invalid account_id: {account_id:?}")); - } - Ok(account_id) -} - -fn label_for(account_id: &str) -> String { - // Webview labels must be alphanumeric + `-` / `_`. Callers that reached - // here without first going through `sanitize_account_id` still get a - // defensively-scrubbed label so invalid characters never reach the - // tauri webview-label parser. - let safe: String = account_id - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '-' || c == '_' { - c - } else { - '_' - } - }) - .collect(); - format!("acct_{}", safe) -} - -fn data_directory_for(app: &AppHandle, account_id: &str) -> Result { - // Guard against path traversal — `account_id` is joined into a filesystem - // path that is later passed to `create_dir_all` / `remove_dir_all`. - let account_id = sanitize_account_id(account_id)?; - let base = app - .path() - .app_local_data_dir() - .map_err(|e| format!("app_local_data_dir: {e}"))?; - Ok(base.join("webview_accounts").join(account_id)) -} - -/// Produce the `initialization_script` payload for this webview. -/// -/// Empty for the 6 zero-injection providers (whatsapp, wechat, telegram, -/// slack, discord, browserscan) — they load with ZERO injected JS. Some have -/// native/CDP scraper paths (`wechat_scanner`, etc.). The per-account -/// CDP session opener (`cdp::session`) still injects the notification-permission -/// shim via `Page.addScriptToEvaluateOnNewDocument` before the real provider -/// URL loads. The 2 deferred providers (linkedin, google-meet) still get the -/// JS recipe bridge. -fn build_init_script(account_id: &str, provider: &str) -> String { - let Some(recipe_js) = provider_recipe_js(provider) else { - return String::new(); - }; - let ctx = serde_json::json!({ - "accountId": account_id, - "provider": provider, - }); - format!( - "window.__OPENHUMAN_RECIPE_CTX__ = {ctx};\n\n{runtime}\n\n{recipe}\n", - ctx = ctx, - runtime = RUNTIME_JS, - recipe = recipe_js - ) -} - -/// Spawn (or focus) the embedded webview for an account. -#[tauri::command] -pub async fn webview_account_open( - app: AppHandle, - state: tauri::State<'_, WebviewAccountsState>, - args: OpenArgs, -) -> Result { - let label = label_for(&args.account_id); - log::info!( - "[webview-accounts] open account_id={} provider={} label={}", - args.account_id, - args.provider, - label - ); - - // Reject unknown providers early. `provider_url` already errors when - // no URL override is supplied; the `provider_is_supported` check - // additionally gates custom-URL overrides so an arbitrary provider - // string can't ride in via the debug `url` field. - if !provider_is_supported(&args.provider) { - return Err(format!("unknown provider: {}", args.provider)); - } - let real_url_str = args - .url - .as_deref() - .or_else(|| provider_url(&args.provider)) - .ok_or_else(|| format!("no url for provider: {}", args.provider))? - .to_string(); - // Validate the real URL up front — otherwise a malformed debug - // `args.url` would only fail later inside the async CDP session - // loop, which is much harder to surface to the caller. The parsed - // Url also feeds `scanner_url_prefix` so scanners match on the - // actual origin the user navigated to (honoring debug overrides). - let real_url: Url = real_url_str - .parse() - .map_err(|e| format!("invalid provider url {real_url_str}: {e}"))?; - // Scanner target-match uses `url.starts_with(prefix)`, so the - // prefix needs to be the ORIGIN (scheme + host), not the full URL - // — same-host intra-app navigations must keep matching after the - // initial load. - let scanner_url_prefix = format!("{}/", real_url.origin().ascii_serialization()); - let skip_cdp_for_debug = args.provider == "slack" && !slack_scanner_enabled(); - // We normally open the webview at a tiny placeholder URL so the CDP - // session opener can attach and inject the notification-permission - // shim (see `cdp/session.rs`) BEFORE the real provider URL loads; - // without it Slack surfaces in-app "enable notifications" - // banners. For Slack debug sessions we allow opting out via - // `OPENHUMAN_DISABLE_SLACK_SCANNER=1`, which also skips the long-lived - // CDP session so external DevTools can attach cleanly. - let initial_url_str = if skip_cdp_for_debug { - real_url_str.clone() - } else { - cdp::placeholder_url(&args.account_id) - }; - let initial_url: Url = initial_url_str - .parse() - .map_err(|e| format!("invalid initial url {initial_url_str}: {e}"))?; - - // If a webview for this account already exists, just reposition / show. - { - let map = state.inner.lock().unwrap(); - if let Some(existing_label) = map.get(&args.account_id).cloned() { - drop(map); - if let Some(existing) = app.get_webview(&existing_label) { - // Issue #1233 — when this is a prewarm call landing on an - // already-prewarmed account, do nothing: the webview is - // already off-screen, the CDP session is already attached, - // and the prewarm flag should stay set so the eventual - // user-initiated open can promote it. Just return the label. - if args.prewarm { - log::debug!( - "[webview-accounts] prewarm idempotent skip: account={} already warm label={}", - args.account_id, - existing_label - ); - return Ok(existing_label); - } - // Issue #1233 — a prewarmed webview is reaching its first - // user-initiated open. Clear the prewarm flag BEFORE we - // resize/reveal so any in-flight CDP load event still - // racing toward `emit_load_finished` flows through the - // normal path instead of being silently suppressed. - let was_prewarmed = state - .prewarm_accounts - .lock() - .unwrap() - .remove(&args.account_id); - if was_prewarmed { - log::info!( - "[webview-accounts] prewarm hit account={} label={} — promoting to live", - args.account_id, - existing_label - ); - } - if let Some(b) = args.bounds { - let _ = existing.set_position(LogicalPosition::new(b.x, b.y)); - let _ = existing.set_size(LogicalSize::new(b.width, b.height)); - state - .requested_bounds - .lock() - .unwrap() - .insert(args.account_id.clone(), b); - } - let _ = existing.show(); - log::info!( - "[webview-accounts] reused existing label={} for account={}", - existing_label, - args.account_id - ); - // Warm re-open: the page is already painted, so skip the - // loading overlay cycle and tell the frontend to go straight - // to `open`. We bypass `emit_load_finished` because the - // `loaded_accounts` dedup set would swallow the emit after - // the first cold open of this account. - let reuse_url = existing.url().map(|u| u.to_string()).unwrap_or_default(); - if let Err(err) = app.emit( - "webview-account:load", - serde_json::json!({ - "account_id": args.account_id, - "state": "reused", - "trigger": RevealTrigger::Load.as_str(), - "url": reuse_url, - }), - ) { - log::warn!( - "[webview-accounts][{}] emit reused event failed: {}", - args.account_id, - err - ); - } - return Ok(existing_label); - } - // Stale entry — fall through and rebuild - log::warn!( - "[webview-accounts] stale label {} found for account {}, rebuilding", - existing_label, - args.account_id - ); - } - } - - // Grab the raw Window (not WebviewWindow) so `add_child` works even - // after we've attached sibling webviews — `get_webview_window` checks - // `is_webview_window()` which flips to false once a window has more - // than one webview. - let parent_window = app - .get_window("main") - .ok_or_else(|| "main window not found".to_string())?; - - let data_dir = data_directory_for(&app, &args.account_id)?; - if let Err(err) = std::fs::create_dir_all(&data_dir) { - log::warn!( - "[webview-accounts] failed to create data dir {}: {}", - data_dir.display(), - err - ); - } - - let init_script = build_init_script(&args.account_id, &args.provider); - - let mut builder = WebviewBuilder::new(label.clone(), WebviewUrl::External(initial_url)) - .data_directory(data_dir); - if !init_script.is_empty() { - builder = builder.initialization_script(&init_script); - } - - // Keep link clicks that leave the provider's host set in the OS - // browser, not the embedded webview. Same-host navigations (including - // OAuth hops to accounts.google.com etc., which we pre-declare per - // provider) stay in-app. Provider-specific native-app deep links - // (`zoomus://`, `zoommtg://`, …) are rewritten to the web-client URL - // and re-navigated in-app so meetings don't bounce out. - let nav_provider = args.provider.clone(); - let nav_app = app.clone(); - let nav_label = label.clone(); - let nav_account_id = args.account_id.clone(); - builder = builder.on_navigation(move |url| { - // Notify the frontend on every committed navigation. The - // `webview-account:load` event is dedup'd per cold open, so it - // can't be used to spot post-login redirects (e.g. Google - // Meet's accounts.google.com → meet.google.com hop). Frontends - // that - // care about live URL transitions — onboarding's auto-detect - // for "user finished signing in", for instance — listen here. - if let Err(err) = nav_app.emit( - "webview-account:navigate", - serde_json::json!({ - "account_id": nav_account_id, - "provider": nav_provider, - "url": redact_navigation_url(url), - }), - ) { - log::debug!( - "[webview-accounts] emit webview-account:navigate failed: {}", - err - ); - } - // Google Meet: when Google's edge SSR-redirects the post-account- - // picker URL to `workspace.google.com/products/meet/...` (the - // marketing landing page), `workspace.google.com` matches the - // bare `google.com` suffix in `provider_allowed_hosts` so - // `url_is_internal` would commit the navigation and the user - // would land on the Workspace marketing page instead of Meet. - // Catch this here and replace the parent URL with the canonical - // Meet entry point so the embedded view stays on the app. - // - // BUT: unauthenticated users get bounced right back to - // `workspace.google.com/products/meet/` by Google's edge, so an - // unguarded rewrite ping-pongs forever (`navigate` → Google - // redirect → `on_navigation` → `navigate` → …). We track per-label - // attempts in `WebviewAccountsState::gmeet_marketing_rewrites` and - // bail to a Google sign-in URL after a small threshold so the user - // can break out of the loop. See #1213 (downstream watchdog - // symptom) and `track_gmeet_marketing_rewrite` for the policy. - if nav_provider == "google-meet" { - if let Some(host) = url.host_str() { - // Post-auth handoff: when the bail's `ServiceLogin?continue=` - // chain completes, Google sometimes drops the continue param - // (URL ends in `?utm_source=sign_in_no_continue`) and dumps - // the user on `myaccount.google.com` instead of Meet. The - // session cookie is now valid, so a direct navigation to - // `meet.google.com` will paint Meet without bouncing back - // through the workspace marketing redirect (which was the - // unauthenticated branch). Force the hop here so the user - // doesn't have to click through the apps grid manually. - // - // Gated on the per-label `gmeet_awaiting_handoff` flag — - // set by the Bail branch right before navigating to - // `ServiceLogin?continue=` — so legitimate user-initiated - // visits to `myaccount.google.com` (e.g. "Manage your - // Google Account" from the avatar menu) pass through and - // remain reachable in-app. - if host == "myaccount.google.com" { - let consumed = nav_app - .try_state::() - .map(|s| s.take_awaiting_gmeet_handoff(&nav_label)) - .unwrap_or(false); - if !consumed { - log::debug!( - "[webview-accounts] gmeet myaccount.google.com nav (label={}) not in handoff window; passing through", - nav_label - ); - } else { - log::info!( - "[webview-accounts] gmeet post-auth handoff detected on myaccount.google.com; navigating parent to https://meet.google.com/" - ); - let app = nav_app.clone(); - let label = nav_label.clone(); - // Reset the marketing-rewrite counter so the next - // workspace bounce (if any) gets a fresh window — - // the user is now authenticated and shouldn't - // loop again. - if let Some(s) = nav_app.try_state::() { - s.clear_gmeet_marketing_rewrite(&nav_label); - } - tauri::async_runtime::spawn(async move { - if let Some(wv) = app.get_webview(&label) { - if let Ok(target) = Url::parse("https://meet.google.com/") { - if let Err(e) = wv.navigate(target) { - log::warn!( - "[webview-accounts] gmeet post-auth navigate failed label={} err={}", - label, - e - ); - } - } - } - }); - return false; - } - } - if is_gmeet_marketing_redirect(host, url.path()) { - let action = nav_app - .try_state::() - .map(|s| s.track_gmeet_marketing_rewrite(&nav_label, Instant::now())) - .unwrap_or(GmeetRewriteAction::Rewrite); - let app = nav_app.clone(); - let label = nav_label.clone(); - let target_url = match action { - GmeetRewriteAction::Rewrite => { - log::info!( - "[webview-accounts] gmeet workspace marketing redirect intercepted ({}); rewriting parent to https://meet.google.com/", - url - ); - "https://meet.google.com/" - } - GmeetRewriteAction::Bail => { - log::warn!( - "[webview-accounts] gmeet workspace rewrite loop detected on label={} (>{} attempts in {}s); falling through to Google sign-in", - nav_label, - GMEET_REWRITE_MAX_ATTEMPTS, - GMEET_REWRITE_WINDOW.as_secs() - ); - // Arm the post-auth handoff flag so the next - // `myaccount.google.com` commit on this label - // (the `?utm_source=sign_in_no_continue` dump - // page Google sometimes drops users on after - // the ServiceLogin chain) gets force-redirected - // back to Meet. Without this gate, ANY - // `myaccount.google.com` visit was hijacked, - // breaking legitimate "Manage your Google - // Account" flows. - if let Some(s) = nav_app.try_state::() { - s.mark_awaiting_gmeet_handoff(&nav_label); - } - // `service=meet` is rejected by Google (`400 - // malformed`); the continue param must be - // URL-encoded since `&` would split it. Drop - // `service=` and encode `continue=` so the - // sign-in landing actually loads. - "https://accounts.google.com/ServiceLogin?continue=https%3A%2F%2Fmeet.google.com%2F" - } - }; - tauri::async_runtime::spawn(async move { - if let Some(wv) = app.get_webview(&label) { - if let Ok(target) = Url::parse(target_url) { - if let Err(e) = wv.navigate(target) { - log::warn!( - "[webview-accounts] gmeet workspace rewrite navigate failed label={} target={} err={}", - label, - target_url, - e - ); - } - } - } - }); - return false; - } - } - } - if let Some(rewritten) = rewrite_provider_deep_link(&nav_provider, url) { - log::info!( - "[webview-accounts] deep-link rewrite {} → {} (provider={})", - url, - rewritten, - nav_provider - ); - let app = nav_app.clone(); - let label = nav_label.clone(); - tauri::async_runtime::spawn(async move { - if let Some(wv) = app.get_webview(&label) { - if let Err(e) = wv.navigate(rewritten) { - log::warn!( - "[webview-accounts] post-rewrite navigate failed label={} err={}", - label, - e - ); - } - } - }); - return false; - } - if url_is_internal(&nav_provider, url) { - true - } else { - // Suppress provider native-desktop-app deep-link schemes that - // we don't know how to rewrite. macOS would otherwise hand - // these to the native provider app — `slack://magic-login/…` - // signs the native Slack app into the workspace, breaking - // embedded-webview isolation (#1074). The web flow's HTTPS - // fallback handles sign-in without the deep link. - if is_provider_native_deep_link_scheme(url.scheme()) { - log::warn!( - "[webview-accounts] suppressing native-app deep-link scheme={} url={} (would breach workspace isolation)", - url.scheme(), - redact_native_deep_link_url(url) - ); - return false; - } - let target = unwrap_provider_redirect(url) - .map(|u| u.to_string()) - .unwrap_or_else(|| url.to_string()); - if target != url.as_str() { - log::info!( - "[webview-accounts] external navigation {} → (unwrapped) {} → system browser", - url, - target - ); - } else { - log::info!( - "[webview-accounts] external navigation {} → system browser", - url - ); - } - open_in_system_browser(&target); - false - } - }); - - // Cmd/Ctrl-click and `target="_blank"` / `window.open(...)` trigger a - // new-window request. Default policy: deny and hand the URL to the - // system browser — matches user intent of "open in new tab outside - // the app". - // - // Exception: some providers (Slack Huddles) spawn popups via - // `window.open()` and abort the flow if the return value is falsey. - // For those URLs we allow CEF's default popup handling so an in-app - // child window opens and the caller gets a real window handle. - let popup_provider = args.provider.clone(); - let popup_app = app.clone(); - let popup_label = label.clone(); - builder = builder.on_new_window(move |url, _features| { - if let Some(rewritten) = rewrite_provider_deep_link(&popup_provider, &url) { - log::info!( - "[webview-accounts] new-window deep-link rewrite {} → {} (provider={})", - url, - rewritten, - popup_provider - ); - let app = popup_app.clone(); - let label = popup_label.clone(); - tauri::async_runtime::spawn(async move { - if let Some(wv) = app.get_webview(&label) { - if let Err(e) = wv.navigate(rewritten) { - log::warn!( - "[webview-accounts] post-rewrite navigate (popup) failed label={} err={}", - label, - e - ); - } - } - }); - return NewWindowResponse::Deny; - } - if let Some(target) = popup_should_navigate_parent(&popup_provider, &url) { - log::info!( - "[webview-accounts] new-window {} → navigate parent (provider={})", - redact_navigation_url(&url), - popup_provider - ); - let app = popup_app.clone(); - let label = popup_label.clone(); - tauri::async_runtime::spawn(async move { - if let Some(wv) = app.get_webview(&label) { - if let Err(e) = wv.navigate(target) { - log::warn!( - "[webview-accounts] popup→parent navigate failed label={} err={}", - label, - e - ); - } - } - }); - return NewWindowResponse::Deny; - } - if popup_should_stay_in_app(&popup_provider, &url) { - log::info!( - "[webview-accounts] new-window request {} → in-app popup (provider={})", - url, - popup_provider - ); - NewWindowResponse::Allow - } else { - // Suppress provider native-desktop-app deep-link schemes that - // we don't know how to rewrite (matches the on_navigation - // fallback). Without this, a `slack://...` popup would land - // in the native Slack app via macOS's URL handler and - // breach embedded-webview workspace isolation (#1074). - if is_provider_native_deep_link_scheme(url.scheme()) { - log::warn!( - "[webview-accounts] suppressing native-app deep-link scheme={} url={} (would breach workspace isolation)", - url.scheme(), - redact_native_deep_link_url(&url) - ); - return NewWindowResponse::Deny; - } - let target = unwrap_provider_redirect(&url) - .map(|u| u.to_string()) - .unwrap_or_else(|| url.to_string()); - if target != url.as_str() { - log::info!( - "[webview-accounts] new-window request {} → (unwrapped) {} → system browser", - url, - target - ); - } else { - log::info!( - "[webview-accounts] new-window request {} → system browser", - url - ); - } - open_in_system_browser(&target); - NewWindowResponse::Deny - } - }); - - // Enable devtools on child webviews in debug builds only so recipe - // diagnostics and IndexedDB state can be inspected. Access on macOS is via - // Safari → Develop → → - // (the parent Tauri window's right-click "Inspect" does not propagate - // into child webviews on WKWebView). In release builds we leave CDP off - // so third-party-site webviews are not remotely inspectable. - if cfg!(debug_assertions) { - builder = builder.devtools(true); - } - - // Wire the native page-load signal and forward only *usable* load - // completions to `emit_load_finished`: - // - skip placeholder `about:blank#openhuman-acct-*` commits (otherwise - // we reveal a blank viewport before real content arrives), - // - treat Chromium network error pages (`chrome-error://…`) as timeout - // signals so frontend shows retry/help UI instead of the dino page. - // - // Real provider commits still emit `finished`. Dedup against CDP - // `Page.loadEventFired` + watchdog happens in `emit_load_finished`. - let page_load_app = app.clone(); - let page_load_account_id = args.account_id.clone(); - let page_load_placeholder_fragment = format!("#{}", cdp::placeholder_marker(&args.account_id)); - let page_load_real_url = real_url_str.clone(); - builder = builder.on_page_load(move |_webview, payload| { - if !matches!(payload.event(), tauri::webview::PageLoadEvent::Finished) { - return; - } - let url = payload.url(); - if url.scheme() == "data" { - return; - } - if !skip_cdp_for_debug && url.as_str().ends_with(&page_load_placeholder_fragment) { - log::debug!( - "[webview-accounts][{}] skipping placeholder native-finished url={}", - page_load_account_id, - redact_url_for_log(url.as_str()) - ); - return; - } - if url.scheme() == "chrome-error" { - emit_load_finished( - &page_load_app, - &page_load_account_id, - "timeout", - &page_load_real_url, - RevealTrigger::Load, - ); - return; - } - emit_load_finished( - &page_load_app, - &page_load_account_id, - "finished", - url.as_str(), - RevealTrigger::Load, - ); - }); - - let bounds = args.bounds.unwrap_or(Bounds { - x: 0.0, - y: 0.0, - width: 800.0, - height: 600.0, - }); - - // Park the webview off-screen during its first page load so the React - // placeholder's loading spinner is not covered by the native CEF subview. - // `webview_account_reveal` (invoked from the frontend after the load event - // arrives, or by the 15 s watchdog) moves it back to `bounds` + shows it. - // - // Warm-open reuse (when a webview already exists for this account) earlier - // in this function returns before we get here, so existing webviews keep - // their current position — we only off-screen the first cold spawn. - // Spawn strategy: keep the webview at the caller's requested position - // but shrink the initial size to 1×1 under CEF so the native subview - // doesn't paint over the React loading spinner. `webview_account_reveal` - // grows it back to `bounds.width × bounds.height` once the page-loaded - // signal arrives. - // - // Why not move off-screen: moving the NSView after a cold CEF spawn on - // macOS sometimes leaves the page painted but not repainted at the new - // origin, leaving the user looking at a blank viewport until they - // reload. Keeping the position stable and only toggling size sidesteps - // that repaint edge case while still keeping the webview visually - // hidden (1 px under the overlay) during load. - // - // Issue #1233 — when `args.prewarm == true`, the frontend has not asked - // for a visible rect (the user hasn't clicked the rail icon yet). Spawn - // the webview at a fixed off-screen position with size 1×1 so it never - // paints anywhere on screen until the eventual user-initiated open - // promotes it via the warm-reopen branch above. - let (initial_position, initial_size) = if args.prewarm { - ( - LogicalPosition::new(PREWARM_OFFSCREEN_X, PREWARM_OFFSCREEN_Y), - LogicalSize::new(1.0, 1.0), - ) - } else if skip_cdp_for_debug { - ( - LogicalPosition::new(bounds.x, bounds.y), - LogicalSize::new(bounds.width, bounds.height), - ) - } else { - ( - LogicalPosition::new(bounds.x, bounds.y), - LogicalSize::new(1.0, 1.0), - ) - }; - - // Issue #1233 — only remember `requested_bounds` for non-prewarm opens. - // Prewarm doesn't have a visible rect to restore to; the user-initiated - // open later supplies the bounds via the warm-reopen branch. - if !args.prewarm { - state - .requested_bounds - .lock() - .unwrap() - .insert(args.account_id.clone(), bounds); - } - // Issue #1233 — mark the account as prewarmed BEFORE add_child so the - // load-event suppression in `emit_load_finished` is in place by the time - // the CDP session or native on_page_load fires. - if args.prewarm { - state - .prewarm_accounts - .lock() - .unwrap() - .insert(args.account_id.clone()); - } - // Defensive reset: if a prior close/purge was raced by a stale emit we - // could still have the account marked as "already loaded". Clear here so - // the fresh spawn is allowed to fire the first event again. - state - .loaded_accounts - .lock() - .unwrap() - .remove(&args.account_id); - - let webview = parent_window - .add_child(builder, initial_position, initial_size) - .map_err(|e| format!("add_child failed: {e}"))?; - - // Install the in-process CDP transport so the per-account session - // opener and the provider scanners can attach. Failure here is - // logged but not fatal — the scanners retry through - // `cdp::conn_for_account` once the registry is populated, so a - // transient install error just delays first attach by one backoff - // tick. - if let Err(err) = crate::cdp::install_for_account(&args.account_id) { - log::warn!( - "[webview-accounts] cdp install_for_account({}) failed: {} \ - (scanners will retry)", - args.account_id, - err - ); - } - - // Capture the cold-spawn timestamp so the reveal-time log can compute - // spawn -> frontend reveal latency for the Slack first-load investigation. - state - .spawn_started_at - .lock() - .unwrap() - .insert(args.account_id.clone(), Instant::now()); - state - .account_providers - .lock() - .unwrap() - .insert(args.account_id.clone(), args.provider.clone()); - - log::info!( - "[webview-accounts] spawned label={} requested_bounds={:?} initial_size={:?}", - webview.label(), - bounds, - initial_size - ); - - state - .inner - .lock() - .unwrap() - .insert(args.account_id.clone(), label.clone()); - - // Spawn the per-account CDP session opener: holds an attached session - // for the lifetime of the webview so `Emulation.setUserAgentOverride` - // (which reverts on detach) keeps applying, and drives the initial - // Page.navigate from our placeholder URL to the real provider URL. - // Also installs the `#openhuman-account-{id}` fragment the scanners - // match on for multi-account disambiguation. - // Spawn the per-account CDP session opener, replacing any prior - // handle for this account (the old one would still be trying to - // attach to a target that's been torn down). - { - if skip_cdp_for_debug { - log::info!( - "[webview-accounts] skipping CDP session via OPENHUMAN_DISABLE_SLACK_SCANNER for account={}", - args.account_id - ); - } else { - let cdp::SpawnedSession { session, watchdog } = - cdp::spawn_session(app.clone(), args.account_id.clone(), real_url_str.clone()); - if let Some(old) = state - .cdp_sessions - .lock() - .unwrap() - .insert(args.account_id.clone(), session) - { - old.abort(); - } - if let Some(old) = state - .load_watchdogs - .lock() - .unwrap() - .insert(args.account_id.clone(), watchdog) - { - old.abort(); - } - } - } - - // For providers we know how to scrape via CDP, kick off the IndexedDB - // scanner. CDP requires the CEF runtime's remote-debugging port. - { - // Prefix is derived from the validated real URL's origin above - // so debug `args.url` overrides (alt hosts, localhost mirrors) - // resolve correctly — previously we always used the static - // `provider_url(...)` default even when the webview had - // navigated elsewhere. - if args.provider == "whatsapp" { - let registry = app - .try_state::>() - .map(|s| s.inner().clone()); - if let Some(registry) = registry { - registry.ensure_scanner( - app.clone(), - args.account_id.clone(), - scanner_url_prefix.clone(), - ); - } else { - log::warn!("[webview-accounts] CDP ScannerRegistry not in app state"); - } - } else if args.provider == "slack" { - if slack_scanner_enabled() { - let registry = app - .try_state::>() - .map(|s| s.inner().clone()); - if let Some(registry) = registry { - registry.ensure_scanner( - app.clone(), - args.account_id.clone(), - scanner_url_prefix.clone(), - ); - } else { - log::warn!("[webview-accounts] slack ScannerRegistry not in app state"); - } - } else { - log::info!( - "[webview-accounts] slack scanner disabled via OPENHUMAN_DISABLE_SLACK_SCANNER for account={}", - args.account_id - ); - } - } else if args.provider == "telegram" { - let registry = app - .try_state::>() - .map(|s| s.inner().clone()); - if let Some(registry) = registry { - registry.ensure_scanner( - app.clone(), - args.account_id.clone(), - scanner_url_prefix.clone(), - ); - } else { - log::warn!("[webview-accounts] telegram ScannerRegistry not in app state"); - } - } else if args.provider == "discord" { - // Discord MITM uses CDP `Network.*` to capture HTTP API calls - // and gateway WebSocket frames — see `discord_scanner/mod.rs`. - let registry = app - .try_state::>() - .map(|s| s.inner().clone()); - if let Some(registry) = registry { - registry.ensure_scanner( - app.clone(), - args.account_id.clone(), - scanner_url_prefix.clone(), - ); - } else { - log::warn!("[webview-accounts] discord ScannerRegistry not in app state"); - } - } else if args.provider == "wechat" { - if let Some(registry) = app - .try_state::>() - .map(|s| s.inner().clone()) - { - registry.ensure_scanner( - app.clone(), - args.account_id.clone(), - scanner_url_prefix.clone(), - ); - } else { - log::warn!("[webview-accounts] wechat ScannerRegistry not in app state"); - } - } - - // Upstream Tauri does not offer CEF's native browser-notification - // interception. Synthetic notifications continue to use the platform - // notification bridge above. - } - - Ok(label) -} - -/// Off-screen position used for the prewarmed webview. Same magnitude as -/// the [`super::lib::CEF_PREWARM_LABEL`] warmup placeholder so the native -/// view is well outside any plausible monitor layout. Issue #1233. -pub(crate) const PREWARM_OFFSCREEN_X: f64 = -20_000.0; -pub(crate) const PREWARM_OFFSCREEN_Y: f64 = -20_000.0; - -/// Issue #1233 — spawn a hidden 1×1 webview for `account_id` so its CEF -/// profile and provider page are warm before the user clicks the rail icon. -/// On the user's first click, the existing `webview_account_open` warm-reopen -/// branch reuses the prewarmed webview and emits `state:"reused"` so the React -/// loading overlay never has to wait for a cold load. -/// -/// Implemented as a thin delegate to `webview_account_open` with -/// `prewarm: true`. Sharing the cold-open code path means the prewarmed -/// webview gets the full handler suite (`on_navigation`, `on_new_window`, -/// `on_page_load`), the per-provider scanner bootstrap, and the CEF -/// notification registration — none of which can be retroactively wired -/// when the warm-reopen branch later returns early. -/// -/// Idempotent — calling for an already-warm account is a no-op. Best-effort — -/// the frontend can safely fire-and-forget; on failure the worst case is a -/// normal cold open later. -#[tauri::command] -pub async fn webview_account_prewarm( - app: AppHandle, - state: tauri::State<'_, WebviewAccountsState>, - args: PrewarmArgs, -) -> Result<(), String> { - log::info!( - "[webview-accounts] prewarm account_id={} provider={}", - args.account_id, - args.provider - ); - let open_args = OpenArgs { - account_id: args.account_id, - provider: args.provider, - url: args.url, - bounds: None, - prewarm: true, - }; - webview_account_open(app, state, open_args) - .await - .map(|_| ()) -} - -#[tauri::command] -pub async fn webview_account_close( - app: AppHandle, - state: tauri::State<'_, WebviewAccountsState>, - args: AccountIdArgs, -) -> Result<(), String> { - let label_opt = state.inner.lock().unwrap().remove(&args.account_id); - let Some(label) = label_opt else { - log::debug!( - "[webview-accounts] close: no webview for account {}", - args.account_id - ); - return Ok(()); - }; - if let Some(wv) = app.get_webview(&label) { - if let Err(e) = wv.close() { - log::warn!("[webview-accounts] close({label}) failed: {e}"); - } - } - teardown_account_scanners(&app, &args.account_id); - state.browser_ids.lock().unwrap().remove(&args.account_id); - if let Some(task) = state.cdp_sessions.lock().unwrap().remove(&args.account_id) { - task.abort(); - log::debug!( - "[cdp-session] aborted session task for account={}", - args.account_id - ); - } - if let Some(task) = state - .load_watchdogs - .lock() - .unwrap() - .remove(&args.account_id) - { - task.abort(); - log::debug!( - "[webview-accounts] aborted load watchdog for account={}", - args.account_id - ); - } - // Reset load-overlay bookkeeping so the next open of this account starts - // with a fresh "not yet loaded" state. - state - .loaded_accounts - .lock() - .unwrap() - .remove(&args.account_id); - state - .requested_bounds - .lock() - .unwrap() - .remove(&args.account_id); - state - .spawn_started_at - .lock() - .unwrap() - .remove(&args.account_id); - state - .account_providers - .lock() - .unwrap() - .remove(&args.account_id); - // Issue #1233 — drop the prewarm flag too so a future prewarm dispatch - // for the same id can re-attempt cleanly. - state - .prewarm_accounts - .lock() - .unwrap() - .remove(&args.account_id); - // Drop any gmeet workspace-rewrite counter for this label — labels are - // reused on reopen, so a stale entry from a closed-mid-loop session - // would saturate the next fresh open's window. - state.clear_gmeet_marketing_rewrite(&label); - log::info!("[webview-accounts] closed label={}", label); - Ok(()) -} - -/// Close the webview AND wipe its on-disk `data_directory` so cookies, -/// storage and cached credentials are forgotten. Use this for the -/// user-initiated "logout" action — `webview_account_close` keeps the -/// data dir intact so the next open restores the session. -#[tauri::command] -pub async fn webview_account_purge( - app: AppHandle, - state: tauri::State<'_, WebviewAccountsState>, - args: AccountIdArgs, -) -> Result<(), String> { - // Close first so the native webview releases its file handles before we - // try to delete the data directory. - let label_opt = state.inner.lock().unwrap().remove(&args.account_id); - if let Some(label) = label_opt.as_ref() { - if let Some(wv) = app.get_webview(label) { - if let Err(e) = wv.close() { - log::warn!("[webview-accounts] purge close({label}) failed: {e}"); - } - } - } - - teardown_account_scanners(&app, &args.account_id); - state.browser_ids.lock().unwrap().remove(&args.account_id); - if let Some(task) = state.cdp_sessions.lock().unwrap().remove(&args.account_id) { - task.abort(); - log::debug!( - "[cdp-session] purge aborted session task for account={}", - args.account_id - ); - } - if let Some(task) = state - .load_watchdogs - .lock() - .unwrap() - .remove(&args.account_id) - { - task.abort(); - log::debug!( - "[webview-accounts] purge aborted load watchdog for account={}", - args.account_id - ); - } - state - .loaded_accounts - .lock() - .unwrap() - .remove(&args.account_id); - state - .requested_bounds - .lock() - .unwrap() - .remove(&args.account_id); - state - .spawn_started_at - .lock() - .unwrap() - .remove(&args.account_id); - state - .account_providers - .lock() - .unwrap() - .remove(&args.account_id); - // Issue #1233 — drop the prewarm flag too on purge. - state - .prewarm_accounts - .lock() - .unwrap() - .remove(&args.account_id); - if let Some(label) = label_opt.as_ref() { - state.clear_gmeet_marketing_rewrite(label); - // Drop any pending handoff flag for this label so a stale entry - // can't hijack the next genuine `myaccount.google.com` visit on - // a webview that re-uses the same label. - state.take_awaiting_gmeet_handoff(label); - } - - let data_dir = data_directory_for(&app, &args.account_id)?; - purge_data_dir_with_retry(&data_dir) - .await - .map_err(|e| format!("purge data dir {}: {e}", data_dir.display()))?; - - log::info!( - "[webview-accounts] purged account={} label={:?}", - args.account_id, - label_opt - ); - Ok(()) -} - -/// CEF / WKWebView holds file handles briefly after `wv.close()` returns, -/// so a single `remove_dir_all` racing the close call routinely fails on -/// macOS and leaves the per-account cookie jar on disk. Re-adding the same -/// account after a logout then lands the user already signed in (#1076). -/// -/// Retry the deletion a handful of times with exponential backoff so the -/// subprocess has a chance to drop its handles. Logs every attempt so a -/// stuck handle is diagnosable from the audit log. -async fn purge_data_dir_with_retry(data_dir: &std::path::Path) -> std::io::Result<()> { - if !data_dir.exists() { - return Ok(()); - } - const MAX_ATTEMPTS: u32 = 5; - const INITIAL_BACKOFF_MS: u64 = 100; - let mut backoff = INITIAL_BACKOFF_MS; - for attempt in 1..=MAX_ATTEMPTS { - match std::fs::remove_dir_all(data_dir) { - Ok(()) => { - log::info!( - "[webview-accounts] purged data dir {} (attempt {}/{})", - data_dir.display(), - attempt, - MAX_ATTEMPTS - ); - return Ok(()); - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - log::info!( - "[webview-accounts] purge data dir {} already removed before attempt {}/{}", - data_dir.display(), - attempt, - MAX_ATTEMPTS - ); - return Ok(()); - } - Err(err) if attempt < MAX_ATTEMPTS => { - log::debug!( - "[webview-accounts] purge remove_dir_all {} attempt {}/{} failed: {} — retrying in {}ms", - data_dir.display(), - attempt, - MAX_ATTEMPTS, - err, - backoff - ); - tokio::time::sleep(std::time::Duration::from_millis(backoff)).await; - backoff *= 2; - } - Err(err) => { - log::warn!( - "[webview-accounts] purge remove_dir_all {} failed after {} attempts: {} — cookies may persist; cross-launch fallback handled by schedule_cef_profile_purge", - data_dir.display(), - MAX_ATTEMPTS, - err - ); - return Err(err); - } - } - } - Ok(()) -} - -#[tauri::command] -pub async fn webview_account_bounds( - app: AppHandle, - state: tauri::State<'_, WebviewAccountsState>, - args: BoundsArgs, -) -> Result<(), String> { - let label_opt = state.inner.lock().unwrap().get(&args.account_id).cloned(); - let Some(label) = label_opt else { - return Err(format!("no webview for account {}", args.account_id)); - }; - let wv = app - .get_webview(&label) - .ok_or_else(|| format!("webview {label} missing"))?; - wv.set_position(LogicalPosition::new(args.bounds.x, args.bounds.y)) - .map_err(|e| format!("set_position: {e}"))?; - wv.set_size(LogicalSize::new(args.bounds.width, args.bounds.height)) - .map_err(|e| format!("set_size: {e}"))?; - log::trace!( - "[webview-accounts] bounds label={} -> {:?}", - label, - args.bounds - ); - // Keep the in-state bounds synced so `webview_account_reveal` has the - // latest rect even if the frontend's own cache is cleared between the - // `webview_account_open` call and the `webview-account:load` signal. - state - .requested_bounds - .lock() - .unwrap() - .insert(args.account_id.clone(), args.bounds); - Ok(()) -} - -/// Move an off-screen-spawned webview back to the frontend's desired rect and -/// show it. Invoked by the frontend when it receives the `webview-account:load` -/// event so the loading spinner is uncovered only after the page has painted. -/// -/// Called as the final step of the first-open flow: -/// 1. `webview_account_open` — CEF subview spawned off-screen -/// 2. native `on_page_load` OR CDP `Page.loadEventFired` OR 15 s watchdog -/// 3. frontend listener → `webview_account_reveal` -#[tauri::command] -pub async fn webview_account_reveal( - app: AppHandle, - state: tauri::State<'_, WebviewAccountsState>, - args: RevealArgs, -) -> Result<(), String> { - let label_opt = state.inner.lock().unwrap().get(&args.account_id).cloned(); - let Some(label) = label_opt else { - // Reveal race: the webview was closed before the load event arrived. - // Return Ok so the frontend doesn't surface an error. - log::debug!( - "[webview-accounts] reveal: no webview for account {}", - args.account_id - ); - return Ok(()); - }; - let wv = app - .get_webview(&label) - .ok_or_else(|| format!("webview {label} missing"))?; - wv.set_position(LogicalPosition::new(args.bounds.x, args.bounds.y)) - .map_err(|e| format!("set_position: {e}"))?; - wv.set_size(LogicalSize::new(args.bounds.width, args.bounds.height)) - .map_err(|e| format!("set_size: {e}"))?; - wv.show().map_err(|e| format!("show: {e}"))?; - state - .requested_bounds - .lock() - .unwrap() - .insert(args.account_id.clone(), args.bounds); - let provider = state - .account_providers - .lock() - .unwrap() - .get(&args.account_id) - .cloned() - .unwrap_or_else(|| "unknown".to_string()); - let elapsed_ms = state - .spawn_started_at - .lock() - .unwrap() - .remove(&args.account_id) - .map(|started| started.elapsed().as_millis()) - .map(|ms| ms.to_string()) - .unwrap_or_else(|| "unknown".to_string()); - let trigger = RevealTrigger::from_ipc(args.trigger.as_deref()).as_str(); - log::info!( - "[webview-accounts][{}][{}] reveal trigger={} elapsed_ms={} bounds={:?}", - provider, - args.account_id, - trigger, - elapsed_ms, - args.bounds - ); - Ok(()) -} - -#[tauri::command] -pub async fn webview_account_hide( - app: AppHandle, - state: tauri::State<'_, WebviewAccountsState>, - args: AccountIdArgs, -) -> Result<(), String> { - let label_opt = state.inner.lock().unwrap().get(&args.account_id).cloned(); - let Some(label) = label_opt else { - return Ok(()); - }; - if let Some(wv) = app.get_webview(&label) { - let _ = wv.hide(); - log::debug!("[webview-accounts] hide label={}", label); - } - Ok(()) -} - -#[tauri::command] -pub async fn webview_account_show( - app: AppHandle, - state: tauri::State<'_, WebviewAccountsState>, - args: AccountIdArgs, -) -> Result<(), String> { - let label_opt = state.inner.lock().unwrap().get(&args.account_id).cloned(); - let Some(label) = label_opt else { - return Ok(()); - }; - if let Some(wv) = app.get_webview(&label) { - let _ = wv.show(); - log::debug!("[webview-accounts] show label={}", label); - } - Ok(()) -} - -/// Web-shape notification permission state used by frontend parity code. -/// Effectively granted because interception is handled in-app via CEF. -#[tauri::command] -pub fn webview_notification_permission_state() -> String { - "granted".to_string() -} - -/// Request notification permission and return web-shape state. -#[tauri::command] -pub fn webview_notification_permission_request() -> String { - webview_notification_permission_state() -} - -/// Enable/disable global DND for embedded webview OS toasts. -#[tauri::command] -pub fn webview_notification_set_dnd( - state: tauri::State<'_, WebviewAccountsState>, - enabled: bool, -) -> Result<(), String> { - let mut prefs = state.notification_bypass.lock().unwrap(); - prefs.global_dnd = enabled; - log::debug!("[notify-bypass] set global_dnd={enabled}"); - Ok(()) -} - -/// Mute/unmute a specific embedded account for OS toasts. -#[tauri::command] -pub fn webview_notification_mute_account( - state: tauri::State<'_, WebviewAccountsState>, - account_id: String, - muted: bool, -) -> Result<(), String> { - let account_id = sanitize_account_id(&account_id)?.to_string(); - let mut prefs = state.notification_bypass.lock().unwrap(); - if muted { - prefs.muted_accounts.insert(account_id.clone()); - } else { - prefs.muted_accounts.remove(&account_id); - } - log::debug!( - "[notify-bypass] set muted account_id={} muted={}", - account_id, - muted - ); - Ok(()) -} - -/// Return current bypass preferences for the settings UI. -#[tauri::command] -pub fn webview_notification_get_bypass_prefs( - state: tauri::State<'_, WebviewAccountsState>, -) -> NotificationBypassPrefsPayload { - let prefs = state.notification_bypass.lock().unwrap(); - NotificationBypassPrefsPayload::from(&*prefs) -} - -/// Track which account is currently focused in the shell UI. -#[tauri::command] -pub fn webview_set_focused_account( - state: tauri::State<'_, WebviewAccountsState>, - account_id: Option, -) -> Result<(), String> { - let mut prefs = state.notification_bypass.lock().unwrap(); - prefs.focused_account = match account_id { - Some(id) => Some(sanitize_account_id(&id)?.to_string()), - None => None, - }; - log::debug!( - "[notify-bypass] set focused_account={}", - prefs.focused_account.as_deref().unwrap_or("") - ); - Ok(()) -} - -/// Called from the injected runtime each time the recipe emits an event. -/// We forward to React via a Tauri event so the UI can render and persist. -#[tauri::command] -pub async fn webview_recipe_event( - app: AppHandle, - webview: tauri::Webview, - args: RecipeEventArgs, -) -> Result<(), String> { - // The event can only be trusted if the invoking webview is the - // `acct_` webview for the account in the payload. A - // compromised renderer or a sibling child webview must not be able to - // forge events for another account. - let caller_label = webview.label().to_string(); - let expected_label = label_for(&args.account_id); - if caller_label != expected_label { - log::warn!( - "[webview-accounts] recipe_event rejected: caller_label={} expected={} account={}", - caller_label, - expected_label, - args.account_id - ); - return Err("webview label does not match account_id".to_string()); - } - log::debug!( - "[webview-accounts] recipe_event account={} provider={} kind={}", - args.account_id, - args.provider, - args.kind - ); - if args.provider == "google-meet" { - match args.kind.as_str() { - "meet_call_started" => { - let code = args - .payload - .get("code") - .and_then(|v| v.as_str()) - .unwrap_or("?"); - log::info!("[gmeet][{}] call_started code={}", args.account_id, code); - } - "meet_captions" => { - let code = args - .payload - .get("code") - .and_then(|v| v.as_str()) - .unwrap_or("?"); - let n = args - .payload - .get("captions") - .and_then(|v| v.as_array()) - .map(|a| a.len()) - .unwrap_or(0); - log::info!( - "[gmeet][{}] captions code={} rows={}", - args.account_id, - code, - n - ); - } - "meet_call_ended" => { - let code = args - .payload - .get("code") - .and_then(|v| v.as_str()) - .unwrap_or("?"); - let reason = args - .payload - .get("reason") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - log::info!( - "[gmeet][{}] call_ended code={} reason={}", - args.account_id, - code, - reason - ); - } - _ => {} - } - } - if args.kind == "ingest" { - if let Some(messages) = args.payload.get("messages").and_then(|v| v.as_array()) { - log::info!( - "[webview-accounts] ingest from acct_{}: {} messages", - args.account_id, - messages.len() - ); - } - } else if args.kind == "ws_message" { - let direction = args - .payload - .get("direction") - .and_then(|v| v.as_str()) - .unwrap_or("?"); - let size = args - .payload - .get("size") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - log::trace!( - "[webview-accounts][{}] ws {} {} bytes", - args.account_id, - direction, - size - ); - } else if args.kind == "log" { - let level = args - .payload - .get("level") - .and_then(|v| v.as_str()) - .unwrap_or("info"); - let msg = args - .payload - .get("msg") - .and_then(|v| v.as_str()) - .unwrap_or(""); - match level { - "warn" => log::warn!("[webview-accounts][{}] {}", args.account_id, msg), - "error" => log::error!("[webview-accounts][{}] {}", args.account_id, msg), - _ => log::info!("[webview-accounts][{}] {}", args.account_id, msg), - } - } - - if let Err(err) = post_provider_surfaces_event(&args).await { - log::warn!( - "[webview-accounts] provider_surfaces ingest failed account={} provider={} kind={}: {}", - args.account_id, - args.provider, - args.kind, - err - ); - } - - let event = WebviewEvent { - account_id: args.account_id, - provider: args.provider, - kind: args.kind, - payload: args.payload, - ts: args.ts, - }; - app.emit("webview:event", &event) - .map_err(|e| format!("emit failed: {e}"))?; - Ok(()) -} - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod tests; diff --git a/app/src-tauri/src/webview_accounts/mod_tests.rs b/app/src-tauri/src/webview_accounts/mod_tests.rs deleted file mode 100644 index 0aadeac481..0000000000 --- a/app/src-tauri/src/webview_accounts/mod_tests.rs +++ /dev/null @@ -1,1351 +0,0 @@ -use super::*; - -fn url(s: &str) -> Url { - Url::parse(s).expect("valid url") -} - -#[test] -fn reveal_trigger_from_ipc_warns_and_defaults_unknown_to_load() { - assert_eq!(RevealTrigger::from_ipc(None), RevealTrigger::Load); - assert_eq!(RevealTrigger::from_ipc(Some("load")), RevealTrigger::Load); - assert_eq!( - RevealTrigger::from_ipc(Some("watchdog")), - RevealTrigger::Watchdog - ); - assert_eq!( - RevealTrigger::from_ipc(Some("watchdog-typo")), - RevealTrigger::Load - ); -} - -// ── shutdown teardown ────────────────────────────────── - -/// Smoke-test [`WebviewAccountsState::drain_for_shutdown`] in isolation -/// from the Tauri runtime. Populates the state with representative -/// per-account resources (CDP / watchdog `JoinHandle`s, a CEF browser -/// id, an `acct_*` label, plus the small bookkeeping sets) and asserts -/// that one call drains every collection and aborts the long-running -/// tasks, that the returned label list is what `shutdown_all` will -/// `wv.close()` against, and that a second call is a safe no-op. -/// -/// `shutdown_all` itself takes an `AppHandle` and is exercised end-to- -/// end at runtime; the inner `drain_for_shutdown` covers the part of -/// the teardown that doesn't need a Tauri runtime to verify. -#[tokio::test] -async fn drain_for_shutdown_clears_state_and_repeat_is_noop() { - use std::time::Duration; - - let state = WebviewAccountsState::default(); - - let cdp_task = tokio::spawn(async { - tokio::time::sleep(Duration::from_secs(60)).await; - }); - let cdp_abort = cdp_task.abort_handle(); - let watchdog_task = tokio::spawn(async { - tokio::time::sleep(Duration::from_secs(60)).await; - }); - let watchdog_abort = watchdog_task.abort_handle(); - - state - .cdp_sessions - .lock() - .unwrap() - .insert("acct-1".into(), cdp_task); - state - .load_watchdogs - .lock() - .unwrap() - .insert("acct-1".into(), watchdog_task); - state - .browser_ids - .lock() - .unwrap() - .insert("acct-1".into(), 42); - state - .inner - .lock() - .unwrap() - .insert("acct-1".into(), "acct_1".into()); - state - .account_providers - .lock() - .unwrap() - .insert("acct-1".into(), "slack".into()); - state - .loaded_accounts - .lock() - .unwrap() - .insert("acct-1".into()); - state.requested_bounds.lock().unwrap().insert( - "acct-1".into(), - Bounds { - x: 0.0, - y: 0.0, - width: 800.0, - height: 600.0, - }, - ); - state - .spawn_started_at - .lock() - .unwrap() - .insert("acct-1".into(), Instant::now()); - // Saturate the gmeet rewrite counter so we can assert it gets - // cleared by drain (otherwise the next reopen would inherit a - // stale entry — `label_for()` reuses the same label). - for _ in 0..=GMEET_REWRITE_MAX_ATTEMPTS { - let _ = state.track_gmeet_marketing_rewrite("acct_1", Instant::now()); - } - assert!(!state.gmeet_marketing_rewrites.lock().unwrap().is_empty()); - - let labels = state.drain_for_shutdown(); - tokio::task::yield_now().await; - - assert_eq!( - labels, - vec![("acct-1".to_string(), "acct_1".to_string())], - "shutdown_all should close the acct_* webview returned here" - ); - assert!(cdp_abort.is_finished(), "CDP session task was aborted"); - assert!( - watchdog_abort.is_finished(), - "load watchdog task was aborted" - ); - assert!(state.cdp_sessions.lock().unwrap().is_empty()); - assert!(state.load_watchdogs.lock().unwrap().is_empty()); - assert!(state.browser_ids.lock().unwrap().is_empty()); - assert!(state.inner.lock().unwrap().is_empty()); - assert!(state.account_providers.lock().unwrap().is_empty()); - assert!(state.loaded_accounts.lock().unwrap().is_empty()); - assert!(state.requested_bounds.lock().unwrap().is_empty()); - assert!(state.spawn_started_at.lock().unwrap().is_empty()); - assert!( - state.gmeet_marketing_rewrites.lock().unwrap().is_empty(), - "gmeet rewrite counter must clear on drain so reopens don't inherit stale entries" - ); - - // Second call must be a safe no-op: nothing left to drain. - let labels2 = state.drain_for_shutdown(); - assert!(labels2.is_empty()); - assert!(state.cdp_sessions.lock().unwrap().is_empty()); - assert!(state.inner.lock().unwrap().is_empty()); - assert!(state.account_providers.lock().unwrap().is_empty()); -} - -// ── provider registry match arms ────────────────────────────────── - -#[test] -fn zoom_registered_in_provider_url() { - assert_eq!(provider_url("zoom"), Some("https://zoom.us/")); -} - -#[test] -fn wechat_registered_in_provider_url() { - assert_eq!(provider_url("wechat"), Some("https://web.wechat.com/")); -} - -#[test] -fn wechat_has_no_recipe_js_injection() { - assert!(provider_recipe_js("wechat").is_none()); -} - -#[test] -fn wechat_allowed_hosts_cover_web_and_login_domains() { - let hosts = provider_allowed_hosts("wechat"); - assert!(hosts.contains(&"wechat.com"), "wechat.com in allowlist"); - assert!(hosts.contains(&"wx.qq.com"), "wx.qq.com in allowlist"); - assert!( - hosts.contains(&"login.weixin.qq.com"), - "login.weixin.qq.com in allowlist" - ); -} - -#[test] -fn new_mail_and_social_providers_registered_in_provider_url() { - assert_eq!( - provider_url("gmail"), - Some("https://mail.google.com/mail/u/0/") - ); - assert_eq!( - provider_url("outlook"), - Some("https://outlook.live.com/mail/") - ); - assert_eq!( - provider_url("instagram"), - Some("https://www.instagram.com/direct/inbox/") - ); - assert_eq!(provider_url("twitter"), Some("https://x.com/messages/")); -} - -#[test] -fn new_providers_are_supported_with_no_js_injection() { - // Per the CLAUDE.md "no new JS injection" rule, these CEF-only - // providers must be supported via Rust navigation handlers + CDP - // scanners — never a `recipe.js`. - for p in ["gmail", "outlook", "instagram", "twitter"] { - assert!(provider_is_supported(p), "{p} is supported"); - assert!(provider_recipe_js(p).is_none(), "{p} has no recipe.js"); - } -} - -#[test] -fn new_providers_allowed_hosts_cover_web_and_login_domains() { - assert!(provider_allowed_hosts("gmail").contains(&"google.com")); - assert!(provider_allowed_hosts("gmail").contains(&"accounts.google.com")); - assert!(provider_allowed_hosts("outlook").contains(&"outlook.live.com")); - assert!(provider_allowed_hosts("outlook").contains(&"login.live.com")); - assert!(provider_allowed_hosts("instagram").contains(&"instagram.com")); - assert!(provider_allowed_hosts("instagram").contains(&"facebook.com")); - assert!(provider_allowed_hosts("twitter").contains(&"x.com")); - assert!(provider_allowed_hosts("twitter").contains(&"twitter.com")); - // X "Continue with Google" must keep the OAuth popup in-profile. - assert!(provider_allowed_hosts("twitter").contains(&"accounts.google.com")); -} - -#[test] -fn gmail_and_twitter_support_google_sso() { - assert!(provider_supports_google_sso("gmail")); - // X offers "Continue with Google" — the popup must stay in the - // per-account CEF session (#3755). - assert!(provider_supports_google_sso("twitter")); -} - -#[test] -fn new_providers_have_display_names() { - assert_eq!(provider_display_name("gmail"), "Gmail"); - assert_eq!(provider_display_name("outlook"), "Outlook"); - assert_eq!(provider_display_name("instagram"), "Instagram"); - assert_eq!(provider_display_name("twitter"), "X"); -} - -#[test] -fn zoom_has_no_recipe_js_injection() { - // Per the CLAUDE.md "no new JS injection" rule for CEF child - // webviews, Zoom must rely solely on Rust `on_navigation` + - // `on_new_window` (plus CDP from scanner modules, if any) — no - // `recipe.js` should be registered. - assert!(provider_recipe_js("zoom").is_none()); -} - -#[test] -fn zoom_allowed_hosts_covers_core_domains() { - let hosts = provider_allowed_hosts("zoom"); - assert!(hosts.contains(&"zoom.us"), "zoom.us in allowlist"); - assert!(hosts.contains(&"zoomgov.com"), "zoomgov.com in allowlist"); - assert!(hosts.contains(&"zdassets.com"), "zdassets.com in allowlist"); -} - -#[test] -fn zoom_allowed_hosts_covers_google_oauth() { - // Zoom's "Sign in with Google" reroutes the popup into the - // embedded webview (see popup_should_navigate_parent). The - // resulting accounts.google.com / oauth2.googleapis.com / - // www.googleapis.com hops MUST be classified internal so the - // auth chain doesn't escape to the system browser mid-flight - // and trigger Zoom error 300 (#1294). - assert!(url_is_internal( - "zoom", - &url("https://accounts.google.com/v3/signin/identifier"), - )); - assert!(url_is_internal( - "zoom", - &url("https://oauth2.googleapis.com/token"), - )); - assert!(url_is_internal( - "zoom", - &url("https://www.googleapis.com/oauth2/v3/userinfo"), - )); -} - -#[test] -fn zoom_supports_google_sso() { - // Zoom's web client offers "Sign in with Google" via a popup - // window.open("https://accounts.google.com/..."). The popup - // gate at popup_should_navigate_parent gates on this helper — - // without zoom listed the popup falls through to the system - // browser and breaks the auth callback (#1294). - assert!(provider_supports_google_sso("zoom")); -} - -#[test] -fn zoom_popup_navigates_parent_for_google_sso() { - // Mirror of slack_google_signin_popup_navigates_parent — - // clicking "Sign in with Google" inside Zoom MUST replace the - // parent webview's URL instead of escaping to the system - // browser, so the Google session cookie lands in the per-account - // CEF profile (#1294). - assert_eq!( - popup_should_navigate_parent( - "zoom", - &url("https://accounts.google.com/v3/signin/identifier"), - ) - .map(|u| u.to_string()), - Some("https://accounts.google.com/v3/signin/identifier".to_string()) - ); -} - -// ── LinkedIn Google SSO (issue #1021) ────────────────────────────── - -#[test] -fn linkedin_supports_google_sso() { - // LinkedIn's "Sign in with Google" button must be handled in-app; - // without linkedin in provider_supports_google_sso the popup falls - // through to the system browser, which opens blank (#1021). - assert!(provider_supports_google_sso("linkedin")); -} - -#[test] -fn linkedin_allowed_hosts_cover_google_oauth() { - // Google auth chain hops through oauth2.googleapis.com and - // www.googleapis.com which are not Google SSO hosts and must be - // present in the explicit allowlist so mid-flight redirects don't - // leak to the system browser. - let hosts = provider_allowed_hosts("linkedin"); - for host in [ - "accounts.google.com", - "accounts.googleusercontent.com", - "ssl.gstatic.com", - "fonts.gstatic.com", - "lh3.googleusercontent.com", - "oauth2.googleapis.com", - "www.googleapis.com", - ] { - assert!(hosts.contains(&host), "{host} in LinkedIn allowlist"); - } -} - -#[test] -fn linkedin_google_signin_popup_navigates_parent() { - // Clicking "Sign in with Google" on LinkedIn's login page issues a - // window.open to accounts.google.com/signin/... — must navigate the - // parent in-app instead of opening the system browser (#1021). - assert_eq!( - popup_should_navigate_parent( - "linkedin", - &url("https://accounts.google.com/v3/signin/identifier"), - ) - .map(|u| u.to_string()), - Some("https://accounts.google.com/v3/signin/identifier".to_string()) - ); -} - -#[test] -fn linkedin_google_oauth2_popup_navigates_parent() { - // LinkedIn may issue window.open to the initial OAuth2 auth - // endpoint (/o/oauth2/v2/auth) which doesn't contain "signin" - // in the path — must still be caught and routed in-app (#1021). - assert!(popup_should_navigate_parent( - "linkedin", - &url("https://accounts.google.com/o/oauth2/v2/auth?client_id=x&redirect_uri=https://www.linkedin.com/..."), - ) - .is_some()); -} - -#[test] -fn linkedin_google_account_chooser_popup_stays_in_app() { - // Regression guard for the refactor that shares the GSI account-chooser - // arm between linkedin + twitter: LinkedIn's gsi/select popup must still - // stay in-app (#1021). - assert!(popup_should_stay_in_app( - "linkedin", - &url("https://accounts.google.com/gsi/select?client_id=x&ux_mode=popup"), - )); -} - -#[test] -fn twitter_supports_google_sso() { - // X/Twitter offers "Continue with Google"; without twitter in the SSO set - // the /o/oauth2 leg would not navigate the parent in-app (#5009). - assert!(provider_supports_google_sso("twitter")); -} - -#[test] -fn twitter_google_account_chooser_popup_stays_in_app() { - // #5009: X's "Continue with Google" opens the GSI account chooser via - // window.open(accounts.google.com/gsi/select). Before the fix twitter had - // no popup_should_stay_in_app arm, so this popup fell through to the system - // browser — the user picked their account OUTSIDE the app and the embedded - // webview was revealed on a dead page (blank screen). It must stay in-app. - assert!(popup_should_stay_in_app( - "twitter", - &url("https://accounts.google.com/gsi/select?client_id=x&ux_mode=popup&origin=https%3A%2F%2Fx.com"), - )); - // The GSI button/status endpoints share the same in-app requirement. - assert!(popup_should_stay_in_app( - "twitter", - &url("https://accounts.google.com/gsi/button?client_id=x"), - )); -} - -#[test] -fn twitter_google_account_chooser_popup_is_not_navigate_parent() { - // The account-chooser popup must be handled by popup_should_stay_in_app, - // NOT popup_should_navigate_parent — navigating the parent to gsi/select - // would destroy the opener the popup postMessages the credential back to. - // (gsi/select is not an is_google_auth_popup match, so this holds.) - assert!(popup_should_navigate_parent( - "twitter", - &url("https://accounts.google.com/gsi/select?client_id=x&ux_mode=popup"), - ) - .is_none()); -} - -#[test] -fn twitter_google_oauth2_popup_mode_stays_in_app_not_navigate_parent() { - // #5009 core fix: X's "Continue with Google" is a GSI *popup* (ux_mode=popup) - // — the popup postMessages the id_token back to the x.com opener. The - // /o/oauth2 leg must stay in-app and must NOT navigate the parent; doing so - // destroyed the opener and painted the pane white. - let u = url( - "https://accounts.google.com/o/oauth2/v2/auth?client_id=x&ux_mode=popup\ - &gsiwebsdk=gis_attributes&redirect_uri=gis_transform&response_type=id_token\ - &origin=https%3A%2F%2Fx.com", - ); - assert!( - popup_should_navigate_parent("twitter", &u).is_none(), - "popup-mode /o/oauth2 must not replace the x.com opener" - ); - assert!( - popup_should_stay_in_app("twitter", &u), - "popup-mode /o/oauth2 must stay as an in-app child window" - ); -} - -#[test] -fn linkedin_google_oauth2_redirect_mode_still_navigates_parent() { - // Regression: LinkedIn's Google sign-in is a *redirect* flow (a real https - // redirect_uri, no GSI popup markers). That is not a GSI popup, so it must - // still replace the parent in-app (#1021) — the #5009 guard must not touch - // it. - let u = url("https://accounts.google.com/o/oauth2/v2/auth?client_id=x\ - &redirect_uri=https://www.linkedin.com/oauth/callback"); - assert!(popup_should_navigate_parent("linkedin", &u).is_some()); - assert!(!popup_should_stay_in_app("linkedin", &u)); -} - -#[test] -fn twitter_non_google_popup_does_not_stay_in_app() { - // Scope guard: only the Google account-chooser popup is kept in-app. An - // ordinary target="_blank"/window.open link still routes to the system - // browser, so this is not a blanket popup allow. - assert!(!popup_should_stay_in_app( - "twitter", - &url("https://example.com/some/article"), - )); - assert!(!popup_should_stay_in_app( - "twitter", - &url("https://x.com/i/status/123") - )); -} - -#[test] -fn linkedin_google_sso_navigation_is_internal() { - // Direct (non-popup) navigation to accounts.google.com during the - // LinkedIn Google SSO flow must be classified internal so it stays - // in the embedded webview. - assert!(url_is_internal( - "linkedin", - &url("https://accounts.google.com/v3/signin/identifier"), - )); - assert!(url_is_internal( - "linkedin", - &url("https://accounts.youtube.com/accounts/SetSID?..."), - )); -} - -#[test] -fn linkedin_own_domain_still_internal() { - assert!(url_is_internal( - "linkedin", - &url("https://www.linkedin.com/messaging/"), - )); - assert!(url_is_internal( - "linkedin", - &url("https://media.licdn.com/dms/image/foo.jpg"), - )); -} - -#[test] -fn linkedin_unrelated_popup_still_goes_to_system_browser() { - // Non-Google external links from LinkedIn must still route out. - assert!(popup_should_navigate_parent("linkedin", &url("https://example.com/blog"),).is_none()); - assert!(!popup_should_stay_in_app( - "linkedin", - &url("https://example.com/blog"), - )); -} - -#[test] -fn linkedin_gsi_popup_stays_in_app() { - // LinkedIn's "Sign in with Google" uses the Google Identity Services - // (GSI) library. The GSI button iframe (accounts.google.com/gsi/button) - // calls window.open("accounts.google.com/gsi/select?...") to show the - // account chooser. This popup must be an in-app child window — NOT sent - // to the system browser (blank screen) and NOT a parent navigation (the - // postMessage credential callback would have no opener to reach) (#1021). - assert!(popup_should_stay_in_app( - "linkedin", - &url("https://accounts.google.com/gsi/select?client_id=990339570472-k6nq&ux_mode=popup"), - )); - assert!(popup_should_stay_in_app( - "linkedin", - &url("https://accounts.google.com/gsi/issue?client_id=x"), - )); -} - -#[test] -fn linkedin_gsi_popup_does_not_navigate_parent() { - // The GSI account-chooser popup must NOT navigate the parent — it needs - // to remain a child popup for postMessage to work. - assert!(popup_should_navigate_parent( - "linkedin", - &url("https://accounts.google.com/gsi/select?client_id=x"), - ) - .is_none()); -} - -#[test] -fn slack_allowed_hosts_include_google_oauth() { - let hosts = provider_allowed_hosts("slack"); - for host in [ - "accounts.google.com", - "accounts.googleusercontent.com", - "ssl.gstatic.com", - "fonts.gstatic.com", - "lh3.googleusercontent.com", - "oauth2.googleapis.com", - "www.googleapis.com", - ] { - assert!(hosts.contains(&host), "{host} in Slack allowlist"); - } -} - -#[test] -fn slack_allowed_hosts_still_internal_for_slack_origins() { - assert!(url_is_internal( - "slack", - &url("https://app.slack.com/client/T123/C456"), - )); - assert!(url_is_internal( - "slack", - &url("https://a.slack-edge.com/bv1/app.js"), - )); - assert!(url_is_internal( - "slack", - &url("https://wss-primary.slack.com/?ticket=redacted"), - )); -} - -#[test] -fn slack_allowed_hosts_do_not_bare_allow_google() { - let hosts = provider_allowed_hosts("slack"); - assert!( - !hosts.contains(&"google.com"), - "bare google.com not allowed" - ); - assert!(!hosts.contains(&"googleusercontent.com")); - assert!(!hosts.contains(&"gstatic.com")); - assert!(!hosts.contains(&"googleapis.com")); - - assert!(url_is_internal( - "slack", - &url("https://accounts.google.com/v3/signin/identifier"), - )); - assert!(!url_is_internal("slack", &url("https://google.com/"))); - assert!(!url_is_internal("slack", &url("https://mail.google.com/"))); - assert!(!url_is_internal("slack", &url("https://apis.google.com/"))); -} - -#[test] -fn zoom_is_supported() { - assert!(provider_is_supported("zoom")); -} - -// ── url_is_internal: subdomain + exact match ────────────────────── - -#[test] -fn zoom_web_client_subdomain_is_internal() { - assert!(url_is_internal( - "zoom", - &url("https://app.zoom.us/wc/join/123") - )); -} - -#[test] -fn zoom_apex_domain_is_internal() { - assert!(url_is_internal("zoom", &url("https://zoom.us/signin"))); -} - -#[test] -fn zoom_external_host_is_not_internal() { - assert!(!url_is_internal( - "zoom", - &url("https://unrelated.example.com/") - )); -} - -// ── rewrite_provider_deep_link: Zoom flows ──────────────────────── - -#[test] -fn rewrite_join_flow_with_confno() { - let rewritten = rewrite_provider_deep_link( - "zoom", - &url("zoomus://zoom.us/join?action=join&confno=9819254358"), - ) - .expect("rewrite should succeed"); - assert_eq!(rewritten.as_str(), "https://app.zoom.us/wc/join/9819254358"); -} - -#[test] -fn rewrite_start_flow_with_confno() { - let rewritten = rewrite_provider_deep_link( - "zoom", - &url("zoomus://zoom.us/start?action=start&confno=86449940711"), - ) - .expect("rewrite should succeed"); - assert_eq!( - rewritten.as_str(), - "https://app.zoom.us/wc/join/86449940711" - ); -} - -#[test] -fn rewrite_preserves_pwd_query_param() { - let rewritten = rewrite_provider_deep_link( - "zoom", - &url("zoomus://zoom.us/join?action=join&confno=111&pwd=secret"), - ) - .expect("rewrite should succeed"); - assert_eq!( - rewritten.as_str(), - "https://app.zoom.us/wc/join/111?pwd=secret" - ); -} - -#[test] -fn rewrite_falls_back_to_tk_when_pwd_absent() { - let rewritten = rewrite_provider_deep_link( - "zoom", - &url("zoommtg://zoom.us/join?confno=222&tk=tokenvalue"), - ) - .expect("rewrite should succeed"); - assert_eq!( - rewritten.as_str(), - "https://app.zoom.us/wc/join/222?pwd=tokenvalue" - ); -} - -#[test] -fn rewrite_accepts_zoommtg_scheme() { - let rewritten = rewrite_provider_deep_link( - "zoom", - &url("zoommtg://zoom.us/join?action=join&confno=333"), - ) - .expect("rewrite should succeed"); - assert_eq!(rewritten.as_str(), "https://app.zoom.us/wc/join/333"); -} - -#[test] -fn rewrite_without_confno_falls_back_to_home() { - let rewritten = rewrite_provider_deep_link("zoom", &url("zoomus://zoom.us/home?action=home")) - .expect("rewrite should succeed"); - assert_eq!(rewritten.as_str(), "https://app.zoom.us/wc/home"); -} - -#[test] -fn rewrite_with_empty_confno_falls_back_to_home() { - let rewritten = - rewrite_provider_deep_link("zoom", &url("zoomus://zoom.us/join?action=join&confno=")) - .expect("rewrite should succeed"); - assert_eq!(rewritten.as_str(), "https://app.zoom.us/wc/home"); -} - -#[test] -fn rewrite_rejects_non_zoom_provider() { - assert!(rewrite_provider_deep_link( - "slack", - &url("zoomus://zoom.us/join?action=join&confno=444") - ) - .is_none()); - assert!(rewrite_provider_deep_link( - "google-meet", - &url("zoomus://zoom.us/join?action=join&confno=555") - ) - .is_none()); -} - -#[test] -fn rewrite_rejects_http_zoom_url() { - // Ordinary https zoom.us navigations must pass through untouched so - // the existing `url_is_internal` flow decides. - assert!(rewrite_provider_deep_link("zoom", &url("https://zoom.us/j/9819254358")).is_none()); -} - -#[test] -fn rewrite_rejects_unknown_scheme() { - assert!(rewrite_provider_deep_link( - "zoom", - &url("msteams://teams.microsoft.com/l/meetup-join/666") - ) - .is_none()); -} - -// ── is_provider_native_deep_link_scheme: native-app suppression ─── -// -// These guard the workspace-isolation contract from #1074: provider -// native-desktop-app deep-link schemes must NEVER reach the system -// browser, because macOS hands them off to the native provider app -// which then signs the user into the workspace using session tokens -// intended only for the embedded webview (see slack://magic-login -// smoking gun in the #1074 trace). - -#[test] -fn deep_link_scheme_matches_known_provider_native_apps() { - // Slack desktop ("slack://T01.../magic-login/") - assert!(is_provider_native_deep_link_scheme("slack")); - // Discord desktop - assert!(is_provider_native_deep_link_scheme("discord")); - // Telegram desktop ("tg://join?invite=…") - assert!(is_provider_native_deep_link_scheme("tg")); - // Microsoft Teams - assert!(is_provider_native_deep_link_scheme("msteams")); - // Zoom client (both variants registered by the installer) - assert!(is_provider_native_deep_link_scheme("zoomus")); - assert!(is_provider_native_deep_link_scheme("zoommtg")); -} - -#[test] -fn deep_link_scheme_rejects_legitimate_external_schemes() { - // HTTP(S) — the bread-and-butter external link. - assert!(!is_provider_native_deep_link_scheme("https")); - assert!(!is_provider_native_deep_link_scheme("http")); - // Mail clients are legit external — must NOT be suppressed. - assert!(!is_provider_native_deep_link_scheme("mailto")); - // Telephone / sms are legit external too. - assert!(!is_provider_native_deep_link_scheme("tel")); - assert!(!is_provider_native_deep_link_scheme("sms")); - // about: / data: / blob: handled elsewhere; never deep-link. - assert!(!is_provider_native_deep_link_scheme("about")); - assert!(!is_provider_native_deep_link_scheme("data")); - assert!(!is_provider_native_deep_link_scheme("blob")); - // Empty / unrelated string. - assert!(!is_provider_native_deep_link_scheme("")); - assert!(!is_provider_native_deep_link_scheme("file")); -} - -#[test] -fn deep_link_scheme_matches_real_world_slack_magic_login_url() { - // Real slack://-flavoured magic-login URL recorded in the - // #1074 CDP trace. The handler must catch it before - // open_in_system_browser is reached. - let parsed = url("slack://T01CWHNCJ9Z/magic-login/11035712490054-abc"); - assert!(is_provider_native_deep_link_scheme(parsed.scheme())); -} - -#[test] -fn deep_link_scheme_does_not_match_https_app_slack_com() { - // The web-flow URL stays untouched — only the slack:// scheme is - // suppressed; ordinary HTTPS slack navigations route normally. - let parsed = url("https://app.slack.com/client/T01CWHNCJ9Z"); - assert!(!is_provider_native_deep_link_scheme(parsed.scheme())); -} - -/// Locks the contract that zoomus:// stays on the rewrite path -/// (handled by `rewrite_provider_deep_link` for the "zoom" provider) -/// rather than being silently suppressed. -/// -/// The wiring in on_navigation / on_new_window calls -/// `rewrite_provider_deep_link` BEFORE the suppress check, so a -/// rewriteable scheme is rewritten and never reaches the suppress -/// branch. This test pins both halves of that contract: the rewrite -/// still succeeds for zoom, AND the scheme is recognised as a -/// native-app deep-link (so if a future provider config dropped the -/// rewrite, suppression would be the safe fallback rather than -/// leaking to the system browser). -#[test] -fn zoomus_join_still_rewrites_and_is_recognized_as_native_scheme() { - let zoom_url = url("zoomus://zoom.us/join?action=join&confno=9819254358"); - assert!(is_provider_native_deep_link_scheme(zoom_url.scheme())); - let rewritten = rewrite_provider_deep_link("zoom", &zoom_url) - .expect("zoom rewrite should still succeed before suppress branch"); - assert_eq!(rewritten.as_str(), "https://app.zoom.us/wc/join/9819254358"); -} - -#[test] -fn rewrite_percent_encodes_reserved_chars_in_pwd() { - // Zoom tokens commonly contain `&` / `=` / `%` / `#` / `+` which - // would corrupt a hand-rolled format!() URL. The `Url`-based - // builder must percent-encode them. - let rewritten = rewrite_provider_deep_link( - "zoom", - &url("zoomus://zoom.us/join?action=join&confno=777&pwd=a%26b%3Dc"), - ) - .expect("rewrite should succeed"); - // `url::Url` round-trips the encoded `%26` (`&`) and `%3D` (`=`) - // back into the rewritten query. - assert!( - rewritten.as_str().contains("pwd=a%26b%3Dc"), - "expected encoded pwd, got {}", - rewritten.as_str() - ); -} - -#[test] -fn rewrite_percent_encodes_confno_segment() { - // Defensive — path segments never should carry reserved chars but - // the helper must not corrupt them if they do. - let rewritten = rewrite_provider_deep_link( - "zoom", - &url("zoomus://zoom.us/join?action=join&confno=abc%2Fdef"), - ) - .expect("rewrite should succeed"); - // `/` inside the id must be percent-encoded, not merged into the path. - assert!( - rewritten.path().ends_with("/abc%2Fdef"), - "expected encoded path segment, got {}", - rewritten.path() - ); -} - -// ── popup_should_stay_in_app: Zoom WebClient popups ─────────────── - -#[test] -fn zoom_webclient_popup_stays_in_app() { - assert!(popup_should_stay_in_app( - "zoom", - &url("https://app.zoom.us/wc/join/999") - )); -} - -#[test] -fn zoom_apex_webclient_popup_stays_in_app() { - assert!(popup_should_stay_in_app( - "zoom", - &url("https://zoom.us/wc/join/999") - )); -} - -#[test] -fn zoom_non_wc_popup_does_not_stay_in_app() { - // Marketing / blog / download-link popups should hand off to the - // system browser, not grow an in-app child window. - assert!(!popup_should_stay_in_app( - "zoom", - &url("https://zoom.us/about") - )); -} - -#[test] -fn zoom_popup_to_foreign_host_does_not_stay_in_app() { - assert!(!popup_should_stay_in_app( - "zoom", - &url("https://example.com/wc/join/888") - )); -} - -// ── popup_should_navigate_parent: Google-auth popups ────────────── - -#[test] -fn unsupported_provider_popup_does_not_navigate_parent() { - // Only providers that explicitly support Google SSO opt into - // the popup-takeover path. Every other provider (and any unknown - // string) must fall through to the default popup handling. - assert!(popup_should_navigate_parent( - "discord", - &url("https://accounts.google.com/signin/v2/identifier"), - ) - .is_none()); - assert!(popup_should_navigate_parent( - "whatsapp", - &url("https://accounts.google.com/signin/v2/identifier"), - ) - .is_none()); - assert!(popup_should_navigate_parent( - "unknown-provider", - &url("https://accounts.google.com/signin/v2/identifier"), - ) - .is_none()); -} - -#[test] -fn google_meet_accounts_popup_navigates_parent() { - assert!(popup_should_navigate_parent( - "google-meet", - &url("https://accounts.google.com/signin/v2/identifier"), - ) - .is_some()); -} - -#[test] -fn slack_google_signin_popup_navigates_parent() { - assert_eq!( - popup_should_navigate_parent( - "slack", - &url("https://accounts.google.com/v3/signin/identifier"), - ) - .map(|u| u.to_string()), - Some("https://accounts.google.com/v3/signin/identifier".to_string()) - ); -} - -#[test] -fn slack_about_blank_popup_does_not_navigate_parent() { - assert!(popup_should_navigate_parent("slack", &url("about:blank")).is_none()); -} - -#[test] -fn slack_same_origin_popup_does_not_navigate_parent() { - assert!( - popup_should_navigate_parent("slack", &url("https://app.slack.com/client/T123/C456"),) - .is_none() - ); -} - -#[test] -fn slack_unrelated_popup_does_not_navigate_parent() { - assert!(popup_should_navigate_parent("slack", &url("https://example.com/blog"),).is_none()); -} - -#[test] -fn slack_meet_google_com_popup_does_not_navigate_parent() { - assert!( - popup_should_navigate_parent("slack", &url("https://meet.google.com/abc-defg-hij"),) - .is_none() - ); -} - -#[test] -fn gmeet_room_popup_navigates_parent() { - // "Start an instant meeting" / "New meeting" calls - // window.open(meet.google.com/) to launch a room. - // Without intervention this would route to system Chrome and - // leak the meeting out of OpenHuman. - assert_eq!( - popup_should_navigate_parent("google-meet", &url("https://meet.google.com/abc-defg-hij"),) - .map(|u| u.to_string()), - Some("https://meet.google.com/abc-defg-hij".to_string()) - ); -} - -#[test] -fn gmeet_landing_popup_navigates_parent() { - // Bare meet.google.com (no room code) should also be kept - // in-app — matches the "back to Meet home" UX after hangup. - assert!( - popup_should_navigate_parent("google-meet", &url("https://meet.google.com/"),).is_some() - ); -} - -#[test] -fn gmeet_workspace_popup_does_not_navigate_parent() { - // workspace.google.com is the marketing page; if it ever - // arrives via window.open() we let the default external-route - // logic handle it (covered in the on_navigation rewrite path - // separately). - assert!(popup_should_navigate_parent( - "google-meet", - &url("https://workspace.google.com/products/meet/"), - ) - .is_none()); -} - -#[test] -fn gmeet_unrelated_popup_does_not_navigate_parent() { - // External link in the post-call review screen, for instance. - // Should NOT navigate the parent — should fall through to the - // system-browser path. - assert!( - popup_should_navigate_parent("google-meet", &url("https://example.com/blog"),).is_none() - ); -} - -// ── provider_supports_google_sso ─────────────────────────────────── - -#[test] -fn provider_supports_google_sso_matrix() { - assert!(provider_supports_google_sso("google-meet")); - assert!(provider_supports_google_sso("slack")); - assert!(provider_supports_google_sso("zoom")); - assert!(provider_supports_google_sso("linkedin")); - assert!(!provider_supports_google_sso("whatsapp")); - assert!(!provider_supports_google_sso("telegram")); - assert!(!provider_supports_google_sso("discord")); - assert!(!provider_supports_google_sso("browserscan")); - assert!(!provider_supports_google_sso("")); - assert!(!provider_supports_google_sso("unknown-provider")); -} - -#[test] -fn google_meet_service_login_popup_navigates_parent() { - assert_eq!( - popup_should_navigate_parent( - "google-meet", - &url("https://accounts.google.com/ServiceLogin?continue=https://meet.google.com"), - ) - .map(|u| u.to_string()), - Some( - "https://accounts.google.com/ServiceLogin?continue=https://meet.google.com".to_string() - ) - ); -} - -#[test] -fn redact_navigation_url_strips_query_and_fragment() { - let redacted = redact_navigation_url(&url( - "https://accounts.google.com/o/oauth2/v2/auth?code=secret#frag", - )); - assert_eq!(redacted, "https://accounts.google.com/o/oauth2/v2/auth"); -} - -// ── purge_data_dir_with_retry ────────────────────────────────── - -#[tokio::test] -async fn purge_data_dir_with_retry_noop_when_missing() { - let dir = std::env::temp_dir().join(format!("openhuman-purge-noop-{}", std::process::id())); - // Sanity: dir must NOT exist - let _ = std::fs::remove_dir_all(&dir); - assert!(!dir.exists()); - - // Should return without error or panic. - purge_data_dir_with_retry(&dir) - .await - .expect("missing dir should be treated as success"); - - assert!(!dir.exists()); -} - -#[tokio::test] -async fn purge_data_dir_with_retry_removes_existing_dir() { - let dir = std::env::temp_dir().join(format!( - "openhuman-purge-existing-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::create_dir_all(dir.join("nested/dir")).expect("create test dir"); - std::fs::write(dir.join("cookies.json"), b"{\"sid\":\"abc\"}").expect("write test cookie file"); - std::fs::write(dir.join("nested/dir/local.storage"), b"key=value").expect("write nested file"); - assert!(dir.exists()); - - purge_data_dir_with_retry(&dir) - .await - .expect("existing dir should be removed"); - - assert!(!dir.exists(), "data dir should be removed"); -} - -// ── track_gmeet_marketing_rewrite ────────────────────────────── - -#[test] -fn gmeet_rewrite_allowed_under_threshold() { - let state = WebviewAccountsState::default(); - let label = "acct_test"; - let now = Instant::now(); - for i in 1..=GMEET_REWRITE_MAX_ATTEMPTS { - assert_eq!( - state.track_gmeet_marketing_rewrite(label, now), - GmeetRewriteAction::Rewrite, - "attempt {} should still rewrite", - i - ); - } -} - -#[test] -fn gmeet_rewrite_bails_after_threshold() { - let state = WebviewAccountsState::default(); - let label = "acct_test"; - let now = Instant::now(); - for _ in 0..GMEET_REWRITE_MAX_ATTEMPTS { - let _ = state.track_gmeet_marketing_rewrite(label, now); - } - // Next call exceeds the threshold within the window — must bail. - assert_eq!( - state.track_gmeet_marketing_rewrite(label, now), - GmeetRewriteAction::Bail - ); -} - -#[test] -fn gmeet_rewrite_resets_after_window() { - let state = WebviewAccountsState::default(); - let label = "acct_test"; - let start = Instant::now(); - // Saturate the counter at start. - for _ in 0..=GMEET_REWRITE_MAX_ATTEMPTS { - let _ = state.track_gmeet_marketing_rewrite(label, start); - } - // After the window expires, a fresh attempt must rewrite again. - let later = start + GMEET_REWRITE_WINDOW + Duration::from_secs(1); - assert_eq!( - state.track_gmeet_marketing_rewrite(label, later), - GmeetRewriteAction::Rewrite - ); -} - -// ── is_google_sso_host ──────────────────────────────────────── - -#[test] -fn google_sso_host_matches_canonical_accounts() { - assert!(is_google_sso_host("accounts.google.com")); - assert!(is_google_sso_host("accounts.googleusercontent.com")); - assert!(is_google_sso_host("accounts.youtube.com")); - assert!(is_google_sso_host("myaccount.google.com")); -} - -#[test] -fn google_sso_host_matches_cctld_variants() { - assert!(is_google_sso_host("accounts.google.co.in")); - assert!(is_google_sso_host("accounts.google.co.uk")); - assert!(is_google_sso_host("accounts.google.de")); - assert!(is_google_sso_host("accounts.google.fr")); - assert!(is_google_sso_host("accounts.google.com.au")); -} - -#[test] -fn google_sso_host_rejects_phishing_alikes() { - // Spoofed hosts that hijack the full domain by prefixing `accounts.google.`. - assert!(!is_google_sso_host("accounts.google.com.evil.tld")); - assert!(!is_google_sso_host("accounts.google.")); - assert!(!is_google_sso_host("accounts.google.com.evil.example.com")); - // Two-label suffix where the second label is NOT a real cctld - // (the dots-only predicate accepted these — CR caught it). - assert!(!is_google_sso_host("accounts.google.com.evil")); - assert!(!is_google_sso_host("accounts.google.co.attacker")); - assert!(!is_google_sso_host("accounts.google.com.attackerlong")); - // Single label that's not a real cctld (3+ chars). - assert!(!is_google_sso_host("accounts.google.evil")); - assert!(!is_google_sso_host("accounts.google.attackerlong")); - // Unknown sld in the 2-label shape — only co/com/net/org allowed. - assert!(!is_google_sso_host("accounts.google.xyz.uk")); - // Unrelated google sub-services that aren't sso surfaces. - assert!(!is_google_sso_host("mail.google.com")); - assert!(!is_google_sso_host("meet.google.com")); - assert!(!is_google_sso_host("workspace.google.com")); - assert!(!is_google_sso_host("evil.com")); -} - -#[test] -fn google_sso_host_case_insensitive() { - assert!(is_google_sso_host("ACCOUNTS.GOOGLE.COM")); - assert!(is_google_sso_host("Accounts.Google.Co.Uk")); -} - -// ── url_is_internal: gmeet SSO coverage ─────────────────────── - -#[test] -fn url_is_internal_allows_youtube_setsid_for_gmeet() { - assert!(url_is_internal( - "google-meet", - &url( - "https://accounts.youtube.com/accounts/SetSID?ssdc=1&continue=https://meet.google.com/" - ), - )); -} - -#[test] -fn url_is_internal_allows_youtube_setsid_for_slack_google_sso() { - assert!(url_is_internal( - "slack", - &url("https://accounts.youtube.com/accounts/SetSID?ssdc=1&continue=https://app.slack.com/"), - )); -} - -#[test] -fn url_is_internal_allows_cctld_accounts_google_for_gmail() { - assert!(url_is_internal( - "gmail", - &url("https://accounts.google.co.in/signin/v2/identifier"), - )); -} - -#[test] -fn url_is_internal_blocks_unrelated_youtube_for_gmeet() { - // Plain youtube.com (e.g. video play) MUST stay external for - // gmeet — the SSO bypass only covers `accounts.youtube.com`. - assert!(!url_is_internal( - "google-meet", - &url("https://www.youtube.com/watch?v=abc"), - )); -} - -#[test] -fn gmeet_rewrite_per_label_independent() { - let state = WebviewAccountsState::default(); - let now = Instant::now(); - // Saturate label A — bails next time. - for _ in 0..=GMEET_REWRITE_MAX_ATTEMPTS { - let _ = state.track_gmeet_marketing_rewrite("acct_a", now); - } - // Label B must still be allowed independently. - assert_eq!( - state.track_gmeet_marketing_rewrite("acct_b", now), - GmeetRewriteAction::Rewrite - ); -} - -// ── is_gmeet_marketing_redirect ──────────────────────────────── - -#[test] -fn gmeet_marketing_match_canonical_paths() { - assert!(is_gmeet_marketing_redirect( - "workspace.google.com", - "/products/meet/" - )); - assert!(is_gmeet_marketing_redirect( - "workspace.google.com", - "/products/meet" - )); - assert!(is_gmeet_marketing_redirect( - "workspace.google.com", - "/products/meet/learn-more" - )); - assert!(is_gmeet_marketing_redirect( - "WORKSPACE.GOOGLE.COM", - "/PRODUCTS/MEET/" - )); -} - -#[test] -fn gmeet_marketing_match_subdomain_workspace() { - assert!(is_gmeet_marketing_redirect( - "support.workspace.google.com", - "/products/meet/faq" - )); -} - -#[test] -fn gmeet_marketing_rejects_other_workspace_paths() { - // Legitimate Workspace pages a user might reach from Meet must NOT - // be hijacked — admin console, Workspace Status, support, etc. - assert!(!is_gmeet_marketing_redirect("workspace.google.com", "/")); - assert!(!is_gmeet_marketing_redirect( - "workspace.google.com", - "/products/calendar/" - )); - assert!(!is_gmeet_marketing_redirect( - "admin.workspace.google.com", - "/ac/users" - )); - assert!(!is_gmeet_marketing_redirect( - "workspace.google.com", - "/status" - )); - assert!(!is_gmeet_marketing_redirect( - "workspace.google.com", - "/products/meeter" - )); -} - -#[test] -fn gmeet_marketing_rejects_non_workspace_hosts() { - assert!(!is_gmeet_marketing_redirect( - "meet.google.com", - "/products/meet/" - )); - assert!(!is_gmeet_marketing_redirect("evil.com", "/products/meet/")); - // Phishing alike: workspace-google.com is NOT workspace.google.com - assert!(!is_gmeet_marketing_redirect( - "workspace-google.com", - "/products/meet/" - )); -} - -#[test] -fn gmeet_clear_marketing_rewrite_drops_counter() { - let state = WebviewAccountsState::default(); - let now = Instant::now(); - for _ in 0..=GMEET_REWRITE_MAX_ATTEMPTS { - let _ = state.track_gmeet_marketing_rewrite("acct_test", now); - } - // Counter saturated — next call would bail. - assert_eq!( - state.track_gmeet_marketing_rewrite("acct_test", now), - GmeetRewriteAction::Bail - ); - // Clear it — next call within the window starts fresh. - state.clear_gmeet_marketing_rewrite("acct_test"); - assert_eq!( - state.track_gmeet_marketing_rewrite("acct_test", now), - GmeetRewriteAction::Rewrite - ); -} - -#[test] -fn gmeet_handoff_flag_default_is_unset() { - let state = WebviewAccountsState::default(); - assert!(!state.take_awaiting_gmeet_handoff("acct_test")); -} - -#[test] -fn gmeet_handoff_flag_marks_then_consumes_single_shot() { - let state = WebviewAccountsState::default(); - state.mark_awaiting_gmeet_handoff("acct_test"); - // First take returns true. - assert!(state.take_awaiting_gmeet_handoff("acct_test")); - // Second take returns false — single-shot semantics so a later - // user-initiated `myaccount.google.com` visit isn't hijacked. - assert!(!state.take_awaiting_gmeet_handoff("acct_test")); -} - -#[test] -fn gmeet_handoff_flag_is_per_label() { - let state = WebviewAccountsState::default(); - state.mark_awaiting_gmeet_handoff("acct_a"); - // `acct_b` was never marked — must not consume a flag set on `acct_a`. - assert!(!state.take_awaiting_gmeet_handoff("acct_b")); - // `acct_a`'s flag is still pending. - assert!(state.take_awaiting_gmeet_handoff("acct_a")); -} - -#[test] -fn gmeet_handoff_flag_cleared_by_drain_for_shutdown() { - let state = WebviewAccountsState::default(); - state.mark_awaiting_gmeet_handoff("acct_test"); - let _ = state.drain_for_shutdown(); - // Stale flag would hijack the first user-initiated - // `myaccount.google.com` visit after relaunch. - assert!(!state.take_awaiting_gmeet_handoff("acct_test")); -} - -// ── prewarm bookkeeping (issue #1233) ────────────────── - -/// Default state must include an empty `prewarm_accounts` set so -/// fresh boots never spuriously suppress load events. -#[test] -fn prewarm_accounts_default_is_empty() { - let state = WebviewAccountsState::default(); - assert!(state.prewarm_accounts.lock().unwrap().is_empty()); -} - -/// Inserting an id into `prewarm_accounts` and then removing it should -/// leave the set empty — covers the warm-reopen path where the user's -/// first click promotes the prewarmed webview to live. -#[test] -fn prewarm_accounts_insert_then_remove_clears() { - let state = WebviewAccountsState::default(); - state - .prewarm_accounts - .lock() - .unwrap() - .insert("acct-1".to_string()); - assert!(state.prewarm_accounts.lock().unwrap().contains("acct-1")); - state.prewarm_accounts.lock().unwrap().remove("acct-1"); - assert!(!state.prewarm_accounts.lock().unwrap().contains("acct-1")); -} - -/// `drain_for_shutdown` must not leak prewarm flags either — otherwise -/// a relaunch could spuriously suppress the very first cold open. -#[test] -fn prewarm_flag_cleared_by_drain_for_shutdown() { - let state = WebviewAccountsState::default(); - state - .prewarm_accounts - .lock() - .unwrap() - .insert("acct-warm".to_string()); - let _ = state.drain_for_shutdown(); - assert!(state.prewarm_accounts.lock().unwrap().is_empty()); -} diff --git a/app/src-tauri/src/webview_accounts/runtime.js b/app/src-tauri/src/webview_accounts/runtime.js deleted file mode 100644 index e4ee1306ea..0000000000 --- a/app/src-tauri/src/webview_accounts/runtime.js +++ /dev/null @@ -1,155 +0,0 @@ -// OpenHuman webview-accounts recipe runtime. -// Injected via WebviewBuilder.initialization_script BEFORE page JS runs. -// Exposes a small `window.__openhumanRecipe` API per-provider recipes use -// to scrape the DOM and pipe state back to Rust. -// -// Runs in the loaded service's origin (e.g. https://mail.google.com). -// IPC back to Rust uses Tauri's `window.__TAURI_INTERNALS__.invoke`, -// which Tauri auto-injects into every webview it controls (including -// child webviews on external origins). -// -// Event kinds emitted to Rust via `webview_recipe_event`: -// log { level, msg } -// ingest { messages, unread?, snapshotKey? } (recipe-driven) -// arbitrary — recipes push via api.emit(kind, payload) -// -// NOTE: only injected for providers that still need a JS bridge -// (linkedin, google-meet). The migrated providers (whatsapp, telegram, -// slack, discord, browserscan) load with ZERO injected JS under cef — -// their scraping runs natively via CDP in the per-provider scanner -// modules. WebSocket interception lives in the Rust-side CDP Network -// listener (see `discord_scanner/mod.rs`), not here. -// -// Browser push notifications are intercepted natively in the CEF render -// process by `cef-helper`'s NotifyV8Handler, which replaces -// window.Notification + ServiceWorkerRegistration.prototype.showNotification -// with V8 native bindings (see the tauri-cef fork). -(function () { - if (window.__openhumanRecipe) return; - - const ctx = window.__OPENHUMAN_RECIPE_CTX__ || { accountId: 'unknown', provider: 'unknown' }; - const POLL_MS = 2000; - - function rawInvoke(cmd, payload) { - try { - const inv = window.__TAURI_INTERNALS__ && window.__TAURI_INTERNALS__.invoke; - if (typeof inv !== 'function') return Promise.resolve(); - return inv(cmd, payload || {}); - } catch (e) { - // swallow — never let a bad invoke break the host page - return Promise.resolve(); - } - } - - function send(kind, payload) { - return rawInvoke('webview_recipe_event', { - args: { - account_id: ctx.accountId, - provider: ctx.provider, - kind: kind, - payload: payload || {}, - ts: Date.now(), - }, - }); - } - - let loopFn = null; - let pollTimer = null; - - function safeRunLoop() { - if (!loopFn) return; - try { - loopFn(api); - } catch (e) { - send('log', { level: 'warn', msg: '[recipe] loop threw: ' + (e && e.message ? e.message : String(e)) }); - } - } - - const api = { - loop(fn) { - loopFn = fn; - if (pollTimer) clearInterval(pollTimer); - pollTimer = setInterval(safeRunLoop, POLL_MS); - // also kick once on next tick so we don't wait POLL_MS for the first call - setTimeout(safeRunLoop, 250); - send('log', { level: 'info', msg: '[recipe] loop registered, polling every ' + POLL_MS + 'ms' }); - }, - ingest(payload) { - // payload: { messages: Array<{id?, from?, body, ts?}>, unread?, snapshotKey? } - send('ingest', payload || {}); - }, - log(level, msg) { - send('log', { level: level || 'info', msg: String(msg) }); - }, - /** Push an arbitrary event kind up to Rust. Recipe-specific events - * (e.g. `meet_call_started`) go through here — the host side just - * sees another `webview:event` envelope with the given `kind`. */ - emit(kind, payload) { - if (!kind) return; - send(String(kind), payload || {}); - }, - context() { - return Object.assign({}, ctx); - }, - }; - - window.__openhumanRecipe = api; - send('log', { level: 'info', msg: '[recipe-runtime] ready provider=' + ctx.provider + ' accountId=' + ctx.accountId }); - - // CEF Alloy's Permissions API does not reflect the media-access callback. - // Preserve the mic/camera response for providers that check it before - // calling getUserMedia; display-capture is deliberately not overridden. - try { - if ( - navigator.permissions && - typeof navigator.permissions.query === 'function' && - !navigator.permissions.__ohPermissionsShimInstalled - ) { - const permProto = Object.getPrototypeOf(navigator.permissions); - const permDescriptor = Object.getOwnPropertyDescriptor(permProto, 'query'); - const origQuery = (permDescriptor && permDescriptor.value - ? permDescriptor.value - : navigator.permissions.query - ).bind(navigator.permissions); - const spoofed = { - camera: 'granted', - microphone: 'granted', - }; - const spoofedQuery = async function (descriptor) { - const n = descriptor && descriptor.name; - if (n && spoofed[n]) { - return { - state: spoofed[n], - status: spoofed[n], - name: n, - onchange: null, - addEventListener: function () {}, - removeEventListener: function () {}, - dispatchEvent: function () { return true; }, - }; - } - return origQuery(descriptor); - }; - try { - Object.defineProperty(permProto, 'query', { - configurable: true, - writable: true, - value: spoofedQuery, - }); - } catch (e) { - Object.defineProperty(navigator.permissions, 'query', { - configurable: true, - writable: true, - value: spoofedQuery, - }); - } - navigator.permissions.__ohPermissionsShimInstalled = true; - send('log', { level: 'info', msg: '[recipe-runtime] media permissions.query shim installed' }); - } - } catch (e) { - send('log', { - level: 'warn', - msg: '[recipe-runtime] media permissions.query shim failed: ' + (e && e.message ? e.message : e), - }); - } -})(); diff --git a/app/src-tauri/src/wechat_scanner/dom_snapshot.rs b/app/src-tauri/src/wechat_scanner/dom_snapshot.rs deleted file mode 100644 index c925fb5faf..0000000000 --- a/app/src-tauri/src/wechat_scanner/dom_snapshot.rs +++ /dev/null @@ -1,348 +0,0 @@ -//! WeChat Web DOM scrape via `DOMSnapshot.captureSnapshot` (pure CDP). - -use serde_json::Value; - -use crate::cdp::{CdpConn, Snapshot}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ChatRow { - pub name: String, - pub preview: Option, - pub unread: u32, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MessageRow { - pub chat_id: String, - pub chat_name: String, - pub sender: Option, - pub body: String, - pub ts: Option, -} - -pub struct DomScan { - pub chat_rows: Vec, - pub messages: Vec, - pub unread: u32, - pub hash: u64, -} - -pub async fn scan(cdp: &mut CdpConn, session: &str) -> Result { - let snap = Snapshot::capture(cdp, session).await?; - let mut chat_rows = Vec::new(); - let mut unread: u32 = 0; - for idx in snap.find_all(is_chat_list_row) { - let name = find_row_title(&snap, idx).unwrap_or_default(); - let preview = find_row_preview(&snap, idx); - let badge = find_row_unread(&snap, idx); - if name.is_empty() && preview.as_deref().map(str::is_empty).unwrap_or(true) { - continue; - } - unread = unread.saturating_add(badge); - chat_rows.push(ChatRow { - name, - preview, - unread: badge, - }); - } - let active_chat_name = find_active_chat_title(&snap); - let chat_id_base = active_chat_name - .as_deref() - .filter(|s| !s.is_empty()) - .unwrap_or("active"); - let mut messages = Vec::new(); - for idx in snap.find_all(is_message_bubble) { - let body = snap.text_content(idx); - if body.len() < 2 { - continue; - } - messages.push(MessageRow { - chat_id: chat_id_base.to_string(), - chat_name: active_chat_name - .clone() - .unwrap_or_else(|| chat_id_base.to_string()), - sender: find_message_sender(&snap, idx), - body, - ts: None, - }); - } - let hash = hash_scan(&chat_rows, &messages, unread); - Ok(DomScan { - chat_rows, - messages, - unread, - hash, - }) -} - -pub fn scan_to_core_payload( - account_id: &str, - scan: &DomScan, -) -> openhuman_core::openhuman::channels::webview_accounts::WechatScanPayload { - use openhuman_core::openhuman::channels::webview_accounts::{ - WechatChatRow, WechatMessageRow, WechatScanPayload, - }; - WechatScanPayload { - account_id: account_id.to_string(), - chat_rows: scan - .chat_rows - .iter() - .map(|r| WechatChatRow { - name: r.name.clone(), - preview: r.preview.clone(), - unread: r.unread, - }) - .collect(), - messages: scan - .messages - .iter() - .map(|m| WechatMessageRow { - chat_id: m.chat_id.clone(), - chat_name: m.chat_name.clone(), - sender: m.sender.clone(), - body: m.body.clone(), - ts: m.ts, - }) - .collect(), - unread: scan.unread, - snapshot_key: format!("{:x}", scan.hash), - source: "cdp-dom".to_string(), - } -} - -#[allow(dead_code)] -pub fn ingest_payload_for_scan(scan: &DomScan) -> Value { - openhuman_core::openhuman::channels::webview_accounts::list_ingest_payload( - &scan_to_core_payload("test-account", scan), - ) -} - -fn is_chat_list_row(snap: &Snapshot, idx: usize) -> bool { - if !snap.is_element(idx) { - return false; - } - let tag = snap.tag(idx); - (tag.eq_ignore_ascii_case("LI") || tag.eq_ignore_ascii_case("DIV")) - && (class_matches_any( - snap, - idx, - &[ - "session", - "chat-item", - "chat_item", - "conversation-item", - "recent", - "nav-item", - ], - ) || snap.attr(idx, "data-chat-id").is_some()) -} - -fn is_message_bubble(snap: &Snapshot, idx: usize) -> bool { - snap.is_element(idx) - && (class_matches_any( - snap, - idx, - &[ - "message", - "msg", - "bubble", - "chat-message", - "message-item", - "msg-item", - ], - ) || snap.attr(idx, "data-message-id").is_some()) -} - -fn class_matches_any(snap: &Snapshot, idx: usize, needles: &[&str]) -> bool { - snap.classes(idx).any(|c| { - let lower = c.to_ascii_lowercase(); - needles.iter().any(|n| lower.contains(n)) - }) -} - -fn find_row_title(snap: &Snapshot, root: usize) -> Option { - find_text_by_class_hints( - snap, - root, - &[ - "nickname", - "nick-name", - "title", - "name", - "user-name", - "session-name", - ], - ) -} - -fn find_row_preview(snap: &Snapshot, root: usize) -> Option { - find_text_by_class_hints( - snap, - root, - &["preview", "last-msg", "msg-preview", "desc", "subtitle"], - ) -} - -fn find_row_unread(snap: &Snapshot, root: usize) -> u32 { - find_text_by_class_hints(snap, root, &["badge", "unread", "count", "num"]) - .and_then(|s| s.trim().parse().ok()) - .unwrap_or(0) -} - -fn find_active_chat_title(snap: &Snapshot) -> Option { - snap.find_all(|s, i| { - s.is_element(i) - && class_matches_any( - s, - i, - &["chat-title", "conversation-title", "header-title", "title"], - ) - }) - .into_iter() - .find_map(|idx| { - let t = snap.text_content(idx); - if t.is_empty() { - None - } else { - Some(t) - } - }) -} - -fn find_message_sender(snap: &Snapshot, bubble: usize) -> Option { - parent_of(snap, bubble) - .and_then(|parent| find_text_by_class_hints(snap, parent, &["sender", "nickname", "name"])) -} - -fn find_text_by_class_hints(snap: &Snapshot, root: usize, hints: &[&str]) -> Option { - let node = snap.find_descendant(root, |s, i| { - s.is_element(i) && class_matches_any(s, i, hints) - })?; - let t = snap.text_content(node); - if t.is_empty() { - None - } else { - Some(t) - } -} - -fn parent_of(snap: &Snapshot, idx: usize) -> Option { - (0..snap.len()).find(|&i| snap.children(i).contains(&idx)) -} - -fn hash_scan(chat_rows: &[ChatRow], messages: &[MessageRow], unread: u32) -> u64 { - let mut h: u64 = 0xcbf29ce484222325; - fn mix(h: &mut u64, b: u8) { - *h ^= b as u64; - *h = h.wrapping_mul(0x100000001b3); - } - for b in (chat_rows.len() as u32).to_le_bytes() { - mix(&mut h, b); - } - for b in (messages.len() as u32).to_le_bytes() { - mix(&mut h, b); - } - for b in unread.to_le_bytes() { - mix(&mut h, b); - } - for r in chat_rows { - for b in r.name.as_bytes() { - mix(&mut h, *b); - } - mix(&mut h, 0x7c); - if let Some(p) = &r.preview { - for b in p.as_bytes() { - mix(&mut h, *b); - } - } - mix(&mut h, 0x7c); - for b in r.unread.to_le_bytes() { - mix(&mut h, b); - } - } - for m in messages { - for b in m.chat_id.as_bytes() { - mix(&mut h, *b); - } - mix(&mut h, 0x7c); - for b in m.chat_name.as_bytes() { - mix(&mut h, *b); - } - mix(&mut h, 0x7c); - if let Some(sender) = &m.sender { - for b in sender.as_bytes() { - mix(&mut h, *b); - } - } - mix(&mut h, 0x7c); - for b in m.body.as_bytes() { - mix(&mut h, *b); - } - mix(&mut h, 0x7c); - if let Some(ts) = m.ts { - for b in ts.to_le_bytes() { - mix(&mut h, b); - } - } - mix(&mut h, 0x7c); - } - h -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn hash_changes_when_message_body_changes() { - let row = ChatRow { - name: "A".into(), - preview: None, - unread: 0, - }; - let first = MessageRow { - chat_id: "c".into(), - chat_name: "A".into(), - sender: Some("alice".into()), - body: "hello".into(), - ts: Some(1), - }; - let second = MessageRow { - body: "world".into(), - ..first.clone() - }; - assert_ne!( - hash_scan(&[row.clone()], &[first], 0), - hash_scan(&[row], &[second], 0) - ); - } - - #[test] - fn hash_changes_when_unread_moves_between_chats() { - let a1 = ChatRow { - name: "A".into(), - preview: None, - unread: 2, - }; - let b1 = ChatRow { - name: "B".into(), - preview: None, - unread: 0, - }; - let a2 = ChatRow { - name: "A".into(), - preview: None, - unread: 0, - }; - let b2 = ChatRow { - name: "B".into(), - preview: None, - unread: 2, - }; - assert_ne!( - hash_scan(&[a1, b1], &[], 2), - hash_scan(&[a2, b2], &[], 2), - "per-chat unread distribution must affect the hash" - ); - } -} diff --git a/app/src-tauri/src/wechat_scanner/mod.rs b/app/src-tauri/src/wechat_scanner/mod.rs deleted file mode 100644 index 193c3738da..0000000000 --- a/app/src-tauri/src/wechat_scanner/mod.rs +++ /dev/null @@ -1,247 +0,0 @@ -//! WeChat Web scanner over CDP — chat list + active conversation DOM scrape. - -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Duration; - -use openhuman_core::openhuman::channels::webview_accounts::{ - list_ingest_envelope, memory_doc_ingest_list_snapshot, memory_doc_ingest_peer_transcript, - validate_scan, WechatMessageRow, WechatScanPayload, -}; -use parking_lot::Mutex; -use serde_json::{json, Value}; -use tauri::{AppHandle, Emitter, Runtime}; -use tokio::task::AbortHandle; -use tokio::time::sleep; - -mod dom_snapshot; - -const SCAN_INTERVAL: Duration = Duration::from_secs(3); -const STARTUP_DELAY: Duration = Duration::from_secs(8); - -pub fn wechat_scanner_disabled() -> bool { - matches!( - std::env::var("OPENHUMAN_DISABLE_WECHAT_SCANNER") - .ok() - .as_deref() - .map(str::trim), - Some("1") | Some("true") | Some("yes") - ) -} - -pub fn spawn_scanner( - app: AppHandle, - account_id: String, - url_prefix: String, -) -> AbortHandle { - tokio::spawn(async move { - let fragment = crate::cdp::target_url_fragment(&account_id); - log::info!( - "[wechat] scanner up account={} url_prefix={} fragment={}", - account_id, - url_prefix, - fragment - ); - sleep(STARTUP_DELAY).await; - let mut last_hash: Option = None; - loop { - match scan_once(&app, &account_id, &url_prefix, &fragment).await { - Ok(scan) => { - if Some(scan.hash) == last_hash { - sleep(SCAN_INTERVAL).await; - continue; - } - last_hash = Some(scan.hash); - let payload = dom_snapshot::scan_to_core_payload(&account_id, &scan); - if validate_scan(&payload).is_err() { - sleep(SCAN_INTERVAL).await; - continue; - } - log::info!( - "[wechat][{}] dom scan chats={} msgs={} unread={}", - account_id, - scan.chat_rows.len(), - scan.messages.len(), - scan.unread - ); - emit_and_persist(&app, &account_id, &payload); - } - Err(e) => log::debug!("[wechat][{}] dom scan failed: {}", account_id, e), - } - sleep(SCAN_INTERVAL).await; - } - }) - .abort_handle() -} - -async fn scan_once( - app: &AppHandle, - account_id: &str, - url_prefix: &str, - url_fragment: &str, -) -> Result { - let prefix = url_prefix.to_string(); - let fragment = url_fragment.to_string(); - let pred = move |t: &crate::cdp::target::CdpTarget| -> bool { - t.url.starts_with(&prefix) && t.url.ends_with(&fragment) - }; - let (mut cdp, session) = - crate::cdp::target::connect_and_attach_matching_in_process::(app, account_id, pred) - .await?; - let scan = dom_snapshot::scan(&mut cdp, &session).await; - crate::cdp::detach_session(&mut cdp, &session).await; - scan -} - -fn emit_and_persist(app: &AppHandle, account_id: &str, payload: &WechatScanPayload) { - if let Err(e) = app.emit( - "webview:event", - &list_ingest_envelope(account_id, payload, chrono_now_millis()), - ) { - log::warn!("[wechat][{}] ingest emit failed: {}", account_id, e); - } - if !payload.chat_rows.is_empty() { - let acct = account_id.to_string(); - let list = payload.clone(); - tokio::spawn(async move { - if let Err(e) = post_memory_doc(&acct, memory_doc_ingest_list_snapshot(&list)).await { - log::warn!("[wechat][{}] list memory failed: {}", acct, e); - } - }); - } - let mut groups: HashMap)> = HashMap::new(); - for m in &payload.messages { - if m.body.trim().is_empty() { - continue; - } - let e = groups.entry(m.chat_id.clone()).or_default(); - if e.0.is_empty() { - e.0 = m.chat_name.clone(); - } - e.1.push(m.clone()); - } - for (chat_id, (chat_name, rows)) in groups { - let acct = account_id.to_string(); - tokio::spawn(async move { - match memory_doc_ingest_peer_transcript(&acct, &chat_id, &chat_name, &rows) { - Ok(params) => { - if let Err(e) = post_memory_doc(&acct, Ok(params)).await { - log::warn!( - "[wechat][{}] peer memory upsert failed chat_id={}: {}", - acct, - chat_id, - e - ); - } - } - Err(e) => log::warn!( - "[wechat][{}] peer transcript build failed chat_id={}: {}", - acct, - chat_id, - e - ), - } - }); - } -} - -async fn post_memory_doc( - account_id: &str, - params: Result, String>, -) -> Result<(), String> { - let params = params?; - let body = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "openhuman.memory_doc_ingest", - "params": Value::Object(params), - }); - let url = crate::core_rpc::core_rpc_url_value(); - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(15)) - .build() - .map_err(|e| format!("http client: {e}"))?; - let resp = crate::core_rpc::apply_auth(client.post(&url)) - .map_err(|e| format!("prepare {url}: {e}"))? - .json(&body) - .send() - .await - .map_err(|e| format!("POST {url}: {e}"))?; - if !resp.status().is_success() { - return Err(format!( - "{}: {}", - resp.status(), - resp.text().await.unwrap_or_default() - )); - } - let v: Value = resp.json().await.map_err(|e| format!("decode: {e}"))?; - if v.get("error").is_some() { - return Err(format!("rpc error: {}", v["error"])); - } - log::info!("[wechat][{}] memory upsert ok", account_id); - Ok(()) -} - -fn chrono_now_millis() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) -} - -#[derive(Default)] -pub struct ScannerRegistry { - started: Mutex>, -} - -impl ScannerRegistry { - pub fn new() -> Arc { - Arc::new(Self::default()) - } - - pub fn ensure_scanner( - &self, - app: AppHandle, - account_id: String, - url_prefix: String, - ) { - if wechat_scanner_disabled() { - return; - } - let mut g = self.started.lock(); - if g.contains_key(&account_id) { - return; - } - let scanner_account_id = account_id.clone(); - g.insert( - account_id, - spawn_scanner(app, scanner_account_id, url_prefix), - ); - } - - pub fn forget(&self, account_id: &str) { - if let Some(h) = self.started.lock().remove(account_id) { - h.abort(); - } - } - - pub fn forget_all(&self) -> usize { - let entries: Vec<_> = self.started.lock().drain().collect(); - for (_, h) in &entries { - h.abort(); - } - entries.len() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn disabled_env_var_is_honored() { - std::env::set_var("OPENHUMAN_DISABLE_WECHAT_SCANNER", "1"); - assert!(wechat_scanner_disabled()); - std::env::remove_var("OPENHUMAN_DISABLE_WECHAT_SCANNER"); - } -} diff --git a/app/src-tauri/src/whatsapp_scanner/dom_snapshot.rs b/app/src-tauri/src/whatsapp_scanner/dom_snapshot.rs deleted file mode 100644 index 64af81ab19..0000000000 --- a/app/src-tauri/src/whatsapp_scanner/dom_snapshot.rs +++ /dev/null @@ -1,903 +0,0 @@ -//! Pure-CDP DOM scrape for WhatsApp message rows. -//! -//! Replaces the old `dom_scan.js` (injected via `Runtime.evaluate`) with a -//! single `DOMSnapshot.captureSnapshot` call that runs at the browser's C++ -//! level — no JavaScript executes in the page's JS world. The returned -//! flat-array snapshot is walked in Rust to: -//! -//! 1. locate `[data-id]` elements that parse as a message row (see -//! `split_data_id` for the two accepted shapes — legacy compound -//! `"__"` plus the current bare-msgId hex); -//! 2. pull `data-pre-plain-text` off a descendant to recover author + -//! timestamp; -//! 3. collect rendered body text — historically `span.selectable-text`, -//! now also any `span[dir="ltr|rtl"]` since current WhatsApp Web -//! drops the `selectable-text` class on message bodies. The longest -//! span text wins so the timestamp sibling (e.g. `00:19`) loses to -//! the actual message body. -//! -//! Output matches the shape `dom_scan.js` used to return so the rest of -//! the scanner (merge, emit, hash-dedup) doesn't need to change. When the -//! bare-msgId format hits, `chat_id` and `from_me` come back empty/false -//! and the merge in `mod.rs::scan_once` (`by_msg_id` lookup) backfills -//! both from the IDB-side message keyed by `msgId`. - -use std::collections::{HashMap, HashSet}; - -use serde::Deserialize; -use serde_json::{json, Value}; - -use crate::cdp::CdpConn; - -/// One scraped message row. Mirrors the JSON object the old JS emitted so -/// the merge path in `mod.rs` keeps working unchanged. -#[derive(Debug, Clone)] -pub struct DomMessage { - pub data_id: String, - pub from_me: bool, - pub chat_id: String, - pub msg_id: String, - pub author: Option, - pub pre_timestamp: Option, - pub body: String, -} - -impl DomMessage { - pub fn to_json(&self) -> Value { - json!({ - "dataId": self.data_id, - "fromMe": self.from_me, - "chatId": self.chat_id, - "msgId": self.msg_id, - "author": self.author, - "preTimestamp": self.pre_timestamp, - "body": self.body, - }) - } -} - -/// Per-stage telemetry produced by [`capture_messages`]. The counters -/// disambiguate the three failure modes that `dom=0` used to collapse into -/// a single number (issue #1376): rows never matched, rows matched but -/// body extraction returned empty, or active chat name failed to resolve -/// (forcing rows to be filtered out downstream by the merge step). -/// -/// Field invariants: -/// * `rows_seen` — `[data-id]` elements that parsed as message rows BEFORE -/// any body filter. Counts every accepted `data-id` shape (legacy -/// compound + bare-msgId). -/// * `rows_with_body` — subset where [`find_body`] returned a non-empty -/// string. `rows_seen - rows_with_body` is bodies that vanished. -/// * `rows_dropped_no_body` — convenience, equals -/// `rows_seen - rows_with_body`. -/// * `active_chat_resolved` — true when -/// `header[data-testid="conversation-header"]` produced a display name -/// (precondition for the chat-id reverse lookup in `mod.rs`). -#[derive(Debug, Clone, Default)] -pub struct CaptureReport { - pub rows: Vec, - pub hash: u64, - pub active_chat_name: Option, - pub rows_seen: usize, - pub rows_with_body: usize, - pub rows_dropped_no_body: usize, - pub active_chat_resolved: bool, -} - -/// Run `DOMSnapshot.captureSnapshot` against an attached page session and -/// return parsed message rows, a FNV-1a hash over (dataId, body), and the -/// active conversation's display name (from -/// `header[data-testid="conversation-header"]`) when one is open. The chat -/// name is the only DOM signal that carries the active chat's identity — -/// modern WhatsApp Web omits the chat JID from the URL, from `data-id`, and -/// from any DOM attribute, so the merge step in `mod.rs` reverse-looks-up -/// `chats[*].name → chats[*].jid` to stamp `chatId` onto DOM rows. -/// -/// Returns a [`CaptureReport`] with per-stage counters. See the type doc -/// for invariants. -pub async fn capture_messages(cdp: &mut CdpConn, session: &str) -> Result { - // `computedStyles` is a required array — empty is fine, we don't need - // any CSS. The other flags default sensibly; explicitly disable the - // heavy paint/rect output to keep payloads small. - let raw = cdp - .call( - "DOMSnapshot.captureSnapshot", - json!({ - "computedStyles": [], - "includePaintOrder": false, - "includeDOMRects": false, - }), - Some(session), - ) - .await?; - let snap: CaptureSnapshot = - serde_json::from_value(raw).map_err(|e| format!("decode DOMSnapshot: {e}"))?; - Ok(report_from_snapshot(&snap)) -} - -/// Synthesize a [`CaptureReport`] from a parsed `CaptureSnapshot`. Split -/// out from [`capture_messages`] so unit tests can drive the body-finder -/// + counters off a JSON fixture without mocking CDP. -pub(crate) fn report_from_snapshot(snap: &CaptureSnapshot) -> CaptureReport { - let stats = parse_rows(snap); - let hash = fnv_hash(&stats.rows); - let active_chat_name = parse_active_chat_name(snap); - let active_chat_resolved = active_chat_name.is_some(); - CaptureReport { - rows: stats.rows, - hash, - active_chat_name, - rows_seen: stats.rows_seen, - rows_with_body: stats.rows_with_body, - rows_dropped_no_body: stats.rows_seen.saturating_sub(stats.rows_with_body), - active_chat_resolved, - } -} - -// ─── CDP response shape ───────────────────────────────────────────── - -#[derive(Deserialize, Debug, Default)] -pub(crate) struct CaptureSnapshot { - #[serde(default)] - documents: Vec, - #[serde(default)] - strings: Vec, -} - -#[derive(Deserialize, Debug, Default)] -struct DocumentSnap { - #[serde(default)] - nodes: NodeTreeSnap, -} - -/// Flat-array node tree from `DOMSnapshot.NodeTreeSnapshot`. Each array is -/// indexed by node index; -1 sentinel means "absent". `attributes[i]` is a -/// flat list of alternating `[nameIdx, valueIdx, ...]` string-table indices. -#[derive(Deserialize, Debug, Default)] -struct NodeTreeSnap { - #[serde(rename = "parentIndex", default)] - parent_index: Vec, - #[serde(rename = "nodeType", default)] - node_type: Vec, - #[serde(rename = "nodeName", default)] - node_name: Vec, - #[serde(rename = "nodeValue", default)] - node_value: Vec, - #[serde(default)] - attributes: Vec>, -} - -/// Output of [`parse_rows`] including per-stage counters used to populate -/// [`CaptureReport`]. `rows` is the filtered keep-set (rows with body OR -/// `data-pre-plain-text`); `rows_seen` is the count of accepted `data-id` -/// shapes BEFORE any body/chrome filter; `rows_with_body` is the count -/// where [`find_body`] returned non-empty for a row in `rows_seen`. -#[derive(Debug, Default)] -pub(crate) struct ParseStats { - pub rows: Vec, - pub rows_seen: usize, - pub rows_with_body: usize, -} - -const NODE_TYPE_ELEMENT: i32 = 1; -const NODE_TYPE_TEXT: i32 = 3; -/// Hard cap on body length to mirror `dom_scan.js` (which sliced at 4000). -const MAX_BODY_CHARS: usize = 4000; - -// ─── parsing ──────────────────────────────────────────────────────── - -fn parse_rows(snap: &CaptureSnapshot) -> ParseStats { - // Main frame only — iframes aren't used by WhatsApp's message list. - let doc = match snap.documents.first() { - Some(d) => d, - None => return ParseStats::default(), - }; - let nodes = &doc.nodes; - let strings = &snap.strings; - let count = nodes.node_type.len(); - if count == 0 { - return ParseStats::default(); - } - - // Precompute children map so descendant walks are O(subtree) instead of - // O(total-nodes) per root. - let mut children: Vec> = vec![Vec::new(); count]; - for (i, &p) in nodes.parent_index.iter().enumerate() { - if p >= 0 && (p as usize) < count { - children[p as usize].push(i); - } - } - - let mut out = Vec::new(); - let mut seen: HashSet = HashSet::new(); - let mut rows_seen = 0usize; - let mut rows_with_body = 0usize; - - for i in 0..count { - if nodes.node_type.get(i).copied().unwrap_or(0) != NODE_TYPE_ELEMENT { - continue; - } - let attrs = attrs_map(nodes, i, strings); - let data_id = match attrs.get("data-id") { - Some(v) if !v.is_empty() => v.clone(), - _ => continue, - }; - // data-id format: "__" — chat-list rows and - // other framework hooks use different shapes, so filter strictly. - let (from_me, chat_id, msg_id) = match split_data_id(&data_id) { - Some(x) => x, - None => continue, - }; - if !seen.insert(data_id.clone()) { - continue; - } - - // Telemetry: count every accepted data-id BEFORE the body filter so - // the per-stage counts in `CaptureReport` distinguish "no rows - // matched at all" from "rows matched but body extraction failed". - rows_seen += 1; - - let (pre_ts, author) = find_pre_plain(nodes, strings, &children, i); - let body = find_body(nodes, strings, &children, i); - if !body.is_empty() { - rows_with_body += 1; - } - // A row with neither a body nor a pre-plain-text tag is just chrome - // (avatar wrapper, reaction chip, etc) — skip it. Note: this still - // contributes to `rows_seen` because the goal of that counter is - // "did we find rows at all", not "did we keep them". - if body.is_empty() && pre_ts.is_none() { - continue; - } - - out.push(DomMessage { - data_id, - from_me, - chat_id, - msg_id, - author, - pre_timestamp: pre_ts, - body: truncate_chars(&body, MAX_BODY_CHARS), - }); - } - - ParseStats { - rows: out, - rows_seen, - rows_with_body, - } -} - -/// Find the `header[data-testid="conversation-header"]` element and return -/// its first non-empty text — the active chat's display name as rendered in -/// WhatsApp Web's top bar (e.g. `"Anushka"` for a 1:1, `"Family Group"` for -/// a group chat). Returns `None` when no chat is open or the header isn't -/// in the snapshot (e.g. user is on the chat list / settings panel). -/// -/// This is the linkage point for stamping `chatId` onto DOM rows: callers -/// reverse-look-up the returned name in their IDB-side `chats` map (where -/// `chats[jid].name` holds the same string) to recover the chat JID. -fn parse_active_chat_name(snap: &CaptureSnapshot) -> Option { - let doc = snap.documents.first()?; - let nodes = &doc.nodes; - let strings = &snap.strings; - let count = nodes.node_type.len(); - if count == 0 { - return None; - } - - let mut children: Vec> = vec![Vec::new(); count]; - for (i, &p) in nodes.parent_index.iter().enumerate() { - if p >= 0 && (p as usize) < count { - children[p as usize].push(i); - } - } - - // Locate the header by attribute, not by class name (classes are - // obfuscated and drift; `data-testid` is stable across recent versions). - for i in 0..count { - if nodes.node_type.get(i).copied().unwrap_or(0) != NODE_TYPE_ELEMENT { - continue; - } - let attrs = attrs_map(nodes, i, strings); - if attrs.get("data-testid").map(String::as_str) != Some("conversation-header") { - continue; - } - // The header's `collect_text` concatenates avatar alt-text, the chat - // title, the participant subtitle (for groups, this is the entire - // member list with no separators), online status, and action-button - // labels — `Some("Kirat karoAmenreet, Arshdeep, ...")`-style noise. - // The chat title is reliably the first `` descendant of the - // header that ISN'T an icon ligature. Modern WhatsApp Web wraps - // Material-style icons in `wds-ic-…`, - // and the first such span is the avatar's `data-icon`/material-glyph - // marker (e.g. `wds-ic-disappearing-messages`, `wds-ic-search`). - // Skip spans whose trimmed text matches an icon-name pattern. - let mut stack: Vec = vec![i]; - while let Some(idx) = stack.pop() { - if nodes.node_type.get(idx).copied().unwrap_or(0) == NODE_TYPE_ELEMENT { - let name = str_at(strings, *nodes.node_name.get(idx).unwrap_or(&-1)); - if name.eq_ignore_ascii_case("SPAN") { - let span_text = collect_text(nodes, strings, &children, idx); - let trimmed = span_text.trim(); - if !trimmed.is_empty() && !looks_like_icon_ligature(trimmed) { - return Some(trimmed.to_string()); - } - } - } - if let Some(kids) = children.get(idx) { - for &k in kids.iter().rev() { - stack.push(k); - } - } - } - // Fallback (defensive): no SPAN under the header — fall back to - // the first text-line inside the header itself. - let text = collect_text(nodes, strings, &children, i); - let trimmed = text.trim(); - let first_line = trimmed.lines().next().unwrap_or("").trim(); - if !first_line.is_empty() { - return Some(first_line.to_string()); - } - } - None -} - -/// Returns true when `s` looks like a Material/WhatsApp icon ligature name -/// (e.g. `wds-ic-search`, `wds-ic-disappearing-messages`, `material-icons`, -/// `arrow_forward`). These appear as the first SPAN inside icon wrappers -/// and would otherwise win the chat-title race in `parse_active_chat_name`. -/// -/// **Two-pass heuristic (issue #1376 fix):** -/// 1. Explicit WDS prefix: `wds-ic-*` / `wds-icon*` — always icon. -/// 2. Token-shape check: no whitespace, all chars `[a-z0-9_-]`, AND the -/// token must contain at least one `-` or `_` delimiter. This distinguishes -/// icon names like `arrow_forward` / `material-icons` from plain one-word -/// message bodies like "ok", "hello", "yes" — real words never contain -/// hyphens or underscores in this context, but icon ligature names always do. -fn looks_like_icon_ligature(s: &str) -> bool { - let t = s.trim(); - if t.starts_with("wds-ic-") || t.starts_with("wds-icon") { - return true; - } - // Require at least one delimiter so plain lowercase words (e.g. "ok", - // "hello") are NOT treated as ligatures. - !t.is_empty() - && !t.contains(char::is_whitespace) - && (t.contains('-') || t.contains('_')) - && t.chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') -} - -/// Build a `name → value` map for a single element's attributes. Missing or -/// malformed entries are silently skipped. -fn attrs_map(nodes: &NodeTreeSnap, idx: usize, strings: &[String]) -> HashMap { - let mut map = HashMap::new(); - if let Some(flat) = nodes.attributes.get(idx) { - let mut i = 0; - while i + 1 < flat.len() { - let k = str_at(strings, flat[i]); - let v = str_at(strings, flat[i + 1]); - if !k.is_empty() { - map.insert(k.to_string(), v.to_string()); - } - i += 2; - } - } - map -} - -fn str_at(strings: &[String], idx: i32) -> &str { - if idx < 0 { - return ""; - } - strings.get(idx as usize).map(String::as_str).unwrap_or("") -} - -/// Parse a WhatsApp Web row's `data-id`. Two shapes are accepted: -/// -/// 1. **Legacy compound** — `"true_12345@c.us_3EB0A..."` → `(true, "12345@c.us", "3EB0A...")`. -/// Used by older WhatsApp Web builds. -/// -/// 2. **Bare msgId** — `"2A327AC82CD56D95E087"` (hex or alphanumeric) → -/// `(false, "", "2A327AC82CD56D95E087")`. Used by current WhatsApp Web -/// (observed via live CDP probe 2026-04-30): rows now expose only the -/// message identifier on `data-id`; `fromMe` is no longer derivable from -/// this attribute. The merge step in `mod.rs::scan_once` keys DOM rows by -/// `msgId` and pulls `chatId` / `fromMe` from the IDB-side message, so a -/// blank `chat_id` here is harmless — see the `by_msg_id` lookup at -/// `mod.rs:498-528`. -/// -/// Reject anything that's neither — chat-list framework rows, lazy-load -/// sentinels, and other non-message hooks all carry `data-id` values that -/// shouldn't slip into the message stream. -fn split_data_id(s: &str) -> Option<(bool, String, String)> { - // Legacy form first — `splitn(3, '_')` keeps the msgId intact even when - // it contains `_`. - let parts: Vec<&str> = s.splitn(3, '_').collect(); - if parts.len() == 3 { - let from_me_tok = parts[0]; - let chat_id = parts[1]; - let msg_id = parts[2]; - let from_me = match from_me_tok { - "true" => Some(true), - "false" => Some(false), - _ => None, - }; - if let Some(fm) = from_me { - if !chat_id.is_empty() && !msg_id.is_empty() { - return Some((fm, chat_id.to_string(), msg_id.to_string())); - } - } - } - - // Bare-msgId fallback. Accept only ASCII alnum (current WhatsApp ids are - // hex but allow alphanumeric for forward compatibility) and require a - // minimum length so single-char framework hooks like `data-id="x"` don't - // get picked up. 16 chars covers the shortest msgId observed in the - // wild. - if s.len() >= 16 && s.bytes().all(|b| b.is_ascii_alphanumeric()) { - return Some((false, String::new(), s.to_string())); - } - - None -} - -/// Find the first descendant carrying `data-pre-plain-text` and parse -/// `"[HH:MM, D/M/YYYY] Author Name: "` out of it. -fn find_pre_plain( - nodes: &NodeTreeSnap, - strings: &[String], - children: &[Vec], - root: usize, -) -> (Option, Option) { - let mut stack = vec![root]; - while let Some(idx) = stack.pop() { - if nodes.node_type.get(idx).copied().unwrap_or(0) == NODE_TYPE_ELEMENT { - if let Some(flat) = nodes.attributes.get(idx) { - let mut i = 0; - while i + 1 < flat.len() { - if str_at(strings, flat[i]) == "data-pre-plain-text" { - let pre = str_at(strings, flat[i + 1]); - if let Some(parsed) = parse_pre_attr(pre) { - return (Some(parsed.0), Some(parsed.1)); - } - } - i += 2; - } - } - } - if let Some(kids) = children.get(idx) { - // Depth-first, preserve order — doesn't matter for correctness - // but keeps behavior predictable when multiple descendants carry - // the attr (shouldn't happen in WhatsApp's DOM). - for &k in kids.iter().rev() { - stack.push(k); - } - } - } - (None, None) -} - -/// Pick the longest rendered body text inside the row. -/// -/// Three tiers, tried in order — each tier only runs when the previous -/// returned empty: -/// -/// **Tier 1** — descendant `span.selectable-text` (legacy WhatsApp Web). -/// **Tier 2** — descendant `span[dir="ltr"|"rtl"]` (current WhatsApp Web -/// drops the `selectable-text` class but keeps the `dir` hint on text -/// spans). Both tiers walk every match and keep the longest, mirroring -/// the original `dom_scan.js` behavior. -/// -/// **Tier 3 fallback (issue #1376)** — when WhatsApp Web layout drift -/// strips both class+dir hints from the message body, walk every -/// descendant TEXT node, skip icon ligatures (`wds-ic-*`, `wds-icon`) -/// and chrome strings (timestamps `H:MM`, status indicators ✓ ✓✓ 🔇), -/// concatenate the remainder. This is the broadest recovery path and -/// catches rows that render their body via a plain `
` or unhinted -/// `` wrapper. Capped at [`MAX_BODY_CHARS`]. -/// -/// Final fallback (unchanged): everything under the row with the -/// `"[HH:MM, D/M/YYYY] Author:"` prefix stripped — handles rows rendered -/// without any dedicated text span at all. -fn find_body( - nodes: &NodeTreeSnap, - strings: &[String], - children: &[Vec], - root: usize, -) -> String { - // Tiers 1 + 2 — span-attribute-driven discovery (longest wins). - let mut best = String::new(); - let mut stack = vec![root]; - while let Some(idx) = stack.pop() { - if nodes.node_type.get(idx).copied().unwrap_or(0) == NODE_TYPE_ELEMENT { - let name = str_at(strings, *nodes.node_name.get(idx).unwrap_or(&-1)); - if name.eq_ignore_ascii_case("SPAN") { - let attrs = attrs_map(nodes, idx, strings); - let has_class = attrs - .get("class") - .map(|c| c.split_whitespace().any(|w| w == "selectable-text")) - .unwrap_or(false); - let dir = attrs.get("dir").map(String::as_str).unwrap_or(""); - if has_class || dir == "ltr" || dir == "rtl" { - let t = collect_text(nodes, strings, children, idx); - let trimmed = t.trim(); - if trimmed.len() > best.len() { - best = trimmed.to_string(); - } - } - } - } - if let Some(kids) = children.get(idx) { - for &k in kids.iter().rev() { - stack.push(k); - } - } - } - if !best.is_empty() { - return best; - } - - // Tier 3 fallback (issue #1376): walk every descendant text node, - // filter out icon ligatures + chrome strings, concatenate. - let tier3 = collect_descendant_text_filtered(nodes, strings, children, root); - if !tier3.is_empty() { - return truncate_chars(&tier3, MAX_BODY_CHARS); - } - - // Last-resort: everything under the row, with the - // "[HH:MM, ...] Author:" prefix stripped — handles rows rendered - // without a dedicated text span. - let full = collect_text(nodes, strings, children, root); - strip_pre_prefix(full.trim()).to_string() -} - -/// Tier-3 helper for [`find_body`] (issue #1376). Walks every TEXT node -/// under `root` whose nearest element ancestor is NOT an icon wrapper -/// (`wds-ic-*` / `wds-icon` class) and whose trimmed value is NOT a -/// chrome string (timestamp `H:MM[ AM/PM]`, single status indicator). -/// Joins surviving snippets with spaces. -/// -/// Skip rules (in the order they're checked): -/// 1. Element ancestor's `class` contains `wds-icon` or any `wds-ic-*` -/// token — entire icon subtree is ignored. -/// 2. Trimmed text matches the timestamp regex shape `H:MM` / -/// `HH:MM` / `H:MM AM` / `H:MM PM` (case-insensitive). Captures -/// WhatsApp's per-bubble timestamp + delivery clock chip. -/// 3. Trimmed text is a single delivery-status glyph (✓, ✓✓, ✓✓ tinted -/// blue, 🔇, 📌, 📷, 🎤, 🎥, 📎, 📄). The first two are the only -/// common ones today; the rest are defensive against future glyph -/// drift. -fn collect_descendant_text_filtered( - nodes: &NodeTreeSnap, - strings: &[String], - children: &[Vec], - root: usize, -) -> String { - // Build "is this node inside an icon wrapper?" lookup as we walk — - // cheaper than recomputing per text node. - let mut out_parts: Vec = Vec::new(); - // Stack carries (node_idx, ancestor_is_icon). - let mut stack: Vec<(usize, bool)> = vec![(root, false)]; - while let Some((idx, ancestor_is_icon)) = stack.pop() { - let node_type = nodes.node_type.get(idx).copied().unwrap_or(0); - let mut now_is_icon = ancestor_is_icon; - if node_type == NODE_TYPE_ELEMENT { - let attrs = attrs_map(nodes, idx, strings); - if let Some(class) = attrs.get("class") { - if class - .split_whitespace() - .any(|w| w == "wds-icon" || w.starts_with("wds-ic-")) - { - now_is_icon = true; - } - } - } else if node_type == NODE_TYPE_TEXT && !ancestor_is_icon { - let raw = str_at(strings, *nodes.node_value.get(idx).unwrap_or(&-1)); - let trimmed = raw.trim(); - if !trimmed.is_empty() - && !looks_like_timestamp(trimmed) - && !looks_like_status_glyph(trimmed) - && !looks_like_icon_ligature(trimmed) - { - out_parts.push(trimmed.to_string()); - } - } - if let Some(kids) = children.get(idx) { - for &k in kids.iter().rev() { - stack.push((k, now_is_icon)); - } - } - } - out_parts.join(" ").trim().to_string() -} - -/// Returns true when `s` looks like a WhatsApp Web message-bubble -/// timestamp: `H:MM`, `HH:MM`, optionally followed by ` AM` / ` PM` -/// (any case). Reject anything else so real bodies that happen to -/// include digits aren't dropped. -fn looks_like_timestamp(s: &str) -> bool { - let t = s.trim(); - if t.is_empty() { - return false; - } - // Optional trailing AM/PM after a single space — accept and strip. - let upper = t.to_ascii_uppercase(); - let core = if let Some(stripped) = upper - .strip_suffix(" AM") - .or_else(|| upper.strip_suffix(" PM")) - { - stripped - } else { - upper.as_str() - }; - // Now `core` must be exactly H:MM or HH:MM with both halves digits. - let mut parts = core.split(':'); - let (Some(h), Some(m), None) = (parts.next(), parts.next(), parts.next()) else { - return false; - }; - if h.is_empty() || h.len() > 2 || m.len() != 2 { - return false; - } - h.bytes().all(|b| b.is_ascii_digit()) && m.bytes().all(|b| b.is_ascii_digit()) -} - -/// Returns true when `s` is a single delivery-status glyph WhatsApp -/// renders next to message bubbles (e.g. ✓, ✓✓, 🔇). The check is -/// intentionally conservative: short string AND every char is in a -/// small allow-list of known glyph code points. Real one-char message -/// bodies (e.g. emoji-only "👍" reactions still render as a separate -/// bubble) are NOT in the list and survive the filter. -fn looks_like_status_glyph(s: &str) -> bool { - let t = s.trim(); - if t.is_empty() || t.chars().count() > 2 { - return false; - } - t.chars().all(|c| { - matches!( - c, - '\u{2713}' | '\u{2714}' | '\u{1F507}' | '\u{1F508}' | '\u{1F509}' - ) - }) -} - -/// Concatenate every TEXT_NODE nodeValue under `root` in document order. -fn collect_text( - nodes: &NodeTreeSnap, - strings: &[String], - children: &[Vec], - root: usize, -) -> String { - let mut out = String::new(); - let mut stack = vec![root]; - while let Some(idx) = stack.pop() { - if nodes.node_type.get(idx).copied().unwrap_or(0) == NODE_TYPE_TEXT { - out.push_str(str_at(strings, *nodes.node_value.get(idx).unwrap_or(&-1))); - } - if let Some(kids) = children.get(idx) { - // Reverse so the first child is processed first (stack ordering). - for &k in kids.iter().rev() { - stack.push(k); - } - } - } - out -} - -/// Parse `"[12:34, 3/15/2025] John Doe: "` → `("12:34, 3/15/2025", "John Doe")`. -fn parse_pre_attr(pre: &str) -> Option<(String, String)> { - let s = pre.trim_start(); - if !s.starts_with('[') { - return None; - } - let close = s.find(']')?; - let ts = s[1..close].trim().to_string(); - let rest = s[close + 1..].trim_start(); - let colon = rest.find(':')?; - let author = rest[..colon].trim().to_string(); - if ts.is_empty() || author.is_empty() { - return None; - } - Some((ts, author)) -} - -/// Strip a leading `"[...] foo: "` prefix from a concatenated row text. -fn strip_pre_prefix(text: &str) -> &str { - let t = text.trim_start(); - if !t.starts_with('[') { - return text; - } - let close = match t.find(']') { - Some(i) => i, - None => return text, - }; - let rest = &t[close + 1..]; - let colon = match rest.find(':') { - Some(i) => i, - None => return text, - }; - let after = &rest[colon + 1..]; - after.strip_prefix(' ').unwrap_or(after) -} - -/// Truncate a String to at most `max` chars (not bytes) — safe for UTF-8. -fn truncate_chars(s: &str, max: usize) -> String { - if s.chars().count() <= max { - return s.to_string(); - } - s.chars().take(max).collect() -} - -/// Render a TRACE-friendly preview of a `DomMessage` row for the -/// per-row debug dump in `mod.rs::scan_once`. Each entry is a -/// `(attribute key, snippet)` pair drawn from the row's identifying -/// attributes and the first few non-icon child text nodes (≤ `cap` chars -/// each, no PII beyond what already lives in the row). -/// -/// Designed for `log::trace!` only — keep payloads small so the lines fit -/// in stdout without scrolling. The icon filter reuses -/// [`looks_like_icon_ligature`] so `wds-ic-*` ligatures don't dominate -/// previews of rows that render mostly chrome. -pub(crate) fn text_snippet_preview(row: &DomMessage, cap: usize) -> Vec<(String, String)> { - let mut out: Vec<(String, String)> = Vec::new(); - out.push(("dataId".to_string(), truncate_chars(&row.data_id, cap))); - out.push(("msgId".to_string(), truncate_chars(&row.msg_id, cap))); - if !row.chat_id.is_empty() { - out.push(("chatId".to_string(), truncate_chars(&row.chat_id, cap))); - } - if let Some(author) = &row.author { - out.push(("author".to_string(), truncate_chars(author, cap))); - } - if let Some(pre) = &row.pre_timestamp { - out.push(("preTs".to_string(), truncate_chars(pre, cap))); - } - // Body preview always last so the visually heavy line wraps at the end. - out.push(("body".to_string(), truncate_chars(&row.body, cap))); - out -} - -/// FNV-1a 32-bit rolling hash over `(dataId + 0x01 + body)` per row. Used -/// purely for change detection on the Rust side — no persistence, no wire -/// format. Byte-based (JS version was UTF-16 code units; ASCII-equivalent). -fn fnv_hash(rows: &[DomMessage]) -> u64 { - let mut h: u32 = 2166136261; - for r in rows { - for b in r.data_id.as_bytes() { - h ^= *b as u32; - h = h.wrapping_mul(16777619); - } - h ^= 0x01; - h = h.wrapping_mul(16777619); - for b in r.body.as_bytes() { - h ^= *b as u32; - h = h.wrapping_mul(16777619); - } - } - h as u64 -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn split_data_id_parses_msg_row() { - let (fm, chat, msg) = split_data_id("false_12345@c.us_3EB0ABCDEF").unwrap(); - assert!(!fm); - assert_eq!(chat, "12345@c.us"); - assert_eq!(msg, "3EB0ABCDEF"); - } - - #[test] - fn split_data_id_keeps_underscores_in_msg_id() { - let (_, _, msg) = split_data_id("true_chat@g.us_AB_CD_EF").unwrap(); - assert_eq!(msg, "AB_CD_EF"); - } - - #[test] - fn split_data_id_rejects_non_message_rows() { - assert!(split_data_id("chat-list-item_abc").is_none()); - // "maybe_abc_def" matches len>=16 alnum check after `_` strip? It - // has underscores and is 13 chars — both rejections fire. - assert!(split_data_id("maybe_abc_def").is_none()); - // Single-char hooks (e.g. `
`) must not pass. - assert!(split_data_id("x").is_none()); - // Anything with a hyphen / non-alnum is rejected by the bare-id fallback. - assert!(split_data_id("chat-list-row").is_none()); - } - - #[test] - fn split_data_id_accepts_bare_msg_id() { - // Current WhatsApp Web format (observed 2026-04-30 via CDP probe). - let (fm, chat, msg) = split_data_id("2A327AC82CD56D95E087").unwrap(); - assert!( - !fm, - "bare format defaults fromMe=false; merge fills from IDB" - ); - assert_eq!(chat, "", "no chatId in bare format; merge fills from IDB"); - assert_eq!(msg, "2A327AC82CD56D95E087"); - } - - #[test] - fn split_data_id_accepts_long_alnum_msg_id() { - let (_, _, msg) = split_data_id("AC36940161A53812E1A666B0F6BB71B7").unwrap(); - assert_eq!(msg, "AC36940161A53812E1A666B0F6BB71B7"); - } - - #[test] - fn parse_pre_attr_extracts_ts_and_author() { - let (ts, author) = parse_pre_attr("[4:53 AM, 7/5/2025] Jane Doe: ").unwrap(); - assert_eq!(ts, "4:53 AM, 7/5/2025"); - assert_eq!(author, "Jane Doe"); - } - - #[test] - fn parse_pre_attr_rejects_malformed() { - assert!(parse_pre_attr("no bracket").is_none()); - assert!(parse_pre_attr("[only-ts]").is_none()); - } - - #[test] - fn strip_pre_prefix_drops_leading_meta() { - assert_eq!( - strip_pre_prefix("[12:34, 3/15/2025] Bob: hello world"), - "hello world" - ); - } - - #[test] - fn strip_pre_prefix_passthrough_when_no_match() { - assert_eq!(strip_pre_prefix("hello world"), "hello world"); - } - - #[test] - fn truncate_chars_is_utf8_safe() { - // Each emoji is a single char but 4 bytes in UTF-8. - let s = "💬💬💬💬💬"; - assert_eq!(truncate_chars(s, 3), "💬💬💬"); - assert_eq!(truncate_chars(s, 10), s); - } - - // ── Issue #1376 — looks_like_icon_ligature must not drop real words ── - - #[test] - fn icon_ligature_matches_wds_prefix() { - assert!(looks_like_icon_ligature("wds-ic-search")); - assert!(looks_like_icon_ligature("wds-ic-disappearing-messages")); - assert!(looks_like_icon_ligature("wds-icon")); - assert!(looks_like_icon_ligature("wds-icon-foo")); - } - - #[test] - fn icon_ligature_matches_delimiter_tokens() { - // Material icon ligature names always contain a delimiter. - assert!(looks_like_icon_ligature("arrow_forward")); - assert!(looks_like_icon_ligature("material-icons")); - assert!(looks_like_icon_ligature("search-icon")); - } - - #[test] - fn icon_ligature_does_not_match_plain_words() { - // These are ordinary one-word message bodies — must NOT be filtered. - assert!(!looks_like_icon_ligature("ok")); - assert!(!looks_like_icon_ligature("hello")); - assert!(!looks_like_icon_ligature("yes")); - assert!(!looks_like_icon_ligature("no")); - assert!(!looks_like_icon_ligature("thanks")); - assert!(!looks_like_icon_ligature("lol")); - } - - #[test] - fn icon_ligature_does_not_match_multi_word() { - // Multi-word text is never a ligature (whitespace present). - assert!(!looks_like_icon_ligature("hello tier 3")); - assert!(!looks_like_icon_ligature("hello world")); - } - - #[test] - fn icon_ligature_does_not_match_empty() { - assert!(!looks_like_icon_ligature("")); - assert!(!looks_like_icon_ligature(" ")); - } -} diff --git a/app/src-tauri/src/whatsapp_scanner/dom_snapshot_tests.rs b/app/src-tauri/src/whatsapp_scanner/dom_snapshot_tests.rs deleted file mode 100644 index 5bd9344015..0000000000 --- a/app/src-tauri/src/whatsapp_scanner/dom_snapshot_tests.rs +++ /dev/null @@ -1,131 +0,0 @@ -//! Fixture-driven tests for `dom_snapshot::report_from_snapshot` (issue -//! #1376). The fixture lives at `test_fixtures/dom_snapshot_2026_05.json` -//! and exercises four body-extraction tiers in `find_body`: -//! -//! * **Tier 1** — `` (legacy WhatsApp Web -//! shape; some bubbles still render this). -//! * **Tier 2** — `` (current WhatsApp Web shape after -//! the `selectable-text` class was dropped). -//! * **Tier 3 multi-word (issue #1376 fallback)** — body wrapper with -//! neither `selectable-text` class nor `dir` hint; only the descendant -//! text walk + chrome filter recovers the body. -//! * **Tier 3 single-word (regression guard)** — same as tier 3 but with -//! a one-word body like "ok"; guards against -//! `looks_like_icon_ligature` false-positives that would silently drop -//! short plain-text bodies (CodeRabbit issue, fix in #1804). -//! -//! Each row in the fixture stresses exactly one tier so a regression in -//! any tier surfaces as a single failed assertion. The fixture is -//! intentionally synthetic — replace with a captured live snapshot once -//! one is available. -//! -//! Tests use the `pub(crate)` exports `CaptureSnapshot` + -//! `report_from_snapshot` from the parent module so they exercise the -//! full `parse_rows` → `find_body` pipeline. - -use super::dom_snapshot::{report_from_snapshot, CaptureSnapshot}; - -const FIXTURE_2026_05: &str = include_str!("test_fixtures/dom_snapshot_2026_05.json"); - -fn load_fixture() -> CaptureSnapshot { - serde_json::from_str(FIXTURE_2026_05) - .expect("dom_snapshot_2026_05.json must be valid CaptureSnapshot JSON") -} - -#[test] -fn parse_rows_finds_four_data_id_rows() { - let snap = load_fixture(); - let report = report_from_snapshot(&snap); - assert_eq!( - report.rows_seen, 4, - "fixture has four [data-id] rows (tiers 1/2/3-multi/3-single), all should be counted in rows_seen" - ); -} - -#[test] -fn capture_pipeline_resolves_active_chat_name() { - let snap = load_fixture(); - let report = report_from_snapshot(&snap); - assert!( - report.active_chat_resolved, - "fixture has header[data-testid=conversation-header] with text \"Test Chat\"" - ); - assert_eq!(report.active_chat_name.as_deref(), Some("Test Chat")); -} - -#[test] -fn find_body_extracts_via_selectable_text_tier1() { - let snap = load_fixture(); - let report = report_from_snapshot(&snap); - let row = report - .rows - .iter() - .find(|r| r.msg_id == "msgABC123") - .expect("tier 1 row (msgABC123) must survive"); - assert_eq!( - row.body, "hello tier 1", - "tier 1 row body comes from " - ); -} - -#[test] -fn find_body_extracts_via_dir_attr_tier2() { - let snap = load_fixture(); - let report = report_from_snapshot(&snap); - let row = report - .rows - .iter() - .find(|r| r.msg_id == "msgDEF456") - .expect("tier 2 row (msgDEF456) must survive"); - assert_eq!( - row.body, "hello tier 2", - "tier 2 row body comes from " - ); -} - -#[test] -fn find_body_extracts_via_descendant_text_tier3_fallback() { - let snap = load_fixture(); - let report = report_from_snapshot(&snap); - let row = report.rows.iter().find(|r| r.msg_id == "msgGHI789").expect( - "tier 3 row (msgGHI789) must survive — body comes from \ - descendant text walk fallback (issue #1376)", - ); - assert_eq!( - row.body, "hello tier 3", - "tier 3 fallback recovers body when neither class nor dir hint is present" - ); -} - -#[test] -fn find_body_tier3_does_not_drop_single_word_body() { - // Regression guard for CodeRabbit finding (PR #1804): the old - // `looks_like_icon_ligature` treated any lowercase single-token as a - // ligature, silently dropping "ok", "yes", "hello" etc. via tier-3. - // This test fails if that regression reappears. - let snap = load_fixture(); - let report = report_from_snapshot(&snap); - let row = report.rows.iter().find(|r| r.msg_id == "msgJKL012").expect( - "tier 3 single-word row (msgJKL012) must survive — \ - looks_like_icon_ligature must not filter plain words like 'ok'", - ); - assert_eq!( - row.body, "ok", - "single-word body 'ok' must not be dropped by looks_like_icon_ligature" - ); -} - -#[test] -fn capture_pipeline_extracts_all_four_bodies() { - let snap = load_fixture(); - let report = report_from_snapshot(&snap); - assert!( - report.rows_with_body >= 4, - "all four tiers should produce non-empty bodies; got rows_with_body={}", - report.rows_with_body - ); - assert_eq!( - report.rows_dropped_no_body, 0, - "no rows should be dropped when fixture contains body text in every row" - ); -} diff --git a/app/src-tauri/src/whatsapp_scanner/idb.rs b/app/src-tauri/src/whatsapp_scanner/idb.rs deleted file mode 100644 index 9b8388ab7e..0000000000 --- a/app/src-tauri/src/whatsapp_scanner/idb.rs +++ /dev/null @@ -1,461 +0,0 @@ -//! WhatsApp IndexedDB walk driven via the CDP `IndexedDB` domain. -//! -//! Replaces the old `scanner.js` in-page walk with pure CDP calls: -//! * `IndexedDB.requestData` pages through each object store at the -//! browser's C++ layer (no page-world JS needed to list rows). -//! * `Runtime.callFunctionOn` with a fixed, WhatsApp-agnostic serializer -//! (`function(){return [this].concat(arguments);}`) converts the -//! resulting `Runtime.RemoteObject`s into JSON via `returnByValue`. -//! -//! The serializer is the only JS that executes in the page context. It is -//! structural — it can't read anything the page doesn't already hold — and -//! runs once per batch of ~100 records, not once per scan cycle. Records -//! are normalised in Rust (see `normalize_message` / `normalize_chat`). - -use std::collections::{HashMap, HashSet}; - -use serde_json::{json, Value}; - -use crate::cdp::CdpConn; - -/// Only database that carries the chat + message stores. Discovered -/// empirically — a full `Target.getTargets` + `storeMap` dump (now removed) -/// showed every interesting store lives under `model-storage`. -const DATABASE_NAME: &str = "model-storage"; -const MESSAGE_STORE: &str = "message"; -const CHAT_STORE: &str = "chat"; -const CONTACT_STORE: &str = "contact"; -const GROUP_META_STORE: &str = "group-metadata"; - -/// Row window size per `IndexedDB.requestData` call. 500 keeps individual -/// CDP responses well under a megabyte while amortising request overhead. -const PAGE_SIZE: i64 = 500; -/// Hard cap per store. Mirrors the old JS limit so the full-scan cost -/// stays bounded on accounts with huge histories. -const MAX_RECORDS_PER_STORE: usize = 20_000; -/// How many RemoteObjects to materialise in one `Runtime.callFunctionOn` -/// batch. 100 keeps request argument counts reasonable and response bodies -/// in the low-MB range even for fat message records. -const SERIALIZE_BATCH: usize = 100; - -/// Normalised message record — same shape the old `scanner.js` emitted so -/// the downstream merge / emit pipeline doesn't need to change. Bodies are -/// intentionally omitted: WhatsApp stores message text encrypted in IDB, -/// plaintext comes from the DOM snapshot path and is merged in by id. -#[derive(Debug, Clone, Default)] -pub struct IdbMessage { - pub id: String, - pub chat_id: String, - pub from_me: bool, - /// "me" for self-sent; otherwise the author/from JID. - pub from: Option, - pub to: Option, - pub type_: Option, - pub timestamp: Option, -} - -impl IdbMessage { - pub fn to_json(&self) -> Value { - json!({ - "id": self.id, - "chatId": self.chat_id, - "fromMe": self.from_me, - "from": self.from, - "to": self.to, - "type": self.type_, - "timestamp": self.timestamp, - // `body` deliberately absent — populated later by the DOM merge. - "body": Value::Null, - }) - } -} - -/// Walk the WhatsApp IDB via CDP. Returns `(messages, chatNames)` where -/// `chatNames` is a `jid → display-name` map built from the chat, contact -/// and group-metadata stores. Per-store failures are logged and swallowed -/// so one unreadable store doesn't nuke the whole cycle. -pub async fn walk( - cdp: &mut CdpConn, - session: &str, - url_prefix: &str, -) -> Result<(Vec, HashMap), String> { - let origin = origin_from_url(url_prefix) - .ok_or_else(|| format!("cannot derive origin from {url_prefix}"))?; - - // `IndexedDB.enable` isn't strictly required for `requestData` on modern - // Chromium but older CEF builds refuse without it. Cost is trivial. - if let Err(e) = cdp.call("IndexedDB.enable", json!({}), Some(session)).await { - log::debug!("[wa][idb] enable: {}", e); - } - - let mut messages: Vec = Vec::new(); - let mut chat_names: HashMap = HashMap::new(); - let mut seen_ids: HashSet = HashSet::new(); - - // Messages store → IdbMessage list, deduped by id. - match read_store(cdp, session, &origin, MESSAGE_STORE).await { - Ok(rows) => { - for raw in &rows { - if let Some(m) = normalize_message(raw) { - if seen_ids.insert(m.id.clone()) { - messages.push(m); - } - } - } - } - Err(e) => log::warn!("[wa][idb] read {} failed: {}", MESSAGE_STORE, e), - } - - // Chat / contact / group-metadata stores → jid → name lookup. Last - // write wins; the stores have disjoint id spaces in practice (contacts - // use phone JIDs, groups use @g.us). - for store in [CHAT_STORE, CONTACT_STORE, GROUP_META_STORE] { - match read_store(cdp, session, &origin, store).await { - Ok(rows) => { - for raw in &rows { - let norm = if store == CONTACT_STORE { - normalize_contact(raw) - } else { - normalize_chat(raw) - }; - if let Some((id, name)) = norm { - chat_names.insert(id, name); - } - } - } - Err(e) => log::warn!("[wa][idb] read {} failed: {}", store, e), - } - } - - Ok((messages, chat_names)) -} - -// ─── CDP plumbing ─────────────────────────────────────────────────── - -/// Page through `objectStoreName` via `IndexedDB.requestData`, materialising -/// each value RemoteObject into JSON (via `serialize_values`). Stops at -/// `MAX_RECORDS_PER_STORE` or when `hasMore: false`. -async fn read_store( - cdp: &mut CdpConn, - session: &str, - origin: &str, - store: &str, -) -> Result, String> { - let mut out: Vec = Vec::new(); - let mut skip: i64 = 0; - loop { - let remaining = MAX_RECORDS_PER_STORE.saturating_sub(out.len()); - if remaining == 0 { - break; - } - let page = (remaining as i64).min(PAGE_SIZE); - // NB: `indexName` is deliberately omitted — passing an empty - // string makes this CEF build reject the request with - // "Could not get index". The CDP spec says empty string means - // "primary key index", but the C++ backend here only accepts an - // unset field. Confirmed against CEF 146 (Chrome 146.0.7680.165). - // Same fix as `slack_scanner/idb.rs` and `telegram_scanner/idb.rs`. - let resp = cdp - .call( - "IndexedDB.requestData", - json!({ - "securityOrigin": origin, - "databaseName": DATABASE_NAME, - "objectStoreName": store, - "skipCount": skip, - "pageSize": page, - }), - Some(session), - ) - .await?; - let entries = resp - .get("objectStoreDataEntries") - .and_then(|x| x.as_array()) - .cloned() - .unwrap_or_default(); - if entries.is_empty() { - break; - } - let value_refs: Vec<&Value> = entries - .iter() - .map(|e| e.get("value").unwrap_or(&Value::Null)) - .collect(); - let materialised = serialize_values(cdp, session, &value_refs).await?; - out.extend(materialised); - - let has_more = resp - .get("hasMore") - .and_then(|x| x.as_bool()) - .unwrap_or(false); - skip += entries.len() as i64; - if !has_more { - break; - } - } - log::debug!( - "[wa][idb] store={} records={} (capped at {})", - store, - out.len(), - MAX_RECORDS_PER_STORE - ); - Ok(out) -} - -/// Convert a list of `Runtime.RemoteObject` references (as returned inside -/// `ObjectStoreDataEntry.value`) into JSON. Primitives are read off the -/// RemoteObject's inline `value` field directly; complex objects are batched -/// through `Runtime.callFunctionOn` with a generic serializer. -async fn serialize_values( - cdp: &mut CdpConn, - session: &str, - values: &[&Value], -) -> Result, String> { - // Pre-split: inline primitives vs. objectIds that need serialization. - // Keep positions so we can re-assemble in the original order. - let mut result: Vec = vec![Value::Null; values.len()]; - let mut pending: Vec<(usize, String)> = Vec::new(); - for (i, v) in values.iter().enumerate() { - // RemoteObject primitives carry their value inline. - if let Some(inline) = v.get("value") { - result[i] = inline.clone(); - continue; - } - if let Some(oid) = v.get("objectId").and_then(|x| x.as_str()) { - pending.push((i, oid.to_string())); - continue; - } - // Unserialisable RemoteObjects (e.g. `NaN`/`Infinity`) or ones - // without an objectId get null — nothing downstream can use them. - } - for chunk in pending.chunks(SERIALIZE_BATCH) { - let oids: Vec<&str> = chunk.iter().map(|(_, oid)| oid.as_str()).collect(); - let serialised = call_function_batch(cdp, session, &oids).await?; - if serialised.len() != chunk.len() { - return Err(format!( - "serialise batch length mismatch: got {}, expected {}", - serialised.len(), - chunk.len() - )); - } - for ((idx, _), val) in chunk.iter().zip(serialised) { - result[*idx] = val; - } - } - Ok(result) -} - -/// Single `Runtime.callFunctionOn` invocation that materialises up to -/// `SERIALIZE_BATCH` RemoteObjects to JSON. The function body is fixed and -/// WhatsApp-agnostic — it just returns `[this, ...arguments]`. Uses the -/// first objectId as `this` (needed so Chromium knows which execution -/// context the call targets) and passes the rest as arguments. -async fn call_function_batch( - cdp: &mut CdpConn, - session: &str, - object_ids: &[&str], -) -> Result, String> { - if object_ids.is_empty() { - return Ok(Vec::new()); - } - let (first, rest) = object_ids.split_first().unwrap(); - let args: Vec = rest.iter().map(|oid| json!({ "objectId": oid })).collect(); - let resp = cdp - .call( - "Runtime.callFunctionOn", - json!({ - "objectId": first, - "functionDeclaration": "function(){return [this].concat(Array.prototype.slice.call(arguments));}", - "arguments": args, - "returnByValue": true, - "silent": true, - }), - Some(session), - ) - .await?; - if let Some(exc) = resp.get("exceptionDetails") { - return Err(format!("callFunctionOn threw: {exc}")); - } - let arr = resp - .pointer("/result/value") - .and_then(|v| v.as_array()) - .cloned() - .ok_or_else(|| format!("callFunctionOn result not array: {resp}"))?; - Ok(arr) -} - -/// Parse `https://web.whatsapp.com/some/path` → `https://web.whatsapp.com`. -/// Returns `None` on URLs missing a scheme/host. -fn origin_from_url(u: &str) -> Option { - let (scheme, rest) = u.split_once("://")?; - let host = rest.split('/').next()?; - if scheme.is_empty() || host.is_empty() { - return None; - } - Some(format!("{scheme}://{host}")) -} - -// ─── normalisation ────────────────────────────────────────────────── - -/// WhatsApp's id fields take many shapes: -/// `"user@c.us"`, -/// `{_serialized: "user@c.us", …}`, -/// `{id: {_serialized: "..."}}`, -/// `{remote: {_serialized: "..."}}`. -/// Return the canonical JID string or None. -fn normalize_id(v: &Value) -> Option { - if v.is_null() { - return None; - } - if let Some(s) = v.as_str() { - return if s.is_empty() { - None - } else { - Some(s.to_string()) - }; - } - let obj = v.as_object()?; - let str_of = |k: &str, src: &serde_json::Map| -> Option { - src.get(k) - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - }; - if let Some(s) = str_of("_serialized", obj) { - return Some(s); - } - if let Some(id) = obj.get("id") { - if let Some(m) = id.as_object() { - if let Some(s) = str_of("_serialized", m) { - return Some(s); - } - } - if let Some(s) = id.as_str() { - if !s.is_empty() { - return Some(s.to_string()); - } - } - } - if let Some(remote) = obj.get("remote") { - if let Some(m) = remote.as_object() { - if let Some(s) = str_of("_serialized", m) { - return Some(s); - } - } - if let Some(s) = remote.as_str() { - if !s.is_empty() { - return Some(s.to_string()); - } - } - } - None -} - -fn normalize_message(raw: &Value) -> Option { - let obj = raw.as_object()?; - let id = obj - .get("id") - .and_then(normalize_id) - .or_else(|| obj.get("_id").and_then(normalize_id)) - .or_else(|| obj.get("key").and_then(normalize_id))?; - let from_jid = obj - .get("from") - .and_then(normalize_id) - .or_else(|| obj.get("remoteJid").and_then(normalize_id)); - let to_jid = obj.get("to").and_then(normalize_id); - let author = obj - .get("author") - .and_then(normalize_id) - .or_else(|| obj.get("participant").and_then(normalize_id)); - let chat_id = obj - .get("chatId") - .and_then(normalize_id) - .or_else(|| obj.get("remote").and_then(normalize_id)) - .or_else(|| from_jid.clone()) - .or_else(|| to_jid.clone())?; - let from_me = obj.get("fromMe").and_then(|v| v.as_bool()).unwrap_or(false) - || obj - .get("isSentByMe") - .and_then(|v| v.as_bool()) - .unwrap_or(false) - || obj - .get("isFromMe") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let timestamp = obj - .get("t") - .and_then(|v| v.as_i64()) - .or_else(|| obj.get("timestamp").and_then(|v| v.as_i64())) - .or_else(|| obj.get("messageTimestamp").and_then(|v| v.as_i64())); - // `type` is usually the WA enum string; for raw-envelope records it - // falls back to the first key of the `message` object (e.g. - // `conversation`, `imageMessage`). - let type_ = obj - .get("type") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(String::from) - .or_else(|| { - obj.get("message") - .and_then(|m| m.as_object()) - .and_then(|m| m.keys().next().cloned()) - }); - let from = if from_me { - Some("me".to_string()) - } else { - author.or_else(|| from_jid.clone()) - }; - Some(IdbMessage { - id, - chat_id, - from_me, - from, - to: to_jid, - type_, - timestamp, - }) -} - -/// Chat / group records — `id` + first non-empty display name candidate. -fn normalize_chat(raw: &Value) -> Option<(String, String)> { - let obj = raw.as_object()?; - let id = obj - .get("id") - .and_then(normalize_id) - .or_else(|| obj.get("_id").and_then(normalize_id))?; - let name = first_non_empty_str(obj, &["name", "subject", "formattedTitle"]).or_else(|| { - obj.get("contact") - .and_then(|c| c.as_object()) - .and_then(|c| first_non_empty_str(c, &["name", "pushname"])) - })?; - Some((id, name)) -} - -/// Contact records — different name priority from chat records (contacts -/// carry `notify`/`pushname`/`verifiedName` in addition to the usual). -fn normalize_contact(raw: &Value) -> Option<(String, String)> { - let obj = raw.as_object()?; - let id = obj - .get("id") - .and_then(normalize_id) - .or_else(|| obj.get("_id").and_then(normalize_id))?; - let name = first_non_empty_str( - obj, - &["name", "notify", "shortName", "pushname", "verifiedName"], - )?; - Some((id, name)) -} - -fn first_non_empty_str(obj: &serde_json::Map, keys: &[&str]) -> Option { - for k in keys { - if let Some(s) = obj.get(*k).and_then(|v| v.as_str()) { - if !s.is_empty() { - return Some(s.to_string()); - } - } - } - None -} - -#[cfg(test)] -#[path = "idb_tests.rs"] -mod tests; diff --git a/app/src-tauri/src/whatsapp_scanner/idb_tests.rs b/app/src-tauri/src/whatsapp_scanner/idb_tests.rs deleted file mode 100644 index a95535eacf..0000000000 --- a/app/src-tauri/src/whatsapp_scanner/idb_tests.rs +++ /dev/null @@ -1,152 +0,0 @@ -use super::*; - -#[test] -fn origin_strips_path() { - assert_eq!( - origin_from_url("https://web.whatsapp.com/").as_deref(), - Some("https://web.whatsapp.com") - ); - assert_eq!( - origin_from_url("https://web.whatsapp.com").as_deref(), - Some("https://web.whatsapp.com") - ); - assert_eq!( - origin_from_url("https://web.whatsapp.com/accounts/42").as_deref(), - Some("https://web.whatsapp.com") - ); -} - -#[test] -fn origin_rejects_malformed() { - assert!(origin_from_url("web.whatsapp.com").is_none()); - assert!(origin_from_url("://nohost").is_none()); -} - -#[test] -fn normalize_id_handles_shapes() { - // Plain string - assert_eq!(normalize_id(&json!("me@c.us")).as_deref(), Some("me@c.us")); - // _serialized - assert_eq!( - normalize_id(&json!({"_serialized": "a@c.us", "user": "a"})).as_deref(), - Some("a@c.us") - ); - // nested id._serialized - assert_eq!( - normalize_id(&json!({"id": {"_serialized": "g@g.us"}})).as_deref(), - Some("g@g.us") - ); - // id as string - assert_eq!( - normalize_id(&json!({"id": "x@c.us"})).as_deref(), - Some("x@c.us") - ); - // remote object - assert_eq!( - normalize_id(&json!({"remote": {"_serialized": "r@c.us"}})).as_deref(), - Some("r@c.us") - ); - // null / missing - assert!(normalize_id(&json!(null)).is_none()); - assert!(normalize_id(&json!({})).is_none()); - assert!(normalize_id(&json!("")).is_none()); -} - -#[test] -fn normalize_message_extracts_core_fields() { - let raw = json!({ - "id": {"_serialized": "false_chat@c.us_MSG1", "fromMe": false}, - "from": "chat@c.us", - "to": "me@c.us", - "fromMe": false, - "t": 1_700_000_000i64, - "type": "chat", - }); - let m = normalize_message(&raw).unwrap(); - assert_eq!(m.id, "false_chat@c.us_MSG1"); - assert_eq!(m.chat_id, "chat@c.us"); - assert_eq!(m.from.as_deref(), Some("chat@c.us")); - assert_eq!(m.to.as_deref(), Some("me@c.us")); - assert!(!m.from_me); - assert_eq!(m.timestamp, Some(1_700_000_000)); - assert_eq!(m.type_.as_deref(), Some("chat")); -} - -#[test] -fn normalize_message_sets_from_to_me_when_self_sent() { - let raw = json!({ - "id": "id-1", - "chatId": "chat@c.us", - "fromMe": true, - }); - let m = normalize_message(&raw).unwrap(); - assert_eq!(m.from.as_deref(), Some("me")); - assert!(m.from_me); -} - -#[test] -fn normalize_message_envelope_type_falls_back_to_first_key() { - let raw = json!({ - "id": "id-2", - "chatId": "chat@c.us", - "message": {"imageMessage": {"url": "..."}}, - }); - let m = normalize_message(&raw).unwrap(); - assert_eq!(m.type_.as_deref(), Some("imageMessage")); -} - -#[test] -fn normalize_chat_pulls_display_name() { - let raw = json!({ - "id": "chat@c.us", - "name": "Chat Display", - }); - assert_eq!( - normalize_chat(&raw), - Some(("chat@c.us".to_string(), "Chat Display".to_string())) - ); -} - -#[test] -fn normalize_chat_falls_back_to_contact_pushname() { - let raw = json!({ - "id": "chat@c.us", - "contact": {"pushname": "Pushed"}, - }); - assert_eq!( - normalize_chat(&raw), - Some(("chat@c.us".to_string(), "Pushed".to_string())) - ); -} - -#[test] -fn normalize_contact_prefers_name_then_notify() { - assert_eq!( - normalize_contact(&json!({"id": "c@c.us", "name": "Real"})), - Some(("c@c.us".to_string(), "Real".to_string())) - ); - assert_eq!( - normalize_contact(&json!({"id": "c@c.us", "notify": "Notify"})), - Some(("c@c.us".to_string(), "Notify".to_string())) - ); -} - -#[test] -fn requestdata_params_omit_index_name() { - // Regression guard for Bug 1: passing `indexName: ""` to - // `IndexedDB.requestData` makes CEF 146 reject the call with - // "Could not get index". The field must be omitted entirely. - // Same constraint observed in slack_scanner/idb.rs:210-214 and - // telegram_scanner/idb.rs:210. - let params = json!({ - "securityOrigin": "https://web.whatsapp.com", - "databaseName": "model-storage", - "objectStoreName": "message", - "skipCount": 0i64, - "pageSize": 500i64, - }); - assert!( - params.get("indexName").is_none(), - "indexName must be omitted entirely - passing empty string is rejected by CEF 146 with 'Could not get index' (see slack_scanner/idb.rs:210-214)" - ); -} diff --git a/app/src-tauri/src/whatsapp_scanner/mod.rs b/app/src-tauri/src/whatsapp_scanner/mod.rs deleted file mode 100644 index b7dec57a36..0000000000 --- a/app/src-tauri/src/whatsapp_scanner/mod.rs +++ /dev/null @@ -1,1294 +0,0 @@ -//! WhatsApp Web scanner driven over the Chrome DevTools Protocol (CDP). -//! -//! Attaches to the embedded CEF webview via the in-process CDP transport -//! installed by `webview_accounts::open` (no TCP listener). Per tracked -//! WhatsApp-account webview, two interleaved loops run: -//! -//! * **Fast tick** (`FAST_SCAN_INTERVAL`, 2s) — `dom_scan.js` scrapes -//! rendered `[data-id]` message rows from the DOM. Emits only when -//! the visible-set hash changes so idle windows stay silent. -//! * **Full tick** (`FULL_SCAN_INTERVAL`, 30s) — `scanner.js` walks -//! WhatsApp's IndexedDB stores (model-storage, signal-storage, …) to -//! pull message metadata, chat names, contact names. -//! -//! Each scan groups messages by `(chatId, day)` and posts one -//! `openhuman.memory_doc_ingest` JSON-RPC call per group to the core, so -//! each day of a conversation upserts a single memory doc. We also emit -//! `webview:event` ingest events so any React UI listening can update -//! live when the main window is open. -//! -//! NOTE: only meaningful with the `cef` feature — the wry runtime does -//! not expose a remote debugging port. Compile-gated at the call site. - -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use parking_lot::Mutex; -use serde_json::{json, Value}; -use tauri::{AppHandle, Emitter, Runtime}; -use tokio::task::AbortHandle; -use tokio::time::sleep; - -mod dom_snapshot; -#[cfg(test)] -mod dom_snapshot_tests; -mod idb; - -/// Cadence for the expensive full scan — pages the whole IDB via CDP and -/// captures a fresh DOM snapshot. Each pass serialises thousands of -/// message records, so we pay this cost infrequently. -const FULL_SCAN_INTERVAL: Duration = Duration::from_secs(30); -/// Cadence for the cheap fast scan (DOM `[data-id]` scrape only). Runs at -/// Franz-like 2s so the ingest stream feels live — each tick captures the -/// DOM via `DOMSnapshot.captureSnapshot` (pure CDP, no page-world JS). -const FAST_SCAN_INTERVAL: Duration = Duration::from_secs(2); - -/// Product of one full scan — IDB walk (via `idb::walk`) joined with a -/// DOM snapshot (via `dom_snapshot::capture_messages`). `messages` carries -/// IDB-sourced metadata only; DOM-sourced bodies are merged in by id at -/// emit time (see `emit_snapshot`). -#[derive(Debug, Clone, Default)] -pub struct ScanSnapshot { - pub ok: bool, - pub error: Option, - /// `jid → display name`, drawn from chat/contact/group-metadata stores. - pub chats: serde_json::Map, - /// Normalised message metadata (no bodies — see note above). - pub messages: Vec, - /// DOM-scraped rendered bodies; merged into `messages` by id. - pub dom_messages: Vec, - /// Active chat's display name parsed from - /// `header[data-testid="conversation-header"]`. Used by the merge step - /// to reverse-look-up `chatId` for DOM rows that lack one (modern - /// WhatsApp Web doesn't expose chat JID anywhere on the message rows). - pub active_chat_name: Option, - /// Per-stage telemetry from the DOM-capture pass (issue #1376). `None` - /// when the DOM call failed entirely (e.g. CDP disconnect mid-scan); - /// otherwise carries the same `rows` as `dom_messages` plus counters - /// disambiguating "0 rows" from "rows with no body". - pub capture_report: Option, -} - -/// Spawn a per-account CDP poller. Idempotent at call site (caller tracks -/// account → JoinHandle if it cares about cancellation). -/// -/// The scanner runs two interleaved loops: -/// * **Fast tick** (`FAST_SCAN_INTERVAL`, 2s) — cheap DOM scrape. Only -/// emits an ingest event when the visible-row hash changes, so idle -/// windows don't spam the UI. -/// * **Full tick** (`FULL_SCAN_INTERVAL`, 30s) — the expensive IDB walk -/// + spy/keystore snapshot. Always emits. -/// -/// Both ticks share the same `webview:event` ingest envelope so downstream -/// consumers don't need to care which one produced the event. -pub fn spawn_scanner( - app: AppHandle, - account_id: String, - url_prefix: String, -) -> Vec { - let task = tokio::spawn(async move { - let fragment = crate::cdp::target_url_fragment(&account_id); - log::info!( - "[wa] scanner up account={} url_prefix={} fragment={} fast={:?} full={:?}", - account_id, - url_prefix, - fragment, - FAST_SCAN_INTERVAL, - FULL_SCAN_INTERVAL - ); - // Wait a moment for the page to actually load + log in. We'd rather - // miss the first cycle than thrash the CDP endpoint while the - // target isn't even there yet. - sleep(Duration::from_secs(5)).await; - let mut last_dom_hash: Option = None; - let mut last_full: Instant = Instant::now() - .checked_sub(FULL_SCAN_INTERVAL) - .unwrap_or_else(Instant::now); - loop { - // Gate: run a full IDB scan if enough time has elapsed, - // otherwise run the cheap DOM-only scan. - let do_full = last_full.elapsed() >= FULL_SCAN_INTERVAL; - if !do_full { - match scan_dom_once(&app, &account_id, &url_prefix, &fragment).await { - Ok(dom) => { - let changed = - last_dom_hash != Some(dom.hash) && !dom.dom_messages.is_empty(); - if changed { - log::info!( - "[wa][{}] fast dom-scan rows={} hash={} (changed)", - account_id, - dom.dom_messages.len(), - dom.hash - ); - emit_dom_only(&app, &account_id, &dom.dom_messages); - last_dom_hash = Some(dom.hash); - } - } - Err(e) => { - log::debug!("[wa][{}] dom-scan failed: {}", account_id, e); - } - } - sleep(FAST_SCAN_INTERVAL).await; - continue; - } - last_full = Instant::now(); - match scan_once(&app, &account_id, &url_prefix, &fragment).await { - Ok(snap) => { - // Per-stage telemetry from the DOM capture (#1376) — - // disambiguates "no rows seen" from "rows seen but body - // extraction returned empty" from "active chat header - // unresolved (downstream filter will drop the rows)". - let (seen, with_body, no_body, chat_resolved) = match &snap.capture_report { - Some(r) => ( - r.rows_seen, - r.rows_with_body, - r.rows_dropped_no_body, - r.active_chat_resolved, - ), - None => (0, 0, 0, false), - }; - log::info!( - "[wa][{}] full scan ok messages={} chats={} dom={} (seen={} with_body={} no_body={} chat_resolved={})", - account_id, - snap.messages.len(), - snap.chats.len(), - snap.dom_messages.len(), - seen, - with_body, - no_body, - chat_resolved, - ); - // Preview a few DOM-scraped rows so it's obvious from the - // log whether the active chat produced fresh bodies. - for (i, dm) in snap.dom_messages.iter().take(5).enumerate() { - let chat = dm.get("chatId").and_then(|v| v.as_str()).unwrap_or("?"); - let msg = dm.get("msgId").and_then(|v| v.as_str()).unwrap_or("?"); - let from_me = dm.get("fromMe").and_then(|v| v.as_bool()).unwrap_or(false); - let author = dm.get("author").and_then(|v| v.as_str()).unwrap_or(""); - let ts = dm - .get("preTimestamp") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let body = dm.get("body").and_then(|v| v.as_str()).unwrap_or(""); - let preview: String = body.chars().take(120).collect(); - log::info!( - "[wa][{}] dom#{} chat={} msg={} fromMe={} [{}] {}: {:?}", - account_id, - i + 1, - chat, - msg, - from_me, - ts, - author, - preview - ); - } - // TRACE-level structured row dump (first 3 rows, ≤120 char - // snippets) so a developer chasing #1376-style "dom=0 but - // bodies exist" can see exactly what the parser produced - // without re-instrumenting. Truncation lives in - // `dom_snapshot::text_snippet_preview` to honor the - // CLAUDE.md "no PII in trace dumps" rule. - if let Some(report) = snap.capture_report.as_ref() { - for (i, row) in report.rows.iter().take(3).enumerate() { - let pairs = dom_snapshot::text_snippet_preview(row, 120); - for (k, v) in &pairs { - log::trace!( - "[wa][{}] dom-trace#{} {}={:?}", - account_id, - i + 1, - k, - v - ); - } - } - } - emit_snapshot(&app, &account_id, &snap); - } - Err(e) => { - log::warn!("[wa][{}] scan failed: {}", account_id, e); - } - } - // After a full scan, go back to fast-tick cadence until the - // next `FULL_SCAN_INTERVAL` elapses. - sleep(FAST_SCAN_INTERVAL).await; - } - }); - vec![task.abort_handle()] -} - -/// Emit an ingest payload carrying only DOM-scraped rows, grouped by -/// (chatId, day) so React can upsert each day's transcript into memory. -fn emit_dom_only(app: &AppHandle, account_id: &str, dom: &[Value]) { - // Use the most recent contact-names snapshot from a full IDB scan so - // DOM-only rows get resolved display names too. - let names = contact_cache_get(account_id); - emit_grouped_whatsapp(app, account_id, dom, &names, "cdp-dom"); -} - -/// Per-account snapshot of `{jid -> display name}`. Populated on every -/// full IDB scan (from chats / contacts / group-metadata stores) and read -/// by fast DOM-only ticks so the transcript lines show names instead of -/// raw JIDs even when the scrape comes from the DOM. -fn contact_cache( -) -> &'static std::sync::Mutex>> { - use std::sync::OnceLock; - static CACHE: OnceLock< - std::sync::Mutex>>, - > = OnceLock::new(); - CACHE.get_or_init(|| std::sync::Mutex::new(Default::default())) -} - -fn contact_cache_put(account_id: &str, names: &serde_json::Map) { - if names.is_empty() { - return; - } - let mut g = contact_cache().lock().unwrap(); - g.insert(account_id.to_string(), names.clone()); -} - -fn contact_cache_get(account_id: &str) -> serde_json::Map { - let g = contact_cache().lock().unwrap(); - g.get(account_id).cloned().unwrap_or_default() -} - -fn chrono_now_millis() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) -} - -/// Normalize a chat display name for active-chat → JID matching (issue #1376). -/// Lowercases, strips every non-ASCII-alphanumeric code point (drops emoji, -/// punctuation, dashes, separators), and collapses internal whitespace runs -/// to nothing — leaves `[a-z0-9]*`. Lets the matcher find a match when the -/// DOM-parsed header text drifts slightly from the IDB-stored chat name -/// (extra spaces, trailing emoji, hyphenation differences). -pub(crate) fn normalize_chat_name(s: &str) -> String { - s.chars() - .filter_map(|c| { - if c.is_ascii_alphanumeric() { - Some(c.to_ascii_lowercase()) - } else { - None - } - }) - .collect() -} - -async fn scan_once( - app: &AppHandle, - account_id: &str, - url_prefix: &str, - url_fragment: &str, -) -> Result { - // One CDP attach per tick — we attach to the WhatsApp page session via - // the account's in-process CDP transport, run the IDB walk + DOM - // snapshot, then detach (which frees every RemoteObject the IDB walk - // materialised, so no per-object releases). - let url_prefix_owned = url_prefix.to_string(); - let url_fragment_owned = url_fragment.to_string(); - let pred = move |t: &crate::cdp::target::CdpTarget| -> bool { - t.url.starts_with(&url_prefix_owned) && t.url.ends_with(&url_fragment_owned) - }; - let (mut cdp, page_session) = - crate::cdp::target::connect_and_attach_matching_in_process::(app, account_id, pred) - .await - .map_err(|e| format!("attach: {e} (prefix={url_prefix} fragment={url_fragment})"))?; - - // IDB + DOM are independent — run IDB first (the heavier of the two) - // so a DOM failure doesn't mask IDB errors. Errors are captured on - // `snap.error` instead of bubbling so the caller can still act on - // whatever partial data came back. - let mut snap = ScanSnapshot { - ok: true, - ..Default::default() - }; - match idb::walk(&mut cdp, &page_session, url_prefix).await { - Ok((messages, chat_names)) => { - snap.messages = messages.iter().map(idb::IdbMessage::to_json).collect(); - snap.chats = chat_names - .into_iter() - .map(|(k, v)| (k, Value::String(v))) - .collect(); - } - Err(e) => { - snap.ok = false; - snap.error = Some(format!("idb walk: {e}")); - log::warn!("[wa][{}] idb walk failed: {}", account_id, e); - } - } - let mut capture_report: Option = None; - match dom_snapshot::capture_messages(&mut cdp, &page_session).await { - Ok(report) => { - snap.dom_messages = report - .rows - .iter() - .map(dom_snapshot::DomMessage::to_json) - .collect(); - snap.active_chat_name = report.active_chat_name.clone(); - capture_report = Some(report); - } - Err(e) => { - // Fast-tick DOM scans will retry every 2s, so degrade gracefully. - log::warn!("[wa][{}] dom snapshot failed: {}", account_id, e); - } - } - - let _ = cdp - .call( - "Target.detachFromTarget", - json!({ "sessionId": page_session }), - None, - ) - .await; - let _ = app; - snap.capture_report = capture_report; - Ok(snap) -} - -/// Result of a fast DOM-only scan. Small enough to bounce back every 2s. -#[derive(Debug, Default)] -pub struct DomScanResult { - pub dom_messages: Vec, - pub hash: u64, -} - -/// Fast tick: open a CDP session, attach to the WhatsApp page, snapshot -/// the DOM via `DOMSnapshot.captureSnapshot`, detach. No IDB, no worker -/// enumeration, no JavaScript runs in the page — the snapshot is produced -/// at the browser's C++ layer. The flat-array response is parsed in Rust -/// (see `dom_snapshot.rs`). -async fn scan_dom_once( - app: &AppHandle, - account_id: &str, - url_prefix: &str, - url_fragment: &str, -) -> Result { - let url_prefix_owned = url_prefix.to_string(); - let url_fragment_owned = url_fragment.to_string(); - let pred = move |t: &crate::cdp::target::CdpTarget| -> bool { - t.url.starts_with(&url_prefix_owned) && t.url.ends_with(&url_fragment_owned) - }; - let (mut cdp, page_session) = - crate::cdp::target::connect_and_attach_matching_in_process::(app, account_id, pred) - .await - .map_err(|e| format!("attach: {e} (prefix={url_prefix} fragment={url_fragment})"))?; - let captured = dom_snapshot::capture_messages(&mut cdp, &page_session).await; - // Detach no matter what — otherwise dangling sessions pile up on long - // runs and eventually the CDP endpoint refuses new attachments. - let _ = cdp - .call( - "Target.detachFromTarget", - json!({ "sessionId": page_session }), - None, - ) - .await; - let report = captured?; - let dom_messages: Vec = report - .rows - .iter() - .map(dom_snapshot::DomMessage::to_json) - .collect(); - log::debug!( - "[wa][{}] fast dom-scan rows={} hash={} (seen={} with_body={} no_body={} chat_resolved={})", - account_id, - dom_messages.len(), - report.hash, - report.rows_seen, - report.rows_with_body, - report.rows_dropped_no_body, - report.active_chat_resolved, - ); - Ok(DomScanResult { - dom_messages, - hash: report.hash, - }) -} - -/// Forward the snapshot to React via the same `webview:event` channel -/// recipe ingest already uses. UI code can listen for kind == "ingest". -fn emit_snapshot(app: &AppHandle, account_id: &str, snap: &ScanSnapshot) { - if !snap.ok { - log::warn!( - "[wa][{}] snapshot not ok (idb walk failed: {:?}) — falling through with dom-only data", - account_id, - snap.error - ); - // Fall through so DOM messages still reach the structured store. - } - // Resolve the active chat's JID from its display name (parsed from the - // conversation header). Modern WhatsApp Web doesn't put the chat JID - // anywhere on individual message rows or in the URL, so this is the - // only signal we have. The IDB-side `chats` map has `name → jid` (we - // store it as `jid → {name, …}`, so iterate). Match prefers exact - // case-sensitive equality and falls back to case-insensitive; ignore - // ambiguous matches (multiple chats with the same display name) so we - // don't mis-attribute messages. - let active_chat_jid: Option = snap.active_chat_name.as_deref().and_then(|name| { - let name_lc = name.to_ascii_lowercase(); - let name_norm = normalize_chat_name(name); - let mut exact: Vec<&str> = Vec::new(); - let mut ci: Vec<&str> = Vec::new(); - let mut normalized: Vec<&str> = Vec::new(); - let mut substring: Vec<&str> = Vec::new(); - for (jid, chat) in snap.chats.iter() { - let chat_name = chat.get("name").and_then(|v| v.as_str()).unwrap_or(""); - if chat_name == name { - exact.push(jid); - } else if !chat_name.is_empty() && chat_name.to_ascii_lowercase() == name_lc { - ci.push(jid); - } else if !chat_name.is_empty() { - // Normalized tier (issue #1376): strip non-alphanumeric + - // collapse whitespace + lowercase, then compare equality. - // Catches drift introduced by emoji, punctuation, or - // double-spaces between the DOM header text and the IDB - // chat name (e.g. group titles like "17-18-19 July samagam" - // vs "17 18 19 July samagam ✨"). Substring stays as a final - // fallback so loose matches are tried last. - let chat_norm = normalize_chat_name(chat_name); - if !name_norm.is_empty() && chat_norm == name_norm { - normalized.push(jid); - } else if chat_name.to_ascii_lowercase().contains(&name_lc) - || name_lc.contains(&chat_name.to_ascii_lowercase()) - { - substring.push(jid); - } - } - } - // Prefer exact > case-insensitive > normalized > substring. Each - // tier only wins when it has exactly one candidate (avoids - // cross-attribution when many chats share a token like a common - // first name). Tier counts feed into the warn log so ambiguous - // resolutions are visible in the scanner output. - match (exact.len(), ci.len(), normalized.len(), substring.len()) { - (1, _, _, _) => Some(exact[0].to_string()), - (0, 1, _, _) => Some(ci[0].to_string()), - (0, 0, 1, _) => Some(normalized[0].to_string()), - (0, 0, 0, 1) => Some(substring[0].to_string()), - (0, 0, 0, 0) => { - // No IDB chat matches the active-chat header — happens for - // 1:1 chats where IDB stores the peer JID but the - // human-readable contact name lives in the device address - // book (never reaches IDB). Synthesize a stable - // `dom:` backfill key so DOM rows still - // persist instead of being filtered at the structured-store - // empty-`chat_id` guard. Distinct from real WhatsApp JIDs - // (which always contain `@`), so downstream consumers can - // tell DOM-only chat ids apart. - let synth = normalize_chat_name(name); - if synth.is_empty() { - None - } else { - Some(format!("dom:{synth}")) - } - } - (e, c, n, s) => { - log::warn!( - "[whatsapp_scanner] ambiguous active-chat resolution: {} candidates for '{}' — skipping backfill", - e + c + n + s, - name - ); - None - } - } - }); - log::info!( - "[wa][{}] active chat resolution: name={:?} → jid={:?} chats_in_map={}", - account_id, - snap.active_chat_name, - active_chat_jid, - snap.chats.len() - ); - // Join DOM-scraped bodies into the messages list by msgId. WhatsApp - // caches decrypted bodies in memory, so IndexedDB gives us metadata and - // the DOM gives us text for currently-rendered chats — unioning them - // here gives downstream consumers a single message list. - // The merge logic lives in `merge_dom_into_snapshot` so it can be - // exercised independently in unit tests. - let (messages, patched, appended) = merge_dom_into_snapshot( - &snap.messages, - &snap.dom_messages, - active_chat_jid.as_deref(), - ); - if patched > 0 || appended > 0 { - log::info!( - "[wa][{}] dom-merge patched={} appended={} total={}", - account_id, - patched, - appended, - messages.len() - ); - } - // Cache the contact/chat name map so the next fast DOM-only tick can - // resolve sender JIDs → display names without re-walking IDB. - contact_cache_put(account_id, &snap.chats); - // Also emit one grouped `whatsapp` ingest event per (chatId, day) so - // the React listener can call `openhuman.memory_doc_ingest` with a - // stable namespace/key that upserts cleanly. - emit_grouped_whatsapp(app, account_id, &messages, &snap.chats, "cdp-indexeddb"); -} - -/// Parse a unix-seconds timestamp to a UTC `YYYY-MM-DD` string. Uses the -/// Howard Hinnant civil-from-days algorithm — no external deps. -fn seconds_to_ymd(secs: i64) -> String { - let days = secs.div_euclid(86_400); - let z = days + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = (z - era * 146_097) as u64; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; - let y_real = (if m <= 2 { y + 1 } else { y }) as i32; - format!("{:04}-{:02}-{:02}", y_real, m, d) -} - -/// Parse WA's `data-pre-plain-text` timestamp (e.g. `"4:53 AM, 7/5/2025"`) -/// to `YYYY-MM-DD`. Returns None if the format doesn't match. -fn parse_pre_timestamp_ymd(s: &str) -> Option { - // Everything after the first comma is the date: "4:53 AM, 7/5/2025" - let (_, date_part) = s.split_once(',')?; - let date_part = date_part.trim(); - let parts: Vec<&str> = date_part.split('/').collect(); - if parts.len() != 3 { - return None; - } - let m: u32 = parts[0].trim().parse().ok()?; - let d: u32 = parts[1].trim().parse().ok()?; - let y: i32 = parts[2].trim().parse().ok()?; - if !(1..=12).contains(&m) || !(1..=31).contains(&d) || !(1900..=3000).contains(&y) { - return None; - } - Some(format!("{:04}-{:02}-{:02}", y, m, d)) -} - -/// Group messages by (chatId, day) and emit one `webview:event` per group -/// matching the shape `persistWhatsappChatDay` (React) consumes. React in -/// turn calls `openhuman.memory_doc_ingest` to upsert each day's transcript -/// into the memory layer. -fn emit_grouped_whatsapp( - app: &AppHandle, - account_id: &str, - messages: &[Value], - chats: &serde_json::Map, - source: &str, -) { - use std::collections::HashMap; - let now_secs = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - - // Group: (chatId, day) -> Vec - let mut groups: HashMap<(String, String), Vec> = HashMap::new(); - for m in messages { - let chat_id = match m.get("chatId").and_then(|v| v.as_str()) { - Some(s) if !s.is_empty() => s.to_string(), - _ => continue, - }; - // Require body — memory docs without content are noise. - let body = m - .get("body") - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .unwrap_or_default(); - if body.is_empty() { - continue; - } - - // Derive day + canonical timestamp (seconds). - let (day, ts_secs): (String, i64) = - if let Some(t) = m.get("timestamp").and_then(|v| v.as_i64()) { - (seconds_to_ymd(t), t) - } else if let Some(pre) = m.get("preTimestamp").and_then(|v| v.as_str()) { - match parse_pre_timestamp_ymd(pre) { - Some(d) => (d, now_secs), - None => (seconds_to_ymd(now_secs), now_secs), - } - } else { - (seconds_to_ymd(now_secs), now_secs) - }; - - // React expects `fromMe`, `from`, `body`, `timestamp` (sec), `type`. - let from_me = m.get("fromMe").and_then(|v| v.as_bool()).unwrap_or(false); - let raw_from: Option = m - .get("from") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - // Prefer: chats[from].name → DOM `author` (parsed from data-pre-plain-text) - // → chats[chatId].name (1:1 chats where chatId == sender) - // → raw JID as last resort. - let author_from_dom = m - .get("author") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - let resolved_name: Option = raw_from - .as_ref() - .and_then(|jid| { - chats - .get(jid) - .and_then(|c| c.get("name")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - }) - .or(author_from_dom) - .or_else(|| { - chats - .get(&chat_id) - .and_then(|c| c.get("name")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - }); - // `from` field keeps the JID so downstream code can key by it; - // `fromName` carries the human-readable label for the transcript. - let from_value = raw_from - .clone() - .or_else(|| resolved_name.clone()) - .unwrap_or_default(); - let id = m - .get("id") - .cloned() - .or_else(|| m.get("dataId").cloned()) - .unwrap_or(Value::Null); - let type_ = m.get("type").cloned().unwrap_or(Value::Null); - let normalized = json!({ - "id": id, - "chatId": chat_id.clone(), - "fromMe": from_me, - "from": from_value, - "fromName": resolved_name, - "body": body, - "timestamp": ts_secs, - "type": type_, - }); - groups.entry((chat_id, day)).or_default().push(normalized); - } - - // Emit one event per (chatId, day). Match envelope shape React expects - // so when the main window IS open the UI updates live. In parallel we - // POST the same payload directly to the core RPC so the memory write - // happens regardless of whether the React listener is attached. - let mut emitted = 0usize; - for ((chat_id, day), msgs) in groups { - let chat_name = chats - .get(&chat_id) - .and_then(|c| c.get("name")) - .and_then(|v| v.as_str()) - .unwrap_or(&chat_id) - .to_string(); - let payload = json!({ - "provider": "whatsapp", - "source": source, - "chatId": chat_id, - "chatName": chat_name, - "day": day, - "messages": msgs, - }); - let envelope = json!({ - "account_id": account_id, - "provider": "whatsapp", - "kind": "ingest", - "payload": payload.clone(), - "ts": chrono_now_millis(), - }); - if let Err(e) = app.emit("webview:event", &envelope) { - log::warn!("[wa][{}] ingest emit failed: {}", account_id, e); - } else { - emitted += 1; - } - // Direct memory write via core RPC — fire-and-forget so the - // scanner tick doesn't block on HTTP. - let acct = account_id.to_string(); - tokio::spawn(async move { - if let Err(e) = post_memory_doc_ingest(&acct, &payload).await { - log::warn!("[wa][{}] memory write failed: {}", acct, e); - } - }); - } - if emitted > 0 { - log::info!( - "[wa][{}] emitted {} ingest group(s) source={}", - account_id, - emitted, - source - ); - } - - // Dual-write: also persist structured chat+message data via the - // dedicated whatsapp_data store. Fire-and-forget alongside the existing - // memory doc ingest path — does not affect scanner tick timing. - { - let acct = account_id.to_string(); - let chats_value = Value::Object(chats.clone()); - // Build normalized message array for the structured ingest. - // Handles both full IDB-scan shape (chatId, timestamp, from/fromName, - // type) and fast DOM-only rows (author, preTimestamp, dataId). - let msgs_for_ingest: Vec = messages - .iter() - .filter_map(|m| { - // Accept chatId from full-scan or chat/chat_id fallbacks on DOM rows. - let chat_id = m - .get("chatId") - .or_else(|| m.get("chat")) - .or_else(|| m.get("chat_id")) - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty())? - .to_string(); - let body = m - .get("body") - .and_then(|v| v.as_str()) - .map(|s| s.trim()) - .unwrap_or(""); - // Include non-text messages (stickers/images) so message_count - // and last_message_ts stay accurate. Empty body is allowed. - let msg_id = m - .get("id") - .cloned() - .or_else(|| m.get("dataId").cloned()) - .and_then(|v| v.as_str().map(|s| s.to_string())) - .unwrap_or_default(); - if msg_id.is_empty() { - return None; - } - // Resolve sender: full-scan uses fromName/from; DOM rows use author. - let sender = m - .get("fromName") - .cloned() - .or_else(|| m.get("from").cloned()) - .or_else(|| m.get("author").cloned()); - let sender_jid = m - .get("from") - .cloned() - .or_else(|| m.get("author").cloned()) - .or_else(|| m.get("participant").cloned()); - // Resolve timestamp: full-scan has numeric timestamp; - // DOM rows may carry a string preTimestamp that needs parsing. - let timestamp = m - .get("timestamp") - .and_then(|v| v.as_i64()) - .or_else(|| m.get("preTimestamp").and_then(|v| v.as_i64())) - .unwrap_or(0); - // Per-row source tag (issue #1376): rows that picked up their - // body from the DOM scrape get tagged `cdp-dom` so the - // structured store can distinguish DOM-sourced text from - // IDB-sourced metadata. `merge_dom_into_snapshot` stamps - // `bodySource = "dom"` (IDB row patched with DOM body) and - // `"dom-only"` (DOM row with no IDB peer); both cases fall - // back to `cdp-dom`. Everything else inherits the caller's - // tag (full-scan = `cdp-indexeddb`, fast-tick = `cdp-dom`). - let row_source = m - .get("bodySource") - .and_then(|v| v.as_str()) - .filter(|s| matches!(*s, "dom" | "dom-only")) - .map(|_| "cdp-dom") - .unwrap_or(source); - Some(json!({ - "message_id": msg_id, - "chat_id": chat_id, - "sender": sender.unwrap_or(Value::Null), - "sender_jid": sender_jid.unwrap_or(Value::Null), - "from_me": m.get("fromMe").and_then(|v| v.as_bool()).unwrap_or(false), - "body": body, - "timestamp": timestamp, - "message_type": m.get("type").cloned().unwrap_or(Value::Null), - "source": row_source, - })) - }) - .collect(); - let src = source.to_string(); - tokio::spawn(async move { - if let Err(e) = - post_whatsapp_data_ingest(&acct, &chats_value, &msgs_for_ingest, &src).await - { - log::warn!( - "[wa][{}] whatsapp_data structured ingest failed: {}", - acct, - e - ); - } - }); - } -} - -/// Build the JSON-RPC `params` object for `openhuman.memory_doc_ingest` -/// from a single (chatId, day) ingest payload. Extracted as a pure -/// function so it can be tested independently of the HTTP layer. -/// -/// Returns `None` when the payload is missing required fields (chatId, day, -/// or a non-empty messages array) — callers should skip the HTTP call. -fn build_doc_ingest_params(account_id: &str, ingest: &Value) -> Option { - let chat_id = ingest - .get("chatId") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - let day = ingest - .get("day") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - let chat_name = ingest - .get("chatName") - .and_then(|v| v.as_str()) - .unwrap_or(chat_id); - let empty: Vec = Vec::new(); - let msgs: &Vec = ingest - .get("messages") - .and_then(|v| v.as_array()) - .unwrap_or(&empty); - if chat_id.is_empty() || day.is_empty() || msgs.is_empty() { - return None; - } - - // Build a stable transcript — sorted by timestamp, one line per msg. - let mut sorted: Vec<&Value> = msgs.iter().collect(); - sorted.sort_by_key(|m| m.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0)); - let transcript: String = sorted - .iter() - .map(|m| { - let ts = m.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0); - let hhmm = if ts > 0 { - let secs_of_day = (ts.rem_euclid(86_400)) as u32; - format!("{:02}:{:02}Z", secs_of_day / 3600, (secs_of_day / 60) % 60) - } else { - "--:--".to_string() - }; - let who = if m.get("fromMe").and_then(|v| v.as_bool()).unwrap_or(false) { - "me".to_string() - } else { - // Prefer the resolved display name; fall back to raw JID - // (the "from" field), then "?". - m.get("fromName") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .or_else(|| m.get("from").and_then(|v| v.as_str())) - .filter(|s| !s.is_empty()) - .unwrap_or("?") - .to_string() - }; - let body = m - .get("body") - .and_then(|v| v.as_str()) - .unwrap_or("") - .replace(['\r', '\n'], " "); - let type_ = m - .get("type") - .and_then(|v| v.as_str()) - .filter(|t| *t != "chat" && !t.is_empty()) - .map(|t| format!(" [{t}]")) - .unwrap_or_default(); - format!("[{hhmm}] {who}{type_}: {body}") - }) - .collect::>() - .join("\n"); - - let header = format!( - "# WhatsApp — {chat_name} — {day}\nchat_id: {chat_id}\naccount_id: {account_id}\nmessages: {n}\n\n", - n = sorted.len() - ); - let content = format!("{header}{transcript}"); - - let namespace = format!("whatsapp-web:{account_id}"); - let key = format!("{chat_id}:{day}"); - let title = format!("WhatsApp · {chat_name} · {day}"); - - Some(json!({ - "namespace": namespace, - "key": key, - "title": title, - "content": content, - "source_type": "whatsapp-web", - "priority": "medium", - "tags": ["whatsapp", "chat-transcript", day], - "metadata": { - "provider": "whatsapp", - "account_id": account_id, - "chat_id": chat_id, - "chat_name": chat_name, - "day": day, - "message_count": sorted.len(), - }, - "category": "core", - })) -} - -/// Build the `openhuman.memory_doc_ingest` payload for a single -/// (chatId, day) group and POST it directly to the core. The shape -/// mirrors `persistWhatsappChatDay` on the React side so the memory docs -/// line up whether the scanner or the UI drove the ingest. -/// -/// Retries once (after 500ms) on connection errors so the scanner isn't -/// silently dropped when the core sidecar isn't ready yet at startup. -async fn post_memory_doc_ingest(account_id: &str, ingest: &Value) -> Result<(), String> { - let params = match build_doc_ingest_params(account_id, ingest) { - Some(p) => p, - None => return Ok(()), - }; - - // Extract namespace/key for the success log from the built params. - let namespace = params - .get("namespace") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let key = params - .get("key") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let msg_count = params - .get("metadata") - .and_then(|m| m.get("message_count")) - .and_then(|v| v.as_u64()) - .unwrap_or(0); - - let body = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "openhuman.memory_doc_ingest", - "params": params, - }); - - let url = crate::core_rpc::core_rpc_url_value(); - - // Retry up to 2 attempts with 500ms delay on connection errors (e.g. - // core sidecar not yet ready at scanner startup). HTTP-level errors - // (non-2xx responses, JSON-RPC errors) are not retried — they indicate - // a real problem rather than a startup race. - let mut last_err = String::new(); - for attempt in 1u8..=2 { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(15)) - .build() - .map_err(|e| format!("http client: {e}"))?; - let req = crate::core_rpc::apply_auth(client.post(&url)) - .map_err(|e| format!("prepare {url}: {e}"))?; - let send_result = req.json(&body).send().await; - match send_result { - Err(e) if e.is_connect() || e.is_timeout() => { - last_err = format!("POST {url}: {e}"); - if attempt < 2 { - log::debug!( - "[wa][{}] memory ingest connect error (attempt {}), retrying in 500ms: {}", - account_id, - attempt, - e - ); - sleep(Duration::from_millis(500)).await; - continue; - } - return Err(last_err); - } - Err(e) => return Err(format!("POST {url}: {e}")), - Ok(resp) => { - let status = resp.status(); - if !status.is_success() { - let body_text = resp.text().await.unwrap_or_default(); - return Err(format!("{status}: {body_text}")); - } - let v: Value = resp.json().await.map_err(|e| format!("decode: {e}"))?; - if let Some(err) = v.get("error") { - return Err(format!("rpc error: {err}")); - } - log::info!( - "[wa][{}] memory upsert ok namespace={} key={} msgs={}", - account_id, - namespace, - key, - msg_count - ); - return Ok(()); - } - } - } - Err(last_err) -} - -/// POST a structured `openhuman.whatsapp_data_ingest` payload to the core. -/// -/// This is the dual-write path alongside `post_memory_doc_ingest`. It -/// persists chats and messages into the dedicated `whatsapp_data.db` SQLite -/// store so the agent can query them via structured RPC tools. -async fn post_whatsapp_data_ingest( - account_id: &str, - chats: &Value, - messages: &[Value], - source: &str, -) -> Result<(), String> { - if messages.is_empty() && chats.as_object().map(|o| o.is_empty()).unwrap_or(true) { - return Ok(()); - } - - // Convert chats map values to {name: string|null} once, before batching. - // The scanner passes chats as either: - // - Value::String(display_name) — contact-cache format - // - Value::Object({name: ..., ...}) — full IDB scan format - let chats_param: serde_json::Map = chats - .as_object() - .map(|o| { - o.iter() - .map(|(jid, v)| { - let name = if let Some(s) = v.as_str() { - if s.is_empty() { - Value::Null - } else { - Value::String(s.to_string()) - } - } else { - v.get("name") - .and_then(|n| n.as_str()) - .filter(|s| !s.is_empty()) - .map(|s| Value::String(s.to_string())) - .unwrap_or(Value::Null) - }; - (jid.clone(), json!({ "name": name })) - }) - .collect() - }) - .unwrap_or_default(); - - // Split messages into chunks, preserving the historical batching/dedup - // behavior: chats are sent only with the first batch (upserts are - // idempotent), and the store's 90-day prune runs per ingest call. The - // store now lives in the shell, so each batch dispatches over the - // in-process native request bus instead of an HTTP JSON-RPC POST. - const BATCH_SIZE: usize = 500; - - // Build at least one batch even when messages is empty (chats-only upsert). - let chunks: Vec<&[Value]> = if messages.is_empty() { - vec![&[]] - } else { - messages.chunks(BATCH_SIZE).collect() - }; - - let total_batches = chunks.len(); - log::debug!( - "[wa][{}] whatsapp_data_ingest chats={} messages={} batches={} source={}", - account_id, - chats_param.len(), - messages.len(), - total_batches, - source - ); - - for (batch_idx, chunk) in chunks.iter().enumerate() { - let batch_chats = if batch_idx == 0 { - Value::Object(chats_param.clone()) - } else { - Value::Object(serde_json::Map::new()) - }; - let params = json!({ - "account_id": account_id, - "chats": batch_chats, - "messages": chunk, - }); - // Deserialize into the shared core DTO and dispatch to the shell store's - // native handler registered in `whatsapp_data::register_native_handlers`. - let req: openhuman_core::openhuman::channels::whatsapp_data::types::IngestRequest = - serde_json::from_value(params) - .map_err(|e| format!("build ingest request (batch {}): {e}", batch_idx + 1))?; - openhuman_core::core::bus::BUS.native().request::< - openhuman_core::openhuman::channels::whatsapp_data::types::IngestRequest, - openhuman_core::openhuman::channels::whatsapp_data::types::IngestResult, - >( - openhuman_core::openhuman::channels::whatsapp_data::methods::INGEST, - req, - ) - .await - .map_err(|e| { - format!( - "whatsapp_data ingest batch {}/{}: {e}", - batch_idx + 1, - total_batches - ) - })?; - } - - log::debug!( - "[wa][{}] whatsapp_data_ingest ok messages={} batches={}", - account_id, - messages.len(), - total_batches, - ); - Ok(()) -} - -/// Merge DOM-scraped rows into an IDB-sourced message list. -/// -/// Extracted from `emit_snapshot` so the merge logic can be tested -/// independently of the Tauri `AppHandle`. Behaviour: -/// -/// 1. Build an index of DOM rows keyed by both their full `dataId` and bare -/// `msgId` (the current WA Web format emits only the bare hex id). -/// 2. Patch IDB messages that have an empty `body` with the DOM row's body; -/// mark the DOM row as consumed. -/// 3. Append unmatched DOM rows that have a non-empty body, stamping -/// `chatId` from `active_chat_jid` when the row lacks one. -/// -/// Returns the merged message list along with patch/append counts for -/// diagnostic logging. -fn merge_dom_into_snapshot( - idb_messages: &[Value], - dom_messages: &[Value], - active_chat_jid: Option<&str>, -) -> (Vec, usize, usize) { - use std::collections::{HashMap, HashSet}; - - let mut messages = idb_messages.to_vec(); - - if dom_messages.is_empty() { - return (messages, 0, 0); - } - - // Index DOM rows by full dataId and bare msgId. - let mut by_msg_id: HashMap = HashMap::new(); - for dm in dom_messages { - let did = dm - .get("dataId") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - if did.is_empty() { - continue; - } - by_msg_id.insert(did.clone(), (did.clone(), dm.clone())); - if let Some(mid) = dm.get("msgId").and_then(|v| v.as_str()) { - by_msg_id - .entry(mid.to_string()) - .or_insert_with(|| (did.clone(), dm.clone())); - } - } - - let mut consumed: HashSet = HashSet::new(); - let mut patched = 0usize; - - for m in messages.iter_mut() { - let mid_opt = m.get("id").and_then(|v| v.as_str()).map(|s| s.to_string()); - let has_body = m - .get("body") - .and_then(|v| v.as_str()) - .map(|s| !s.is_empty()) - .unwrap_or(false); - if has_body { - continue; - } - if let Some(mid) = mid_opt { - let bare_mid = mid.rsplit('_').next().map(str::to_string); - let lookup = by_msg_id - .get(&mid) - .cloned() - .or_else(|| bare_mid.as_deref().and_then(|b| by_msg_id.get(b).cloned())); - if let Some((did, dm)) = lookup { - if consumed.contains(&did) { - continue; - } - if let Some(body) = dm.get("body").and_then(|v| v.as_str()) { - if let Some(obj) = m.as_object_mut() { - obj.insert("body".to_string(), json!(body)); - obj.insert("bodySource".to_string(), json!("dom")); - patched += 1; - consumed.insert(did); - } - } - } - } - } - - // Append unmatched DOM rows that have a body. - let mut appended = 0usize; - let mut appended_dids: HashSet = HashSet::new(); - for (_key, (did, dm)) in by_msg_id { - if consumed.contains(&did) || appended_dids.contains(&did) { - continue; - } - if dm - .get("body") - .and_then(|v| v.as_str()) - .map(|s| !s.is_empty()) - .unwrap_or(false) - { - let dom_chat_id = dm - .get("chatId") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(|s| Value::String(s.to_string())) - .or_else(|| active_chat_jid.map(|j| Value::String(j.to_string()))) - .unwrap_or(Value::Null); - messages.push(json!({ - "id": dm.get("dataId").cloned().unwrap_or(Value::Null), - "chatId": dom_chat_id, - "fromMe": dm.get("fromMe").cloned().unwrap_or(Value::Null), - "body": dm.get("body").cloned().unwrap_or(Value::Null), - "author": dm.get("author").cloned().unwrap_or(Value::Null), - "preTimestamp": dm.get("preTimestamp").cloned().unwrap_or(Value::Null), - "bodySource": "dom-only", - })); - appended += 1; - appended_dids.insert(did); - } - } - - (messages, patched, appended) -} - -/// Track which (account_id, provider) pairs we've already started a scanner -/// for. The webview lifecycle can call `ensure_scanner` repeatedly without -/// double-spawning. -#[derive(Default)] -pub struct ScannerRegistry { - started: Mutex>>, -} - -impl ScannerRegistry { - pub fn new() -> Arc { - Arc::new(Self::default()) - } - - pub fn ensure_scanner( - &self, - app: AppHandle, - account_id: String, - url_prefix: String, - ) { - let mut g = self.started.lock(); - if g.contains_key(&account_id) { - log::debug!("[wa] scanner already running for {}", account_id); - return; - } - let handles = spawn_scanner(app, account_id.clone(), url_prefix); - g.insert(account_id, handles); - } - - pub fn forget(&self, account_id: &str) { - let handles = self.started.lock().remove(account_id); - if let Some(handles) = handles { - let count = handles.len(); - for handle in handles { - handle.abort(); - } - log::info!("[wa] aborted {} scanner task(s) for {}", count, account_id); - } - } - - pub fn forget_all(&self) -> usize { - let entries: Vec<_> = self.started.lock().drain().collect(); - let task_count = entries.iter().map(|(_, handles)| handles.len()).sum(); - for (account_id, handles) in entries { - for handle in handles { - handle.abort(); - } - log::debug!("[wa] aborted scanner tasks for {}", account_id); - } - if task_count > 0 { - log::info!("[wa] aborted {} scanner task(s)", task_count); - } - task_count - } -} - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod tests; diff --git a/app/src-tauri/src/whatsapp_scanner/mod_tests.rs b/app/src-tauri/src/whatsapp_scanner/mod_tests.rs deleted file mode 100644 index 9084bcdf7b..0000000000 --- a/app/src-tauri/src/whatsapp_scanner/mod_tests.rs +++ /dev/null @@ -1,609 +0,0 @@ -use super::*; - -// ── Issue #1376 — chat-name normalization for active-chat → JID lookup ── - -#[test] -fn normalize_chat_name_strips_punctuation_and_emoji() { - // Group titles routinely pick up emoji + punctuation drift between - // the DOM-parsed conversation header and the IDB-stored chat name. - // Normalization should collapse both sides to the same key so the - // lookup at scan_once succeeds. - assert_eq!( - normalize_chat_name("17-18-19 July samagam"), - "171819julysamagam" - ); - assert_eq!( - normalize_chat_name("17 18 19 July samagam"), - "171819julysamagam" - ); - assert_eq!( - normalize_chat_name("17-18-19 July samagam ✨"), - "171819julysamagam" - ); - assert_eq!( - normalize_chat_name("17.18.19 July, samagam!"), - "171819julysamagam" - ); - // Identity property — already-normal strings round-trip unchanged. - assert_eq!(normalize_chat_name("foo123"), "foo123"); - // Empty input → empty output (caller guards against this). - assert_eq!(normalize_chat_name(""), ""); - assert_eq!(normalize_chat_name(" "), ""); - assert_eq!(normalize_chat_name("✨"), ""); -} - -#[test] -fn normalize_chat_name_lowercases() { - assert_eq!(normalize_chat_name("Hello World"), "helloworld"); - assert_eq!(normalize_chat_name("HELLO"), "hello"); - assert_eq!(normalize_chat_name("hElLo"), "hello"); -} - -fn insert_pending_tasks( - registry: &ScannerRegistry, - account_id: &str, - count: usize, -) -> Vec> { - let mut tasks = Vec::with_capacity(count); - let mut abort_handles = Vec::with_capacity(count); - for _ in 0..count { - let task = tokio::spawn(async { - std::future::pending::<()>().await; - }); - abort_handles.push(task.abort_handle()); - tasks.push(task); - } - registry - .started - .lock() - .insert(account_id.to_string(), abort_handles); - tasks -} - -async fn assert_cancelled(task: tokio::task::JoinHandle<()>) { - let err = tokio::time::timeout(Duration::from_secs(1), task) - .await - .expect("aborted scanner task should finish") - .expect_err("scanner task should be cancelled"); - assert!(err.is_cancelled()); -} - -async fn assert_all_cancelled(tasks: Vec>) { - for task in tasks { - assert_cancelled(task).await; - } -} - -#[tokio::test] -async fn registry_forget_aborts_all_handles_for_account_only() { - let registry = ScannerRegistry::default(); - let account_tasks = insert_pending_tasks(®istry, "acct-1", 2); - let survivor_tasks = insert_pending_tasks(®istry, "acct-2", 1); - - registry.forget("acct-1"); - - { - let guard = registry.started.lock(); - assert_eq!(guard.len(), 1); - assert!(guard.contains_key("acct-2")); - } - assert_all_cancelled(account_tasks).await; - assert!( - !survivor_tasks[0].is_finished(), - "forget(acct-1) must not abort acct-2" - ); - - assert_eq!(registry.forget_all(), 1); - assert_all_cancelled(survivor_tasks).await; -} - -#[tokio::test] -async fn registry_forget_missing_account_is_noop() { - let registry = ScannerRegistry::default(); - let mut tasks = insert_pending_tasks(®istry, "acct-1", 1); - - registry.forget("missing"); - - { - let guard = registry.started.lock(); - assert_eq!(guard.len(), 1); - assert!(guard.contains_key("acct-1")); - } - assert!( - !tasks[0].is_finished(), - "forget(missing) must not abort existing scanners" - ); - - registry.forget("acct-1"); - assert_cancelled(tasks.pop().expect("task")).await; -} - -#[tokio::test] -async fn registry_forget_all_aborts_all_tasks_and_reports_handle_count() { - let registry = ScannerRegistry::default(); - let task_a = insert_pending_tasks(®istry, "acct-1", 2); - let task_b = insert_pending_tasks(®istry, "acct-2", 3); - - assert_eq!(registry.forget_all(), 5); - - assert!(registry.started.lock().is_empty()); - assert_all_cancelled(task_a).await; - assert_all_cancelled(task_b).await; -} - -#[tokio::test] -async fn registry_forget_all_is_repeatable_noop_after_drain() { - let registry = ScannerRegistry::default(); - assert_eq!(registry.forget_all(), 0); - - let tasks = insert_pending_tasks(®istry, "acct-1", 1); - assert_eq!(registry.forget_all(), 1); - assert_eq!(registry.forget_all(), 0); - - assert!(registry.started.lock().is_empty()); - assert_all_cancelled(tasks).await; -} - -// ── seconds_to_ymd ──────────────────────────────────────────────────────── - -#[test] -fn seconds_to_ymd_known_timestamp() { - // Unix timestamp 1_700_000_000 = 2023-11-14 (UTC). - assert_eq!(seconds_to_ymd(1_700_000_000), "2023-11-14"); -} - -#[test] -fn seconds_to_ymd_epoch_zero() { - // Unix epoch origin = 1970-01-01. - assert_eq!(seconds_to_ymd(0), "1970-01-01"); -} - -#[test] -fn seconds_to_ymd_output_format_is_yyyy_mm_dd() { - let s = seconds_to_ymd(1_700_000_000); - // Must match YYYY-MM-DD: 10 chars, digit/digit/digit/digit-...-... - assert_eq!(s.len(), 10, "expected 10-char date string, got: {s}"); - let parts: Vec<&str> = s.split('-').collect(); - assert_eq!(parts.len(), 3, "expected 3 dash-separated parts: {s}"); - assert_eq!(parts[0].len(), 4, "year must be 4 digits: {s}"); - assert_eq!(parts[1].len(), 2, "month must be 2 digits: {s}"); - assert_eq!(parts[2].len(), 2, "day must be 2 digits: {s}"); - assert!( - parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit())), - "all parts must be numeric: {s}" - ); -} - -// ── parse_pre_timestamp_ymd ─────────────────────────────────────────────── - -#[test] -fn parse_pre_timestamp_ymd_valid_wa_format() { - // WhatsApp Web format: "4:53 AM, 7/5/2025" - let result = parse_pre_timestamp_ymd("4:53 AM, 7/5/2025"); - assert_eq!(result.as_deref(), Some("2025-07-05")); -} - -#[test] -fn parse_pre_timestamp_ymd_another_valid_date() { - // "10:01 PM, 11/14/2023" — matches our known ts - let result = parse_pre_timestamp_ymd("10:01 PM, 11/14/2023"); - assert_eq!(result.as_deref(), Some("2023-11-14")); -} - -#[test] -fn parse_pre_timestamp_ymd_empty_string_returns_none() { - assert!(parse_pre_timestamp_ymd("").is_none()); -} - -#[test] -fn parse_pre_timestamp_ymd_no_comma_returns_none() { - assert!(parse_pre_timestamp_ymd("4:53 AM 7/5/2025").is_none()); -} - -#[test] -fn parse_pre_timestamp_ymd_invalid_date_parts_return_none() { - // Month 13 is out of range. - assert!(parse_pre_timestamp_ymd("10:00 AM, 13/5/2025").is_none()); - // Day 32 is out of range. - assert!(parse_pre_timestamp_ymd("10:00 AM, 1/32/2025").is_none()); -} - -#[test] -fn parse_pre_timestamp_ymd_garbage_returns_none() { - assert!(parse_pre_timestamp_ymd("not a timestamp at all").is_none()); -} - -// ── emit_grouped_whatsapp grouping ──────────────────────────────────────── - -/// Build a minimal message Value that `emit_grouped_whatsapp` will accept. -fn make_msg(chat_id: &str, ts: i64, body: &str, from_me: bool) -> Value { - json!({ - "chatId": chat_id, - "body": body, - "timestamp": ts, - "fromMe": from_me, - "from": if from_me { "me" } else { chat_id }, - }) -} - -#[test] -fn grouping_produces_correct_group_count_and_keys() { - use std::collections::HashMap; - - // 3 messages in alice@c.us on day 2023-11-14 (ts ≈ 1_700_000_000). - // 2 messages in group@g.us on a different day (ts ≈ 1_700_100_000 = - // 2023-11-15 UTC). - let day1_ts = 1_700_000_000i64; // 2023-11-14 - let day2_ts = 1_700_100_000i64; // 2023-11-15 - - let messages = vec![ - make_msg("alice@c.us", day1_ts, "Hello", false), - make_msg("alice@c.us", day1_ts + 60, "How are you?", false), - make_msg("alice@c.us", day1_ts + 120, "Fine thanks", true), - make_msg("group@g.us", day2_ts, "Meeting at 3pm", false), - make_msg("group@g.us", day2_ts + 30, "Got it", true), - ]; - - // Collect groups the same way emit_grouped_whatsapp does it. - let empty_chats = serde_json::Map::new(); - let now_secs = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - - let mut groups: HashMap<(String, String), Vec> = HashMap::new(); - for m in &messages { - let chat_id = match m.get("chatId").and_then(|v| v.as_str()) { - Some(s) if !s.is_empty() => s.to_string(), - _ => continue, - }; - let body = m - .get("body") - .and_then(|v| v.as_str()) - .map(|s| s.trim().to_string()) - .unwrap_or_default(); - if body.is_empty() { - continue; - } - let day: String = if let Some(t) = m.get("timestamp").and_then(|v| v.as_i64()) { - seconds_to_ymd(t) - } else { - seconds_to_ymd(now_secs) - }; - let _ = &empty_chats; - groups.entry((chat_id, day)).or_default().push(m.clone()); - } - - assert_eq!(groups.len(), 2, "expected exactly 2 (chatId, day) groups"); - - let alice_day = seconds_to_ymd(day1_ts); - let group_day = seconds_to_ymd(day2_ts); - - let alice_key = ("alice@c.us".to_string(), alice_day.clone()); - let group_key = ("group@g.us".to_string(), group_day.clone()); - - assert!( - groups.contains_key(&alice_key), - "alice group missing; groups: {groups:?}" - ); - assert!( - groups.contains_key(&group_key), - "group@g.us group missing; groups: {groups:?}" - ); - - assert_eq!( - groups[&alice_key].len(), - 3, - "alice chat should have 3 messages" - ); - assert_eq!( - groups[&group_key].len(), - 2, - "group chat should have 2 messages" - ); -} - -// ── transcript format ───────────────────────────────────────────────────── - -#[test] -fn build_doc_ingest_params_transcript_contains_senders_and_bodies() { - let day_ts = 1_700_000_000i64; // 2023-11-14 - let ingest = json!({ - "chatId": "alice@c.us", - "chatName": "Alice", - "day": seconds_to_ymd(day_ts), - "messages": [ - { - "chatId": "alice@c.us", - "fromMe": false, - "from": "alice@c.us", - "fromName": "Alice", - "body": "Hey there!", - "timestamp": day_ts, - }, - { - "chatId": "alice@c.us", - "fromMe": true, - "from": "me", - "fromName": null, - "body": "Hi Alice!", - "timestamp": day_ts + 60, - }, - { - "chatId": "alice@c.us", - "fromMe": false, - "from": "alice@c.us", - "fromName": "Alice", - "body": "How are you?", - "timestamp": day_ts + 120, - }, - ], - }); - - let params = build_doc_ingest_params("test-acct@c.us", &ingest) - .expect("should build params for valid ingest"); - - let content = params - .get("content") - .and_then(|v| v.as_str()) - .expect("content must be present"); - - // Senders should appear in the transcript. - assert!( - content.contains("Alice"), - "transcript must contain sender name 'Alice'; content:\n{content}" - ); - assert!( - content.contains("me"), - "transcript must contain 'me' for self-sent messages; content:\n{content}" - ); - - // Bodies must be present. - assert!( - content.contains("Hey there!"), - "transcript must contain first message body; content:\n{content}" - ); - assert!( - content.contains("Hi Alice!"), - "transcript must contain second message body; content:\n{content}" - ); - assert!( - content.contains("How are you?"), - "transcript must contain third message body; content:\n{content}" - ); - - // Lines must appear in ascending timestamp order — verify by position. - let pos_hey = content.find("Hey there!").expect("Hey there not found"); - let pos_hi = content.find("Hi Alice!").expect("Hi Alice not found"); - let pos_how = content.find("How are you?").expect("How are you not found"); - assert!( - pos_hey < pos_hi && pos_hi < pos_how, - "transcript lines must be in timestamp order" - ); -} - -// ── build_doc_ingest_params payload shape ───────────────────────────────── - -#[test] -fn build_doc_ingest_params_namespace_and_key_format() { - let day = "2023-11-14"; - let ingest = json!({ - "chatId": "alice@c.us", - "chatName": "Alice", - "day": day, - "messages": [ - { "chatId": "alice@c.us", "fromMe": false, "from": "alice@c.us", - "fromName": "Alice", "body": "Hello", "timestamp": 1_700_000_000i64 } - ], - }); - - let params = build_doc_ingest_params("test-acct@c.us", &ingest).expect("should build params"); - - assert_eq!( - params.get("namespace").and_then(|v| v.as_str()), - Some("whatsapp-web:test-acct@c.us"), - "namespace must be 'whatsapp-web:'" - ); - assert_eq!( - params.get("key").and_then(|v| v.as_str()), - Some("alice@c.us:2023-11-14"), - "key must be ':'" - ); - assert_eq!( - params.get("source_type").and_then(|v| v.as_str()), - Some("whatsapp-web"), - "source_type must be 'whatsapp-web'" - ); - - // Content must be non-empty and contain the body. - let content = params - .get("content") - .and_then(|v| v.as_str()) - .expect("content must be present"); - assert!(!content.is_empty(), "content must not be empty"); - assert!( - content.contains("Hello"), - "content must contain message body; got:\n{content}" - ); -} - -#[test] -fn build_doc_ingest_params_missing_chat_id_returns_none() { - let ingest = json!({ - "chatName": "Alice", - "day": "2023-11-14", - "messages": [ - { "chatId": "alice@c.us", "fromMe": false, "body": "Hello", "timestamp": 1i64 } - ], - }); - assert!( - build_doc_ingest_params("acct", &ingest).is_none(), - "missing chatId must return None" - ); -} - -#[test] -fn build_doc_ingest_params_empty_messages_returns_none() { - let ingest = json!({ - "chatId": "alice@c.us", - "chatName": "Alice", - "day": "2023-11-14", - "messages": [], - }); - assert!( - build_doc_ingest_params("acct", &ingest).is_none(), - "empty messages must return None" - ); -} - -// ── DOM-IDB merge ───────────────────────────────────────────────────────── - -#[test] -fn merge_dom_patches_empty_body_from_idb_message() { - // IDB message with empty body; matching DOM row has the decrypted body. - let idb = vec![json!({ - "id": "abc123", - "chatId": "alice@c.us", - "fromMe": false, - "body": "", - })]; - let dom = vec![json!({ - "dataId": "abc123", - "msgId": "abc123", - "chatId": "alice@c.us", - "fromMe": false, - "body": "Hello", - "author": "Alice", - "preTimestamp": null, - })]; - - let (merged, patched, appended) = merge_dom_into_snapshot(&idb, &dom, None); - - assert_eq!(patched, 1, "one message should be patched"); - assert_eq!(appended, 0, "no messages should be appended"); - assert_eq!(merged.len(), 1, "still one message in merged list"); - - let body = merged[0] - .get("body") - .and_then(|v| v.as_str()) - .expect("body must be present"); - assert_eq!(body, "Hello", "patched body must equal DOM body"); - - let source = merged[0] - .get("bodySource") - .and_then(|v| v.as_str()) - .expect("bodySource must be present"); - assert_eq!(source, "dom", "bodySource must be 'dom' after patching"); -} - -#[test] -fn merge_dom_appends_unmatched_row_with_active_chat_backfill() { - // No IDB messages; DOM has a row with no chatId. active_chat_jid - // should be stamped onto the appended message. - let idb: Vec = vec![]; - let dom = vec![json!({ - "dataId": "newrow1", - "msgId": "newrow1", - "chatId": "", // empty — needs backfill - "fromMe": false, - "body": "Hey from active chat", - "author": "Bob", - "preTimestamp": null, - })]; - - let (merged, patched, appended) = merge_dom_into_snapshot(&idb, &dom, Some("bob@c.us")); - - assert_eq!(patched, 0, "nothing to patch"); - assert_eq!(appended, 1, "one row should be appended"); - assert_eq!(merged.len(), 1, "merged list should have 1 entry"); - - let chat_id = merged[0] - .get("chatId") - .and_then(|v| v.as_str()) - .expect("chatId must be present"); - assert_eq!( - chat_id, "bob@c.us", - "chatId should be backfilled from active_chat_jid" - ); - - let body_source = merged[0] - .get("bodySource") - .and_then(|v| v.as_str()) - .expect("bodySource must be present"); - assert_eq!(body_source, "dom-only"); -} - -#[test] -fn merge_dom_does_not_append_row_without_body() { - // DOM rows without a body should be silently skipped. - let idb: Vec = vec![]; - let dom = vec![json!({ - "dataId": "empty1", - "msgId": "empty1", - "chatId": "alice@c.us", - "fromMe": false, - "body": "", - })]; - - let (merged, patched, appended) = merge_dom_into_snapshot(&idb, &dom, None); - - assert_eq!(patched, 0); - assert_eq!(appended, 0, "empty-body DOM rows must not be appended"); - assert!( - merged.is_empty(), - "no messages should appear in merged list" - ); -} - -#[test] -fn merge_dom_does_not_consume_row_twice() { - // Two IDB messages with the same bare msgId; only the first match - // should consume the DOM row. - let idb = vec![ - json!({ "id": "chat_abc", "chatId": "alice@c.us", "fromMe": false, "body": "" }), - json!({ "id": "chat_abc_2", "chatId": "alice@c.us", "fromMe": true, "body": "" }), - ]; - // DOM row keyed only by bare msgId "abc". - let dom = vec![json!({ - "dataId": "abc", - "msgId": "abc", - "chatId": "alice@c.us", - "fromMe": false, - "body": "Only once", - })]; - - let (merged, patched, _appended) = merge_dom_into_snapshot(&idb, &dom, None); - - // Exactly one of the two IDB messages should be patched. - assert_eq!(patched, 1, "DOM row must be consumed at most once"); - assert_eq!(merged.len(), 2, "both IDB messages must survive merge"); - let patched_bodies: Vec<&str> = merged - .iter() - .filter_map(|m| m.get("body").and_then(|v| v.as_str())) - .filter(|b| *b == "Only once") - .collect(); - assert_eq!( - patched_bodies.len(), - 1, - "body 'Only once' must appear exactly once in merged list" - ); -} - -#[test] -fn merge_dom_empty_dom_returns_idb_messages_unchanged() { - let idb = vec![ - json!({ "id": "m1", "chatId": "a@c.us", "body": "hello" }), - json!({ "id": "m2", "chatId": "a@c.us", "body": "" }), - ]; - let dom: Vec = vec![]; - - let (merged, patched, appended) = merge_dom_into_snapshot(&idb, &dom, None); - - assert_eq!(patched, 0); - assert_eq!(appended, 0); - assert_eq!(merged.len(), 2, "IDB messages must be returned unchanged"); - assert_eq!( - merged[0].get("body").and_then(|v| v.as_str()), - Some("hello") - ); -} diff --git a/app/src-tauri/src/whatsapp_scanner/test_fixtures/dom_snapshot_2026_05.json b/app/src-tauri/src/whatsapp_scanner/test_fixtures/dom_snapshot_2026_05.json deleted file mode 100644 index db8bc22a0f..0000000000 --- a/app/src-tauri/src/whatsapp_scanner/test_fixtures/dom_snapshot_2026_05.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "_comment": "Synthetic CDP DOMSnapshot.captureSnapshot fixture for issue #1376 regression tests. Replace with a real WA Web capture once available. Four message rows: tier 1 (selectable-text class), tier 2 (dir=ltr), tier 3 multi-word body (descendant text fallback), and tier 3 single-word body (regression guard: must not be filtered by looks_like_icon_ligature). One conversation-header gives parse_active_chat_name something to resolve.", - "documents": [ - { - "nodes": { - "parentIndex": [-1, 0, 1, 2, 0, 4, 5, 0, 7, 8, 0, 10, 11, 0, 13, 14], - "nodeType": [ 1, 1, 1, 3, 1, 1, 3, 1, 1, 3, 1, 1, 3, 1, 1, 3], - "nodeName": [ 0, 1, 4, -1, 6, 4, -1, 6, 4, -1, 6, 6, -1, 6, 6, -1], - "nodeValue": [-1, -1, -1, 5, -1, -1, 11, -1, -1, 15, -1, -1, 17, -1, -1, 19], - "attributes": [ - [], - [2, 3], - [], - [], - [7, 8], - [9, 10], - [], - [7, 12], - [13, 14], - [], - [7, 16], - [], - [], - [7, 18], - [], - [] - ] - } - } - ], - "strings": [ - "html", - "header", - "data-testid", - "conversation-header", - "span", - "Test Chat", - "div", - "data-id", - "false_chat1@c.us_msgABC123", - "class", - "selectable-text", - "hello tier 1", - "false_chat1@c.us_msgDEF456", - "dir", - "ltr", - "hello tier 2", - "false_chat1@c.us_msgGHI789", - "hello tier 3", - "false_chat1@c.us_msgJKL012", - "ok" - ] -} diff --git a/app/src/utils/tauriCommands/common.test.ts b/app/src/utils/tauriCommands/common.test.ts index 0210dc649f..ef633ace9f 100644 --- a/app/src/utils/tauriCommands/common.test.ts +++ b/app/src/utils/tauriCommands/common.test.ts @@ -234,14 +234,14 @@ describe('safeInvoke (tauriCommands/common)', () => { throw cefThrow; }); - const err = await safeInvoke('webview_account_hide').catch((e: unknown) => e); + const err = await safeInvoke('mascot_window_hide').catch((e: unknown) => e); expect(err).toBeInstanceOf(IpcUnavailableError); const typed = err as IpcUnavailableError; expect(typed.name).toBe('IpcUnavailableError'); - expect(typed.cmd).toBe('webview_account_hide'); + expect(typed.cmd).toBe('mascot_window_hide'); expect(typed.cause).toBe(cefThrow); - expect(typed.message).toContain('webview_account_hide'); + expect(typed.message).toContain('mascot_window_hide'); expect(typed.message).toContain('postMessage'); }); @@ -267,10 +267,10 @@ describe('safeInvoke (tauriCommands/common)', () => { const cefThrow = new TypeError("Cannot read properties of undefined (reading 'postMessage')"); coreInvokeMock.mockRejectedValue(cefThrow); - const err = await safeInvoke('webview_account_reveal').catch((e: unknown) => e); + const err = await safeInvoke('mascot_window_show').catch((e: unknown) => e); expect(err).toBeInstanceOf(IpcUnavailableError); - expect((err as IpcUnavailableError).cmd).toBe('webview_account_reveal'); + expect((err as IpcUnavailableError).cmd).toBe('mascot_window_show'); }); // #5155: the dereference is now *guarded* — the vendored bootstrap and diff --git a/src/openhuman/channels/mod.rs b/src/openhuman/channels/mod.rs index 5f11e6bd08..85d4a984e8 100644 --- a/src/openhuman/channels/mod.rs +++ b/src/openhuman/channels/mod.rs @@ -41,9 +41,6 @@ pub mod proactive; pub mod providers; #[cfg(feature = "channels")] pub(crate) mod relay_runtime; -/// Webview-account bridge for embedded provider webviews. -#[cfg(feature = "channels")] -pub mod webview_accounts; /// Read-only WhatsApp chat/message store fed by the desktop scanner (formerly /// `openhuman::whatsapp_data`). #[cfg(feature = "channels")] diff --git a/src/openhuman/channels/webview_accounts/README.md b/src/openhuman/channels/webview_accounts/README.md deleted file mode 100644 index f394d35e86..0000000000 --- a/src/openhuman/channels/webview_accounts/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# webview_accounts - -Core-side support for the third-party accounts the Tauri shell hosts in CEF webviews (Gmail, WhatsApp, Telegram, Slack, Discord, LinkedIn, Zoom, Google Messages, WeChat). The core runs in-process but has **no direct CEF handle**, so this module works entirely off out-of-band data the shell hands it. It does two unrelated things: (1) a read-only probe of the shared Chromium cookie store to heuristically decide which providers have a live login, and (2) a pure normalization contract that turns scraped WeChat Web DOM data into context/memory payloads. The module has no domain state, no event subscribers, and no RPC controller of its own — it is a pure library of helper functions exported for other layers to call. - -## Responsibilities - -- Detect which supported webview providers currently have an active login, by inspecting Chromium's shared `Cookies` SQLite DB read-only for known per-provider session-cookie names. -- Never fail the detection probe: a missing env var, locked/corrupt/missing DB, or schema drift all map to "every provider logged_out". The result always contains a key for every tracked provider. -- Normalize WeChat Web scan payloads (chat list rows + per-peer message rows) into: - - a context "ingest envelope"/list payload (`list_ingest_envelope` / `list_ingest_payload`), and - - memory-document parameter maps (`memory_doc_ingest_list_snapshot`, `memory_doc_ingest_peer_transcript`) ready to be stored as memory docs. -- Validate WeChat scan payloads (`validate_scan`). - -## Key files - -| File | Role | -| --- | --- | -| `src/openhuman/channels/webview_accounts/mod.rs` | Module docstring (explains the cookie-store heuristic) + `mod`/`pub mod` decls + `pub use` re-exports. No logic. | -| `src/openhuman/channels/webview_accounts/ops.rs` | Cookie-store probe: `Provider` table, `detect_webview_logins()`, SQLite `file:` URI construction, LIKE-escaping, plus inline unit tests. | -| `src/openhuman/channels/webview_accounts/wechat_ingest.rs` | WeChat Web ingest contract: payload types, validation, context envelope/payload builders, memory-doc param builders, and self-contained date/time helpers. Includes inline tests. | -| `src/openhuman/channels/webview_accounts/wechat_ingest_tests.rs` | Sibling test suite for the WeChat ingest contract (wired via `#[path = "wechat_ingest_tests.rs"] mod tests;`). | - -## Public surface - -Re-exported from `mod.rs`: - -- `detect_webview_logins() -> serde_json::Value` — JSON object keyed by provider slug (`gmail`, `whatsapp`, `wechat`, `telegram`, `slack`, `discord`, `linkedin`, `zoom`, `google_messages`), each value a `bool`. All keys always present. -- `validate_scan(&WechatScanPayload) -> Result<(), String>` -- `list_ingest_envelope(account_id, &WechatScanPayload, ts_millis) -> Value` -- `list_ingest_payload(&WechatScanPayload) -> Value` -- `memory_doc_ingest_list_snapshot(&WechatScanPayload) -> Result, String>` -- `memory_doc_ingest_peer_transcript(account_id, chat_id, chat_name, &[WechatMessageRow]) -> Result, String>` -- Types: `WechatChatRow`, `WechatMessageRow`, `WechatScanPayload` (all `Serialize`/`Deserialize`). - -## RPC / controllers - -None. There is no `schemas.rs`, no `all_*_controller_schemas` pair, and no `handle_*` fns — this module exposes no JSON-RPC surface itself. Its functions are plain helpers intended to be called by other layers (e.g. snapshot assembly / context / memory ingest). - -## Agent tools - -None. No `tools.rs`. - -## Events - -None. No `bus.rs`; the module publishes/subscribes to no `DomainEvent`s. - -## Persistence - -No state of its own. It performs a **read-only** open of the shared Chromium cookie store (an external SQLite DB owned by CEF), located via the `OPENHUMAN_CEF_COOKIES_DB` env var. Opens use `file:...?mode=ro&immutable=1&nolock=1` so it can read while CEF holds an exclusive lock; stale reads are acceptable for the heuristic. The WeChat ingest helpers only produce in-memory parameter maps — they do not write anything. - -## Configuration - -- `OPENHUMAN_CEF_COOKIES_DB` (constant `COOKIES_DB_ENV` in `ops.rs`) — absolute path to the shared CEF `Cookies` SQLite file, exported by the Tauri shell before launching the core. Unset/empty ⇒ all providers reported logged_out. The module deliberately does **not** guess a platform default; the shell is the authoritative source of the bundle/cache path. - -## Dependencies - -No `use crate::openhuman::` or `use crate::core::` imports — the module depends on **no other OpenHuman domains or core modules**. External crate dependencies only: - -- `rusqlite` — read-only probe of the Chromium cookie DB. -- `serde` / `serde_json` — WeChat payload (de)serialization and JSON output for `detect_webview_logins`. -- `urlencoding` — percent-encode the cookie DB path into a SQLite `file:` URI. -- `tempfile` (dev) — test fixtures. - -## Used by - -Declared at `src/openhuman/mod.rs:108` (`pub mod webview_accounts;`). No current in-tree Rust callers of its exported functions were found (`detect_webview_logins` and the WeChat ingest helpers have no callers under `src/` outside this module today; the only other match is an unrelated test-pattern comment in `src/openhuman/platform/connectivity/rpc.rs`). The exports are public API kept ready for snapshot/context/memory consumers and align with the shell-side `app/src-tauri/src/webview_accounts/` provider list. - -## Notes / gotchas - -- **Heuristic, not authoritative.** Login detection keys off the *presence* of a known session-cookie name under a host suffix. Chromium prunes expired cookies at startup, so presence is a strong-but-not-certain signal. Session-cookie names are chosen per provider to avoid false positives from analytics/consent cookies (e.g. `NID`/`CONSENT` on google.com do not count). -- **Host matching** uses SQL `LIKE '%suffix'` with `ESCAPE '\'`; `host_suffix` values are run through `escape_like` so `_`/`%`/`\` in a future provider entry can't silently widen the match. -- **Path encoding matters.** The SQLite `file:` URI path is percent-encoded (`sqlite_uri_path`) so spaces (`/Users/John Doe/...`), `?`, `#`, `%`, and Windows `\` separators don't break URI parsing — without this, macOS users with a space in their username would silently report all-false. -- **No PII in logs.** The DB path is never logged (it can contain a username); only the env-key name is logged at `debug`. -- **`detect_webview_logins` never returns an error** — every failure path returns the all-false object so snapshot assembly always succeeds. -- **WeChat date/time helpers are hand-rolled** (`ts_to_ymd`, `format_message_stamp`, `chrono_day_key`) using a civil-date algorithm rather than pulling `chrono`, and emit UTC (`Z`) stamps. -- **Two unrelated concerns share one module** (cookie probe vs. WeChat ingest contract); they have no code dependency on each other beyond living under the same webview-accounts umbrella. diff --git a/src/openhuman/channels/webview_accounts/mod.rs b/src/openhuman/channels/webview_accounts/mod.rs deleted file mode 100644 index 48b28a3d12..0000000000 --- a/src/openhuman/channels/webview_accounts/mod.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! WeChat webview-scan ingest normalization. -//! -//! The Tauri shell's `wechat_scanner` scrapes the embedded WeChat CEF -//! webview via CDP and hands the raw DOM snapshot to this module, which -//! validates it and normalizes it into the memory-doc ingest envelope the -//! rest of the system consumes. The shell owns capture; core owns the -//! normalization + persistence contract. -//! -//! (The former webview cookie-login heuristic — `ops::detect_webview_logins` -//! — was removed as unused; only the WeChat ingest surface remains, kept in -//! core because it produces the shared memory-ingest envelope.) - -pub mod wechat_ingest; - -#[cfg(test)] -#[path = "wechat_ingest_tests.rs"] -mod tests; - -pub use wechat_ingest::{ - list_ingest_envelope, list_ingest_payload, memory_doc_ingest_list_snapshot, - memory_doc_ingest_peer_transcript, validate_scan, WechatChatRow, WechatMessageRow, - WechatScanPayload, -}; diff --git a/src/openhuman/channels/webview_accounts/wechat_ingest.rs b/src/openhuman/channels/webview_accounts/wechat_ingest.rs deleted file mode 100644 index 253c8573c0..0000000000 --- a/src/openhuman/channels/webview_accounts/wechat_ingest.rs +++ /dev/null @@ -1,358 +0,0 @@ -//! WeChat Web ingest contract — normalized payloads for context + memory. - -use serde::{Deserialize, Serialize}; -use serde_json::{json, Map, Value}; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WechatChatRow { - pub name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub preview: Option, - #[serde(default)] - pub unread: u32, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WechatMessageRow { - pub chat_id: String, - pub chat_name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sender: Option, - pub body: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ts: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WechatScanPayload { - pub account_id: String, - #[serde(default)] - pub chat_rows: Vec, - #[serde(default)] - pub messages: Vec, - #[serde(default)] - pub unread: u32, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub snapshot_key: String, - #[serde(default = "default_source")] - pub source: String, -} - -fn default_source() -> String { - "cdp-dom".to_string() -} - -pub fn validate_scan(payload: &WechatScanPayload) -> Result<(), String> { - if payload.account_id.trim().is_empty() { - return Err("account_id is required".into()); - } - if payload.chat_rows.is_empty() && payload.messages.is_empty() { - return Err("scan has no chat rows or messages".into()); - } - Ok(()) -} - -pub fn list_ingest_envelope( - account_id: &str, - payload: &WechatScanPayload, - ts_millis: i64, -) -> Value { - json!({ - "account_id": account_id, - "provider": "wechat", - "kind": "ingest", - "payload": list_ingest_payload(payload), - "ts": ts_millis, - }) -} - -pub fn list_ingest_payload(payload: &WechatScanPayload) -> Value { - let messages: Vec = payload - .chat_rows - .iter() - .enumerate() - .map(|(idx, row)| { - let id = if row.name.is_empty() { - format!("wechat:row:{idx}") - } else { - format!("wechat:{idx}:{}", row.name) - }; - json!({ - "id": id, - "from": if row.name.is_empty() { Value::Null } else { json!(row.name) }, - "body": row.preview.clone().map(Value::String).unwrap_or(Value::Null), - "unread": row.unread, - }) - }) - .collect(); - json!({ - "messages": messages, - "unread": payload.unread, - "snapshotKey": payload.snapshot_key, - }) -} - -pub fn memory_doc_ingest_list_snapshot( - payload: &WechatScanPayload, -) -> Result, String> { - validate_scan(payload)?; - if payload.chat_rows.is_empty() { - return Err("no chat rows for list snapshot".into()); - } - let namespace = format!("wechat-web:{}", payload.account_id); - let key = if payload.snapshot_key.is_empty() { - format!("list:{}", chrono_day_key()) - } else { - format!("list:{}", payload.snapshot_key) - }; - Ok(memory_doc_params( - namespace, - key, - format!( - "WeChat · chat list · {}", - short_account(&payload.account_id) - ), - format_list_transcript(payload), - json!({ - "provider": "wechat", - "account_id": payload.account_id, - "kind": "chat-list", - "chat_count": payload.chat_rows.len(), - "unread": payload.unread, - }), - vec!["wechat", "chat-list"], - )) -} - -pub fn memory_doc_ingest_peer_transcript( - account_id: &str, - chat_id: &str, - chat_name: &str, - rows: &[WechatMessageRow], -) -> Result, String> { - if account_id.trim().is_empty() { - return Err("account_id is required".into()); - } - if chat_id.trim().is_empty() { - return Err("chat_id is required".into()); - } - if rows.is_empty() { - return Err("no messages for peer transcript".into()); - } - let mut sorted: Vec<&WechatMessageRow> = rows.iter().collect(); - sorted.sort_by_key(|m| m.ts.unwrap_or(0)); - let first_day = ts_to_ymd(sorted.first().and_then(|m| m.ts).unwrap_or(0)); - let last_day = ts_to_ymd(sorted.last().and_then(|m| m.ts).unwrap_or(0)); - let transcript: String = sorted - .iter() - .map(|m| { - let stamp = m.ts.map(format_message_stamp).unwrap_or_else(|| "?".into()); - let who = m.sender.as_deref().filter(|s| !s.is_empty()).unwrap_or("?"); - format!("[{stamp}] {who}: {}", m.body.replace(['\r', '\n'], " ")) - }) - .collect::>() - .join("\n"); - let peer_label = if chat_name.trim().is_empty() { - chat_id - } else { - chat_name - }; - let header = format!( - "# WeChat — {peer_label}\nchat_id: {chat_id}\naccount_id: {account_id}\nmessages: {}\nrange: {first_day} → {last_day}\n\n", - sorted.len() - ); - let key = if peer_key_looks_clean(chat_name) { - format!("{chat_id}:{chat_name}") - } else { - chat_id.to_string() - }; - Ok(memory_doc_params( - format!("wechat-web:{account_id}"), - key, - format!("WeChat · {peer_label}"), - format!("{header}{transcript}"), - json!({ - "provider": "wechat", - "account_id": account_id, - "chat_id": chat_id, - "chat_name": chat_name, - "message_count": sorted.len(), - }), - vec!["wechat", "peer-transcript"], - )) -} - -fn memory_doc_params( - namespace: String, - key: String, - title: String, - content: String, - metadata: Value, - tags: Vec<&str>, -) -> Map { - let mut params = Map::new(); - params.insert("namespace".into(), json!(namespace)); - params.insert("key".into(), json!(key)); - params.insert("title".into(), json!(title)); - params.insert("content".into(), json!(content)); - params.insert("source_type".into(), json!("wechat-web")); - params.insert("priority".into(), json!("medium")); - params.insert("tags".into(), json!(tags)); - params.insert("metadata".into(), metadata); - params.insert("category".into(), json!("core")); - params -} - -fn format_list_transcript(payload: &WechatScanPayload) -> String { - let mut lines = vec![ - "# WeChat — chat list".to_string(), - format!("account_id: {}", payload.account_id), - format!("chats: {}", payload.chat_rows.len()), - format!("unread: {}", payload.unread), - String::new(), - ]; - for row in &payload.chat_rows { - let preview = row.preview.as_deref().unwrap_or(""); - let badge = if row.unread > 0 { - format!(" [{} unread]", row.unread) - } else { - String::new() - }; - lines.push(format!("- {}{}: {}", row.name, badge, preview)); - } - lines.join("\n") -} - -fn short_account(account_id: &str) -> String { - if account_id.chars().count() <= 8 { - account_id.to_string() - } else { - account_id.chars().take(8).collect() - } -} - -fn peer_key_looks_clean(name: &str) -> bool { - !name.is_empty() - && name - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') -} - -fn format_message_stamp(ts: i64) -> String { - let day = ts_to_ymd(ts); - let secs_of_day = (ts.rem_euclid(86_400)) as u32; - format!( - "{} {:02}:{:02}Z", - day, - secs_of_day / 3600, - (secs_of_day / 60) % 60 - ) -} - -fn ts_to_ymd(secs: i64) -> String { - if secs <= 0 { - return String::new(); - } - let days = secs.div_euclid(86_400); - let z = days + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = (z - era * 146_097) as u64; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; - let y_real = (if m <= 2 { y + 1 } else { y }) as i32; - format!("{:04}-{:02}-{:02}", y_real, m, d) -} - -fn chrono_day_key() -> String { - ts_to_ymd( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn validate_rejects_empty_account() { - let mut p = WechatScanPayload { - account_id: "acct".into(), - chat_rows: vec![WechatChatRow { - name: "A".into(), - preview: None, - unread: 0, - }], - messages: vec![], - unread: 0, - snapshot_key: String::new(), - source: "cdp-dom".into(), - }; - p.account_id = " ".into(); - assert!(validate_scan(&p).is_err()); - } - - #[test] - fn validate_rejects_empty_scan() { - assert!(validate_scan(&WechatScanPayload { - account_id: "acct".into(), - chat_rows: vec![], - messages: vec![], - unread: 0, - snapshot_key: String::new(), - source: "cdp-dom".into(), - }) - .is_err()); - } - - #[test] - fn list_ingest_payload_has_messages() { - let v = list_ingest_payload(&WechatScanPayload { - account_id: "a".into(), - chat_rows: vec![WechatChatRow { - name: "Bob".into(), - preview: Some("hi".into()), - unread: 1, - }], - messages: vec![], - unread: 1, - snapshot_key: "k".into(), - source: "cdp-dom".into(), - }); - assert_eq!(v["messages"].as_array().map(|a| a.len()), Some(1)); - } - - #[test] - fn peer_transcript_rejects_empty_messages() { - assert!(memory_doc_ingest_peer_transcript("acct", "c1", "Alice", &[]).is_err()); - } - - #[test] - fn peer_transcript_key_includes_chat_id_for_clean_names() { - let rows = vec![WechatMessageRow { - chat_id: "chat-1".into(), - chat_name: "Alice".into(), - sender: None, - body: "hello".into(), - ts: Some(1), - }]; - - let first = memory_doc_ingest_peer_transcript("acct", "chat-1", "Alice", &rows).unwrap(); - let second = memory_doc_ingest_peer_transcript("acct", "chat-2", "Alice", &rows).unwrap(); - - assert_eq!(first["key"].as_str(), Some("chat-1:Alice")); - assert_eq!(second["key"].as_str(), Some("chat-2:Alice")); - } - - #[test] - fn short_account_truncates_on_char_boundary() { - assert_eq!(short_account("acct-123"), "acct-123"); - assert_eq!(short_account("ééééééééé"), "éééééééé"); - } -} diff --git a/src/openhuman/channels/webview_accounts/wechat_ingest_tests.rs b/src/openhuman/channels/webview_accounts/wechat_ingest_tests.rs deleted file mode 100644 index 6651019ccb..0000000000 --- a/src/openhuman/channels/webview_accounts/wechat_ingest_tests.rs +++ /dev/null @@ -1,54 +0,0 @@ -use super::wechat_ingest::{ - list_ingest_envelope, memory_doc_ingest_peer_transcript, validate_scan, WechatChatRow, - WechatMessageRow, WechatScanPayload, -}; - -#[test] -fn envelope_includes_provider_and_kind() { - let payload = WechatScanPayload { - account_id: "acct-x".into(), - chat_rows: vec![WechatChatRow { - name: "Bob".into(), - preview: Some("ping".into()), - unread: 1, - }], - messages: vec![], - unread: 1, - snapshot_key: "deadbeef".into(), - source: "cdp-dom".into(), - }; - let env = list_ingest_envelope("acct-x", &payload, 1_234); - assert_eq!(env["provider"].as_str(), Some("wechat")); - assert_eq!(env["kind"].as_str(), Some("ingest")); -} - -#[test] -fn validate_accepts_messages_only_scan() { - let payload = WechatScanPayload { - account_id: "acct".into(), - chat_rows: vec![], - messages: vec![WechatMessageRow { - chat_id: "c1".into(), - chat_name: "Alice".into(), - sender: None, - body: "hello".into(), - ts: None, - }], - unread: 0, - snapshot_key: String::new(), - source: "cdp-dom".into(), - }; - assert!(validate_scan(&payload).is_ok()); -} - -#[test] -fn peer_transcript_rejects_blank_chat_id() { - let rows = vec![WechatMessageRow { - chat_id: " ".into(), - chat_name: "x".into(), - sender: None, - body: "y".into(), - ts: None, - }]; - assert!(memory_doc_ingest_peer_transcript("acct", " ", "x", &rows).is_err()); -}