diff --git a/src/core/pingProxy.ts b/src/core/pingProxy.ts new file mode 100644 index 00000000..f0b2c13a --- /dev/null +++ b/src/core/pingProxy.ts @@ -0,0 +1,121 @@ +class LatencyMonitor { + private ws: WebSocket | null = null + private isConnected = false + + constructor (public serverUrl: string) { + } + + async connect () { + return new Promise((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) + } +} diff --git a/src/core/proxyAutoSelect.ts b/src/core/proxyAutoSelect.ts new file mode 100644 index 00000000..cf3aeb0d --- /dev/null +++ b/src/core/proxyAutoSelect.ts @@ -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 + checkStarted: boolean +} + +export const proxyPingState = proxy({ + selectedProxy: null, + proxyStatus: {}, + checkStarted: false +}) + +let currentPingAbortController: AbortController | null = null + +export async function selectBestProxy (proxies: string[]): Promise { + 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 + } +} diff --git a/src/index.ts b/src/index.ts index 185caab6..a91190eb 100644 --- a/src/index.ts +++ b/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') ?? ''}` } }) } diff --git a/src/react/ProxiesList.tsx b/src/react/ProxiesList.tsx new file mode 100644 index 00000000..18c5b5e9 --- /dev/null +++ b/src/react/ProxiesList.tsx @@ -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 ( + +
+
+ {proxies.map(proxy => ( +
+ {proxy.url} +
+ ))} +
+
+ + + Note: You can self-host your own proxy in less than a minute with the script from + + +
+
+
+ ) +} diff --git a/src/react/Select.tsx b/src/react/Select.tsx index 8d79fe54..ea081bb1 100644 --- a/src/react/Select.tsx +++ b/src/react/Select.tsx @@ -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(defaultValue?.label ?? '') const [currValue, setCurrValue] = useState(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) => { diff --git a/src/react/SelectOption.tsx b/src/react/SelectOption.tsx index c7b7ab38..6d4c59fa 100644 --- a/src/react/SelectOption.tsx +++ b/src/react/SelectOption.tsx @@ -196,3 +196,52 @@ export default () => { } + +// 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 ( +
+
+
{ + 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} +
+
+ {onManageClick && ( + + )} +
+ ) +} diff --git a/src/react/ServersList.tsx b/src/react/ServersList.tsx index a88f8a59..4b23c184 100644 --- a/src/react/ServersList.tsx +++ b/src/react/ServersList.tsx @@ -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 { 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 ( +
+
+ {text} +
+ ) +} + 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 ({ @@ -126,15 +166,25 @@ export default ({ {isSmallWidth ? : Proxy:} - { +