feat: add right-click restorer for current site

- Add RightClickRestorer page with active unlock trigger
- Content script with dual-world strategy (isolated + main world)
- Monkey patch preventDefault/stopPropagation via background executeScript
- Overlay penetration for pointer-events blocking layers
- Delegated main-world injection to avoid CSP restrictions
- Async message handling for reliable state updates
- i18n translations (zh/en) and unit tests
This commit is contained in:
雨霖铃
2026-05-23 12:39:14 +08:00
parent 2d7bb8fc2c
commit fa0f1bdcad
16 changed files with 570 additions and 13 deletions
@@ -0,0 +1,37 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import RightClickRestorerPage from '../index';
const mockUnlock = vi.fn();
vi.mock('../useRightClickRestorer', () => ({
useRightClickRestorer: () => ({
domain: 'example.com',
isLoading: false,
isUnlocked: false,
unlock: mockUnlock,
}),
}));
describe('RightClickRestorerPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render current domain', () => {
render(<RightClickRestorerPage />);
expect(screen.getByText(/example\.com/)).toBeInTheDocument();
});
it('should render locked status', () => {
render(<RightClickRestorerPage />);
expect(screen.getByText(/statusLocked/)).toBeInTheDocument();
});
it('should call unlock when button clicked', () => {
render(<RightClickRestorerPage />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(mockUnlock).toHaveBeenCalled();
});
});
@@ -0,0 +1,62 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { useRightClickRestorer } from '../useRightClickRestorer';
import { sendMessageToContent } from '@/utils/messages';
const mockTabsQuery = vi.fn();
vi.mock('@/utils/messages', () => ({
MessageAction: {
RESTORE_RIGHT_CLICK: 'restoreRightClick',
QUERY_RIGHT_CLICK_STATUS: 'queryRightClickStatus',
},
sendMessageToContent: vi.fn(),
}));
beforeEach(() => {
vi.clearAllMocks();
mockTabsQuery.mockResolvedValue([{ url: 'https://example.com/path' }]);
chrome.tabs.query = mockTabsQuery;
vi.mocked(sendMessageToContent).mockResolvedValue({ success: true, restored: false });
});
describe('useRightClickRestorer', () => {
it('should load domain and query status', async () => {
const { result } = renderHook(() => useRightClickRestorer());
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.domain).toBe('example.com');
expect(result.current.isUnlocked).toBe(false);
expect(sendMessageToContent).toHaveBeenCalledWith('queryRightClickStatus');
});
it('should unlock right click', async () => {
vi.mocked(sendMessageToContent).mockResolvedValueOnce({ success: true, restored: false });
vi.mocked(sendMessageToContent).mockResolvedValueOnce({ success: true, restored: true });
const { result } = renderHook(() => useRightClickRestorer());
await waitFor(() => expect(result.current.isLoading).toBe(false));
await act(async () => {
await result.current.unlock();
});
expect(result.current.isUnlocked).toBe(true);
expect(sendMessageToContent).toHaveBeenLastCalledWith('restoreRightClick');
});
it('should handle sendMessage failure gracefully', async () => {
vi.mocked(sendMessageToContent).mockRejectedValue(new Error('Connection failed'));
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);
});
});
+64
View File
@@ -0,0 +1,64 @@
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 { useRightClickRestorer } from './useRightClickRestorer';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
export default function RightClickRestorerPage() {
const { t } = useLazyTranslation('rightClickRestorer');
const { domain, isLoading, isUnlocked, unlock } = useRightClickRestorer();
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full animate-in fade-in duration-200">
<span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide">
{t('rightClickRestorer:loading')}
</span>
</div>
);
}
return (
<div className="p-4 w-full flex flex-col space-y-4 animate-in fade-in duration-300">
{/* Current Domain */}
<div className="w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden transition-all">
<div className="p-4">
<Label className="text-sm font-medium">{t('rightClickRestorer:currentDomain')}</Label>
<div className="mt-2 flex items-center justify-between">
<code className="text-sm bg-muted px-2 py-1 rounded">{domain || '—'}</code>
{isUnlocked ? (
<Badge variant="default" className="gap-1 bg-green-600 hover:bg-green-700">
<ShieldCheck className="h-3 w-3" />
{t('rightClickRestorer:statusUnlocked')}
</Badge>
) : (
<Badge variant="secondary" className="gap-1">
<Shield className="h-3 w-3" />
{t('rightClickRestorer:statusLocked')}
</Badge>
)}
</div>
</div>
</div>
{/* 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>
</div>
</div>
</div>
);
}
@@ -0,0 +1,59 @@
import { useCallback, useEffect, useState } from 'react';
import { MessageAction, sendMessageToContent } from '@/utils/messages';
export interface UseRightClickRestorerReturn {
domain: string;
isLoading: boolean;
isUnlocked: boolean;
unlock: () => Promise<void>;
}
export function useRightClickRestorer(): UseRightClickRestorerReturn {
const [domain, setDomain] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isUnlocked, setIsUnlocked] = useState<boolean>(false);
useEffect(() => {
const load = async () => {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab?.url) {
try {
setDomain(new URL(tab.url).hostname);
} catch {
setDomain('');
}
}
const response = await sendMessageToContent(MessageAction.QUERY_RIGHT_CLICK_STATUS);
if (response?.success) {
setIsUnlocked(response.restored);
}
} catch (err) {
console.error('[RightClickRestorer] Failed to load state:', err);
} finally {
setIsLoading(false);
}
};
void load();
}, []);
const unlock = useCallback(async () => {
try {
const response = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
if (response?.success) {
setIsUnlocked(response.restored);
}
} catch (err) {
console.error('[RightClickRestorer] Failed to unlock:', err);
}
}, []);
return {
domain,
isLoading,
isUnlocked,
unlock,
};
}