Skip to content

Commit

Permalink
kiosk: periodically refresh persistent shared games (#9760)
Browse files Browse the repository at this point in the history
* kiosk: periodically refresh persistent shared games

* update GameSlide.tsx

* clean up game deletion code

* Clarify code comments

* Use existing JsonObject from pxtlib

* Rename a field for clarity
  • Loading branch information
eanders-ms authored Oct 27, 2023
1 parent 05a565e commit 263199a
Show file tree
Hide file tree
Showing 14 changed files with 309 additions and 84 deletions.
2 changes: 2 additions & 0 deletions kiosk/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import * as AddingGames from "./Services/AddingGamesService";
import * as GamepadManager from "./Services/GamepadManager";
import * as NavGrid from "./Services/NavGrid";
import * as RectCache from "./Services/RectCache";
import * as GameRefreshService from "./Services/GameRefreshService";
import Background from "./Components/Background";

function App() {
Expand Down Expand Up @@ -63,6 +64,7 @@ function App() {
GamepadManager.initialize();
NavGrid.initialize();
RectCache.initialize();
GameRefreshService.initialize();
}
}, [ready]);

Expand Down
14 changes: 11 additions & 3 deletions kiosk/src/Components/GameSlide.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { useContext } from "react";
import { useContext, useMemo } from "react";
import { playSoundEffect } from "../Services/SoundEffectService";
import { launchGame } from "../Transforms/launchGame";
import { GameData, HighScore } from "../Types";
import { GameData } from "../Types";
import { AppStateContext } from "../State/AppStateContext";
import HighScoresList from "./HighScoresList";
import { GameMenu } from "./GameMenu";
import { getHighScores } from "../State";
import { getEffectiveGameId } from "../Utils";

