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:
@@ -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"
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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,18 +52,32 @@ 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">
|
||||||
<p className="text-xs text-muted-foreground">{t('rightClickRestorer:unlockDesc')}</p>
|
{isUnsupported ? (
|
||||||
<Button
|
<>
|
||||||
className="w-full gap-2"
|
<p className="text-xs text-muted-foreground">
|
||||||
onClick={() => void unlock()}
|
{t('rightClickRestorer:unsupportedDesc')}
|
||||||
disabled={isUnlocked}
|
</p>
|
||||||
variant={isUnlocked ? 'secondary' : 'default'}
|
<Button className="w-full gap-2" disabled variant="secondary">
|
||||||
>
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<MousePointerClick className="h-4 w-4" />
|
{t('rightClickRestorer:unsupported')}
|
||||||
{isUnlocked
|
</Button>
|
||||||
? t('rightClickRestorer:alreadyUnlocked')
|
</>
|
||||||
: t('rightClickRestorer:unlockBtn')}
|
) : (
|
||||||
</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>
|
</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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user