feat: fully implement happy flow for persistant fingerprint generation

This commit is contained in:
zhom
2025-08-06 04:33:01 +04:00
parent ff35717cb5
commit b5b08a0196
20 changed files with 2531 additions and 1545 deletions
-1
View File
@@ -25,7 +25,6 @@ import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
import { showErrorToast } from "@/lib/toast-utils";
import { sleep } from "@/lib/utils";
import type { BrowserProfile, CamoufoxConfig, GroupWithCount } from "@/types";
type BrowserTypeString =
+22 -24
View File
@@ -11,7 +11,6 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { getCurrentOS } from "@/lib/browser-utils";
import type { BrowserProfile, CamoufoxConfig } from "@/types";
interface CamoufoxConfigDialogProps {
@@ -28,8 +27,6 @@ export function CamoufoxConfigDialog({
onSave,
}: CamoufoxConfigDialogProps) {
const [config, setConfig] = useState<CamoufoxConfig>({
enable_cache: true,
os: [getCurrentOS()],
geoip: true,
});
const [isSaving, setIsSaving] = useState(false);
@@ -39,8 +36,6 @@ export function CamoufoxConfigDialog({
if (profile && profile.browser === "camoufox") {
setConfig(
profile.camoufox_config || {
enable_cache: true,
os: [getCurrentOS()],
geoip: true,
},
);
@@ -54,12 +49,31 @@ export function CamoufoxConfigDialog({
const handleSave = async () => {
if (!profile) return;
// Validate fingerprint JSON if it exists
if (config.fingerprint) {
try {
JSON.parse(config.fingerprint);
} catch (_error) {
const { toast } = await import("sonner");
toast.error("Invalid fingerprint configuration", {
description:
"The fingerprint configuration contains invalid JSON. Please check your advanced settings.",
});
return;
}
}
setIsSaving(true);
try {
await onSave(profile, config);
onClose();
} catch (error) {
console.error("Failed to save camoufox config:", error);
const { toast } = await import("sonner");
toast.error("Failed to save configuration", {
description:
error instanceof Error ? error.message : "Unknown error occurred",
});
} finally {
setIsSaving(false);
}
@@ -70,8 +84,6 @@ export function CamoufoxConfigDialog({
if (profile && profile.browser === "camoufox") {
setConfig(
profile.camoufox_config || {
enable_cache: true,
os: [getCurrentOS()],
geoip: true,
},
);
@@ -83,11 +95,7 @@ export function CamoufoxConfigDialog({
return null;
}
// Get the selected OS for warning
const selectedOS = config.os?.[0];
const currentOS = getCurrentOS();
const showOSWarning =
selectedOS && selectedOS !== currentOS && currentOS !== "unknown";
// No OS warning needed anymore since we removed OS selection
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
@@ -98,22 +106,12 @@ export function CamoufoxConfigDialog({
</DialogTitle>
</DialogHeader>
<ScrollArea className="flex-1 pr-6 h-[320px]">
<ScrollArea className="flex-1 pr-6 h-[400px]">
<div className="py-4">
{/* OS Warning */}
{showOSWarning && (
<div className="mb-6 p-3 bg-amber-50 rounded-md border border-amber-200">
<p className="text-sm text-amber-800">
Warning: Spoofing OS features is detectable by advanced
anti-bot systems. Some platform-specific APIs and behaviors
cannot be fully replicated.
</p>
</div>
)}
<SharedCamoufoxConfigForm
config={config}
onConfigChange={updateConfig}
forceAdvanced={true}
/>
</div>
</ScrollArea>
+9 -11
View File
@@ -25,7 +25,7 @@ import {
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useBrowserDownload } from "@/hooks/use-browser-download";
import { getBrowserIcon, getCurrentOS } from "@/lib/browser-utils";
import { getBrowserIcon } from "@/lib/browser-utils";
import type { BrowserReleaseTypes, CamoufoxConfig, StoredProxy } from "@/types";
type BrowserTypeString =
@@ -109,8 +109,7 @@ export function CreateProfileDialog({
// Camoufox anti-detect states
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>({
enable_cache: true, // Cache enabled by default
os: [getCurrentOS()], // Default to current OS
geoip: true, // Default to automatic geoip
});
// Common states
@@ -285,13 +284,17 @@ export function CreateProfileDialog({
return;
}
// The fingerprint will be generated at launch time by the Rust backend
// We don't need to generate it here during profile creation
const finalCamoufoxConfig = { ...camoufoxConfig };
await onCreateProfile({
name: profileName.trim(),
browserStr: "camoufox" as BrowserTypeString,
version: bestCamoufoxVersion.version,
releaseType: bestCamoufoxVersion.releaseType,
proxyId: selectedProxyId,
camoufoxConfig,
camoufoxConfig: finalCamoufoxConfig,
});
}
@@ -314,8 +317,7 @@ export function CreateProfileDialog({
setAvailableReleaseTypes({});
setCamoufoxReleaseTypes({});
setCamoufoxConfig({
enable_cache: true,
os: [getCurrentOS()], // Reset to current OS
geoip: true, // Reset to automatic geoip
});
setActiveTab("regular");
onClose();
@@ -352,11 +354,7 @@ export function CreateProfileDialog({
return isBrowserDownloading(browserStr);
};
// Get the selected OS for warning
const selectedOS = camoufoxConfig.os?.[0];
const currentOS = getCurrentOS();
const _showOSWarning =
selectedOS && selectedOS !== currentOS && currentOS !== "unknown";
// No OS warning needed anymore
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
+537
View File
@@ -0,0 +1,537 @@
/** biome-ignore-all lint/a11y/noStaticElementInteractions: temporary suppress until in active use */
/** biome-ignore-all lint/a11y/useKeyWithClickEvents: temporary suppress until in active use */
"use client";
import { Command as CommandPrimitive, useCommandState } from "cmdk";
import * as React from "react";
import { forwardRef, useEffect } from "react";
import { LuX } from "react-icons/lu";
import { cn } from "../lib/utils";
import { Badge } from "./ui/badge";
import { Command, CommandGroup, CommandItem, CommandList } from "./ui/command";
export interface Option {
value: string;
label?: string;
disable?: boolean;
/** fixed option that can't be removed. */
fixed?: boolean;
/** Group the options by providing key. */
[key: string]: string | boolean | undefined;
}
interface GroupOption {
[key: string]: Option[];
}
interface MultipleSelectorProps {
value?: Option[];
defaultOptions?: Option[];
/** manually controlled options */
options?: Option[];
placeholder?: string;
/** Loading component. */
loadingIndicator?: React.ReactNode;
/** Empty component. */
emptyIndicator?: React.ReactNode;
/** Debounce time for async search. Only work with `onSearch`. */
delay?: number;
/**
* Only work with `onSearch` prop. Trigger search when `onFocus`.
* For example, when user click on the input, it will trigger the search to get initial options.
**/
triggerSearchOnFocus?: boolean;
/** async search */
onSearch?: (value: string) => Promise<Option[]>;
onChange?: (options: Option[]) => void;
/** Limit the maximum number of selected options. */
maxSelected?: number;
/** When the number of selected options exceeds the limit, the onMaxSelected will be called. */
onMaxSelected?: (maxLimit: number) => void;
/** Hide the placeholder when there are options selected. */
hidePlaceholderWhenSelected?: boolean;
disabled?: boolean;
/** Group the options base on provided key. */
groupBy?: string;
className?: string;
badgeClassName?: string;
/**
* First item selected is a default behavior by cmdk. That is why the default is true.
* This is a workaround solution by add a dummy item.
*
* @reference: https://github.com/pacocoursey/cmdk/issues/171
*/
selectFirstItem?: boolean;
/** Allow user to create option when there is no option matched. */
creatable?: boolean;
/** Props of `Command` */
commandProps?: React.ComponentPropsWithoutRef<typeof Command>;
/** Props of `CommandInput` */
inputProps?: Omit<
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
"value" | "placeholder" | "disabled"
>;
}
export interface MultipleSelectorRef {
selectedValue: Option[];
input: HTMLInputElement;
}
// eslint-disable-next-line react-refresh/only-export-components
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
function transToGroupOption(options: Option[], groupBy?: string) {
if (options.length === 0) {
return {};
}
if (!groupBy) {
return {
"": options,
};
}
const groupOption: GroupOption = {};
options.forEach((option) => {
const key = (option[groupBy] as string) || "";
if (!groupOption[key]) {
groupOption[key] = [option];
} else {
groupOption[key]?.push(option);
}
});
return groupOption;
}
function removePickedOption(groupOption: GroupOption, picked: Option[]) {
const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption;
for (const [key, value] of Object.entries(cloneOption)) {
cloneOption[key] = value.filter(
(val) => !picked.find((p) => p.value === val.value),
);
}
return cloneOption;
}
function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
for (const [, value] of Object.entries(groupOption)) {
if (
value.some((option) => targetOption.find((p) => p.value === option.value))
) {
return true;
}
}
return false;
}
/**
* The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly.
* So we create one and copy the `Empty` implementation from `cmdk`.
*
* @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607
**/
const CommandEmpty = forwardRef<
HTMLDivElement,
React.ComponentProps<typeof CommandPrimitive.Empty>
>(({ className, ...props }, forwardedRef) => {
const render = useCommandState((state) => state.filtered.count === 0);
if (!render) return null;
return (
<div
ref={forwardedRef}
className={cn("py-6 text-sm text-center", className)}
cmdk-empty=""
role="presentation"
{...props}
/>
);
});
CommandEmpty.displayName = "CommandEmpty";
const MultipleSelector = React.forwardRef<
MultipleSelectorRef,
MultipleSelectorProps
>(
(
{
value,
onChange,
placeholder,
defaultOptions: arrayDefaultOptions = [],
options: arrayOptions,
delay,
onSearch,
loadingIndicator,
emptyIndicator,
maxSelected = Number.MAX_SAFE_INTEGER,
onMaxSelected,
hidePlaceholderWhenSelected,
disabled,
groupBy,
className,
badgeClassName,
selectFirstItem = true,
creatable = false,
triggerSearchOnFocus = false,
commandProps,
inputProps,
}: MultipleSelectorProps,
ref: React.Ref<MultipleSelectorRef>,
) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
const [selected, setSelected] = React.useState<Option[]>(value || []);
const [options, setOptions] = React.useState<GroupOption>(
transToGroupOption(arrayDefaultOptions, groupBy),
);
const [inputValue, setInputValue] = React.useState("");
const debouncedSearchTerm = useDebounce(inputValue, delay || 500);
React.useImperativeHandle(
ref,
() => ({
selectedValue: [...selected],
input: inputRef.current as HTMLInputElement,
focus: () => inputRef.current?.focus(),
}),
[selected],
);
const handleUnselect = React.useCallback(
(option: Option) => {
const newOptions = selected.filter((s) => s.value !== option.value);
setSelected(newOptions);
onChange?.(newOptions);
},
[onChange, selected],
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current;
if (input) {
if (e.key === "Delete" || e.key === "Backspace") {
if (input.value === "" && selected.length > 0) {
const lastSelectOption = selected[selected.length - 1];
// If last item is fixed, we should not remove it.
if (!lastSelectOption?.fixed) {
// biome-ignore lint/style/noNonNullAssertion: false positive
handleUnselect(selected.at(-1)!);
}
}
}
// This is not a default behavior of the <input /> field
if (e.key === "Escape") {
input.blur();
}
}
},
[handleUnselect, selected],
);
useEffect(() => {
if (value) {
setSelected(value);
}
}, [value]);
useEffect(() => {
/** If `onSearch` is provided, do not trigger options updated. */
if (!arrayOptions || onSearch) {
return;
}
const newOption = transToGroupOption(arrayOptions || [], groupBy);
if (JSON.stringify(newOption) !== JSON.stringify(options)) {
setOptions(newOption);
}
}, [arrayOptions, groupBy, onSearch, options]);
useEffect(() => {
const doSearch = async () => {
setIsLoading(true);
const res = await onSearch?.(debouncedSearchTerm);
setOptions(transToGroupOption(res || [], groupBy));
setIsLoading(false);
};
const exec = async () => {
if (!onSearch || !open) return;
if (triggerSearchOnFocus) {
await doSearch();
}
if (debouncedSearchTerm) {
await doSearch();
}
};
void exec();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus, onSearch]);
const CreatableItem = () => {
if (!creatable) return undefined;
if (
isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
selected.find((s) => s.value === inputValue)
) {
return undefined;
}
const Item = (
<CommandItem
value={inputValue}
className="cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={(value: string) => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue("");
const newOptions = [...selected, { value, label: value }];
setSelected(newOptions);
onChange?.(newOptions);
}}
>
{`Create "${inputValue}"`}
</CommandItem>
);
// For normal creatable
if (!onSearch && inputValue.length > 0) {
return Item;
}
// For async search creatable. avoid showing creatable item before loading at first.
if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
return Item;
}
return undefined;
};
const EmptyItem = React.useCallback(() => {
if (!emptyIndicator) return undefined;
// For async search that showing emptyIndicator
if (onSearch && !creatable && Object.keys(options).length === 0) {
return (
<CommandItem value="-" disabled>
{emptyIndicator}
</CommandItem>
);
}
return <CommandEmpty>{emptyIndicator}</CommandEmpty>;
}, [creatable, emptyIndicator, onSearch, options]);
const selectables = React.useMemo<GroupOption>(
() => removePickedOption(options, selected),
[options, selected],
);
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
const commandFilter = React.useCallback(() => {
if (commandProps?.filter) {
return commandProps.filter;
}
if (creatable) {
return (value: string, search: string) => {
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
};
}
// Using default filter in `cmdk`. We don't have to provide it.
return undefined;
}, [creatable, commandProps?.filter]);
return (
<Command
{...commandProps}
onKeyDown={(e) => {
handleKeyDown(e);
commandProps?.onKeyDown?.(e);
}}
className={cn(
"h-auto overflow-visible bg-transparent",
commandProps?.className,
)}
shouldFilter={
commandProps?.shouldFilter !== undefined
? commandProps.shouldFilter
: !onSearch
} // When onSearch is provided, we don't want to filter the options. You can still override it.
filter={commandFilter()}
>
<div
className={cn(
"min-h-10 rounded-md border border-input text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
{
"px-3 py-2": selected.length !== 0,
"cursor-text": !disabled && selected.length !== 0,
},
className,
)}
onClick={() => {
if (disabled) return;
inputRef.current?.focus();
}}
>
<div className="flex flex-wrap gap-1">
{selected.map((option) => {
return (
<Badge
key={option.value}
className={cn(
"data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground",
"data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground",
badgeClassName,
)}
data-fixed={option.fixed}
data-disabled={disabled || undefined}
>
{option.label ?? option.value}
<button
type="button"
className={cn(
"ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2",
(disabled || option.fixed) && "hidden",
)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleUnselect(option);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(option)}
>
<LuX className="w-3 h-3 text-muted-foreground hover:text-foreground" />
</button>
</Badge>
);
})}
{/* Avoid having the "Search" Icon */}
<CommandPrimitive.Input
{...inputProps}
ref={inputRef}
value={inputValue}
disabled={disabled}
onValueChange={(value) => {
setInputValue(value);
inputProps?.onValueChange?.(value);
}}
onBlur={(event) => {
setOpen(false);
inputProps?.onBlur?.(event);
}}
onFocus={(event) => {
setOpen(true);
if (triggerSearchOnFocus && onSearch) {
onSearch(debouncedSearchTerm);
}
inputProps?.onFocus?.(event);
}}
placeholder={
hidePlaceholderWhenSelected && selected.length !== 0
? ""
: placeholder
}
className={cn(
"flex-1 bg-transparent outline-none placeholder:text-muted-foreground",
{
"w-full": hidePlaceholderWhenSelected,
"px-3 py-2": selected.length === 0,
"ml-1": selected.length !== 0,
},
inputProps?.className,
)}
/>
</div>
</div>
<div className="relative">
{open && (
<CommandList className="absolute top-1 z-10 w-full rounded-md border shadow-md outline-none bg-popover text-popover-foreground animate-in">
{isLoading ? (
loadingIndicator
) : (
<>
{EmptyItem()}
{CreatableItem()}
{!selectFirstItem && (
<CommandItem value="-" className="hidden" />
)}
{Object.entries(selectables).map(([key, dropdowns]) => (
<CommandGroup
key={key}
heading={key}
className="overflow-auto h-full"
>
{dropdowns.map((option) => {
return (
<CommandItem
key={option.value}
value={option.value}
disabled={option.disable}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={() => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue("");
const newOptions = [...selected, option];
setSelected(newOptions);
onChange?.(newOptions);
}}
className={cn(
"cursor-pointer",
option.disable &&
"cursor-default text-muted-foreground",
)}
>
{option.label ?? option.value}
</CommandItem>
);
})}
</CommandGroup>
))}
</>
)}
</CommandList>
)}
</div>
</Command>
);
},
);
MultipleSelector.displayName = "MultipleSelector";
export default MultipleSelector;
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = "Textarea";
export { Textarea };
+161 -29
View File
@@ -71,41 +71,173 @@ export interface AppUpdateProgress {
}
export interface CamoufoxConfig {
os?: string[];
proxy?: string;
screen_max_width?: number;
screen_max_height?: number;
geoip?: string | boolean;
block_images?: boolean;
block_webrtc?: boolean;
block_webgl?: boolean;
disable_coop?: boolean;
geoip?: string | boolean;
country?: string;
executable_path?: string;
fingerprint?: string; // JSON string of the complete fingerprint config
}
// Extended interface for the advanced fingerprint configuration
export interface CamoufoxFingerprintConfig {
// Navigator properties
"navigator.userAgent"?: string;
"navigator.appVersion"?: string;
"navigator.platform"?: string;
"navigator.oscpu"?: string;
"navigator.appCodeName"?: string;
"navigator.appName"?: string;
"navigator.product"?: string;
"navigator.productSub"?: string;
"navigator.buildID"?: string;
"navigator.language"?: string;
"navigator.languages"?: string[];
"navigator.doNotTrack"?: string;
"navigator.hardwareConcurrency"?: number;
"navigator.maxTouchPoints"?: number;
"navigator.cookieEnabled"?: boolean;
"navigator.globalPrivacyControl"?: boolean;
"navigator.onLine"?: boolean;
// Screen properties
"screen.height"?: number;
"screen.width"?: number;
"screen.availHeight"?: number;
"screen.availWidth"?: number;
"screen.availTop"?: number;
"screen.availLeft"?: number;
"screen.colorDepth"?: number;
"screen.pixelDepth"?: number;
"screen.pageXOffset"?: number;
"screen.pageYOffset"?: number;
// Window properties
"window.outerHeight"?: number;
"window.outerWidth"?: number;
"window.innerHeight"?: number;
"window.innerWidth"?: number;
"window.screenX"?: number;
"window.screenY"?: number;
"window.scrollMinX"?: number;
"window.scrollMinY"?: number;
"window.scrollMaxX"?: number;
"window.scrollMaxY"?: number;
"window.devicePixelRatio"?: number;
"window.history.length"?: number;
// Document properties
"document.body.clientWidth"?: number;
"document.body.clientHeight"?: number;
"document.body.clientTop"?: number;
"document.body.clientLeft"?: number;
// Locale and geolocation
"locale:language"?: string;
"locale:region"?: string;
"locale:script"?: string;
"locale:all"?: string;
"geolocation:latitude"?: number;
"geolocation:longitude"?: number;
"geolocation:accuracy"?: number;
timezone?: string;
latitude?: number;
longitude?: number;
humanize?: boolean;
humanize_duration?: number;
headless?: boolean;
locale?: string[];
addons?: string[];
// Headers
"headers.Accept-Language"?: string;
"headers.User-Agent"?: string;
"headers.Accept-Encoding"?: string;
// WebRTC
"webrtc:ipv4"?: string;
"webrtc:ipv6"?: string;
"webrtc:localipv4"?: string;
"webrtc:localipv6"?: string;
// Battery
"battery:charging"?: boolean;
"battery:chargingTime"?: number;
"battery:dischargingTime"?: number;
"battery:level"?: number;
// Fonts
fonts?: string[];
custom_fonts_only?: boolean;
exclude_addons?: string[];
screen_min_width?: number;
screen_max_width?: number;
screen_min_height?: number;
screen_max_height?: number;
window_width?: number;
window_height?: number;
ff_version?: number;
main_world_eval?: boolean;
webgl_vendor?: string;
webgl_renderer?: string;
proxy?: string;
enable_cache?: boolean;
virtual_display?: string;
"fonts:spacing_seed"?: number;
// Audio
"AudioContext:sampleRate"?: number;
"AudioContext:outputLatency"?: number;
"AudioContext:maxChannelCount"?: number;
// Media devices
"mediaDevices:micros"?: number;
"mediaDevices:webcams"?: number;
"mediaDevices:speakers"?: number;
"mediaDevices:enabled"?: boolean;
// WebGL
"webGl:renderer"?: string;
"webGl:vendor"?: string;
"webGl:supportedExtensions"?: string[];
"webGl2:supportedExtensions"?: string[];
"webGl:contextAttributes"?: {
alpha?: boolean;
antialias?: boolean;
depth?: boolean;
failIfMajorPerformanceCaveat?: boolean;
powerPreference?: string;
premultipliedAlpha?: boolean;
preserveDrawingBuffer?: boolean;
stencil?: boolean;
};
"webGl2:contextAttributes"?: {
alpha?: boolean;
antialias?: boolean;
depth?: boolean;
failIfMajorPerformanceCaveat?: boolean;
powerPreference?: string;
premultipliedAlpha?: boolean;
preserveDrawingBuffer?: boolean;
stencil?: boolean;
};
"webGl:parameters"?: Record<string, unknown>;
"webGl2:parameters"?: Record<string, unknown>;
"webGl:shaderPrecisionFormats"?: Record<string, unknown>;
"webGl2:shaderPrecisionFormats"?: Record<string, unknown>;
// Canvas
"canvas:aaOffset"?: number;
"canvas:aaCapOffset"?: boolean;
// Voices
voices?: Array<{
isLocalService?: boolean;
isDefault?: boolean;
voiceURI?: string;
name?: string;
lang?: string;
}>;
"voices:blockIfNotDefined"?: boolean;
"voices:fakeCompletion"?: boolean;
"voices:fakeCompletion:charsPerSecond"?: number;
// Other properties
humanize?: boolean;
"humanize:maxTime"?: number;
"humanize:minTime"?: number;
showcursor?: boolean;
allowMainWorld?: boolean;
forceScopeAccess?: boolean;
enableRemoteSubframes?: boolean;
disableTheming?: boolean;
memorysaver?: boolean;
addons?: string[];
certificatePaths?: string[];
certificates?: string[];
debug?: boolean;
additional_args?: string[];
env_vars?: Record<string, string>;
firefox_prefs?: Record<string, unknown>;
pdfViewerEnabled?: boolean;
}
export interface CamoufoxLaunchResult {