From e26e947d2019f04d0c0f3ff4f2973956bb3787f5 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 13 Dec 2024 18:35:07 +0300 Subject: [PATCH 01/16] 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 00000000..577b2da3 --- /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 93bbe6b9..16cd3b36 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 c6a979c1..283bd6a3 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 1085e3fc..5c2353f3 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') From 61bf7b0cb7c2fde5d05f7fe7cd7feab65fd50f3b Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 19 Mar 2025 02:12:18 +0300 Subject: [PATCH 03/16] import fixes --- src/clientMods.ts | 6 +- src/optionsGuiScheme.tsx | 2 +- src/optionsStorage.ts | 3 + src/react/ModsPage.tsx | 156 ++++++++++++++++++++++++++++++++ src/react/appStorageProvider.ts | 2 + src/react/mods.module.css | 117 ++++++++++++++++++++++++ src/react/mods.module.css.d.ts | 22 +++++ src/reactUi.tsx | 1 + 8 files changed, 305 insertions(+), 4 deletions(-) create mode 100644 src/react/mods.module.css create mode 100644 src/react/mods.module.css.d.ts diff --git a/src/clientMods.ts b/src/clientMods.ts index 577b2da3..a0a392f3 100644 --- a/src/clientMods.ts +++ b/src/clientMods.ts @@ -3,7 +3,7 @@ import * as react from 'react' import { gt } from 'semver' import { proxy } from 'valtio' import { options } from './optionsStorage' -import { getStoredValue, setStoredValue } from './react/storageProvider' +import { appStorage } from './react/appStorageProvider' import { showOptionsModal } from './react/SelectOption' // #region Database @@ -243,11 +243,11 @@ const checkModsUpdates = async () => { const refreshModRepositories = async () => { if (options.modsAutoUpdate === 'never') return - const lastCheck = getStoredValue('modsAutoUpdateLastCheck') + const lastCheck = appStorage.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()) + appStorage.modsAutoUpdateLastCheck = Date.now() } export const installModByName = async (repoUrl: string, name: string) => { diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index 0ec2e9f1..f9b51cc7 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -214,7 +214,7 @@ export const guiOptionsScheme: { { custom () { const modsUpdateSnapshot = useSnapshot(modsUpdateStatus) - return + + +
+
+ {filteredMods ? ( + <> + {filteredMods.repos.map(repo => ( +
+
toggleRepo(repo.url)} + > + {expandedRepos[repo.url] ? '▼' : '▶'} + {repo.name || repo.url} + ({repo.packages.length}) +
+ {expandedRepos[repo.url] && ( +
+ {repo.packages.map(mod => ( +
setSelectedMod(mod)} + > +
{mod.name}
+
+ {mod.description} + {mod.author && ` • By ${mod.author}`} + {mod.version && ` • v${mod.version}`} +
+
+ ))} +
+ )} +
+ ))} + {filteredMods.modsWithoutRepos.length > 0 && ( +
+
+ + Other Mods + ({filteredMods.modsWithoutRepos.length}) +
+
+ {filteredMods.modsWithoutRepos.map(mod => ( +
setSelectedMod(mod)} + > +
{mod.name}
+
+ {mod.description} + {mod.author && ` • By ${mod.author}`} + {mod.version && ` • v${mod.version}`} +
+
+ ))} +
+
+ )} + + ) : ( +
Loading mods...
+ )} +
+
+ {selectedMod ? ( + <> +
+
{selectedMod.name}
+
+ {selectedMod.description} +
+
+ {selectedMod.author && `Author: ${selectedMod.author}`} + {selectedMod.version && `\nVersion: ${selectedMod.version}`} + {selectedMod.section && `\nSection: ${selectedMod.section}`} +
+
+
+ + {selectedMod.installed && ( + <> + + + + )} +
+ + ) : ( +
Select a mod to view details
+ )} +
+
+ } diff --git a/src/react/appStorageProvider.ts b/src/react/appStorageProvider.ts index 30caa0ec..68fa3562 100644 --- a/src/react/appStorageProvider.ts +++ b/src/react/appStorageProvider.ts @@ -37,6 +37,7 @@ type StorageData = { serversHistory: ServerHistoryEntry[] authenticatedAccounts: AuthenticatedAccount[] serversList: StoreServerItem[] | undefined + modsAutoUpdateLastCheck: number | undefined } const oldKeysAliases: Partial> = { @@ -76,6 +77,7 @@ const defaultStorageData: StorageData = { serversHistory: [], authenticatedAccounts: [], serversList: undefined, + modsAutoUpdateLastCheck: undefined, } export const setStorageDataOnAppConfigLoad = () => { diff --git a/src/react/mods.module.css b/src/react/mods.module.css new file mode 100644 index 00000000..423c71b9 --- /dev/null +++ b/src/react/mods.module.css @@ -0,0 +1,117 @@ +.root { + display: flex; + flex-direction: column; + height: 100%; + padding: 10px; + gap: 10px; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; +} + +.searchBar { + flex: 1; +} + +.filterButton { + width: 120px; +} + +.content { + display: flex; + flex: 1; + gap: 10px; + overflow: hidden; +} + +.modList { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 5px; +} + +.sidebar { + width: 200px; + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px; + background: rgba(0, 0, 0, 0.3); + border-radius: 4px; +} + +.modInfo { + display: flex; + flex-direction: column; + gap: 5px; +} + +.modInfoTitle { + font-size: 12px; + font-weight: bold; + color: white; +} + +.modInfoText { + font-size: 10px; + color: #bcbcbc; +} + +.modActions { + display: flex; + flex-direction: column; + gap: 5px; +} + +.modRow { + display: flex; + flex-direction: column; + padding: 8px; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + cursor: pointer; +} + +.modRow:hover { + background: rgba(0, 0, 0, 0.3); +} + +.modRowTitle { + font-size: 12px; + color: white; + margin-bottom: 4px; +} + +.modRowInfo { + font-size: 10px; + color: #bcbcbc; +} + +.repoHeader { + display: flex; + align-items: center; + gap: 4px; + color: #bcbcbc; + font-size: 8px; + cursor: pointer; + padding: 4px; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; +} + +.repoHeader:hover { + background: rgba(0, 0, 0, 0.3); +} + +.repoContent { + margin-left: 10px; + display: flex; + flex-direction: column; + gap: 5px; +} diff --git a/src/react/mods.module.css.d.ts b/src/react/mods.module.css.d.ts new file mode 100644 index 00000000..1c2d0141 --- /dev/null +++ b/src/react/mods.module.css.d.ts @@ -0,0 +1,22 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + content: string; + filterButton: string; + header: string; + modActions: string; + modInfo: string; + modInfoText: string; + modInfoTitle: string; + modList: string; + modRow: string; + modRowInfo: string; + modRowTitle: string; + repoContent: string; + repoHeader: string; + root: string; + searchBar: string; + sidebar: string; +} +declare const cssExports: CssExports; +export default cssExports; diff --git a/src/reactUi.tsx b/src/reactUi.tsx index 5207e5b4..a24c518e 100644 --- a/src/reactUi.tsx +++ b/src/reactUi.tsx @@ -211,6 +211,7 @@ const App = () => { +
From 66b9305db0ff8fd0a80c605007fea78944c7f940 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Thu, 20 Mar 2025 00:36:10 +0300 Subject: [PATCH 04/16] alsmost finished, huge progress --- src/clientMods.ts | 231 ++++++++++++++++----- src/core/progressReporter.ts | 3 +- src/index.ts | 4 +- src/react/Input.tsx | 2 +- src/react/ModsPage.tsx | 363 ++++++++++++++++++++++----------- src/react/Screen.tsx | 5 +- src/react/SelectOption.tsx | 2 + src/react/mods.module.css | 82 ++++++-- src/react/mods.module.css.d.ts | 4 +- src/reactUi.tsx | 3 +- src/screens.css | 1 + 11 files changed, 516 insertions(+), 184 deletions(-) diff --git a/src/clientMods.ts b/src/clientMods.ts index a0a392f3..034f60c7 100644 --- a/src/clientMods.ts +++ b/src/clientMods.ts @@ -1,10 +1,42 @@ +/* eslint-disable no-await-in-loop */ import { openDB } from 'idb' import * as react from 'react' import { gt } from 'semver' import { proxy } from 'valtio' import { options } from './optionsStorage' import { appStorage } from './react/appStorageProvider' -import { showOptionsModal } from './react/SelectOption' +import { showInputsModal, showOptionsModal } from './react/SelectOption' +import { ProgressReporter } from './core/progressReporter' + +let sillyProtection = false +const protectRuntime = () => { + if (sillyProtection) return + sillyProtection = true + const sensetiveKeys = new Set(['authenticatedAccounts']) + window.localStorage = new Proxy(window.localStorage, { + get (target, prop) { + if (typeof prop === 'string' && sensetiveKeys.has(prop)) { + console.warn(`Access to sensitive key "${prop}" was blocked`) + return null + } + return Reflect.get(target, prop) + }, + set (target, prop, value) { + if (typeof prop === 'string' && sensetiveKeys.has(prop)) { + console.warn(`Attempt to set sensitive key "${prop}" was blocked`) + return false + } + return Reflect.set(target, prop, value) + }, + deleteProperty (target, prop) { + if (typeof prop === 'string' && sensetiveKeys.has(prop)) { + console.warn(`Attempt to delete sensitive key "${prop}" was blocked`) + return false + } + return Reflect.deleteProperty(target, prop) + } + }) +} // #region Database const dbPromise = openDB('mods-db', 1, { @@ -19,24 +51,28 @@ const dbPromise = openDB('mods-db', 1, { }) // mcraft-repo.json -export interface Repository { - url: string +export interface McraftRepoFile { packages: ClientModDefinition[] - prefix?: string + /** @default true */ + prefix?: string | boolean name?: string // display name description?: string mirrorUrls?: string[] autoUpdateOverride?: boolean lastUpdated?: number } +export interface Repository extends McraftRepoFile { + url: string +} export interface ClientMod { - repo: string name: string; // unique identifier like owner.name version: string enabled?: boolean scriptMainUnstable?: string; + serverPlugin?: string + // serverPlugins?: string[] // workerScript?: string stylesGlobal?: string // stylesLocal?: string @@ -50,22 +86,24 @@ export interface ClientMod { } const cleanupFetchedModData = (mod: ClientModDefinition | Record) => { - delete mod.enabled - delete mod.repo - delete mod.autoUpdateOverride - delete mod.lastUpdated + delete mod['enabled'] + delete mod['repo'] + delete mod['autoUpdateOverride'] + delete mod['lastUpdated'] return mod } -export type ClientModDefinition = ClientMod & { +export type ClientModDefinition = Omit & { scriptMainUnstable?: boolean stylesGlobal?: boolean + serverPlugin?: boolean } async function savePlugin (data: ClientMod) { const db = await dbPromise data.lastUpdated = Date.now() await db.put('mods', data) + modsReactiveUpdater.counter++ } async function getPlugin (name: string) { @@ -81,11 +119,13 @@ async function getAllMods () { async function deletePlugin (name) { const db = await dbPromise await db.delete('mods', name) + modsReactiveUpdater.counter++ } -async function clearPlugins () { +async function removeAllMods () { const db = await dbPromise await db.clear('mods') + modsReactiveUpdater.counter++ } // --- @@ -105,6 +145,7 @@ async function getAllRepositories () { const db = await dbPromise return db.getAll('repositories') as Promise } +window.getAllRepositories = getAllRepositories async function deleteRepository (url) { const db = await dbPromise @@ -124,7 +165,9 @@ window.mcraft = { } const activateMod = async (mod: ClientMod, reason: string) => { + protectRuntime() console.debug(`Activating mod ${mod.name} (${reason})...`) + window.loadedMods ??= {} if (window.loadedMods[mod.name]) { console.warn(`Mod is ${mod.name} already loaded, skipping activation...`) return false @@ -136,16 +179,22 @@ const activateMod = async (mod: ClientMod, reason: string) => { document.head.appendChild(style) } if (mod.scriptMainUnstable) { - const blob = new Blob([mod.scriptMainUnstable], { type: 'application/javascript' }) + const blob = new Blob([mod.scriptMainUnstable], { type: 'text/javascript' }) const url = URL.createObjectURL(blob) + // eslint-disable-next-line no-useless-catch try { - const module = await import(url) + const module = await import(/* webpackIgnore: true */ url) module.default?.(structuredClone(mod)) window.loadedMods[mod.name] = module } catch (e) { - console.error(`Error loading mod ${mod.name}:`, e) + // if (e instanceof Error && e.message.startsWith('Cannot find module')) { + // throw new Error(`mainUnstable.js is not valid ES module! Ensure you have default export with function to activate.`) + // } + // console.error(`Error loading mod ${mod.name}:`, e) + throw e } } + mod.enabled = true return true } @@ -154,53 +203,88 @@ export const appStartup = async () => { const mods = await getAllMods() for (const mod of mods) { - // eslint-disable-next-line no-await-in-loop - await activateMod(mod, 'autostart') + await activateMod(mod, 'autostart').catch(e => { + modsErrors[mod.name] ??= [] + modsErrors[mod.name].push(`startup: ${String(e)}`) + console.error(`Error activating mod on startup ${mod.name}:`, e) + }) } } export const modsUpdateStatus = proxy({} as Record) export const modsWaitingReloadStatus = proxy({} as Record) +export const modsErrors = proxy({} as Record) -const installOrUpdateMod = async (repo: Repository, mod: ClientModDefinition, activate = true) => { +const normalizeRepoUrl = (url: string) => { + if (url.startsWith('https://')) return url + if (url.startsWith('http://')) return url + if (url.startsWith('//')) return `https:${url}` + return `https://raw.githubusercontent.com/${url}/master` +} + +const installOrUpdateMod = async (repo: Repository, mod: ClientModDefinition, activate = true, progress?: ProgressReporter) => { + // eslint-disable-next-line no-useless-catch try { const fetchData = async (urls: string[]) => { const errored = [] as string[] + // eslint-disable-next-line no-unreachable-loop for (const urlTemplate of urls) { - const url = new URL(`${mod.name.split('.').pop()}/${urlTemplate}`, repo.url).href + const modNameOnly = mod.name.split('.').pop() + const modFolder = repo.prefix === false ? modNameOnly : typeof repo.prefix === 'string' ? `${repo.prefix}/${modNameOnly}` : mod.name + const url = new URL(`${modFolder}/${urlTemplate}`, normalizeRepoUrl(repo.url).replace(/\/$/, '') + '/').href + // eslint-disable-next-line no-useless-catch try { - // eslint-disable-next-line no-await-in-loop - return await fetch(url).then(async res => res.text()) + const response = await fetch(url) + if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`) + return await response.text() } catch (e) { - errored.push(String(e)) + // errored.push(String(e)) + throw 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) + if (mod.stylesGlobal) { + await progress?.executeWithMessage( + `Installing ${mod.name} styles`, + async () => { + mod.stylesGlobal = await fetchData(['global.css']) as any + } + ) + } + if (mod.scriptMainUnstable) { + await progress?.executeWithMessage( + `Installing ${mod.name} script`, + async () => { + mod.scriptMainUnstable = await fetchData(['mainUnstable.js']) as any + } + ) + } + if (activate) { + const result = await activateMod(mod as ClientMod, 'install') + if (!result) { + modsWaitingReloadStatus[mod.name] = true + } + } + await savePlugin(mod as ClientMod) 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 - } + // console.error(`Error installing mod ${mod.name}:`, e) + throw e } } 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) + void installOrUpdateMod(repo, mod).catch(e => { + console.error(`Error updating mod ${mod.name}:`, e) + }) } } } @@ -208,88 +292,137 @@ const checkRepositoryUpdates = async (repo: Repository) => { } 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 + const fetchUrl = normalizeRepoUrl(url).replace(/\/$/, '') + '/mcraft-repo.json' 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 + response.url = urlOriginal void saveRepository(response) + modsReactiveUpdater.counter++ return true } catch (e) { - console[hasMirrors ? 'warn' : 'error'](`Error fetching repository (trying other mirrors) ${url}:`, e) + console.warn(`Error fetching repository (trying other mirrors) ${url}:`, e) return false } } -const fetchAllRepositories = async () => { +export const fetchAllRepositories = async () => { const repositories = await getAllRepositories() - return Promise.all(repositories.map(async (repo) => { + await 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 } })) + appStorage.modsAutoUpdateLastCheck = Date.now() } const checkModsUpdates = async () => { - await refreshModRepositories() + await autoRefreshModRepositories() for (const repo of await getAllRepositories()) { - // eslint-disable-next-line no-await-in-loop + await checkRepositoryUpdates(repo) } } -const refreshModRepositories = async () => { +const autoRefreshModRepositories = async () => { if (options.modsAutoUpdate === 'never') return const lastCheck = appStorage.modsAutoUpdateLastCheck if (lastCheck && Date.now() - lastCheck < 1000 * 60 * 60 * options.modsUpdatePeriodCheck) return await fetchAllRepositories() // todo think of not updating check timestamp on offline access - appStorage.modsAutoUpdateLastCheck = Date.now() } -export const installModByName = async (repoUrl: string, name: string) => { +export const installModByName = async (repoUrl: string, name: string, progress?: ProgressReporter) => { + progress?.beginStage('main', `Installing ${name}`) 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) + await installOrUpdateMod(repo, mod, undefined, progress) + progress?.endStage('main') } export const uninstallModAction = async (name: string) => { const choice = await showOptionsModal(`Uninstall mod ${name}?`, ['Yes']) if (!choice) return await deletePlugin(name) + window.loadedMods ??= {} if (window.loadedMods[name]) { // window.loadedMods[name].default?.(null) delete window.loadedMods[name] modsWaitingReloadStatus[name] = true } + // Clear any errors associated with the mod + delete modsErrors[name] } +export const setEnabledModAction = async (name: string, enabled: boolean) => { + const mod = await getPlugin(name) + if (!mod) throw new Error(`Mod ${name} not found`) + if (enabled) { + if (window.loadedMods?.[mod.name]) { + mod.enabled = true + } else { + await activateMod(mod, 'manual') + } + } else { + // todo deactivate mod + mod.enabled = false + } + await savePlugin(mod) +} + +export const modsReactiveUpdater = proxy({ + counter: 0 +}) + 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 => ({ + const installedMods = await getAllMods() + const modsWithoutRepos = installedMods.filter(mod => !repos.some(repo => repo.packages.some(m => m.name === mod.name))) + const mapMods = (mapMods: ClientMod[]) => mapMods.map(mod => ({ ...mod, - installed: mods.some(m => m.name === mod.name), + installed: installedMods.some(m => m.name === mod.name), + enabled: !!window.loadedMods?.[mod.name] })) return { repos: repos.map(repo => ({ ...repo, - packages: mapMods(repo.packages), + packages: mapMods(repo.packages as ClientMod[]), })), modsWithoutRepos: mapMods(modsWithoutRepos), } } export const removeRepositoryAction = async (url: string) => { + // todo remove mods const choice = await showOptionsModal('Remove repository? Installed mods won\' be automatically removed.', ['Yes']) if (!choice) return await deleteRepository(url) + modsReactiveUpdater.counter++ +} + +export const selectAndRemoveRepository = async () => { + const repos = await getAllRepositories() + const choice = await showOptionsModal('Select repository to remove', repos.map(repo => repo.url)) + if (!choice) return + await removeRepositoryAction(choice) +} + +export const addRepositoryAction = async () => { + const { url } = await showInputsModal('Add repository', { + url: { + type: 'text', + label: 'Repository URL or slug', + placeholder: 'github-owner/repo-name', + }, + }) + if (!url) return + await fetchRepository(url, url) } // export const getAllMods = () => {} diff --git a/src/core/progressReporter.ts b/src/core/progressReporter.ts index 6ef6044f..79e8886c 100644 --- a/src/core/progressReporter.ts +++ b/src/core/progressReporter.ts @@ -1,6 +1,7 @@ import { setLoadingScreenStatus } from '../appStatus' import { appStatusState } from '../react/AppStatusProvider' import { hideNotification, showNotification } from '../react/NotificationProvider' +import { pixelartIcons } from '../react/PixelartIcon' export interface ProgressReporter { currentMessage: string | undefined @@ -169,7 +170,7 @@ export const createNotificationProgressReporter = (endMessage?: string): Progres }, end () { if (endMessage) { - showNotification(endMessage, '', false, '', undefined, true) + showNotification(endMessage, '', false, pixelartIcons.check, undefined, true) } else { hideNotification() } diff --git a/src/index.ts b/src/index.ts index 3df8d4ee..40205d31 100644 --- a/src/index.ts +++ b/src/index.ts @@ -747,7 +747,7 @@ export async function connect (connectOptions: ConnectOptions) { onBotCreate() bot.once('login', () => { - setLoadingScreenStatus('Loading world') + progress.setMessage('Loading world') }) const start = Date.now() @@ -779,7 +779,7 @@ export async function connect (connectOptions: ConnectOptions) { if (p2pConnectTimeout) clearTimeout(p2pConnectTimeout) playerState.onlineMode = !!connectOptions.authenticatedAccount - setLoadingScreenStatus('Placing blocks (starting viewer)') + progress.setMessage('Placing blocks (starting viewer)') if (!connectOptions.worldStateFileContents || connectOptions.worldStateFileContents.length < 3 * 1024 * 1024) { localStorage.lastConnectOptions = JSON.stringify(connectOptions) if (process.env.NODE_ENV === 'development' && !localStorage.lockUrl && !Object.keys(window.debugQueryParams).length) { diff --git a/src/react/Input.tsx b/src/react/Input.tsx index 169e880d..9b36c5ce 100644 --- a/src/react/Input.tsx +++ b/src/react/Input.tsx @@ -35,7 +35,6 @@ const Input = ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue, w return
{ setValue(e.target.value) diff --git a/src/react/ModsPage.tsx b/src/react/ModsPage.tsx index 261d19c1..e535792a 100644 --- a/src/react/ModsPage.tsx +++ b/src/react/ModsPage.tsx @@ -1,25 +1,136 @@ import { useEffect, useState } from 'react' -import { getAllModsDisplayList } from '../clientMods' +import { useSnapshot } from 'valtio' +import { addRepositoryAction, setEnabledModAction, getAllModsDisplayList, installModByName, selectAndRemoveRepository, uninstallModAction, fetchAllRepositories, modsReactiveUpdater, modsErrors } from '../clientMods' +import { createNotificationProgressReporter, ProgressReporter } from '../core/progressReporter' import { useIsModalActive } from './utilsApp' import Input from './Input' import Button from './Button' import styles from './mods.module.css' +import { showOptionsModal } from './SelectOption' +import Screen from './Screen' +import { pixelartIcons } from './PixelartIcon' +import { showNotification } from './NotificationProvider' +import { usePassesScaledDimensions } from './UIProvider' type ModsData = Awaited> +const ModSidebar = ({ mod }: { mod: (ModsData['repos'][0]['packages'][0] & { repo?: string }) | null }) => { + // just make it update + const { counter } = useSnapshot(modsReactiveUpdater) + const errors = useSnapshot(modsErrors) + + const handleAction = async (action: () => Promise, errorMessage: string, progress?: ProgressReporter) => { + try { + await action() + progress?.end() + } catch (error) { + console.error(error) + progress?.end() + showNotification(errorMessage, error.message, true) + } + } + + if (!mod) { + return
Select a mod to view details
+ } + + return ( + <> +
+
{mod.name}
+
+ {mod.description} +
+
+ {mod.author && `Author: ${mod.author}`} + {mod.version && `\nVersion: ${mod.version}`} + {mod.section && `\nSection: ${mod.section}`} +
+ {errors[mod.name]?.length > 0 && ( +
+
    + {errors[mod.name].map((error, i) => ( +
  • {error}
  • + ))} +
+
+ )} +
+
+ {mod.installed ? ( + <> + {mod.enabled ? ( +
+ + ) +} + export default () => { - const isModalActive = useIsModalActive('mods') + const isModalActive = useIsModalActive('mods', true) const [modsData, setModsData] = useState(null) const [search, setSearch] = useState('') const [showOnlyInstalled, setShowOnlyInstalled] = useState(false) - const [selectedMod, setSelectedMod] = useState(null) + const [showOnlyEnabled, setShowOnlyEnabled] = useState(false) + const [selectedMod, setSelectedMod] = useState<(ModsData['repos'][0]['packages'][0] & { repo?: string }) | null>(null) const [expandedRepos, setExpandedRepos] = useState>({}) + const useHorizontalLayout = usePassesScaledDimensions(400) + const { counter } = useSnapshot(modsReactiveUpdater) + const errors = useSnapshot(modsErrors) useEffect(() => { if (isModalActive) { - void getAllModsDisplayList().then(setModsData) + void getAllModsDisplayList().then(mods => { + setModsData(mods) + if (selectedMod) { + setSelectedMod(mods.repos.find(repo => repo.packages.find(mod => mod.name === selectedMod.name))?.packages.find(mod => mod.name === selectedMod.name) ?? null) + } + }) } - }, [isModalActive]) + }, [isModalActive, counter]) if (!isModalActive) return null @@ -30,91 +141,113 @@ export default () => { })) } + const modFilter = (mod: ModsData['repos'][0]['packages'][0]) => { + const matchesSearch = mod.name.toLowerCase().includes(search.toLowerCase()) || + mod.description?.toLowerCase().includes(search.toLowerCase()) + const matchesInstalledFilter = !showOnlyInstalled || mod.installed + const matchesEnabledFilter = !showOnlyEnabled || mod.enabled + return matchesSearch && matchesInstalledFilter && matchesEnabledFilter + } + const filteredMods = modsData ? { repos: modsData.repos.map(repo => ({ ...repo, - packages: repo.packages.filter(mod => { - const matchesSearch = mod.name.toLowerCase().includes(search.toLowerCase()) || - mod.description?.toLowerCase().includes(search.toLowerCase()) - const matchesFilter = !showOnlyInstalled || mod.installed - return matchesSearch && matchesFilter - }) + packages: repo.packages.filter(modFilter) })), - modsWithoutRepos: modsData.modsWithoutRepos.filter(mod => { - const matchesSearch = mod.name.toLowerCase().includes(search.toLowerCase()) || - mod.description?.toLowerCase().includes(search.toLowerCase()) - const matchesFilter = !showOnlyInstalled || mod.installed - return matchesSearch && matchesFilter - }) + modsWithoutRepos: modsData.modsWithoutRepos.filter(modFilter) } : null - return
-
-
-
Client Mods
-
-
- setSearch(e.target.value)} - placeholder="Search mods..." - /> - - -
-
-
- {filteredMods ? ( - <> - {filteredMods.repos.map(repo => ( -
-
toggleRepo(repo.url)} - > - {expandedRepos[repo.url] ? '▼' : '▶'} - {repo.name || repo.url} - ({repo.packages.length}) -
- {expandedRepos[repo.url] && ( -
- {repo.packages.map(mod => ( -
setSelectedMod(mod)} - > -
{mod.name}
-
- {mod.description} - {mod.author && ` • By ${mod.author}`} - {mod.version && ` • v${mod.version}`} -
-
- ))} -
- )} + const getStatsText = () => { + if (!modsData) return 'Loading...' + const totalRepos = modsData.repos.length + const totalMods = modsData.repos.reduce((acc, repo) => acc + repo.packages.length, 0) + modsData.modsWithoutRepos.length + const filteredModsCount = filteredMods ? + filteredMods.repos.reduce((acc, repo) => acc + repo.packages.length, 0) + filteredMods.modsWithoutRepos.length : 0 + + if (showOnlyEnabled) { + return `Showing enabled mods (${filteredModsCount} of ${totalMods})` + } + if (showOnlyInstalled) { + return `Showing installed mods (${filteredModsCount} of ${totalMods})` + } + return `Showing all ${totalRepos} repos with ${filteredModsCount} mods` + } + + return +
+
+
+
+ {getStatsText()} +
+
+
+ {filteredMods ? ( + <> + {filteredMods.repos.map(repo => ( +
+
toggleRepo(repo.url)} + > + {expandedRepos[repo.url] ? '▼' : '▶'} + {repo.name || repo.url} + ({repo.packages.length})
- ))} - {filteredMods.modsWithoutRepos.length > 0 && ( -
-
- - Other Mods - ({filteredMods.modsWithoutRepos.length}) -
+ {expandedRepos[repo.url] && (
- {filteredMods.modsWithoutRepos.map(mod => ( + {repo.packages.map(mod => (
setSelectedMod(mod)} + onClick={() => setSelectedMod({ ...mod, repo: repo.url })} + data-enabled={mod.enabled} + data-has-error={errors[mod.name]?.length > 0} >
{mod.name}
@@ -125,45 +258,45 @@ export default () => {
))}
-
- )} - - ) : ( -
Loading mods...
- )} -
-
- {selectedMod ? ( - <> -
-
{selectedMod.name}
-
- {selectedMod.description} -
-
- {selectedMod.author && `Author: ${selectedMod.author}`} - {selectedMod.version && `\nVersion: ${selectedMod.version}`} - {selectedMod.section && `\nSection: ${selectedMod.section}`} -
-
-
- - {selectedMod.installed && ( - <> - - - )}
- - ) : ( -
Select a mod to view details
- )} -
+ ))} + {filteredMods.modsWithoutRepos.length > 0 && ( +
+
+ + Other Mods + ({filteredMods.modsWithoutRepos.length}) +
+
+ {filteredMods.modsWithoutRepos.map(mod => ( +
setSelectedMod(mod)} + data-enabled={mod.enabled} + data-has-error={errors[mod.name]?.length > 0} + > +
{mod.name}
+
+ {mod.description} + {mod.author && ` • By ${mod.author}`} + {mod.version && ` • v${mod.version}`} +
+
+ ))} +
+
+ )} + + ) : ( +
Loading mods...
+ )} +
+
+
-
+
} diff --git a/src/react/Screen.tsx b/src/react/Screen.tsx index a17ede40..605ec28f 100644 --- a/src/react/Screen.tsx +++ b/src/react/Screen.tsx @@ -6,14 +6,15 @@ interface Props { className?: string titleSelectable?: boolean titleMarginTop?: number + contentStyle?: React.CSSProperties } -export default ({ title, children, backdrop = true, style, className = '', titleSelectable, titleMarginTop }: Props) => { +export default ({ title, children, backdrop = true, style, className = '', titleSelectable, titleMarginTop, contentStyle }: Props) => { return ( <> {backdrop === 'dirt' ?
: backdrop ?
: null}
-
+
{title}
{children}
diff --git a/src/react/SelectOption.tsx b/src/react/SelectOption.tsx index 4df471c0..14f3a1a3 100644 --- a/src/react/SelectOption.tsx +++ b/src/react/SelectOption.tsx @@ -52,6 +52,7 @@ type InputOption = { type: 'text' | 'checkbox' defaultValue?: string | boolean label?: string + placeholder?: string } export const showInputsModal = async >( title: string, @@ -130,6 +131,7 @@ export default () => { autoFocus type='text' defaultValue={input.defaultValue as string} + placeholder={input.placeholder} onChange={(e) => { inputValues.current[key] = e.target.value }} diff --git a/src/react/mods.module.css b/src/react/mods.module.css index 423c71b9..f2217394 100644 --- a/src/react/mods.module.css +++ b/src/react/mods.module.css @@ -2,30 +2,54 @@ display: flex; flex-direction: column; height: 100%; + width: 100%; padding: 10px; + padding-top: 0; + box-sizing: border-box; gap: 10px; } .header { display: flex; - justify-content: space-between; - align-items: center; - gap: 10px; + gap: 5px; +} + +.statsRow { + color: #999; + font-size: 10px; + margin-bottom: 8px; +} + +.statsRow { + color: #999; + font-size: 10px; + margin-bottom: 8px; } .searchBar { flex: 1; } -.filterButton { - width: 120px; -} - .content { display: flex; flex: 1; gap: 10px; overflow: hidden; + min-height: 0; /* Important for Firefox */ +} + +.verticalContent { + flex-direction: column; +} + +.verticalContent .modList { + height: 50%; + min-height: 200px; +} + +.verticalContent .sidebar { + height: 50%; + width: 100%; } .modList { @@ -34,6 +58,11 @@ display: flex; flex-direction: column; gap: 5px; + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + padding: 5px; + min-height: 0; /* Important for Firefox */ + height: 100%; } .sidebar { @@ -44,6 +73,8 @@ padding: 10px; background: rgba(0, 0, 0, 0.3); border-radius: 4px; + flex-shrink: 0; + height: 100%; } .modInfo { @@ -65,7 +96,6 @@ .modActions { display: flex; - flex-direction: column; gap: 5px; } @@ -73,13 +103,12 @@ display: flex; flex-direction: column; padding: 8px; - background: rgba(0, 0, 0, 0.2); border-radius: 4px; cursor: pointer; } .modRow:hover { - background: rgba(0, 0, 0, 0.3); + background: rgba(0, 0, 0, 0.2); } .modRowTitle { @@ -101,12 +130,11 @@ font-size: 8px; cursor: pointer; padding: 4px; - background: rgba(0, 0, 0, 0.2); border-radius: 4px; } .repoHeader:hover { - background: rgba(0, 0, 0, 0.3); + background: rgba(0, 0, 0, 0.2); } .repoContent { @@ -115,3 +143,33 @@ flex-direction: column; gap: 5px; } + +/* Mod state styles */ +.modRow[data-enabled="false"] { + opacity: 0.5; +} + +.modRow[data-enabled="true"] { + color: lime; +} + +.modRow[data-enabled="true"] .modRowTitle { + color: lime; +} + +/* Error state styles */ +.modRow[data-has-error="true"] { + background: rgba(255, 0, 0, 0.1); +} + +.modRow[data-has-error="true"] .modRowTitle { + color: #ff6b6b; +} + +.modErrorList { + font-size: 8px; + color: #ff6b6b; + margin-top: 5px; + padding-left: 10px; + list-style-type: disc; +} diff --git a/src/react/mods.module.css.d.ts b/src/react/mods.module.css.d.ts index 1c2d0141..7c2edcbc 100644 --- a/src/react/mods.module.css.d.ts +++ b/src/react/mods.module.css.d.ts @@ -2,9 +2,9 @@ // Please do not change this file! interface CssExports { content: string; - filterButton: string; header: string; modActions: string; + modErrorList: string; modInfo: string; modInfoText: string; modInfoTitle: string; @@ -17,6 +17,8 @@ interface CssExports { root: string; searchBar: string; sidebar: string; + statsRow: string; + verticalContent: string; } declare const cssExports: CssExports; export default cssExports; diff --git a/src/reactUi.tsx b/src/reactUi.tsx index a24c518e..9abb6164 100644 --- a/src/reactUi.tsx +++ b/src/reactUi.tsx @@ -202,7 +202,6 @@ const App = () => { - @@ -212,6 +211,8 @@ const App = () => { + +
diff --git a/src/screens.css b/src/screens.css index 289fe129..8d9c2b75 100644 --- a/src/screens.css +++ b/src/screens.css @@ -17,6 +17,7 @@ inset: 0; height: 100dvh; background: rgba(0, 0, 0, 0.75); + z-index: 12; } .fullscreen { From f4632c5388c896e2486ac826f82aac867a36630f Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Thu, 20 Mar 2025 00:37:53 +0300 Subject: [PATCH 05/16] protection --- src/clientMods.ts | 41 +++++++++++++++++++++++++++--- src/react/ModsPage.tsx | 45 ++++++++++++++++++--------------- src/react/appStorageProvider.ts | 2 +- 3 files changed, 62 insertions(+), 26 deletions(-) diff --git a/src/clientMods.ts b/src/clientMods.ts index 034f60c7..87a33bc6 100644 --- a/src/clientMods.ts +++ b/src/clientMods.ts @@ -12,12 +12,45 @@ let sillyProtection = false const protectRuntime = () => { if (sillyProtection) return sillyProtection = true - const sensetiveKeys = new Set(['authenticatedAccounts']) + const sensetiveKeys = new Set(['authenticatedAccounts', 'serversList', 'username']) window.localStorage = new Proxy(window.localStorage, { get (target, prop) { - if (typeof prop === 'string' && sensetiveKeys.has(prop)) { - console.warn(`Access to sensitive key "${prop}" was blocked`) - return null + if (typeof prop === 'string') { + if (sensetiveKeys.has(prop)) { + console.warn(`Access to sensitive key "${prop}" was blocked`) + return null + } + if (prop === 'getItem') { + return (key: string) => { + if (sensetiveKeys.has(key)) { + console.warn(`Access to sensitive key "${key}" via getItem was blocked`) + return null + } + return target.getItem(key) + } + } + if (prop === 'setItem') { + return (key: string, value: string) => { + if (sensetiveKeys.has(key)) { + console.warn(`Attempt to set sensitive key "${key}" via setItem was blocked`) + return + } + target.setItem(key, value) + } + } + if (prop === 'removeItem') { + return (key: string) => { + if (sensetiveKeys.has(key)) { + console.warn(`Attempt to delete sensitive key "${key}" via removeItem was blocked`) + return + } + target.removeItem(key) + } + } + if (prop === 'clear') { + console.warn('Attempt to clear localStorage was blocked') + return () => {} + } } return Reflect.get(target, prop) }, diff --git a/src/react/ModsPage.tsx b/src/react/ModsPage.tsx index e535792a..d716f7f7 100644 --- a/src/react/ModsPage.tsx +++ b/src/react/ModsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, useMemo } from 'react' import { useSnapshot } from 'valtio' import { addRepositoryAction, setEnabledModAction, getAllModsDisplayList, installModByName, selectAndRemoveRepository, uninstallModAction, fetchAllRepositories, modsReactiveUpdater, modsErrors } from '../clientMods' import { createNotificationProgressReporter, ProgressReporter } from '../core/progressReporter' @@ -15,8 +15,6 @@ import { usePassesScaledDimensions } from './UIProvider' type ModsData = Awaited> const ModSidebar = ({ mod }: { mod: (ModsData['repos'][0]['packages'][0] & { repo?: string }) | null }) => { - // just make it update - const { counter } = useSnapshot(modsReactiveUpdater) const errors = useSnapshot(modsErrors) const handleAction = async (action: () => Promise, errorMessage: string, progress?: ProgressReporter) => { @@ -115,18 +113,27 @@ export default () => { const [search, setSearch] = useState('') const [showOnlyInstalled, setShowOnlyInstalled] = useState(false) const [showOnlyEnabled, setShowOnlyEnabled] = useState(false) - const [selectedMod, setSelectedMod] = useState<(ModsData['repos'][0]['packages'][0] & { repo?: string }) | null>(null) + const [selectedModIndex, setSelectedModIndex] = useState(null) const [expandedRepos, setExpandedRepos] = useState>({}) const useHorizontalLayout = usePassesScaledDimensions(400) const { counter } = useSnapshot(modsReactiveUpdater) const errors = useSnapshot(modsErrors) + const allModsArray = useMemo(() => { + if (!modsData) return [] + return [ + ...modsData.repos.flatMap(repo => repo.packages.map(mod => ({ ...mod, repo: repo.url }))), + ...modsData.modsWithoutRepos + ] + }, [modsData]) + useEffect(() => { if (isModalActive) { void getAllModsDisplayList().then(mods => { setModsData(mods) - if (selectedMod) { - setSelectedMod(mods.repos.find(repo => repo.packages.find(mod => mod.name === selectedMod.name))?.packages.find(mod => mod.name === selectedMod.name) ?? null) + // Update selected mod index if needed + if (selectedModIndex !== null && selectedModIndex < allModsArray.length) { + setSelectedModIndex(selectedModIndex) } }) } @@ -157,22 +164,18 @@ export default () => { modsWithoutRepos: modsData.modsWithoutRepos.filter(modFilter) } : null - const getStatsText = () => { - if (!modsData) return 'Loading...' - const totalRepos = modsData.repos.length - const totalMods = modsData.repos.reduce((acc, repo) => acc + repo.packages.length, 0) + modsData.modsWithoutRepos.length - const filteredModsCount = filteredMods ? - filteredMods.repos.reduce((acc, repo) => acc + repo.packages.length, 0) + filteredMods.modsWithoutRepos.length : 0 + const filteredModsCount = filteredMods ? + filteredMods.repos.reduce((acc, repo) => acc + repo.packages.length, 0) + filteredMods.modsWithoutRepos.length : 0 - if (showOnlyEnabled) { - return `Showing enabled mods (${filteredModsCount} of ${totalMods})` - } - if (showOnlyInstalled) { - return `Showing installed mods (${filteredModsCount} of ${totalMods})` - } + const totalRepos = modsData?.repos.length ?? 0 + + const getStatsText = () => { + if (!filteredMods) return 'Loading...' return `Showing all ${totalRepos} repos with ${filteredModsCount} mods` } + const selectedMod = selectedModIndex === null ? null : allModsArray[selectedModIndex] + return
@@ -241,11 +244,11 @@ export default () => {
{expandedRepos[repo.url] && (
- {repo.packages.map(mod => ( + {repo.packages.map((mod, index) => (
setSelectedMod({ ...mod, repo: repo.url })} + onClick={() => setSelectedModIndex(allModsArray.findIndex(m => m.name === mod.name))} data-enabled={mod.enabled} data-has-error={errors[mod.name]?.length > 0} > @@ -273,7 +276,7 @@ export default () => {
setSelectedMod(mod)} + onClick={() => setSelectedModIndex(allModsArray.findIndex(m => m.name === mod.name))} data-enabled={mod.enabled} data-has-error={errors[mod.name]?.length > 0} > diff --git a/src/react/appStorageProvider.ts b/src/react/appStorageProvider.ts index 68fa3562..cb2dd215 100644 --- a/src/react/appStorageProvider.ts +++ b/src/react/appStorageProvider.ts @@ -7,6 +7,7 @@ import type { BaseServerInfo } from './AddServerOrConnect' // when opening html file locally in browser, localStorage is shared between all ever opened html files, so we try to avoid conflicts const localStoragePrefix = process.env?.SINGLE_FILE_BUILD ? 'minecraft-web-client:' : '' +const { localStorage } = window export interface SavedProxiesData { proxies: string[] @@ -85,7 +86,6 @@ export const setStorageDataOnAppConfigLoad = () => { } export const appStorage = proxy({ ...defaultStorageData }) -window.appStorage = appStorage // Restore data from localStorage for (const key of Object.keys(defaultStorageData)) { From 31a657948ba2a1b4feb167218e05dcdbb108d496 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Thu, 20 Mar 2025 03:53:44 +0300 Subject: [PATCH 06/16] local server support! finish all --- package.json | 2 +- pnpm-lock.yaml | 12 +-- src/clientMods.ts | 67 +++++++++++++---- src/defaultLocalServerOptions.js | 1 + src/react/CreateWorld.tsx | 47 +++++++++++- src/react/CreateWorldProvider.tsx | 17 ++++- src/react/ModsPage.tsx | 119 +++++++++++++++++++++--------- src/react/SelectOption.tsx | 14 +++- src/react/appStorageProvider.ts | 2 + src/react/mods.module.css | 2 + 10 files changed, 220 insertions(+), 63 deletions(-) diff --git a/package.json b/package.json index cd8c25ff..191ec390 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "esbuild-plugin-polyfill-node": "^0.3.0", "express": "^4.18.2", "filesize": "^10.0.12", - "flying-squid": "npm:@zardoy/flying-squid@^0.0.51", + "flying-squid": "npm:@zardoy/flying-squid@^0.0.57", "fs-extra": "^11.1.1", "google-drive-browserfs": "github:zardoy/browserfs#google-drive", "jszip": "^3.10.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9093dfc0..c9ac46d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,8 +120,8 @@ importers: specifier: ^10.0.12 version: 10.0.12 flying-squid: - specifier: npm:@zardoy/flying-squid@^0.0.51 - version: '@zardoy/flying-squid@0.0.51(encoding@0.1.13)' + specifier: npm:@zardoy/flying-squid@^0.0.57 + version: '@zardoy/flying-squid@0.0.57(encoding@0.1.13)' fs-extra: specifier: ^11.1.1 version: 11.1.1 @@ -3541,8 +3541,8 @@ packages: engines: {node: '>=8'} hasBin: true - '@zardoy/flying-squid@0.0.51': - resolution: {integrity: sha512-HHZ79H9NkS44lL9vk6gVEuJDJqj88gpiBt9Ihh5p4rHXTVbRid95riiNK5dD0kHI94P5/DXdtNalvmJDPU86oQ==} + '@zardoy/flying-squid@0.0.57': + resolution: {integrity: sha512-yjBfdQwUyl5rQ8rCYsC3O/yXTUfxNhhGBLxW+xHJj2xwqWy9/Lx4MV9PqIdl5kyuB4sEVUOghv9npJtnp6mriw==} engines: {node: '>=8'} hasBin: true @@ -13637,7 +13637,7 @@ snapshots: - encoding - supports-color - '@zardoy/flying-squid@0.0.51(encoding@0.1.13)': + '@zardoy/flying-squid@0.0.57(encoding@0.1.13)': dependencies: '@tootallnate/once': 2.0.0 chalk: 5.3.0 @@ -13648,7 +13648,7 @@ snapshots: flatmap: 0.0.3 long: 5.2.3 minecraft-data: 3.83.1 - minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/5ec3dd4b367fcc039fbcb3edd214fe3cf8178a6d(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13) + minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/3bd4dc1b2002cd7badfa5b9cf8dda35cd6cc9ac1(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13) mkdirp: 2.1.6 node-gzip: 1.1.2 node-rsa: 1.1.1 diff --git a/src/clientMods.ts b/src/clientMods.ts index 87a33bc6..630b96f2 100644 --- a/src/clientMods.ts +++ b/src/clientMods.ts @@ -13,7 +13,7 @@ const protectRuntime = () => { if (sillyProtection) return sillyProtection = true const sensetiveKeys = new Set(['authenticatedAccounts', 'serversList', 'username']) - window.localStorage = new Proxy(window.localStorage, { + const proxy = new Proxy(window.localStorage, { get (target, prop) { if (typeof prop === 'string') { if (sensetiveKeys.has(prop)) { @@ -69,6 +69,11 @@ const protectRuntime = () => { return Reflect.deleteProperty(target, prop) } }) + Object.defineProperty(window, 'localStorage', { + value: proxy, + writable: false, + configurable: false, + }) } // #region Database @@ -198,6 +203,7 @@ window.mcraft = { } const activateMod = async (mod: ClientMod, reason: string) => { + if (mod.enabled === false) return false protectRuntime() console.debug(`Activating mod ${mod.name} (${reason})...`) window.loadedMods ??= {} @@ -280,7 +286,7 @@ const installOrUpdateMod = async (repo: Repository, mod: ClientModDefinition, ac } if (mod.stylesGlobal) { await progress?.executeWithMessage( - `Installing ${mod.name} styles`, + `Downloading ${mod.name} styles`, async () => { mod.stylesGlobal = await fetchData(['global.css']) as any } @@ -288,16 +294,27 @@ const installOrUpdateMod = async (repo: Repository, mod: ClientModDefinition, ac } if (mod.scriptMainUnstable) { await progress?.executeWithMessage( - `Installing ${mod.name} script`, + `Downloading ${mod.name} script`, async () => { mod.scriptMainUnstable = await fetchData(['mainUnstable.js']) as any } ) } + if (mod.serverPlugin) { + if (mod.name.endsWith('.disabled')) throw new Error(`Mod name ${mod.name} can't end with .disabled`) + await progress?.executeWithMessage( + `Downloading ${mod.name} server plugin`, + async () => { + mod.serverPlugin = await fetchData(['serverPlugin.js']) as any + } + ) + } if (activate) { - const result = await activateMod(mod as ClientMod, 'install') - if (!result) { + // todo try to de-activate mod if it's already loaded + if (window.loadedMods?.[mod.name]) { modsWaitingReloadStatus[mod.name] = true + } else { + await activateMod(mod as ClientMod, 'install') } } await savePlugin(mod as ClientMod) @@ -324,7 +341,7 @@ const checkRepositoryUpdates = async (repo: Repository) => { } -const fetchRepository = async (urlOriginal: string, url: string, hasMirrors = false) => { +export const fetchRepository = async (urlOriginal: string, url: string, hasMirrors = false) => { const fetchUrl = normalizeRepoUrl(url).replace(/\/$/, '') + '/mcraft-repo.json' try { const response = await fetch(fetchUrl).then(async res => res.json()) @@ -393,18 +410,21 @@ export const uninstallModAction = async (name: string) => { delete modsErrors[name] } -export const setEnabledModAction = async (name: string, enabled: boolean) => { +export const setEnabledModAction = async (name: string, newEnabled: boolean) => { const mod = await getPlugin(name) if (!mod) throw new Error(`Mod ${name} not found`) - if (enabled) { - if (window.loadedMods?.[mod.name]) { - mod.enabled = true - } else { + if (newEnabled) { + mod.enabled = true + if (!window.loadedMods?.[mod.name]) { await activateMod(mod, 'manual') } } else { // todo deactivate mod mod.enabled = false + if (window.loadedMods?.[mod.name]?.deactivate) { + window.loadedMods[mod.name].deactivate() + delete window.loadedMods[mod.name] + } } await savePlugin(mod) } @@ -420,7 +440,9 @@ export const getAllModsDisplayList = async () => { const mapMods = (mapMods: ClientMod[]) => mapMods.map(mod => ({ ...mod, installed: installedMods.some(m => m.name === mod.name), - enabled: !!window.loadedMods?.[mod.name] + activated: !!window.loadedMods?.[mod.name], + installedVersion: installedMods.find(m => m.name === mod.name)?.version, + canBeActivated: mod.scriptMainUnstable || mod.stylesGlobal, })) return { repos: repos.map(repo => ({ @@ -433,7 +455,7 @@ export const getAllModsDisplayList = async () => { export const removeRepositoryAction = async (url: string) => { // todo remove mods - const choice = await showOptionsModal('Remove repository? Installed mods won\' be automatically removed.', ['Yes']) + const choice = await showOptionsModal('Remove repository? Installed mods wont be automatically removed.', ['Yes']) if (!choice) return await deleteRepository(url) modsReactiveUpdater.counter++ @@ -458,4 +480,21 @@ export const addRepositoryAction = async () => { await fetchRepository(url, url) } -// export const getAllMods = () => {} +export const getServerPlugin = async (plugin: string) => { + const mod = await getPlugin(plugin) + if (!mod) return null + if (mod.serverPlugin) { + return { + content: mod.serverPlugin, + version: mod.version + } + } + return null +} + +export const getAvailableServerPlugins = async () => { + const mods = await getAllMods() + return mods.filter(mod => mod.serverPlugin) +} + +window.inspectInstalledMods = getAllMods diff --git a/src/defaultLocalServerOptions.js b/src/defaultLocalServerOptions.js index cd949567..2acd3bce 100644 --- a/src/defaultLocalServerOptions.js +++ b/src/defaultLocalServerOptions.js @@ -8,6 +8,7 @@ module.exports = { 'gameMode': 0, 'difficulty': 0, 'worldFolder': 'world', + 'pluginsFolder': true, // todo set sid, disable entities auto-spawn 'generation': { // grass_field diff --git a/src/react/CreateWorld.tsx b/src/react/CreateWorld.tsx index a936594b..32e67ddb 100644 --- a/src/react/CreateWorld.tsx +++ b/src/react/CreateWorld.tsx @@ -1,11 +1,14 @@ import { useEffect, useState } from 'react' import { proxy, useSnapshot } from 'valtio' import { filesize } from 'filesize' +import { getAvailableServerPlugins } from '../clientMods' +import { showModal } from '../globalState' import Input from './Input' import Screen from './Screen' import Button from './Button' import SelectGameVersion from './SelectGameVersion' import styles from './createWorld.module.css' +import { InputOption, showInputsModal, showOptionsModal } from './SelectOption' // const worldTypes = ['default', 'flat', 'largeBiomes', 'amplified', 'customized', 'buffet', 'debug_all_block_states'] const worldTypes = ['default', 'flat'/* , 'void' */] @@ -15,13 +18,14 @@ export const creatingWorldState = proxy({ title: '', type: worldTypes[0], gameMode: gameModes[0], - version: '' + version: '', + plugins: [] as string[] }) export default ({ cancelClick, createClick, customizeClick, versions, defaultVersion }) => { const [quota, setQuota] = useState('') - const { title, type, version, gameMode } = useSnapshot(creatingWorldState) + const { title, type, version, gameMode, plugins } = useSnapshot(creatingWorldState) useEffect(() => { creatingWorldState.version = defaultVersion void navigator.storage?.estimate?.().then(({ quota, usage }) => { @@ -69,7 +73,38 @@ export default ({ cancelClick, createClick, customizeClick, versions, defaultVer creatingWorldState.gameMode = gameModes[index === gameModes.length - 1 ? 0 : index + 1] }} > - Gamemode: {gameMode} + Game Mode: {gameMode} + +
+
+ +
Default and other world types are WIP
@@ -80,7 +115,11 @@ export default ({ cancelClick, createClick, customizeClick, versions, defaultVer }} >Cancel - +
Note: save important worlds in folders on your hard drive!
{quota}
diff --git a/src/react/CreateWorldProvider.tsx b/src/react/CreateWorldProvider.tsx index b01f129c..505fab89 100644 --- a/src/react/CreateWorldProvider.tsx +++ b/src/react/CreateWorldProvider.tsx @@ -1,7 +1,10 @@ +import fs from 'fs' +import path from 'path' import { hideCurrentModal, showModal } from '../globalState' import defaultLocalServerOptions from '../defaultLocalServerOptions' import { mkdirRecursive, uniqueFileNameFromWorldName } from '../browserfs' import supportedVersions from '../supportedVersions.mjs' +import { getServerPlugin } from '../clientMods' import CreateWorld, { WorldCustomize, creatingWorldState } from './CreateWorld' import { getWorldsPath } from './SingleplayerProvider' import { useIsModalActive } from './utilsApp' @@ -14,7 +17,7 @@ export default () => { const versions = Object.values(versionsPerMinor).map(x => { return { version: x, - label: x === defaultLocalServerOptions.version ? `${x} (available offline)` : x + label: x === defaultLocalServerOptions.version ? `${x} (default)` : x } }) return { }} createClick={async () => { // create new world - const { title, type, version, gameMode } = creatingWorldState + const { title, type, version, gameMode, plugins } = creatingWorldState // todo display path in ui + disable if exist const savePath = await uniqueFileNameFromWorldName(title, getWorldsPath()) await mkdirRecursive(savePath) + for (const plugin of plugins) { + // eslint-disable-next-line no-await-in-loop + const { content, version } = await getServerPlugin(plugin) ?? {} + if (content) { + // eslint-disable-next-line no-await-in-loop + await mkdirRecursive(path.join(savePath, 'plugins')) + // eslint-disable-next-line no-await-in-loop + await fs.promises.writeFile(path.join(savePath, 'plugins', `${plugin}-${version}.js`), content) + } + } let generation if (type === 'flat') { generation = { diff --git a/src/react/ModsPage.tsx b/src/react/ModsPage.tsx index d716f7f7..8aa751ac 100644 --- a/src/react/ModsPage.tsx +++ b/src/react/ModsPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useMemo } from 'react' import { useSnapshot } from 'valtio' -import { addRepositoryAction, setEnabledModAction, getAllModsDisplayList, installModByName, selectAndRemoveRepository, uninstallModAction, fetchAllRepositories, modsReactiveUpdater, modsErrors } from '../clientMods' +import { addRepositoryAction, setEnabledModAction, getAllModsDisplayList, installModByName, selectAndRemoveRepository, uninstallModAction, fetchAllRepositories, modsReactiveUpdater, modsErrors, fetchRepository } from '../clientMods' import { createNotificationProgressReporter, ProgressReporter } from '../core/progressReporter' import { useIsModalActive } from './utilsApp' import Input from './Input' @@ -8,12 +8,46 @@ import Button from './Button' import styles from './mods.module.css' import { showOptionsModal } from './SelectOption' import Screen from './Screen' -import { pixelartIcons } from './PixelartIcon' +import PixelartIcon, { pixelartIcons } from './PixelartIcon' import { showNotification } from './NotificationProvider' import { usePassesScaledDimensions } from './UIProvider' +import { appStorage } from './appStorageProvider' type ModsData = Awaited> +const ModListItem = ({ + mod, + onClick, + hasError +}: { + mod: ModsData['repos'][0]['packages'][0], + onClick: () => void, + hasError: boolean +}) => ( +
+
+ {mod.name} + {mod.installedVersion && mod.installedVersion !== mod.version && ( + + )} +
+
+ {mod.description} + {mod.author && ` • By ${mod.author}`} + {mod.version && ` • v${mod.version}`} + {mod.serverPlugin && ` • World plugin`} +
+
+) + const ModSidebar = ({ mod }: { mod: (ModsData['repos'][0]['packages'][0] & { repo?: string }) | null }) => { const errors = useSnapshot(modsErrors) @@ -35,14 +69,17 @@ const ModSidebar = ({ mod }: { mod: (ModsData['repos'][0]['packages'][0] & { rep return ( <>
-
{mod.name}
+
+ {mod.name} +
{mod.description}
- {mod.author && `Author: ${mod.author}`} - {mod.version && `\nVersion: ${mod.version}`} - {mod.section && `\nSection: ${mod.section}`} + {mod.author && `Author: ${mod.author}\n`} + {mod.version && `Version: ${mod.version}\n`} + {mod.installedVersion && mod.installedVersion !== mod.version && `Installed version: ${mod.installedVersion}\n`} + {mod.section && `Section: ${mod.section}\n`}
{errors[mod.name]?.length > 0 && (
@@ -57,7 +94,7 @@ const ModSidebar = ({ mod }: { mod: (ModsData['repos'][0]['packages'][0] & { rep
{mod.installed ? ( <> - {mod.enabled ? ( + {mod.activated ? (
@@ -244,21 +311,13 @@ export default () => {
{expandedRepos[repo.url] && (
- {repo.packages.map((mod, index) => ( -
( + setSelectedModIndex(allModsArray.findIndex(m => m.name === mod.name))} - data-enabled={mod.enabled} - data-has-error={errors[mod.name]?.length > 0} - > -
{mod.name}
-
- {mod.description} - {mod.author && ` • By ${mod.author}`} - {mod.version && ` • v${mod.version}`} -
-
+ hasError={errors[mod.name]?.length > 0} + /> ))}
)} @@ -273,20 +332,12 @@ export default () => {
{filteredMods.modsWithoutRepos.map(mod => ( -
setSelectedModIndex(allModsArray.findIndex(m => m.name === mod.name))} - data-enabled={mod.enabled} - data-has-error={errors[mod.name]?.length > 0} - > -
{mod.name}
-
- {mod.description} - {mod.author && ` • By ${mod.author}`} - {mod.version && ` • v${mod.version}`} -
-
+ hasError={errors[mod.name]?.length > 0} + /> ))}
diff --git a/src/react/SelectOption.tsx b/src/react/SelectOption.tsx index 14f3a1a3..5cc338bc 100644 --- a/src/react/SelectOption.tsx +++ b/src/react/SelectOption.tsx @@ -48,11 +48,12 @@ export const showOptionsModal = async ( }) } -type InputOption = { - type: 'text' | 'checkbox' +export type InputOption = { + type: 'text' | 'checkbox' | 'button' defaultValue?: string | boolean label?: string placeholder?: string + onButtonClick?: () => void } export const showInputsModal = async >( title: string, @@ -150,6 +151,15 @@ export default () => { {label} )} + {input.type === 'button' && ( + + )}
})}
diff --git a/src/react/appStorageProvider.ts b/src/react/appStorageProvider.ts index cb2dd215..3d2c30ab 100644 --- a/src/react/appStorageProvider.ts +++ b/src/react/appStorageProvider.ts @@ -39,6 +39,7 @@ type StorageData = { authenticatedAccounts: AuthenticatedAccount[] serversList: StoreServerItem[] | undefined modsAutoUpdateLastCheck: number | undefined + firstModsPageVisit: boolean } const oldKeysAliases: Partial> = { @@ -79,6 +80,7 @@ const defaultStorageData: StorageData = { authenticatedAccounts: [], serversList: undefined, modsAutoUpdateLastCheck: undefined, + firstModsPageVisit: true, } export const setStorageDataOnAppConfigLoad = () => { diff --git a/src/react/mods.module.css b/src/react/mods.module.css index f2217394..45afa109 100644 --- a/src/react/mods.module.css +++ b/src/react/mods.module.css @@ -91,6 +91,7 @@ .modInfoText { font-size: 10px; + white-space: pre-wrap; color: #bcbcbc; } @@ -115,6 +116,7 @@ font-size: 12px; color: white; margin-bottom: 4px; + display: flex; } .modRowInfo { From 7fb49c1d20757c258c617b464a1519bf79c8be79 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Thu, 20 Mar 2025 04:00:05 +0300 Subject: [PATCH 07/16] up config --- assets/config.html | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/assets/config.html b/assets/config.html index 090be72d..9bd2dd8e 100644 --- a/assets/config.html +++ b/assets/config.html @@ -11,14 +11,28 @@ location.reload(); } } + + function removeAllData() { + localStorage.removeItem('serversList') + localStorage.removeItem('serversHistory') + localStorage.removeItem('authenticatedAccounts') + localStorage.removeItem('modsAutoUpdateLastCheck') + localStorage.removeItem('firstModsPageVisit') + localStorage.removeItem('proxiesData') + localStorage.removeItem('keybindings') + localStorage.removeItem('username') + localStorage.removeItem('customCommands') + localStorage.removeItem('options') + }
- - - - + + + + +
From ce7b065cbfd7d95531e62bac7d6e7244758ce01f Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 26 Mar 2025 09:38:28 +0300 Subject: [PATCH 08/16] up fields --- src/clientMods.ts | 39 ++++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/src/clientMods.ts b/src/clientMods.ts index 630b96f2..59869c1f 100644 --- a/src/clientMods.ts +++ b/src/clientMods.ts @@ -1,6 +1,8 @@ /* eslint-disable no-await-in-loop */ import { openDB } from 'idb' -import * as react from 'react' +import * as React from 'react' +import * as valtio from 'valtio' +import * as valtioUtils from 'valtio/utils' import { gt } from 'semver' import { proxy } from 'valtio' import { options } from './optionsStorage' @@ -111,10 +113,13 @@ export interface ClientMod { scriptMainUnstable?: string; serverPlugin?: string // serverPlugins?: string[] - // workerScript?: string + // mesherThread?: string stylesGlobal?: string + threeJsBackend?: string // three.js // stylesLocal?: string + requiresNetwork?: boolean + fullyOffline?: boolean description?: string author?: string section?: string @@ -135,6 +140,7 @@ export type ClientModDefinition = Omit & { scriptMainUnstable?: boolean stylesGlobal?: boolean serverPlugin?: boolean + threeJsBackend?: boolean } async function savePlugin (data: ClientMod) { @@ -198,7 +204,11 @@ window.mcraft = { version: process.env.RELEASE_TAG, build: process.env.BUILD_VERSION, ui: {}, - react, + React, + valtio: { + ...valtio, + ...valtioUtils, + }, // openDB } @@ -226,10 +236,17 @@ const activateMod = async (mod: ClientMod, reason: string) => { module.default?.(structuredClone(mod)) window.loadedMods[mod.name] = module } catch (e) { - // if (e instanceof Error && e.message.startsWith('Cannot find module')) { - // throw new Error(`mainUnstable.js is not valid ES module! Ensure you have default export with function to activate.`) - // } - // console.error(`Error loading mod ${mod.name}:`, e) + throw e + } + } + if (mod.threeJsBackend) { + const blob = new Blob([mod.threeJsBackend], { type: 'text/javascript' }) + const url = URL.createObjectURL(blob) + // eslint-disable-next-line no-useless-catch + try { + const module = await import(/* webpackIgnore: true */ url) + module.default?.(structuredClone(mod)) + } catch (e) { throw e } } @@ -300,6 +317,14 @@ const installOrUpdateMod = async (repo: Repository, mod: ClientModDefinition, ac } ) } + if (mod.threeJsBackend) { + await progress?.executeWithMessage( + `Downloading ${mod.name} three.js backend`, + async () => { + mod.threeJsBackend = await fetchData(['three.js']) as any + } + ) + } if (mod.serverPlugin) { if (mod.name.endsWith('.disabled')) throw new Error(`Mod name ${mod.name} can't end with .disabled`) await progress?.executeWithMessage( From 396e539420db3c07b252bc243963fae374a2cb90 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 26 Mar 2025 10:19:08 +0300 Subject: [PATCH 09/16] official threejs backend script support --- renderer/viewer/three/graphicsBackend.ts | 17 ++++++++++++++--- src/clientMods.ts | 3 +-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/renderer/viewer/three/graphicsBackend.ts b/renderer/viewer/three/graphicsBackend.ts index ce18050b..8aeb3587 100644 --- a/renderer/viewer/three/graphicsBackend.ts +++ b/renderer/viewer/three/graphicsBackend.ts @@ -1,7 +1,6 @@ import * as THREE from 'three' import { Vec3 } from 'vec3' -import { proxy } from 'valtio' -import { GraphicsBackendLoader, GraphicsBackend, GraphicsInitOptions, DisplayWorldOptions, RendererReactiveState } from '../../../src/appViewer' +import { GraphicsBackendLoader, GraphicsBackend, GraphicsInitOptions, DisplayWorldOptions } from '../../../src/appViewer' import { ProgressReporter } from '../../../src/core/progressReporter' import { WorldRendererThree } from './worldrendererThree' import { DocumentRenderer } from './documentRenderer' @@ -48,8 +47,9 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO if (worldRenderer) return if (!panoramaRenderer) { panoramaRenderer = new PanoramaRenderer(documentRenderer, initOptions, !!process.env.SINGLE_FILE_BUILD_MODE) - void panoramaRenderer.start() window.panoramaRenderer = panoramaRenderer + callModsMethod('panoramaReady', panoramaRenderer) + void panoramaRenderer.start() } } @@ -69,6 +69,7 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO worldRenderer?.render(sizeChanged) } window.world = worldRenderer + callModsMethod('worldReady', worldRenderer) } const disconnect = () => { @@ -110,7 +111,17 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO } } + globalThis.threeJsBackend = backend + globalThis.resourcesManager = initOptions.resourcesManager + callModsMethod('default', backend) + return backend } +const callModsMethod = (method: string, ...args: any[]) => { + for (const mod of Object.values((window.loadedMods ?? {}) as Record)) { + mod.threeJsBackendModule?.[method]?.(...args) + } +} + export default createGraphicsBackend diff --git a/src/clientMods.ts b/src/clientMods.ts index e50559a9..7f0caa1b 100644 --- a/src/clientMods.ts +++ b/src/clientMods.ts @@ -245,10 +245,9 @@ const activateMod = async (mod: ClientMod, reason: string) => { // eslint-disable-next-line no-useless-catch try { const module = await import(/* webpackIgnore: true */ url) - // for accessing global world var - module.default?.(structuredClone(mod)) // todo window.loadedMods[mod.name] ??= {} + // for accessing global world var window.loadedMods[mod.name].threeJsBackendModule = module } catch (e) { throw e From 23ae0e0f0ce0a6e33533cbcfd05b65b1d24f60f5 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Thu, 27 Mar 2025 07:26:35 +0300 Subject: [PATCH 10/16] globalize --- renderer/viewer/three/entity/EntityMesh.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/renderer/viewer/three/entity/EntityMesh.ts b/renderer/viewer/three/entity/EntityMesh.ts index 67222ed8..f1104100 100644 --- a/renderer/viewer/three/entity/EntityMesh.ts +++ b/renderer/viewer/three/entity/EntityMesh.ts @@ -551,3 +551,4 @@ export class EntityMesh { } } } +window.EntityMesh = EntityMesh From 6af7b7ae97035f1930f9adeaa6a2f729392a667c Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 23 Apr 2025 08:49:12 +0300 Subject: [PATCH 11/16] add a way to edit mods!! finalise threejs integrattion --- renderer/viewer/three/graphicsBackend.ts | 14 ++- src/appConfig.ts | 1 + src/appViewer.ts | 7 +- src/appViewerLoad.ts | 6 +- src/clientMods.ts | 70 +++++++++++-- src/index.ts | 2 +- src/optionsGuiScheme.tsx | 3 + src/react/ModsPage.tsx | 124 ++++++++++++++++++++++- src/react/SelectOption.tsx | 16 ++- src/react/mods.module.css | 16 +++ src/react/mods.module.css.d.ts | 1 + 11 files changed, 236 insertions(+), 24 deletions(-) diff --git a/renderer/viewer/three/graphicsBackend.ts b/renderer/viewer/three/graphicsBackend.ts index 4f1e90b6..14534d40 100644 --- a/renderer/viewer/three/graphicsBackend.ts +++ b/renderer/viewer/three/graphicsBackend.ts @@ -2,6 +2,7 @@ import * as THREE from 'three' import { Vec3 } from 'vec3' import { GraphicsBackendLoader, GraphicsBackend, GraphicsInitOptions, DisplayWorldOptions } from '../../../src/appViewer' import { ProgressReporter } from '../../../src/core/progressReporter' +import { showNotification } from '../../../src/react/NotificationProvider' import { WorldRendererThree } from './worldrendererThree' import { DocumentRenderer } from './documentRenderer' import { PanoramaRenderer } from './panorama' @@ -52,13 +53,14 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO let panoramaRenderer: PanoramaRenderer | null = null let worldRenderer: WorldRendererThree | null = null - const startPanorama = () => { + const startPanorama = async () => { if (worldRenderer) return if (!panoramaRenderer) { panoramaRenderer = new PanoramaRenderer(documentRenderer, initOptions, !!process.env.SINGLE_FILE_BUILD_MODE) window.panoramaRenderer = panoramaRenderer + callModsMethod('panoramaCreated', panoramaRenderer) + await panoramaRenderer.start() callModsMethod('panoramaReady', panoramaRenderer) - void panoramaRenderer.start() } } @@ -130,7 +132,13 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO const callModsMethod = (method: string, ...args: any[]) => { for (const mod of Object.values((window.loadedMods ?? {}) as Record)) { - mod.threeJsBackendModule?.[method]?.(...args) + try { + mod.threeJsBackendModule?.[method]?.(...args) + } catch (err) { + const errorMessage = `[mod three.js] Error calling ${method} on ${mod.name}: ${err}` + showNotification(errorMessage, 'error') + throw new Error(errorMessage) + } } } diff --git a/src/appConfig.ts b/src/appConfig.ts index 497b95ec..156c5974 100644 --- a/src/appConfig.ts +++ b/src/appConfig.ts @@ -29,6 +29,7 @@ export type AppConfig = { defaultLanguage?: string displayLanguageSelector?: boolean supportedLanguages?: string[] + showModsButton?: boolean } export const loadAppConfig = (appConfig: AppConfig) => { diff --git a/src/appViewer.ts b/src/appViewer.ts index 0f29b9a6..ca62bd1b 100644 --- a/src/appViewer.ts +++ b/src/appViewer.ts @@ -89,6 +89,8 @@ export interface GraphicsBackend { } export class AppViewer { + waitBackendLoadPromises = [] as Array> + resourcesManager = new ResourcesManager() worldView: WorldDataEmitter | undefined readonly config: GraphicsBackendConfig = { @@ -114,11 +116,14 @@ export class AppViewer { this.disconnectBackend() } - loadBackend (loader: GraphicsBackendLoader) { + async loadBackend (loader: GraphicsBackendLoader) { if (this.backend) { this.disconnectBackend() } + await Promise.all(this.waitBackendLoadPromises) + this.waitBackendLoadPromises = [] + this.backendLoader = loader const rendererSpecificSettings = {} as Record const rendererSettingsKey = `renderer.${this.backendLoader?.id}` diff --git a/src/appViewerLoad.ts b/src/appViewerLoad.ts index 96e3bf03..a7212262 100644 --- a/src/appViewerLoad.ts +++ b/src/appViewerLoad.ts @@ -15,7 +15,7 @@ const loadBackend = () => { showNotification(`No backend found for renderer ${options.activeRenderer}`, `Falling back to ${backends[0].id}`, true) backend = backends[0] } - appViewer.loadBackend(backend) + void appViewer.loadBackend(backend) } window.loadBackend = loadBackend if (process.env.SINGLE_FILE_BUILD_MODE) { @@ -27,7 +27,9 @@ if (process.env.SINGLE_FILE_BUILD_MODE) { } }) } else { - loadBackend() + setTimeout(() => { + loadBackend() + }) } const animLoop = () => { diff --git a/src/clientMods.ts b/src/clientMods.ts index 7f0caa1b..1ac2116a 100644 --- a/src/clientMods.ts +++ b/src/clientMods.ts @@ -125,6 +125,7 @@ export interface ClientMod { section?: string autoUpdateOverride?: boolean lastUpdated?: number + wasModifiedLocally?: boolean // todo depends, hashsum } @@ -133,17 +134,18 @@ const cleanupFetchedModData = (mod: ClientModDefinition | Record) = delete mod['repo'] delete mod['autoUpdateOverride'] delete mod['lastUpdated'] + delete mod['wasModifiedLocally'] return mod } -export type ClientModDefinition = Omit & { +export type ClientModDefinition = Omit & { scriptMainUnstable?: boolean stylesGlobal?: boolean serverPlugin?: boolean threeJsBackend?: boolean } -async function savePlugin (data: ClientMod) { +export async function saveClientModData (data: ClientMod) { const db = await dbPromise data.lastUpdated = Date.now() await db.put('mods', data) @@ -234,7 +236,8 @@ const activateMod = async (mod: ClientMod, reason: string) => { try { const module = await import(/* webpackIgnore: true */ url) module.default?.(structuredClone(mod)) - window.loadedMods[mod.name] = module + window.loadedMods[mod.name] ??= {} + window.loadedMods[mod.name].mainUnstableModule = module } catch (e) { throw e } @@ -345,7 +348,7 @@ const installOrUpdateMod = async (repo: Repository, mod: ClientModDefinition, ac await activateMod(mod as ClientMod, 'install') } } - await savePlugin(mod as ClientMod) + await saveClientModData(mod as ClientMod) delete modsUpdateStatus[mod.name] } catch (e) { // console.error(`Error installing mod ${mod.name}:`, e) @@ -449,12 +452,22 @@ export const setEnabledModAction = async (name: string, newEnabled: boolean) => } else { // todo deactivate mod mod.enabled = false - if (window.loadedMods?.[mod.name]?.deactivate) { - window.loadedMods[mod.name].deactivate() - delete window.loadedMods[mod.name] + if (window.loadedMods?.[mod.name]) { + if (window.loadedMods[mod.name]?.threeJsBackendModule) { + window.loadedMods[mod.name].threeJsBackendModule.deactivate() + delete window.loadedMods[mod.name].threeJsBackendModule + } + if (window.loadedMods[mod.name]?.mainUnstableModule) { + window.loadedMods[mod.name].mainUnstableModule.deactivate() + delete window.loadedMods[mod.name].mainUnstableModule + } + + if (Object.keys(window.loadedMods[mod.name]).length === 0) { + delete window.loadedMods[mod.name] + } } } - await savePlugin(mod) + await saveClientModData(mod) } export const modsReactiveUpdater = proxy({ @@ -467,7 +480,7 @@ export const getAllModsDisplayList = async () => { const modsWithoutRepos = installedMods.filter(mod => !repos.some(repo => repo.packages.some(m => m.name === mod.name))) const mapMods = (mapMods: ClientMod[]) => mapMods.map(mod => ({ ...mod, - installed: installedMods.some(m => m.name === mod.name), + installed: installedMods.find(m => m.name === mod.name), activated: !!window.loadedMods?.[mod.name], installedVersion: installedMods.find(m => m.name === mod.name)?.version, canBeActivated: mod.scriptMainUnstable || mod.stylesGlobal, @@ -526,3 +539,42 @@ export const getAvailableServerPlugins = async () => { } window.inspectInstalledMods = getAllMods + +type ModifiableField = { + field: string + label: string + language: string + getContent?: () => string +} + +// --- + +export const getAllModsModifiableFields = () => { + const fields: ModifiableField[] = [ + { + field: 'scriptMainUnstable', + label: 'Main Thread Script (unstable)', + language: 'js' + }, + { + field: 'stylesGlobal', + label: 'Global CSS Styles', + language: 'css' + }, + { + field: 'threeJsBackend', + label: 'Three.js Renderer Backend Thread', + language: 'js' + }, + { + field: 'serverPlugin', + label: 'Built-in server plugin', + language: 'js' + } + ] + return fields +} + +export const getModModifiableFields = (mod: ClientMod): ModifiableField[] => { + return getAllModsModifiableFields().filter(field => mod[field.field]) +} diff --git a/src/index.ts b/src/index.ts index 91c5be5d..43db2500 100644 --- a/src/index.ts +++ b/src/index.ts @@ -985,5 +985,5 @@ if (initialLoader) { window.pageLoaded = true void possiblyHandleStateVariable() -void appStartup() +appViewer.waitBackendLoadPromises.push(appStartup()) registerOpenBenchmarkListener() diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index 4814e134..20514489 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -230,7 +230,10 @@ export const guiOptionsScheme: { }, { custom () { + const { appConfig } = useSnapshot(miscUiState) const modsUpdateSnapshot = useSnapshot(modsUpdateStatus) + + if (appConfig?.showModsButton === false) return null return
) } +const EditingCodeWindow = ({ + contents, + language, + onClose +}: { + contents: string, + language: string, + onClose: (newContents?: string) => void +}) => { + const ref = useRef(null) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + e.stopImmediatePropagation() + } + } + window.addEventListener('keydown', handleKeyDown, { capture: true }) + return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) + }, []) + + return +
+