Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added public/images/game_selection/placeholder.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed public/images/game_selection/thunderstore-beta.webp
Binary file not shown.
9 changes: 9 additions & 0 deletions quasar.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 67 additions & 4 deletions scripts/sync.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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}`);
}
Expand All @@ -58,6 +74,53 @@ async function updateSchema() {

updateSchema();

async function updateGameImages(ecosystemData: Buffer, baseUrl: string): Promise<void> {
const ecosystem = JSON.parse(ecosystemData.toString());
const iconUrls = new Set<string>();
for (const game of Object.values<any>(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<boolean> {
try {
await fetch(url);
return true;
} catch {
return false;
}
}

async function fetchGameImage(baseUrl: string, iconUrl: string): Promise<void> {
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;
Expand Down
11 changes: 9 additions & 2 deletions src-electron/electron-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
})
});

Expand Down
5 changes: 5 additions & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -100,6 +102,7 @@ DataFolderProvider.provide(() => new DataFolderProviderImpl());
PlatformInterceptorProvider.provide(() => new PlatformInterceptorImpl());

provideProtocolImplementation(() => ProtocolProviderImplementation)
provideGameImageImplementation(() => GameImageProviderImplementation);

BindLoaderImpl.bind();

Expand Down Expand Up @@ -133,6 +136,8 @@ onMounted(async () => {

await FileUtils.ensureDirectory(PathResolver.APPDATA_DIR);

await GameImageProvider.init();

await ThemeManager.apply();

window.app.isApplicationPortable().then((isPortable: boolean) => {
Expand Down
12 changes: 12 additions & 0 deletions src/components/composables/GameImageComposable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ref } from 'vue';
import GameImageProvider from '../../providers/generic/image/GameImageProvider';

export function useGameImageComposable() {
const imageSrc = ref<string>(GameImageProvider.placeholderUrl);

async function setIcon(iconUrl: string) {
imageSrc.value = await GameImageProvider.resolve(iconUrl);
}

return { imageSrc, setIcon };
}
11 changes: 5 additions & 6 deletions src/components/game-selection/GameSelectionCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<div class="game-card">
<div class="game-card__image">
<template v-if="activeTab === GameInstanceType.GAME">
<img :src="getImageHref(`/images/game_selection/${game.gameImage}`)" alt="Game Logo" class="game-card__thumbnail"/>
<img :src="imageSrc" alt="Game Logo" class="game-card__thumbnail"/>
</template>
<template v-else>
<div class="game-card__placeholder">{{ game.displayName }}</div>
Expand Down Expand Up @@ -34,9 +34,10 @@
</template>

<script lang="ts" setup>
import { watch } from 'vue';
import Game from '../../model/game/Game';
import { GameInstanceType } from '../../model/schema/ThunderstoreSchema';
import ProtocolProvider from '../../providers/generic/protocol/ProtocolProvider';
import { useGameImageComposable } from '../composables/GameImageComposable';

type Props = {
game: Game;
Expand All @@ -53,9 +54,8 @@ const emit = defineEmits<{
'toggle-favourite': [game: Game];
}>();

function getImageHref(image: string) {
return ProtocolProvider.getPublicAssetUrl(image);
}
const { imageSrc, setIcon } = useGameImageComposable();
watch(() => props.game.iconUrl, setIcon, { immediate: true });
</script>

<style scoped lang="scss">
Expand All @@ -80,7 +80,6 @@ function getImageHref(image: string) {

.game-card__image {
display: block;
background-color: black;
}

.game-card__thumbnail {
Expand Down
9 changes: 6 additions & 3 deletions src/components/navigation/ManagerActivityBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<div class="activity-bar--left">
<div class="activity-bar__group">
<button class="activity-bar__context-item" id="game-switch-button" @click.prevent.stop="changeGame">
<img class="game-icon" :src="ProtocolProvider.getPublicAssetUrl(`/images/game_selection/${activeGame.gameImage}`)" alt="Game icon"/>
<img class="game-icon" :src="imageSrc" alt="Game icon"/>
<span>{{ activeGame.displayName }}</span>
</button>
<span class="activity-bar__item-label non-selectable activity-bar__item-divider">/</span>
Expand Down Expand Up @@ -32,19 +32,22 @@
</template>

<script lang="ts" setup>
import { computed } from 'vue';
import { computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import { getStore } from '../../providers/generic/store/StoreProvider';
import { State } from '../../store';
import { ActivityDropdown } from '../all';
import ProtocolProvider from '../../providers/generic/protocol/ProtocolProvider';
import { useGameImageComposable } from '../composables/GameImageComposable';

const store = getStore<State>();
const router = useRouter();

const activeGame = computed(() => store.state.activeGame);
const profile = computed(() => store.getters['profile/activeProfile']);

const { imageSrc, setIcon } = useGameImageComposable();
watch(() => activeGame.value.iconUrl, setIcon, { immediate: true });

function changeGame() {
router.push('/');
}
Expand Down
10 changes: 5 additions & 5 deletions src/model/game/Game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default class Game {
private readonly _thunderstoreUrl: string;
private readonly _thunderstoreIdentifier: string;
private readonly _storePlatformMetadata: StorePlatformMetadata[];
private readonly _gameImage: string;
private readonly _iconUrl: string;
private readonly _displayMode: GameSelectionDisplayMode;
private readonly _instanceType: GameInstanceType;
private readonly _packageLoader: PackageLoader;
Expand All @@ -32,7 +32,7 @@ export default class Game {
tsUrl: string,
tsIdentifier: string,
platforms: StorePlatformMetadata[],
gameImage: string,
iconUrl: string,
displayMode: GameSelectionDisplayMode,
instanceType: GameInstanceType,
packageLoader: PackageLoader,
Expand All @@ -49,7 +49,7 @@ export default class Game {
this._thunderstoreIdentifier = tsIdentifier;
this._storePlatformMetadata = platforms;
this._activePlatform = platforms[0];
this._gameImage = gameImage;
this._iconUrl = iconUrl;
this._displayMode = displayMode;
this._instanceType = instanceType;
this._packageLoader = packageLoader;
Expand Down Expand Up @@ -109,8 +109,8 @@ export default class Game {
this._activePlatform = platform;
}

get gameImage(): string {
return this._gameImage;
get iconUrl(): string {
return this._iconUrl;
}

get displayMode(): GameSelectionDisplayMode {
Expand Down
2 changes: 1 addition & 1 deletion src/model/game/GameManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export default class GameManager {
game.distributions.map(
(x) => new StorePlatformMetadata(x.platform, x.identifier || undefined)
),
game.meta.iconUrl || "thunderstore-beta.webp",
game.meta.iconUrl ?? "",
game.gameSelectionDisplayMode,
game.gameInstanceType,
game.packageLoader,
Expand Down
4 changes: 4 additions & 0 deletions src/pages/GameSelectionScreen.vue
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,10 @@ import Game from '../model/game/Game';
import { capitalize } from '../utils/StringUtils';
import { StorePlatform as platformLabels } from '../model/platform/StorePlatform';
import EcosystemUpdateIndicator from '../components/navigation/EcosystemUpdateIndicator.vue';
import { getStore } from '../providers/generic/store/StoreProvider';
import { State } from '../store';

const store = getStore<State>();

const gameSelection = useGameSelectionComposable();
provide(gameSelectionKey, gameSelection);
Expand Down Expand Up @@ -159,6 +162,7 @@ function selectPlatform() {
onMounted(async () => {
window.app.checkForApplicationUpdates();
await initialize();
void store.dispatch('ecosystemUpdate/updateEcosystemSchema');
});
</script>

Expand Down
28 changes: 28 additions & 0 deletions src/providers/generic/image/GameImageProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import ProviderUtils from '../ProviderUtils';

export type GameImageProvider = {
init(): Promise<void>;
readonly placeholderUrl: string;
resolve(iconUrl: string): Promise<string>;
};

let implementation: (() => GameImageProvider) | undefined;

function getImplementation(): GameImageProvider {
if (!implementation) {
ProviderUtils.throwNotProvidedError("GameImageProvider");
}
return implementation!();
}

export function provideGameImageImplementation(provider: () => GameImageProvider) {
implementation = provider;
}

const gameImage: GameImageProvider = {
init: () => getImplementation().init(),
get placeholderUrl() { return getImplementation().placeholderUrl; },
resolve: (iconUrl) => getImplementation().resolve(iconUrl),
};

export default gameImage;
Loading
Loading