Compare commits
1 commit
next
...
proxy-auto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50acc6d9f3 |
9 changed files with 456 additions and 16 deletions
121
src/core/pingProxy.ts
Normal file
121
src/core/pingProxy.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
class LatencyMonitor {
|
||||
private ws: WebSocket | null = null
|
||||
private isConnected = false
|
||||
|
||||
constructor (public serverUrl: string) {
|
||||
}
|
||||
|
||||
async connect () {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
// Convert http(s):// to ws(s)://
|
||||
let wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/vm/net/ping'
|
||||
if (!wsUrl.startsWith('ws')) {
|
||||
wsUrl = 'wss://' + wsUrl
|
||||
}
|
||||
this.ws = new WebSocket(wsUrl)
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.isConnected = true
|
||||
resolve()
|
||||
}
|
||||
this.ws.onerror = (error) => {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async measureLatency (): Promise<{
|
||||
roundTripTime: number;
|
||||
serverProcessingTime: number;
|
||||
networkLatency: number;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.isConnected) {
|
||||
reject(new Error('Not connected'))
|
||||
return
|
||||
}
|
||||
|
||||
const pingId = Date.now().toString()
|
||||
const startTime = performance.now()
|
||||
|
||||
const handler = (event: MessageEvent) => {
|
||||
if (typeof event.data === 'string' && event.data.startsWith('pong:')) {
|
||||
const [_, receivedPingId, serverProcessingTime] = event.data.split(':')
|
||||
|
||||
if (receivedPingId === pingId) {
|
||||
this.ws?.removeEventListener('message', handler)
|
||||
const roundTripTime = performance.now() - startTime
|
||||
|
||||
resolve({
|
||||
roundTripTime,
|
||||
serverProcessingTime: parseFloat(serverProcessingTime),
|
||||
networkLatency: roundTripTime - parseFloat(serverProcessingTime)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.ws?.addEventListener('message', handler)
|
||||
this.ws?.send('ping:' + pingId)
|
||||
})
|
||||
}
|
||||
|
||||
disconnect () {
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.isConnected = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function pingProxyServer (serverUrl: string, abortSignal?: AbortSignal) {
|
||||
try {
|
||||
const monitor = new LatencyMonitor(serverUrl)
|
||||
if (abortSignal) {
|
||||
abortSignal.addEventListener('abort', () => {
|
||||
monitor.disconnect()
|
||||
})
|
||||
}
|
||||
|
||||
await monitor.connect()
|
||||
const latency = await monitor.measureLatency()
|
||||
monitor.disconnect()
|
||||
return {
|
||||
success: true,
|
||||
latency: Math.round(latency.networkLatency)
|
||||
}
|
||||
} catch (err) {
|
||||
let msg = String(err)
|
||||
if (err instanceof Event && err.type === 'error') {
|
||||
msg = 'Connection error'
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function monitorLatency () {
|
||||
const monitor = new LatencyMonitor('https://your-server.com')
|
||||
|
||||
try {
|
||||
await monitor.connect()
|
||||
|
||||
// Single measurement
|
||||
const latency = await monitor.measureLatency()
|
||||
|
||||
// Or continuous monitoring
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const latency = await monitor.measureLatency()
|
||||
console.log('Current latency:', latency)
|
||||
} catch (error) {
|
||||
console.error('Error measuring latency:', error)
|
||||
}
|
||||
}, 5000) // Check every 5 seconds
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to connect:', error)
|
||||
}
|
||||
}
|
||||
91
src/core/proxyAutoSelect.ts
Normal file
91
src/core/proxyAutoSelect.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { proxy } from 'valtio'
|
||||
import { appStorage } from '../react/appStorageProvider'
|
||||
import { pingProxyServer } from './pingProxy'
|
||||
|
||||
export interface ProxyPingState {
|
||||
selectedProxy: string | null
|
||||
proxyStatus: Record<string, {
|
||||
status: 'checking' | 'success' | 'error'
|
||||
latency?: number
|
||||
error?: string
|
||||
}>
|
||||
checkStarted: boolean
|
||||
}
|
||||
|
||||
export const proxyPingState = proxy<ProxyPingState>({
|
||||
selectedProxy: null,
|
||||
proxyStatus: {},
|
||||
checkStarted: false
|
||||
})
|
||||
|
||||
let currentPingAbortController: AbortController | null = null
|
||||
|
||||
export async function selectBestProxy (proxies: string[]): Promise<string | null> {
|
||||
if (proxyPingState.checkStarted) {
|
||||
cancelProxyPinging()
|
||||
}
|
||||
proxyPingState.checkStarted = true
|
||||
|
||||
// Cancel any ongoing pings
|
||||
if (currentPingAbortController) {
|
||||
currentPingAbortController.abort()
|
||||
}
|
||||
currentPingAbortController = new AbortController()
|
||||
const abortController = currentPingAbortController // Store in local const to satisfy TypeScript
|
||||
|
||||
// Reset ping states
|
||||
for (const proxy of proxies) {
|
||||
proxyPingState.proxyStatus[proxy] = { status: 'checking' }
|
||||
}
|
||||
|
||||
try {
|
||||
// Create a promise for each proxy
|
||||
const pingPromises = proxies.map(async (proxy) => {
|
||||
if (proxy.startsWith(':')) {
|
||||
proxy = `${location.protocol}//${location.hostname}${proxy}`
|
||||
}
|
||||
try {
|
||||
const result = await pingProxyServer(proxy, abortController.signal)
|
||||
if (result.success) {
|
||||
proxyPingState.proxyStatus[proxy] = { status: 'success', latency: result.latency }
|
||||
return { proxy, latency: result.latency }
|
||||
} else {
|
||||
proxyPingState.proxyStatus[proxy] = { status: 'error', error: result.error }
|
||||
return null
|
||||
}
|
||||
} catch (err) {
|
||||
proxyPingState.proxyStatus[proxy] = { status: 'error', error: String(err) }
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
// Use Promise.race to get the first successful response
|
||||
const results = await Promise.race([
|
||||
// Wait for first successful ping
|
||||
Promise.any(pingPromises.map(async p => p.then(r => r && { type: 'success' as const, data: r }))),
|
||||
// Or wait for all to fail
|
||||
Promise.all(pingPromises).then(results => {
|
||||
if (results.every(r => r === null)) {
|
||||
return { type: 'all-failed' as const }
|
||||
}
|
||||
return null
|
||||
})
|
||||
])
|
||||
|
||||
if (!results || results.type === 'all-failed') {
|
||||
return null
|
||||
}
|
||||
|
||||
return results.type === 'success' ? results.data.proxy : null
|
||||
} finally {
|
||||
currentPingAbortController = null
|
||||
proxyPingState.checkStarted = false
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelProxyPinging () {
|
||||
if (currentPingAbortController) {
|
||||
currentPingAbortController.abort()
|
||||
currentPingAbortController = null
|
||||
}
|
||||
}
|
||||
24
src/index.ts
24
src/index.ts
|
|
@ -97,6 +97,8 @@ import { registerOpenBenchmarkListener } from './benchmark'
|
|||
import { tryHandleBuiltinCommand } from './builtinCommands'
|
||||
import { loadingTimerState } from './react/LoadingTimer'
|
||||
import { loadPluginsIntoWorld } from './react/CreateWorldProvider'
|
||||
import { appStorage } from './react/appStorageProvider'
|
||||
import { selectBestProxy } from './core/proxyAutoSelect'
|
||||
|
||||
window.debug = debug
|
||||
window.beforeRenderFrame = []
|
||||
|
|
@ -193,8 +195,6 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
const https = connectOptions.proxy.startsWith('https://') || location.protocol === 'https:'
|
||||
connectOptions.proxy = `${connectOptions.proxy}:${https ? 443 : 80}`
|
||||
}
|
||||
const parsedProxy = parseServerAddress(connectOptions.proxy, false)
|
||||
const proxy = { host: parsedProxy.host, port: parsedProxy.port }
|
||||
let { username } = connectOptions
|
||||
|
||||
if (connectOptions.server) {
|
||||
|
|
@ -289,6 +289,26 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
let clientDataStream: Duplex | undefined
|
||||
|
||||
if (connectOptions.server && !connectOptions.viewerWsConnect && !parsedServer.isWebSocket) {
|
||||
if (appStorage.proxiesData?.isAutoSelect && appStorage.proxiesData.proxies.length > 0) {
|
||||
setLoadingScreenStatus('Selecting best proxy...')
|
||||
const bestProxy = await selectBestProxy(appStorage.proxiesData.proxies)
|
||||
if (bestProxy) {
|
||||
connectOptions.proxy = bestProxy
|
||||
} else {
|
||||
let message = 'Failed to find a working proxy.'
|
||||
if (navigator.onLine) {
|
||||
message += '\n\nPlease check your internet connection and try again.'
|
||||
} else {
|
||||
message += '\nWe tried these proxies but none of them worked, try opening any of these urls in your browser:'
|
||||
message += `\n${appStorage.proxiesData.proxies.join(', ')}`
|
||||
}
|
||||
setLoadingScreenStatus(message, true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const parsedProxy = parseServerAddress(connectOptions.proxy, false)
|
||||
const proxy = { host: parsedProxy.host, port: parsedProxy.port }
|
||||
console.log(`using proxy ${proxy.host}:${proxy.port || location.port}`)
|
||||
net['setProxy']({ hostname: proxy.host, port: proxy.port, headers: { Authorization: `Bearer ${new URLSearchParams(location.search).get('token') ?? ''}` } })
|
||||
}
|
||||
|
|
|
|||
98
src/react/ProxiesList.tsx
Normal file
98
src/react/ProxiesList.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { useSnapshot, proxy } from 'valtio'
|
||||
import { openURL } from 'renderer/viewer/lib/simpleUtils'
|
||||
import { hideCurrentModal } from '../globalState'
|
||||
import { showInputsModal } from './SelectOption'
|
||||
import Screen from './Screen'
|
||||
import Button from './Button'
|
||||
import { pixelartIcons } from './PixelartIcon'
|
||||
import { useIsModalActive } from './utilsApp'
|
||||
|
||||
// This would be in a separate state file in a real implementation
|
||||
export const proxiesState = proxy({
|
||||
proxies: [] as Array<{ id: string, url: string }>
|
||||
})
|
||||
|
||||
export default () => {
|
||||
const { proxies } = useSnapshot(proxiesState)
|
||||
const isActive = useIsModalActive('proxies')
|
||||
|
||||
if (!isActive) return null
|
||||
|
||||
const addProxy = async () => {
|
||||
const result = await showInputsModal('Add Proxy', {
|
||||
url: {
|
||||
type: 'text',
|
||||
label: 'Proxy URL',
|
||||
placeholder: 'wss://your-proxy.com'
|
||||
}
|
||||
})
|
||||
if (!result) return
|
||||
|
||||
proxiesState.proxies.push({
|
||||
id: Math.random().toString(36).slice(2),
|
||||
url: result.url
|
||||
})
|
||||
}
|
||||
|
||||
const editProxy = async (proxy: { id: string, url: string }) => {
|
||||
const result = await showInputsModal('Edit Proxy', {
|
||||
url: {
|
||||
type: 'text',
|
||||
label: 'Proxy URL',
|
||||
placeholder: 'wss://your-proxy.com',
|
||||
defaultValue: proxy.url
|
||||
}
|
||||
})
|
||||
if (!result) return
|
||||
|
||||
const index = proxiesState.proxies.findIndex(p => p.id === proxy.id)
|
||||
if (index !== -1) {
|
||||
proxiesState.proxies[index].url = result.url
|
||||
}
|
||||
}
|
||||
|
||||
const removeProxy = (id: string) => {
|
||||
proxiesState.proxies = proxiesState.proxies.filter(p => p.id !== id)
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen title="Proxies">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 10 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
{proxies.map(proxy => (
|
||||
<div key={proxy.id} style={{ display: 'flex', alignItems: 'center', gap: 5, backgroundColor: 'rgba(0,0,0,0.5)', padding: 5 }}>
|
||||
<span style={{ flex: 1 }}>{proxy.url}</span>
|
||||
<Button
|
||||
icon={pixelartIcons.edit}
|
||||
style={{ width: 24, height: 24, padding: 0 }}
|
||||
onClick={async () => editProxy(proxy)}
|
||||
/>
|
||||
<Button
|
||||
icon={pixelartIcons.close}
|
||||
style={{ width: 24, height: 24, padding: 0, color: '#ff4444' }}
|
||||
onClick={() => removeProxy(proxy.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<Button
|
||||
style={{ flex: 1 }}
|
||||
onClick={addProxy}
|
||||
>
|
||||
Add Proxy
|
||||
</Button>
|
||||
<span style={{ fontSize: 6, color: '#aaa' }}>
|
||||
Note: You can self-host your own proxy in less than a minute with the script from
|
||||
</span>
|
||||
<Button
|
||||
style={{ fontSize: 6, padding: '2px 4px' }}
|
||||
onClick={() => openURL('https://github.com/zardoy/minecraft-everywhere')}
|
||||
>
|
||||
github.com/zardoy/minecraft-everywhere
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ interface Props {
|
|||
placeholder?: string
|
||||
containerStyle?: CSSProperties
|
||||
disabled?: boolean
|
||||
renderOption?: (option: OptionStorage) => React.ReactNode
|
||||
}
|
||||
|
||||
export default ({
|
||||
|
|
@ -29,7 +30,8 @@ export default ({
|
|||
defaultValue,
|
||||
containerStyle,
|
||||
placeholder,
|
||||
disabled
|
||||
disabled,
|
||||
renderOption
|
||||
}: Props) => {
|
||||
const [inputValue, setInputValue] = useState<string | undefined>(defaultValue?.label ?? '')
|
||||
const [currValue, setCurrValue] = useState<string | undefined>(defaultValue?.label ?? '')
|
||||
|
|
@ -46,6 +48,12 @@ export default ({
|
|||
formatCreateLabel={(value) => {
|
||||
return 'Use "' + value + '"'
|
||||
}}
|
||||
formatOptionLabel={(option) => {
|
||||
if (renderOption) {
|
||||
return renderOption(option)
|
||||
}
|
||||
return option.label
|
||||
}}
|
||||
isDisabled={disabled}
|
||||
placeholder={placeholder ?? ''}
|
||||
onChange={(e, action) => {
|
||||
|
|
|
|||
|
|
@ -196,3 +196,52 @@ export default () => {
|
|||
</div>
|
||||
</Screen>
|
||||
}
|
||||
|
||||
// Simple select option component for basic selection without custom input
|
||||
export const SimpleSelectOption = ({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
onManageClick,
|
||||
manageButtonText = 'Manage...',
|
||||
placeholder = 'Select...'
|
||||
}: {
|
||||
options: Array<{ value: string, label: string }>
|
||||
value?: string
|
||||
onChange: (value: string) => void
|
||||
onManageClick?: () => void
|
||||
manageButtonText?: string
|
||||
placeholder?: string
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 5, alignItems: 'center' }}>
|
||||
<div style={{ position: 'relative', flex: 1, border: '1px solid grey', padding: '3px', backgroundColor: 'black', minHeight: '20px' }}>
|
||||
<div
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
color: value ? 'white' : 'gray',
|
||||
userSelect: 'none'
|
||||
}}
|
||||
onClick={() => {
|
||||
void showOptionsModal(placeholder, options.map(o => o.label)).then(selected => {
|
||||
if (selected) {
|
||||
const option = options.find(o => o.label === selected)
|
||||
if (option) onChange(option.value)
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
{value ? options.find(o => o.value === value)?.label || value : placeholder}
|
||||
</div>
|
||||
</div>
|
||||
{onManageClick && (
|
||||
<Button
|
||||
style={{ padding: '3px 8px' }}
|
||||
onClick={onManageClick}
|
||||
>
|
||||
{manageButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import React, { useMemo } from 'react'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { miscUiState } from '../globalState'
|
||||
import { miscUiState, showModal } from '../globalState'
|
||||
import { appQueryParams } from '../appParams'
|
||||
import { proxyPingState, selectBestProxy } from '../core/proxyAutoSelect'
|
||||
import Singleplayer from './Singleplayer'
|
||||
import Input from './Input'
|
||||
import Button from './Button'
|
||||
import PixelartIcon, { pixelartIcons } from './PixelartIcon'
|
||||
import Select from './Select'
|
||||
import { SimpleSelectOption } from './SelectOption'
|
||||
import { BaseServerInfo } from './AddServerOrConnect'
|
||||
import { useIsSmallWidth } from './simpleHooks'
|
||||
import { appStorage, SavedProxiesData, ServerHistoryEntry } from './appStorageProvider'
|
||||
import { proxiesState } from './ProxiesList'
|
||||
|
||||
const getInitialProxies = () => {
|
||||
const proxies = [] as string[]
|
||||
|
|
@ -20,7 +22,11 @@ const getInitialProxies = () => {
|
|||
}
|
||||
|
||||
export const getCurrentProxy = (): string | undefined => {
|
||||
return appQueryParams.proxy ?? appStorage.proxiesData?.selected ?? getInitialProxies()[0]
|
||||
return appQueryParams.proxy ?? (
|
||||
appStorage.proxiesData?.isAutoSelect
|
||||
? undefined // Let connect function handle auto-select
|
||||
: appStorage.proxiesData?.selected ?? getInitialProxies()[0]
|
||||
)
|
||||
}
|
||||
|
||||
export const getCurrentUsername = () => {
|
||||
|
|
@ -36,6 +42,40 @@ interface Props extends React.ComponentProps<typeof Singleplayer> {
|
|||
setQuickConnectIp?: (ip: string) => void
|
||||
}
|
||||
|
||||
const ProxyPingStatus = ({ proxy }: { proxy: string }) => {
|
||||
const pingState = useSnapshot(proxyPingState).proxyStatus[proxy]
|
||||
useEffect(() => {
|
||||
if (!proxyPingState.checkStarted) {
|
||||
void selectBestProxy(appStorage.proxiesData?.proxies ?? [])
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!pingState) return null
|
||||
|
||||
let color = 'yellow'
|
||||
let text = '...'
|
||||
|
||||
if (pingState.status === 'success') {
|
||||
color = 'limegreen'
|
||||
text = `${pingState.latency}ms`
|
||||
} else if (pingState.status === 'error') {
|
||||
color = 'red'
|
||||
text = 'err'
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginLeft: '4px' }}>
|
||||
<div style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: color
|
||||
}} />
|
||||
<span style={{ fontSize: '12px', color: 'lightgray' }}>{text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ({
|
||||
joinServer,
|
||||
onProfileClick,
|
||||
|
|
@ -69,7 +109,7 @@ export default ({
|
|||
const isSmallWidth = useIsSmallWidth()
|
||||
|
||||
const initialProxies = getInitialProxies()
|
||||
const proxiesData = snap.proxiesData ?? { proxies: initialProxies, selected: initialProxies[0] }
|
||||
const proxiesData = snap.proxiesData ?? { proxies: initialProxies, selected: initialProxies[0], isAutoSelect: false }
|
||||
return <Singleplayer
|
||||
{...props}
|
||||
worldData={props.worldData ? props.worldData.map(world => ({
|
||||
|
|
@ -126,15 +166,25 @@ export default ({
|
|||
{isSmallWidth
|
||||
? <PixelartIcon iconName={pixelartIcons.server} styles={{ fontSize: 14, color: 'lightgray', marginLeft: 2 }} onClick={onProfileClick} />
|
||||
: <span style={{ color: 'lightgray', fontSize: 14 }}>Proxy:</span>}
|
||||
<Select
|
||||
initialOptions={proxiesData.proxies.map(p => { return { value: p, label: p } })}
|
||||
defaultValue={{ value: proxiesData.selected, label: proxiesData.selected }}
|
||||
updateOptions={(newSel) => {
|
||||
updateProxies({ proxies: [...proxiesData.proxies], selected: newSel })
|
||||
}}
|
||||
containerStyle={{
|
||||
width: isSmallWidth ? 140 : 180,
|
||||
<SimpleSelectOption
|
||||
options={[
|
||||
{ value: 'auto', label: '🔄 Auto-select' },
|
||||
...proxiesData.proxies.map(p => ({ value: p, label: p })),
|
||||
{ value: 'manage', label: '⚙️ Add/Remove proxy...' }
|
||||
]}
|
||||
value={proxiesData.isAutoSelect ? 'auto' : proxiesData.selected}
|
||||
onChange={(newSel) => {
|
||||
if (newSel === 'manage') {
|
||||
showModal({ reactType: 'proxies' })
|
||||
return
|
||||
}
|
||||
if (newSel === 'auto') {
|
||||
updateProxies({ proxies: [...proxiesData.proxies], selected: proxiesData.selected, isAutoSelect: true })
|
||||
} else {
|
||||
updateProxies({ proxies: [...proxiesData.proxies], selected: newSel, isAutoSelect: false })
|
||||
}
|
||||
}}
|
||||
placeholder="Select proxy"
|
||||
/>
|
||||
<PixelartIcon iconName='user' styles={{ fontSize: 14, color: 'lightgray', marginLeft: 2 }} onClick={onProfileClick} />
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const migrateRemoveLocalStorage = false
|
|||
export interface SavedProxiesData {
|
||||
proxies: string[]
|
||||
selected: string
|
||||
isAutoSelect?: boolean
|
||||
}
|
||||
|
||||
export interface ServerHistoryEntry {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ import CreditsAboutModal from './react/CreditsAboutModal'
|
|||
import GlobalOverlayHints from './react/GlobalOverlayHints'
|
||||
import FullscreenTime from './react/FullscreenTime'
|
||||
import StorageConflictModal from './react/StorageConflictModal'
|
||||
import ProxiesList from './react/ProxiesList'
|
||||
|
||||
const isFirefox = ua.getBrowser().name === 'Firefox'
|
||||
if (isFirefox) {
|
||||
|
|
@ -250,6 +251,7 @@ const App = () => {
|
|||
<SelectOption />
|
||||
<CreditsAboutModal />
|
||||
<NoModalFoundProvider />
|
||||
<ProxiesList />
|
||||
</RobustPortal>
|
||||
<RobustPortal to={document.body}>
|
||||
<div className='overlay-top-scaled'>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue