Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,5 @@ http = "1"
urlencoding = "2"
tokio = { version = "1", features = ["full"] }
warp = "0.3"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
regex = "1"
63 changes: 63 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::collections::HashSet;
use std::sync::Mutex;
use regex::Regex;
use tauri::{
menu::{MenuBuilder, MenuItemBuilder},
tray::TrayIconBuilder,
Expand Down Expand Up @@ -1137,6 +1138,67 @@ fn hide_all_webviews(app: tauri::AppHandle) -> Result<(), String> {
Ok(())
}

#[tauri::command]
async fn discover_site_icon(url: String) -> Result<Option<String>, String> {
let parsed_url = reqwest::Url::parse(&url).map_err(|e| e.to_string())?;
let client = reqwest::Client::builder()
.user_agent(USER_AGENT)
.redirect(reqwest::redirect::Policy::limited(5))
.timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| e.to_string())?;

let response = client
.get(parsed_url.clone())
.send()
.await
.map_err(|e| e.to_string())?;

if !response.status().is_success() {
return Ok(None);
}

let html = response.text().await.map_err(|e| e.to_string())?;
Ok(extract_site_icon_url(&parsed_url, &html))
}

fn extract_site_icon_url(base_url: &reqwest::Url, html: &str) -> Option<String> {
let link_tag_regex = Regex::new(r"(?is)<link\b[^>]*>").ok()?;
let rel_regex = Regex::new(r#"(?i)rel\s*=\s*["']([^"']+)["']"#).ok()?;
let href_regex = Regex::new(r#"(?i)href\s*=\s*["']([^"']+)["']"#).ok()?;

for tag_match in link_tag_regex.find_iter(html) {
let tag = tag_match.as_str();
let Some(rel_value) = rel_regex
.captures(tag)
.and_then(|captures| captures.get(1))
.map(|value| value.as_str().to_ascii_lowercase()) else {
continue;
};

if !rel_value.contains("icon") {
continue;
}

let Some(href) = href_regex
.captures(tag)
.and_then(|captures| captures.get(1))
.map(|value| value.as_str().trim()) else {
continue;
};

if href.is_empty() || href.starts_with("data:") {
continue;
}

if let Ok(resolved_url) = base_url.join(href) {
return Some(resolved_url.to_string());
}
}

None
}

#[derive(serde::Deserialize, serde::Serialize, Clone)]
struct CapturedMessage {
role: String,
Expand Down Expand Up @@ -1686,6 +1748,7 @@ pub fn run() {
}
})
.invoke_handler(tauri::generate_handler![
discover_site_icon,
switch_webview,
refresh_webview,
hide_all_webviews,
Expand Down
26 changes: 18 additions & 8 deletions src/hooks/useCachedIcon.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { getCachedIcon, cacheIcon } from '@/lib/icon-cache';
import { getServiceIconCandidates } from '@/lib/icon';
import { resolveServiceIconCandidates } from '@/lib/icon';

interface CachedIconState {
url: string | null;
loading: boolean;
candidateIndex: number;
candidateUrl: string | null;
resolved: boolean;
candidates: string[];
}

const iconStateCache = new Map<string, CachedIconState>();
Expand Down Expand Up @@ -42,9 +43,16 @@ async function loadIconWithCache(
serviceUrl: string,
iconUrl: string | undefined
): Promise<CachedIconState> {
const candidates = getServiceIconCandidates(serviceUrl, iconUrl);
const candidates = await resolveServiceIconCandidates(serviceUrl, iconUrl);
if (candidates.length === 0) {
return { url: null, loading: false, candidateIndex: -1, candidateUrl: null, resolved: false };
return {
url: null,
loading: false,
candidateIndex: -1,
candidateUrl: null,
resolved: false,
candidates: [],
};
}

for (const [index, candidate] of candidates.entries()) {
Expand All @@ -56,6 +64,7 @@ async function loadIconWithCache(
candidateIndex: index,
candidateUrl: candidate,
resolved: true,
candidates,
};
}
}
Expand All @@ -66,6 +75,7 @@ async function loadIconWithCache(
candidateIndex: 0,
candidateUrl: candidates[0] ?? null,
resolved: false,
candidates,
};
}

Expand Down Expand Up @@ -96,6 +106,7 @@ export function useCachedIcon(
candidateIndex: -1,
candidateUrl: null,
resolved: false,
candidates: [],
}
);
});
Expand Down Expand Up @@ -135,21 +146,20 @@ export function useCachedIcon(
}, [cacheKey, serviceUrl, iconUrl, reportResolvedCandidate]);

