feat(accounts): Zoom webview account + zoomus:// deep-link rewrite (#657) (#853)

This commit is contained in:
oxoxDev
2026-04-24 17:24:49 +05:30
committed by GitHub
parent b329e45cdb
commit 5a246bc5b6
5 changed files with 394 additions and 4 deletions
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M24 12c0 6.627-5.373 12-12 12S0 18.627 0 12 5.373 0 12 0s12 5.373 12 12z" fill="#2D8CFF"/>
<path d="M4 8.8h8.4c.88 0 1.6.72 1.6 1.6V15.2c0 .88-.72 1.6-1.6 1.6H4a.8.8 0 01-.8-.8V9.6c0-.44.36-.8.8-.8zm16.72-.4a.8.8 0 00-.8 0l-4.32 2.6a.8.8 0 00-.4.68v2.64c0 .28.16.56.4.68l4.32 2.6a.8.8 0 001.2-.68V9.08a.8.8 0 00-.4-.68z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 455 B

+7
View File
@@ -0,0 +1,7 @@
{
"id": "zoom",
"name": "Zoom",
"version": "0.1.0",
"serviceURL": "https://zoom.us/",
"icon": "icon.svg"
}
+364 -3
View File
@@ -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<Url> {
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<R: Runtime>(
// 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<R: Runtime>(
// 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<R: Runtime>(
.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")
));
}
}
+12 -1
View File
@@ -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<AccountProvider, string> = {
slack: '#4A154B',
discord: '#5865F2',
'google-meet': '#00897B',
zoom: '#2D8CFF',
browserscan: '#6B7280',
};
@@ -48,6 +57,8 @@ export const ProviderIcon = ({
return <SiDiscord className={className} style={style} />;
case 'google-meet':
return <SiGooglemeet className={className} style={style} />;
case 'zoom':
return <SiZoom className={className} style={style} />;
case 'browserscan':
return <TbRobot className={className} style={style} />;
default:
+7
View File
@@ -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[] = [