637 lines
19 KiB
TypeScript
637 lines
19 KiB
TypeScript
/* eslint-disable no-await-in-loop */
|
|
import { openDB } from 'idb'
|
|
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'
|
|
import { appStorage } from './react/appStorageProvider'
|
|
import { showInputsModal, showOptionsModal } from './react/SelectOption'
|
|
import { ProgressReporter } from './core/progressReporter'
|
|
import { showNotification } from './react/NotificationProvider'
|
|
|
|
let sillyProtection = false
|
|
const protectRuntime = () => {
|
|
if (sillyProtection) return
|
|
sillyProtection = true
|
|
const sensetiveKeys = new Set(['authenticatedAccounts', 'serversList', 'username'])
|
|
const proxy = new Proxy(window.localStorage, {
|
|
get (target, prop) {
|
|
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)
|
|
},
|
|
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)
|
|
}
|
|
})
|
|
Object.defineProperty(window, 'localStorage', {
|
|
value: proxy,
|
|
writable: false,
|
|
configurable: false,
|
|
})
|
|
}
|
|
|
|
// #region Database
|
|
const dbPromise = openDB('mods-db', 1, {
|
|
upgrade (db) {
|
|
db.createObjectStore('mods', {
|
|
keyPath: 'name',
|
|
})
|
|
db.createObjectStore('repositories', {
|
|
keyPath: 'url',
|
|
})
|
|
},
|
|
})
|
|
|
|
export interface ModSetting {
|
|
label?: string
|
|
type: 'toggle' | 'choice' | 'input' | 'slider'
|
|
hidden?: boolean
|
|
values?: string[]
|
|
inputType?: string
|
|
hint?: string
|
|
default?: any
|
|
}
|
|
|
|
export interface ModSettingsDict {
|
|
[settingId: string]: ModSetting
|
|
}
|
|
|
|
export interface ModAction {
|
|
method?: string
|
|
label?: string
|
|
/** @default false */
|
|
gameGlobal?: boolean
|
|
/** @default false */
|
|
onlyForeground?: boolean
|
|
}
|
|
|
|
// mcraft-repo.json
|
|
export interface McraftRepoFile {
|
|
packages: ClientModDefinition[]
|
|
/** @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 {
|
|
name: string; // unique identifier like owner.name
|
|
version: string
|
|
enabled?: boolean
|
|
|
|
scriptMainUnstable?: string;
|
|
serverPlugin?: string
|
|
// serverPlugins?: string[]
|
|
// mesherThread?: string
|
|
stylesGlobal?: string
|
|
threeJsBackend?: string // three.js
|
|
// stylesLocal?: string
|
|
|
|
requiresNetwork?: boolean
|
|
fullyOffline?: boolean
|
|
description?: string
|
|
author?: string
|
|
section?: string
|
|
autoUpdateOverride?: boolean
|
|
lastUpdated?: number
|
|
wasModifiedLocally?: boolean
|
|
// todo depends, hashsum
|
|
|
|
settings?: ModSettingsDict
|
|
actionsMain?: Record<string, ModAction>
|
|
}
|
|
|
|
const cleanupFetchedModData = (mod: ClientModDefinition | Record<string, any>) => {
|
|
delete mod['enabled']
|
|
delete mod['repo']
|
|
delete mod['autoUpdateOverride']
|
|
delete mod['lastUpdated']
|
|
delete mod['wasModifiedLocally']
|
|
return mod
|
|
}
|
|
|
|
export type ClientModDefinition = Omit<ClientMod, 'enabled' | 'wasModifiedLocally'> & {
|
|
scriptMainUnstable?: boolean
|
|
stylesGlobal?: boolean
|
|
serverPlugin?: boolean
|
|
threeJsBackend?: boolean
|
|
}
|
|
|
|
export async function saveClientModData (data: ClientMod) {
|
|
const db = await dbPromise
|
|
data.lastUpdated = Date.now()
|
|
await db.put('mods', data)
|
|
modsReactiveUpdater.counter++
|
|
}
|
|
|
|
async function getPlugin (name: string) {
|
|
const db = await dbPromise
|
|
return db.get('mods', name) as Promise<ClientMod | undefined>
|
|
}
|
|
|
|
export async function getAllMods () {
|
|
const db = await dbPromise
|
|
return db.getAll('mods') as Promise<ClientMod[]>
|
|
}
|
|
|
|
async function deletePlugin (name) {
|
|
const db = await dbPromise
|
|
await db.delete('mods', name)
|
|
modsReactiveUpdater.counter++
|
|
}
|
|
|
|
async function removeAllMods () {
|
|
const db = await dbPromise
|
|
await db.clear('mods')
|
|
modsReactiveUpdater.counter++
|
|
}
|
|
|
|
// ---
|
|
|
|
async function saveRepository (data: Repository) {
|
|
const db = await dbPromise
|
|
data.lastUpdated = Date.now()
|
|
await db.put('repositories', data)
|
|
}
|
|
|
|
async function getRepository (url: string) {
|
|
const db = await dbPromise
|
|
return db.get('repositories', url) as Promise<Repository | undefined>
|
|
}
|
|
|
|
async function getAllRepositories () {
|
|
const db = await dbPromise
|
|
return db.getAll('repositories') as Promise<Repository[]>
|
|
}
|
|
window.getAllRepositories = getAllRepositories
|
|
|
|
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,
|
|
valtio: {
|
|
...valtio,
|
|
...valtioUtils,
|
|
},
|
|
// openDB
|
|
}
|
|
|
|
const activateMod = async (mod: ClientMod, reason: string) => {
|
|
if (mod.enabled === false) return false
|
|
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
|
|
}
|
|
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: '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), { settings: getModSettingsProxy(mod) })
|
|
window.loadedMods[mod.name] ??= {}
|
|
window.loadedMods[mod.name].mainUnstableModule = module
|
|
} catch (e) {
|
|
throw e
|
|
}
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
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)
|
|
// todo
|
|
window.loadedMods[mod.name] ??= {}
|
|
// for accessing global world var
|
|
window.loadedMods[mod.name].threeJsBackendModule = module
|
|
} catch (e) {
|
|
throw e
|
|
}
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
mod.enabled = true
|
|
return true
|
|
}
|
|
|
|
export const appStartup = async () => {
|
|
void checkModsUpdates()
|
|
|
|
const mods = await getAllMods()
|
|
for (const mod of mods) {
|
|
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<string, [string, string]>)
|
|
export const modsWaitingReloadStatus = proxy({} as Record<string, boolean>)
|
|
export const modsErrors = proxy({} as Record<string, string[]>)
|
|
|
|
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 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 {
|
|
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))
|
|
throw e
|
|
}
|
|
}
|
|
console.warn(`[${mod.name}] Error installing component of ${urls[0]}: ${errored.join(', ')}`)
|
|
return undefined
|
|
}
|
|
if (mod.stylesGlobal) {
|
|
await progress?.executeWithMessage(
|
|
`Downloading ${mod.name} styles`,
|
|
async () => {
|
|
mod.stylesGlobal = await fetchData(['global.css']) as any
|
|
}
|
|
)
|
|
}
|
|
if (mod.scriptMainUnstable) {
|
|
await progress?.executeWithMessage(
|
|
`Downloading ${mod.name} script`,
|
|
async () => {
|
|
mod.scriptMainUnstable = await fetchData(['mainUnstable.js']) as any
|
|
}
|
|
)
|
|
}
|
|
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(
|
|
`Downloading ${mod.name} server plugin`,
|
|
async () => {
|
|
mod.serverPlugin = await fetchData(['serverPlugin.js']) as any
|
|
}
|
|
)
|
|
}
|
|
if (activate) {
|
|
// 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 saveClientModData(mod as ClientMod)
|
|
delete modsUpdateStatus[mod.name]
|
|
} catch (e) {
|
|
// console.error(`Error installing mod ${mod.name}:`, e)
|
|
throw e
|
|
}
|
|
}
|
|
|
|
const checkRepositoryUpdates = async (repo: Repository) => {
|
|
for (const mod of repo.packages) {
|
|
|
|
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).catch(e => {
|
|
console.error(`Error updating mod ${mod.name}:`, e)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
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())
|
|
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.warn(`Error fetching repository (trying other mirrors) ${url}:`, e)
|
|
return false
|
|
}
|
|
}
|
|
|
|
export const fetchAllRepositories = async () => {
|
|
const repositories = await getAllRepositories()
|
|
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
|
|
|
|
if (await fetchRepository(repo.url, url, !isLast)) break
|
|
}
|
|
}))
|
|
appStorage.modsAutoUpdateLastCheck = Date.now()
|
|
}
|
|
|
|
const checkModsUpdates = async () => {
|
|
await autoRefreshModRepositories()
|
|
for (const repo of await getAllRepositories()) {
|
|
|
|
await checkRepositoryUpdates(repo)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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}`)
|
|
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, newEnabled: boolean) => {
|
|
const mod = await getPlugin(name)
|
|
if (!mod) throw new Error(`Mod ${name} not found`)
|
|
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]) {
|
|
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 saveClientModData(mod)
|
|
}
|
|
|
|
export const modsReactiveUpdater = proxy({
|
|
counter: 0
|
|
})
|
|
|
|
export const getAllModsDisplayList = async () => {
|
|
const repos = await getAllRepositories()
|
|
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: 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,
|
|
}))
|
|
return {
|
|
repos: repos.map(repo => ({
|
|
...repo,
|
|
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 wont 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 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
|
|
|
|
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])
|
|
}
|
|
|
|
export const getModSettingsProxy = (mod: ClientMod) => {
|
|
if (!mod.settings) return valtio.proxy({})
|
|
|
|
const proxy = valtio.proxy({})
|
|
for (const [key, setting] of Object.entries(mod.settings)) {
|
|
proxy[key] = options[`mod-${mod.name}-${key}`] ?? setting.default
|
|
}
|
|
|
|
valtio.subscribe(proxy, (ops) => {
|
|
for (const op of ops) {
|
|
const [type, path, value] = op
|
|
const key = path[0] as string
|
|
options[`mod-${mod.name}-${key}`] = value
|
|
}
|
|
})
|
|
|
|
return proxy
|
|
}
|
|
|
|
export const callMethodAction = async (modName: string, type: 'main', method: string) => {
|
|
try {
|
|
const mod = window.loadedMods?.[modName]
|
|
await mod[method]()
|
|
} catch (err) {
|
|
showNotification(`Failed to execute ${method}`, `Problem in ${type} js script of ${modName}`, true)
|
|
}
|
|
}
|