interface IProps {
game: GameData;
Expand All @@ -19,13 +20,20 @@ const GameSlide: React.FC<IProps> = ({ game }) => {
launchGame(game.id);
};

const thumbnailUrl = useMemo(() => {
if (game) {
const gameId = getEffectiveGameId(game);
return `url("https://makecode.com/api/${gameId}/thumb")`;
}
}, [game, game?.tempGameId]);

return (
<div className="gameTile" onClick={handleSlideClick}>
<div className="gameSelectionIndicator" />
<div
className="gameThumbnail"
style={{
backgroundImage: `url("https://makecode.com/api/${game.id}/thumb")`,
backgroundImage: thumbnailUrl,
}}
/>

Expand Down
29 changes: 18 additions & 11 deletions kiosk/src/Components/PlayingGame.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { useContext, useEffect, useMemo, useState } from "react";
import { AppStateContext } from "../State/AppStateContext";
import { getLaunchedGame } from "../State";
import { escapeGame } from "../Transforms/escapeGame";
import { playSoundEffect } from "../Services/SoundEffectService";
import { useOnControlPress } from "../Hooks";
import { stringifyQueryString } from "../Utils";
import { getEffectiveGameId, stringifyQueryString } from "../Utils";
import * as GamepadManager from "../Services/GamepadManager";
import * as IndexedDb from "../Services/IndexedDb";
import configData from "../config.json";

export default function PlayingGame() {
const { state: kiosk, dispatch } = useContext(AppStateContext);
const { launchedGameId: gameId } = kiosk;

const launchedGame = useMemo(() => {
return getLaunchedGame();
}, [kiosk.launchedGameId]);

// Handle Back and Start button presses
useOnControlPress(
Expand All @@ -29,19 +33,22 @@ export default function PlayingGame() {
>(undefined);

useEffect(() => {
if (gameId) {
if (launchedGame) {
const gameId = getEffectiveGameId(launchedGame);
// Try to fetch the built game from local storage.
IndexedDb.getBuiltJsInfoAsync(gameId).then(builtGame => {
setBuiltJsInfo(builtGame);
setFetchingBuiltJsInfo(false);
});
IndexedDb.getBuiltJsInfoAsync(gameId).then(
builtGame => {
setBuiltJsInfo(builtGame);
setFetchingBuiltJsInfo(false);
}
);
}
}, [gameId]);
}, [launchedGame]);

const playUrl = useMemo(() => {
if (gameId && !fetchingBuiltJsInfo) {
if (launchedGame && !fetchingBuiltJsInfo) {
return stringifyQueryString(configData.PlayUrlRoot, {
id: gameId,
id: getEffectiveGameId(launchedGame),
// TODO: Show sim buttons on mobile & touch devices.
hideSimButtons: pxt.BrowserUtils.isMobile() ? undefined : 1,
noFooter: 1,
Expand All @@ -58,7 +65,7 @@ export default function PlayingGame() {
sendBuilt: builtJsInfo ? undefined : 1,
});
}
}, [gameId, fetchingBuiltJsInfo]);
}, [launchedGame, fetchingBuiltJsInfo]);

return (
<iframe
Expand Down
5 changes: 2 additions & 3 deletions kiosk/src/Services/BackendRequests.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ShareIds, GameInfo } from "../Types";
import { ShareIds } from "../Types";

export const getGameCodesAsync = async (
kioskCode: string
Expand Down Expand Up @@ -72,15 +72,14 @@ export const canthrow_addGameToKioskAsync = async (

export const getGameDetailsAsync = async (
gameId: string
): Promise<GameInfo | undefined> => {
): Promise<pxt.Cloud.JsonScript | undefined> => {
try {
const gameDetailsUrl = `${pxt.Cloud.apiRoot}/${gameId}`;
const response = await fetch(gameDetailsUrl);
if (!response.ok) {
throw new Error("Unable to fetch the game details");
} else {
const gameDetails = await response.json();
console.log("Game details: " + gameDetails);
return gameDetails;
}
} catch (error) {
Expand Down
75 changes: 75 additions & 0 deletions kiosk/src/Services/GameRefreshService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { secondsToMs, minutesToMs } from "../Utils";
import { stateAndDispatch } from "../State";
import * as Storage from "./LocalStorage";
import { getGameDetailsAsync } from "./BackendRequests";
import * as Actions from "../State/Actions";
import { safeGameName, safeGameDescription, isPersistentGameId } from "../Utils";

const REFRESH_TIMER_INTERVAL_MS = secondsToMs(15);
const GAME_NEEDS_REFRESH_MS = minutesToMs(1);

let refreshTimer: NodeJS.Timeout | undefined;

// Periodically refreshes the metadata for "persistent share" games, i.e. games
// that can be updated without their share ID changing.
async function refreshOneAsync() {
const { state, dispatch } = stateAndDispatch();

const nowMs = Date.now();

const userAddedGames = Object.values(Storage.getUserAddedGames());

for (const game of userAddedGames) {
// Only refresh persistent share games
if (!isPersistentGameId(game.id)) continue;
// Don't refresh deleted games
if (game.deleted) continue;

const lastRefreshMs = game.lastRefreshMs ?? 0;

if (lastRefreshMs + GAME_NEEDS_REFRESH_MS < nowMs) {
//console.log(`Refreshing game ${game.id}`);
const details = await getGameDetailsAsync(game.id);
if (details) {
if (details.id !== game.tempGameId) {
// The temporary gameId changed, update local copy of game metadata
dispatch(
Actions.updateGame(game.id, {
name: safeGameName(details.name),
description: safeGameDescription(
details.description
),
tempGameId: details.id,
})
);
//console.log(`Refreshed game ${game.id} ${details.name}`);
// Update a maximum of one game per refresh cycle
break;
} else {
//console.log(`Did not refresh game ${game.id}`);
// Poke the game to refresh the lastRefreshMs timestamp
dispatch(Actions.updateGame(game.id, {}));
}
}
}
}

// Schedule the next refresh
refreshTimer = setTimeout(
refreshOneAsync,
// Slightly randomize the interval to avoid multiple timers in the app
// on the same interval to perfectly align
REFRESH_TIMER_INTERVAL_MS + (Math.random() * 1000) | 0
);
}

let initializeOnce = () => {
initializeOnce = () => {
throw new Error("GamepadManager.initialize() called more than once.");
};
refreshTimer = setTimeout(refreshOneAsync, REFRESH_TIMER_INTERVAL_MS);
};

export function initialize() {
initializeOnce();
}
3 changes: 2 additions & 1 deletion kiosk/src/Services/IndexedDb.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { openDB, IDBPDatabase } from "idb";
import { isPersistentGameId } from "../Utils";

class KioskDb {
db: IDBPDatabase | undefined;
Expand Down Expand Up @@ -61,7 +62,7 @@ class KioskDb {
): Promise<void> {
const ver = pxt.appTarget?.versions?.target;
if (!ver) return;
if (/^S/.test(gameId)) return; // skip persistent-share games for now (need to get their actual gameId and use that as the key)
if (isPersistentGameId(gameId)) return; // disallow keying to persistent-share gameIds (a safety measure. shouldn't happen in practice)
const key = `${ver}:${gameId}`;
await this.setAsync("builtjs", key, info);
}
Expand Down
16 changes: 9 additions & 7 deletions kiosk/src/Services/SimHostService.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { stateAndDispatch } from "../State";
import { stateAndDispatch, getLaunchedGame } from "../State";
import * as Actions from "../State/Actions";
import * as Constants from "../Constants";
import { gameOver } from "../Transforms/gameOver";
import { resetHighScores } from "../Transforms/resetHighScores";
import * as GamepadManager from "./GamepadManager";
import { postNotification } from "../Transforms/postNotification";
import { makeNotification } from "../Utils";
import { getEffectiveGameId, makeNotification } from "../Utils";
import * as IndexedDb from "./IndexedDb";

export function initialize() {
Expand Down Expand Up @@ -78,8 +78,10 @@ export function initialize() {

window.addEventListener("message", async (event) => {
const { state, dispatch } = stateAndDispatch();
if (event.data?.js && state.launchedGameId) {
await IndexedDb.setBuiltJsInfoAsync(state.launchedGameId, event.data);
const launchedGame = getLaunchedGame();
if (event.data?.js && launchedGame) {
const gameId = getEffectiveGameId(launchedGame);
await IndexedDb.setBuiltJsInfoAsync(gameId, event.data);
}
switch (event.data.type) {
case "simulator":
Expand Down Expand Up @@ -111,9 +113,9 @@ export function initialize() {
break;

case "ready":
const builtGame = await IndexedDb.getBuiltJsInfoAsync(state.launchedGameId!);
if (builtGame) {
await sendBuiltGameAsync(state.launchedGameId!);
if (launchedGame) {
const gameId = getEffectiveGameId(launchedGame);
await sendBuiltGameAsync(gameId);
}
break;
}
Expand Down
14 changes: 14 additions & 0 deletions kiosk/src/State/Actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ type RemoveGame = ActionBase & {
gameId: string;
};

type UpdateGame = ActionBase & {
type: "UPDATE_GAME";
gameId: string;
gameData: Partial<GameData>;
};

type SaveHighScore = ActionBase & {
type: "SAVE_HIGH_SCORE";
gameId: string;
Expand Down Expand Up @@ -137,6 +143,7 @@ export type Action =
| SetVolume
| AddGame
| RemoveGame
| UpdateGame
| SaveHighScore
| LoadHighScores
| SetMostRecentScores
Expand Down Expand Up @@ -200,6 +207,12 @@ const removeGame = (gameId: string): RemoveGame => ({
gameId,
});

const updateGame = (gameId: string, gameData: Partial<GameData>): UpdateGame => ({
type: "UPDATE_GAME",
gameId,
gameData
});

const saveHighScore = (
gameId: string,
highScore: HighScoreWithId
Expand Down Expand Up @@ -279,6 +292,7 @@ export {
setVolume,
addGame,
removeGame,
updateGame,
saveHighScore,
loadHighScores,
setMostRecentScores,
Expand Down
Loading

0 comments on commit 263199a

Please sign in to comment.