Skip to content

Commit

Permalink
Merge branch 'main' into fix/swap_network_switch
Browse files Browse the repository at this point in the history
  • Loading branch information
BrettCleary authored Nov 16, 2024
2 parents 7745bfb + 7a9c4f7 commit 5a2e08b
Show file tree
Hide file tree
Showing 10 changed files with 326 additions and 77 deletions.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,8 @@
"@babel/plugin-transform-arrow-functions": "^7.24.7",
"@lavamoat/allow-scripts": "^3.2.0",
"@lavamoat/preinstall-always-fail": "^2.1.0",
"@metamask/providers": "^18.1.1",
"@metamask/utils": "^10.0.1",
"@playwright/test": "^1.46.0",
"@tanstack/react-query-devtools": "^5.59.20",
"@testing-library/dom": "^7.31.2",
Expand Down Expand Up @@ -368,7 +370,7 @@
"@hyperplay/extension-provider": "^0.0.8",
"@hyperplay/mock-backend": "^0.0.1",
"@hyperplay/overlay": "^0.0.7",
"@hyperplay/patcher": "^0.0.17",
"@hyperplay/patcher": "^0.0.18",
"@hyperplay/providers": "^0.0.6",
"@hyperplay/proxy-server": "^0.0.11"
},
Expand Down
16 changes: 16 additions & 0 deletions src/backend/metrics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,21 @@ export interface PatchingFailed {
sensitiveProperties?: never
}

export interface PatchingTooSlow {
event: 'Patching Too Slow'
properties: {
game_name: string
game_title: string
platform: ReturnType<typeof getPlatformName>
platform_arch: InstallPlatform
old_game_version: string
new_game_version: string
est_time_to_patch_sec: string
est_time_to_install_sec: string
}
sensitiveProperties?: never
}

