Repository navigation
fix(native): restore wallet controls and fast private receive #137
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
39522ee
e05b6aa
5a89bfd
45ed0cd
6dab9b1
e8896a1
4959969
55ad0c9
97baf36
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import {useEffect,useRef,useState} from 'react'; | ||
| import './native-about.css'; | ||
|
|
||
| export default function NativeAbout({onClose,onClearWallet}:{onClose:()=>void;onClearWallet?:()=>Promise<void>}){ | ||
| const close=useRef<HTMLButtonElement>(null),[confirmClear,setConfirmClear]=useState(false),[clearing,setClearing]=useState(false),[clearError,setClearError]=useState(''); | ||
| const clear=async()=>{ | ||
| if(!onClearWallet||clearing)return; | ||
| setClearing(true);setClearError(''); | ||
| try{await onClearWallet();} | ||
| catch(error){setClearError(error instanceof Error?error.message:String(error));setClearing(false);} | ||
| }; | ||
| useEffect(()=>{ | ||
| close.current?.focus(); | ||
| const previous=document.body.style.overflow,key=(event:KeyboardEvent)=>{ | ||
| if(event.key==='Escape'&&!clearing)onClose(); | ||
| if(event.key==='Tab'){ | ||
| const buttons=[...document.querySelectorAll<HTMLButtonElement>('.native-about button')]; | ||
| if(!buttons.length)return; | ||
| const first=buttons[0]!,last=buttons.at(-1)!; | ||
| if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus();} | ||
| else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus();} | ||
| } | ||
| }; | ||
| document.body.style.overflow='hidden';addEventListener('keydown',key); | ||
| return()=>{document.body.style.overflow=previous;removeEventListener('keydown',key);}; | ||
| },[onClose,clearing]); | ||
| return <div className="native-about-layer" onMouseDown={event=>{if(event.target===event.currentTarget&&!clearing)onClose();}}> | ||
| <section className="native-about" role="dialog" aria-modal="true" aria-labelledby="native-about-title"> | ||
| <span>About</span> | ||
| <h2 id="native-about-title">Your keys stay with you.</h2> | ||
| <p>Shielded is a private Bitcoin wallet on Arkade. Payments inside the pool keep their amount, sender and recipient private. Deposits and withdrawals are public.</p> | ||
| <p>This Mutinynet build is for testing with test funds only. Wallet traffic uses Tor by default, and Tor can be turned off. Repeated identifiers and timing can still reveal links.</p> | ||
| {onClearWallet&&<section className="native-about-wallet" aria-labelledby="native-about-wallet-title"> | ||
| <span>Wallet controls</span> | ||
| <h3 id="native-about-wallet-title">Clear this wallet</h3> | ||
| <p>Remove the local wallet, recovery data and vault secret from this device. Keep your recovery phrase first.</p> | ||
| {!confirmClear?<button type="button" className="native-about-clear" onClick={()=>setConfirmClear(true)}>Clear wallet…</button>:<div className="native-about-confirm" role="alert"> | ||
| <p>Pending Lightning swaps may still need this device's saved recovery records. Clear anyway?</p> | ||
| <div><button type="button" onClick={()=>setConfirmClear(false)} disabled={clearing}>Keep wallet</button><button type="button" className="native-about-danger" onClick={()=>void clear()} disabled={clearing}>{clearing?'Clearing wallet…':'Clear permanently'}</button></div> | ||
| </div>} | ||
| {clearError&&<p className="native-about-error" role="alert">{clearError}</p>} | ||
| </section>} | ||
| <button ref={close} type="button" onClick={onClose} disabled={clearing}>Close</button> | ||
| </section> | ||
| </div>; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,24 +1,51 @@ | ||
| import {useEffect,useId,useLayoutEffect,useRef,useState,type CSSProperties} from 'react'; | ||
| import {createPortal} from 'react-dom'; | ||
| import type {NativeTorHealthState} from './native-tor-monitor.ts'; | ||
| import './native-tor-health.css'; | ||
|
|
||
| export const initialTorHealth:NativeTorHealthState={status:{phase:'idle',generation:0},check:'idle'}; | ||
|
|
||
| export default function NativeTorHealth({health=initialTorHealth,tone='light',onToggleTor,transportBusy=false}:{health?:NativeTorHealthState;tone?:'light'|'dark';onToggleTor?:()=>void;transportBusy?:boolean}){ | ||
| const [open,setOpen]=useState(false),[position,setPosition]=useState<CSSProperties>({}),host=useRef<HTMLDivElement>(null),panel=useRef<HTMLDivElement>(null),trigger=useRef<HTMLButtonElement>(null),panelId=useId(); | ||
| const {status,check,checkedAt,latencyMs}=health; | ||
| const direct=(status.mode??'tor')==='direct'; | ||
| const ready=status.phase==='ready',connected=ready&&check==='ok'; | ||
| const label=direct?status.phase==='error'||check==='failed'?'Direct connection failed · Tor off':status.phase==='paused'?'Direct paused':ready?(connected?'Tor off · Direct connection':check==='checking'?'Checking direct connection':'Direct ready'):'Connecting directly':status.phase==='paused'?'Tor paused':status.phase==='error'?'Tor unavailable':status.phase==='bootstrapping'?'Connecting to Tor':ready?(connected?'Tor connected':check==='failed'?'Tor check failed':check==='checking'?'Checking Tor':'Tor ready'):'Starting Tor'; | ||
| const state=direct&&status.phase==='error'||ready&&check==='failed'?'error':direct?'direct':connected?'connected':status.phase==='error'||ready&&check==='failed'?'error':status.phase==='paused'?'paused':'connecting'; | ||
| const progress=!direct&&status.phase==='bootstrapping'&&status.progress&&/^\d{1,3}%$/.test(status.progress)?status.progress:undefined; | ||
| const detail=direct?(status.phase==='paused'?'The direct connection is paused. Unlock the wallet to reconnect.':status.phase==='error'?(status.error||'The direct connection is unavailable. Reconnect to try again.'):ready&&check==='failed'?'The latest direct connection check failed. Tor remains off.':connected?'A recent direct connection to the wallet service succeeded.':ready?'Checking the direct connection to the wallet service.':'Connecting directly to the wallet services.'):status.phase==='paused'?'The private connection is paused. Unlock the wallet to reconnect.':status.phase==='error'?(status.error||'Tor could not start. Reconnect to try again.'):ready&&check==='failed'?'The latest connection check through Tor failed. Your wallet never falls back to a direct connection.':connected?'A recent connection to the wallet service succeeded through Tor.':ready?'Tor is ready. Checking the connection to the wallet service.':'Tor starts automatically and keeps wallet traffic private while it connects.'; | ||
| return <details className="native-tor-health" data-tone={tone} data-health={state}> | ||
| <summary aria-label={`Wallet connection: ${label}`}><span className="native-tor-dot" aria-hidden="true"/><span role="status" aria-live="polite">{label}{progress&&<span className="native-tor-percent"> {progress}</span>}</span><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="m5 6 3 3 3-3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg></summary> | ||
| <div className="native-tor-panel"><strong>{label}</strong><p>{detail}</p> | ||
| {progress&&<progress max={100} value={Math.min(100,parseInt(progress,10))} aria-label="Tor bootstrap progress"/>} | ||
| {ready&&checkedAt&&<p className="native-tor-checked">Last checked {new Date(checkedAt).toLocaleTimeString(undefined,{hour:'2-digit',minute:'2-digit',second:'2-digit'})}{latencyMs!==undefined?` · ${(latencyMs/1000).toFixed(1)}s`:''}</p>} | ||
| {ready&&check==='failed'&&health.error&&<p className="native-tor-problem">{health.error}</p>} | ||
| {onToggleTor&&<button className="native-tor-switch" type="button" role="switch" aria-checked={!direct} disabled={transportBusy} onClick={()=>{if(!transportBusy)onToggleTor();}}><span>Use Tor</span><span className="native-tor-switch-track" aria-hidden="true"/></button>} | ||
| <p className="native-tor-privacy">{direct?'Your IP address and DNS lookups are exposed. Direct connections have no Tor circuit isolation.':'Separate Tor scopes protect unrelated wallet items. Repeated identifiers and timing can still reveal links. Wallet services may be unavailable even when Tor is connected.'}</p> | ||
| </div> | ||
| </details>; | ||
| const place=()=>{ | ||
| const rect=trigger.current?.getBoundingClientRect(); | ||
| if(!rect)return {}; | ||
| const width=Math.min(320,Math.max(240,innerWidth-32)),left=Math.max(16,Math.min(rect.left,innerWidth-width-16)); | ||
| return tone==='dark'?{left,width,bottom:Math.max(16,innerHeight-rect.top+10)}:{left,width,top:rect.bottom+10}; | ||
| }; | ||
| const toggle=()=>{ | ||
| if(!open)setPosition(place()); | ||
| setOpen(value=>!value); | ||
| }; | ||
| useLayoutEffect(()=>{ | ||
| if(!open)return; | ||
| const update=()=>setPosition(place()); | ||
| update();addEventListener('resize',update);addEventListener('scroll',update,true); | ||
| return()=>{removeEventListener('resize',update);removeEventListener('scroll',update,true);}; | ||
| },[open,tone]); | ||
| useEffect(()=>{ | ||
| if(!open)return; | ||
| const outside=(event:PointerEvent)=>{const target=event.target as Node;if(!host.current?.contains(target)&&!panel.current?.contains(target))setOpen(false);},key=(event:KeyboardEvent)=>{if(event.key==='Escape'){setOpen(false);trigger.current?.focus();}}; | ||
| addEventListener('pointerdown',outside);addEventListener('keydown',key); | ||
| return()=>{removeEventListener('pointerdown',outside);removeEventListener('keydown',key);}; | ||
| },[open]); | ||
| const body=<><strong>{label}</strong><p>{detail}</p> | ||
| {progress&&<progress max={100} value={Math.min(100,parseInt(progress,10))} aria-label="Tor bootstrap progress"/>} | ||
| {ready&&checkedAt&&<p className="native-tor-checked">Last checked {new Date(checkedAt).toLocaleTimeString(undefined,{hour:'2-digit',minute:'2-digit',second:'2-digit'})}{latencyMs!==undefined?` · ${(latencyMs/1000).toFixed(1)}s`:''}</p>} | ||
| {ready&&check==='failed'&&health.error&&<p className="native-tor-problem">{health.error}</p>} | ||
| {onToggleTor&&<button className="native-tor-switch" type="button" role="switch" aria-checked={!direct} disabled={transportBusy} onClick={()=>{if(!transportBusy){setOpen(false);onToggleTor();}}}><span>Use Tor</span><span className="native-tor-switch-track" aria-hidden="true"/></button>} | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| <p className="native-tor-privacy">{direct?'Your IP address and DNS lookups are exposed. Direct connections have no Tor circuit isolation.':'Separate Tor scopes protect unrelated wallet items. Repeated identifiers and timing can still reveal links. Wallet services may be unavailable even when Tor is connected.'}</p> | ||
| </>; | ||
| const content=<div ref={panel} className="native-tor-panel" id={panelId} role="region" aria-label="Connection details" data-placement={tone==='dark'?'above':'below'} style={position}>{body}</div>; | ||
| return <><div ref={host} className="native-tor-health" data-tone={tone} data-health={state}> | ||
| <button ref={trigger} type="button" className="native-tor-trigger" aria-label={`Wallet connection: ${label}`} aria-expanded={open} aria-controls={panelId} onClick={toggle}><span className="native-tor-dot" aria-hidden="true"/><span role="status" aria-live="polite">{label}{progress&&<span className="native-tor-percent"> {progress}</span>}</span><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="m5 6 3 3 3-3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg></button> | ||
| {typeof document==='undefined'?<div className="native-tor-panel" id={panelId} role="region" aria-label="Connection details" hidden>{body}</div>:!open?<div id={panelId} hidden/>:null} | ||
| </div>{open&&typeof document!=='undefined'?createPortal(content,document.body):null}</>; | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ import {createNativeCleanup} from './native-cleanup.ts'; | |
| import {createNativeViewHandoff,type NativeWalletView} from './native-view-handoff.ts'; | ||
| import {openNativeVault,resumeNativeForeground} from './native-open.ts'; | ||
| import {saveNativeSetup} from './native-setup.ts'; | ||
| import {clearNativeWalletData} from './native-reset.ts'; | ||
| import NativeConnectionWarning from './NativeConnectionWarning.tsx'; | ||
| import {createNativeTorMonitor,type NativeTorHealthState} from './native-tor-monitor.ts'; | ||
| import WalletOnboarding from './WalletOnboarding.tsx'; | ||
|
|
@@ -19,6 +20,7 @@ const loadWallet=()=>import('./RollupWallet'); | |
| const Wallet=lazy(loadWallet); | ||
| const getSecret=()=>invoke<string|null>('plugin:shielded-platform|secret_get'); | ||
| const putSecret=(value:string)=>invoke<void>('plugin:shielded-platform|secret_set',{value}); | ||
| const clearSecret=()=>invoke<void>('plugin:shielded-platform|secret_clear'); | ||
| type Session={generation:number;cache:ReturnType<typeof createNativeStorage>;resumed:boolean;onReady?:()=>void;onStartupFailure?:(message:string)=>void}; | ||
|
|
||
| export default function NativeWallet(){ | ||
|
|
@@ -29,7 +31,7 @@ export default function NativeWallet(){ | |
| const [externalLink,setExternalLink]=useState(''); | ||
| const [confirmDirect,setConfirmDirect]=useState(false),[switching,setSwitching]=useState(false),[saving,setSaving]=useState(false); | ||
| const [reconnecting,setReconnecting]=useState(false); | ||
| const selectedMode=useRef<NativeMode>('tor'),transition=useRef(false),creating=useRef<Promise<void>|undefined>(undefined); | ||
| const selectedMode=useRef<NativeMode>('tor'),transition=useRef(false),clearing=useRef(false),creating=useRef<Promise<void>|undefined>(undefined); | ||
| const setupAllowed=useRef(false); | ||
| const active=useRef<Session|undefined>(undefined),attempt=useRef(0),cleanup=useRef(createNativeCleanup()),closed=useRef<Promise<void>>(Promise.resolve()),manualLock=useRef(false); | ||
| const connectionPending=useRef(false); | ||
|
|
@@ -66,7 +68,7 @@ export default function NativeWallet(){ | |
| },[]); | ||
| const open=useCallback(()=>{ | ||
| if(opening.current)return opening.current; | ||
| if(document.hidden||manualLock.current){setBusy(false);setPhase('Private connection paused');return Promise.resolve();} | ||
| if(document.hidden||manualLock.current||clearing.current){setBusy(false);setPhase('Private connection paused');return Promise.resolve();} | ||
| const previousClose=closed.current; | ||
| const resume=active.current; | ||
| const run=(async()=>{ | ||
|
|
@@ -77,17 +79,17 @@ export default function NativeWallet(){ | |
| await cleanup.current.drain(); | ||
| if(mine!==attempt.current)return; | ||
| await previousClose.catch(()=>{}); | ||
| if(mine!==attempt.current||document.hidden||manualLock.current)return; | ||
| if(mine!==attempt.current||document.hidden||manualLock.current||clearing.current)return; | ||
| setPhase(selectedMode.current==='tor'?'Connecting to Tor':'Opening direct connection'); | ||
| const generationGuard=()=>mine===attempt.current&&!document.hidden&&!manualLock.current&&!healthExpired.current; | ||
| const generationGuard=()=>mine===attempt.current&&!document.hidden&&!manualLock.current&&!clearing.current&&!healthExpired.current; | ||
| const opened=await openNativeVault(async()=>{ | ||
| const status=await nativeNetwork.start(selectedMode.current); | ||
| if(status.phase==='ready')openingGeneration.current=status.generation; | ||
| return status; | ||
| },getSecret,generationGuard,{mode:selectedMode.current,current:generationGuard}); | ||
| if(opened&&opened.secret!==null)await loadWallet(); | ||
| openingGeneration.current=undefined; | ||
| if(mine!==attempt.current||document.hidden||manualLock.current)return; | ||
| if(mine!==attempt.current||document.hidden||manualLock.current||clearing.current)return; | ||
| if(!opened||healthExpired.current)throw new Error('The connection changed while opening the wallet. Reconnect to try again.'); | ||
| const {secret,status}=opened; | ||
| if(secret===null){ | ||
|
|
@@ -98,7 +100,7 @@ export default function NativeWallet(){ | |
| setSetup(false);const cache=resume?.cache??createNativeStorage(secret,localStorage,putSecret); | ||
| manualLock.current=false;const next:Session={generation:status.generation,cache,resumed:!!resume};active.current=next; | ||
| const view=handoff.current.stage(next),candidate=handoff.current.snapshot.candidate===view; | ||
| const current=()=>active.current===next&&mine===attempt.current&&!document.hidden&&!manualLock.current&&!healthExpired.current&&handoff.current.snapshot.candidate===view; | ||
| const current=()=>active.current===next&&mine===attempt.current&&!document.hidden&&!manualLock.current&&!clearing.current&&!healthExpired.current&&handoff.current.snapshot.candidate===view; | ||
| if(candidate){ | ||
| next.onStartupFailure=message=>{ | ||
| if(!current())return; | ||
|
|
@@ -122,6 +124,17 @@ export default function NativeWallet(){ | |
| })(); | ||
| opening.current=run.finally(()=>{opening.current=undefined;});return opening.current; | ||
| },[suspend]); | ||
| const clearWallet=useCallback(async()=>{ | ||
| if(transition.current||creating.current)throw new Error('Wait for the current wallet operation to finish before clearing it.'); | ||
| transition.current=true;clearing.current=true;setBusy(true);setSaving(true);setError('');setPhase('Clearing wallet'); | ||
| try{ | ||
| await pause(); | ||
| await clearNativeWalletData(clearSecret); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| location.reload(); | ||
|
Comment on lines
+149
to
+150
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| }catch(cause){ | ||
| transition.current=false;clearing.current=false;setSaving(false);setBusy(false);fail(cause);throw cause; | ||
| } | ||
| },[pause]); | ||
| const create=async(restore:boolean)=>{ | ||
| if(creating.current||transition.current||manualLock.current||!setupAllowed.current)return; | ||
| const mine=attempt.current;setBusy(true);setSaving(true);setError(''); | ||
|
|
@@ -156,7 +169,7 @@ export default function NativeWallet(){ | |
| void suspend(); | ||
| }else if(!manualLock.current){ | ||
| const mine=attempt.current; | ||
| void resumeNativeForeground(Promise.all([closed.current,opening.current]),()=>live&&mine===attempt.current&&!manualLock.current&&!document.hidden,open).catch(fail); | ||
| void resumeNativeForeground(Promise.all([closed.current,opening.current]),()=>live&&mine===attempt.current&&!manualLock.current&&!clearing.current&&!document.hidden,open).catch(fail); | ||
| } | ||
| }; | ||
| document.addEventListener('visibilitychange',visibility); | ||
|
|
@@ -187,12 +200,12 @@ export default function NativeWallet(){ | |
| const nextAttempt=attempt.current+1;await pause(); | ||
| if(attempt.current===nextAttempt&&!manualLock.current&&!document.hidden)await open(); | ||
| }).catch(cause=>{if(active.current===current&&attempt.current===mine){fail(cause);void pause();}}); | ||
| },[open,pause]); | ||
| },[open,pause]); | ||
| return <> | ||
| {session&&<div className="native-wallet-topbar"><div className="app-header-status"><NativeTorHealth health={torHealth} onToggleTor={toggleTor} transportBusy={switching||saving||(reconnecting&&!error)}/><AppUpdateNotice/>{reconnecting&&<span role="status">{error||phase}</span>}{reconnecting&&error&&<button type="button" onClick={()=>void open()}>Reconnect</button>}</div><button className="native-wallet-lock" type="button" onClick={()=>{manualLock.current=true;setConfirmDirect(false);void pause();}}>Lock wallet</button></div>} | ||
| {confirmDirect&&<NativeConnectionWarning onCancel={()=>setConfirmDirect(false)} onConfirm={()=>void switchMode('direct')}/>} | ||
| {externalLink&&<section className="stock-warning" role="dialog" aria-label="External link"><p>Copy this link to open it outside Shielded. Your browser uses its own network connection.</p><input aria-label="Link to copy" readOnly value={externalLink} onFocus={event=>event.target.select()}/><button type="button" onClick={()=>setExternalLink('')}>Close</button></section>} | ||
| {session?<>{[views.shown,views.candidate].filter((view):view is NativeWalletView<Session>=>!!view).map(view=>{const shown=view===views.shown;return <Suspense key={view.session.generation} fallback={shown?<p role="status">Opening wallet</p>:null}><Wallet storage={view.session.cache.storage} reload={reload} onShutdown={view.onShutdown} onReady={view.session.onReady} onStartupFailure={view.session.onStartupFailure} animateEntrance={!view.session.resumed} suspended={view.retired} viewActive={shown&&!view.retired} hidden={!shown}/></Suspense>;})}</> | ||
| {session?<>{[views.shown,views.candidate].filter((view):view is NativeWalletView<Session>=>!!view).map(view=>{const shown=view===views.shown;return <Suspense key={view.session.generation} fallback={shown?<p role="status">Opening wallet</p>:null}><Wallet nativeHost storage={view.session.cache.storage} reload={reload} onShutdown={view.onShutdown} onClearWallet={clearWallet} onReady={view.session.onReady} onStartupFailure={view.session.onStartupFailure} animateEntrance={!view.session.resumed} suspended={view.retired} viewActive={shown&&!view.retired} hidden={!shown}/></Suspense>;})}</> | ||
| :<WalletOnboarding transport="tor" setup={setup} busy={busy||switching||saving} phase={phase} error={error} phrase={phrase} torHealth={torHealth} onToggleTor={manualLock.current?undefined:toggleTor} transportBusy={switching||saving} onPhraseChange={setPhrase} onCreate={()=>void create(false)} onRestore={()=>void create(true)} onReconnect={()=>{if(transition.current||creating.current)return;manualLock.current=false;void open();}}/>} | ||
| </>; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.