diff --git a/.eslintrc.json b/.eslintrc.json index 3552f6a7b..1104cfe2d 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -199,7 +199,8 @@ "no-bitwise": "off", "unicorn/filename-case": "off", "max-depth": "off", - "unicorn/no-typeof-undefined": "off" + "unicorn/no-typeof-undefined": "off", + "unicorn/relative-url-style": "off" }, "overrides": [ { diff --git a/renderer/rsbuildSharedConfig.ts b/renderer/rsbuildSharedConfig.ts index 57eea0417..d22d60982 100644 --- a/renderer/rsbuildSharedConfig.ts +++ b/renderer/rsbuildSharedConfig.ts @@ -61,6 +61,16 @@ export const appAndRendererSharedConfig = () => defineConfig({ ], tools: { rspack (config, helpers) { + if (process.env.SINGLE_FILE_BUILD === 'true') { + config.module.rules.push({ + test: /\.worker\.(js|ts)$/, + loader: "worker-rspack-loader", + options: { + inline: "no-fallback", + }, + }) + } + const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8')) const hasFileProtocol = Object.values(packageJson.pnpm.overrides).some((dep) => (dep as string).startsWith('file:')) if (hasFileProtocol) { diff --git a/renderer/viewer/lib/simpleUtils.ts b/renderer/viewer/lib/simpleUtils.ts index 2d0b62554..a9b6790b1 100644 --- a/renderer/viewer/lib/simpleUtils.ts +++ b/renderer/viewer/lib/simpleUtils.ts @@ -33,3 +33,9 @@ export function sectionPos (pos: { x: number, y: number, z: number }) { const z = Math.floor(pos.z / 16) return [x, y, z] } +// doesn't support snapshots + +export const toMajorVersion = version => { + const [a, b] = (String(version)).split('.') + return `${a}.${b}` +} diff --git a/renderer/viewer/lib/workerProxy.ts b/renderer/viewer/lib/workerProxy.ts index 9d8e7fcc0..e27c984f0 100644 --- a/renderer/viewer/lib/workerProxy.ts +++ b/renderer/viewer/lib/workerProxy.ts @@ -1,9 +1,18 @@ -export function createWorkerProxy void>> (handlers: T, channel?: MessagePort): { __workerProxy: T } { +export function createWorkerProxy void | Promise>> (handlers: T, channel?: MessagePort): { __workerProxy: T } { const target = channel ?? globalThis target.addEventListener('message', (event: any) => { - const { type, args } = event.data + const { type, args, msgId } = event.data if (handlers[type]) { - handlers[type](...args) + const result = handlers[type](...args) + if (result instanceof Promise) { + void result.then((result) => { + target.postMessage({ + type: 'result', + msgId, + args: [result] + }) + }) + } } }) return null as any @@ -23,6 +32,7 @@ export function createWorkerProxy v export const useWorkerProxy = void> }> (worker: Worker | MessagePort, autoTransfer = true): T['__workerProxy'] & { transfer: (...args: Transferable[]) => T['__workerProxy'] } => { + let messageId = 0 // in main thread return new Proxy({} as any, { get (target, prop) { @@ -41,11 +51,25 @@ export const useWorkerProxy = { + const msgId = messageId++ const transfer = autoTransfer ? args.filter(arg => arg instanceof ArrayBuffer || arg instanceof MessagePort || arg instanceof ImageBitmap || arg instanceof OffscreenCanvas || arg instanceof ImageData) : [] worker.postMessage({ type: prop, + msgId, args, }, transfer as any[]) + return { + // eslint-disable-next-line unicorn/no-thenable + then (onfulfilled: (value: any) => void) { + const handler = ({ data }: MessageEvent): void => { + if (data.type === 'result' && data.msgId === msgId) { + onfulfilled(data.args[0]) + worker.removeEventListener('message', handler as EventListener) + } + } + worker.addEventListener('message', handler as EventListener) + } + } } } }) diff --git a/renderer/viewer/lib/worldrendererCommon.ts b/renderer/viewer/lib/worldrendererCommon.ts index 8454ed16e..366a27889 100644 --- a/renderer/viewer/lib/worldrendererCommon.ts +++ b/renderer/viewer/lib/worldrendererCommon.ts @@ -9,13 +9,12 @@ import { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider' import { generateSpiralMatrix } from 'flying-squid/dist/utils' import { subscribeKey } from 'valtio/utils' import { dynamicMcDataFiles } from '../../buildMesherConfig.mjs' -import { toMajorVersion } from '../../../src/utils' import { ResourcesManager } from '../../../src/resourcesManager' import { DisplayWorldOptions, GraphicsInitOptions, RendererReactiveState } from '../../../src/appViewer' import { SoundSystem } from '../three/threeJsSound' import { buildCleanupDecorator } from './cleanupDecorator' import { HighestBlockInfo, MesherGeometryOutput, CustomBlockModels, BlockStateModelInfo, getBlockAssetsCacheKey, MesherConfig } from './mesher/shared' -import { chunkPos } from './simpleUtils' +import { chunkPos, toMajorVersion } from './simpleUtils' import { addNewStat, removeAllStats, removeStat, updatePanesVisibility, updateStatText } from './ui/newStats' import { WorldDataEmitter } from './worldDataEmitter' import { IPlayerState } from './basePlayerState' diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 36ccb9b0c..4863bbf67 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -208,6 +208,13 @@ const appConfig = defineConfig({ if (singleBuildFiles.length !== 1 || singleBuildFiles[0] !== 'index.html') { throw new Error('Single file build must only have index.html in the dist/single folder. Ensure workers are imported & built correctly.') } + // check if dist/static/js/async is empty + if (fs.existsSync('./dist/static/js/async')) { + const asyncFiles = fs.readdirSync('./dist/static/js/async') + if (asyncFiles.length > 0) { + throw new Error('dist/static/js/async must be empty. Ensure workers are imported & built correctly.') + } + } // process index.html const singleBuildHtml = './dist/single/index.html' @@ -224,8 +231,9 @@ const appConfig = defineConfig({ // write output file size console.log('single file size', (fs.statSync(singleBuildHtml).size / 1024 / 1024).toFixed(2), 'mb') } else { + patchWorkerImport() if (!disableServiceWorker) { - const { count, size, warnings } = await generateSW({ + const { count, size, warnings } = await generateSW({ // dontCacheBustURLsMatching: [new RegExp('...')], globDirectory: 'dist', skipWaiting: true, @@ -254,3 +262,21 @@ export default mergeRsbuildConfig( appAndRendererSharedConfig(), appConfig ) + +const patchWorkerImport = () => { + const workerFiles = fs.readdirSync('./dist/static/js/async').filter(x => x.endsWith('.js')) + let patched = false + for (const file of workerFiles) { + const filePath = `./dist/static/js/async/${file}` + const content = fs.readFileSync(filePath, 'utf8') + const matches = content.match(/importScripts\([^)]+\)/g) || [] + if (matches.length > 1) throw new Error('Multiple importScripts found in ' + filePath) + const newContent = content.replace(/importScripts\(\w+\.\w+/, + "importScripts(location.pathname.split('/').slice(0, -4).join('/')+'/'") + if (newContent !== content) { + fs.writeFileSync(filePath, newContent, 'utf8') + patched = true + } + } + if (!patched) throw new Error('No importScripts found in any worker files') +} diff --git a/src/index.ts b/src/index.ts index 3da14257a..eeacc9298 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,8 +17,6 @@ import './mineflayer/timers' import { getServerInfo } from './mineflayer/mc-protocol' import { onGameLoad } from './inventoryWindows' import initCollisionShapes from './getCollisionInteractionShapes' -import protocolMicrosoftAuth from 'minecraft-protocol/src/client/microsoftAuth' -import microsoftAuthflow from './microsoftAuthflow' import { Duplex } from 'stream' import './scaleInterface' @@ -75,7 +73,7 @@ import { saveToBrowserMemory } from './react/PauseScreen' import './devReload' import './water' import { ConnectOptions, loadMinecraftData, getVersionAutoSelect, downloadOtherGameData, downloadAllMinecraftData } from './connect' -import { ref, subscribe } from 'valtio' +import { subscribe } from 'valtio' import { signInMessageState } from './react/SignInMessageProvider' import { updateAuthenticatedAccountData, updateLoadedServerData, updateServerConnectionHistory } from './react/serversStorage' import packetsPatcher from './mineflayer/plugins/packetsPatcher' @@ -97,6 +95,7 @@ import { createConsoleLogProgressReporter, createFullScreenProgressReporter, Pro import { appViewer } from './appViewer' import './appViewerLoad' import { registerOpenBenchmarkListener } from './benchmark' +import { getProtocolClientGetter } from './protocolWorker/protocolMain' import { tryHandleBuiltinCommand } from './builtinCommands' window.debug = debug @@ -162,11 +161,6 @@ export async function connect (connectOptions: ConnectOptions) { } }) } - if (sessionStorage.delayLoadUntilClick) { - await new Promise(resolve => { - window.addEventListener('click', resolve) - }) - } miscUiState.hasErrors = false lastConnectOptions.value = connectOptions @@ -184,8 +178,8 @@ export async function connect (connectOptions: ConnectOptions) { const { renderDistance: renderDistanceSingleplayer, multiplayerRenderDistance } = options - const parsedServer = parseServerAddress(connectOptions.server) - const server = { host: parsedServer.host, port: parsedServer.port } + const serverParsed = parseServerAddress(connectOptions.server) + const server = { host: serverParsed.host, port: serverParsed.port } if (connectOptions.proxy?.startsWith(':')) { connectOptions.proxy = `${location.protocol}//${location.hostname}${connectOptions.proxy}` } @@ -193,12 +187,12 @@ 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 } + const proxyParsed = parseServerAddress(connectOptions.proxy, false) + const proxy = { host: proxyParsed.host, port: proxyParsed.port } let { username } = connectOptions if (connectOptions.server) { - console.log(`connecting to ${server.host}:${server.port ?? 25_565}`) + console.log(`connecting to ${serverParsed.serverIpFull}`) } console.log('using player username', username) @@ -242,14 +236,13 @@ export async function connect (connectOptions: ConnectOptions) { }) } } - let lastPacket = undefined as string | undefined + const lastPacket = undefined as string | undefined const onPossibleErrorDisconnect = () => { if (lastPacket && bot?._client && bot._client.state !== states.PLAY) { appStatusState.descriptionHint = `Last Server Packet: ${lastPacket}` } } const handleError = (err) => { - console.error(err) if (err === 'ResizeObserver loop completed with undelivered notifications.') { return } @@ -287,16 +280,14 @@ export async function connect (connectOptions: ConnectOptions) { let clientDataStream: Duplex | undefined - if (connectOptions.server && !connectOptions.viewerWsConnect && !parsedServer.isWebSocket) { + if (connectOptions.server && !connectOptions.viewerWsConnect && !serverParsed.isWebSocket) { 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') ?? ''}` } }) } const renderDistance = singleplayer ? renderDistanceSingleplayer : multiplayerRenderDistance - let updateDataAfterJoin = () => { } let localServer let localReplaySession: ReturnType | undefined - let lastKnownKickReason = undefined as string | undefined try { const serverOptions = defaultsDeep({}, connectOptions.serverOverrides ?? {}, options.localServerOptions, defaultServerOptions) Object.assign(serverOptions, connectOptions.serverOverridesFlat ?? {}) @@ -415,30 +406,12 @@ export async function connect (connectOptions: ConnectOptions) { } setLoadingScreenStatus(initialLoadingText) - if (parsedServer.isWebSocket) { - clientDataStream = (await getWebsocketStream(server.host)).mineflayerStream - } - - let newTokensCacheResult = null as any - const cachedTokens = typeof connectOptions.authenticatedAccount === 'object' ? connectOptions.authenticatedAccount.cachedTokens : {} - const authData = connectOptions.authenticatedAccount ? await microsoftAuthflow({ - tokenCaches: cachedTokens, - proxyBaseUrl: connectOptions.proxy, - setProgressText (text) { - setLoadingScreenStatus(text) - }, - setCacheResult (result) { - newTokensCacheResult = result - }, - connectingServer: server.host - }) : undefined - if (p2pMultiplayer) { clientDataStream = await connectToPeer(connectOptions.peerId!, connectOptions.peerOptions) } if (connectOptions.viewerWsConnect) { const { version, time, requiresPass } = await getViewerVersionData(connectOptions.viewerWsConnect) - let password + let password: string | null = null if (requiresPass) { password = prompt('Enter password') if (!password) { @@ -463,6 +436,8 @@ export async function connect (connectOptions: ConnectOptions) { } const brand = clientDataStream ? 'minecraft-web-client' : undefined + const createClient = await getProtocolClientGetter(proxy, connectOptions, serverParsed) + bot = mineflayer.createBot({ host: server.host, port: server.port ? +server.port : undefined, @@ -483,53 +458,13 @@ export async function connect (connectOptions: ConnectOptions) { connect () { }, Client: CustomChannelClient as any, } : {}, - onMsaCode (data) { - signInMessageState.code = data.user_code - signInMessageState.link = data.verification_uri - signInMessageState.expiresOn = Date.now() + data.expires_in * 1000 - }, - sessionServer: authData?.sessionEndpoint?.toString(), - auth: connectOptions.authenticatedAccount ? async (client, options) => { - authData!.setOnMsaCodeCallback(options.onMsaCode) - authData?.setConnectingVersion(client.version) - //@ts-expect-error - client.authflow = authData!.authFlow - try { - signInMessageState.abortController = ref(new AbortController()) - await Promise.race([ - protocolMicrosoftAuth.authenticate(client, options), - new Promise((_r, reject) => { - signInMessageState.abortController.signal.addEventListener('abort', () => { - reject(new UserError('Aborted by user')) - }) - }) - ]) - if (signInMessageState.shouldSaveToken) { - updateAuthenticatedAccountData(accounts => { - const existingAccount = accounts.find(a => a.username === client.username) - if (existingAccount) { - existingAccount.cachedTokens = { ...existingAccount.cachedTokens, ...newTokensCacheResult } - } else { - accounts.push({ - username: client.username, - cachedTokens: { ...cachedTokens, ...newTokensCacheResult } - }) - } - return accounts - }) - updateDataAfterJoin = () => { - updateLoadedServerData(s => ({ ...s, authenticatedAccountOverride: client.username }), connectOptions.serverIndex) - } - } else { - updateDataAfterJoin = () => { - updateLoadedServerData(s => ({ ...s, authenticatedAccountOverride: undefined }), connectOptions.serverIndex) - } - } - setLoadingScreenStatus('Authentication successful. Logging in to server') - } finally { - signInMessageState.code = '' + get client () { + if (clientDataStream || singleplayer || p2pMultiplayer || localReplaySession || connectOptions.viewerWsConnect || (!options.protocolWorkerOptimisation && !serverParsed.isWebSocket)) { + return undefined } - } : undefined, + return createClient.call(this) + }, + // auth: connectOptions.authenticatedAccount ? : undefined, username, viewDistance: renderDistance, checkTimeoutInterval: 240 * 1000, @@ -562,50 +497,6 @@ export async function connect (connectOptions: ConnectOptions) { } else if (clientDataStream) { // bot.emit('inject_allowed') bot._client.emit('connect') - } else { - const setupConnectHandlers = () => { - Socket.prototype['handleStringMessage'] = function (message: string) { - if (message.startsWith('proxy-message') || message.startsWith('proxy-command:')) { // for future - return false - } - if (message.startsWith('proxy-shutdown:')) { - lastKnownKickReason = message.slice('proxy-shutdown:'.length) - return false - } - return true - } - bot._client.socket.on('connect', () => { - console.log('Proxy WebSocket connection established') - //@ts-expect-error - bot._client.socket._ws.addEventListener('close', () => { - console.log('WebSocket connection closed') - setTimeout(() => { - if (bot) { - bot.emit('end', 'WebSocket connection closed with unknown reason') - } - }, 1000) - }) - bot._client.socket.on('close', () => { - setTimeout(() => { - if (bot) { - bot.emit('end', 'WebSocket connection closed with unknown reason') - } - }) - }) - }) - } - // socket setup actually can be delayed because of dns lookup - if (bot._client.socket) { - setupConnectHandlers() - } else { - const originalSetSocket = bot._client.setSocket.bind(bot._client) - bot._client.setSocket = (socket) => { - if (!bot) return - originalSetSocket(socket) - setupConnectHandlers() - } - } - } } catch (err) { handleError(err) @@ -641,7 +532,7 @@ export async function connect (connectOptions: ConnectOptions) { }) const packetBeforePlay = (_, __, ___, fullBuffer) => { - lastPacket = fullBuffer.toString() + // lastPacket = fullBuffer.toString() } bot._client.on('packet', packetBeforePlay as any) const playStateSwitch = (newState) => { @@ -654,9 +545,6 @@ export async function connect (connectOptions: ConnectOptions) { bot.on('end', (endReason) => { if (ended) return console.log('disconnected for', endReason) - if (endReason === 'socketClosed') { - endReason = lastKnownKickReason ?? 'Connection with proxy server lost' - } // close all modals for (const modal of activeModalStack) { hideModal(modal) @@ -732,7 +620,6 @@ export async function connect (connectOptions: ConnectOptions) { localStorage.removeItem('lastConnectOptions') } connectOptions.onSuccessfulPlay?.() - updateDataAfterJoin() if (connectOptions.autoLoginPassword) { setTimeout(() => { bot.chat(`/login ${connectOptions.autoLoginPassword}`) diff --git a/src/mineflayer/mc-protocol.ts b/src/mineflayer/mc-protocol.ts index 63a90fa46..9736732d3 100644 --- a/src/mineflayer/mc-protocol.ts +++ b/src/mineflayer/mc-protocol.ts @@ -7,16 +7,10 @@ import { getWebsocketStream } from './websocket-core' let lastPacketTime = 0 customEvents.on('mineflayerBotCreated', () => { - // todo move more code here - if (!appQueryParams.noPacketsValidation) { - (bot._client as unknown as Client).on('packet', (data, packetMeta, buffer, fullBuffer) => { - validatePacket(packetMeta.name, data, fullBuffer, true) - lastPacketTime = performance.now() - }); - (bot._client as unknown as Client).on('writePacket', (name, params) => { - validatePacket(name, params, Buffer.alloc(0), false) - }) - } + (bot._client as unknown as Client).on('packet', (data, packetMeta, buffer, fullBuffer) => { + lastPacketTime = performance.now() + }) + }) setInterval(() => { diff --git a/src/mineflayer/plugins/ping.ts b/src/mineflayer/plugins/ping.ts index 9753e4ed4..ad66446b1 100644 --- a/src/mineflayer/plugins/ping.ts +++ b/src/mineflayer/plugins/ping.ts @@ -1,9 +1,15 @@ import { versionToNumber } from 'renderer/viewer/common/utils' +import { getProtocolWorkerChannel } from '../../protocolWorker/protocolMain' export default () => { let i = 0 bot.pingProxy = async () => { const curI = ++i + if (bot && (!bot._client as any)._ws) { + const result = await getProtocolWorkerChannel()?.pingProxy(curI) + return result ?? -1 + } + return new Promise(resolve => { //@ts-expect-error bot._client.socket._ws.send(`ping:${curI}`) diff --git a/src/optionsStorage.ts b/src/optionsStorage.ts index c8e6298e4..9c01f4768 100644 --- a/src/optionsStorage.ts +++ b/src/optionsStorage.ts @@ -66,6 +66,7 @@ const defaultOptions = { jeiEnabled: true as boolean | Array<'creative' | 'survival' | 'adventure' | 'spectator'>, preventBackgroundTimeoutKick: false, preventSleep: false, + protocolWorkerOptimisation: true, debugContro: false, // antiAliasing: false, diff --git a/src/microsoftAuthflow.ts b/src/protocolWorker/microsoftAuthflow.ts similarity index 58% rename from src/microsoftAuthflow.ts rename to src/protocolWorker/microsoftAuthflow.ts index 00f4e6754..e3ee4f2bd 100644 --- a/src/microsoftAuthflow.ts +++ b/src/protocolWorker/microsoftAuthflow.ts @@ -1,3 +1,10 @@ +import { ref } from 'valtio' +import { signInMessageState } from '../react/SignInMessageProvider' +import { updateAuthenticatedAccountData, updateLoadedServerData } from '../react/serversStorage' +import { setLoadingScreenStatus } from '../appStatus' +import { ConnectOptions } from '../connect' +import { showNotification } from '../react/NotificationProvider' + export const getProxyDetails = async (proxyBaseUrl: string) => { if (!proxyBaseUrl.startsWith('http')) proxyBaseUrl = `${isPageSecure() ? 'https' : 'http'}://${proxyBaseUrl}` const url = `${proxyBaseUrl}/api/vm/net/connect` @@ -10,13 +17,14 @@ export const getProxyDetails = async (proxyBaseUrl: string) => { return result } -export default async ({ tokenCaches, proxyBaseUrl, setProgressText = (text) => { }, setCacheResult, connectingServer }) => { +export const getAuthData = async ({ tokenCaches, proxyBaseUrl, setProgressText = (text) => { }, connectingServer }) => { let onMsaCodeCallback let connectingVersion = '' // const authEndpoint = 'http://localhost:3000/' // const sessionEndpoint = 'http://localhost:3000/session' let authEndpoint: URL | undefined let sessionEndpoint: URL | undefined + let newTokensCacheResult = null as any const result = await getProxyDetails(proxyBaseUrl) try { @@ -32,7 +40,7 @@ export default async ({ tokenCaches, proxyBaseUrl, setProgressText = (text) => { async getMinecraftJavaToken () { setProgressText('Authenticating with Microsoft account') if (!window.crypto && !isPageSecure()) throw new Error('Crypto API is available only in secure contexts. Be sure to use https!') - let result = null + let result = null as any await fetch(authEndpoint, { method: 'POST', headers: { @@ -73,7 +81,7 @@ export default async ({ tokenCaches, proxyBaseUrl, setProgressText = (text) => { } if (json.error) throw new Error(json.error) if (json.token) result = json - if (json.newCache) setCacheResult(json.newCache) + if (json.newCache) newTokensCacheResult = json.newCache } const strings = decoder.decode(value) @@ -86,11 +94,7 @@ export default async ({ tokenCaches, proxyBaseUrl, setProgressText = (text) => { } return reader.read().then(processText) }) - const restoredData = await restoreData(result) - if (restoredData?.certificates?.profileKeys?.privatePEM) { - restoredData.certificates.profileKeys.private = restoredData.certificates.profileKeys.privatePEM - } - return restoredData + return result } } return { @@ -101,75 +105,66 @@ export default async ({ tokenCaches, proxyBaseUrl, setProgressText = (text) => { }, setConnectingVersion (version) { connectingVersion = version + }, + get newTokensCacheResult () { + return newTokensCacheResult } } } -function isPageSecure (url = window.location.href) { - return !url.startsWith('http:') -} - -// restore dates from strings -const restoreData = async (json) => { - const promises = [] as Array> - if (typeof json === 'object' && json) { - for (const [key, value] of Object.entries(json)) { - if (typeof value === 'string') { - promises.push(tryRestorePublicKey(value, key, json)) - if (value.endsWith('Z')) { - const date = new Date(value) - if (!isNaN(date.getTime())) { - json[key] = date - } - } +export const authFlowMainThread = async (worker: Worker, authData: Awaited>, connectOptions: ConnectOptions, setActionAfterJoin: (action: () => void) => void) => { + const cachedTokens = typeof connectOptions.authenticatedAccount === 'object' ? connectOptions.authenticatedAccount.cachedTokens : {} + signInMessageState.abortController = ref(new AbortController()) + await new Promise(resolve => { + worker.addEventListener('message', ({ data }) => { + if (data.type === 'authFlow') { + authData.setConnectingVersion(data.version) + resolve() } - if (typeof value === 'object') { - // eslint-disable-next-line no-await-in-loop - await restoreData(value) - } - } - } - - await Promise.all(promises) + }) + }) - return json -} + authData.setOnMsaCodeCallback((codeData) => { + signInMessageState.code = codeData.user_code + signInMessageState.link = codeData.verification_uri + signInMessageState.expiresOn = Date.now() + codeData.expires_in * 1000 + }) -const tryRestorePublicKey = async (value: string, name: string, parent: { [x: string]: any }) => { - value = value.trim() - if (!name.endsWith('PEM') || !value.startsWith('-----BEGIN RSA PUBLIC KEY-----') || !value.endsWith('-----END RSA PUBLIC KEY-----')) return - const der = pemToArrayBuffer(value) - const key = await window.crypto.subtle.importKey( - 'spki', // Specify that the data is in SPKI format - der, - { - name: 'RSA-OAEP', - hash: { name: 'SHA-256' } - }, - true, - ['encrypt'] // Specify key usages - ) - const originalName = name.replace('PEM', '') - const exported = await window.crypto.subtle.exportKey('spki', key) - const exportedBuffer = new Uint8Array(exported) - parent[originalName] = { - export () { - return exportedBuffer - } + const data = await authData.authFlow.getMinecraftJavaToken() + signInMessageState.code = '' + if (!data) return + const username = data.profile.name + if (signInMessageState.shouldSaveToken) { + updateAuthenticatedAccountData(accounts => { + const existingAccount = accounts.find(a => a.username === username) + if (existingAccount) { + existingAccount.cachedTokens = { ...existingAccount.cachedTokens, ...authData.newTokensCacheResult } + } else { + accounts.push({ + username, + cachedTokens: { ...cachedTokens, ...authData.newTokensCacheResult } + }) + } + showNotification(`Account ${username} saved`) + return accounts + }) + setActionAfterJoin(() => { + updateLoadedServerData(s => ({ ...s, authenticatedAccountOverride: username }), connectOptions.serverIndex) + }) + } else { + setActionAfterJoin(() => { + updateLoadedServerData(s => ({ ...s, authenticatedAccountOverride: undefined }), connectOptions.serverIndex) + }) } + worker.postMessage({ + type: 'authflowResult', + data + }) + setLoadingScreenStatus('Authentication successful. Logging in to server') } -function pemToArrayBuffer (pem) { - // Fetch the part of the PEM string between header and footer - const pemHeader = '-----BEGIN RSA PUBLIC KEY-----' - const pemFooter = '-----END RSA PUBLIC KEY-----' - const pemContents = pem.slice(pemHeader.length, pem.length - pemFooter.length).trim() - const binaryDerString = atob(pemContents.replaceAll(/\s/g, '')) - const binaryDer = new Uint8Array(binaryDerString.length) - for (let i = 0; i < binaryDerString.length; i++) { - binaryDer[i] = binaryDerString.codePointAt(i)! - } - return binaryDer.buffer +function isPageSecure (url = window.location.href) { + return !url.startsWith('http:') } const urlWithBase = (url: string, base: string) => { diff --git a/src/protocolWorker/protocol.worker.ts b/src/protocolWorker/protocol.worker.ts new file mode 100644 index 000000000..90abf44cf --- /dev/null +++ b/src/protocolWorker/protocol.worker.ts @@ -0,0 +1,320 @@ +/* eslint-disable no-restricted-globals */ +import './protocolWorkerGlobals' +import * as net from 'net' +import EventEmitter from 'events' +import { Duplex } from 'stream' +import { Client, createClient } from 'minecraft-protocol' +import protocolMicrosoftAuth from 'minecraft-protocol/src/client/microsoftAuth' +import { createWorkerProxy } from 'renderer/viewer/lib/workerProxy' +import { validatePacket } from '../mineflayer/minecraft-protocol-extra' +import { getWebsocketStream } from '../mineflayer/websocket-core' + +// This is a Web Worker for handling minecraft connection: protocol packet serialization/deserialization + +// TODO: use another strategy by sending all events instead +const REDIRECT_EVENTS = ['connection', 'listening', 'playerJoin', 'connect_allowed', 'connect'] +const REIDRECT_EVENTS_WITH_ARGS = ['end', 'playerChat', 'systemChat', 'state'] +const ENABLE_TRANSFER = false + +const emitEvent = (event: string, ...args: any[]) => { + const transfer = ENABLE_TRANSFER ? args.filter(arg => arg instanceof ArrayBuffer || arg instanceof MessagePort || arg instanceof ImageBitmap || arg instanceof OffscreenCanvas || arg instanceof ImageData) : [] + self.postMessage({ type: 'event', event, args }, transfer as any) +} +let client: Client +const registeredChannels = [] as string[] +let skipWriteLog = false + +type ProtocolWorkerInitOptions = { + options: any + noPacketsValidation: boolean + useAuthFlow: boolean + isWebSocket: boolean +} + +let clientCreationPromise: Promise | undefined +let lastKnownKickReason: string | undefined +export const PROXY_WORKER_TYPE = createWorkerProxy({ + setProxy (data: { hostname: string, port: number | undefined }) { + console.log('[protocolWorker] using proxy', data) + net['setProxy']({ + hostname: data.hostname, + port: data.port + }) + }, + async init ({ options, noPacketsValidation, useAuthFlow, isWebSocket }: ProtocolWorkerInitOptions) { + if (client) throw new Error('Client already initialized') + const withResolvers = Promise.withResolvers() + clientCreationPromise = withResolvers.promise + + // let stream: Duplex | undefined + if (isWebSocket) { + options.stream = (await getWebsocketStream(options.host)).mineflayerStream + } + + await globalThis._LOAD_MC_DATA() + if (useAuthFlow) { + options.auth = authFlowWorkerThread + } + client = createClient(options) + + for (const event of REDIRECT_EVENTS) { + client.on(event, () => { + emitEvent(event) + }) + } + + for (const event of REIDRECT_EVENTS_WITH_ARGS) { + // eslint-disable-next-line @typescript-eslint/no-loop-func + client.on(event, (...args) => { + if (event === 'end') { + if (args[0] === 'socketClosed') { + args[0] = lastKnownKickReason || 'Connection with proxy server has been lost' + } + } + emitEvent(event, ...args) + }) + } + + const oldWrite = client.write + client.write = (...args) => { + if (!skipWriteLog) { + emitEvent('writePacket', ...args) + } + return oldWrite.apply(client, args) + } + + client.on('packet', (data, packetMeta, buffer, fullBuffer) => { + if (window.stopPacketsProcessing) return + if (!noPacketsValidation) { + validatePacket(packetMeta.name, data, fullBuffer, true) + } + emitEvent('packet', data, packetMeta, {}, { byteLength: fullBuffer.byteLength }) + }) + + if (isWebSocket) { + client.emit('connect') + } + + wrapClientSocket(client) + setupPropertiesSync(client) + withResolvers.resolve() + debugAnalyzeNeededProperties(client) + clientCreationPromise = undefined + }, + call (data: { name: string, args: any[] }) { + // ignore sending back data + const inner = async () => { + await clientCreationPromise + if (data.name === 'write') { + skipWriteLog = true + } + client[data.name].bind(client)(...data.args) + + if (data.name === 'registerChannel' && !registeredChannels.includes(data.args[0])) { + client.on(data.args[0], (...args: any[]) => { + emitEvent(data.args[0], ...args) + }) + registeredChannels.push(data.args[0]) + } + } + void inner() + }, + + async pingProxy (number: number) { + return new Promise((resolve) => { + (client.socket as any)._ws.send(`ping:${number}`) + const date = Date.now() + const onPong = (received) => { + if (received !== number.toString()) return + client.socket.off('pong' as any, onPong) + resolve(Date.now() - date) + } + client.socket.on('pong' as any, onPong) + }) + } +}) + +const authFlowWorkerThread = async (client, options) => { + self.postMessage({ + type: 'authFlow', + version: client.version, + username: client.username + }) + options.onMsaCode = (data) => { + self.postMessage({ + type: 'msaCode', + data + }) + } + + client.authflow = { + async getMinecraftJavaToken () { + return new Promise(resolve => { + self.addEventListener('message', async (e) => { + if (e.data.type === 'authflowResult') { + const restoredData = await restoreData(e.data.data) + if (restoredData?.certificates?.profileKeys?.privatePEM) { + restoredData.certificates.profileKeys.private = restoredData.certificates.profileKeys.privatePEM + } + resolve(restoredData) + } + }) + }) + } + } + await Promise.race([ + protocolMicrosoftAuth.authenticate(client, options), + // new Promise((_r, reject) => { + // signInMessageState.abortController.signal.addEventListener('abort', () => { + // reject(new UserError('Aborted by user')) + // }) + // }) + ]) +} + +// restore dates from strings +const restoreData = async (json) => { + const promises = [] as Array> + if (typeof json === 'object' && json) { + for (const [key, value] of Object.entries(json)) { + if (typeof value === 'string') { + promises.push(tryRestorePublicKey(value, key, json)) + if (value.endsWith('Z')) { + const date = new Date(value) + if (!isNaN(date.getTime())) { + json[key] = date + } + } + } + if (typeof value === 'object') { + // eslint-disable-next-line no-await-in-loop + await restoreData(value) + } + } + } + + await Promise.all(promises) + + return json +} + +const tryRestorePublicKey = async (value: string, name: string, parent: { [x: string]: any }) => { + value = value.trim() + if (!name.endsWith('PEM') || !value.startsWith('-----BEGIN RSA PUBLIC KEY-----') || !value.endsWith('-----END RSA PUBLIC KEY-----')) return + const der = pemToArrayBuffer(value) + const key = await window.crypto.subtle.importKey( + 'spki', // Specify that the data is in SPKI format + der, + { + name: 'RSA-OAEP', + hash: { name: 'SHA-256' } + }, + true, + ['encrypt'] // Specify key usages + ) + const originalName = name.replace('PEM', '') + const exported = await window.crypto.subtle.exportKey('spki', key) + const exportedBuffer = new Uint8Array(exported) + parent[originalName] = { + export () { + return exportedBuffer + } + } +} + +function pemToArrayBuffer (pem) { + // Fetch the part of the PEM string between header and footer + const pemHeader = '-----BEGIN RSA PUBLIC KEY-----' + const pemFooter = '-----END RSA PUBLIC KEY-----' + const pemContents = pem.slice(pemHeader.length, pem.length - pemFooter.length).trim() + const binaryDerString = atob(pemContents.replaceAll(/\s/g, '')) + const binaryDer = new Uint8Array(binaryDerString.length) + for (let i = 0; i < binaryDerString.length; i++) { + binaryDer[i] = binaryDerString.codePointAt(i)! + } + return binaryDer.buffer +} + +const syncProperties = [ + 'version', + 'username', + 'uuid', + 'ended', + 'latency', + 'isServer' +] + +const setupPropertiesSync = (obj) => { + sendProperties(obj, syncProperties) +} + +const sendProperties = (obj: any, properties: string[]) => { + try { + const sendObj = {} + for (const property of properties) { + sendObj[property] = obj[property] + } + self.postMessage({ type: 'properties', properties: sendObj }) + } catch (err) { + // fallback to individual property send + for (const property of properties) { + try { + self.postMessage({ type: 'properties', properties: { [property]: obj[property] } }) + } catch (err) { + console.error('Failed to sync property (from worker)', property, err) + } + } + } +} + +const expectedProperties = new Set([ + 'version', +]) + +const debugAnalyzeNeededProperties = (obj) => { + const dummyEventEmitter = new EventEmitter() + const dummyEventEmitterPrototype = Object.getPrototypeOf(dummyEventEmitter) + const redundantProperties = Object.getOwnPropertyNames(obj).filter(property => !expectedProperties.has(property) && !(property in dummyEventEmitterPrototype)) + // console.log('redundantProperties', redundantProperties) +} + +const wrapClientSocket = (client: Client) => { + const setupConnectHandlers = () => { + net.Socket.prototype['handleStringMessage'] = function (message: string) { + if (message.startsWith('proxy-message') || message.startsWith('proxy-command:')) { // for future + return false + } + if (message.startsWith('proxy-shutdown:')) { + lastKnownKickReason = message.slice('proxy-shutdown:'.length) + return false + } + return true + } + client.socket.on('connect', () => { + console.log('Proxy WebSocket connection established') + //@ts-expect-error + client.socket._ws.addEventListener('close', () => { + console.log('WebSocket connection closed') + // TODO important: for some reason close event of socket is never triggered now! + setTimeout(() => { + client.emit('end', lastKnownKickReason || 'WebSocket connection closed with unknown reason') + }, 500) + }) + client.socket.on('close', () => { + setTimeout(() => { + client.emit('end', lastKnownKickReason || 'WebSocket connection closed with unknown reason') + }) + }) + }) + } + // socket setup actually can be delayed because of dns lookup + if (client.socket) { + setupConnectHandlers() + } else { + const originalSetSocket = client.setSocket.bind(client) + client.setSocket = (socket) => { + originalSetSocket(socket) + setupConnectHandlers() + } + } +} diff --git a/src/protocolWorker/protocolMain.ts b/src/protocolWorker/protocolMain.ts new file mode 100644 index 000000000..f8827041b --- /dev/null +++ b/src/protocolWorker/protocolMain.ts @@ -0,0 +1,211 @@ +import EventEmitter from 'events' +import { Client, ClientOptions } from 'minecraft-protocol' +import { useWorkerProxy } from 'renderer/viewer/lib/workerProxy' +import { appQueryParams } from '../appParams' +import { ConnectOptions } from '../connect' +import { setLoadingScreenStatus } from '../appStatus' +import { ParsedServerAddress } from '../parseServerAddress' +import { authFlowMainThread, getAuthData } from './microsoftAuthflow' +import type { PROXY_WORKER_TYPE } from './protocol.worker' + +const debug = require('debug')('minecraft-protocol') + +let protocolWorkerChannel: typeof PROXY_WORKER_TYPE['__workerProxy'] | undefined + +export const getProtocolWorkerChannel = () => { + return protocolWorkerChannel +} + +const copyPrimitiveValues = (obj: any, deep = false, ignoreKeys: string[] = []) => { + const copy = {} as Record + for (const key in obj) { + if (ignoreKeys.includes(key)) continue + if (typeof obj[key] === 'object' && obj[key] !== null && deep) { + copy[key] = copyPrimitiveValues(obj[key]) + } else if (typeof obj[key] === 'number' || typeof obj[key] === 'string' || typeof obj[key] === 'boolean') { + copy[key] = obj[key] + } + } + return copy +} + +export const getProtocolClientGetter = async (proxy: { host: string, port?: string }, connectOptions: ConnectOptions, server: ParsedServerAddress) => { + const cachedTokens = typeof connectOptions.authenticatedAccount === 'object' ? connectOptions.authenticatedAccount.cachedTokens : {} + const authData = connectOptions.authenticatedAccount ? + await getAuthData({ + tokenCaches: cachedTokens, + proxyBaseUrl: connectOptions.proxy, + setProgressText (text) { + setLoadingScreenStatus(text) + }, + connectingServer: server.serverIpFull.replace(/:25565$/, '') + }) + : undefined + + function createMinecraftProtocolClient (this: any) { + if (!this.brand) return // brand is not resolved yet + if (bot?._client) return bot._client + const createClientOptions = copyPrimitiveValues(this, false, ['client']) as ClientOptions + + createClientOptions.sessionServer = authData?.sessionEndpoint.toString() + + const worker = new Worker(new URL('./protocol.worker.ts', import.meta.url)) + protocolWorkerChannel = useWorkerProxy(worker) + setTimeout(() => { + if (bot) { + bot.on('end', () => { + worker.terminate() + }) + } else { + worker.terminate() + } + }) + + protocolWorkerChannel.setProxy({ + hostname: proxy.host, + port: proxy.port ? +proxy.port : undefined + }) + void protocolWorkerChannel.init({ + options: createClientOptions, + noPacketsValidation: appQueryParams.noPacketsValidation === 'true', + useAuthFlow: !!authData, + isWebSocket: server.isWebSocket + }) + + const eventEmitter = new EventEmitter() as any + eventEmitter.version = this.version + + worker.addEventListener('message', ({ data }) => { + if (data.type === 'event') { + eventEmitter.emit(data.event, ...data.args) + if (data.event === 'packet') { + let [packetData, packetMeta] = data.args + if (window.stopPacketsProcessing === true || (Array.isArray(window.stopPacketsProcessing) && window.stopPacketsProcessing.includes(packetMeta.name))) { + if (window.skipPackets && !window.skipPackets.includes(packetMeta.name)) { + return + } + } + + // Start timing the packet processing + const startTime = performance.now() + + // restore transferred data + if (packetData instanceof Uint8Array) { + packetData = Buffer.from(packetData) + } else if (typeof packetData === 'object' && packetData !== null) { + // Deep patch any Uint8Array values in the packet data object + const patchUint8Arrays = (obj: any) => { + for (const key in obj) { + if (obj[key] instanceof Uint8Array) { + obj[key] = Buffer.from(obj[key]) + } else if (typeof obj[key] === 'object' && obj[key] !== null) { + patchUint8Arrays(obj[key]) + } + } + } + patchUint8Arrays(packetData) + } + + eventEmitter.state = packetMeta.state + debug(`RECV ${eventEmitter.state}:${packetMeta.name}`, packetData) + + // Initialize packet timing tracking if not exists + if (!window.packetTimings) { + window.packetTimings = {} + } + + if (!window.packetTimings[packetMeta.name]) { + window.packetTimings[packetMeta.name] = { + total: 0, + count: 0, + avg: 0 + } + } + + eventEmitter.emit(packetMeta.name, packetData, packetMeta) + + // Calculate processing time + const processingTime = performance.now() - startTime + window.packetTimings[packetMeta.name].total += processingTime + window.packetTimings[packetMeta.name].count++ + window.packetTimings[packetMeta.name].avg = + window.packetTimings[packetMeta.name].total / window.packetTimings[packetMeta.name].count + + // Update packetsThreadBlocking every second + if (!window.lastStatsUpdate) { + window.lastStatsUpdate = Date.now() + setInterval(() => { + // Sort by total processing time + window.packetsThreadBlocking = Object.entries(window.packetTimings) + .sort(([, a], [, b]) => b.total - a.total) + .reduce((acc, [key, value]) => { + acc[key] = value + return acc + }, {}) + + // Reset timings for next interval + window.packetTimings = {} + window.lastStatsUpdate = Date.now() + }, 1000) + } + } + } + + if (data.type === 'properties') { + // eslint-disable-next-line guard-for-in + for (const property in data.properties) { + eventEmitter[property] = data.properties[property] + } + } + }) + + eventEmitter.on('writePacket', (...args: any[]) => { + debug(`SEND ${eventEmitter.state}:${args[0]}`, ...args.slice(1)) + }) + + const redirectMethodsToWorker = (names: string[]) => { + for (const name of names) { + // eslint-disable-next-line @typescript-eslint/no-loop-func + eventEmitter[name] = async (...args: any[]) => { + protocolWorkerChannel?.call({ + name, + args: JSON.parse(JSON.stringify(args)) + }) + + if (name === 'write') { + eventEmitter.emit('writePacket', ...args) + } + } + } + } + + redirectMethodsToWorker([ + 'write', + 'writeRaw', + 'writeChannel', + 'registerChannel', + 'unregisterChannel', + 'chat', + 'reportPlayer', + 'end' + ]) + + if (authData) { + void authFlowMainThread(worker, authData, connectOptions, (onJoin) => { + eventEmitter.on('login', onJoin) + }) + } + + return eventEmitter + // return new Proxy(eventEmitter, { + // get (target, prop) { + // if (!(prop in target)) { + // // console.warn(`Accessing non-existent property "${String(prop)}" on event emitter`) + // } + // const value = target[prop] + // return typeof value === 'function' ? value.bind(target) : value + // } + // }) + } + return createMinecraftProtocolClient +} diff --git a/src/protocolWorker/protocolWorkerGlobals.ts b/src/protocolWorker/protocolWorkerGlobals.ts new file mode 100644 index 000000000..101ccffdf --- /dev/null +++ b/src/protocolWorker/protocolWorkerGlobals.ts @@ -0,0 +1,6 @@ +// '/'+location.pathname.split('/').slice(0, -4).join('/') + +// eslint-disable-next-line no-restricted-globals +globalThis.window = self +//@ts-expect-error +process.versions = { node: '' } diff --git a/src/react/AppStatusProvider.tsx b/src/react/AppStatusProvider.tsx index 24fb91e7b..9efcf4760 100644 --- a/src/react/AppStatusProvider.tsx +++ b/src/react/AppStatusProvider.tsx @@ -4,7 +4,7 @@ import { activeModalStack, activeModalStacks, hideModal, insertActiveModalStack, import { guessProblem } from '../errorLoadingScreenHelpers' import type { ConnectOptions } from '../connect' import { downloadPacketsReplay, packetsRecordingState, replayLogger } from '../packetsReplay/packetsReplayLegacy' -import { getProxyDetails } from '../microsoftAuthflow' +import { getProxyDetails } from '../protocolWorker/microsoftAuthflow' import { downloadAutoCapturedPackets, getLastAutoCapturedPackets } from '../mineflayer/plugins/packetsRecording' import { appQueryParams } from '../appParams' import AppStatus from './AppStatus' diff --git a/src/shims/minecraftData.ts b/src/shims/minecraftData.ts index 989aa698f..8436aea30 100644 --- a/src/shims/minecraftData.ts +++ b/src/shims/minecraftData.ts @@ -1,7 +1,7 @@ import { versionToNumber } from 'renderer/viewer/common/utils' +import { toMajorVersion } from 'renderer/viewer/lib/simpleUtils' import { restoreMinecraftData } from '../optimizeJson' // import minecraftInitialDataJson from '../../generated/minecraft-initial-data.json' -import { toMajorVersion } from '../utils' import { importLargeData } from '../../generated/large-data-aliases' const customResolver = () => { diff --git a/src/utils.ts b/src/utils.ts index 93e2eb7be..68209f121 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -128,12 +128,6 @@ export const isMajorVersionGreater = (ver1: string, ver2: string) => { return +a1 > +a2 || (+a1 === +a2 && +b1 > +b2) } -// doesn't support snapshots -export const toMajorVersion = version => { - const [a, b] = (String(version)).split('.') - return `${a}.${b}` -} - let prevRenderDistance = options.renderDistance export const setRenderDistance = () => { assertDefined(worldView)