feat(right-click-restorer): add unsupported page detection

- Detect and block chrome://, chrome-extension://, about://, edge://, brave:// pages
- Show 'Unsupported' badge with warning icon and disabled button
- Add 'unsupported' and 'unsupportedDesc' i18n keys (zh/en)
- Update useRightClickRestorer hook with isUnsupported state
- Add unit tests for unsupported page behavior
- Update page UI to render conditional unsupported layout
This commit is contained in:
雨霖铃
2026-05-23 12:46:20 +08:00
parent 4c08e17b19
commit 6bac88fbc4
6 changed files with 103 additions and 18 deletions
@@ -9,6 +9,7 @@ vi.mock('../useRightClickRestorer', () => ({
domain: 'example.com',
isLoading: false,
isUnlocked: false,
isUnsupported: false,
unlock: mockUnlock,
}),
}));
@@ -27,9 +27,23 @@ describe('useRightClickRestorer', () => {
expect(result.current.domain).toBe('example.com');
expect(result.current.isUnlocked).toBe(false);
expect(result.current.isUnsupported).toBe(false);
expect(sendMessageToContent).toHaveBeenCalledWith('queryRightClickStatus');
});
it('should mark internal pages as unsupported', async () => {
mockTabsQuery.mockResolvedValue([{ url: 'chrome://newtab/' }]);
chrome.tabs.query = mockTabsQuery;
const { result } = renderHook(() => useRightClickRestorer());
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.isUnsupported).toBe(true);
expect(result.current.isUnlocked).toBe(false);
expect(sendMessageToContent).not.toHaveBeenCalled();
});
it('should unlock right click', async () => {
vi.mocked(sendMessageToContent).mockResolvedValueOnce({ success: true, restored: false });
vi.mocked(sendMessageToContent).mockResolvedValueOnce({ success: true, restored: true });
@@ -46,6 +60,22 @@ describe('useRightClickRestorer', () => {
expect(sendMessageToContent).toHaveBeenLastCalledWith('restoreRightClick');
});
it('should not unlock unsupported pages', async () => {
mockTabsQuery.mockResolvedValue([{ url: 'chrome://settings/' }]);
chrome.tabs.query = mockTabsQuery;
const { result } = renderHook(() => useRightClickRestorer());
await waitFor(() => expect(result.current.isLoading).toBe(false));
await act(async () => {
await result.current.unlock();
});
expect(result.current.isUnlocked).toBe(false);
expect(sendMessageToContent).not.toHaveBeenCalled();
});
it('should handle sendMessage failure gracefully', async () => {
vi.mocked(sendMessageToContent).mockRejectedValue(new Error('Connection failed'));
+34 -15
View File
@@ -1,13 +1,13 @@
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Shield, ShieldCheck, MousePointerClick } from 'lucide-react';
import { Shield, ShieldCheck, MousePointerClick, AlertTriangle } from 'lucide-react';
import { useRightClickRestorer } from './useRightClickRestorer';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
export default function RightClickRestorerPage() {
const { t } = useLazyTranslation('rightClickRestorer');
const { domain, isLoading, isUnlocked, unlock } = useRightClickRestorer();
const { domain, isLoading, isUnlocked, isUnsupported, unlock } = useRightClickRestorer();
if (isLoading) {
return (
@@ -29,7 +29,12 @@ export default function RightClickRestorerPage() {
<code className="text-sm bg-muted px-2 py-1 rounded truncate min-w-0 flex-1">
{domain || '—'}
</code>
{isUnlocked ? (
{isUnsupported ? (
<Badge variant="destructive" className="gap-1 shrink-0">
<AlertTriangle className="h-3 w-3" />
{t('rightClickRestorer:unsupported')}
</Badge>
) : isUnlocked ? (
<Badge variant="default" className="gap-1 bg-green-600 hover:bg-green-700 shrink-0">
<ShieldCheck className="h-3 w-3" />
{t('rightClickRestorer:statusUnlocked')}
@@ -47,18 +52,32 @@ export default function RightClickRestorerPage() {
{/* Unlock Action */}
<div className="w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden transition-all">
<div className="p-4 space-y-3">
<p className="text-xs text-muted-foreground">{t('rightClickRestorer:unlockDesc')}</p>
<Button
className="w-full gap-2"
onClick={() => void unlock()}
disabled={isUnlocked}
variant={isUnlocked ? 'secondary' : 'default'}
>
<MousePointerClick className="h-4 w-4" />
{isUnlocked
? t('rightClickRestorer:alreadyUnlocked')
: t('rightClickRestorer:unlockBtn')}
</Button>
{isUnsupported ? (
<>
<p className="text-xs text-muted-foreground">
{t('rightClickRestorer:unsupportedDesc')}
</p>
<Button className="w-full gap-2" disabled variant="secondary">
<AlertTriangle className="h-4 w-4" />
{t('rightClickRestorer:unsupported')}
</Button>
</>
) : (
<>
<p className="text-xs text-muted-foreground">{t('rightClickRestorer:unlockDesc')}</p>
<Button
className="w-full gap-2"
onClick={() => void unlock()}
disabled={isUnlocked}
variant={isUnlocked ? 'secondary' : 'default'}
>
<MousePointerClick className="h-4 w-4" />
{isUnlocked
? t('rightClickRestorer:alreadyUnlocked')
: t('rightClickRestorer:unlockBtn')}
</Button>
</>
)}
</div>
</div>
</div>
@@ -1,10 +1,29 @@
import { useCallback, useEffect, useState } from 'react';
import { MessageAction, sendMessageToContent } from '@/utils/messages';
const UNSUPPORTED_PROTOCOLS = new Set([
'chrome:',
'chrome-extension:',
'about:',
'edge:',
'brave:',
]);
function isUnsupportedPage(url: string | undefined): boolean {
if (!url) return true;
try {
const protocol = new URL(url).protocol;
return UNSUPPORTED_PROTOCOLS.has(protocol);
} catch {
return true;
}
}
export interface UseRightClickRestorerReturn {
domain: string;
isLoading: boolean;
isUnlocked: boolean;
isUnsupported: boolean;
unlock: () => Promise<void>;
}
@@ -12,19 +31,28 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
const [domain, setDomain] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isUnlocked, setIsUnlocked] = useState<boolean>(false);
const [isUnsupported, setIsUnsupported] = useState<boolean>(false);
useEffect(() => {
const load = async () => {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab?.url) {
const url = tab?.url;
if (url) {
try {
setDomain(new URL(tab.url).hostname);
setDomain(new URL(url).hostname);
} catch {
setDomain('');
}
}
if (isUnsupportedPage(url)) {
setIsUnsupported(true);
setIsLoading(false);
return;
}
const response = await sendMessageToContent(MessageAction.QUERY_RIGHT_CLICK_STATUS);
if (response?.success) {
setIsUnlocked(response.restored);
@@ -40,6 +68,8 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
}, []);
const unlock = useCallback(async () => {
if (isUnsupported) return;
try {
const response = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
if (response?.success) {
@@ -48,12 +78,13 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
} catch (err) {
console.error('[RightClickRestorer] Failed to unlock:', err);
}
}, []);
}, [isUnsupported]);
return {
domain,
isLoading,
isUnlocked,
isUnsupported,
unlock,
};
}