Skip to content

Commit 42b445c

Browse files
NateIsernclaude
andcommitted
feat(web): say once what the browser build cannot protect
The web bundle is the same wallet, but its at-rest container is not: in a plain browser `kv-store` falls back to plaintext `localStorage` because there is no OS keychain to reach, there is no biometric unlock, and clearing site data destroys the wallet. wallet.fairco.in says that on the first visit and points at the native builds. Electron ships the SAME web bundle and is `Platform.OS === "web"`, but it reaches the OS keychain through its preload bridge and IS the native desktop app, so the notice would be wrong there — hence the `electronAPI` test rather than a bare platform check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6d8a3fd commit 42b445c

3 files changed

Lines changed: 128 additions & 0 deletions

File tree

app/_layout.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { useExplorerRealtime } from "../src/hooks/useExplorerRealtime";
3636
import { useWalletStore } from "../src/wallet/wallet-store";
3737
import { useLockStore } from "../src/wallet/lock-store";
3838
import { LockGate } from "../src/ui/components/LockGate";
39+
import { WebWelcomeDialog } from "../src/ui/components/WebWelcomeDialog";
3940
import { ErrorBoundary } from "../src/ui/components/ErrorBoundary";
4041
import { installCrashHandler } from "../src/services/crash-log";
4142
import { getAutoLockTimeout } from "../src/storage/secure-store";
@@ -363,6 +364,10 @@ function AppContent({ ready }: { ready: boolean }) {
363364
{/* Full-screen lock overlay: covers every authenticated route while the
364365
app is locked so no screen can be reached behind it (finding C1). */}
365366
<LockGate />
367+
{/* Browser-only first-visit notice: the web build stores keys in plain
368+
site storage, so it says so once before the wallet is used. Renders
369+
null on native and under Electron. */}
370+
<WebWelcomeDialog />
366371
</View>
367372
);
368373
}

