Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Client side js mods. Modding! #255

Open
wants to merge 3 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions assets/config.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Configure client</title>
<script>
function removeSettings() {
if (confirm('Are you sure you want to RESET ALL SETTINGS?')) {
localStorage.setItem('options', '{}');
location.reload();
}
}
</script>
</head>
<body>
<div style="display: flex;gap: 10px;">
<button>Reset all settings</button>
<button>Remove all user data (worlds, resourcepacks)</button>
<button>Remove all mods</button>
<button>Remove all mod repositories</button>
</div>
<input />
</body>
</html>
1 change: 1 addition & 0 deletions rsbuild.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ const appConfig = defineConfig({
fs.copyFileSync('./assets/favicon.png', './dist/favicon.png')
fs.copyFileSync('./assets/playground.html', './dist/playground.html')
fs.copyFileSync('./assets/manifest.json', './dist/manifest.json')
fs.copyFileSync('./assets/config.html', './dist/config.html')
fs.copyFileSync('./assets/loading-bg.jpg', './dist/loading-bg.jpg')
if (fs.existsSync('./assets/release.json')) {
fs.copyFileSync('./assets/release.json', './dist/release.json')
Expand Down
295 changes: 295 additions & 0 deletions src/clientMods.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
import { openDB } from 'idb'
import * as react from 'react'
import { gt } from 'semver'
import { proxy } from 'valtio'
import { options } from './optionsStorage'
import { getStoredValue, setStoredValue } from './react/storageProvider'
import { showOptionsModal } from './react/SelectOption'

// #region Database
const dbPromise = openDB('mods-db', 1, {
upgrade (db) {
db.createObjectStore('mods', {
keyPath: 'name',
})
db.createObjectStore('repositories', {
keyPath: 'url',
})
},
})

// mcraft-repo.json
export interface Repository {
url: string
packages: ClientModDefinition[]
prefix?: string
name?: string // display name
description?: string
mirrorUrls?: string[]
autoUpdateOverride?: boolean
lastUpdated?: number
}

export interface ClientMod {
repo: string
name: string; // unique identifier like owner.name
version: string
enabled?: boolean

scriptMainUnstable?: string;
// workerScript?: string
stylesGlobal?: string
// stylesLocal?: string

description?: string
author?: string
section?: string
autoUpdateOverride?: boolean
lastUpdated?: number
// todo depends, hashsum
}

const cleanupFetchedModData = (mod: ClientModDefinition | Record<string, any>) => {
delete mod.enabled
delete mod.repo
delete mod.autoUpdateOverride
delete mod.lastUpdated
return mod
}

export type ClientModDefinition = ClientMod & {
scriptMainUnstable?: boolean
stylesGlobal?: boolean
}

async function savePlugin (data: ClientMod) {
const db = await dbPromise
data.lastUpdated = Date.now()
await db.put('mods', data)
}

async function getPlugin (name: string) {
const db = await dbPromise
return db.get('mods', name) as Promise<ClientMod | undefined>
}

async function getAllMods () {
const db = await dbPromise
return db.getAll('mods') as Promise<ClientMod[]>
}

async function deletePlugin (name) {
const db = await dbPromise
await db.delete('mods', name)
}

async function clearPlugins () {
const db = await dbPromise
await db.clear('mods')
}

// ---

async function saveRepository (data: Repository) {
const db = await dbPromise
data.lastUpdated = Date.now()
await db.put('repositories', data)
}

async function getRepository (url: string) {
const db = await dbPromise
return db.get('repositories', url) as Promise<Repository | undefined>
}

async function getAllRepositories () {
const db = await dbPromise
return db.getAll('repositories') as Promise<Repository[]>
}

async function deleteRepository (url) {
const db = await dbPromise
await db.delete('repositories', url)
}

// ---

// #endregion

window.mcraft = {
version: process.env.RELEASE_TAG,
build: process.env.BUILD_VERSION,
ui: {},
react,
// openDB
}

const activateMod = async (mod: ClientMod, reason: string) => {
console.debug(`Activating mod ${mod.name} (${reason})...`)
if (window.loadedMods[mod.name]) {
console.warn(`Mod is ${mod.name} already loaded, skipping activation...`)
return false
}
if (mod.stylesGlobal) {
const style = document.createElement('style')
style.textContent = mod.stylesGlobal
style.id = `mod-${mod.name}`
document.head.appendChild(style)
}
if (mod.scriptMainUnstable) {
const blob = new Blob([mod.scriptMainUnstable], { type: 'application/javascript' })
const url = URL.createObjectURL(blob)
try {
const module = await import(url)
module.default?.(structuredClone(mod))
window.loadedMods[mod.name] = module
} catch (e) {
console.error(`Error loading mod ${mod.name}:`, e)
}
}
return true
}

export const appStartup = async () => {
void checkModsUpdates()

const mods = await getAllMods()
for (const mod of mods) {
// eslint-disable-next-line no-await-in-loop
await activateMod(mod, 'autostart')
}
}

export const modsUpdateStatus = proxy({} as Record<string, [string, string]>)
export const modsWaitingReloadStatus = proxy({} as Record<string, boolean>)

const installOrUpdateMod = async (repo: Repository, mod: ClientModDefinition, activate = true) => {
try {
const fetchData = async (urls: string[]) => {
const errored = [] as string[]
for (const urlTemplate of urls) {
const url = new URL(`${mod.name.split('.').pop()}/${urlTemplate}`, repo.url).href
try {
// eslint-disable-next-line no-await-in-loop
return await fetch(url).then(async res => res.text())
} catch (e) {
errored.push(String(e))
}
}
console.warn(`[${mod.name}] Error installing component of ${urls[0]}: ${errored.join(', ')}`)
return undefined
}
if (mod.stylesGlobal) mod.stylesGlobal = await fetchData(['global.css']) as any
if (mod.scriptMainUnstable) mod.scriptMainUnstable = await fetchData(['mainUnstable.js']) as any
await savePlugin(mod)
delete modsUpdateStatus[mod.name]
} catch (e) {
console.error(`Error installing mod ${mod.name}:`, e)
}
if (activate) {
const result = await activateMod(mod, 'install')
if (!result) {
modsWaitingReloadStatus[mod.name] = true
}
}
}

const checkRepositoryUpdates = async (repo: Repository) => {
for (const mod of repo.packages) {
// eslint-disable-next-line no-await-in-loop
const modExisting = await getPlugin(mod.name)
if (modExisting?.version && gt(mod.version, modExisting.version)) {
modsUpdateStatus[mod.name] = [modExisting.version, mod.version]
if (options.modsAutoUpdate === 'always' && (!repo.autoUpdateOverride && !modExisting.autoUpdateOverride)) {
void installOrUpdateMod(repo, mod)
}
}
}

}

const fetchRepository = async (urlOriginal: string, url: string, hasMirrors = false) => {
const fetchUrl = !url.startsWith('https://') && url.includes('/') ? `https://raw.githubusercontent.com/${url}/master/mcraft-repo.json` : url
try {
const response = await fetch(fetchUrl).then(async res => res.json())
if (!response.packages) throw new Error(`No packages field in the response json of the repository: ${fetchUrl}`)
response.autoUpdateOverride = (await getRepository(urlOriginal))?.autoUpdateOverride
void saveRepository(response)
return true
} catch (e) {
console[hasMirrors ? 'warn' : 'error'](`Error fetching repository (trying other mirrors) ${url}:`, e)
return false
}
}

const fetchAllRepositories = async () => {
const repositories = await getAllRepositories()
return Promise.all(repositories.map(async (repo) => {
const allUrls = [repo.url, ...(repo.mirrorUrls || [])]
for (const [i, url] of allUrls.entries()) {
const isLast = i === allUrls.length - 1
// eslint-disable-next-line no-await-in-loop
if (await fetchRepository(repo.url, url, !isLast)) break
}
}))
}

const checkModsUpdates = async () => {
await refreshModRepositories()
for (const repo of await getAllRepositories()) {
// eslint-disable-next-line no-await-in-loop
await checkRepositoryUpdates(repo)
}
}

const refreshModRepositories = async () => {
if (options.modsAutoUpdate === 'never') return
const lastCheck = getStoredValue('modsAutoUpdateLastCheck')
if (lastCheck && Date.now() - lastCheck < 1000 * 60 * 60 * options.modsUpdatePeriodCheck) return
await fetchAllRepositories()
// todo think of not updating check timestamp on offline access
setStoredValue('modsAutoUpdateLastCheck', Date.now())
}

export const installModByName = async (repoUrl: string, name: string) => {
const repo = await getRepository(repoUrl)
if (!repo) throw new Error(`Repository ${repoUrl} not found`)
const mod = repo.packages.find(m => m.name === name)
if (!mod) throw new Error(`Mod ${name} not found in repository ${repoUrl}`)
return installOrUpdateMod(repo, mod)
}

export const uninstallModAction = async (name: string) => {
const choice = await showOptionsModal(`Uninstall mod ${name}?`, ['Yes'])
if (!choice) return
await deletePlugin(name)
if (window.loadedMods[name]) {
// window.loadedMods[name].default?.(null)
delete window.loadedMods[name]
modsWaitingReloadStatus[name] = true
}
}

export const getAllModsDisplayList = async () => {
const repos = await getAllRepositories()
const mods = await getAllMods()
const modsWithoutRepos = mods.filter(mod => !repos.some(repo => repo.packages.some(m => m.name === mod.name)))
const mapMods = (mods: ClientMod[]) => mods.map(mod => ({
...mod,
installed: mods.some(m => m.name === mod.name),
}))
return {
repos: repos.map(repo => ({
...repo,
packages: mapMods(repo.packages),
})),
modsWithoutRepos: mapMods(modsWithoutRepos),
}
}

export const removeRepositoryAction = async (url: string) => {
const choice = await showOptionsModal('Remove repository? Installed mods won\' be automatically removed.', ['Yes'])
if (!choice) return
await deleteRepository(url)
}

// export const getAllMods = () => {}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ import { mainMenuState } from './react/MainMenuRenderApp'
import { ItemsRenderer } from 'mc-assets/dist/itemsRenderer'
import './mobileShim'
import { parseFormattedMessagePacket } from './botUtils'
import { appStartup } from './clientMods'
import { getViewerVersionData, getWsProtocolStream } from './viewerConnector'

window.debug = debug
Expand Down Expand Up @@ -1092,3 +1093,4 @@ if (initialLoader) {
window.pageLoaded = true

void possiblyHandleStateVariable()
void appStartup()
7 changes: 7 additions & 0 deletions src/optionsGuiScheme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { openFilePicker, resetLocalStorageWithoutWorld } from './browserfs'
import { completeTexturePackInstall, getResourcePackNames, resourcePackState, uninstallTexturePack } from './resourcePack'
import { downloadPacketsReplay, packetsReplaceSessionState } from './packetsReplay'
import { showOptionsModal } from './react/SelectOption'
import { modsUpdateStatus } from './clientMods'

export const guiOptionsScheme: {
[t in OptionsGroupType]: Array<{ [K in keyof AppOptions]?: Partial<OptionMeta<AppOptions[K]>> } & { custom? }>
Expand Down Expand Up @@ -201,6 +202,12 @@ export const guiOptionsScheme: {
return <Button label='Advanced...' onClick={() => openOptionsMenu('advanced')} inScreen />
},
},
{
custom () {
const modsUpdateSnapshot = useSnapshot(modsUpdateStatus)
return <Button label={`Client Mods: ${Object.keys(window.loadedMods ?? {}).length} (${Object.keys(modsUpdateSnapshot).length})}`} onClick={() => showModal({ reactType: 'mods' })} inScreen />
},
},
{
custom () {
return <Button label='VR...' onClick={() => openOptionsMenu('VR')} inScreen />
Expand Down
3 changes: 3 additions & 0 deletions src/optionsStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ const defaultOptions = {
useVersionsTextures: 'latest',
serverResourcePacks: 'prompt' as 'prompt' | 'always' | 'never',
handDisplay: false,
modsSupport: false,
modsAutoUpdate: 'check' as 'check' | 'never' | 'always',
modsUpdatePeriodCheck: 24, // hours
packetsLoggerPreset: 'all' as 'all' | 'no-buffers',

// antiAliasing: false,
Expand Down
13 changes: 13 additions & 0 deletions src/react/ModsPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useIsModalActive } from './utilsApp'

export default () => {
const isModalActive = useIsModalActive('mods')

if (!isModalActive) return null
return <div>
<div className="dirt-bg" />
<div className="fullscreen">
<div className="screen-title">Client Mods</div>
</div>
</div>
}
1 change: 1 addition & 0 deletions src/react/storageProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { CustomCommand } from './KeybindingsCustom'

type StorageData = {
customCommands: Record<string, CustomCommand>
modsAutoUpdateLastCheck: number
// ...
}

Expand Down
Loading
Loading