From 5a246bc5b6b63855eedf4beb19f329d3a5521bda Mon Sep 17 00:00:00 2001
From: oxoxDev <164490987+oxoxDev@users.noreply.github.com>
Date: Fri, 24 Apr 2026 17:24:49 +0530
Subject: [PATCH] feat(accounts): Zoom webview account + zoomus:// deep-link
rewrite (#657) (#853)
---
app/src-tauri/recipes/zoom/icon.svg | 4 +
app/src-tauri/recipes/zoom/manifest.json | 7 +
app/src-tauri/src/webview_accounts/mod.rs | 367 +++++++++++++++++-
app/src/components/accounts/providerIcons.tsx | 13 +-
app/src/types/accounts.ts | 7 +
5 files changed, 394 insertions(+), 4 deletions(-)
create mode 100644 app/src-tauri/recipes/zoom/icon.svg
create mode 100644 app/src-tauri/recipes/zoom/manifest.json
diff --git a/app/src-tauri/recipes/zoom/icon.svg b/app/src-tauri/recipes/zoom/icon.svg
new file mode 100644
index 000000000..c971be981
--- /dev/null
+++ b/app/src-tauri/recipes/zoom/icon.svg
@@ -0,0 +1,4 @@
+
diff --git a/app/src-tauri/recipes/zoom/manifest.json b/app/src-tauri/recipes/zoom/manifest.json
new file mode 100644
index 000000000..d807e199b
--- /dev/null
+++ b/app/src-tauri/recipes/zoom/manifest.json
@@ -0,0 +1,7 @@
+{
+ "id": "zoom",
+ "name": "Zoom",
+ "version": "0.1.0",
+ "serviceURL": "https://zoom.us/",
+ "icon": "icon.svg"
+}
diff --git a/app/src-tauri/src/webview_accounts/mod.rs b/app/src-tauri/src/webview_accounts/mod.rs
index e518b419c..c979fb48c 100644
--- a/app/src-tauri/src/webview_accounts/mod.rs
+++ b/app/src-tauri/src/webview_accounts/mod.rs
@@ -67,6 +67,7 @@ fn provider_url(provider: &str) -> Option<&'static str> {
"slack" => Some("https://app.slack.com/client/"),
"discord" => Some("https://discord.com/channels/@me"),
"google-meet" => Some("https://meet.google.com/"),
+ "zoom" => Some("https://zoom.us/"),
"browserscan" => Some("https://www.browserscan.net/bot-detection"),
_ => None,
}
@@ -75,7 +76,7 @@ fn provider_url(provider: &str) -> Option<&'static str> {
fn provider_user_agent(provider: &str) -> Option<&'static str> {
match provider {
"whatsapp" | "telegram" | "linkedin" | "gmail" | "slack" | "discord" | "google-meet"
- | "browserscan" => Some(CHROME_UA),
+ | "zoom" | "browserscan" => Some(CHROME_UA),
_ => None,
}
}
@@ -106,7 +107,7 @@ fn provider_is_supported(provider: &str) -> bool {
fn provider_ua_spoof(provider: &str) -> bool {
matches!(
provider,
- "slack" | "gmail" | "linkedin" | "discord" | "google-meet" | "browserscan"
+ "slack" | "gmail" | "linkedin" | "discord" | "google-meet" | "zoom" | "browserscan"
)
}
@@ -139,11 +140,57 @@ fn provider_allowed_hosts(provider: &str) -> &'static [&'static str] {
"gstatic.com",
"googleapis.com",
],
+ "zoom" => &["zoom.us", "zoom.com", "zoomgov.com", "zdassets.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.
@@ -190,6 +237,19 @@ fn popup_should_stay_in_app(provider: &str, url: &Url) -> bool {
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,
+ }
+ }
_ => false,
}
}
@@ -258,6 +318,7 @@ pub fn provider_display_name(provider: &str) -> &'static str {
"slack" => "Slack",
"discord" => "Discord",
"google-meet" => "Google Meet",
+ "zoom" => "Zoom",
"browserscan" => "BrowserScan",
_ => "OpenHuman",
}
@@ -934,9 +995,35 @@ pub async fn webview_account_open(
// 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) 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();
builder = builder.on_navigation(move |url| {
+ 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 {
@@ -970,7 +1057,31 @@ pub async fn webview_account_open(
// 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 popup_should_stay_in_app(&popup_provider, &url) {
log::info!(
"[webview-accounts] new-window request {} → in-app popup (provider={})",
@@ -1632,3 +1743,253 @@ pub async fn webview_recipe_event(
.map_err(|e| format!("emit failed: {e}"))?;
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn url(s: &str) -> Url {
+ Url::parse(s).expect("valid url")
+ }
+
+ // ── provider registry match arms ──────────────────────────────────
+
+ #[test]
+ fn zoom_registered_in_provider_url() {
+ assert_eq!(provider_url("zoom"), Some("https://zoom.us/"));
+ }
+
+ #[test]
+ fn zoom_registered_in_user_agent() {
+ assert_eq!(provider_user_agent("zoom"), Some(CHROME_UA));
+ }
+
+ #[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_enables_ua_spoof() {
+ assert!(provider_ua_spoof("zoom"));
+ }
+
+ #[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_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());
+ }
+
+ #[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")
+ ));
+ }
+}
diff --git a/app/src/components/accounts/providerIcons.tsx b/app/src/components/accounts/providerIcons.tsx
index ffea0fac2..beed33833 100644
--- a/app/src/components/accounts/providerIcons.tsx
+++ b/app/src/components/accounts/providerIcons.tsx
@@ -1,5 +1,13 @@
import { FaLinkedin } from 'react-icons/fa';
-import { SiDiscord, SiGmail, SiGooglemeet, SiSlack, SiTelegram, SiWhatsapp } from 'react-icons/si';
+import {
+ SiDiscord,
+ SiGmail,
+ SiGooglemeet,
+ SiSlack,
+ SiTelegram,
+ SiWhatsapp,
+ SiZoom,
+} from 'react-icons/si';
import { TbRobot } from 'react-icons/tb';
import type { AccountProvider } from '../../types/accounts';
@@ -17,6 +25,7 @@ const PROVIDER_COLOR: Record = {
slack: '#4A154B',
discord: '#5865F2',
'google-meet': '#00897B',
+ zoom: '#2D8CFF',
browserscan: '#6B7280',
};
@@ -48,6 +57,8 @@ export const ProviderIcon = ({
return ;
case 'google-meet':
return ;
+ case 'zoom':
+ return ;
case 'browserscan':
return ;
default:
diff --git a/app/src/types/accounts.ts b/app/src/types/accounts.ts
index 31b134b31..27daae677 100644
--- a/app/src/types/accounts.ts
+++ b/app/src/types/accounts.ts
@@ -8,6 +8,7 @@ export type AccountProvider =
| 'slack'
| 'discord'
| 'google-meet'
+ | 'zoom'
| 'browserscan';
export type AccountStatus = 'pending' | 'open' | 'error' | 'closed';
@@ -94,6 +95,12 @@ const BASE_PROVIDERS: ProviderDescriptor[] = [
description: 'Join Google Meet calls and capture live captions.',
serviceUrl: 'https://meet.google.com/',
},
+ {
+ id: 'zoom',
+ label: 'Zoom',
+ description: 'Zoom web client — log in and join meetings from inside the app.',
+ serviceUrl: 'https://zoom.us/',
+ },
];
const DEV_PROVIDERS: ProviderDescriptor[] = [