diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index 1ce2d608..76530928 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -194,6 +194,50 @@ test("profile creation stays compact and saves basic, protected, and ephemeral p en.createProfile.proxy.noProxy, ]); await app.capture("profile-create-compact-wide"); + // The device's operating system is chosen on the dialog itself. Without + // a plan that includes cross-OS fingerprints, only this computer's + // system can be picked. + const hostOs = + { darwin: "macOS", win32: "Windows" }[process.platform] ?? "Linux"; + assert.equal( + await app.execute( + `return document.querySelector('#profile-os').textContent.trim();`, + ), + hostOs, + ); + await app.clickSelector("#profile-os"); + await app.waitFor( + () => + app.execute( + `return document.querySelectorAll('[cmdk-item]').length;`, + ), + { description: "operating system options" }, + ); + const osOptions = await app.execute(` + return [...document.querySelectorAll('[cmdk-item]')].map((item) => ({ + text: item.textContent.trim(), + disabled: item.getAttribute('aria-disabled') === 'true', + })); + `); + assert.equal(osOptions.length, 5, JSON.stringify(osOptions)); + assert.deepEqual( + osOptions.filter((option) => !option.disabled).map((o) => o.text), + [hostOs], + ); + assert.ok( + osOptions + .filter((option) => option.disabled) + .every((option) => option.text.endsWith("PRO")), + JSON.stringify(osOptions), + ); + await app.pressShortcut({ key: "Escape" }); + await app.waitFor( + () => + app.execute( + `return !document.querySelector('[cmdk-item]') && Boolean(document.querySelector('#profile-name'));`, + ), + { description: "the OS menu to close and the dialog to stay" }, + ); // Adding a route is part of the route list, and stays there when the // search finds nothing. await app.clickSelector("#profile-proxy"); @@ -1350,7 +1394,7 @@ test("soft table and creation surfaces stay quiet and readable in every theme", const colors = await paintedColors(app, { off: ["#ephemeral"], on: ["#enable-password"], - name: ['label[for="profile-name"]'], + name: ['[data-slot="profile-name-field"]'], password: ["#profile-password"], }); assertQuietTint(colors.off, colors.page, 1.35, `${theme.id} option`); diff --git a/src/components/create-profile-dialog.tsx b/src/components/create-profile-dialog.tsx index e48be848..7603808a 100644 --- a/src/components/create-profile-dialog.tsx +++ b/src/components/create-profile-dialog.tsx @@ -52,6 +52,7 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { ProBadge } from "@/components/ui/pro-badge"; import { Select, SelectContent, @@ -69,6 +70,7 @@ import { useBrowserDownload } from "@/hooks/use-browser-download"; import { useInputModality } from "@/hooks/use-input-modality"; import { useProxyEvents } from "@/hooks/use-proxy-events"; import { useVpnEvents } from "@/hooks/use-vpn-events"; +import { getOSDisplayName, getOSIcon } from "@/lib/browser-utils"; import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels"; import { MOTION_EASE_OUT } from "@/lib/motion"; import { cn } from "@/lib/utils"; @@ -76,6 +78,8 @@ import type { BrowserReleaseTypes, WayfernConfig, WayfernOS } from "@/types"; const PASSWORD_MIN_LEN = 8; +const OS_OPTIONS: WayfernOS[] = ["windows", "macos", "linux", "android", "ios"]; + const getCurrentOS = (): WayfernOS => { if (typeof navigator === "undefined") return "linux"; const platform = navigator.platform.toLowerCase(); @@ -125,6 +129,9 @@ export function CreateProfileDialog({ const proxyListboxId = useId(); const proxyLabelId = useId(); const proxyValueId = useId(); + const osListboxId = useId(); + const osLabelId = useId(); + const osValueId = useId(); const advancedOptionsId = useId(); const reduceMotion = useReducedMotion() ?? false; const inputModality = useInputModality(); @@ -132,6 +139,7 @@ export function CreateProfileDialog({ const [advancedOpen, setAdvancedOpen] = useState(false); const [selectedProxyId, setSelectedProxyId] = useState(); const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false); + const [osPopoverOpen, setOsPopoverOpen] = useState(false); const [showProxyForm, setShowProxyForm] = useState(false); const [dnsBlocklist, setDnsBlocklist] = useState(""); const [launchHook, setLaunchHook] = useState(""); @@ -271,6 +279,10 @@ export function CreateProfileDialog({ const proxyLabel = selectedVpn ? `WG — ${selectedVpn.name}` : (selectedProxy?.name ?? t("createProfile.proxy.noProxy")); + const hostOs = getCurrentOS(); + const selectedOs = wayfernConfig.os ?? hostOs; + const crossOs = selectedOs !== hostOs; + const SelectedOsIcon = getOSIcon(selectedOs); const clearPassword = () => { setEnablePassword(false); @@ -285,6 +297,7 @@ export function CreateProfileDialog({ setAdvancedOpen(false); setSelectedProxyId(undefined); setProxyPopoverOpen(false); + setOsPopoverOpen(false); setShowProxyForm(false); setDnsBlocklist(""); setLaunchHook(""); @@ -417,38 +430,115 @@ export function CreateProfileDialog({ data-slot="profile-create-fields" className="-mx-1 flex min-h-0 flex-col gap-3 overflow-y-auto px-1 pt-1 pb-2" > - - - {t("createProfile.profileName")} - setProfileName(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter" && !event.nativeEvent.isComposing) { - event.preventDefault(); - void handleCreate(); - } - }} - placeholder={t("createProfile.profileNamePlaceholder")} - disabled={isCreating} - className="h-9 text-base font-medium md:text-base" - /> - + + + {t("fingerprint.osLabel")} + + + + + + + + + + {OS_OPTIONS.map((os) => { + const locked = os !== hostOs && !crossOsUnlocked; + const Icon = getOSIcon(os); + return ( + { + updateWayfernConfig("os", os); + setOsPopoverOpen(false); + }} + > + + ); + })} + + + + + + )} + {crossOs && ( + + + )} + {downloading ? ( diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 14c2c42d..fe85de11 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -19,7 +19,6 @@ import { emit, listen } from "@tauri-apps/api/event"; import type { Dispatch, SetStateAction } from "react"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import { FaApple, FaLinux, FaWindows } from "react-icons/fa"; import { FiWifi } from "react-icons/fi"; import { LuArrowDown, @@ -128,6 +127,7 @@ import { parseBackendError, translateBackendError } from "@/lib/backend-errors"; import { getBrowserDisplayName, getOSDisplayName, + getOSIcon, getProfileIcon, isCrossOsProfile, } from "@/lib/browser-utils"; @@ -2543,13 +2543,7 @@ export function ProfilesDataTable({ const profile = row.original; const crossOs = isCrossOsProfile(profile); const os = profile.host_os || profile.wayfern_config?.os || ""; - const Icon = crossOs - ? os === "macos" - ? FaApple - : os === "windows" - ? FaWindows - : FaLinux - : getProfileIcon(profile); + const Icon = crossOs ? getOSIcon(os) : getProfileIcon(profile); const message = crossOs ? t("crossOs.viewOnly", { os: getOSDisplayName(os) }) : meta.runningProfiles.has(profile.id) diff --git a/src/components/wayfern-config-form.tsx b/src/components/wayfern-config-form.tsx index a92852de..e0638322 100644 --- a/src/components/wayfern-config-form.tsx +++ b/src/components/wayfern-config-form.tsx @@ -176,6 +176,8 @@ interface WayfernConfigFormProps { readOnly?: boolean; crossOsUnlocked?: boolean; limitedMode?: boolean; + /** False when the caller shows its own operating system picker. */ + osPicker?: boolean; profileVersion?: string; profileBrowser?: string; /// The saved profile whose persona is edited here. Absent while a profile is @@ -275,6 +277,7 @@ export function WayfernConfigForm({ readOnly = false, crossOsUnlocked = false, limitedMode = false, + osPicker = true, profileVersion, profileBrowser, profileId, @@ -428,60 +431,65 @@ export function WayfernConfigForm({ * whereas their absence is the truth. */ const isIdentityDerived = config.identity_id != null; + const generateButton = profileVersion && (!isCreating || crossOsUnlocked) && ( + + {isCreating + ? t("fingerprint.generateFingerprint") + : t("fingerprint.regenerateFingerprint")} + + ); + const renderAdvancedForm = () => (
- {/* Operating System Selection */} -
-
- - {profileVersion && (!isCreating || crossOsUnlocked) && ( - - {isCreating - ? t("fingerprint.generateFingerprint") - : t("fingerprint.regenerateFingerprint")} - + {!osPicker ? ( + generateButton &&
{generateButton}
+ ) : ( +
+
+ + {generateButton} +
+ + {selectedOS !== currentOS && crossOsUnlocked && ( + + + {t("fingerprint.crossOsWarning")} + + )}
- - {selectedOS !== currentOS && crossOsUnlocked && ( - - - {t("fingerprint.crossOsWarning")} - - - )} -
+ )} {/* Randomize Fingerprint Option */}
diff --git a/src/lib/browser-utils.ts b/src/lib/browser-utils.ts index 42aaf393..02a25baa 100644 --- a/src/lib/browser-utils.ts +++ b/src/lib/browser-utils.ts @@ -6,12 +6,16 @@ import { SiBrave, SiOpera, SiVivaldi } from "react-icons/si"; */ import { + FaAndroid, + FaApple, FaChrome, FaEdge, FaExclamationTriangle, FaFire, FaFirefox, + FaLinux, FaSafari, + FaWindows, } from "react-icons/fa"; import { LuLock } from "react-icons/lu"; import { getCurrentOS } from "@/lib/platform"; @@ -103,7 +107,25 @@ export function getOSDisplayName(os: string): string { return "Windows"; case "linux": return "Linux"; + case "android": + return "Android"; + case "ios": + return "iOS"; default: return os; } } + +export function getOSIcon(os: string) { + switch (os) { + case "macos": + case "ios": + return FaApple; + case "windows": + return FaWindows; + case "android": + return FaAndroid; + default: + return FaLinux; + } +}