fix(security): harden browser handoff boundaries

This commit is contained in:
NimBold
2026-07-17 02:05:41 +03:30
parent 50c3da2f5d
commit a8dc4fb447
9 changed files with 240 additions and 36 deletions
+19 -9
View File
@@ -137,7 +137,7 @@ function App() {
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading);
const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
const { addToast } = useToast();
const { addToast, removeToast } = useToast();
const isMacUserAgent = navigator.userAgent.includes('Mac');
const usesCustomWindowControls = !isMacUserAgent && platform.os !== 'macos';
// Keep dialogs out of the titlebar area while platform detection is still
@@ -179,19 +179,20 @@ function App() {
const actionLabel = action === 'shutdown' ? 'Shut down' : action === 'restart' ? 'Restart' : 'Sleep';
let timerId: number | null = null;
let toastId: string | null = null;
const cancel = () => {
if (timerId !== null) {
window.clearTimeout(timerId);
if (pendingPostActionTimer.current === timerId) {
pendingPostActionTimer.current = null;
}
timerId = null;
clearPendingPostActionTimer();
timerId = null;
if (toastId !== null) {
removeToast(toastId);
toastId = null;
}
};
addToast({
toastId = addToast({
variant: 'warning',
isActionable: true,
onDismiss: clearPendingPostActionTimer,
message: (
<div className="flex items-center gap-3">
<span>{actionLabel} in 10 seconds.</span>
@@ -207,6 +208,10 @@ function App() {
});
timerId = window.setTimeout(() => {
if (toastId !== null) {
removeToast(toastId);
toastId = null;
}
if (pendingPostActionTimer.current === timerId) {
pendingPostActionTimer.current = null;
}
@@ -233,7 +238,7 @@ function App() {
});
}, 10_000);
pendingPostActionTimer.current = timerId;
}, [addToast, clearPendingPostActionTimer]);
}, [addToast, clearPendingPostActionTimer, removeToast]);
const startSidebarResize = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
@@ -326,6 +331,11 @@ function App() {
}
});
unlistenExtension = await listen('extension-add-download', (event) => {
if (event.payload.request_id) {
void invoke('ack_extension_download', { requestId: event.payload.request_id }).catch(error => {
console.error('Failed to acknowledge browser extension download:', error);
});
}
if (!startupInputReady.current || useSettingsStore.getState().showKeychainModal) {
pendingStartupInputs.current.push({ type: 'extension', payload: event.payload });
return;
+1 -1
View File
@@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ExtensionCookieScope } from "./ExtensionCookieScope";
export type ExtensionDownload = { urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array<ExtensionCookieScope> | null, media: boolean, };
export type ExtensionDownload = { request_id?: string, urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, headers: string | null, cookies: string | null, cookie_scopes: Array<ExtensionCookieScope> | null, media: boolean, };
+16 -2
View File
@@ -14,6 +14,7 @@ export interface ToastMessage {
variant?: ToastVariant;
duration?: number;
isActionable?: boolean;
onDismiss?: () => void;
}
interface ToastState extends ToastMessage {
@@ -21,7 +22,7 @@ interface ToastState extends ToastMessage {
}
interface ToastContextType {
addToast: (toast: Omit<ToastMessage, 'id'>) => void;
addToast: (toast: Omit<ToastMessage, 'id'>) => string;
removeToast: (id: string) => void;
}
@@ -33,10 +34,12 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) =
const addToast = useCallback((toast: Omit<ToastMessage, 'id'>) => {
nextToastId.current += 1;
const id = `toast-${nextToastId.current}`;
setToasts(prev => {
const next = [...prev, { ...toast, id: `toast-${nextToastId.current}` }];
const next = [...prev, { ...toast, id }];
return next.slice(-MAX_VISIBLE_TOASTS);
});
return id;
}, []);
const removeToast = useCallback((id: string) => {
@@ -70,6 +73,7 @@ const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void
const [isHovered, setIsHovered] = useState(false);
const timerStartedAt = useRef<number | null>(null);
const remainingDuration = useRef<number | null>(null);
const onDismissCalled = useRef(false);
useLayoutEffect(() => {
const frame = requestAnimationFrame(() => setIsMounted(true));
@@ -99,6 +103,16 @@ const ToastItem: React.FC<{ toast: ToastState; removeToast: (id: string) => void
};
}, [toast, isHovered, removeToast]);
useEffect(() => {
const dismiss = () => {
if (onDismissCalled.current) return;
onDismissCalled.current = true;
toast.onDismiss?.();
};
if (toast.exiting) dismiss();
return dismiss;
}, [toast.exiting, toast.onDismiss]);
useEffect(() => {
if (toast.exiting) {
const fallbackTimer = setTimeout(() => {
+1
View File
@@ -65,6 +65,7 @@ type CommandMap = {
grant_keychain_access: { args: undefined; result: PairingTokenHydration };
acknowledge_pairing_token_change: { args: undefined; result: void };
set_extension_frontend_ready: { args: { ready: boolean }; result: void };
ack_extension_download: { args: { requestId: string }; result: void };
get_system_proxy: { args: undefined; result: string | null };
get_file_category: { args: { filename: string }; result: DownloadCategory };
check_for_updates: { args: undefined; result: ReleaseCheckOutcome };