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 { tryHandleBuiltinCommand } from './builtinCommands'
|
||||||
import { loadingTimerState } from './react/LoadingTimer'
|
import { loadingTimerState } from './react/LoadingTimer'
|
||||||
import { loadPluginsIntoWorld } from './react/CreateWorldProvider'
|
import { loadPluginsIntoWorld } from './react/CreateWorldProvider'
|
||||||
|
import { appStorage } from './react/appStorageProvider'
|
||||||
|
import { selectBestProxy } from './core/proxyAutoSelect'
|
||||||
|
|
||||||
window.debug = debug
|
window.debug = debug
|
||||||
window.beforeRenderFrame = []
|
window.beforeRenderFrame = []
|
||||||
|
|
@ -193,8 +195,6 @@ export async function connect (connectOptions: ConnectOptions) {
|
||||||
const https = connectOptions.proxy.startsWith('https://') || location.protocol === 'https:'
|
const https = connectOptions.proxy.startsWith('https://') || location.protocol === 'https:'
|
||||||
connectOptions.proxy = `${connectOptions.proxy}:${https ? 443 : 80}`
|
connectOptions.proxy = `${connectOptions.proxy}:${https ? 443 : 80}`
|
||||||
}
|
}
|
||||||
const parsedProxy = parseServerAddress(connectOptions.proxy, false)
|
|
||||||
const proxy = { host: parsedProxy.host, port: parsedProxy.port }
|
|
||||||
let { username } = connectOptions
|
let { username } = connectOptions
|
||||||
|
|
||||||
if (connectOptions.server) {
|
if (connectOptions.server) {
|
||||||
|
|
@ -289,6 +289,26 @@ export async function connect (connectOptions: ConnectOptions) {
|
||||||
let clientDataStream: Duplex | undefined
|
let clientDataStream: Duplex | undefined
|
||||||
|
|
||||||
if (connectOptions.server && !connectOptions.viewerWsConnect && !parsedServer.isWebSocket) {
|
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}`)
|
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') ?? ''}` } })
|
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
|
placeholder?: string
|
||||||
containerStyle?: CSSProperties
|
containerStyle?: CSSProperties
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
renderOption?: (option: OptionStorage) => React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ({
|
export default ({
|
||||||
|
|
@ -29,7 +30,8 @@ export default ({
|
||||||
defaultValue,
|
defaultValue,
|
||||||
containerStyle,
|
containerStyle,
|
||||||
placeholder,
|
placeholder,
|
||||||
disabled
|
disabled,
|
||||||
|
renderOption
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [inputValue, setInputValue] = useState<string | undefined>(defaultValue?.label ?? '')
|
const [inputValue, setInputValue] = useState<string | undefined>(defaultValue?.label ?? '')
|
||||||
const [currValue, setCurrValue] = useState<string | undefined>(defaultValue?.label ?? '')
|
const [currValue, setCurrValue] = useState<string | undefined>(defaultValue?.label ?? '')
|
||||||
|
|
@ -46,6 +48,12 @@ export default ({
|
||||||
formatCreateLabel={(value) => {
|
formatCreateLabel={(value) => {
|
||||||
return 'Use "' + value + '"'
|
return 'Use "' + value + '"'
|
||||||
}}
|
}}
|
||||||
|
formatOptionLabel={(option) => {
|
||||||
|
if (renderOption) {
|
||||||
|
return renderOption(option)
|
||||||
|
}
|
||||||
|
return option.label
|
||||||
|
}}
|
||||||
isDisabled={disabled}
|
isDisabled={disabled}
|
||||||
placeholder={placeholder ?? ''}
|
placeholder={placeholder ?? ''}
|
||||||
onChange={(e, action) => {
|
onChange={(e, action) => {
|
||||||
|
|
|
||||||
|
|
@ -196,3 +196,52 @@ export default () => {
|
||||||
</div>
|
</div>
|
||||||
</Screen>
|
</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 { useSnapshot } from 'valtio'
|
||||||
import { miscUiState } from '../globalState'
|
import { miscUiState, showModal } from '../globalState'
|
||||||
import { appQueryParams } from '../appParams'
|
import { appQueryParams } from '../appParams'
|
||||||
|
import { proxyPingState, selectBestProxy } from '../core/proxyAutoSelect'
|
||||||
import Singleplayer from './Singleplayer'
|
import Singleplayer from './Singleplayer'
|
||||||
import Input from './Input'
|
import Input from './Input'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import PixelartIcon, { pixelartIcons } from './PixelartIcon'
|
import PixelartIcon, { pixelartIcons } from './PixelartIcon'
|
||||||
import Select from './Select'
|
import { SimpleSelectOption } from './SelectOption'
|
||||||
import { BaseServerInfo } from './AddServerOrConnect'
|
import { BaseServerInfo } from './AddServerOrConnect'
|
||||||
import { useIsSmallWidth } from './simpleHooks'
|
import { useIsSmallWidth } from './simpleHooks'
|
||||||
import { appStorage, SavedProxiesData, ServerHistoryEntry } from './appStorageProvider'
|
import { appStorage, SavedProxiesData, ServerHistoryEntry } from './appStorageProvider'
|
||||||
|
import { proxiesState } from './ProxiesList'
|
||||||
|
|
||||||
const getInitialProxies = () => {
|
const getInitialProxies = () => {
|
||||||
const proxies = [] as string[]
|
const proxies = [] as string[]
|
||||||
|
|
@ -20,7 +22,11 @@ const getInitialProxies = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getCurrentProxy = (): string | undefined => {
|
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 = () => {
|
export const getCurrentUsername = () => {
|
||||||
|
|
@ -36,6 +42,40 @@ interface Props extends React.ComponentProps<typeof Singleplayer> {
|
||||||
setQuickConnectIp?: (ip: string) => void
|
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 ({
|
export default ({
|
||||||
joinServer,
|
joinServer,
|
||||||
onProfileClick,
|
onProfileClick,
|
||||||
|
|
@ -69,7 +109,7 @@ export default ({
|
||||||
const isSmallWidth = useIsSmallWidth()
|
const isSmallWidth = useIsSmallWidth()
|
||||||
|
|
||||||
const initialProxies = getInitialProxies()
|
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
|
return <Singleplayer
|
||||||
{...props}
|
{...props}
|
||||||
worldData={props.worldData ? props.worldData.map(world => ({
|
worldData={props.worldData ? props.worldData.map(world => ({
|
||||||
|
|
@ -126,15 +166,25 @@ export default ({
|
||||||
{isSmallWidth
|
{isSmallWidth
|
||||||
? <PixelartIcon iconName={pixelartIcons.server} styles={{ fontSize: 14, color: 'lightgray', marginLeft: 2 }} onClick={onProfileClick} />
|
? <PixelartIcon iconName={pixelartIcons.server} styles={{ fontSize: 14, color: 'lightgray', marginLeft: 2 }} onClick={onProfileClick} />
|
||||||
: <span style={{ color: 'lightgray', fontSize: 14 }}>Proxy:</span>}
|
: <span style={{ color: 'lightgray', fontSize: 14 }}>Proxy:</span>}
|
||||||
<Select
|
<SimpleSelectOption
|
||||||
initialOptions={proxiesData.proxies.map(p => { return { value: p, label: p } })}
|
options={[
|
||||||
defaultValue={{ value: proxiesData.selected, label: proxiesData.selected }}
|
{ value: 'auto', label: '🔄 Auto-select' },
|
||||||
updateOptions={(newSel) => {
|
...proxiesData.proxies.map(p => ({ value: p, label: p })),
|
||||||
updateProxies({ proxies: [...proxiesData.proxies], selected: newSel })
|
{ value: 'manage', label: '⚙️ Add/Remove proxy...' }
|
||||||
}}
|
]}
|
||||||
containerStyle={{
|
value={proxiesData.isAutoSelect ? 'auto' : proxiesData.selected}
|
||||||
width: isSmallWidth ? 140 : 180,
|
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} />
|
<PixelartIcon iconName='user' styles={{ fontSize: 14, color: 'lightgray', marginLeft: 2 }} onClick={onProfileClick} />
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ const migrateRemoveLocalStorage = false
|
||||||
export interface SavedProxiesData {
|
export interface SavedProxiesData {
|
||||||
proxies: string[]
|
proxies: string[]
|
||||||
selected: string
|
selected: string
|
||||||
|
isAutoSelect?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerHistoryEntry {
|
export interface ServerHistoryEntry {
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ import CreditsAboutModal from './react/CreditsAboutModal'
|
||||||
import GlobalOverlayHints from './react/GlobalOverlayHints'
|
import GlobalOverlayHints from './react/GlobalOverlayHints'
|
||||||
import FullscreenTime from './react/FullscreenTime'
|
import FullscreenTime from './react/FullscreenTime'
|
||||||
import StorageConflictModal from './react/StorageConflictModal'
|
import StorageConflictModal from './react/StorageConflictModal'
|
||||||
|
import ProxiesList from './react/ProxiesList'
|
||||||
|
|
||||||
const isFirefox = ua.getBrowser().name === 'Firefox'
|
const isFirefox = ua.getBrowser().name === 'Firefox'
|
||||||
if (isFirefox) {
|
if (isFirefox) {
|
||||||
|
|
@ -250,6 +251,7 @@ const App = () => {
|
||||||
<SelectOption />
|
<SelectOption />
|
||||||
<CreditsAboutModal />
|
<CreditsAboutModal />
|
||||||
<NoModalFoundProvider />
|
<NoModalFoundProvider />
|
||||||
|
<ProxiesList />
|
||||||
</RobustPortal>
|
</RobustPortal>
|
||||||
<RobustPortal to={document.body}>
|
<RobustPortal to={document.body}>
|
||||||
<div className='overlay-top-scaled'>
|
<div className='overlay-top-scaled'>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue