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
+2
View File
@@ -5,6 +5,8 @@
"currentDomain": "Current Domain", "currentDomain": "Current Domain",
"statusLocked": "Locked", "statusLocked": "Locked",
"statusUnlocked": "Unlocked", "statusUnlocked": "Unlocked",
"unsupported": "Unsupported",
"unsupportedDesc": "The current page is a browser internal or extension page. Right-click unlock is not available. Please switch to a regular webpage.",
"unlockDesc": "Click the button below to temporarily unlock the right-click menu for the current website. You will need to unlock again after refreshing the page.", "unlockDesc": "Click the button below to temporarily unlock the right-click menu for the current website. You will need to unlock again after refreshing the page.",
"unlockBtn": "Unlock Right Click", "unlockBtn": "Unlock Right Click",
"alreadyUnlocked": "Right Click Unlocked" "alreadyUnlocked": "Right Click Unlocked"
+2
View File
@@ -5,6 +5,8 @@
"currentDomain": "当前域名", "currentDomain": "当前域名",
"statusLocked": "未解锁", "statusLocked": "未解锁",
"statusUnlocked": "已解锁", "statusUnlocked": "已解锁",
"unsupported": "不支持",
"unsupportedDesc": "当前页面为浏览器内部页面或扩展页面,无法解锁右键功能。请切换到普通网页后重试。",
"unlockDesc": "点击下方按钮,为当前网站临时解锁右键菜单。刷新页面后需要重新解锁。", "unlockDesc": "点击下方按钮,为当前网站临时解锁右键菜单。刷新页面后需要重新解锁。",
"unlockBtn": "解锁当前网站右键", "unlockBtn": "解锁当前网站右键",
"alreadyUnlocked": "右键已解锁" "alreadyUnlocked": "右键已解锁"
@@ -9,6 +9,7 @@ vi.mock('../useRightClickRestorer', () => ({
domain: 'example.com', domain: 'example.com',
isLoading: false, isLoading: false,
isUnlocked: false, isUnlocked: false,
isUnsupported: false,
unlock: mockUnlock, unlock: mockUnlock,
}), }),
})); }));
@@ -27,9 +27,23 @@ describe('useRightClickRestorer', () => {
expect(result.current.domain).toBe('example.com'); expect(result.current.domain).toBe('example.com');
expect(result.current.isUnlocked).toBe(false); expect(result.current.isUnlocked).toBe(false);
expect(result.current.isUnsupported).toBe(false);
expect(sendMessageToContent).toHaveBeenCalledWith('queryRightClickStatus'); 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 () => { it('should unlock right click', async () => {
vi.mocked(sendMessageToContent).mockResolvedValueOnce({ success: true, restored: false }); vi.mocked(sendMessageToContent).mockResolvedValueOnce({ success: true, restored: false });
vi.mocked(sendMessageToContent).mockResolvedValueOnce({ success: true, restored: true }); vi.mocked(sendMessageToContent).mockResolvedValueOnce({ success: true, restored: true });
@@ -46,6 +60,22 @@ describe('useRightClickRestorer', () => {
expect(sendMessageToContent).toHaveBeenLastCalledWith('restoreRightClick'); 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 () => { it('should handle sendMessage failure gracefully', async () => {
vi.mocked(sendMessageToContent).mockRejectedValue(new Error('Connection failed')); vi.mocked(sendMessageToContent).mockRejectedValue(new Error('Connection failed'));
+22 -3
View File
@@ -1,13 +1,13 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge'; 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 { useRightClickRestorer } from './useRightClickRestorer';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
export default function RightClickRestorerPage() { export default function RightClickRestorerPage() {
const { t } = useLazyTranslation('rightClickRestorer'); const { t } = useLazyTranslation('rightClickRestorer');
const { domain, isLoading, isUnlocked, unlock } = useRightClickRestorer(); const { domain, isLoading, isUnlocked, isUnsupported, unlock } = useRightClickRestorer();
if (isLoading) { if (isLoading) {
return ( 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"> <code className="text-sm bg-muted px-2 py-1 rounded truncate min-w-0 flex-1">
{domain || '—'} {domain || '—'}
</code> </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"> <Badge variant="default" className="gap-1 bg-green-600 hover:bg-green-700 shrink-0">
<ShieldCheck className="h-3 w-3" /> <ShieldCheck className="h-3 w-3" />
{t('rightClickRestorer:statusUnlocked')} {t('rightClickRestorer:statusUnlocked')}
@@ -47,6 +52,18 @@ export default function RightClickRestorerPage() {
{/* Unlock Action */} {/* Unlock Action */}
<div className="w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden transition-all"> <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"> <div className="p-4 space-y-3">
{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> <p className="text-xs text-muted-foreground">{t('rightClickRestorer:unlockDesc')}</p>
<Button <Button
className="w-full gap-2" className="w-full gap-2"
@@ -59,6 +76,8 @@ export default function RightClickRestorerPage() {
? t('rightClickRestorer:alreadyUnlocked') ? t('rightClickRestorer:alreadyUnlocked')
: t('rightClickRestorer:unlockBtn')} : t('rightClickRestorer:unlockBtn')}
</Button> </Button>
</>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -1,10 +1,29 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { MessageAction, sendMessageToContent } from '@/utils/messages'; 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 { export interface UseRightClickRestorerReturn {
domain: string; domain: string;
isLoading: boolean; isLoading: boolean;
isUnlocked: boolean; isUnlocked: boolean;
isUnsupported: boolean;
unlock: () => Promise<void>; unlock: () => Promise<void>;
} }
@@ -12,19 +31,28 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
const [domain, setDomain] = useState<string>(''); const [domain, setDomain] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [isUnlocked, setIsUnlocked] = useState<boolean>(false); const [isUnlocked, setIsUnlocked] = useState<boolean>(false);
const [isUnsupported, setIsUnsupported] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
try { try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab?.url) { const url = tab?.url;
if (url) {
try { try {
setDomain(new URL(tab.url).hostname); setDomain(new URL(url).hostname);
} catch { } catch {
setDomain(''); setDomain('');
} }
} }
if (isUnsupportedPage(url)) {
setIsUnsupported(true);
setIsLoading(false);
return;
}
const response = await sendMessageToContent(MessageAction.QUERY_RIGHT_CLICK_STATUS); const response = await sendMessageToContent(MessageAction.QUERY_RIGHT_CLICK_STATUS);
if (response?.success) { if (response?.success) {
setIsUnlocked(response.restored); setIsUnlocked(response.restored);
@@ -40,6 +68,8 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
}, []); }, []);
const unlock = useCallback(async () => { const unlock = useCallback(async () => {
if (isUnsupported) return;
try { try {
const response = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK); const response = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
if (response?.success) { if (response?.success) {
@@ -48,12 +78,13 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
} catch (err) { } catch (err) {
console.error('[RightClickRestorer] Failed to unlock:', err); console.error('[RightClickRestorer] Failed to unlock:', err);
} }
}, []); }, [isUnsupported]);
return { return {
domain, domain,
isLoading, isLoading,
isUnlocked, isUnlocked,
isUnsupported,
unlock, unlock,
}; };
} }