const handleError = useCallback(() => {
const candidates = getServiceIconCandidates(serviceUrl, iconUrl);

setState((prev) => {
const nextIndex = prev.candidateIndex + 1;
const nextState: CachedIconState = {
url: candidates[nextIndex] ?? null,
url: prev.candidates[nextIndex] ?? null,
loading: false,
candidateIndex: nextIndex,
candidateUrl: candidates[nextIndex] ?? null,
candidateUrl: prev.candidates[nextIndex] ?? null,
resolved: false,
candidates: prev.candidates,
};
iconStateCache.set(cacheKey, nextState);
return nextState;
});
}, [cacheKey, serviceUrl, iconUrl]);
}, [cacheKey]);

const handleLoad = useCallback(() => {
setState((prev) => {
Expand Down
34 changes: 33 additions & 1 deletion src/lib/icon.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { invoke } from '@tauri-apps/api/core';

const OFFICIAL_ICON_MAP: Record<string, string> = {
'deepseek.com': 'https://deepseek.com/favicon.ico',
'qianwen.com':
Expand Down Expand Up @@ -71,7 +73,7 @@ export async function findWorkingIconCandidate(
explicitIconUrl?: string,
options?: { timeoutMs?: number; ImageCtor?: typeof Image }
): Promise<string | null> {
const candidates = getServiceIconCandidates(serviceUrl, explicitIconUrl);
const candidates = await resolveServiceIconCandidates(serviceUrl, explicitIconUrl);

for (const candidate of candidates) {
try {
Expand All @@ -85,6 +87,27 @@ export async function findWorkingIconCandidate(
return null;
}

export async function resolveServiceIconCandidates(
serviceUrl: string,
explicitIconUrl?: string
): Promise<string[]> {
const baseCandidates = getServiceIconCandidates(serviceUrl, explicitIconUrl);
const discoveredIconUrl = await discoverSiteIcon(serviceUrl);

if (!discoveredIconUrl || baseCandidates.includes(discoveredIconUrl)) {
return baseCandidates;
}

const explicitIsPreferred =
!!explicitIconUrl && !isThirdPartyFallbackIcon(explicitIconUrl) && baseCandidates[0] === explicitIconUrl;

if (explicitIsPreferred) {
return [baseCandidates[0], discoveredIconUrl, ...baseCandidates.slice(1)];
}

return [discoveredIconUrl, ...baseCandidates];
}

function parseUrl(url: string): URL | null {
try {
return new URL(url);
Expand All @@ -93,6 +116,15 @@ function parseUrl(url: string): URL | null {
}
}

async function discoverSiteIcon(serviceUrl: string): Promise<string | null> {
try {
const discoveredIconUrl = await invoke<string | null>('discover_site_icon', { url: serviceUrl });
return typeof discoveredIconUrl === 'string' && discoveredIconUrl ? discoveredIconUrl : null;
} catch {
return null;
}
}

function probeImageUrl(
url: string,
options?: { timeoutMs?: number; ImageCtor?: typeof Image }
Expand Down
29 changes: 27 additions & 2 deletions tests/unit/icon.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, it, expect } from 'vitest';
import { findWorkingIconCandidate, getServiceIconCandidates, normalizeServiceUrl } from '@/lib/icon';
import { invoke } from '@tauri-apps/api/core';
import { beforeEach, describe, it, expect, vi } from 'vitest';
import {
findWorkingIconCandidate,
getServiceIconCandidates,
normalizeServiceUrl,
resolveServiceIconCandidates,
} from '@/lib/icon';

class MockImage {
onload: null | (() => void) = null;
Expand All @@ -26,6 +32,11 @@ class MockImage {
}

describe('getServiceIconCandidates', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(invoke).mockResolvedValue(null);
});

it('should return explicit icon URL first when provided', () => {
const candidates = getServiceIconCandidates(
'https://chatgpt.com',
Expand Down Expand Up @@ -143,4 +154,18 @@ describe('getServiceIconCandidates', () => {

expect(iconUrl).toBeNull();
});

it('should prioritize discovered page icon ahead of generic fallback candidates', async () => {
vi.mocked(invoke).mockResolvedValue('https://aistudio.xiaomimimo.com/favicon.0619b0d2.png');

const candidates = await resolveServiceIconCandidates(
'https://aistudio.xiaomimimo.com',
'https://www.google.com/s2/favicons?domain=aistudio.xiaomimimo.com&sz=64'
);

expect(candidates[0]).toBe('https://aistudio.xiaomimimo.com/favicon.0619b0d2.png');
expect(candidates).toContain(
'https://www.google.com/s2/favicons?domain=aistudio.xiaomimimo.com&sz=64'
);
});
});
38 changes: 32 additions & 6 deletions tests/unit/use-cached-icon.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { invoke } from '@tauri-apps/api/core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useCachedIcon } from '@/hooks/useCachedIcon';
import { getServiceIconCandidates } from '@/lib/icon';
import { resolveServiceIconCandidates } from '@/lib/icon';
import { getCachedIcon, cacheIcon } from '@/lib/icon-cache';

vi.mock('@/lib/icon', () => ({
getServiceIconCandidates: vi.fn(),
resolveServiceIconCandidates: vi.fn(),
}));

vi.mock('@/lib/icon-cache', () => ({
Expand All @@ -16,10 +17,11 @@ vi.mock('@/lib/icon-cache', () => ({
describe('useCachedIcon', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(invoke).mockResolvedValue(null);
});

it('should fall back to the first candidate URL when caching fetches all fail', async () => {
vi.mocked(getServiceIconCandidates).mockReturnValue([
vi.mocked(resolveServiceIconCandidates).mockResolvedValue([
'https://example.com/favicon.svg',
'https://example.com/favicon.ico',
]);
Expand All @@ -44,7 +46,7 @@ describe('useCachedIcon', () => {
});

it('should advance to the next candidate when the current image source errors', async () => {
vi.mocked(getServiceIconCandidates).mockReturnValue([
vi.mocked(resolveServiceIconCandidates).mockResolvedValue([
'https://example.com/favicon.svg',
'https://example.com/favicon.ico',
'https://www.google.com/s2/favicons?domain=example.com&sz=64',
Expand Down Expand Up @@ -77,7 +79,7 @@ describe('useCachedIcon', () => {
});

it('reports the resolved candidate URL after a fallback image loads successfully', async () => {
vi.mocked(getServiceIconCandidates).mockReturnValue([
vi.mocked(resolveServiceIconCandidates).mockResolvedValue([
'https://example.com/favicon.svg',
'https://example.com/favicon.ico',
]);
Expand Down Expand Up @@ -111,8 +113,32 @@ describe('useCachedIcon', () => {
expect(handleResolved).toHaveBeenCalledWith('https://example.com/favicon.ico');
});

it('should prefer a discovered page icon over a generic fallback URL', async () => {
vi.mocked(resolveServiceIconCandidates).mockResolvedValue([
'https://aistudio.xiaomimimo.com/favicon.0619b0d2.png',
'https://www.google.com/s2/favicons?domain=aistudio.xiaomimimo.com&sz=64',
]);
vi.mocked(getCachedIcon).mockResolvedValue(null);

const fetchMock = vi.fn().mockResolvedValue({
ok: false,
blob: vi.fn(),
});
vi.stubGlobal('fetch', fetchMock);

const { result } = renderHook(() =>
useCachedIcon('svc-discovered', 'https://aistudio.xiaomimimo.com', undefined)
);

await waitFor(() => {
expect(result.current.loading).toBe(false);
});

expect(result.current.iconSrc).toBe('https://aistudio.xiaomimimo.com/favicon.0619b0d2.png');
});

it('should skip non-image response and continue trying next candidate', async () => {
vi.mocked(getServiceIconCandidates).mockReturnValue([
vi.mocked(resolveServiceIconCandidates).mockResolvedValue([
'https://example.com/not-image',
'https://example.com/real-image',
]);
Expand Down
Loading