src/i18n/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -866,6 +866,15 @@ const translations: Record<TranslatedLanguage, Record<string, string>> = {
866866
"notificationsSettings.events.incomingPending": "Incoming (pending)",
867867
"notificationsSettings.events.incomingConfirmed": "Incoming (confirmed)",
868868
"notificationsSettings.events.outgoingConfirmed": "Sent (confirmed)",
869+
870+
// ---------- Web build welcome ----------
871+
"webWelcome.title": "FAIRWallet in your browser",
872+
"webWelcome.description": "This is the full wallet, running entirely in this tab — your keys never leave it. The native apps still protect them better.",
873+
"webWelcome.caveat.storage": "In a browser your keys sit in this site's storage as plain text: there is no Keychain or Android keystore to lock them into.",
874+
"webWelcome.caveat.lock": "No biometric or device unlock — anyone with access to this browser profile reaches the wallet.",
875+
"webWelcome.caveat.siteData": "Clearing site data deletes the wallet. Back up your recovery phrase before you fund it.",
876+
"webWelcome.getApp": "Get the app",
877+
"webWelcome.continue": "Continue in browser",
869878
},
870879
es: {
871880
// ---------- Common ----------
@@ -1724,6 +1733,15 @@ const translations: Record<TranslatedLanguage, Record<string, string>> = {
17241733
"notificationsSettings.events.incomingPending": "Entrante (pendiente)",
17251734
"notificationsSettings.events.incomingConfirmed": "Entrante (confirmado)",
17261735
"notificationsSettings.events.outgoingConfirmed": "Enviado (confirmado)",
1736+
1737+
// ---------- Bienvenida a la versión web ----------
1738+
"webWelcome.title": "FAIRWallet en tu navegador",
1739+
"webWelcome.description": "Es la billetera completa, funcionando entera en esta pestaña: tus claves no salen de aquí. Aun así, las apps nativas las protegen mejor.",
1740+
"webWelcome.caveat.storage": "En el navegador tus claves se guardan en texto plano en el almacenamiento del sitio: no hay Keychain ni keystore de Android donde encerrarlas.",
1741+
"webWelcome.caveat.lock": "Sin desbloqueo biométrico ni del dispositivo: quien tenga acceso a este perfil del navegador llega a la billetera.",
1742+
"webWelcome.caveat.siteData": "Si borras los datos del sitio, borras la billetera. Guarda tu frase de recuperación antes de meterle fondos.",
1743+
"webWelcome.getApp": "Descargar la app",
1744+
"webWelcome.continue": "Seguir en el navegador",
17271745
},
17281746
};
17291747

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* One-time welcome dialog for the browser build (wallet.fairco.in).
3+
*
4+
* The web bundle is the same wallet as the native app, but its at-rest
5+
* container is not: `kv-store` falls back to plaintext `localStorage` in a
6+
* plain browser because there is no OS keychain to reach — no Keychain /
7+
* EncryptedSharedPreferences, no biometric unlock, and clearing site data
8+
* destroys the wallet. That is a real difference in custody, so the browser
9+
* says it out loud once, on first visit, and points at the native builds.
10+
*
11+
* Electron ships the SAME web bundle but reaches the OS keychain through its
12+
* preload bridge and IS the native desktop app, so the warning would be wrong
13+
* there — hence the `electronAPI` check rather than a bare `Platform.OS`
14+
* test.
15+
*/
16+
17+
import { useCallback, useEffect, useState } from "react";
18+
import { Linking, Platform, Text, View } from "react-native";
19+
import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons";
20+
import { Dialog } from "@oxyhq/bloom/dialog";
21+
import { useTheme } from "@oxyhq/bloom/theme";
22+
import { t } from "../../i18n";
23+
import { getItemAsync, setItemAsync } from "../../storage/kv-store";
24+
25+
const SEEN_KEY = "fairwallet_web_welcome_seen";
26+
27+
const DOWNLOAD_URL = "https://fairco.in/wallet";
28+
29+
/**
30+
* True only in a plain browser tab. Electron's renderer is also
31+
* `Platform.OS === "web"`, but it exposes the preload bridge, and it is the
32+
* desktop app this dialog would otherwise be recommending.
33+
*/
34+
const isBrowser =
35+
Platform.OS === "web" &&
36+
typeof window !== "undefined" &&
37+
!("electronAPI" in window);
38+
39+
// Kick the storage round-trip off at module scope, like the root layout does
40+
// for the theme, so the flag is already in flight before the first render.
41+
const seenPromise = isBrowser
42+
? getItemAsync(SEEN_KEY).catch(() => null)
43+
: Promise.resolve("1");
44+
45+
const CAVEATS = [
46+
"webWelcome.caveat.storage",
47+
"webWelcome.caveat.lock",
48+
"webWelcome.caveat.siteData",
49+
] as const;
50+
51+
export function WebWelcomeDialog() {
52+
const theme = useTheme();
53+
const [open, setOpen] = useState(false);
54+
55+
useEffect(() => {
56+
if (!isBrowser) return;
57+
let active = true;
58+
seenPromise.then((seen) => {
59+
if (active && seen === null) setOpen(true);
60+
});
61+
return () => {
62+
active = false;
63+
};
64+
}, []);
65+
66+
const dismiss = useCallback(() => {
67+
setOpen(false);
68+
void setItemAsync(SEEN_KEY, "1");
69+
}, []);
70+
71+
const openDownloads = useCallback(() => {
72+
void Linking.openURL(DOWNLOAD_URL);
73+
}, []);
74+
75+
if (!isBrowser) return null;
76+
77+
return (
78+
<Dialog
79+
open={open}
80+
onClose={dismiss}
81+
placement={{ base: "bottom", md: "center" }}
82+
title={t("webWelcome.title")}
83+
description={t("webWelcome.description")}
84+
actions={[
85+
{ label: t("webWelcome.getApp"), onPress: openDownloads },
86+
{ label: t("webWelcome.continue"), color: "cancel" },
87+
]}
88+
>
89+
<View className="gap-3 mt-2">
90+
{CAVEATS.map((key) => (
91+
<View key={key} className="flex-row gap-2.5">
92+
<MaterialCommunityIcons
93+
name="alert-circle-outline"
94+
size={18}
95+
color={theme.colors.textSecondary}
96+
/>
97+
<Text className="text-muted-foreground text-sm flex-1">
98+
{t(key)}
99+
</Text>
100+
</View>
101+
))}
102+
</View>
103+
</Dialog>
104+
);
105+
}

0 commit comments

Comments
 (0)