From e0cd0930a184704ffe2fe7e353835c10d45cd448 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 6 Feb 2026 18:16:08 +0530 Subject: [PATCH 01/21] feat: add mnemonic recovery flow and encryption key management - Introduced a new Mnemonic page for users to generate or import their recovery phrase. - Updated routing to redirect users to the Mnemonic page after onboarding. - Enhanced state management to store and retrieve AES encryption keys derived from the mnemonic. - Added utility functions for generating and validating BIP39 mnemonic phrases. - Updated selectors and reducers to handle encryption key state in the auth slice. - Included new dependencies for cryptographic operations. --- package.json | 2 + src/AppRoutes.tsx | 23 +- src/pages/Mnemonic.tsx | 360 ++++++++++++++++++++++++++++ src/pages/onboarding/Onboarding.tsx | 2 +- src/store/authSelectors.ts | 6 + src/store/authSlice.ts | 11 +- src/store/index.ts | 2 +- src/utils/cryptoKeys.ts | 35 +++ yarn.lock | 18 ++ 9 files changed, 455 insertions(+), 4 deletions(-) create mode 100644 src/pages/Mnemonic.tsx create mode 100644 src/utils/cryptoKeys.ts diff --git a/package.json b/package.json index c76e94153..e43d9b45a 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,9 @@ "prepare": "husky" }, "dependencies": { + "@noble/hashes": "^2.0.1", "@reduxjs/toolkit": "^2.11.2", + "@scure/bip39": "^2.0.1", "@sentry/react": "^10.38.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-deep-link": "^2", diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index 6cf59dc2a..227bd91df 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -7,18 +7,27 @@ import PublicRoute from './components/PublicRoute'; import SettingsModal from './components/settings/SettingsModal'; import Home from './pages/Home'; import Login from './pages/Login'; +import Mnemonic from './pages/Mnemonic'; import Onboarding from './pages/onboarding/Onboarding'; import Welcome from './pages/Welcome'; -import { selectIsOnboarded } from './store/authSelectors'; +import { selectHasEncryptionKey, selectIsOnboarded } from './store/authSelectors'; import { useAppSelector } from './store/hooks'; import { isTauri } from './utils/tauriCommands'; const OnboardingRoute = () => { const isOnboarded = useAppSelector(selectIsOnboarded); + const hasEncryptionKey = useAppSelector(selectHasEncryptionKey); + if (isOnboarded && !hasEncryptionKey) return ; if (isOnboarded) return ; return ; }; +const MnemonicRoute = () => { + const hasEncryptionKey = useAppSelector(selectHasEncryptionKey); + if (hasEncryptionKey) return ; + return ; +}; + /** * Home route wrapper: shows Home by default. * Only redirects to onboarding when user profile is loaded and onboarding is not done. @@ -26,6 +35,7 @@ const OnboardingRoute = () => { const HomeRoute = () => { const user = useAppSelector(state => state.user.user); const isOnboarded = useAppSelector(selectIsOnboarded); + const hasEncryptionKey = useAppSelector(selectHasEncryptionKey); // While user profile is still loading, show Home (avoid flash to onboarding) if (!user) return ; @@ -33,6 +43,9 @@ const HomeRoute = () => { // User loaded but onboarding not done → redirect to onboarding if (!isOnboarded) return ; + // Onboarded but no encryption key → redirect to mnemonic page + if (!hasEncryptionKey) return ; + return ; }; @@ -76,6 +89,14 @@ const AppRoutes = () => { } /> + + + + } + /> { + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + const user = useAppSelector(state => state.user.user); + const [mode, setMode] = useState<'generate' | 'import'>('generate'); + const [copied, setCopied] = useState(false); + const [confirmed, setConfirmed] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Generate mode state + const mnemonic = useMemo(() => generateMnemonicPhrase(), []); + const words = useMemo(() => mnemonic.split(' '), [mnemonic]); + + // Import mode state + const [importWords, setImportWords] = useState(Array(WORD_COUNT).fill('')); + const [importValid, setImportValid] = useState(null); + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + + useEffect(() => { + if (copied) { + const timer = setTimeout(() => setCopied(false), 3000); + return () => clearTimeout(timer); + } + }, [copied]); + + // Reset state when switching modes + useEffect(() => { + setConfirmed(false); + setError(null); + setImportValid(null); + }, [mode]); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(mnemonic); + setCopied(true); + } catch { + const textarea = document.createElement('textarea'); + textarea.value = mnemonic; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + setCopied(true); + } + }, [mnemonic]); + + const handleImportWordChange = useCallback( + (index: number, value: string) => { + // Handle paste of full mnemonic phrase + const pastedWords = value.trim().split(/\s+/); + if (pastedWords.length > 1) { + const newWords = [...importWords]; + for (let i = 0; i < Math.min(pastedWords.length, WORD_COUNT - index); i++) { + newWords[index + i] = pastedWords[i].toLowerCase(); + } + setImportWords(newWords); + setImportValid(null); + // Focus the next empty field or the last filled field + const nextEmpty = newWords.findIndex(w => !w); + const focusIndex = nextEmpty === -1 ? WORD_COUNT - 1 : nextEmpty; + inputRefs.current[focusIndex]?.focus(); + return; + } + + const newWords = [...importWords]; + newWords[index] = value.toLowerCase().trim(); + setImportWords(newWords); + setImportValid(null); + + // Auto-advance to next input when a word is entered + if (value.trim() && index < WORD_COUNT - 1) { + inputRefs.current[index + 1]?.focus(); + } + }, + [importWords], + ); + + const handleImportKeyDown = useCallback( + (index: number, e: React.KeyboardEvent) => { + if (e.key === 'Backspace' && !importWords[index] && index > 0) { + inputRefs.current[index - 1]?.focus(); + } + }, + [importWords], + ); + + const handleValidateImport = useCallback(() => { + const phrase = importWords.join(' ').trim(); + const filledWords = importWords.filter(w => w.trim()); + + if (filledWords.length !== WORD_COUNT) { + setError(`Please enter all ${WORD_COUNT} words.`); + setImportValid(false); + return false; + } + + const isValid = validateMnemonicPhrase(phrase); + setImportValid(isValid); + + if (!isValid) { + setError('Invalid recovery phrase. Please check your words and try again.'); + return false; + } + + setError(null); + return true; + }, [importWords]); + + const handleContinue = async () => { + setError(null); + setLoading(true); + + try { + let phraseToUse: string; + + if (mode === 'import') { + if (!handleValidateImport()) { + setLoading(false); + return; + } + phraseToUse = importWords.join(' ').trim(); + } else { + if (!confirmed) { + setLoading(false); + return; + } + phraseToUse = mnemonic; + } + + const aesKey = deriveAesKeyFromMnemonic(phraseToUse); + + if (user?._id) { + dispatch(setEncryptionKeyForUser({ userId: user._id, key: aesKey })); + } + + navigate('/home'); + } catch (e) { + setError(e instanceof Error ? e.message : 'Something went wrong. Please try again.'); + } finally { + setLoading(false); + } + }; + + const isImportComplete = importWords.every(w => w.trim()); + const canContinue = mode === 'generate' ? confirmed : isImportComplete; + + return ( +
+
+
+ +
+ +
+ {mode === 'generate' ? ( + <> +
+

Your Recovery Phrase

+

+ Write down these 24 words in order and store them somewhere safe. This phrase is + used to encrypt your data and can never be recovered if lost. +

+
+ + {/* Mnemonic Grid */} +
+
+ {words.map((word, index) => ( +
+ + {index + 1}. + + {word} +
+ ))} +
+
+ + {/* Copy Button */} + + + {/* Import existing link */} + + + {/* Warning */} +
+ + + +

+ Never share your recovery phrase with anyone. Anyone with these words can access + your encrypted data. AlphaHuman will never ask for your recovery phrase. +

