Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
Vitaly Turovsky
50acc6d9f3 feat: add proxy auto-select and proxies select menu! 2025-07-08 15:06:22 +03:00
9 changed files with 456 additions and 16 deletions

121
src/core/pingProxy.ts Normal file
View 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)
}
}

View 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
}
}

View file

@ -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
View 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>
)
}

View file

@ -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) => {

View file

@ -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>
)
}

View file

@ -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

View file

@ -15,6 +15,7 @@ const migrateRemoveLocalStorage = false
export interface SavedProxiesData {
proxies: string[]
selected: string
isAutoSelect?: boolean
}
export interface ServerHistoryEntry {

View file

@ -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'>