mirror of
https://github.com/selfxyz/self.git
synced 2026-04-27 03:01:15 -04:00
SELF-2323: fix: keychain to webview communication (#1871)
* fix: keychain to webview communication * lint * update coderabbit comments * lint
This commit is contained in:
@@ -16,7 +16,7 @@ class MessageRouter(
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||
) {
|
||||
private val handlers = mutableMapOf<BridgeDomain, BridgeHandler>()
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
|
||||
fun register(handler: BridgeHandler) {
|
||||
handlers[handler.domain] = handler
|
||||
@@ -26,8 +26,10 @@ class MessageRouter(
|
||||
val request = try {
|
||||
json.decodeFromString<BridgeRequest>(rawJson)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("BridgeRouter", "Failed to decode request: ${e::class.simpleName}")
|
||||
return
|
||||
}
|
||||
android.util.Log.d("BridgeRouter", "Received: domain=${request.domain} method=${request.method}")
|
||||
|
||||
if (request.version != BRIDGE_PROTOCOL_VERSION) {
|
||||
sendResponse(
|
||||
@@ -118,6 +120,7 @@ class MessageRouter(
|
||||
|
||||
private fun sendResponse(response: BridgeResponse) {
|
||||
val responseJson = json.encodeToString(response)
|
||||
android.util.Log.d("BridgeRouter", "Sending response: domain=${response.domain} success=${response.success}")
|
||||
sendToWebView("window.SelfNativeBridge._handleResponse(${escapeForJs(responseJson)})")
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.security.crypto.MasterKey
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import xyz.self.sdk.bridge.BridgeDomain
|
||||
import xyz.self.sdk.bridge.BridgeHandler
|
||||
@@ -48,7 +49,9 @@ class SecureStorageHandler(context: Context) : BridgeHandler {
|
||||
val key = params["key"]?.jsonPrimitive?.content
|
||||
?: throw BridgeHandlerException("MISSING_KEY", "Key parameter required")
|
||||
val value = prefs.getString(key, null)
|
||||
return if (value != null) JsonPrimitive(value) else JsonNull
|
||||
return buildJsonObject {
|
||||
put("value", if (value != null) JsonPrimitive(value) else JsonNull)
|
||||
}
|
||||
}
|
||||
|
||||
private fun set(params: Map<String, JsonElement>): JsonElement? {
|
||||
|
||||
@@ -103,7 +103,10 @@ class AndroidWebViewHost(
|
||||
}
|
||||
|
||||
fun evaluateJs(js: String) {
|
||||
if (!::webView.isInitialized) return
|
||||
if (!::webView.isInitialized) {
|
||||
android.util.Log.e("WebViewHost", "evaluateJs called but webView not initialized")
|
||||
return
|
||||
}
|
||||
webView.evaluateJavascript(js, null)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { NotificationPreferencesScreen } from './screens/account/NotificationPre
|
||||
import { SecurityScreen } from './screens/account/SecurityScreen';
|
||||
import { SettingsScreen } from './screens/account/SettingsScreen';
|
||||
import { ComingSoonScreen } from './screens/ComingSoonScreen';
|
||||
import { KeychainDebugScreen } from './screens/debug/KeychainDebugScreen';
|
||||
import { HomeScreen } from './screens/home/HomeScreen';
|
||||
import { ConfirmIdentificationScreen } from './screens/onboarding/ConfirmIdentificationScreen';
|
||||
import { CountryPickerScreen } from './screens/onboarding/CountryPickerScreen';
|
||||
@@ -45,6 +46,7 @@ export const App: React.FC = () => (
|
||||
<Route path="/settings/security" element={<SecurityScreen />} />
|
||||
<Route path="/settings/notifications" element={<NotificationPreferencesScreen />} />
|
||||
<Route path="/settings/dev-mode" element={<DevModeScreen />} />
|
||||
{import.meta.env.DEV && <Route path="/debug/keychain" element={<KeychainDebugScreen />} />}
|
||||
<Route path="/account/verified" element={<VerificationResultScreen />} />
|
||||
<Route path="/coming-soon" element={<ComingSoonScreen />} />
|
||||
<Route path="/tunnel/tour/:step" element={<TourScreen />} />
|
||||
|
||||
@@ -58,32 +58,55 @@ export const DevModeScreen: React.FC = () => {
|
||||
}, [navigate, haptic, analytics, documentType, nationality, ageIndex, expiryIndex, ofacCheck]);
|
||||
|
||||
return (
|
||||
<EuclidDevModeScreen
|
||||
insets={{ top: 0, bottom: 0 }}
|
||||
escapeIcon={({ size, color }) => <LeftArrowIcon size={size} color={color} />}
|
||||
onBack={onBack}
|
||||
idCard={idCard}
|
||||
documentType={documentType}
|
||||
onDocumentTypePress={() => {
|
||||
setDocumentType(prev => (prev === 'passport' ? 'id_card' : 'passport'));
|
||||
}}
|
||||
nationality={nationality}
|
||||
onNationalityPress={() => {
|
||||
setNationality(prev => (prev === 'united states of america' ? 'germany' : 'united states of america'));
|
||||
}}
|
||||
age={ageOptions[ageIndex]}
|
||||
onAgeIncrement={() => setAgeIndex(prev => Math.min(prev + 1, ageOptions.length - 1))}
|
||||
onAgeDecrement={() => setAgeIndex(prev => Math.max(prev - 1, 0))}
|
||||
documentExpiresIn={expiryOptions[expiryIndex]}
|
||||
onDocumentExpiresIncrement={() => setExpiryIndex(prev => Math.min(prev + 1, expiryOptions.length - 1))}
|
||||
onDocumentExpiresDecrement={() => setExpiryIndex(prev => Math.max(prev - 1, 0))}
|
||||
ofacCheck={ofacCheck}
|
||||
onOfacCheckChange={value => {
|
||||
haptic.trigger('selection');
|
||||
setOfacCheck(value);
|
||||
}}
|
||||
onResetAllValues={onResetAllValues}
|
||||
onGenerateMockDocument={onGenerateMockDocument}
|
||||
/>
|
||||
<>
|
||||
<EuclidDevModeScreen
|
||||
insets={{ top: 0, bottom: 0 }}
|
||||
escapeIcon={({ size, color }) => <LeftArrowIcon size={size} color={color} />}
|
||||
onBack={onBack}
|
||||
idCard={idCard}
|
||||
documentType={documentType}
|
||||
onDocumentTypePress={() => {
|
||||
setDocumentType(prev => (prev === 'passport' ? 'id_card' : 'passport'));
|
||||
}}
|
||||
nationality={nationality}
|
||||
onNationalityPress={() => {
|
||||
setNationality(prev => (prev === 'united states of america' ? 'germany' : 'united states of america'));
|
||||
}}
|
||||
age={ageOptions[ageIndex]}
|
||||
onAgeIncrement={() => setAgeIndex(prev => Math.min(prev + 1, ageOptions.length - 1))}
|
||||
onAgeDecrement={() => setAgeIndex(prev => Math.max(prev - 1, 0))}
|
||||
documentExpiresIn={expiryOptions[expiryIndex]}
|
||||
onDocumentExpiresIncrement={() => setExpiryIndex(prev => Math.min(prev + 1, expiryOptions.length - 1))}
|
||||
onDocumentExpiresDecrement={() => setExpiryIndex(prev => Math.max(prev - 1, 0))}
|
||||
ofacCheck={ofacCheck}
|
||||
onOfacCheckChange={value => {
|
||||
haptic.trigger('selection');
|
||||
setOfacCheck(value);
|
||||
}}
|
||||
onResetAllValues={onResetAllValues}
|
||||
onGenerateMockDocument={onGenerateMockDocument}
|
||||
/>
|
||||
{import.meta.env.DEV && (
|
||||
<button
|
||||
onClick={() => navigate('/debug/keychain')}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 24,
|
||||
right: 24,
|
||||
padding: '10px 18px',
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
backgroundColor: '#7c8aff',
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
Keychain Debug
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
345
packages/webview-app/src/screens/debug/KeychainDebugScreen.tsx
Normal file
345
packages/webview-app/src/screens/debug/KeychainDebugScreen.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { bridgeStorageAdapter } from '@selfxyz/webview-bridge/adapters';
|
||||
|
||||
import { useBridge } from '../../providers/BridgeProvider';
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
|
||||
interface LogEntry {
|
||||
time: string;
|
||||
message: string;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
function timestamp(): string {
|
||||
return new Date().toLocaleTimeString('en-US', { hour12: false });
|
||||
}
|
||||
|
||||
export const KeychainDebugScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const bridge = useBridge();
|
||||
const { documents } = useSelfClient();
|
||||
const storage = useRef(bridgeStorageAdapter(bridge)).current;
|
||||
|
||||
const [key, setKey] = useState('test-key');
|
||||
const [value, setValue] = useState('hello-world');
|
||||
const [docId, setDocId] = useState('mock-doc-1');
|
||||
const [log, setLog] = useState<LogEntry[]>([]);
|
||||
|
||||
const addLog = useCallback((message: string, error = false) => {
|
||||
setLog(prev => [...prev, { time: timestamp(), message, error }]);
|
||||
}, []);
|
||||
|
||||
const handlePing = useCallback(async () => {
|
||||
addLog(`Bridge: connected=${bridge.isConnected}, pending=${bridge.pendingCount}`);
|
||||
try {
|
||||
const result = await bridge.request('secureStorage', 'get', { key: '__ping__' }, 5000);
|
||||
addLog(`PING OK -> ${JSON.stringify(result)}`);
|
||||
} catch (e) {
|
||||
addLog(`PING FAILED: ${e}`, true);
|
||||
}
|
||||
}, [bridge, addLog]);
|
||||
|
||||
const handleSet = useCallback(async () => {
|
||||
try {
|
||||
await storage.set(key, value);
|
||||
addLog(`SET "${key}" = "${value}" -> OK`);
|
||||
} catch (e) {
|
||||
addLog(`SET "${key}" FAILED: ${e}`, true);
|
||||
}
|
||||
}, [storage, key, value, addLog]);
|
||||
|
||||
const handleGet = useCallback(async () => {
|
||||
try {
|
||||
const result = await storage.get(key);
|
||||
addLog(`GET "${key}" -> ${result === null ? 'null' : `"${result}"`}`);
|
||||
} catch (e) {
|
||||
addLog(`GET "${key}" FAILED: ${e}`, true);
|
||||
}
|
||||
}, [storage, key, addLog]);
|
||||
|
||||
const handleRemove = useCallback(async () => {
|
||||
try {
|
||||
await storage.remove(key);
|
||||
addLog(`REMOVE "${key}" -> OK`);
|
||||
} catch (e) {
|
||||
addLog(`REMOVE "${key}" FAILED: ${e}`, true);
|
||||
}
|
||||
}, [storage, key, addLog]);
|
||||
|
||||
const handleSaveDoc = useCallback(async () => {
|
||||
try {
|
||||
const mockDoc = {
|
||||
documentType: 'passport',
|
||||
issuingCountry: 'US',
|
||||
nationality: 'USA',
|
||||
dateOfBirth: '1990-01-15',
|
||||
dateOfExpiry: '2030-06-20',
|
||||
documentNumber: 'X12345678',
|
||||
firstName: 'Debug',
|
||||
lastName: 'User',
|
||||
};
|
||||
await documents.saveDocument(docId, mockDoc as never);
|
||||
|
||||
const catalog = await documents.loadDocumentCatalog();
|
||||
const entry = {
|
||||
id: docId,
|
||||
documentType: 'passport',
|
||||
documentCategory: 'passport' as const,
|
||||
data: '',
|
||||
mock: true,
|
||||
};
|
||||
const existing = catalog.documents.findIndex((d: { id: string }) => d.id === docId);
|
||||
if (existing >= 0) {
|
||||
catalog.documents[existing] = entry;
|
||||
} else {
|
||||
catalog.documents.push(entry);
|
||||
}
|
||||
await documents.saveDocumentCatalog(catalog);
|
||||
|
||||
addLog(`SAVE DOC "${docId}" + catalog -> OK`);
|
||||
} catch (e) {
|
||||
addLog(`SAVE DOC "${docId}" FAILED: ${e}`, true);
|
||||
}
|
||||
}, [documents, docId, addLog]);
|
||||
|
||||
const handleLoadDoc = useCallback(async () => {
|
||||
try {
|
||||
const doc = await documents.loadDocumentById(docId);
|
||||
addLog(`LOAD DOC "${docId}" -> ${doc === null ? 'null' : JSON.stringify(doc).slice(0, 120)}`);
|
||||
} catch (e) {
|
||||
addLog(`LOAD DOC "${docId}" FAILED: ${e}`, true);
|
||||
}
|
||||
}, [documents, docId, addLog]);
|
||||
|
||||
const handleLoadCatalog = useCallback(async () => {
|
||||
try {
|
||||
const catalog = await documents.loadDocumentCatalog();
|
||||
addLog(`CATALOG -> ${JSON.stringify(catalog)}`);
|
||||
} catch (e) {
|
||||
addLog(`CATALOG FAILED: ${e}`, true);
|
||||
}
|
||||
}, [documents, addLog]);
|
||||
|
||||
const handleDeleteDoc = useCallback(async () => {
|
||||
try {
|
||||
await documents.deleteDocument(docId);
|
||||
|
||||
const catalog = await documents.loadDocumentCatalog();
|
||||
catalog.documents = catalog.documents.filter((d: { id: string }) => d.id !== docId);
|
||||
await documents.saveDocumentCatalog(catalog);
|
||||
|
||||
addLog(`DELETE DOC "${docId}" + catalog update -> OK`);
|
||||
} catch (e) {
|
||||
addLog(`DELETE DOC "${docId}" FAILED: ${e}`, true);
|
||||
}
|
||||
}, [documents, docId, addLog]);
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<button style={styles.backButton} onClick={() => navigate('/settings/dev-mode')}>
|
||||
← Back
|
||||
</button>
|
||||
<h2 style={styles.title}>Keychain Debug</h2>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 12, color: bridge.isConnected ? '#a0e0a0' : '#e94560' }}>
|
||||
{bridge.isConnected ? 'Bridge connected' : 'No transport'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={styles.section}>
|
||||
<div style={styles.row}>
|
||||
<button style={{ ...styles.button, backgroundColor: '#e0a030' }} onClick={handlePing}>
|
||||
Ping Bridge (5s timeout)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.section}>
|
||||
<h3 style={styles.sectionTitle}>Raw Secure Storage</h3>
|
||||
<div style={styles.row}>
|
||||
<input style={styles.input} placeholder="Key" value={key} onChange={e => setKey(e.target.value)} />
|
||||
<input style={styles.input} placeholder="Value" value={value} onChange={e => setValue(e.target.value)} />
|
||||
</div>
|
||||
<div style={styles.row}>
|
||||
<button style={styles.button} onClick={handleSet}>
|
||||
Set
|
||||
</button>
|
||||
<button style={styles.button} onClick={handleGet}>
|
||||
Get
|
||||
</button>
|
||||
<button style={{ ...styles.button, ...styles.dangerButton }} onClick={handleRemove}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.section}>
|
||||
<h3 style={styles.sectionTitle}>Documents Adapter</h3>
|
||||
<div style={styles.row}>
|
||||
<input
|
||||
style={styles.input}
|
||||
placeholder="Document ID"
|
||||
value={docId}
|
||||
onChange={e => setDocId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={styles.row}>
|
||||
<button style={styles.button} onClick={handleSaveDoc}>
|
||||
Save Mock
|
||||
</button>
|
||||
<button style={styles.button} onClick={handleLoadDoc}>
|
||||
Load
|
||||
</button>
|
||||
<button style={{ ...styles.button, ...styles.dangerButton }} onClick={handleDeleteDoc}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<div style={styles.row}>
|
||||
<button style={styles.button} onClick={handleLoadCatalog}>
|
||||
Load Catalog
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.logSection}>
|
||||
<div style={styles.logHeader}>
|
||||
<h3 style={styles.sectionTitle}>Log</h3>
|
||||
<button style={styles.clearButton} onClick={() => setLog([])}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div style={styles.logArea}>
|
||||
{log.length === 0 && <span style={styles.placeholder}>No operations yet</span>}
|
||||
{log.map((entry, i) => (
|
||||
<div key={i} style={entry.error ? styles.logError : styles.logEntry}>
|
||||
<span style={styles.logTime}>{entry.time}</span> {entry.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
padding: 16,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
maxWidth: 480,
|
||||
margin: '0 auto',
|
||||
color: '#e0e0e0',
|
||||
backgroundColor: '#1a1a2e',
|
||||
minHeight: '100vh',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 20,
|
||||
},
|
||||
backButton: {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#7c8aff',
|
||||
fontSize: 18,
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
},
|
||||
title: {
|
||||
margin: 0,
|
||||
fontSize: 20,
|
||||
fontWeight: 600,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 20,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#16213e',
|
||||
},
|
||||
sectionTitle: {
|
||||
margin: '0 0 10px',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: 1,
|
||||
color: '#7c8aff',
|
||||
},
|
||||
row: {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #333',
|
||||
backgroundColor: '#0f3460',
|
||||
color: '#e0e0e0',
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
},
|
||||
button: {
|
||||
padding: '8px 14px',
|
||||
borderRadius: 6,
|
||||
border: 'none',
|
||||
backgroundColor: '#7c8aff',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
dangerButton: {
|
||||
backgroundColor: '#e94560',
|
||||
},
|
||||
logSection: {
|
||||
flex: 1,
|
||||
},
|
||||
logHeader: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
clearButton: {
|
||||
background: 'none',
|
||||
border: '1px solid #555',
|
||||
color: '#aaa',
|
||||
borderRadius: 4,
|
||||
padding: '4px 10px',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
logArea: {
|
||||
backgroundColor: '#0a0a1a',
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
maxHeight: 300,
|
||||
overflowY: 'auto' as const,
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
placeholder: {
|
||||
color: '#555',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
logEntry: {
|
||||
color: '#a0e0a0',
|
||||
wordBreak: 'break-all' as const,
|
||||
},
|
||||
logError: {
|
||||
color: '#e94560',
|
||||
wordBreak: 'break-all' as const,
|
||||
},
|
||||
logTime: {
|
||||
color: '#666',
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user