diff --git a/public/images/game_selection/placeholder.webp b/public/images/game_selection/placeholder.webp new file mode 100644 index 000000000..a87083d47 Binary files /dev/null and b/public/images/game_selection/placeholder.webp differ diff --git a/public/images/game_selection/thunderstore-beta.webp b/public/images/game_selection/thunderstore-beta.webp deleted file mode 100644 index 7284eb280..000000000 Binary files a/public/images/game_selection/thunderstore-beta.webp and /dev/null differ diff --git a/quasar.config.ts b/quasar.config.ts index e9d7b21c7..d9f477a22 100644 --- a/quasar.config.ts +++ b/quasar.config.ts @@ -100,6 +100,15 @@ export default defineConfig((ctx) => { https: false, port: 9020, open: true, // opens browser window automatically + proxy: { + // Proxy for a local ecosystem-schema deployment. + // https://github.com/thunderstore-io/ecosystem-schema + '/_local': { + target: 'http://localhost:1337', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/_local/, ''), + }, + }, }, // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework diff --git a/scripts/sync.ts b/scripts/sync.ts index 49493c4cb..025f94fdc 100644 --- a/scripts/sync.ts +++ b/scripts/sync.ts @@ -1,12 +1,23 @@ import fs from "fs"; +import path from "path"; import {FetchingJSONSchemaStore, InputData, JSONSchemaInput, quicktype} from "quicktype-core"; -const ECOSYSTEM_DATA_URL = "https://thunderstore.io/api/experimental/schema/dev/latest/"; -const ECOSYSTEM_JSON_SCHEMA_URL = "https://thunderstore.io/api/experimental/schema/ecosystem-json-schema/latest/"; +const REMOTE_SOURCE = { + ecosystemDataUrl: "https://thunderstore.io/api/experimental/schema/dev/latest/", + ecosystemJsonSchemaUrl: "https://thunderstore.io/api/experimental/schema/ecosystem-json-schema/latest/", + gameImageBaseUrl: "https://gcdn.thunderstore.io/assets", +}; +const LOCAL_SOURCE = { + ecosystemDataUrl: "http://localhost:1337/latest.json", + ecosystemJsonSchemaUrl: "http://localhost:1337/latest.schema.json", + gameImageBaseUrl: "http://localhost:1337/assets", +}; + const ECOSYSTEM_DATA_PATH = "./src/assets/data/ecosystem.json"; const ECOSYSTEM_JSON_SCHEMA_PATH = "./src/assets/data/ecosystemJsonSchema.json"; const ECOSYSTEM_DATA_TYPES_PATH = "./src/assets/data/ecosystemTypes.ts"; +const GAME_IMAGE_DIR_PATH = "./public/images/game_selection"; /** * This script synchronizes the in-repo ecosystem schema JSON to the latest @@ -22,16 +33,21 @@ async function updateSchema() { console.log("Skipping API update, reading JSON schema from disk..."); schema = fs.readFileSync(ECOSYSTEM_JSON_SCHEMA_PATH); } else { + const source = await isReachable("http://localhost:1337/healthz") ? LOCAL_SOURCE : REMOTE_SOURCE; + console.log(`Syncing from ${new URL(source.ecosystemDataUrl).origin}...`); + console.log("Updating ecosystem.json..."); - const response = await fetch(ECOSYSTEM_DATA_URL); + const response = await fetch(source.ecosystemDataUrl); if (response.status !== 200) { throw new Error(`Received non-200 status from schema API: ${response.status}`); } const data = Buffer.from(await response.arrayBuffer()); fs.writeFileSync(ECOSYSTEM_DATA_PATH, data); + await updateGameImages(data, source.gameImageBaseUrl); + console.log("Updating ecosystemJsonSchema.json..."); - const schemaResponse = await fetch(ECOSYSTEM_JSON_SCHEMA_URL); + const schemaResponse = await fetch(source.ecosystemJsonSchemaUrl); if (schemaResponse.status !== 200) { throw new Error(`Received non-200 status from schema API: ${schemaResponse.status}`); } @@ -58,6 +74,53 @@ async function updateSchema() { updateSchema(); +async function updateGameImages(ecosystemData: Buffer, baseUrl: string): Promise { + const ecosystem = JSON.parse(ecosystemData.toString()); + const iconUrls = new Set(); + for (const game of Object.values(ecosystem.games ?? {})) { + for (const entry of game?.r2modman ?? []) { + const iconUrl = entry?.meta?.iconUrl; + if (typeof iconUrl === "string" && iconUrl) { + iconUrls.add(iconUrl); + } + } + } + + console.log(`Updating ${iconUrls.size} game images...`); + let failures = 0; + for (const iconUrl of iconUrls) { + try { + await fetchGameImage(baseUrl, iconUrl); + } catch (e) { + console.warn(` ${(e as Error).message}`); + failures += 1; + } + } + if (failures > 0) { + console.warn(`Failed to fetch ${failures} of ${iconUrls.size} game images.`); + } +} + +async function isReachable(url: string): Promise { + try { + await fetch(url); + return true; + } catch { + return false; + } +} + +async function fetchGameImage(baseUrl: string, iconUrl: string): Promise { + const response = await fetch(`${baseUrl}/${iconUrl}`); + if (response.status !== 200) { + throw new Error(`Received non-200 status fetching ${iconUrl}: ${response.status}`); + } + const data = Buffer.from(await response.arrayBuffer()); + const outPath = path.join(GAME_IMAGE_DIR_PATH, iconUrl); + fs.mkdirSync(path.dirname(outPath), {recursive: true}); + fs.writeFileSync(outPath, data); +} + function enumKeysToUpperSnakeCase(tsCode: string): string { const enumRegex = /export enum (\w+) \{([\s\S]*?)\}/g; const enumKeyValueRegex = /(\w+) = "(.*?)"/g; diff --git a/src-electron/electron-main.ts b/src-electron/electron-main.ts index 80ce37c70..d407246f2 100644 --- a/src-electron/electron-main.ts +++ b/src-electron/electron-main.ts @@ -135,8 +135,15 @@ app.whenReady().then(() => { if (filePath.endsWith(path.sep)) { filePath = filePath.substring(0, filePath.length - 1); } - const fileContent = await fs.promises.readFile(path.join(publicFolder, filePath)) - return new Response(fileContent); + try { + const fileContent = await fs.promises.readFile(path.join(publicFolder, filePath)); + return new Response(fileContent); + } catch (e: any) { + if (e?.code === 'ENOENT') { + return new Response(null, { status: 404 }); + } + throw e; + } }) }); diff --git a/src/App.vue b/src/App.vue index e0c7d499e..9f05d700b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -57,6 +57,8 @@ import { NodeFsImplementation } from './providers/node/fs/NodeFsImplementation'; import { useRouter } from 'vue-router'; import { ProtocolProviderImplementation } from './providers/generic/protocol/ProtocolProviderImplementation'; import { provideProtocolImplementation } from './providers/generic/protocol/ProtocolProvider'; +import GameImageProvider, { provideGameImageImplementation } from './providers/generic/image/GameImageProvider'; +import { GameImageProviderImplementation } from './r2mm/image/GameImageProviderImpl'; import BreadcrumbNavigator from 'components/breadcrumbs/BreadcrumbNavigator.vue'; const store = baseStore; @@ -100,6 +102,7 @@ DataFolderProvider.provide(() => new DataFolderProviderImpl()); PlatformInterceptorProvider.provide(() => new PlatformInterceptorImpl()); provideProtocolImplementation(() => ProtocolProviderImplementation) +provideGameImageImplementation(() => GameImageProviderImplementation); BindLoaderImpl.bind(); @@ -133,6 +136,8 @@ onMounted(async () => { await FileUtils.ensureDirectory(PathResolver.APPDATA_DIR); + await GameImageProvider.init(); + await ThemeManager.apply(); window.app.isApplicationPortable().then((isPortable: boolean) => { diff --git a/src/components/composables/GameImageComposable.ts b/src/components/composables/GameImageComposable.ts new file mode 100644 index 000000000..ba90e8048 --- /dev/null +++ b/src/components/composables/GameImageComposable.ts @@ -0,0 +1,12 @@ +import { ref } from 'vue'; +import GameImageProvider from '../../providers/generic/image/GameImageProvider'; + +export function useGameImageComposable() { + const imageSrc = ref(GameImageProvider.placeholderUrl); + + async function setIcon(iconUrl: string) { + imageSrc.value = await GameImageProvider.resolve(iconUrl); + } + + return { imageSrc, setIcon }; +} diff --git a/src/components/game-selection/GameSelectionCard.vue b/src/components/game-selection/GameSelectionCard.vue index b867f5d25..0926788dd 100644 --- a/src/components/game-selection/GameSelectionCard.vue +++ b/src/components/game-selection/GameSelectionCard.vue @@ -3,7 +3,7 @@