diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index becc3f0f2..e246c2a4a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -44,7 +44,7 @@ arboard = "3.6" flate2 = "1" brotli = "7" zstd = "0.13" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync", "net"] } futures = "0.3" async-stream = "0.3" bytes = "1.5" diff --git a/src-tauri/src/commands/deeplink.rs b/src-tauri/src/commands/deeplink.rs index af27f316c..760dd8bd3 100644 --- a/src-tauri/src/commands/deeplink.rs +++ b/src-tauri/src/commands/deeplink.rs @@ -34,6 +34,13 @@ pub fn merge_deeplink_config( crate::deeplink::parse_and_merge_config(&request).map_err(|e| e.to_string()) } +/// 下载深链接 `iconUrl` 指向的图标,返回核对过类型的 data URI;任何失败都返回 None, +/// 确认框退回内置图标或首字母(原因只写日志,见 `deeplink::icon_fetch`) +#[tauri::command] +pub async fn fetch_deeplink_icon(url: String) -> Option { + crate::deeplink::fetch_deeplink_icon(&url).await +} + /// Import a provider from a deep link request (legacy, kept for compatibility) #[tauri::command] pub fn import_from_deeplink( diff --git a/src-tauri/src/deeplink/icon_fetch.rs b/src-tauri/src/deeplink/icon_fetch.rs new file mode 100644 index 000000000..ce3abaf3e --- /dev/null +++ b/src-tauri/src/deeplink/icon_fetch.rs @@ -0,0 +1,617 @@ +//! 深链接 `iconUrl` 的下载。 +//! +//! 链接可以来自任意网页,下载地址就等于由对方指定,所以按不可信的服务端请求处理: +//! - 只允许 https、默认端口、域名主机,不收 IP 字面量和带账号密码的地址; +//! - 解析出的地址必须全部是公网地址(拒绝私网、回环、链路本地、CGNAT、ULA 等), +//! 再把连接钉在这些已核对的地址上,防止 DNS 重绑定在核对之后换成内网地址 +//! (fake-ip 网段是例外,见 `is_public_ipv4`); +//! - 不跟随自动重定向,手动最多跟 3 跳,每一跳重新核对; +//! - 不走系统代理(代理替我们解析域名,核对过的地址就不算数了),不带 Cookie / Referer; +//! - 整个过程 5 秒超时,响应体流式读取,超过 256 KB 立即放弃; +//! - Content-Type 和文件头必须一致且是 PNG / JPEG / WebP / SVG 之一,位图再读出宽高核对。 +//! +//! 这里只负责把字节安全地拿回来,返回带核对过类型的 data URI;前端在 canvas 上重新编码成 +//! 96×96 PNG 之后才会存进供应商,保存时后端再按自定义图标的规则完整校验一次。 +//! 下载失败不报错给用户,确认框退回首字母或链接里的内置图标。 + +use base64::Engine; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::time::Duration; +use url::{Host, Url}; + +const MAX_ICON_URL_LENGTH: usize = 2048; +const MAX_ICON_BYTES: usize = 256 * 1024; +const FETCH_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_REDIRECTS: usize = 3; +/// 解码前的宽高上限,和前端 `customIcon.ts` 的 `MAX_SOURCE_DIMENSION` 一致 +const MAX_SOURCE_DIMENSION: u32 = 4096; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum IconFormat { + Png, + Jpeg, + Webp, + Svg, +} + +impl IconFormat { + fn from_content_type(value: &str) -> Option { + let mime = value.split(';').next()?.trim().to_ascii_lowercase(); + match mime.as_str() { + "image/png" => Some(Self::Png), + "image/jpeg" | "image/jpg" => Some(Self::Jpeg), + "image/webp" => Some(Self::Webp), + "image/svg+xml" => Some(Self::Svg), + _ => None, + } + } + + fn mime(self) -> &'static str { + match self { + Self::Png => "image/png", + Self::Jpeg => "image/jpeg", + Self::Webp => "image/webp", + Self::Svg => "image/svg+xml", + } + } +} + +/// 链接里的 `iconUrl` 形状是否可用:https、域名主机、默认端口、没有账号密码。 +/// 解析阶段用它过滤,下载时每一跳重定向也再过一遍。 +pub(crate) fn parse_icon_url(raw: &str) -> Option { + let raw = raw.trim(); + if raw.is_empty() || raw.len() > MAX_ICON_URL_LENGTH { + return None; + } + let url = Url::parse(raw).ok()?; + if url.scheme() != "https" + || !url.username().is_empty() + || url.password().is_some() + || url.port().is_some() + { + return None; + } + // WHATWG 解析会把 0x7f.1、2130706433 之类的写法规整成 IPv4,这里一并挡掉 + let Some(Host::Domain(domain)) = url.host() else { + return None; + }; + let domain = domain.trim_end_matches('.'); + if !domain.contains('.') || domain == "localhost" || domain.ends_with(".localhost") { + return None; + } + Some(url) +} + +/// 只放行公网单播地址 +fn is_public_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => is_public_ipv4(v4), + IpAddr::V6(v6) => is_public_ipv6(v6), + } +} + +/// 198.18.0.0/15(基准测试网段)故意放行:Clash、Surge 等工具的 TUN 模式(fake-ip)把所有 +/// 域名都解析到这个网段,拒绝它就等于让这些用户永远拿不到图标。 +/// +/// 代价是 fake-ip 下核对不到代理最终连接的真实地址:链接指向一个用公开证书的内网域名时, +/// 代理会解析到内网地址并完成 TLS 握手,我们会对它发出一次 GET。接受这个代价的理由: +/// 这次请求不带 Cookie 和认证,响应也不会回到链接发起方手里;而用户点链接时正开着 +/// 发起方的网页,那个网页用 `` 就能从同一台机器、经同一个 TUN 发出同样的请求, +/// 还带着浏览器的 Cookie(浏览器在 fake-ip 下看到的也是这个网段)。这里没有给对方新的能力。 +fn is_public_ipv4(ip: Ipv4Addr) -> bool { + let [a, b, c, _] = ip.octets(); + !(a == 0 // 本网络 0.0.0.0/8 + || a == 10 // 私网 + || a == 127 // 回环 + || (a == 100 && (64..128).contains(&b)) // CGNAT 100.64.0.0/10 + || (a == 169 && b == 254) // 链路本地 + || (a == 172 && (16..32).contains(&b)) // 私网 + || (a == 192 && b == 0 && c == 0) // IETF 协议分配 192.0.0.0/24 + || (a == 192 && b == 0 && c == 2) // 文档 + || (a == 192 && b == 168) // 私网 + || (a == 198 && b == 51 && c == 100) // 文档 + || (a == 203 && b == 0 && c == 113) // 文档 + || a >= 224) // 组播、保留、广播 +} + +fn is_public_ipv6(ip: Ipv6Addr) -> bool { + let segments = ip.segments(); + // NAT64 众所周知前缀 64:ff9b::/96:纯 IPv6 网络下访问 IPv4 站点会解析成它,按内嵌地址判断 + if segments[..6] == [0x64, 0xff9b, 0, 0, 0, 0] { + return is_public_ipv4(embedded_ipv4(segments[6], segments[7])); + } + // 只认全球单播 2000::/3,回环、ULA、链路本地、组播、IPv4 映射地址都不在里面 + if segments[0] & 0xe000 != 0x2000 { + return false; + } + // 2001::/23 是 IETF 协议分配(含 Teredo),2001:db8::/32 是文档地址 + if segments[0] == 0x2001 && (segments[1] < 0x0200 || segments[1] == 0x0db8) { + return false; + } + // 6to4 2002::/16 内嵌 IPv4 + if segments[0] == 0x2002 { + return is_public_ipv4(embedded_ipv4(segments[1], segments[2])); + } + true +} + +fn embedded_ipv4(high: u16, low: u16) -> Ipv4Addr { + let [a, b] = high.to_be_bytes(); + let [c, d] = low.to_be_bytes(); + Ipv4Addr::new(a, b, c, d) +} + +/// 按文件头识别格式 +fn sniff_format(bytes: &[u8]) -> Option { + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + return Some(IconFormat::Png); + } + if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + return Some(IconFormat::Jpeg); + } + if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + return Some(IconFormat::Webp); + } + // SVG 是文本:去掉 BOM 和空白后以 `<` 开头(` Option<(u32, u32)> { + match format { + IconFormat::Png => { + if bytes.len() < 24 || &bytes[12..16] != b"IHDR" { + return None; + } + Some((be_u32(&bytes[16..20]), be_u32(&bytes[20..24]))) + } + IconFormat::Jpeg => jpeg_dimensions(bytes), + IconFormat::Webp => webp_dimensions(bytes), + IconFormat::Svg => None, + } +} + +fn be_u32(bytes: &[u8]) -> u32 { + u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) +} + +fn jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { + let mut offset = 2; + while offset + 4 <= bytes.len() { + if bytes[offset] != 0xff { + return None; + } + let marker = bytes[offset + 1]; + // 填充字节 + if marker == 0xff { + offset += 1; + continue; + } + // 没有长度字段的标记 + if marker == 0x01 || (0xd0..=0xd7).contains(&marker) { + offset += 2; + continue; + } + let length = u16::from_be_bytes([bytes[offset + 2], bytes[offset + 3]]) as usize; + if length < 2 { + return None; + } + // SOF0–SOF15,排除 DHT(C4)、JPG(C8)、DAC(CC) + if (0xc0..=0xcf).contains(&marker) && !matches!(marker, 0xc4 | 0xc8 | 0xcc) { + let segment = bytes.get(offset + 4..offset + 9)?; + let height = u16::from_be_bytes([segment[1], segment[2]]) as u32; + let width = u16::from_be_bytes([segment[3], segment[4]]) as u32; + return Some((width, height)); + } + offset += 2 + length; + } + None +} + +fn webp_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { + let chunk = bytes.get(12..16)?; + match chunk { + // 有损:帧头起始码 9d 01 2a 之后是 14 位宽高 + b"VP8 " => { + let frame = bytes.get(20..30)?; + if frame[3..6] != [0x9d, 0x01, 0x2a] { + return None; + } + let width = u16::from_le_bytes([frame[6], frame[7]]) & 0x3fff; + let height = u16::from_le_bytes([frame[8], frame[9]]) & 0x3fff; + Some((width as u32, height as u32)) + } + // 无损:签名 0x2f 之后按位打包的 14 位 (宽-1)、(高-1) + b"VP8L" => { + let header = bytes.get(20..25)?; + if header[0] != 0x2f { + return None; + } + let bits = u32::from_le_bytes([header[1], header[2], header[3], header[4]]); + Some(((bits & 0x3fff) + 1, ((bits >> 14) & 0x3fff) + 1)) + } + // 扩展:24 位 (宽-1)、(高-1) + b"VP8X" => { + let header = bytes.get(24..30)?; + let width = u32::from_le_bytes([header[0], header[1], header[2], 0]) + 1; + let height = u32::from_le_bytes([header[3], header[4], header[5], 0]) + 1; + Some((width, height)) + } + _ => None, + } +} + +/// 核对下载到的内容,合格就转成 data URI +fn body_to_data_uri(content_type: Option<&str>, bytes: &[u8]) -> Result { + let declared = content_type + .and_then(IconFormat::from_content_type) + .ok_or_else(|| format!("unsupported content type {content_type:?}"))?; + let actual = sniff_format(bytes).ok_or("content is not a supported image")?; + if declared != actual { + return Err(format!( + "content type {} does not match the file ({})", + declared.mime(), + actual.mime() + )); + } + match actual { + IconFormat::Svg => { + std::str::from_utf8(bytes).map_err(|_| "SVG is not valid UTF-8")?; + } + format => { + let (width, height) = + read_dimensions(format, bytes).ok_or("cannot read image dimensions")?; + if width == 0 + || height == 0 + || width > MAX_SOURCE_DIMENSION + || height > MAX_SOURCE_DIMENSION + { + return Err(format!("image is {width}×{height}")); + } + } + } + Ok(format!( + "data:{};base64,{}", + actual.mime(), + base64::engine::general_purpose::STANDARD.encode(bytes) + )) +} + +/// reqwest 的错误文本自带完整 URL(含查询参数,可能是 CDN 签名或令牌),而重定向之后的 +/// 地址也没经过 `url_for_log`。去掉 URL,只留错误类型和底层原因写进日志。 +fn describe_request_error(error: reqwest::Error) -> String { + let error = error.without_url(); + let mut text = error.to_string(); + let mut source = std::error::Error::source(&error); + while let Some(cause) = source { + text.push_str(": "); + text.push_str(&cause.to_string()); + source = cause.source(); + } + text +} + +/// 解析域名,要求每个地址都是公网地址。有一个不是就整体拒绝,不挑着用。 +async fn resolve_public(url: &Url) -> Result<(String, Vec), String> { + let Some(Host::Domain(domain)) = url.host() else { + return Err("host is not a domain".to_string()); + }; + let domain = domain.to_string(); + let addrs: Vec = tokio::net::lookup_host((domain.as_str(), 443)) + .await + .map_err(|e| format!("DNS lookup failed: {e}"))? + .collect(); + if addrs.is_empty() { + return Err("DNS returned no addresses".to_string()); + } + if let Some(addr) = addrs.iter().find(|addr| !is_public_ip(addr.ip())) { + return Err(format!( + "{domain} resolves to non-public address {}", + addr.ip() + )); + } + Ok((domain, addrs)) +} + +async fn fetch_unbounded(raw_url: &str) -> Result { + let mut url = parse_icon_url(raw_url).ok_or("URL is not an allowed https address")?; + for _ in 0..=MAX_REDIRECTS { + let (domain, addrs) = resolve_public(&url).await?; + // 每一跳单独建客户端:把这个域名钉在刚核对过的地址上 + let client = reqwest::Client::builder() + .no_proxy() + .https_only(true) + .redirect(reqwest::redirect::Policy::none()) + .referer(false) + .resolve_to_addrs(&domain, &addrs) + .user_agent(concat!("CC-Switch/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|e| format!("failed to build client: {}", describe_request_error(e)))?; + let mut response = client + .get(url.clone()) + .header( + reqwest::header::ACCEPT, + "image/png,image/jpeg,image/webp,image/svg+xml", + ) + .send() + .await + .map_err(|e| format!("request failed: {}", describe_request_error(e)))?; + + let status = response.status(); + if status.is_redirection() { + let location = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|value| value.to_str().ok()) + .ok_or("redirect without a Location")?; + let next = url + .join(location) + .map_err(|_| "redirect Location is not a URL")?; + url = parse_icon_url(next.as_str()).ok_or("redirect target is not allowed")?; + continue; + } + if !status.is_success() { + return Err(format!("HTTP {status}")); + } + if response + .content_length() + .is_some_and(|length| length > MAX_ICON_BYTES as u64) + { + return Err("response is larger than 256 KB".to_string()); + } + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|e| format!("failed to read body: {}", describe_request_error(e)))? + { + if body.len() + chunk.len() > MAX_ICON_BYTES { + return Err("response is larger than 256 KB".to_string()); + } + body.extend_from_slice(&chunk); + } + return body_to_data_uri(content_type.as_deref(), &body); + } + Err("too many redirects".to_string()) +} + +/// 下载链接里的图标,返回带核对过类型的 data URI。失败原因只进日志。 +pub async fn fetch_deeplink_icon(raw_url: &str) -> Option { + let result = match tokio::time::timeout(FETCH_TIMEOUT, fetch_unbounded(raw_url)).await { + Ok(result) => result, + Err(_) => Err("timed out".to_string()), + }; + match result { + Ok(data_uri) => Some(data_uri), + Err(reason) => { + log::info!( + "Skipped deep link icon {}: {reason}", + crate::url_for_log(raw_url) + ); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn icon_url_shape_rules() { + for ok in [ + "https://example.com/logo.png", + "https://cdn.example.com/a/b.svg?v=2", + " https://example.com/logo.png ", + ] { + assert!(parse_icon_url(ok).is_some(), "{ok} should be accepted"); + } + for bad in [ + "http://example.com/logo.png", + "https://127.0.0.1/logo.png", + "https://2130706433/logo.png", + "https://0x7f.1/logo.png", + "https://[::1]/logo.png", + "https://localhost/logo.png", + "https://foo.localhost/logo.png", + "https://intranet/logo.png", + "https://user:pass@example.com/logo.png", + "https://example.com:8443/logo.png", + "data:image/png;base64,AAAA", + "file:///etc/passwd", + "", + ] { + assert!(parse_icon_url(bad).is_none(), "{bad} should be rejected"); + } + let long = format!("https://example.com/{}", "a".repeat(MAX_ICON_URL_LENGTH)); + assert!(parse_icon_url(&long).is_none()); + } + + #[test] + fn only_public_addresses_pass() { + for ip in [ + "0.0.0.0", + "10.1.2.3", + "127.0.0.1", + "100.64.0.1", + "100.127.255.255", + "169.254.169.254", + "172.16.0.1", + "172.31.255.255", + "192.0.0.8", + "192.168.1.1", + "224.0.0.1", + "255.255.255.255", + "::", + "::1", + "::ffff:127.0.0.1", + "::ffff:8.8.8.8", + "fc00::1", + "fd12:3456::1", + "fe80::1", + "ff02::1", + "2001:db8::1", + "2001::1", + "2002:7f00:1::", + "2002:c0a8:101::", + "64:ff9b::a00:1", + ] { + let ip: IpAddr = ip.parse().unwrap(); + assert!(!is_public_ip(ip), "{ip} should be rejected"); + } + for ip in [ + "8.8.8.8", + "1.1.1.1", + "100.63.255.255", + "100.128.0.1", + "172.15.0.1", + "172.32.0.1", + "198.18.0.15", + "198.19.255.255", + "2606:4700::1111", + "2400:cb00::1", + "2002:808:808::", + "64:ff9b::808:808", + ] { + let ip: IpAddr = ip.parse().unwrap(); + assert!(is_public_ip(ip), "{ip} should be accepted"); + } + } + + fn png_bytes(width: u32, height: u32) -> Vec { + let mut bytes = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR".to_vec(); + bytes.extend_from_slice(&width.to_be_bytes()); + bytes.extend_from_slice(&height.to_be_bytes()); + bytes.extend_from_slice(&[8, 6, 0, 0, 0]); + bytes + } + + fn jpeg_bytes(width: u16, height: u16) -> Vec { + let mut bytes = vec![0xff, 0xd8]; + // APP0 段,长度 16 + bytes.extend_from_slice(&[0xff, 0xe0, 0x00, 0x10]); + bytes.extend_from_slice(&[0; 14]); + // SOF0 + bytes.extend_from_slice(&[0xff, 0xc0, 0x00, 0x11, 0x08]); + bytes.extend_from_slice(&height.to_be_bytes()); + bytes.extend_from_slice(&width.to_be_bytes()); + bytes.extend_from_slice(&[3; 10]); + bytes + } + + fn webp_vp8x(width: u32, height: u32) -> Vec { + let mut bytes = b"RIFF\0\0\0\0WEBPVP8X\x0a\0\0\0\0\0\0\0".to_vec(); + bytes.extend_from_slice(&(width - 1).to_le_bytes()[..3]); + bytes.extend_from_slice(&(height - 1).to_le_bytes()[..3]); + bytes + } + + fn webp_vp8l(width: u32, height: u32) -> Vec { + let mut bytes = b"RIFF\0\0\0\0WEBPVP8L\0\0\0\0\x2f".to_vec(); + let bits = (width - 1) | ((height - 1) << 14); + bytes.extend_from_slice(&bits.to_le_bytes()); + bytes + } + + fn webp_vp8(width: u16, height: u16) -> Vec { + let mut bytes = b"RIFF\0\0\0\0WEBPVP8 \0\0\0\0\0\0\0\x9d\x01\x2a".to_vec(); + bytes.extend_from_slice(&width.to_le_bytes()); + bytes.extend_from_slice(&height.to_le_bytes()); + bytes + } + + #[test] + fn reads_dimensions_of_each_bitmap_format() { + assert_eq!( + read_dimensions(IconFormat::Png, &png_bytes(64, 32)), + Some((64, 32)) + ); + assert_eq!( + read_dimensions(IconFormat::Jpeg, &jpeg_bytes(300, 200)), + Some((300, 200)) + ); + assert_eq!( + read_dimensions(IconFormat::Webp, &webp_vp8x(5000, 10)), + Some((5000, 10)) + ); + assert_eq!( + read_dimensions(IconFormat::Webp, &webp_vp8l(128, 96)), + Some((128, 96)) + ); + assert_eq!( + read_dimensions(IconFormat::Webp, &webp_vp8(48, 40)), + Some((48, 40)) + ); + } + + #[test] + fn body_must_match_its_content_type_and_size_limits() { + let png = png_bytes(64, 64); + let uri = body_to_data_uri(Some("image/png; charset=binary"), &png).unwrap(); + assert!(uri.starts_with("data:image/png;base64,")); + + // 类型和内容对不上 + assert!(body_to_data_uri(Some("image/jpeg"), &png).is_err()); + assert!(body_to_data_uri(Some("application/octet-stream"), &png).is_err()); + assert!(body_to_data_uri(None, &png).is_err()); + // 声明 PNG、实际是 GIF + assert!(body_to_data_uri(Some("image/png"), b"GIF89a\x00\x02\x00\x02").is_err()); + // 体积小、宽高巨大 + assert!(body_to_data_uri(Some("image/png"), &png_bytes(50_000, 50_000)).is_err()); + assert!(body_to_data_uri(Some("image/png"), &png_bytes(0, 10)).is_err()); + assert!(body_to_data_uri(Some("image/webp"), &webp_vp8x(5000, 10)).is_err()); + assert!(body_to_data_uri(Some("image/jpeg"), &jpeg_bytes(300, 200)).is_ok()); + // 文件头被截断 + assert!(body_to_data_uri(Some("image/png"), &png[..20]).is_err()); + } + + #[test] + fn svg_is_recognized_only_as_svg_text() { + let svg = + b"\xef\xbb\xbf\n\n"; + assert!(body_to_data_uri(Some("image/svg+xml"), svg) + .unwrap() + .starts_with("data:image/svg+xml;base64,")); + assert!(body_to_data_uri(Some("image/svg+xml"), b"hi").is_err()); + assert!(body_to_data_uri(Some("text/html"), b"").is_err()); + assert!(body_to_data_uri(Some("image/svg+xml"), b"").is_err()); + } + + #[tokio::test] + async fn request_errors_leave_the_url_out_of_the_log() { + // 连本机一个没人监听的端口,拿到一个带 URL 的真实 reqwest 错误 + let error = reqwest::Client::builder() + .no_proxy() + .build() + .unwrap() + .get("http://127.0.0.1:9/logo.png?token=SECRET_TOKEN") + .send() + .await + .expect_err("nothing listens on port 9"); + assert!(error.to_string().contains("SECRET_TOKEN")); + + let described = describe_request_error(error); + assert!(!described.contains("SECRET_TOKEN"), "{described}"); + assert!(!described.contains("127.0.0.1"), "{described}"); + } + + #[tokio::test] + async fn refuses_disallowed_urls_without_network() { + assert_eq!(fetch_deeplink_icon("http://example.com/a.png").await, None); + assert_eq!(fetch_deeplink_icon("https://127.0.0.1/a.png").await, None); + assert_eq!(fetch_deeplink_icon("https://localhost/a.png").await, None); + } +} diff --git a/src-tauri/src/deeplink/mod.rs b/src-tauri/src/deeplink/mod.rs index 28394850e..2f84d5a7b 100644 --- a/src-tauri/src/deeplink/mod.rs +++ b/src-tauri/src/deeplink/mod.rs @@ -10,6 +10,7 @@ mod codex_link; mod grok_link; +mod icon_fetch; mod mcp; mod parser; mod prompt; @@ -25,6 +26,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; // Re-export public API +pub use icon_fetch::fetch_deeplink_icon; pub use mcp::import_mcp_from_deeplink; pub use parser::parse_deeplink_url; pub use prompt::import_prompt_from_deeplink; @@ -118,6 +120,10 @@ pub struct DeepLinkImportRequest { /// Optional built-in provider icon name; anything else is dropped by the parser #[serde(skip_serializing_if = "Option::is_none")] pub icon: Option, + /// 供应商图标的下载地址(https)。确认框打开时由 `fetch_deeplink_icon` 下载、前端重新编码后 + /// 放进 `icon`;导入本身从不下载它,下载失败就用 `icon` 里的内置图标或首字母 + #[serde(skip_serializing_if = "Option::is_none")] + pub icon_url: Option, /// Optional model name #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, diff --git a/src-tauri/src/deeplink/parser.rs b/src-tauri/src/deeplink/parser.rs index a5e962106..9080e5b2b 100644 --- a/src-tauri/src/deeplink/parser.rs +++ b/src-tauri/src/deeplink/parser.rs @@ -147,6 +147,12 @@ fn parse_provider_deeplink( .get("icon") .map(|v| v.trim().to_lowercase()) .filter(|v| is_builtin_icon_name(v)); + // iconUrl 只在确认框里由专门的命令下载(见 `icon_fetch`)。形状不合格的地址同样丢掉, + // 不让整条链接失败 + let icon_url = params + .get("iconUrl") + .and_then(|v| super::icon_fetch::parse_icon_url(v)) + .map(|url| url.to_string()); let config = params.get("config").cloned(); let config_format = params.get("configFormat").cloned(); // 远程配置还没实现。在解析阶段就拒绝,用户当场看到原因,而不是确认导入之后才失败 @@ -197,6 +203,7 @@ fn parse_provider_deeplink( config, config_format, config_url, + icon_url, usage_enabled, usage_script, usage_api_key, @@ -286,6 +293,7 @@ fn parse_prompt_deeplink( config: None, config_format: None, config_url: None, + icon_url: None, usage_enabled: None, usage_script: None, usage_api_key: None, @@ -361,6 +369,7 @@ fn parse_mcp_deeplink( directory: None, branch: None, config_url: None, + icon_url: None, usage_enabled: None, usage_script: None, usage_api_key: None, @@ -417,6 +426,7 @@ fn parse_skill_deeplink( config: None, config_format: None, config_url: None, + icon_url: None, usage_enabled: None, usage_script: None, usage_api_key: None, diff --git a/src-tauri/src/deeplink/tests.rs b/src-tauri/src/deeplink/tests.rs index 49bf57ef3..2d8c1eb55 100644 --- a/src-tauri/src/deeplink/tests.rs +++ b/src-tauri/src/deeplink/tests.rs @@ -97,6 +97,35 @@ fn test_parse_deeplink_drops_icon_that_is_not_a_builtin_name() { } } +#[test] +fn test_parse_deeplink_keeps_only_allowed_icon_urls() { + let base = "ccswitch://v1/import?resource=provider&app=claude&name=Test&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test&icon=deepseek"; + + let request = parse_deeplink_url(&format!( + "{base}&iconUrl=https%3A%2F%2Fcdn.example.com%2Flogo.svg" + )) + .unwrap(); + assert_eq!( + request.icon_url.as_deref(), + Some("https://cdn.example.com/logo.svg") + ); + // 内置图标照常保留,下载失败时用它 + assert_eq!(request.icon.as_deref(), Some("deepseek")); + + // http、IP、内网主机名、带端口:丢掉,链接本身照常解析 + for url in [ + "http%3A%2F%2Fexample.com%2Flogo.png", + "https%3A%2F%2F127.0.0.1%2Flogo.png", + "https%3A%2F%2F%5B%3A%3A1%5D%2Flogo.png", + "https%3A%2F%2Flocalhost%2Flogo.png", + "https%3A%2F%2Fexample.com%3A8080%2Flogo.png", + "data%3Aimage%2Fpng%3Bbase64%2CiVBORw0KGgo%3D", + ] { + let request = parse_deeplink_url(&format!("{base}&iconUrl={url}")).unwrap(); + assert_eq!(request.icon_url, None, "{url}"); + } +} + #[test] fn test_parse_deeplink_with_notes() { let url = "ccswitch://v1/import?resource=provider&app=codex&name=Codex&homepage=https%3A%2F%2Fcodex.com&endpoint=https%3A%2F%2Fapi.codex.com&apiKey=key123¬es=Test%20notes"; @@ -308,6 +337,7 @@ fn test_build_gemini_provider_with_model() { config: None, config_format: None, config_url: None, + icon_url: None, apps: None, repo: None, directory: None, @@ -362,6 +392,7 @@ fn test_build_gemini_provider_without_model() { config: None, config_format: None, config_url: None, + icon_url: None, apps: None, repo: None, directory: None, @@ -409,6 +440,7 @@ fn test_deeplink_usage_script_does_not_copy_provider_credentials() { config: None, config_format: None, config_url: None, + icon_url: None, apps: None, repo: None, directory: None, @@ -457,6 +489,7 @@ fn usage_script_request(code: &str, usage_enabled: Option) -> DeepLinkImpo config: None, config_format: None, config_url: None, + icon_url: None, apps: None, repo: None, directory: None, @@ -574,6 +607,7 @@ fn test_deeplink_usage_script_omits_explicit_credentials_that_match_provider() { config: None, config_format: None, config_url: None, + icon_url: None, apps: None, repo: None, directory: None, @@ -623,6 +657,7 @@ fn test_deeplink_usage_script_preserves_distinct_usage_credentials() { config: None, config_format: None, config_url: None, + icon_url: None, apps: None, repo: None, directory: None, @@ -677,6 +712,7 @@ fn test_parse_and_merge_config_claude() { config: Some(config_b64), config_format: Some("json".to_string()), config_url: None, + icon_url: None, apps: None, repo: None, directory: None, @@ -801,6 +837,7 @@ fn test_parse_and_merge_config_url_override() { config: Some(config_b64), config_format: Some("json".to_string()), config_url: None, + icon_url: None, apps: None, repo: None, directory: None, @@ -865,6 +902,7 @@ fn test_build_claude_provider_preserves_custom_env_fields() { config: Some(config_b64), config_format: Some("json".to_string()), config_url: None, + icon_url: None, apps: None, repo: None, directory: None, @@ -921,6 +959,7 @@ fn test_build_claude_provider_without_config_unchanged() { config: None, config_format: None, config_url: None, + icon_url: None, apps: None, repo: None, directory: None, @@ -1867,3 +1906,51 @@ fn test_first_switch_mode_provider_takes_effect_when_the_toggle_is_on() { .expect("client config written"); assert!(settings.contains("https://api.example.com/v1")); } + +// ============================================================================= +// 确认框下载的图标经前端重新编码后放进 icon,导入时按自定义图标的规则校验 +// ============================================================================= + +fn png_data_uri(width: u32, height: u32) -> String { + use base64::Engine; + let mut out = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut out, width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().unwrap(); + writer + .write_image_data(&vec![0u8; (width * height * 4) as usize]) + .unwrap(); + } + format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(out) + ) +} + +#[test] +#[serial_test::serial] +fn test_import_stores_a_valid_custom_icon_and_rejects_a_broken_one() { + let _home = TestHomeGuard::new(); + let link = format!( + "{}&iconUrl=https%3A%2F%2Fcdn.example.com%2Flogo.png", + provider_link("claude", None) + ); + + let icon = png_data_uri(96, 96); + let mut request = parse_deeplink_url(&link).expect("parse link"); + request.icon = Some(icon.clone()); + let db = Arc::new(Database::memory().expect("create memory db")); + let state = AppState::new(db); + let id = super::import_provider_from_deeplink(&state, request).expect("import provider"); + let stored = state.db.get_provider_by_id(&id, "claude").unwrap().unwrap(); + assert_eq!(stored.icon.as_deref(), Some(icon.as_str())); + + // 截断的 PNG:保存校验拒绝 + let mut request = parse_deeplink_url(&link).expect("parse link"); + request.icon = Some(icon[..icon.len() - 16].to_string()); + let db = Arc::new(Database::memory().expect("create memory db")); + let state = AppState::new(db); + assert!(super::import_provider_from_deeplink(&state, request).is_err()); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0e8d9b5fe..d1a60981a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1566,6 +1566,7 @@ pub fn run() { // Deep link import commands::parse_deeplink, commands::merge_deeplink_config, + commands::fetch_deeplink_icon, commands::import_from_deeplink, commands::import_from_deeplink_unified, commands::peek_pending_deeplink, diff --git a/src/components/DeepLinkImportDialog.tsx b/src/components/DeepLinkImportDialog.tsx index 53ad7a0ae..d21b0171d 100644 --- a/src/components/DeepLinkImportDialog.tsx +++ b/src/components/DeepLinkImportDialog.tsx @@ -33,6 +33,7 @@ import { import { decodeBase64Utf8 } from "@/lib/utils/base64"; import { Switch } from "@/components/ui/switch"; import { getAppLabel, isAdditiveAppId } from "@/config/appConfig"; +import { normalizeIconDataUri } from "@/utils/customIcon"; const API_FORMAT_LABELS: Record< NonNullable, @@ -44,6 +45,20 @@ const API_FORMAT_LABELS: Record< gemini_native: "Gemini", }; +/** + * 下载链接里的 `iconUrl`,按上传图标的流程重新编码成 96×96 PNG。 + * 任何一步失败都返回 undefined,确认框退回链接里的内置图标或首字母,不提示用户。 + */ +async function loadLinkIcon(url: string): Promise { + try { + const dataUri = await deeplinkApi.fetchDeeplinkIcon(url); + return dataUri ? await normalizeIconDataUri(dataUri) : undefined; + } catch (error) { + console.warn("Failed to load the deep link icon", error); + return undefined; + } +} + /** * 「导入后立即生效」开关的初始值:链接里的 `enabled` 只是建议,导入时以开关为准。 * @@ -73,6 +88,27 @@ export function DeepLinkImportDialog() { if (request) setActivateAfterImport(initialActivation(request)); }, [request]); + // 链接带 iconUrl 时,确认框一打开就下载图标并直接预览,不再单独询问; + // 导入时等这次下载结束(最多几秒),拿到的图标随请求交给后端,后端保存时再完整校验 + const [linkIcon, setLinkIcon] = useState(); + const linkIconTaskRef = useRef>(); + useEffect(() => { + setLinkIcon(undefined); + const url = request?.resource === "provider" ? request.iconUrl : undefined; + if (!url) { + linkIconTaskRef.current = undefined; + return; + } + let stale = false; + linkIconTaskRef.current = loadLinkIcon(url).then((icon) => { + if (!stale) setLinkIcon(icon); + return icon; + }); + return () => { + stale = true; + }; + }, [request]); + // 容错判断:MCP 导入结果可能缺少 type 字段 const isMcpImportResult = ( value: unknown, @@ -204,10 +240,18 @@ export function DeepLinkImportDialog() { try { // 供应商和提示词的「立即生效」由用户在确认框里决定,覆盖链接里的值 - const payload = + let payload: DeepLinkImportRequest = request.resource === "provider" || request.resource === "prompt" ? { ...request, enabled: activateAfterImport } : request; + if (request.resource === "provider" && request.iconUrl) { + const icon = await linkIconTaskRef.current; + payload = { + ...payload, + icon: icon ?? request.icon, + iconUrl: undefined, + }; + } const result = await deeplinkApi.importFromDeeplink(payload); const refreshMcp = async (summary: { importedCount: number; @@ -423,11 +467,12 @@ export function DeepLinkImportDialog() { {(request.resource === "provider" || !request.resource) && ( <> {/* Provider Icon - enlarge and center near the top */} - {request.icon && ( + {(request.icon || request.iconUrl) && (
+ {/* 下载中或下载失败时显示内置图标或首字母 */} diff --git a/src/lib/api/deeplink.ts b/src/lib/api/deeplink.ts index fe648f76f..dd665185a 100644 --- a/src/lib/api/deeplink.ts +++ b/src/lib/api/deeplink.ts @@ -24,6 +24,8 @@ export interface DeepLinkImportRequest { endpoint?: string; apiKey?: string; icon?: string; + /** 图标下载地址;确认框里经 `fetchDeeplinkIcon` 下载、重新编码后放进 `icon` */ + iconUrl?: string; model?: string; notes?: string; haikuModel?: string; @@ -129,6 +131,14 @@ export const deeplinkApi = { return invoke("merge_deeplink_config", { request }); }, + /** + * 下载链接里 `iconUrl` 指向的图标,返回核对过类型的 data URI; + * 任何失败(地址不合规、超时、类型不符等)都返回 null,原因只进后端日志 + */ + fetchDeeplinkIcon: async (url: string): Promise => { + return invoke("fetch_deeplink_icon", { url }); + }, + /** * Import a resource from a deep link request (unified handler) * @param request The deep link import request diff --git a/src/utils/customIcon.ts b/src/utils/customIcon.ts index 0da0b07f3..9f82d5c4a 100644 --- a/src/utils/customIcon.ts +++ b/src/utils/customIcon.ts @@ -182,7 +182,30 @@ export async function normalizeIconFile(file: File): Promise { if (!mimeType) throw new CustomIconError("unsupported"); if (file.size > MAX_UPLOAD_BYTES) throw new CustomIconError("tooLarge"); - const img = await loadImage(await readAsDataUrl(file, mimeType)); + return rasterize(await readAsDataUrl(file, mimeType)); +} + +/** 后端下载的图标最大 256 KB,base64 之后再留一点前缀的余量 */ +const MAX_DOWNLOADED_DATA_URI_LENGTH = Math.ceil((256 * 1024 * 4) / 3) + 64; + +/** + * 把深链接 `iconUrl` 下载回来的图片(后端 `fetch_deeplink_icon` 核对过类型的 data URI) + * 转成可以存进 `provider.icon` 的 PNG data URI,流程和上传一样。 + */ +export async function normalizeIconDataUri(dataUri: string): Promise { + const accepted = Object.keys(ACCEPTED_TYPES).some((type) => + dataUri.startsWith(`data:${type};base64,`), + ); + if (!accepted) throw new CustomIconError("unsupported"); + if (dataUri.length > MAX_DOWNLOADED_DATA_URI_LENGTH) { + throw new CustomIconError("tooLarge"); + } + return rasterize(dataUri); +} + +/** 加载图片并画成 96×96 PNG data URI */ +async function rasterize(src: string): Promise { + const img = await loadImage(src); // 没写宽高的 SVG 可能报 0,按正方形处理 const width = img.naturalWidth || OUTPUT_SIZE; diff --git a/tests/components/DeepLinkImportDialog.test.tsx b/tests/components/DeepLinkImportDialog.test.tsx index e856eb823..688506243 100644 --- a/tests/components/DeepLinkImportDialog.test.tsx +++ b/tests/components/DeepLinkImportDialog.test.tsx @@ -8,6 +8,16 @@ import i18n from "i18next"; import { emitTauriEvent } from "../msw/tauriMocks"; import { server } from "../msw/server"; import { peekPendingDeepLink, setPendingDeepLink } from "../msw/state"; +import { pngDataUri } from "../fixtures/customIcon"; + +// jsdom 没有 canvas,重新编码这一步换成可控的假实现 +const { normalizeIconDataUri } = vi.hoisted(() => ({ + normalizeIconDataUri: vi.fn<(dataUri: string) => Promise>(), +})); +vi.mock("@/utils/customIcon", async (importOriginal) => ({ + ...(await importOriginal()), + normalizeIconDataUri, +})); // 模拟后端:先暂存链接,再发 deeplink-pending 通知前端来取 const deliverDeepLink = (request: Record) => { @@ -328,6 +338,132 @@ describe("DeepLinkImportDialog", () => { }); }); + describe("the link's iconUrl", () => { + const DOWNLOADED = "data:image/svg+xml;base64,PHN2Zy8+"; + const NORMALIZED = pngDataUri(96, 96); + + const iconLink = (extra: Record = {}) => ({ + version: "v1", + resource: "provider", + app: "claude", + name: "Icon Provider", + endpoint: "https://api.example.com", + apiKey: "sk-provider-key", + iconUrl: "https://cdn.example.com/logo.svg", + ...extra, + }); + + // 记下下载请求和导入请求;download 决定后端返回什么 + const mockBackend = (download: () => Promise) => { + const fetched: string[] = []; + const sent: Array> = []; + server.use( + http.post( + "http://tauri.local/fetch_deeplink_icon", + async ({ request }) => { + const { url } = (await request.json()) as { url: string }; + fetched.push(url); + return HttpResponse.json(await download()); + }, + ), + http.post( + "http://tauri.local/import_from_deeplink_unified", + async ({ request }) => { + const body = (await request.json()) as { + request: Record; + }; + sent.push(body.request); + return HttpResponse.json({ type: "provider", id: "p1" }); + }, + ), + ); + return { fetched, sent }; + }; + + afterEach(() => { + normalizeIconDataUri.mockReset(); + }); + + it("downloads the icon on open, previews it and imports it without asking", async () => { + normalizeIconDataUri.mockResolvedValue(NORMALIZED); + const { fetched, sent } = mockBackend(async () => DOWNLOADED); + const user = userEvent.setup(); + render(, { wrapper: Wrapper }); + act(() => deliverDeepLink(iconLink({ icon: "deepseek" }))); + + const preview = await screen.findByAltText("Icon Provider"); + expect(preview).toHaveAttribute("src", NORMALIZED); + expect(fetched).toEqual(["https://cdn.example.com/logo.svg"]); + expect(normalizeIconDataUri).toHaveBeenCalledWith(DOWNLOADED); + + await user.click(screen.getByRole("button", { name: "deeplink.import" })); + await waitFor(() => expect(sent).toHaveLength(1)); + expect(sent[0].icon).toBe(NORMALIZED); + expect(sent[0]).not.toHaveProperty("iconUrl"); + }); + + it("falls back to the built-in icon when the download fails", async () => { + const { sent } = mockBackend(async () => null); + const user = userEvent.setup(); + render(, { wrapper: Wrapper }); + act(() => deliverDeepLink(iconLink({ icon: "deepseek" }))); + + await user.click( + await screen.findByRole("button", { name: "deeplink.import" }), + ); + await waitFor(() => expect(sent).toHaveLength(1)); + expect(sent[0].icon).toBe("deepseek"); + expect(normalizeIconDataUri).not.toHaveBeenCalled(); + }); + + it("imports without an icon when re-encoding fails and there is no fallback", async () => { + normalizeIconDataUri.mockRejectedValue(new Error("tainted canvas")); + const { sent } = mockBackend(async () => DOWNLOADED); + const user = userEvent.setup(); + render(, { wrapper: Wrapper }); + act(() => deliverDeepLink(iconLink())); + + // 首字母占位 + expect(await screen.findByText("IP")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "deeplink.import" })); + await waitFor(() => expect(sent).toHaveLength(1)); + expect(sent[0].icon).toBeUndefined(); + }); + + it("waits for a download still in flight when the user imports", async () => { + normalizeIconDataUri.mockResolvedValue(NORMALIZED); + let finishDownload!: (value: string) => void; + const download = new Promise((resolve) => { + finishDownload = resolve; + }); + const { fetched, sent } = mockBackend(() => download); + const user = userEvent.setup(); + render(, { wrapper: Wrapper }); + act(() => deliverDeepLink(iconLink())); + + await waitFor(() => expect(fetched).toHaveLength(1)); + await user.click(screen.getByRole("button", { name: "deeplink.import" })); + // 下载没完成之前不提交 + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(sent).toHaveLength(0); + + finishDownload(DOWNLOADED); + await waitFor(() => expect(sent).toHaveLength(1)); + expect(sent[0].icon).toBe(NORMALIZED); + }); + + it("does not download for links without iconUrl", async () => { + const { fetched } = mockBackend(async () => DOWNLOADED); + render(, { wrapper: Wrapper }); + act(() => + deliverDeepLink(iconLink({ iconUrl: undefined, icon: "deepseek" })), + ); + + await screen.findByRole("button", { name: "deeplink.import" }); + expect(fetched).toHaveLength(0); + }); + }); + describe("when processing is interrupted after the link was read", () => { const coldStartLink = { kind: "import", diff --git a/tests/msw/handlers.ts b/tests/msw/handlers.ts index fbc1d4970..9f4715b9a 100644 --- a/tests/msw/handlers.ts +++ b/tests/msw/handlers.ts @@ -75,6 +75,8 @@ export const handlers = [ http.post(`${TAURI_ENDPOINT}/peek_pending_deeplink`, () => success(peekPendingDeepLink()), ), + // 默认下载失败:确认框退回内置图标或首字母 + http.post(`${TAURI_ENDPOINT}/fetch_deeplink_icon`, () => success(null)), http.post(`${TAURI_ENDPOINT}/ack_pending_deeplink`, async ({ request }) => { const { id } = await withJson<{ id: number }>(request); ackPendingDeepLink(id); diff --git a/tests/utils/customIcon.test.ts b/tests/utils/customIcon.test.ts index 86c8e86a7..ac296a053 100644 --- a/tests/utils/customIcon.test.ts +++ b/tests/utils/customIcon.test.ts @@ -5,6 +5,7 @@ import { MAX_CUSTOM_ICON_LENGTH, MAX_UPLOAD_BYTES, isCustomIcon, + normalizeIconDataUri, normalizeIconFile, } from "@/utils/customIcon"; import { pngDataUri } from "../fixtures/customIcon"; @@ -100,3 +101,25 @@ describe("normalizeIconFile", () => { expect(await rejectCode(svg)).toBe("tooLarge"); }); }); + +describe("normalizeIconDataUri", () => { + const expectCode = async (dataUri: string, code: string) => { + await expect(normalizeIconDataUri(dataUri)).rejects.toSatisfy( + (error: unknown) => + error instanceof CustomIconError && error.code === code, + ); + }; + + it("only takes the image types the backend download allows", async () => { + await expectCode("data:image/gif;base64,R0lGODlhAQABAAAAACw=", "unsupported"); + await expectCode("data:text/html;base64,PHN2Zy8+", "unsupported"); + await expectCode("https://example.com/logo.png", "unsupported"); + // 前缀里少了 ;base64 + await expectCode("data:image/png,abc", "unsupported"); + }); + + it("rejects data larger than the 256 KB download limit", async () => { + const oversized = `data:image/png;base64,${"A".repeat(400 * 1024)}`; + await expectCode(oversized, "tooLarge"); + }); +});