+
+ + {/* Confirmation Checkbox */} + + + ) : ( + <> +
+

Import Recovery Phrase

+

+ Enter your existing 24-word recovery phrase below. You can also paste the full + phrase into the first field. +

+
+ + {/* Import Word Inputs Grid */} +
+
+ {importWords.map((word, index) => ( +
+ + {index + 1}. + + { + inputRefs.current[index] = el; + }} + type="text" + value={word} + onChange={e => handleImportWordChange(index, e.target.value)} + onKeyDown={e => handleImportKeyDown(index, e)} + autoComplete="off" + spellCheck={false} + className={`w-full font-mono text-sm font-medium px-2 py-1.5 rounded-lg border bg-white/60 outline-none transition-colors ${ + importValid === false && word.trim() + ? 'border-coral-300 focus:border-coral-400' + : importValid === true + ? 'border-sage-300 focus:border-sage-400' + : 'border-stone-200 focus:border-primary-400' + }`} + /> +
+ ))} +
+
+ + {/* Validation status */} + {importValid === true && ( +
+ + + + Valid recovery phrase +
+ )} + + {/* Back to generate link */} + + + )} + + {error &&

{error}

} + + {/* Continue Button */} + +
+
+
+ ); +}; + +export default Mnemonic; diff --git a/src/pages/onboarding/Onboarding.tsx b/src/pages/onboarding/Onboarding.tsx index 173faa12f..e048d953c 100644 --- a/src/pages/onboarding/Onboarding.tsx +++ b/src/pages/onboarding/Onboarding.tsx @@ -46,7 +46,7 @@ const Onboarding = () => { if (user?._id) { dispatch(setOnboardedForUser({ userId: user._id, value: true })); } - navigate('/home'); + navigate('/mnemonic'); }; const renderStep = () => { diff --git a/src/store/authSelectors.ts b/src/store/authSelectors.ts index cfa13e7f1..4986f46c2 100644 --- a/src/store/authSelectors.ts +++ b/src/store/authSelectors.ts @@ -5,3 +5,9 @@ export const selectIsOnboarded = (state: RootState): boolean => { if (!userId) return false; return state.auth.isOnboardedByUser[userId] ?? false; }; + +export const selectHasEncryptionKey = (state: RootState): boolean => { + const userId = state.user.user?._id; + if (!userId) return false; + return !!state.auth.encryptionKeyByUser[userId]; +}; diff --git a/src/store/authSlice.ts b/src/store/authSlice.ts index 02b60af31..3180bfeb3 100644 --- a/src/store/authSlice.ts +++ b/src/store/authSlice.ts @@ -9,12 +9,15 @@ export interface AuthState { isOnboardedByUser: Record; /** Analytics consent per user id (opt-in during onboarding) */ isAnalyticsEnabledByUser: Record; + /** AES encryption key (hex) derived from mnemonic, per user id */ + encryptionKeyByUser: Record; } const initialState: AuthState = { token: null, isOnboardedByUser: {}, isAnalyticsEnabledByUser: {}, + encryptionKeyByUser: {}, }; const authSlice = createSlice({ @@ -26,6 +29,7 @@ const authSlice = createSlice({ }, _clearToken: state => { state.token = null; + state.encryptionKeyByUser = {}; }, setOnboardedForUser: (state, action: PayloadAction<{ userId: string; value: boolean }>) => { const { userId, value } = action.payload; @@ -35,6 +39,10 @@ const authSlice = createSlice({ const { userId, enabled } = action.payload; state.isAnalyticsEnabledByUser[userId] = enabled; }, + setEncryptionKeyForUser: (state, action: PayloadAction<{ userId: string; key: string }>) => { + const { userId, key } = action.payload; + state.encryptionKeyByUser[userId] = key; + }, }, }); @@ -45,5 +53,6 @@ export const clearToken = createAsyncThunk('auth/clearToken', async (_, { dispat dispatch(clearTeamState()); }); -export const { setToken, setOnboardedForUser, setAnalyticsForUser } = authSlice.actions; +export const { setToken, setOnboardedForUser, setAnalyticsForUser, setEncryptionKeyForUser } = + authSlice.actions; export default authSlice.reducer; diff --git a/src/store/index.ts b/src/store/index.ts index 859210ad7..03f7ac119 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -25,7 +25,7 @@ import userReducer from './userSlice'; const authPersistConfig = { key: 'auth', storage, - whitelist: ['token', 'isOnboardedByUser', 'isAnalyticsEnabledByUser'], + whitelist: ['token', 'isOnboardedByUser', 'isAnalyticsEnabledByUser', 'encryptionKeyByUser'], }; // Persist config for AI state (config only) diff --git a/src/utils/cryptoKeys.ts b/src/utils/cryptoKeys.ts new file mode 100644 index 000000000..7cf64964b --- /dev/null +++ b/src/utils/cryptoKeys.ts @@ -0,0 +1,35 @@ +import { pbkdf2 } from '@noble/hashes/pbkdf2.js'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { bytesToHex } from '@noble/hashes/utils.js'; +import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english.js'; + +/** + * Generate a 24-word BIP39 mnemonic phrase (256-bit entropy). + */ +export function generateMnemonicPhrase(): string { + return generateMnemonic(wordlist, 256); +} + +/** + * Validate a BIP39 mnemonic phrase. + */ +export function validateMnemonicPhrase(mnemonic: string): boolean { + return validateMnemonic(mnemonic, wordlist); +} + +/** + * Derive a 256-bit AES encryption key from a mnemonic phrase. + * Uses BIP39 seed derivation followed by PBKDF2-SHA256. + * Returns the key as a hex string. + */ +export function deriveAesKeyFromMnemonic(mnemonic: string): string { + // Get the BIP39 seed (512-bit) from the mnemonic + const seed = mnemonicToSeedSync(mnemonic); + + // Derive a 256-bit AES key using PBKDF2 with the seed + const salt = new TextEncoder().encode('alphahuman-aes-key-v1'); + const derivedKey = pbkdf2(sha256, seed, salt, { c: 100000, dkLen: 32 }); + + return bytesToHex(derivedKey); +} diff --git a/yarn.lock b/yarn.lock index 9da88127a..316fe5ecc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -499,6 +499,11 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@noble/hashes@2.0.1", "@noble/hashes@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.0.1.tgz#fc1a928061d1232b0a52bb754393c37a5216c89e" + integrity sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" @@ -685,6 +690,19 @@ resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== +"@scure/base@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.0.0.tgz#ba6371fddf92c2727e88ad6ab485db6e624f9a98" + integrity sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w== + +"@scure/bip39@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-2.0.1.tgz#47a6dc15e04faf200041239d46ae3bb7c3c96add" + integrity sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg== + dependencies: + "@noble/hashes" "2.0.1" + "@scure/base" "2.0.0" + "@sentry-internal/browser-utils@10.38.0": version "10.38.0" resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-10.38.0.tgz#576780062808bd3bae21476393f50caf9acbe12f" From 1470b9482e45b538fbba6fe3ea522c2ab5a02284 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 6 Feb 2026 21:15:47 +0530 Subject: [PATCH 02/21] feat: integrate Web3 wallet functionality and enhance state management - Added WalletInfoSection component to display connected wallet address, network, and balance. - Updated auth slice to manage primary wallet address derived from mnemonic. - Enhanced SkillManager to pass wallet address as load parameters for wallet skill. - Introduced utility function to derive EVM wallet address from mnemonic. - Updated dependencies for cryptographic operations related to wallet functionality. --- package.json | 2 + src-tauri/src/runtime/qjs_engine.rs | 24 +++- src-tauri/src/runtime/qjs_skill_instance.rs | 6 + src-tauri/src/runtime/types.rs | 5 + src/components/WalletInfoSection.tsx | 149 ++++++++++++++++++++ src/lib/skills/manager.ts | 16 ++- src/pages/Home.tsx | 4 + src/pages/Mnemonic.tsx | 5 +- src/store/authSlice.ts | 20 ++- src/store/index.ts | 8 +- src/utils/cryptoKeys.ts | 35 +++++ yarn.lock | 31 ++++ 12 files changed, 295 insertions(+), 10 deletions(-) create mode 100644 src/components/WalletInfoSection.tsx diff --git a/package.json b/package.json index e43d9b45a..265aa7cba 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,9 @@ }, "dependencies": { "@noble/hashes": "^2.0.1", + "@noble/secp256k1": "^2.0.0", "@reduxjs/toolkit": "^2.11.2", + "@scure/bip32": "^1.5.1", "@scure/bip39": "^2.0.1", "@sentry/react": "^10.38.0", "@tauri-apps/api": "^2", diff --git a/src-tauri/src/runtime/qjs_engine.rs b/src-tauri/src/runtime/qjs_engine.rs index cada75935..d76aaaf02 100644 --- a/src-tauri/src/runtime/qjs_engine.rs +++ b/src-tauri/src/runtime/qjs_engine.rs @@ -13,7 +13,7 @@ use crate::runtime::cron_scheduler::CronScheduler; use crate::runtime::manifest::SkillManifest; use crate::runtime::preferences::PreferencesStore; use crate::runtime::skill_registry::SkillRegistry; -use crate::runtime::types::{events, SkillSnapshot, SkillStatus, ToolResult}; +use crate::runtime::types::{events, SkillMessage, SkillSnapshot, SkillStatus, ToolResult}; use crate::runtime::qjs_skill_instance::{BridgeDeps, QjsSkillInstance}; use crate::services::tdlib_v8::storage::IdbStorage; @@ -448,7 +448,27 @@ impl RuntimeEngine { use crate::runtime::types::SkillMessage; match method { - "skill/load" => Ok(serde_json::json!({ "ok": true })), + "skill/load" => { + // Extract load params (exclude manifest and dataDir) and send to skill + let load_params: serde_json::Map = params + .as_object() + .map(|obj| { + obj.iter() + .filter(|(k, _)| k.as_str() != "manifest" && k.as_str() != "dataDir") + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + }) + .unwrap_or_default(); + if !load_params.is_empty() { + let msg = SkillMessage::LoadParams { + params: serde_json::Value::Object(load_params), + }; + if let Err(e) = self.registry.send_message(skill_id, msg) { + log::warn!("[runtime] Failed to send LoadParams to skill '{}': {}", skill_id, e); + } + } + Ok(serde_json::json!({ "ok": true })) + } "setup/start" => { log::info!("[runtime] setup/start for '{}'", skill_id); diff --git a/src-tauri/src/runtime/qjs_skill_instance.rs b/src-tauri/src/runtime/qjs_skill_instance.rs index 12f50d65f..6a402a105 100644 --- a/src-tauri/src/runtime/qjs_skill_instance.rs +++ b/src-tauri/src/runtime/qjs_skill_instance.rs @@ -411,6 +411,12 @@ async fn handle_message( let result = handle_js_void_call(ctx, "onTick", "{}").await; let _ = reply.send(result); } + SkillMessage::LoadParams { params } => { + let params_str = serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string()); + if let Err(e) = handle_js_void_call(ctx, "onLoad", ¶ms_str).await { + log::warn!("[skill:{}] onLoad failed (skill may not export it): {}", skill_id, e); + } + } SkillMessage::Rpc { method, params, reply } => { let result = match method.as_str() { "oauth/complete" => { diff --git a/src-tauri/src/runtime/types.rs b/src-tauri/src/runtime/types.rs index 2abe6945c..5eb79a8c8 100644 --- a/src-tauri/src/runtime/types.rs +++ b/src-tauri/src/runtime/types.rs @@ -92,6 +92,11 @@ pub enum SkillMessage { params: serde_json::Value, reply: tokio::sync::oneshot::Sender>, }, + /// Load params from frontend (e.g. wallet address for wallet skill). + /// Delivered after skill/load RPC; skill may export onLoad(params) to receive them. + LoadParams { + params: serde_json::Value, + }, } /// Result of executing a tool. diff --git a/src/components/WalletInfoSection.tsx b/src/components/WalletInfoSection.tsx new file mode 100644 index 000000000..dc168433a --- /dev/null +++ b/src/components/WalletInfoSection.tsx @@ -0,0 +1,149 @@ +import { useEffect, useState } from 'react'; + +import { skillManager } from '../lib/skills/manager'; +import { useSkillConnectionStatus } from '../lib/skills/hooks'; +import { useAppSelector } from '../store/hooks'; +import { useUser } from '../hooks/useUser'; + +function truncateAddress(address: string): string { + if (!address || address.length < 12) return address; + return `${address.slice(0, 6)}…${address.slice(-4)}`; +} + +export default function WalletInfoSection() { + const walletStatus = useSkillConnectionStatus('wallet'); + const { user } = useUser(); + const primaryAddress = useAppSelector(state => + user?._id ? state.auth.primaryWalletAddressByUser[user._id] : undefined + ); + + const [networkName, setNetworkName] = useState(null); + const [balance, setBalance] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const isConnected = walletStatus === 'connected' && !!primaryAddress; + + useEffect(() => { + if (!isConnected || !primaryAddress || !skillManager.isSkillRunning('wallet')) { + setLoading(false); + setNetworkName(null); + setBalance(null); + setError(null); + return; + } + + let cancelled = false; + setLoading(true); + setError(null); + + (async () => { + try { + const listRes = await skillManager.callTool('wallet', 'list_networks', {}); + const listText = listRes.content?.[0]?.text; + if (!listText || listRes.isError) { + if (!cancelled) setError('Could not load networks'); + return; + } + const listData = JSON.parse(listText) as { networks?: Array<{ chain_id: string; name: string; chain_type: string }> }; + const networks = listData.networks ?? []; + const firstEvm = networks.find((n: { chain_type: string }) => n.chain_type === 'evm'); + const first = firstEvm ?? networks[0]; + if (!first || cancelled) return; + + if (!cancelled) setNetworkName(first.name); + + const balanceRes = await skillManager.callTool('wallet', 'get_balance', { + address: primaryAddress, + chain_id: first.chain_id, + chain_type: first.chain_type ?? 'evm', + }); + const balanceText = balanceRes.content?.[0]?.text; + if (!balanceText || balanceRes.isError) { + if (!cancelled) setBalance('—'); + return; + } + const balanceData = JSON.parse(balanceText) as { + balance_eth?: string; + symbol?: string; + error?: string; + }; + if (balanceData.error && !cancelled) { + setBalance('—'); + return; + } + const eth = balanceData.balance_eth ?? '0'; + const symbol = balanceData.symbol ?? 'ETH'; + const value = parseFloat(eth); + const display = value < 0.0001 ? '0' : value.toFixed(4); + if (!cancelled) setBalance(`${display} ${symbol}`); + } catch (e) { + if (!cancelled) { + setError(e instanceof Error ? e.message : 'Failed to load wallet info'); + setBalance(null); + setNetworkName(null); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [isConnected, primaryAddress]); + + if (!isConnected) return null; + + return ( +
+
+ + + + Web3 Wallet +
+
+
+ Address + + {primaryAddress ? truncateAddress(primaryAddress) : '—'} + +
+
+ Network + + {loading ? ( + Loading… + ) : error ? ( + {error} + ) : ( + networkName ?? '—' + )} + +
+
+ Balance + + {loading ? ( + Loading… + ) : error ? ( + '—' + ) : ( + balance ?? '—' + )} + +
+
+
+ ); +} diff --git a/src/lib/skills/manager.ts b/src/lib/skills/manager.ts index 925670681..e3140204c 100644 --- a/src/lib/skills/manager.ts +++ b/src/lib/skills/manager.ts @@ -32,13 +32,21 @@ class SkillManager { private runtimes = new Map(); /** - * Get skill-specific load parameters (e.g., session data for Telegram) + * Get skill-specific load parameters (e.g., wallet address for wallet skill) */ - private getSkillLoadParams(_skillId: string): Record { + private getSkillLoadParams(skillId: string): Record { const params: Record = {}; - // For now, just return empty params - skill-specific session data - // will be handled through the skill's own setup process + if (skillId === "wallet") { + const state = store.getState(); + const userId = state.user.user?._id; + const primaryAddress = + userId && state.auth.primaryWalletAddressByUser?.[userId]; + if (primaryAddress) { + params.walletAddress = primaryAddress; + } + } + return params; } diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index a2bd44149..fee66aa88 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import ConnectionIndicator from '../components/ConnectionIndicator'; import ModelDownloadProgress from '../components/ModelDownloadProgress'; import SkillsGrid from '../components/SkillsGrid'; +import WalletInfoSection from '../components/WalletInfoSection'; import { useUser } from '../hooks/useUser'; import { TELEGRAM_BOT_USERNAME } from '../utils/config'; import { openUrl } from '../utils/openUrl'; @@ -103,6 +104,9 @@ const Home = () => { + {/* Web3 wallet info (address, balance, network) — only when wallet skill is connected */} + + {/* Action buttons */}
{/* Settings */} diff --git a/src/pages/Mnemonic.tsx b/src/pages/Mnemonic.tsx index 43519fa42..4cf197837 100644 --- a/src/pages/Mnemonic.tsx +++ b/src/pages/Mnemonic.tsx @@ -2,10 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import LottieAnimation from '../components/LottieAnimation'; -import { setEncryptionKeyForUser } from '../store/authSlice'; +import { setEncryptionKeyForUser, setPrimaryWalletAddressForUser } from '../store/authSlice'; import { useAppDispatch, useAppSelector } from '../store/hooks'; import { deriveAesKeyFromMnemonic, + deriveEvmAddressFromMnemonic, generateMnemonicPhrase, validateMnemonicPhrase, } from '../utils/cryptoKeys'; @@ -146,9 +147,11 @@ const Mnemonic = () => { } const aesKey = deriveAesKeyFromMnemonic(phraseToUse); + const walletAddress = deriveEvmAddressFromMnemonic(phraseToUse); if (user?._id) { dispatch(setEncryptionKeyForUser({ userId: user._id, key: aesKey })); + dispatch(setPrimaryWalletAddressForUser({ userId: user._id, address: walletAddress })); } navigate('/home'); diff --git a/src/store/authSlice.ts b/src/store/authSlice.ts index 3180bfeb3..f90e50d33 100644 --- a/src/store/authSlice.ts +++ b/src/store/authSlice.ts @@ -11,6 +11,8 @@ export interface AuthState { isAnalyticsEnabledByUser: Record; /** AES encryption key (hex) derived from mnemonic, per user id */ encryptionKeyByUser: Record; + /** Primary EVM wallet address (0x...) derived from mnemonic, per user id */ + primaryWalletAddressByUser: Record; } const initialState: AuthState = { @@ -18,6 +20,7 @@ const initialState: AuthState = { isOnboardedByUser: {}, isAnalyticsEnabledByUser: {}, encryptionKeyByUser: {}, + primaryWalletAddressByUser: {}, }; const authSlice = createSlice({ @@ -30,6 +33,7 @@ const authSlice = createSlice({ _clearToken: state => { state.token = null; state.encryptionKeyByUser = {}; + state.primaryWalletAddressByUser = {}; }, setOnboardedForUser: (state, action: PayloadAction<{ userId: string; value: boolean }>) => { const { userId, value } = action.payload; @@ -43,6 +47,13 @@ const authSlice = createSlice({ const { userId, key } = action.payload; state.encryptionKeyByUser[userId] = key; }, + setPrimaryWalletAddressForUser: ( + state, + action: PayloadAction<{ userId: string; address: string }> + ) => { + const { userId, address } = action.payload; + state.primaryWalletAddressByUser[userId] = address; + }, }, }); @@ -53,6 +64,11 @@ export const clearToken = createAsyncThunk('auth/clearToken', async (_, { dispat dispatch(clearTeamState()); }); -export const { setToken, setOnboardedForUser, setAnalyticsForUser, setEncryptionKeyForUser } = - authSlice.actions; +export const { + setToken, + setOnboardedForUser, + setAnalyticsForUser, + setEncryptionKeyForUser, + setPrimaryWalletAddressForUser, +} = authSlice.actions; export default authSlice.reducer; diff --git a/src/store/index.ts b/src/store/index.ts index 03f7ac119..54a0c7987 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -25,7 +25,13 @@ import userReducer from './userSlice'; const authPersistConfig = { key: 'auth', storage, - whitelist: ['token', 'isOnboardedByUser', 'isAnalyticsEnabledByUser', 'encryptionKeyByUser'], + whitelist: [ + 'token', + 'isOnboardedByUser', + 'isAnalyticsEnabledByUser', + 'encryptionKeyByUser', + 'primaryWalletAddressByUser', + ], }; // Persist config for AI state (config only) diff --git a/src/utils/cryptoKeys.ts b/src/utils/cryptoKeys.ts index 7cf64964b..9a4416c2e 100644 --- a/src/utils/cryptoKeys.ts +++ b/src/utils/cryptoKeys.ts @@ -1,6 +1,9 @@ +import { getPublicKey } from '@noble/secp256k1'; +import { keccak_256 } from '@noble/hashes/sha3.js'; import { pbkdf2 } from '@noble/hashes/pbkdf2.js'; import { sha256 } from '@noble/hashes/sha2.js'; import { bytesToHex } from '@noble/hashes/utils.js'; +import { HDKey } from '@scure/bip32'; import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'; import { wordlist } from '@scure/bip39/wordlists/english.js'; @@ -33,3 +36,35 @@ export function deriveAesKeyFromMnemonic(mnemonic: string): string { return bytesToHex(derivedKey); } + +/** BIP44 path for first Ethereum account: m/44'/60'/0'/0/0 */ +const EVM_DERIVATION_PATH = "m/44'/60'/0'/0/0"; + +/** + * Derive the first EVM wallet address (Ethereum BIP44) from a mnemonic phrase. + * Uses path m/44'/60'/0'/0/0. Returns a checksummed 0x-prefixed address. + */ +export function deriveEvmAddressFromMnemonic(mnemonic: string): string { + const seed = mnemonicToSeedSync(mnemonic); + const hdkey = HDKey.fromMasterSeed(seed); + const derived = hdkey.derive(EVM_DERIVATION_PATH); + const privateKey = derived.privateKey; + if (!privateKey) throw new Error('Failed to derive private key'); + // Ethereum address = keccak256(uncompressed public key without 0x04)[12:] + const pubKey = getPublicKey(privateKey, false); // uncompressed, 65 bytes + const hash = keccak_256(pubKey.slice(1)); + const addressBytes = hash.slice(-20); + const hex = bytesToHex(addressBytes); + return toChecksumAddress('0x' + hex); +} + +/** Simple checksum: lowercase with 0x, then capitalize by hash. */ +function toChecksumAddress(address: string): string { + const a = address.replace(/^0x/i, '').toLowerCase(); + const hash = bytesToHex(keccak_256(new TextEncoder().encode(a))); + let result = '0x'; + for (let i = 0; i < 40; i++) { + result += parseInt(hash[i], 16) >= 8 ? a[i].toUpperCase() : a[i]; + } + return result; +} diff --git a/yarn.lock b/yarn.lock index 316fe5ecc..f84d0f424 100644 --- a/yarn.lock +++ b/yarn.lock @@ -499,11 +499,28 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@noble/curves@~1.9.0": + version "1.9.7" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.9.7.tgz#79d04b4758a43e4bca2cbdc62e7771352fa6b951" + integrity sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw== + dependencies: + "@noble/hashes" "1.8.0" + +"@noble/hashes@1.8.0", "@noble/hashes@~1.8.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" + integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== + "@noble/hashes@2.0.1", "@noble/hashes@^2.0.1": version "2.0.1" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.0.1.tgz#fc1a928061d1232b0a52bb754393c37a5216c89e" integrity sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw== +"@noble/secp256k1@^2.0.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-2.3.0.tgz#ddfe6e853472fb88cba4d5e59b7067adc1e64adf" + integrity sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" @@ -695,6 +712,20 @@ resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.0.0.tgz#ba6371fddf92c2727e88ad6ab485db6e624f9a98" integrity sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w== +"@scure/base@~1.2.5": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.2.6.tgz#ca917184b8231394dd8847509c67a0be522e59f6" + integrity sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg== + +"@scure/bip32@^1.5.1": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.7.0.tgz#b8683bab172369f988f1589640e53c4606984219" + integrity sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw== + dependencies: + "@noble/curves" "~1.9.0" + "@noble/hashes" "~1.8.0" + "@scure/base" "~1.2.5" + "@scure/bip39@^2.0.1": version "2.0.1" resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-2.0.1.tgz#47a6dc15e04faf200041239d46ae3bb7c3c96add" From c4837892c4d96167eda05450564c796e2cb30349 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 6 Feb 2026 21:54:03 +0530 Subject: [PATCH 03/21] fix: improve error handling and network selection in WalletInfoSection - Enhanced the parsing of network data to ensure robustness against undefined values. - Updated network selection logic to prioritize valid entries and provide a fallback. - Improved error logging for better debugging of wallet information loading issues. - Adjusted balance retrieval to handle cases where chain_id may be missing. --- src/components/WalletInfoSection.tsx | 20 +++++++++++----- src/lib/skills/hooks.ts | 36 +++++++++++++++------------- 2 files changed, 33 insertions(+), 23 deletions(-) diff --git a/src/components/WalletInfoSection.tsx b/src/components/WalletInfoSection.tsx index dc168433a..b1f28943e 100644 --- a/src/components/WalletInfoSection.tsx +++ b/src/components/WalletInfoSection.tsx @@ -45,17 +45,24 @@ export default function WalletInfoSection() { if (!cancelled) setError('Could not load networks'); return; } - const listData = JSON.parse(listText) as { networks?: Array<{ chain_id: string; name: string; chain_type: string }> }; - const networks = listData.networks ?? []; - const firstEvm = networks.find((n: { chain_type: string }) => n.chain_type === 'evm'); - const first = firstEvm ?? networks[0]; + const listData = JSON.parse(listText) as { networks?: Array<{ chain_id?: string; name?: string; chain_type?: string }> }; + const networks = Array.isArray(listData.networks) ? listData.networks : []; + const firstEvm = networks.find((n) => n && n.chain_type === 'evm'); + const first = firstEvm ?? networks.find(Boolean); if (!first || cancelled) return; - if (!cancelled) setNetworkName(first.name); + const networkNameVal = first.name ?? first.chain_id ?? 'Unknown'; + if (!cancelled) setNetworkName(networkNameVal); + + const chainId = first.chain_id ?? ''; + if (!chainId) { + if (!cancelled) setBalance('—'); + return; + } const balanceRes = await skillManager.callTool('wallet', 'get_balance', { address: primaryAddress, - chain_id: first.chain_id, + chain_id: chainId, chain_type: first.chain_type ?? 'evm', }); const balanceText = balanceRes.content?.[0]?.text; @@ -78,6 +85,7 @@ export default function WalletInfoSection() { const display = value < 0.0001 ? '0' : value.toFixed(4); if (!cancelled) setBalance(`${display} ${symbol}`); } catch (e) { + console.error(e); if (!cancelled) { setError(e instanceof Error ? e.message : 'Failed to load wallet info'); setBalance(null); diff --git a/src/lib/skills/hooks.ts b/src/lib/skills/hooks.ts index d79728254..c60582ff0 100644 --- a/src/lib/skills/hooks.ts +++ b/src/lib/skills/hooks.ts @@ -43,42 +43,44 @@ function deriveConnectionStatus( // Process is running or ready — use the skill's self-reported state const hostState = skillState as SkillHostConnectionState | undefined; - if (!hostState) { - // No state pushed yet. Skills that don't maintain an external connection - // (e.g. cron-based skills) may never push host state. If setup is complete - // and the lifecycle says "ready", treat it as connected. - if (setupComplete && lifecycleStatus === "ready") { + const connStatus = hostState?.connection_status; + const authStatus = hostState?.auth_status; + + // If the skill hasn't pushed any state, or pushed state without standard + // connection_status / auth_status fields, fall back to lifecycle + setupComplete. + if (!connStatus && !authStatus) { + if (setupComplete && (lifecycleStatus === "ready" || lifecycleStatus === "running")) { return "connected"; } + if (!hostState) { + return "connecting"; + } + // Skill pushed custom state but no connection fields — treat as connecting return "connecting"; } - const connStatus = hostState.connection_status; - const authStatus = hostState.auth_status; - // Check for errors first if (connStatus === "error" || authStatus === "error") { return "error"; } - // Fully connected and authenticated - if (connStatus === "connected" && authStatus === "authenticated") { - return "connected"; - } - // Connecting or authenticating if (connStatus === "connecting" || authStatus === "authenticating") { return "connecting"; } - // Connected but not authenticated - if (connStatus === "connected" && authStatus === "not_authenticated") { - return "not_authenticated"; + // Connected — check auth if the skill uses it + if (connStatus === "connected") { + if (!authStatus || authStatus === "authenticated") { + return "connected"; + } + if (authStatus === "not_authenticated") { + return "not_authenticated"; + } } // Disconnected from service if (connStatus === "disconnected") { - // If setup is complete but we're disconnected, it might be a reconnecting state if (setupComplete) { return "disconnected"; } From 2cf0bd26224539d670edbd533d33a78242499808 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 6 Feb 2026 22:23:55 +0530 Subject: [PATCH 04/21] feat: add socket event contract for Tauri integration - Documented the socket event contract for communication between the backend and frontend in Tauri mode. - Introduced constants for `REQUEST_ENCRYPTION_KEY` and `ENCRYPTION_KEY_EVENT` to facilitate encryption key management. - Updated `setupTauriSocketListeners` to handle the encryption key request and response flow. --- docs/src/03-services.md | 11 +++++++++++ src/utils/tauriSocket.ts | 22 ++++++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/docs/src/03-services.md b/docs/src/03-services.md index 711209aa8..c665052ee 100644 --- a/docs/src/03-services.md +++ b/docs/src/03-services.md @@ -170,6 +170,17 @@ const socket = io(BACKEND_URL, { }); ``` +### Socket event contract (Tauri) + +In Tauri mode, the Rust socket forwards server events to the frontend via the `server:event` Tauri event. The frontend listens and can emit back via `emitViaRustSocket` (see `utils/tauriSocket.ts`). + +| Direction | Event name | Payload | Description | +|------------|----------------------------|----------------------------------|-------------| +| Backend → | `request:encryption_key` | (any) | Backend requests the client’s encryption key. | +| Frontend → | `encryption_key` | `{ encryptionKey: string \| null }` | Frontend sends `encryptionKeyByUser` for the current user from authSlice. | + +Constants: `REQUEST_ENCRYPTION_KEY`, `ENCRYPTION_KEY_EVENT` (exported from `utils/tauriSocket.ts`). + ## MTProto Service (`services/mtprotoService.ts`) Telegram MTProto client singleton. diff --git a/src/utils/tauriSocket.ts b/src/utils/tauriSocket.ts index b78e22795..aa478fb9b 100644 --- a/src/utils/tauriSocket.ts +++ b/src/utils/tauriSocket.ts @@ -12,6 +12,10 @@ * Legacy bridge (for backwards compatibility during migration): * - socket:should_connect / socket:should_disconnect * - report_socket_connected / disconnected / error + * + * Socket event contract (backend <-> frontend): + * - Backend emits: REQUEST_ENCRYPTION_KEY — frontend should reply with encryption key. + * - Frontend emits: ENCRYPTION_KEY_EVENT — payload { encryptionKey: string | null } from authSlice. */ import { isTauri as coreIsTauri, invoke } from '@tauri-apps/api/core'; import { listen, UnlistenFn } from '@tauri-apps/api/event'; @@ -20,6 +24,12 @@ import { store } from '../store'; import { setSocketIdForUser, setStatusForUser } from '../store/socketSlice'; import { BACKEND_URL } from './config'; +/** Event name the backend emits to request the client’s encryption key. */ +export const REQUEST_ENCRYPTION_KEY = 'request:encryption_key'; + +/** Event name the frontend emits with encryptionKeyByUser (authSlice) for the current user. */ +export const ENCRYPTION_KEY_EVENT = 'encryption_key'; + // Check if we're running in Tauri export const isTauri = (): boolean => { return coreIsTauri(); @@ -154,8 +164,16 @@ export async function setupTauriSocketListeners(): Promise { // Listen for forwarded server events unlistenServerEvent = await listen<{ event: string; data: unknown }>('server:event', event => { - console.log('[TauriSocket] Server event:', event.payload.event, event.payload.data); - // Future: dispatch to specific handlers based on event type + const { event: eventName, data } = event.payload; + console.log('[TauriSocket] Server event:', eventName, data); + + if (eventName === REQUEST_ENCRYPTION_KEY) { + const state = store.getState(); + const userId = + state.user.user?._id ?? getSocketUserId(); + const encryptionKey = state.auth.encryptionKeyByUser[userId] ?? null; + void emitViaRustSocket(ENCRYPTION_KEY_EVENT, { encryptionKey }); + } }); // Legacy: Listen for connect requests from Rust (backwards compat) From a6da2eb29122b0604a111f7937710e1469fd6355 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 10 Feb 2026 15:52:59 +0530 Subject: [PATCH 05/21] Refactor wallet information fetching and connection status handling - Updated `WalletInfoSection` to improve loading state management and error handling during wallet info retrieval. - Introduced a retry mechanism for fetching wallet information with a maximum of 5 attempts. - Enhanced connection status logic in `SkillsGrid` to treat missing authentication status as connected. - Added a new method in `SkillManager` to set the wallet address and notify the wallet skill accordingly. --- src/components/SkillsGrid.tsx | 13 +- src/components/WalletInfoSection.tsx | 190 ++++++++++++++++++--------- src/lib/skills/manager.ts | 19 +++ src/pages/Mnemonic.tsx | 5 +- 4 files changed, 156 insertions(+), 71 deletions(-) diff --git a/src/components/SkillsGrid.tsx b/src/components/SkillsGrid.tsx index ca49e03d8..ed98a2b26 100644 --- a/src/components/SkillsGrid.tsx +++ b/src/components/SkillsGrid.tsx @@ -145,15 +145,18 @@ function deriveConnectionStatus( if (connStatus === 'error' || authStatus === 'error') { return 'error'; } - if (connStatus === 'connected' && authStatus === 'authenticated') { - return 'connected'; + // Connected: treat missing auth_status as connected (e.g. wallet skill doesn't set auth_status) + if (connStatus === 'connected') { + if (!authStatus || authStatus === 'authenticated') { + return 'connected'; + } + if (authStatus === 'not_authenticated') { + return 'not_authenticated'; + } } if (connStatus === 'connecting' || authStatus === 'authenticating') { return 'connecting'; } - if (connStatus === 'connected' && authStatus === 'not_authenticated') { - return 'not_authenticated'; - } if (connStatus === 'disconnected') { return setupComplete ? 'disconnected' : 'setup_required'; } diff --git a/src/components/WalletInfoSection.tsx b/src/components/WalletInfoSection.tsx index b1f28943e..102759586 100644 --- a/src/components/WalletInfoSection.tsx +++ b/src/components/WalletInfoSection.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { skillManager } from '../lib/skills/manager'; import { useSkillConnectionStatus } from '../lib/skills/hooks'; @@ -19,13 +19,132 @@ export default function WalletInfoSection() { const [networkName, setNetworkName] = useState(null); const [balance, setBalance] = useState(null); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const isConnected = walletStatus === 'connected' && !!primaryAddress; + const cancelledRef = useRef(false); + const retryTimerRef = useRef | null>(null); + const fetchWalletInfoRef = useRef<(address: string, attempt?: number) => Promise>(null!); + + const fetchWalletInfo = useCallback(async (address: string, attempt = 0): Promise => { + if (cancelledRef.current) return; + + if (!skillManager.isSkillRunning('wallet')) { + if (attempt < 5 && !cancelledRef.current) { + retryTimerRef.current = setTimeout( + () => fetchWalletInfoRef.current(address, attempt + 1), + 1500 + ); + return; + } + if (!cancelledRef.current) { + setLoading(false); + setNetworkName(null); + setBalance(null); + setError(null); + } + return; + } + + try { + // Wallet skill only supports Ethereum Mainnet (chain_id "1") + const listRes = await skillManager.callTool('wallet', 'list_networks', {}); + if (cancelledRef.current) return; + const listText = listRes.content?.[0]?.text; + if (!listText || listRes.isError) { + if (!cancelledRef.current) { + setError('Could not load networks'); + setLoading(false); + } + return; + } + const listData = JSON.parse(listText) as { + networks?: Array<{ chain_id?: string; name?: string; chain_type?: string }>; + }; + const networks = Array.isArray(listData.networks) ? listData.networks : []; + // Prefer Ethereum Mainnet (skill always has it); else first EVM, else first network + const ethMainnet = networks.find((n) => n?.chain_id === '1' && n?.chain_type === 'evm'); + const firstEvm = networks.find((n) => n && n.chain_type === 'evm'); + const chosen = ethMainnet ?? firstEvm ?? networks.find(Boolean); + if (!chosen || cancelledRef.current) { + if (!cancelledRef.current) setLoading(false); + return; + } + + const networkNameVal = chosen.name ?? chosen.chain_id ?? 'Unknown'; + if (!cancelledRef.current) setNetworkName(networkNameVal); + + const chainId = chosen.chain_id ?? ''; + if (!chainId) { + if (!cancelledRef.current) { + setBalance('—'); + setLoading(false); + } + return; + } + + const balanceRes = await skillManager.callTool('wallet', 'get_balance', { + address, + chain_id: chainId, + chain_type: chosen.chain_type ?? 'evm', + }); + if (cancelledRef.current) return; + const balanceText = balanceRes.content?.[0]?.text; + if (!balanceText || balanceRes.isError) { + if (!cancelledRef.current) { + setBalance('—'); + setLoading(false); + } + return; + } + const balanceData = JSON.parse(balanceText) as { + balance_eth?: string; + symbol?: string; + error?: string; + }; + if (balanceData.error) { + if (!cancelledRef.current) { + setBalance('—'); + setLoading(false); + } + return; + } + const eth = balanceData.balance_eth ?? '0'; + const symbol = balanceData.symbol ?? 'ETH'; + const value = parseFloat(eth); + const display = value < 0.0001 ? '0' : value.toFixed(4); + if (!cancelledRef.current) { + setBalance(`${display} ${symbol}`); + setLoading(false); + } + } catch (e) { + if (!cancelledRef.current) { + const msg = e instanceof Error ? e.message : String(e); + const isTransient = msg.includes('not running') || msg.includes('not started') || msg.includes('transport'); + if (isTransient && attempt < 3) { + retryTimerRef.current = setTimeout(() => fetchWalletInfoRef.current(address, attempt + 1), 2000); + return; + } + console.error('[WalletInfoSection] Failed to load wallet info:', e); + setError('Failed to load wallet info'); + setBalance(null); + setNetworkName(null); + setLoading(false); + } + } + }, []); + + fetchWalletInfoRef.current = fetchWalletInfo; useEffect(() => { - if (!isConnected || !primaryAddress || !skillManager.isSkillRunning('wallet')) { + cancelledRef.current = false; + if (retryTimerRef.current) { + clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } + + if (!isConnected || !primaryAddress) { setLoading(false); setNetworkName(null); setBalance(null); @@ -33,73 +152,16 @@ export default function WalletInfoSection() { return; } - let cancelled = false; setLoading(true); setError(null); - (async () => { - try { - const listRes = await skillManager.callTool('wallet', 'list_networks', {}); - const listText = listRes.content?.[0]?.text; - if (!listText || listRes.isError) { - if (!cancelled) setError('Could not load networks'); - return; - } - const listData = JSON.parse(listText) as { networks?: Array<{ chain_id?: string; name?: string; chain_type?: string }> }; - const networks = Array.isArray(listData.networks) ? listData.networks : []; - const firstEvm = networks.find((n) => n && n.chain_type === 'evm'); - const first = firstEvm ?? networks.find(Boolean); - if (!first || cancelled) return; - - const networkNameVal = first.name ?? first.chain_id ?? 'Unknown'; - if (!cancelled) setNetworkName(networkNameVal); - - const chainId = first.chain_id ?? ''; - if (!chainId) { - if (!cancelled) setBalance('—'); - return; - } - - const balanceRes = await skillManager.callTool('wallet', 'get_balance', { - address: primaryAddress, - chain_id: chainId, - chain_type: first.chain_type ?? 'evm', - }); - const balanceText = balanceRes.content?.[0]?.text; - if (!balanceText || balanceRes.isError) { - if (!cancelled) setBalance('—'); - return; - } - const balanceData = JSON.parse(balanceText) as { - balance_eth?: string; - symbol?: string; - error?: string; - }; - if (balanceData.error && !cancelled) { - setBalance('—'); - return; - } - const eth = balanceData.balance_eth ?? '0'; - const symbol = balanceData.symbol ?? 'ETH'; - const value = parseFloat(eth); - const display = value < 0.0001 ? '0' : value.toFixed(4); - if (!cancelled) setBalance(`${display} ${symbol}`); - } catch (e) { - console.error(e); - if (!cancelled) { - setError(e instanceof Error ? e.message : 'Failed to load wallet info'); - setBalance(null); - setNetworkName(null); - } - } finally { - if (!cancelled) setLoading(false); - } - })(); + fetchWalletInfo(primaryAddress); return () => { - cancelled = true; + cancelledRef.current = true; + if (retryTimerRef.current) clearTimeout(retryTimerRef.current); }; - }, [isConnected, primaryAddress]); + }, [isConnected, primaryAddress, fetchWalletInfo]); if (!isConnected) return null; diff --git a/src/lib/skills/manager.ts b/src/lib/skills/manager.ts index e3140204c..a3091ed9b 100644 --- a/src/lib/skills/manager.ts +++ b/src/lib/skills/manager.ts @@ -17,6 +17,7 @@ import type { SkillOptionDefinition, } from "./types"; import { store } from "../../store"; +import { setPrimaryWalletAddressForUser } from "../../store/authSlice"; import { addSkill, setSkillStatus, @@ -375,6 +376,24 @@ class SkillManager { } } + /** + * Set the wallet address in the frontend app and notify the wallet skill (onLoad). + * Updates Redux (primaryWalletAddressByUser) and, if the wallet skill is running, + * sends load params so the skill receives onLoad({ walletAddress }). + */ + async setWalletAddress(address: string): Promise { + const state = store.getState(); + const userId = state.user.user?._id; + if (!userId) { + return; + } + store.dispatch(setPrimaryWalletAddressForUser({ userId, address })); + const runtime = this.runtimes.get("wallet"); + if (runtime?.isRunning) { + await runtime.load({ walletAddress: address }); + } + } + // ----------------------------------------------------------------------- // Reverse RPC handling // ----------------------------------------------------------------------- diff --git a/src/pages/Mnemonic.tsx b/src/pages/Mnemonic.tsx index 4cf197837..25cd9bcfa 100644 --- a/src/pages/Mnemonic.tsx +++ b/src/pages/Mnemonic.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import LottieAnimation from '../components/LottieAnimation'; -import { setEncryptionKeyForUser, setPrimaryWalletAddressForUser } from '../store/authSlice'; +import { skillManager } from '../lib/skills/manager'; +import { setEncryptionKeyForUser } from '../store/authSlice'; import { useAppDispatch, useAppSelector } from '../store/hooks'; import { deriveAesKeyFromMnemonic, @@ -151,7 +152,7 @@ const Mnemonic = () => { if (user?._id) { dispatch(setEncryptionKeyForUser({ userId: user._id, key: aesKey })); - dispatch(setPrimaryWalletAddressForUser({ userId: user._id, address: walletAddress })); + await skillManager.setWalletAddress(walletAddress); } navigate('/home'); From 98895bc0389228c4f1e041fdd6711132dc38fd2c Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Thu, 19 Feb 2026 20:26:54 +0530 Subject: [PATCH 06/21] feat: add integration token fetching and decryption functionality - Introduced `fetchIntegrationTokens` API call to retrieve encrypted OAuth tokens for integrations. - Implemented decryption logic for integration tokens using a provided key. - Enhanced deep link handling to support fetching and storing integration tokens upon successful OAuth login. - Added utility functions for hex and base64 conversions to facilitate token decryption. - Updated `authApi.ts` and `desktopDeepLinkListener.ts` to integrate new functionality. --- src-tauri/src/runtime/qjs_skill_instance.rs | 2 +- src/services/api/authApi.ts | 21 +++ src/utils/desktopDeepLinkListener.ts | 162 +++++++++++++++++++- yarn.lock | 51 ++++++ 4 files changed, 234 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/runtime/qjs_skill_instance.rs b/src-tauri/src/runtime/qjs_skill_instance.rs index faf0dfb6a..e99e471f0 100644 --- a/src-tauri/src/runtime/qjs_skill_instance.rs +++ b/src-tauri/src/runtime/qjs_skill_instance.rs @@ -535,7 +535,7 @@ async fn handle_message( } SkillMessage::LoadParams { params } => { let params_str = serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string()); - if let Err(e) = handle_js_void_call(ctx, "onLoad", ¶ms_str).await { + if let Err(e) = handle_js_void_call(rt, ctx, "onLoad", ¶ms_str).await { log::warn!("[skill:{}] onLoad failed (skill may not export it): {}", skill_id, e); } } diff --git a/src/services/api/authApi.ts b/src/services/api/authApi.ts index d4141daa3..0e9d76e74 100644 --- a/src/services/api/authApi.ts +++ b/src/services/api/authApi.ts @@ -5,6 +5,13 @@ interface ConsumeLoginTokenResponse { data: { jwtToken: string }; } +interface IntegrationTokensResponse { + success: boolean; + data?: { + encrypted: string; + }; +} + /** * Consume a verified login token and return the JWT. * POST /telegram/login-tokens/:token/consume (no auth required) @@ -21,3 +28,17 @@ export async function consumeLoginToken(loginToken: string): Promise { } return response.data.jwtToken; } + +/** + * Fetch encrypted OAuth tokens for an integration using a client-provided key. + * POST /auth/integrations/:integrationId/tokens (auth required) + */ +export async function fetchIntegrationTokens( + integrationId: string, + key: string +): Promise { + return apiClient.post( + `/auth/integrations/${encodeURIComponent(integrationId)}/tokens`, + { key } + ); +} diff --git a/src/utils/desktopDeepLinkListener.ts b/src/utils/desktopDeepLinkListener.ts index 7b43910b1..d56657c58 100644 --- a/src/utils/desktopDeepLinkListener.ts +++ b/src/utils/desktopDeepLinkListener.ts @@ -2,9 +2,116 @@ import { isTauri as coreIsTauri, invoke } from '@tauri-apps/api/core'; import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link'; import { skillManager } from '../lib/skills/manager'; -import { consumeLoginToken } from '../services/api/authApi'; +import { consumeLoginToken, fetchIntegrationTokens } from '../services/api/authApi'; import { store } from '../store'; import { setToken } from '../store/authSlice'; +import { setSkillState } from '../store/skillsSlice'; + +type IntegrationTokensPayload = { + accessToken: string; + refreshToken: string; + /** ISO timestamp string */ + expiresAt: string; +}; + +function getCurrentUserId(): string | null { + const state = store.getState(); + const explicitId = state.user.user?._id; + if (explicitId) return explicitId; + + const token = state.auth.token; + if (!token) return null; + + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; + const payloadBase64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const payloadJson = atob(payloadBase64); + const payload = JSON.parse(payloadJson); + return payload.tgUserId || payload.userId || payload.sub || null; + } catch { + return null; + } +} + +function hexToBase64(hex: string): string { + const bytes = hexToBytes(hex); + if (bytes.length === 0) return ''; + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +function hexToBytes(hex: string): Uint8Array { + const cleanHex = hex.trim().replace(/^0x/i, ''); + if (!cleanHex) return new Uint8Array(); + const bytes = new Uint8Array(cleanHex.length / 2); + for (let i = 0; i < cleanHex.length; i += 2) { + bytes[i / 2] = parseInt(cleanHex.slice(i, i + 2), 16); + } + return bytes; +} + +function base64ToBytes(b64: string): Uint8Array { + // Normalize potential URL-safe base64 and missing padding + let normalized = b64.replace(/-/g, '+').replace(/_/g, '/'); + const pad = normalized.length % 4; + if (pad === 2) normalized += '=='; + else if (pad === 3) normalized += '='; + + const binary = atob(normalized); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +async function decryptIntegrationTokensWithKey( + encryptedPayload: string, + keyHex: string +): Promise { + if (typeof crypto === 'undefined' || !crypto.subtle) { + throw new Error('Web Crypto API is not available for decryption'); + } + + const keyBytes = hexToBytes(keyHex); + if (keyBytes.length === 0) { + throw new Error('Invalid encryption key'); + } + + const combined = base64ToBytes(encryptedPayload); + // Backend format: IV (16 bytes) + AuthTag (16 bytes) + EncryptedData (rest) + if (combined.length <= 32) { + throw new Error('Encrypted payload too short'); + } + + const iv = combined.slice(0, 16); + const authTag = combined.slice(16, 32); + const encryptedData = combined.slice(32); + const ciphertextWithTag = new Uint8Array(encryptedData.length + authTag.length); + ciphertextWithTag.set(encryptedData, 0); + ciphertextWithTag.set(authTag, encryptedData.length); + + const cryptoKey = await crypto.subtle.importKey( + 'raw', + keyBytes as unknown as BufferSource, + { name: 'AES-GCM' }, + false, + ['decrypt'] + ); + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv, tagLength: 128 }, + cryptoKey, + ciphertextWithTag as unknown as BufferSource + ); + + return new TextDecoder().decode(decrypted); +} + /** * Handle an `alphahuman://auth?token=...` deep link for login. @@ -55,6 +162,59 @@ const handleOAuthDeepLink = async (parsed: URL) => { console.log(`[DeepLink] OAuth success for skill=${skillId} integration=${integrationId}`); try { + + const state = store.getState(); + const userId = getCurrentUserId(); + if (!userId) { + console.warn('[DeepLink] Cannot fetch integration tokens: no current user id'); + return; + } + + const encryptionKeyHex = state.auth.encryptionKeyByUser[userId]; + if (!encryptionKeyHex) { + console.warn( + '[DeepLink] Cannot fetch integration tokens: no encryption key found for user', + userId + ); + return; + } + + const keyForBackend = hexToBase64(encryptionKeyHex); + const response = await fetchIntegrationTokens(integrationId, keyForBackend || encryptionKeyHex); + if (!response.success || !response.data?.encrypted) { + console.warn( + '[DeepLink] Integration tokens response missing encrypted payload for integration', + integrationId + ); + return; + } + + let decryptedTokens: IntegrationTokensPayload; + try { + const plaintext = await decryptIntegrationTokensWithKey( + response.data.encrypted, + encryptionKeyHex + ); + decryptedTokens = JSON.parse(plaintext) as IntegrationTokensPayload; + } catch (err) { + console.error('[DeepLink] Failed to decrypt integration tokens:', err); + return; + } + + const existingState = state.skills.skillStates[skillId] ?? {}; + store.dispatch( + setSkillState({ + skillId, + state: { + ...existingState, + oauthTokens: { + ...(existingState.oauthTokens as Record | undefined), + [integrationId]: decryptedTokens, + }, + }, + }) + ); + await skillManager.notifyOAuthComplete(skillId, integrationId); } catch (err) { console.error('[DeepLink] Failed to notify OAuth complete:', err); diff --git a/yarn.lock b/yarn.lock index cd442e40e..348c67722 100644 --- a/yarn.lock +++ b/yarn.lock @@ -923,6 +923,28 @@ outvariant "^1.4.3" strict-event-emitter "^0.5.1" +"@noble/curves@~1.9.0": + version "1.9.7" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.9.7.tgz#79d04b4758a43e4bca2cbdc62e7771352fa6b951" + integrity sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw== + dependencies: + "@noble/hashes" "1.8.0" + +"@noble/hashes@1.8.0", "@noble/hashes@~1.8.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" + integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== + +"@noble/hashes@2.0.1", "@noble/hashes@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.0.1.tgz#fc1a928061d1232b0a52bb754393c37a5216c89e" + integrity sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw== + +"@noble/secp256k1@^2.0.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-2.3.0.tgz#ddfe6e853472fb88cba4d5e59b7067adc1e64adf" + integrity sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" @@ -1152,6 +1174,33 @@ resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== +"@scure/base@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.0.0.tgz#ba6371fddf92c2727e88ad6ab485db6e624f9a98" + integrity sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w== + +"@scure/base@~1.2.5": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.2.6.tgz#ca917184b8231394dd8847509c67a0be522e59f6" + integrity sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg== + +"@scure/bip32@^1.5.1": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.7.0.tgz#b8683bab172369f988f1589640e53c4606984219" + integrity sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw== + dependencies: + "@noble/curves" "~1.9.0" + "@noble/hashes" "~1.8.0" + "@scure/base" "~1.2.5" + +"@scure/bip39@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-2.0.1.tgz#47a6dc15e04faf200041239d46ae3bb7c3c96add" + integrity sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg== + dependencies: + "@noble/hashes" "2.0.1" + "@scure/base" "2.0.0" + "@sec-ant/readable-stream@^0.4.1": version "0.4.1" resolved "https://registry.yarnpkg.com/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz#60de891bb126abfdc5410fdc6166aca065f10a0c" @@ -7451,6 +7500,7 @@ stringify-entities@^4.0.0: character-entities-legacy "^3.0.0" "strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: + name strip-ansi-cjs version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -8296,6 +8346,7 @@ workerpool@^6.5.1: integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: + name wrap-ansi-cjs version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== From 596a742035e79bb16dfe2f121a7ecb6efec86c3c Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 20 Feb 2026 12:58:13 +0530 Subject: [PATCH 07/21] refactor: move integration token encryption/decryption logic to a new utility file - Extracted encryption and decryption functions for integration tokens into `integrationTokensCrypto.ts`. - Removed redundant functions from `desktopDeepLinkListener.ts` to streamline the code. - Updated deep link handling to store only the encrypted tokens instead of decrypted ones. - Improved type definitions for integration tokens and their payloads. --- src/utils/desktopDeepLinkListener.ts | 103 +----------------- src/utils/integrationTokensCrypto.ts | 150 +++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 100 deletions(-) create mode 100644 src/utils/integrationTokensCrypto.ts diff --git a/src/utils/desktopDeepLinkListener.ts b/src/utils/desktopDeepLinkListener.ts index d56657c58..beaed8af5 100644 --- a/src/utils/desktopDeepLinkListener.ts +++ b/src/utils/desktopDeepLinkListener.ts @@ -6,13 +6,7 @@ import { consumeLoginToken, fetchIntegrationTokens } from '../services/api/authA import { store } from '../store'; import { setToken } from '../store/authSlice'; import { setSkillState } from '../store/skillsSlice'; - -type IntegrationTokensPayload = { - accessToken: string; - refreshToken: string; - /** ISO timestamp string */ - expiresAt: string; -}; +import { hexToBase64 } from './integrationTokensCrypto'; function getCurrentUserId(): string | null { const state = store.getState(); @@ -34,85 +28,6 @@ function getCurrentUserId(): string | null { } } -function hexToBase64(hex: string): string { - const bytes = hexToBytes(hex); - if (bytes.length === 0) return ''; - let binary = ''; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary); -} - -function hexToBytes(hex: string): Uint8Array { - const cleanHex = hex.trim().replace(/^0x/i, ''); - if (!cleanHex) return new Uint8Array(); - const bytes = new Uint8Array(cleanHex.length / 2); - for (let i = 0; i < cleanHex.length; i += 2) { - bytes[i / 2] = parseInt(cleanHex.slice(i, i + 2), 16); - } - return bytes; -} - -function base64ToBytes(b64: string): Uint8Array { - // Normalize potential URL-safe base64 and missing padding - let normalized = b64.replace(/-/g, '+').replace(/_/g, '/'); - const pad = normalized.length % 4; - if (pad === 2) normalized += '=='; - else if (pad === 3) normalized += '='; - - const binary = atob(normalized); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; -} - -async function decryptIntegrationTokensWithKey( - encryptedPayload: string, - keyHex: string -): Promise { - if (typeof crypto === 'undefined' || !crypto.subtle) { - throw new Error('Web Crypto API is not available for decryption'); - } - - const keyBytes = hexToBytes(keyHex); - if (keyBytes.length === 0) { - throw new Error('Invalid encryption key'); - } - - const combined = base64ToBytes(encryptedPayload); - // Backend format: IV (16 bytes) + AuthTag (16 bytes) + EncryptedData (rest) - if (combined.length <= 32) { - throw new Error('Encrypted payload too short'); - } - - const iv = combined.slice(0, 16); - const authTag = combined.slice(16, 32); - const encryptedData = combined.slice(32); - const ciphertextWithTag = new Uint8Array(encryptedData.length + authTag.length); - ciphertextWithTag.set(encryptedData, 0); - ciphertextWithTag.set(authTag, encryptedData.length); - - const cryptoKey = await crypto.subtle.importKey( - 'raw', - keyBytes as unknown as BufferSource, - { name: 'AES-GCM' }, - false, - ['decrypt'] - ); - - const decrypted = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv, tagLength: 128 }, - cryptoKey, - ciphertextWithTag as unknown as BufferSource - ); - - return new TextDecoder().decode(decrypted); -} - - /** * Handle an `alphahuman://auth?token=...` deep link for login. */ @@ -189,18 +104,6 @@ const handleOAuthDeepLink = async (parsed: URL) => { return; } - let decryptedTokens: IntegrationTokensPayload; - try { - const plaintext = await decryptIntegrationTokensWithKey( - response.data.encrypted, - encryptionKeyHex - ); - decryptedTokens = JSON.parse(plaintext) as IntegrationTokensPayload; - } catch (err) { - console.error('[DeepLink] Failed to decrypt integration tokens:', err); - return; - } - const existingState = state.skills.skillStates[skillId] ?? {}; store.dispatch( setSkillState({ @@ -208,8 +111,8 @@ const handleOAuthDeepLink = async (parsed: URL) => { state: { ...existingState, oauthTokens: { - ...(existingState.oauthTokens as Record | undefined), - [integrationId]: decryptedTokens, + ...(existingState.oauthTokens as Record | undefined), + [integrationId]: { encrypted: response.data.encrypted }, }, }, }) diff --git a/src/utils/integrationTokensCrypto.ts b/src/utils/integrationTokensCrypto.ts new file mode 100644 index 000000000..1c7a5eaab --- /dev/null +++ b/src/utils/integrationTokensCrypto.ts @@ -0,0 +1,150 @@ +/** + * Helpers for encrypting/decrypting OAuth integration tokens. + * Matches backend format: IV (16 bytes) + AuthTag (16 bytes) + EncryptedData. + * IV is derived from SHA-256(message) for encryption (deterministic). + */ + +export type IntegrationTokensPayload = { + accessToken: string; + refreshToken: string; + /** ISO timestamp string */ + expiresAt: string; +}; + +/** Stored value: encrypted blob only; decrypt with key when needed */ +export type StoredIntegrationTokens = { + encrypted: string; +}; + +export function hexToBytes(hex: string): Uint8Array { + const cleanHex = hex.trim().replace(/^0x/i, ''); + if (!cleanHex) return new Uint8Array(); + const bytes = new Uint8Array(cleanHex.length / 2); + for (let i = 0; i < cleanHex.length; i += 2) { + bytes[i / 2] = parseInt(cleanHex.slice(i, i + 2), 16); + } + return bytes; +} + +export function hexToBase64(hex: string): string { + const bytes = hexToBytes(hex); + if (bytes.length === 0) return ''; + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +export function base64ToBytes(b64: string): Uint8Array { + let normalized = b64.replace(/-/g, '+').replace(/_/g, '/'); + const pad = normalized.length % 4; + if (pad === 2) normalized += '=='; + else if (pad === 3) normalized += '='; + + const binary = atob(normalized); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +/** + * Decrypt an encrypted tokens payload (base64) using a 32-byte key (hex). + * Backend format: IV (16) + AuthTag (16) + EncryptedData. + */ +export async function decryptIntegrationTokens( + encryptedPayload: string, + keyHex: string +): Promise { + if (typeof crypto === 'undefined' || !crypto.subtle) { + throw new Error('Web Crypto API is not available for decryption'); + } + + const keyBytes = hexToBytes(keyHex); + if (keyBytes.length === 0) { + throw new Error('Invalid encryption key'); + } + + const combined = base64ToBytes(encryptedPayload); + if (combined.length <= 32) { + throw new Error('Encrypted payload too short'); + } + + const iv = combined.slice(0, 16); + const authTag = combined.slice(16, 32); + const encryptedData = combined.slice(32); + const ciphertextWithTag = new Uint8Array(encryptedData.length + authTag.length); + ciphertextWithTag.set(encryptedData, 0); + ciphertextWithTag.set(authTag, encryptedData.length); + + const cryptoKey = await crypto.subtle.importKey( + 'raw', + keyBytes as unknown as BufferSource, + { name: 'AES-GCM' }, + false, + ['decrypt'] + ); + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv, tagLength: 128 }, + cryptoKey, + ciphertextWithTag as unknown as BufferSource + ); + + return new TextDecoder().decode(decrypted); +} + +/** + * Encrypt a plaintext string (e.g. JSON) using a 32-byte key (hex). + * Matches backend: deterministic IV = first 16 bytes of SHA-256(message). + * Returns base64(IV + AuthTag + EncryptedData). + */ +export async function encryptIntegrationTokens( + plaintext: string, + keyHex: string +): Promise { + if (typeof crypto === 'undefined' || !crypto.subtle) { + throw new Error('Web Crypto API is not available for encryption'); + } + + const keyBytes = hexToBytes(keyHex); + if (keyBytes.length === 0) { + throw new Error('Invalid encryption key'); + } + + const messageBytes = new TextEncoder().encode(plaintext); + + const hashBuffer = await crypto.subtle.digest('SHA-256', messageBytes); + const iv = new Uint8Array(hashBuffer).slice(0, 16); + + const cryptoKey = await crypto.subtle.importKey( + 'raw', + keyBytes as unknown as BufferSource, + { name: 'AES-GCM' }, + false, + ['encrypt'] + ); + + const encryptedBuffer = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv, tagLength: 128 }, + cryptoKey, + messageBytes + ); + + const encryptedArray = new Uint8Array(encryptedBuffer); + const authTag = encryptedArray.slice(-16); + const encryptedData = encryptedArray.slice(0, -16); + + const combined = new Uint8Array(iv.length + authTag.length + encryptedData.length); + combined.set(iv, 0); + combined.set(authTag, iv.length); + combined.set(encryptedData, iv.length + authTag.length); + + let binary = ''; + for (let i = 0; i < combined.length; i++) { + binary += String.fromCharCode(combined[i]); + } + return btoa(binary); +} From 159e111a292a3ebb6cc7b11c9f2acde48febb707 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 20 Feb 2026 20:45:36 +0530 Subject: [PATCH 08/21] feat: enhance Gmail skill integration and state management - Updated `SkillManager` to accept an optional access token for Gmail during OAuth completion. - Introduced `gmailSlice` to manage Gmail user profile and emails in the Redux store. - Implemented synchronization of Gmail skill state with the Redux store to keep user data updated. - Refactored `SkillProvider` to handle Gmail state changes and fetch initial state from the backend. - Adjusted deep link handling to pass decrypted access tokens for Gmail integration. - Modified API endpoint for onboarding completion to remove the Telegram prefix. --- src-tauri/src/runtime/ping_scheduler.rs | 2 +- src-tauri/src/runtime/qjs_skill_instance.rs | 16 ++-- .../services/quickjs-libs/qjs_ops/types.rs | 24 +++++- src/lib/skills/manager.ts | 3 + src/pages/Home.tsx | 1 - src/providers/SkillProvider.tsx | 74 ++++++++++++++++++- src/services/api/userApi.ts | 4 +- src/store/gmailSlice.ts | 52 +++++++++++++ src/store/index.ts | 2 + src/store/skillsSlice.ts | 9 ++- src/utils/desktopDeepLinkListener.ts | 32 ++++++-- 11 files changed, 198 insertions(+), 21 deletions(-) create mode 100644 src/store/gmailSlice.ts diff --git a/src-tauri/src/runtime/ping_scheduler.rs b/src-tauri/src/runtime/ping_scheduler.rs index 9eaf4ecd8..4ef822427 100644 --- a/src-tauri/src/runtime/ping_scheduler.rs +++ b/src-tauri/src/runtime/ping_scheduler.rs @@ -296,7 +296,7 @@ impl PingScheduler { "skillId": skill_id, "state": state_map, }); - let _ = handle.emit("skill-state-changed", &payload); + let _ = handle.emit_to("main", "skill-state-changed", payload); } } } diff --git a/src-tauri/src/runtime/qjs_skill_instance.rs b/src-tauri/src/runtime/qjs_skill_instance.rs index e99e471f0..5a00717f2 100644 --- a/src-tauri/src/runtime/qjs_skill_instance.rs +++ b/src-tauri/src/runtime/qjs_skill_instance.rs @@ -387,16 +387,16 @@ async fn run_event_loop( .collect(); state.write().published_state = new_map.clone(); - // Emit Tauri event so the frontend picks up the change + // Emit Tauri event to the main window so the frontend receives it (Tauri 2: target explicitly) if let Some(handle) = app_handle { use tauri::Emitter; - let _ = handle.emit( - "skill-state-changed", - serde_json::json!({ - "skillId": skill_id, - "state": new_map, - }), - ); + let payload = serde_json::json!({ + "skillId": skill_id, + "state": new_map, + }); + if let Err(e) = handle.emit_to("main", "skill-state-changed", payload) { + log::warn!("[skill:{}] Failed to emit skill-state-changed to main: {}", skill_id, e); + } } } } diff --git a/src-tauri/src/services/quickjs-libs/qjs_ops/types.rs b/src-tauri/src/services/quickjs-libs/qjs_ops/types.rs index 742340e2d..3113f53f1 100644 --- a/src-tauri/src/services/quickjs-libs/qjs_ops/types.rs +++ b/src-tauri/src/services/quickjs-libs/qjs_ops/types.rs @@ -136,6 +136,26 @@ pub fn check_telegram_skill(skill_id: &str) -> Result<(), String> { } } -pub fn js_err(msg: String) -> rquickjs::Error { - rquickjs::Error::new_from_js_message("skill", "RuntimeError", msg) +/// Sanitize error message for use with QuickJS/rquickjs. +/// Some messages (e.g. from SQLite "Invalid symbol 45, offset 19") can trigger +/// "Invalid symbol" when rquickjs creates the JS exception — avoid comma and hyphen. +fn sanitize_error_message(msg: &str) -> String { + msg.chars() + .map(|c| { + if c == ',' || c == '-' { + ' ' + } else if c.is_ascii() && !c.is_ascii_control() { + c + } else if c == '\n' || c == '\r' || c == '\t' { + ' ' + } else { + '?' + } + }) + .collect() +} + +pub fn js_err(msg: impl AsRef) -> rquickjs::Error { + let sanitized = sanitize_error_message(msg.as_ref()); + rquickjs::Error::new_from_js_message("skill", "RuntimeError", sanitized) } diff --git a/src/lib/skills/manager.ts b/src/lib/skills/manager.ts index 93dd3c0c5..726ccdc8c 100644 --- a/src/lib/skills/manager.ts +++ b/src/lib/skills/manager.ts @@ -270,11 +270,13 @@ class SkillManager { /** * Notify a skill that OAuth completed successfully. * Called by the deep link handler after backend OAuth callback. + * For Gmail, pass extraCredential.accessToken so the skill uses the token directly. */ async notifyOAuthComplete( skillId: string, integrationId: string, provider?: string, + extraCredential?: { accessToken?: string }, ): Promise { const runtime = this.runtimes.get(skillId); if (!runtime || !runtime.isRunning) { @@ -288,6 +290,7 @@ class SkillManager { credentialId: integrationId, provider: provider ?? manifest?.setup?.oauth?.provider ?? "unknown", grantedScopes: manifest?.setup?.oauth?.scopes ?? [], + ...extraCredential, }; await runtime.oauthComplete(credential); diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 4b6f6a396..b088f1ad8 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -2,7 +2,6 @@ import { useNavigate } from 'react-router-dom'; import ConnectionIndicator from '../components/ConnectionIndicator'; import SkillsGrid from '../components/SkillsGrid'; -import WalletInfoSection from '../components/WalletInfoSection'; import { useUser } from '../hooks/useUser'; import { TELEGRAM_BOT_USERNAME } from '../utils/config'; import { openUrl } from '../utils/openUrl'; diff --git a/src/providers/SkillProvider.tsx b/src/providers/SkillProvider.tsx index 602e7d9dd..31b1820a6 100644 --- a/src/providers/SkillProvider.tsx +++ b/src/providers/SkillProvider.tsx @@ -12,6 +12,12 @@ import { skillManager } from '../lib/skills/manager'; import type { SkillManifest } from '../lib/skills/types'; import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue'; import { useAppDispatch, useAppSelector } from '../store/hooks'; +import { + setGmailEmails, + setGmailProfile, + type GmailEmailSummary, + type GmailProfile, +} from '../store/gmailSlice'; import { setSkillError, setSkillState, setSkillStatus } from '../store/skillsSlice'; import { DEV_AUTO_LOAD_SKILL, IS_DEV } from '../utils/config'; @@ -53,19 +59,66 @@ async function discoverSkills(): Promise { // Provider // --------------------------------------------------------------------------- +/** Normalize event payload: Rust emits { skillId, state }; ensure we get both. */ +function parseSkillStatePayload( + payload: unknown +): { skillId: string; state: Record } | null { + if (payload == null || typeof payload !== 'object') return null; + const raw = payload as Record; + const skillId = raw.skillId as string | undefined; + const state = (raw.state ?? raw) as Record | undefined; + if (!skillId || state == null || typeof state !== 'object') return null; + return { skillId, state }; +} + +/** Sync profile and emails from gmail skill state into gmailSlice. */ +function syncGmailStateToSlice( + gmailState: Record | undefined, + dispatch: ReturnType +): void { + if (!gmailState || typeof gmailState !== 'object') return; + dispatch( + setGmailProfile( + gmailState.profile !== undefined && gmailState.profile != null + ? (gmailState.profile as GmailProfile) + : null + ) + ); + dispatch( + setGmailEmails( + Array.isArray(gmailState.emails) ? (gmailState.emails as GmailEmailSummary[]) : [] + ) + ); +} + export default function SkillProvider({ children }: { children: ReactNode }) { const { token } = useAppSelector(state => state.auth); const skillsState = useAppSelector(state => state.skills.skills); + const skillStates = useAppSelector(state => state.skills.skillStates); const dispatch = useAppDispatch(); const initRef = useRef(false); + // Keep gmailSlice in sync with skills.skillStates.gmail (event handler + rehydration) + const gmailSkillState = skillStates?.gmail as Record | undefined; + useEffect(() => { + if (!gmailSkillState) return; + syncGmailStateToSlice(gmailSkillState, dispatch); + }, [gmailSkillState, dispatch]); + // Listen for skill state changes emitted from the Rust runtime event loop useEffect(() => { let unlisten: (() => void) | undefined; listen<{ skillId: string; state: Record }>('skill-state-changed', event => { - const { skillId, state: newState } = event.payload; + const parsed = parseSkillStatePayload(event.payload); + if (!parsed) return; + const { skillId, state: newState } = parsed; dispatch(setSkillState({ skillId, state: newState })); + + // Transfer Gmail skill state to gmail store (also synced by effect from skillStates.gmail) + if (skillId === 'gmail') { + syncGmailStateToSlice(newState, dispatch); + } }) .then(fn => { unlisten = fn; @@ -79,6 +132,25 @@ export default function SkillProvider({ children }: { children: ReactNode }) { }; }, [dispatch]); + // Fallback: when gmail skill is ready, fetch state from backend (covers events missed before listener attached) + const gmailStatus = skillsState?.gmail?.status; + useEffect(() => { + if (gmailStatus !== 'ready') return; + const timeoutId = window.setTimeout(() => { + invoke<{ state?: Record } | null>('runtime_get_skill_state', { + skillId: 'gmail', + }) + .then(snapshot => { + if (snapshot?.state && typeof snapshot.state === 'object') { + dispatch(setSkillState({ skillId: 'gmail', state: snapshot.state })); + syncGmailStateToSlice(snapshot.state, dispatch); + } + }) + .catch(() => {}); + }, 800); + return () => window.clearTimeout(timeoutId); + }, [gmailStatus, dispatch]); + // Listen for skill runtime errors and surface them in the error notification useEffect(() => { let unlisten: (() => void) | undefined; diff --git a/src/services/api/userApi.ts b/src/services/api/userApi.ts index 5685c14f4..419b182d0 100644 --- a/src/services/api/userApi.ts +++ b/src/services/api/userApi.ts @@ -19,11 +19,11 @@ export const userApi = { /** * Mark onboarding complete for the current user. - * POST /telegram/settings/onboarding-complete + * POST /settings/onboarding-complete */ onboardingComplete: async (): Promise => { await apiClient.post<{ success: boolean; data: unknown }>( - '/telegram/settings/onboarding-complete', + '/settings/onboarding-complete', {} ); }, diff --git a/src/store/gmailSlice.ts b/src/store/gmailSlice.ts new file mode 100644 index 000000000..be32b2191 --- /dev/null +++ b/src/store/gmailSlice.ts @@ -0,0 +1,52 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; + +export interface GmailEmailSummary { + id: string; + threadId: string; + snippet?: string; + subject?: string; + from?: string; + date?: string; +} + +export interface GmailProfile { + email_address: string; + messages_total: number; + threads_total: number; + history_id: string; +} + +interface GmailState { + /** Emails fetched after OAuth connection (from Gmail skill) */ + emails: GmailEmailSummary[]; + /** Profile of the connected Gmail user (from Gmail skill) */ + profile: GmailProfile | null; +} + +const initialState: GmailState = { + emails: [], + profile: null, +}; + +const gmailSlice = createSlice({ + name: 'gmail', + initialState, + reducers: { + setGmailEmails(state, action: PayloadAction) { + state.emails = action.payload; + }, + clearGmailEmails(state) { + state.emails = []; + }, + setGmailProfile(state, action: PayloadAction) { + state.profile = action.payload; + }, + clearGmailProfile(state) { + state.profile = null; + }, + }, +}); + +export const { setGmailEmails, clearGmailEmails, setGmailProfile, clearGmailProfile } = + gmailSlice.actions; +export default gmailSlice.reducer; diff --git a/src/store/index.ts b/src/store/index.ts index 69120398d..d34dd6bcd 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -17,6 +17,7 @@ import { IS_DEV } from '../utils/config'; import { storeSession } from '../utils/tauriCommands'; import aiReducer from './aiSlice'; import authReducer, { setOnboardedForUser, setToken } from './authSlice'; +import gmailReducer from './gmailSlice'; import inviteReducer from './inviteSlice'; import skillsReducer from './skillsSlice'; import socketReducer from './socketSlice'; @@ -93,6 +94,7 @@ export const store = configureStore({ user: userReducer, ai: persistedAiReducer, skills: persistedSkillsReducer, + gmail: gmailReducer, team: teamReducer, thread: persistedThreadReducer, invite: inviteReducer, diff --git a/src/store/skillsSlice.ts b/src/store/skillsSlice.ts index fcf3b2c7a..d93e871e2 100644 --- a/src/store/skillsSlice.ts +++ b/src/store/skillsSlice.ts @@ -79,7 +79,14 @@ const skillsSlice = createSlice({ state, action: PayloadAction<{ skillId: string; state: Record }> ) { - state.skillStates[action.payload.skillId] = action.payload.state; + const { skillId, state: incomingState } = action.payload; + const existing = state.skillStates[skillId] as Record | undefined; + // Preserve frontend-only keys (e.g. oauthTokens from deep link) that the skill never sends + const preserved = + existing && typeof existing.oauthTokens === 'object' && existing.oauthTokens !== null + ? { oauthTokens: existing.oauthTokens } + : {}; + state.skillStates[skillId] = { ...incomingState, ...preserved }; }, removeSkill(state, action: PayloadAction) { diff --git a/src/utils/desktopDeepLinkListener.ts b/src/utils/desktopDeepLinkListener.ts index beaed8af5..aae2c6157 100644 --- a/src/utils/desktopDeepLinkListener.ts +++ b/src/utils/desktopDeepLinkListener.ts @@ -4,9 +4,13 @@ import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link'; import { skillManager } from '../lib/skills/manager'; import { consumeLoginToken, fetchIntegrationTokens } from '../services/api/authApi'; import { store } from '../store'; +import { + decryptIntegrationTokens, + hexToBase64, + type IntegrationTokensPayload, +} from './integrationTokensCrypto'; import { setToken } from '../store/authSlice'; import { setSkillState } from '../store/skillsSlice'; -import { hexToBase64 } from './integrationTokensCrypto'; function getCurrentUserId(): string | null { const state = store.getState(); @@ -118,7 +122,24 @@ const handleOAuthDeepLink = async (parsed: URL) => { }) ); - await skillManager.notifyOAuthComplete(skillId, integrationId); + // For Gmail, pass decrypted access token so the skill uses it instead of the proxy + let extraCredential: { accessToken?: string } | undefined; + if (skillId === 'gmail') { + try { + const decryptedJson = await decryptIntegrationTokens( + response.data.encrypted, + encryptionKeyHex + ); + const payload = JSON.parse(decryptedJson) as IntegrationTokensPayload; + if (payload.accessToken) { + extraCredential = { accessToken: payload.accessToken }; + } + } catch (e) { + console.warn('[DeepLink] Could not decrypt Gmail token for skill:', e); + } + } + + await skillManager.notifyOAuthComplete(skillId, integrationId, undefined, extraCredential); } catch (err) { console.error('[DeepLink] Failed to notify OAuth complete:', err); } @@ -191,9 +212,10 @@ export const setupDesktopDeepLinkListener = async () => { if (typeof window !== 'undefined') { // window.__simulateDeepLink('alphahuman://auth?token=1234567890') // window.__simulateDeepLink('alphahuman://oauth/success?integrationId=6989ef9c8e8bf1b6d991a08c&skillId=notion') - ( - window as Window & { __simulateDeepLink?: (url: string) => Promise } - ).__simulateDeepLink = (url: string) => handleDeepLinkUrls([url]); + const win = window as Window & { + __simulateDeepLink?: (url: string) => Promise; + }; + win.__simulateDeepLink = (url: string) => handleDeepLinkUrls([url]); } } catch (err) { console.error('[DeepLink] Setup failed:', err); From f628f870ad3f23b11e84319f1ae439266a9a2330 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 20 Feb 2026 20:59:38 +0530 Subject: [PATCH 09/21] feat: implement Gmail metadata synchronization to backend - Added a new utility function `syncGmailMetadataToBackend` to emit Gmail profile and email summaries to the backend via the `integration:metadata-sync` socket event. - Updated `SkillProvider` to call the new synchronization function, ensuring Gmail skill state is sent to the backend when updated. --- src/lib/skills/gmailMetadataSync.ts | 62 +++++++++++++++++++++++++++++ src/providers/SkillProvider.tsx | 7 +++- 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 src/lib/skills/gmailMetadataSync.ts diff --git a/src/lib/skills/gmailMetadataSync.ts b/src/lib/skills/gmailMetadataSync.ts new file mode 100644 index 000000000..e834119c4 --- /dev/null +++ b/src/lib/skills/gmailMetadataSync.ts @@ -0,0 +1,62 @@ +/** + * Send Gmail profile (and optionally emails) to the backend via the + * `integration:metadata-sync` socket event so the server can merge them + * into the user's Google OAuth integration metadata. + */ +import { emitViaRustSocket } from '../../utils/tauriSocket'; + +const INTEGRATION_METADATA_SYNC_EVENT = 'integration:metadata-sync'; +const PROVIDER_GOOGLE = 'google'; + +/** Gmail profile shape from skill state (snake_case). */ +interface GmailProfileLike { + email_address: string; + messages_total: number; + threads_total: number; + history_id: string; +} + +/** Single email summary from skill state. */ +interface GmailEmailSummaryLike { + id: string; + threadId: string; + snippet?: string; + subject?: string; + from?: string; + date?: string; +} + +/** Gmail skill state slice we care about for metadata sync. */ +export interface GmailStateForSync { + profile?: GmailProfileLike | null; + emails?: GmailEmailSummaryLike[] | null; +} + +/** + * Emit `integration:metadata-sync` with Gmail profile and emails so the + * backend can merge them into the user's Google OAuth integration. + * No-op when profile is missing or not in Tauri. + */ +export function syncGmailMetadataToBackend(gmailState: GmailStateForSync | undefined): void { + if (!gmailState?.profile || typeof gmailState.profile !== 'object') return; + + const profile = gmailState.profile as GmailProfileLike; + const metadata: Record = { + email_address: profile.email_address, + messages_total: profile.messages_total, + threads_total: profile.threads_total, + history_id: profile.history_id, + }; + + if (Array.isArray(gmailState.emails) && gmailState.emails.length > 0) { + metadata.emails = gmailState.emails as GmailEmailSummaryLike[]; + } + + const payload = { + requestId: crypto.randomUUID(), + provider: PROVIDER_GOOGLE, + metadata, + }; + + void emitViaRustSocket(INTEGRATION_METADATA_SYNC_EVENT, payload); +} diff --git a/src/providers/SkillProvider.tsx b/src/providers/SkillProvider.tsx index 31b1820a6..0584d8dd8 100644 --- a/src/providers/SkillProvider.tsx +++ b/src/providers/SkillProvider.tsx @@ -19,6 +19,10 @@ import { type GmailProfile, } from '../store/gmailSlice'; import { setSkillError, setSkillState, setSkillStatus } from '../store/skillsSlice'; +import { + syncGmailMetadataToBackend, + type GmailStateForSync, +} from '../lib/skills/gmailMetadataSync'; import { DEV_AUTO_LOAD_SKILL, IS_DEV } from '../utils/config'; // --------------------------------------------------------------------------- @@ -71,7 +75,7 @@ function parseSkillStatePayload( return { skillId, state }; } -/** Sync profile and emails from gmail skill state into gmailSlice. */ +/** Sync profile and emails from gmail skill state into gmailSlice and send to backend via socket. */ function syncGmailStateToSlice( gmailState: Record | undefined, dispatch: ReturnType @@ -89,6 +93,7 @@ function syncGmailStateToSlice( Array.isArray(gmailState.emails) ? (gmailState.emails as GmailEmailSummary[]) : [] ) ); + syncGmailMetadataToBackend(gmailState as GmailStateForSync); } export default function SkillProvider({ children }: { children: ReactNode }) { From 97d24601418c8dfbc00385f9167e39fad9050ff9 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 20 Feb 2026 21:21:18 +0530 Subject: [PATCH 10/21] refactor: update Tauri socket event handling and improve Mnemonic component typing - Revised the Tauri socket event contract to clarify that encryption key handling is managed via the API instead of the socket. - Removed outdated socket event constants related to encryption key requests from `tauriSocket.ts`. - Enhanced type definitions in the `Mnemonic` component by specifying the event type for keyboard events. --- docs/src/03-services.md | 9 +-------- src/pages/Mnemonic.tsx | 4 ++-- src/utils/tauriSocket.ts | 18 ------------------ 3 files changed, 3 insertions(+), 28 deletions(-) diff --git a/docs/src/03-services.md b/docs/src/03-services.md index c665052ee..0ec728b38 100644 --- a/docs/src/03-services.md +++ b/docs/src/03-services.md @@ -172,14 +172,7 @@ const socket = io(BACKEND_URL, { ### Socket event contract (Tauri) -In Tauri mode, the Rust socket forwards server events to the frontend via the `server:event` Tauri event. The frontend listens and can emit back via `emitViaRustSocket` (see `utils/tauriSocket.ts`). - -| Direction | Event name | Payload | Description | -|------------|----------------------------|----------------------------------|-------------| -| Backend → | `request:encryption_key` | (any) | Backend requests the client’s encryption key. | -| Frontend → | `encryption_key` | `{ encryptionKey: string \| null }` | Frontend sends `encryptionKeyByUser` for the current user from authSlice. | - -Constants: `REQUEST_ENCRYPTION_KEY`, `ENCRYPTION_KEY_EVENT` (exported from `utils/tauriSocket.ts`). +In Tauri mode, the Rust socket forwards server events to the frontend via the `server:event` Tauri event. The frontend listens and can emit back via `emitViaRustSocket` (see `utils/tauriSocket.ts`). Encryption key is handled via the API, not the socket. ## MTProto Service (`services/mtprotoService.ts`) diff --git a/src/pages/Mnemonic.tsx b/src/pages/Mnemonic.tsx index 25cd9bcfa..43592f5eb 100644 --- a/src/pages/Mnemonic.tsx +++ b/src/pages/Mnemonic.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'; import { useNavigate } from 'react-router-dom'; import LottieAnimation from '../components/LottieAnimation'; @@ -96,7 +96,7 @@ const Mnemonic = () => { ); const handleImportKeyDown = useCallback( - (index: number, e: React.KeyboardEvent) => { + (index: number, e: KeyboardEvent) => { if (e.key === 'Backspace' && !importWords[index] && index > 0) { inputRefs.current[index - 1]?.focus(); } diff --git a/src/utils/tauriSocket.ts b/src/utils/tauriSocket.ts index 95ded80e4..e603742a5 100644 --- a/src/utils/tauriSocket.ts +++ b/src/utils/tauriSocket.ts @@ -12,10 +12,6 @@ * Legacy bridge (for backwards compatibility during migration): * - socket:should_connect / socket:should_disconnect * - report_socket_connected / disconnected / error - * - * Socket event contract (backend <-> frontend): - * - Backend emits: REQUEST_ENCRYPTION_KEY — frontend should reply with encryption key. - * - Frontend emits: ENCRYPTION_KEY_EVENT — payload { encryptionKey: string | null } from authSlice. */ import { isTauri as coreIsTauri, invoke } from '@tauri-apps/api/core'; import { listen, UnlistenFn } from '@tauri-apps/api/event'; @@ -25,12 +21,6 @@ import { store } from '../store'; import { setSocketIdForUser, setStatusForUser } from '../store/socketSlice'; import { BACKEND_URL } from './config'; -/** Event name the backend emits to request the client’s encryption key. */ -export const REQUEST_ENCRYPTION_KEY = 'request:encryption_key'; - -/** Event name the frontend emits with encryptionKeyByUser (authSlice) for the current user. */ -export const ENCRYPTION_KEY_EVENT = 'encryption_key'; - // Check if we're running in Tauri export const isTauri = (): boolean => { return coreIsTauri(); @@ -171,14 +161,6 @@ export async function setupTauriSocketListeners(): Promise { unlistenServerEvent = await listen<{ event: string; data: unknown }>('server:event', event => { const { event: eventName, data } = event.payload; console.log('[TauriSocket] Server event:', eventName, data); - - if (eventName === REQUEST_ENCRYPTION_KEY) { - const state = store.getState(); - const userId = - state.user.user?._id ?? getSocketUserId(); - const encryptionKey = state.auth.encryptionKeyByUser[userId] ?? null; - void emitViaRustSocket(ENCRYPTION_KEY_EVENT, { encryptionKey }); - } }); // Legacy: Listen for connect requests from Rust (backwards compat) From b17d72113029383355c5caa71abc88e5512ed250 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Sat, 21 Feb 2026 00:04:16 +0530 Subject: [PATCH 11/21] fix: update Gmail provider constant and adjust metadata synchronization logic - Changed the constant for the Google provider from 'google' to 'gmail' for clarity. - Commented out the email metadata synchronization logic in `syncGmailMetadataToBackend` to prevent potential issues with undefined email states. --- src/lib/skills/gmailMetadataSync.ts | 8 ++++---- src/providers/SkillProvider.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib/skills/gmailMetadataSync.ts b/src/lib/skills/gmailMetadataSync.ts index e834119c4..520a65931 100644 --- a/src/lib/skills/gmailMetadataSync.ts +++ b/src/lib/skills/gmailMetadataSync.ts @@ -6,7 +6,7 @@ import { emitViaRustSocket } from '../../utils/tauriSocket'; const INTEGRATION_METADATA_SYNC_EVENT = 'integration:metadata-sync'; -const PROVIDER_GOOGLE = 'google'; +const PROVIDER_GOOGLE = 'gmail'; /** Gmail profile shape from skill state (snake_case). */ interface GmailProfileLike { @@ -48,9 +48,9 @@ export function syncGmailMetadataToBackend(gmailState: GmailStateForSync | undef history_id: profile.history_id, }; - if (Array.isArray(gmailState.emails) && gmailState.emails.length > 0) { - metadata.emails = gmailState.emails as GmailEmailSummaryLike[]; - } + // if (Array.isArray(gmailState.emails) && gmailState.emails.length > 0) { + // metadata.emails = gmailState.emails as GmailEmailSummaryLike[]; + // } const payload = { requestId: crypto.randomUUID(), diff --git a/src/providers/SkillProvider.tsx b/src/providers/SkillProvider.tsx index 0584d8dd8..89f7f8794 100644 --- a/src/providers/SkillProvider.tsx +++ b/src/providers/SkillProvider.tsx @@ -93,7 +93,7 @@ function syncGmailStateToSlice( Array.isArray(gmailState.emails) ? (gmailState.emails as GmailEmailSummary[]) : [] ) ); - syncGmailMetadataToBackend(gmailState as GmailStateForSync); + syncGmailMetadataToBackend(gmailState.profile as GmailStateForSync); } export default function SkillProvider({ children }: { children: ReactNode }) { From 497d3414df2cf9b0e677f8799e660bf1f72c5b71 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Mon, 23 Feb 2026 14:35:47 +0530 Subject: [PATCH 12/21] feat: implement Gmail metadata synchronization service - Added a new file `metadataSync.ts` to handle the synchronization of Gmail profile and email summaries to the backend via the `integration:metadata-sync` socket event. - Updated import paths in `SkillProvider` to reflect the new location of the Gmail metadata synchronization function. - Enhanced type definitions for Gmail profile and email summaries to ensure proper data structure during synchronization. --- .../services/metadataSync.ts} | 2 +- src/providers/SkillProvider.tsx | 2 +- src/services/api/userApi.ts | 5 +- src/utils/desktopDeepLinkListener.ts | 32 +++++++++++-- src/utils/integrationTokensCrypto.ts | 47 ++++++++++++++----- 5 files changed, 68 insertions(+), 20 deletions(-) rename src/lib/{skills/gmailMetadataSync.ts => gmail/services/metadataSync.ts} (96%) diff --git a/src/lib/skills/gmailMetadataSync.ts b/src/lib/gmail/services/metadataSync.ts similarity index 96% rename from src/lib/skills/gmailMetadataSync.ts rename to src/lib/gmail/services/metadataSync.ts index 520a65931..916d7240a 100644 --- a/src/lib/skills/gmailMetadataSync.ts +++ b/src/lib/gmail/services/metadataSync.ts @@ -3,7 +3,7 @@ * `integration:metadata-sync` socket event so the server can merge them * into the user's Google OAuth integration metadata. */ -import { emitViaRustSocket } from '../../utils/tauriSocket'; +import { emitViaRustSocket } from '../../../utils/tauriSocket'; const INTEGRATION_METADATA_SYNC_EVENT = 'integration:metadata-sync'; const PROVIDER_GOOGLE = 'gmail'; diff --git a/src/providers/SkillProvider.tsx b/src/providers/SkillProvider.tsx index 89f7f8794..291d79724 100644 --- a/src/providers/SkillProvider.tsx +++ b/src/providers/SkillProvider.tsx @@ -22,7 +22,7 @@ import { setSkillError, setSkillState, setSkillStatus } from '../store/skillsSli import { syncGmailMetadataToBackend, type GmailStateForSync, -} from '../lib/skills/gmailMetadataSync'; +} from '../lib/gmail/services/metadataSync'; import { DEV_AUTO_LOAD_SKILL, IS_DEV } from '../utils/config'; // --------------------------------------------------------------------------- diff --git a/src/services/api/userApi.ts b/src/services/api/userApi.ts index 419b182d0..56285a635 100644 --- a/src/services/api/userApi.ts +++ b/src/services/api/userApi.ts @@ -22,9 +22,6 @@ export const userApi = { * POST /settings/onboarding-complete */ onboardingComplete: async (): Promise => { - await apiClient.post<{ success: boolean; data: unknown }>( - '/settings/onboarding-complete', - {} - ); + await apiClient.post<{ success: boolean; data: unknown }>('/settings/onboarding-complete', {}); }, }; diff --git a/src/utils/desktopDeepLinkListener.ts b/src/utils/desktopDeepLinkListener.ts index aae2c6157..c65a4ae65 100644 --- a/src/utils/desktopDeepLinkListener.ts +++ b/src/utils/desktopDeepLinkListener.ts @@ -90,7 +90,7 @@ const handleOAuthDeepLink = async (parsed: URL) => { } const encryptionKeyHex = state.auth.encryptionKeyByUser[userId]; - if (!encryptionKeyHex) { + if (!encryptionKeyHex || typeof encryptionKeyHex !== 'string') { console.warn( '[DeepLink] Cannot fetch integration tokens: no encryption key found for user', userId @@ -98,8 +98,34 @@ const handleOAuthDeepLink = async (parsed: URL) => { return; } - const keyForBackend = hexToBase64(encryptionKeyHex); - const response = await fetchIntegrationTokens(integrationId, keyForBackend || encryptionKeyHex); + const trimmedHex = encryptionKeyHex.trim().replace(/^0x/i, ''); + if (!trimmedHex || trimmedHex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(trimmedHex)) { + console.error( + '[DeepLink] Cannot fetch integration tokens: encryption key must be non-empty hex (even length, [0-9a-fA-F])', + { userId, encryptionKeyHex } + ); + return; + } + + let keyForBackend: string; + try { + keyForBackend = hexToBase64(encryptionKeyHex); + } catch (e) { + console.error( + '[DeepLink] Cannot fetch integration tokens: encryption key conversion failed', + { userId, encryptionKeyHex, error: e } + ); + return; + } + if (!keyForBackend) { + console.error( + '[DeepLink] Cannot fetch integration tokens: encryption key produced empty base64', + { userId, encryptionKeyHex } + ); + return; + } + + const response = await fetchIntegrationTokens(integrationId, keyForBackend); if (!response.success || !response.data?.encrypted) { console.warn( '[DeepLink] Integration tokens response missing encrypted payload for integration', diff --git a/src/utils/integrationTokensCrypto.ts b/src/utils/integrationTokensCrypto.ts index 1c7a5eaab..bc1aaf45d 100644 --- a/src/utils/integrationTokensCrypto.ts +++ b/src/utils/integrationTokensCrypto.ts @@ -12,13 +12,23 @@ export type IntegrationTokensPayload = { }; /** Stored value: encrypted blob only; decrypt with key when needed */ -export type StoredIntegrationTokens = { - encrypted: string; -}; +export type StoredIntegrationTokens = { encrypted: string }; + +const HEX_REGEX = /^[0-9a-fA-F]*$/; export function hexToBytes(hex: string): Uint8Array { const cleanHex = hex.trim().replace(/^0x/i, ''); if (!cleanHex) return new Uint8Array(); + if (cleanHex.length % 2 !== 0) { + throw new TypeError( + `hexToBytes: hex string must have even length (got ${cleanHex.length})` + ); + } + if (!HEX_REGEX.test(cleanHex)) { + throw new TypeError( + 'hexToBytes: hex string must contain only [0-9a-fA-F] characters' + ); + } const bytes = new Uint8Array(cleanHex.length / 2); for (let i = 0; i < cleanHex.length; i += 2) { bytes[i / 2] = parseInt(cleanHex.slice(i, i + 2), 16); @@ -63,8 +73,8 @@ export async function decryptIntegrationTokens( } const keyBytes = hexToBytes(keyHex); - if (keyBytes.length === 0) { - throw new Error('Invalid encryption key'); + if (keyBytes.length !== 32) { + throw new Error('Invalid encryption key: expected 32-byte AES-GCM key'); } const combined = base64ToBytes(encryptedPayload); @@ -101,21 +111,36 @@ export async function decryptIntegrationTokens( * Matches backend: deterministic IV = first 16 bytes of SHA-256(message). * Returns base64(IV + AuthTag + EncryptedData). */ -export async function encryptIntegrationTokens( - plaintext: string, - keyHex: string -): Promise { +export async function encryptIntegrationTokens(plaintext: string, keyHex: string): Promise { if (typeof crypto === 'undefined' || !crypto.subtle) { throw new Error('Web Crypto API is not available for encryption'); } const keyBytes = hexToBytes(keyHex); - if (keyBytes.length === 0) { - throw new Error('Invalid encryption key'); + if (keyBytes.length !== 32) { + throw new Error('Invalid encryption key: expected 32-byte AES-GCM key'); + } + + try { + const payload = JSON.parse(plaintext) as Record; + if (typeof payload.expiresAt !== 'string' || !payload.expiresAt.trim()) { + throw new Error( + 'Payload must include a non-empty expiresAt field when using deterministic IV' + ); + } + } catch (e) { + if (e instanceof SyntaxError) { + throw new Error( + 'Plaintext must be JSON with a non-empty expiresAt field when using deterministic IV' + ); + } + throw e; } const messageBytes = new TextEncoder().encode(plaintext); + // Deterministic IV for backend compatibility. TODO: Prefer a random 12-byte IV + // (crypto.getRandomValues) prepended to ciphertext; update decrypt to handle both formats. const hashBuffer = await crypto.subtle.digest('SHA-256', messageBytes); const iv = new Uint8Array(hashBuffer).slice(0, 16); From 23d21667e2fdf4a9555d243f765cea8a4505f4fb Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Mon, 23 Feb 2026 14:39:09 +0530 Subject: [PATCH 13/21] refactor: clean up code formatting and improve readability across multiple components - Standardized import statements and formatting in various files, including `WalletInfoSection`, `AgentChatPanel`, and `SkillsPanel`. - Enhanced readability by adjusting line breaks and spacing in JSX elements and function definitions. - Improved consistency in the use of arrow functions and destructuring across components. - Updated type definitions and ensured proper alignment of code for better maintainability. --- README.md | 8 +- SECURITY.md | 10 +- src/components/WalletInfoSection.tsx | 22 +- .../settings/panels/AgentChatPanel.tsx | 24 +- .../settings/panels/SkillsPanel.tsx | 46 +- .../settings/panels/TauriCommandsPanel.tsx | 894 +++++++++--------- .../panels/components/ActionPanel.tsx | 23 +- .../settings/panels/components/InputGroup.tsx | 33 +- .../panels/components/SectionCard.tsx | 24 +- src/lib/gmail/services/metadataSync.ts | 6 +- src/pages/Mnemonic.tsx | 6 +- src/providers/SkillProvider.tsx | 14 +- src/services/api/authApi.ts | 4 +- src/store/gmailSlice.ts | 5 +- src/utils/cryptoKeys.ts | 4 +- src/utils/desktopDeepLinkListener.ts | 13 +- src/utils/integrationTokensCrypto.ts | 8 +- src/utils/tauriCommands.ts | 26 +- 18 files changed, 534 insertions(+), 636 deletions(-) diff --git a/README.md b/README.md index dfcdb8cc1..a5ba635d9 100644 --- a/README.md +++ b/README.md @@ -54,16 +54,16 @@ AlphaHuman is designed to be simpler to deploy, cheaper to run, and more intelli > **Early Beta** — AlphaHuman is under active development. Expect rough edges. -| Platform | Variant | Download | -| ----------- | --------------------------- | ------------------------------------------------------------------------------------------------------------- | +| Platform | Variant | Download | +| ----------- | --------------------------- | -------------------------------------------------------------------------------------------------------------- | | **macOS** | Apple Silicon (M1/M2/M3/M4) | [`.dmg` (aarch64)](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_aarch64.dmg) | | **macOS** | Intel | [`.dmg` (x64)](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_x64.dmg) | | **Windows** | x64 | [`.msi`](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_x64_en-US.msi) | | **Linux** | Debian / Ubuntu | [`.deb` (amd64)](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_amd64.deb) | | **Linux** | Fedora / RHEL | [`.rpm` (x86_64)](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_x86_64.rpm) | | **Linux** | Universal | [`.AppImage`](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_amd64.AppImage) | -| **Android** | — | Coming soon | -| **iOS** | — | Coming soon | +| **Android** | — | Coming soon | +| **iOS** | — | Coming soon | Browse all releases: [github.com/alphahumanai/alphahuman/releases](https://github.com/alphahumanai/alphahuman/releases) diff --git a/SECURITY.md b/SECURITY.md index 56044d378..81195e3c1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,11 +4,11 @@ We provide security updates for the following versions of AlphaHuman: -| Version | Supported | -| ------- | ------------------ | -| Latest | :white_check_mark: | -| Previous minor | :white_check_mark: | -| Older | :x: | +| Version | Supported | +| -------------- | ------------------ | +| Latest | :white_check_mark: | +| Previous minor | :white_check_mark: | +| Older | :x: | We recommend always running the [latest release](https://github.com/alphahumanxyz/alphahuman/releases/latest). AlphaHuman is in early beta; older versions may not receive patches. diff --git a/src/components/WalletInfoSection.tsx b/src/components/WalletInfoSection.tsx index 102759586..e722c2991 100644 --- a/src/components/WalletInfoSection.tsx +++ b/src/components/WalletInfoSection.tsx @@ -1,9 +1,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { skillManager } from '../lib/skills/manager'; -import { useSkillConnectionStatus } from '../lib/skills/hooks'; -import { useAppSelector } from '../store/hooks'; import { useUser } from '../hooks/useUser'; +import { useSkillConnectionStatus } from '../lib/skills/hooks'; +import { skillManager } from '../lib/skills/manager'; +import { useAppSelector } from '../store/hooks'; function truncateAddress(address: string): string { if (!address || address.length < 12) return address; @@ -64,8 +64,8 @@ export default function WalletInfoSection() { }; const networks = Array.isArray(listData.networks) ? listData.networks : []; // Prefer Ethereum Mainnet (skill always has it); else first EVM, else first network - const ethMainnet = networks.find((n) => n?.chain_id === '1' && n?.chain_type === 'evm'); - const firstEvm = networks.find((n) => n && n.chain_type === 'evm'); + const ethMainnet = networks.find(n => n?.chain_id === '1' && n?.chain_type === 'evm'); + const firstEvm = networks.find(n => n && n.chain_type === 'evm'); const chosen = ethMainnet ?? firstEvm ?? networks.find(Boolean); if (!chosen || cancelledRef.current) { if (!cancelledRef.current) setLoading(false); @@ -121,9 +121,13 @@ export default function WalletInfoSection() { } catch (e) { if (!cancelledRef.current) { const msg = e instanceof Error ? e.message : String(e); - const isTransient = msg.includes('not running') || msg.includes('not started') || msg.includes('transport'); + const isTransient = + msg.includes('not running') || msg.includes('not started') || msg.includes('transport'); if (isTransient && attempt < 3) { - retryTimerRef.current = setTimeout(() => fetchWalletInfoRef.current(address, attempt + 1), 2000); + retryTimerRef.current = setTimeout( + () => fetchWalletInfoRef.current(address, attempt + 1), + 2000 + ); return; } console.error('[WalletInfoSection] Failed to load wallet info:', e); @@ -197,7 +201,7 @@ export default function WalletInfoSection() { ) : error ? ( {error} ) : ( - networkName ?? '—' + (networkName ?? '—') )}
@@ -209,7 +213,7 @@ export default function WalletInfoSection() { ) : error ? ( '—' ) : ( - balance ?? '—' + (balance ?? '—') )} diff --git a/src/components/settings/panels/AgentChatPanel.tsx b/src/components/settings/panels/AgentChatPanel.tsx index 5dbc2f035..25f4c9b01 100644 --- a/src/components/settings/panels/AgentChatPanel.tsx +++ b/src/components/settings/panels/AgentChatPanel.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from 'react'; +import { alphahumanAgentChat } from '../../../utils/tauriCommands'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; -import { alphahumanAgentChat } from '../../../utils/tauriCommands'; type ChatMessage = { role: 'user' | 'agent'; text: string }; @@ -46,12 +46,7 @@ const AgentChatPanel = () => { }, []); useEffect(() => { - const payload = { - messages, - providerOverride, - modelOverride, - temperature, - }; + const payload = { messages, providerOverride, modelOverride, temperature }; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); } catch { @@ -65,7 +60,7 @@ const AgentChatPanel = () => { setError(''); setSending(true); setInput(''); - setMessages((prev) => [...prev, { role: 'user', text }]); + setMessages(prev => [...prev, { role: 'user', text }]); try { const response = await alphahumanAgentChat( text, @@ -73,7 +68,7 @@ const AgentChatPanel = () => { modelOverride.trim() ? modelOverride : undefined, Number.isFinite(Number(temperature)) ? Number(temperature) : undefined ); - setMessages((prev) => [...prev, { role: 'agent', text: response.result }]); + setMessages(prev => [...prev, { role: 'agent', text: response.result }]); } catch (err) { const message = err instanceof Error ? err.message : String(err); setError(message); @@ -96,7 +91,7 @@ const AgentChatPanel = () => { className="input input-bordered w-full text-slate-900 bg-white" placeholder="openai" value={providerOverride} - onChange={(event) => setProviderOverride(event.target.value)} + onChange={event => setProviderOverride(event.target.value)} /> @@ -139,8 +134,7 @@ const AgentChatPanel = () => {
+ }`}> {message.text}
@@ -151,7 +145,7 @@ const AgentChatPanel = () => { className="textarea textarea-bordered w-full min-h-[140px] text-slate-900 bg-white" placeholder="Ask the agent anything..." value={input} - onChange={(event) => setInput(event.target.value)} + onChange={event => setInput(event.target.value)} />