export interface GameUninstallRequested {
event: 'Game Uninstall Requested'
properties: {
Expand Down Expand Up @@ -442,5 +457,6 @@ export type PossibleMetricPayloads =
| PatchingStarted
| PatchingSuccess
| PatchingFailed
| PatchingTooSlow

export type PossibleMetricEventNames = PossibleMetricPayloads['event']
110 changes: 73 additions & 37 deletions src/backend/proxy/providerPreload.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { RequestArguments } from 'common/typedefs/ipcBridge'
import { JsonRpcCallback } from 'common/types'
import { contextBridge, ipcRenderer, webFrame } from 'electron'
import type { JsonRpcResponse } from '@metamask/utils'

/**
* @dev Extension must be removed prior to loading a page with this preload script.
Expand All @@ -24,7 +25,11 @@ const enabledTopics = [
]

/* eslint-disable @typescript-eslint/no-explicit-any */
const listenToRendererCalls = (fxn: string, topic: string, cb: any) => {
const listenToRendererCalls = (
fxn: 'once' | 'on' | 'addListener',
topic: string,
cb: any
) => {
if (!enabledTopics.includes(topic)) {
throw `Tried to listen to ${topic} through window.ethereum!`
}
Expand Down Expand Up @@ -91,29 +96,40 @@ const providerApi = {
request: async (args: RequestArguments) => {
return provRequest(args)
},
send: async (...args: unknown[]) => {
return sendRequest(...args)
send: async (...args: unknown[]): Promise<JsonRpcResponse<any>> => {
return sendRequest(...args) as Promise<JsonRpcResponse<any>>
},
sendAsync: async (payload: any, callback: JsonRpcCallback) => {
return sendAsyncRequest(payload, callback)
sendAsync: async (
payload: any,
callback: JsonRpcCallback
): Promise<JsonRpcResponse<any>> => {
return sendAsyncRequest(payload, callback) as Promise<
JsonRpcResponse<any>
>
},
once: (topic: string, cb: any) => {
listenToRendererCalls('once', topic, cb)
return window.ethereum
},
on: (topic: string, cb: any) => {
listenToRendererCalls('on', topic, cb)
return window.ethereum
},
off: (topic: string, cb: any) => {
ipcRenderer.off('providerApi' + topic, cb)
return window.ethereum
},
addListener: (topic: string, cb: any) => {
listenToRendererCalls('addListener', topic, cb)
return window.ethereum
},
removeListener: (topic: string, cb: any) => {
ipcRenderer.removeListener('providerApi' + topic, cb)
return window.ethereum
},
removeAllListeners: (topic: string) => {
removeAllListeners: (topic?: string) => {
ipcRenderer.removeAllListeners('providerApi' + topic)
return window.ethereum
},
enable: async () => {
const args: RequestArguments = {
Expand All @@ -126,37 +142,60 @@ const providerApi = {

contextBridge?.exposeInMainWorld('providerApi', providerApi)

declare global {
interface Window {
providerApi: typeof providerApi
}
}

function initProvider() {
async function exposeWindowEthereum() {
console.log('exposing window ethereum')
if (!Object.hasOwn(window, 'ethereum')) {
const windowAny = window as any
windowAny.ethereum = {
request: windowAny.providerApi.provider.request,
send: windowAny.providerApi.provider.send,
sendAsync: windowAny.providerApi.provider.sendAsync,
once: windowAny.providerApi.provider.once,
on: windowAny.providerApi.provider.on,
off: windowAny.providerApi.provider.off,
addListener: windowAny.providerApi.provider.addListener,
removeListener: windowAny.providerApi.provider.removeListener,
removeAllListeners: windowAny.providerApi.provider.removeAllListeners,
isMetaMask: true,
enable: windowAny.providerApi.provider.enable,
selectedAddress: undefined,
accounts: undefined
}

windowAny.ethereum.on('accountsChanged', (accounts: string[]) => {
console.log('accounts changed', accounts)
windowAny.ethereum.selectedAddress = accounts[0]
windowAny.ethereum.accounts = accounts
})
const windowAny = window
windowAny.ethereum = {
request: windowAny.providerApi.provider.request,
// @ts-expect-error deprecated send call needs to be generic
send: windowAny.providerApi.provider.send,
sendAsync: windowAny.providerApi.provider.sendAsync,
once: windowAny.providerApi.provider.once,
on: windowAny.providerApi.provider.on,
off: windowAny.providerApi.provider.off,
addListener: windowAny.providerApi.provider.addListener,
removeListener: windowAny.providerApi.provider.removeListener,
removeAllListeners: windowAny.providerApi.provider.removeAllListeners,
isMetaMask: true,
enable: windowAny.providerApi.provider.enable,
selectedAddress: null,
accounts: undefined
}

const acct = await windowAny.ethereum.request({ method: 'eth_accounts' })
windowAny.ethereum.selectedAddress =
acct && acct.length > 0 ? acct[0] : ''
windowAny.ethereum.accounts = acct
// @ts-expect-error TODO fix types in MetaMaskInpageProvider
windowAny.ethereum.on('accountsChanged', (accounts: string[]) => {
console.log('accounts changed', accounts)
// @ts-expect-error TODO fix types in MetaMaskInpageProvider
windowAny.ethereum.selectedAddress = accounts[0]
// @ts-expect-error TODO fix types in MetaMaskInpageProvider
windowAny.ethereum.accounts = accounts
})

const ev = new Event('ethereum#initialized')
window.dispatchEvent(ev)

const timeNow = Date.now()
const acct = await windowAny.ethereum.request({ method: 'eth_accounts' })
// @ts-expect-error TODO fix types in MetaMaskInpageProvider
windowAny.ethereum.selectedAddress = acct && acct.length > 0 ? acct[0] : ''
// @ts-expect-error TODO fix types in MetaMaskInpageProvider
windowAny.ethereum.accounts = acct

/**
* opensea performs 4 responsiveness checks by calling eth_accounts with 2 second timeouts.
* if all 4 fail, clicking MetaMask in the connection options will open the metamask.io download page.
* we reload the page so that MetaMask will connect as expected if the user unlocks their wallet
* after this time period.
*/
const timeElapsed = Date.now() - timeNow
if (timeElapsed > 8000) {
window.location.reload()
}
}

Expand All @@ -179,9 +218,6 @@ function initProvider() {
window.addEventListener('eip6963:requestProvider', () => {
announceProvider()
})

const ev = new Event('ethereum#initialized')
window.dispatchEvent(ev)
}

const exposeWindowEthereumProvider = `(${initProvider.toString()})()`
Expand Down
108 changes: 107 additions & 1 deletion src/backend/storeManagers/hyperplay/games.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ import { trackEvent } from 'backend/metrics/metrics'
import { getFlag } from 'backend/flags/flags'
import { ipfsGateway } from 'backend/vite_constants'
import { GlobalConfig } from 'backend/config'
import { PatchingError } from './types'

interface ProgressDownloadingItem {
DownloadItem: DownloadItem
Expand Down Expand Up @@ -1653,6 +1654,93 @@ export async function downloadGameIpdtManifest(
}
}

async function checkIfPatchingIsFaster(
oldManifestPath: string,
newManifestPath: string,
gameInfo: GameInfo
) {
// read manifests
const oldManifestJson = JSON.parse(readFileSync(oldManifestPath).toString())
const newManifestJson = JSON.parse(readFileSync(newManifestPath).toString())

// compare manifests

const { compareManifests } = await import('@hyperplay/patcher')
const { estimatedPatchSizeInKB } = compareManifests(
oldManifestJson.files,
newManifestJson.files
)

// calc break point % where patching is faster
if (
gameInfo?.install?.platform &&
gameInfo.channels &&
gameInfo?.install?.channelName &&
Object.hasOwn(gameInfo.channels, gameInfo.install.channelName)
) {
const channelName = gameInfo.install.channelName
const [releaseMeta] = getReleaseMeta(gameInfo, channelName)
const platform = handleArchAndPlatform(
gameInfo.install.platform,
releaseMeta
)
const downloadSize = parseInt(
releaseMeta.platforms[platform]?.downloadSize ?? '0'
)
const installSize = parseInt(
releaseMeta.platforms[platform]?.installSize ?? '0'
)
// @TODO: get these speed values from local checks of download/write speed
const patchingSpeeds = getFlag('patching-speeds', {
downloadSpeedInKBPerSecond: 25600,
extractionSpeedInKBPerSecond: 51200,
patchingSpeedEstimateInKBPerSecond: 5120
}) as {
downloadSpeedInKBPerSecond: number
extractionSpeedInKBPerSecond: number
patchingSpeedEstimateInKBPerSecond: number
}
const downloadSpeedInKBPerSecond = patchingSpeeds.downloadSpeedInKBPerSecond
const extractionSpeedInKBPerSecond =
patchingSpeeds.extractionSpeedInKBPerSecond
const estTimeToInstallFullGameInSec =
(downloadSize / 1024) * downloadSpeedInKBPerSecond +
(installSize / 1024) * extractionSpeedInKBPerSecond

// @TODO: get this value from local check of patching speed
const patchingSpeedEstimateInKBPerSecond =
patchingSpeeds.patchingSpeedEstimateInKBPerSecond
const estTimeToPatchGameInSec =
estimatedPatchSizeInKB / patchingSpeedEstimateInKBPerSecond

if (estTimeToPatchGameInSec > estTimeToInstallFullGameInSec) {
const abortMessage = `Downloading full game instead of patching. \n
Estimated time to install full game: ${estTimeToInstallFullGameInSec} seconds. \n
Estimated time to patch: ${estTimeToPatchGameInSec}
`
logInfo(abortMessage, LogPrefix.HyperPlay)
const patchingError = new PatchingError(
abortMessage,
'slower-than-install',
{
event: 'Patching Too Slow',
properties: {
game_name: gameInfo.app_name,
game_title: gameInfo.title,
platform: getPlatformName(platform),
platform_arch: platform,
est_time_to_install_sec: estTimeToInstallFullGameInSec.toString(),
est_time_to_patch_sec: estTimeToPatchGameInSec.toString(),
old_game_version: gameInfo.install.version ?? 'unknown',
new_game_version: gameInfo.version ?? 'unknown'
}
}
)
throw patchingError
}
}
}

async function applyPatching(
gameInfo: GameInfo,
newVersion: string,
Expand Down Expand Up @@ -1717,6 +1805,9 @@ async function applyPatching(
const previousManifest = await getManifest(appName, platform, version)
const currentManifest = await getManifest(appName, platform, newVersion)

// check if it is faster to patch or install and throw if install is faster
await checkIfPatchingIsFaster(previousManifest, currentManifest, gameInfo)

logInfo(
`Patching ${gameInfo.title} from ${version} to ${newVersion}`,
LogPrefix.HyperPlay
Expand Down Expand Up @@ -1859,6 +1950,16 @@ async function applyPatching(

return { status: 'done' }
} catch (error) {
if (error instanceof PatchingError) {
if (error.reason === 'slower-than-install') {
if (error.eventToTrack) {
trackEvent(error.eventToTrack)
}
// this will not track any error events or call captureException in the calling code. it will try to install
return { status: 'error' }
}
}

logError(`Error while patching ${error}`, LogPrefix.HyperPlay)

trackEvent({
Expand All @@ -1883,7 +1984,12 @@ async function applyPatching(
newVersion
}
})
rmSync(datastoreDir, { recursive: true })

// errors can be thrown before datastore dir created. rmSync on nonexistent dir blocks indefinitely
if (existsSync(datastoreDir)) {
rmSync(datastoreDir, { recursive: true })
}

return { status: 'error', error: `Error while patching ${error}` }
}
}
Expand Down
19 changes: 19 additions & 0 deletions src/backend/storeManagers/hyperplay/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { PossibleMetricPayloads } from 'backend/metrics/types'

export type PatchingErrorReason = 'slower-than-install'

export class PatchingError extends Error {
reason: PatchingErrorReason
eventToTrack?: PossibleMetricPayloads

constructor(
message: string,
reason: PatchingErrorReason,
eventToTrack?: PossibleMetricPayloads
) {
super(message) // Pass the message to the base Error class
this.reason = reason
this.eventToTrack = eventToTrack
this.name = 'PatchingError' // Set a custom error name
}
}
8 changes: 8 additions & 0 deletions src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { IconDefinition } from '@fortawesome/free-solid-svg-icons'
import { DropdownItemType } from '@hyperplay/ui'
export type { Quest } from '@hyperplay/utils'

import { MetaMaskInpageProvider } from '@metamask/providers'

export type {
Listing as HyperPlayRelease,
ProjectMetaApi as HyperPlayProjectMeta,
Expand Down Expand Up @@ -978,3 +980,9 @@ export interface PointsCollection {
}

export type { GamePageActions } from '@hyperplay/utils'

declare global {
interface Window {
ethereum: MetaMaskInpageProvider
}
}
Loading

0 comments on commit 5a2e08b

Please sign in to comment.