From e26e947d2019f04d0c0f3ff4f2973956bb3787f5 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 13 Dec 2024 18:35:07 +0300 Subject: [PATCH 1/2] almost done with mods --- src/clientMods.ts | 295 +++++++++++++++++++++++++++++++++++ src/index.ts | 2 + src/optionsGuiScheme.tsx | 7 + src/optionsStorage.ts | 3 + src/react/ModsPage.tsx | 13 ++ src/react/storageProvider.ts | 1 + src/reactUi.tsx | 2 + 7 files changed, 323 insertions(+) create mode 100644 src/clientMods.ts create mode 100644 src/react/ModsPage.tsx diff --git a/src/clientMods.ts b/src/clientMods.ts new file mode 100644 index 000000000..577b2da37 --- /dev/null +++ b/src/clientMods.ts @@ -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) => { + 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 +} + +async function getAllMods () { + const db = await dbPromise + return db.getAll('mods') as Promise +} + +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 +} + +async function getAllRepositories () { + const db = await dbPromise + return db.getAll('repositories') as Promise +} + +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) +export const modsWaitingReloadStatus = proxy({} as Record) + +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 = () => {} diff --git a/src/index.ts b/src/index.ts index 93bbe6b90..16cd3b36c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -103,6 +103,7 @@ import { mainMenuState } from './react/MainMenuRenderApp' import { ItemsRenderer } from 'mc-assets/dist/itemsRenderer' import './mobileShim' import { parseFormattedMessagePacket } from './botUtils' +import { appStartup } from './clientMods' window.debug = debug window.THREE = THREE @@ -1068,3 +1069,4 @@ if (initialLoader) { window.pageLoaded = true void possiblyHandleStateVariable() +void appStartup() diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index c6a979c16..283bd6a38 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -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> } & { custom? }> @@ -201,6 +202,12 @@ export const guiOptionsScheme: { return + + + + + + + diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 1085e3fcf..5c2353f39 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -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')