Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ugly glue for sharable kiosk code #6014

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
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
8 changes: 7 additions & 1 deletion kiosk/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@ const url = window.location.href;
const clean = !!/clean(?:[:=])1/.test(url);
const locked = !!/lock(?:[:=])1/i.test(url);
const time = (/time=((?:[0-9]{1,3}))/i.exec(url))?.[1];
const shareSrc = /shared[:=]([^#?]+)/i.exec(url)?.[1];

const kioskSingleton: Kiosk = new Kiosk(clean, locked, time);
const kioskSingleton: Kiosk = new Kiosk({
clean,
locked,
time,
shareSrc,
});
kioskSingleton.initialize().catch(error => alert(error));

function App() {
Expand Down
19 changes: 17 additions & 2 deletions kiosk/src/Components/MainMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import HighScoresList from "./HighScoresList";
import { DeleteButton } from "./DeleteButton";
import { tickEvent } from "../browserUtils";
import DeletionModal from "./DeletionModal";
import { createKioskShareLink } from "../share";

interface IProps {
kiosk: Kiosk
Expand Down Expand Up @@ -49,7 +50,7 @@ const MainMenu: React.FC<IProps> = ({ kiosk }) => {
updateLoop();
}
}, configData.GamepadPollLoopMilli);

return () => {
if (intervalId) {
clearInterval(intervalId);
Expand All @@ -60,6 +61,17 @@ const MainMenu: React.FC<IProps> = ({ kiosk }) => {
}
});

const onUploadClick = async () => {
const sharePointer = await createKioskShareLink({
games: kiosk.games,
});

const outputLink = `https://arcade.makecode.com/kiosk?shared=${sharePointer}`;

alert(`send em to ${outputLink}`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice. I'm guessing when kiosk starts using react-common we can just take advantage of the share dialog or a simplified version of it.

Copy link
Member Author

@jwunderl jwunderl Aug 11, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something like that, I just wanted to implement the smallest amount of ui in this that i could while still having an mvp to keep it simple / only spent about 20 minutes implementing / testing it (same reason this just comes from a button you have to click right now which isn't shippable, instead of hooking it into the gamepad movement flow); for now could just pop to a page with a qr code of it and link saying it's shareable as well if we wanted to add another state.

console.log(outputLink);
}

return(
<div className="mainMenu">
<nav className="mainMenuTopBar">
Expand All @@ -70,6 +82,9 @@ const MainMenu: React.FC<IProps> = ({ kiosk }) => {
<AddGameButton selected={addButtonSelected} content="Add your game" />
</div>
}
{
!kiosk.locked && <button tabIndex={0} onClick={onUploadClick}>click me to upload</button>
}
</nav>
<GameList kiosk={kiosk} addButtonSelected={addButtonSelected}
deleteButtonSelected={deleteButtonSelected} />
Expand All @@ -81,5 +96,5 @@ const MainMenu: React.FC<IProps> = ({ kiosk }) => {
</div>
)
}

export default MainMenu;
42 changes: 30 additions & 12 deletions kiosk/src/Models/Kiosk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ import { KioskState } from "./KioskState";
import configData from "../config.json";
import { getGameDetailsAsync } from "../BackendRequests"
import { tickEvent } from "../browserUtils";
import { getSharedKioskData } from "../share";

export interface KioskOpts {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is such a good change. I need to take advantage of interfaces and objects more often. Especially since we might be wanting to expand these options in the future. Thanks for doing this.

clean: boolean;
locked: boolean;
time?: string;
shareSrc?: string;
}

export class Kiosk {
games: GameData[] = [];
gamepadManager: GamepadManager = new GamepadManager();
Expand All @@ -19,6 +28,7 @@ export class Kiosk {
clean: boolean;
locked: boolean;
time?: string;
shareSrc?: string; // `{shareId}/{filename}`

private readonly highScoresLocalStorageKey: string = "HighScores";
private readonly addedGamesLocalStorageKey: string = "UserAddedGames";
Expand All @@ -30,27 +40,36 @@ export class Kiosk {
private builtGamesCache: { [gameId: string]: BuiltSimJSInfo } = { };
private defaultGameDescription = "Made with love in MakeCode Arcade";

constructor(clean: boolean, locked: boolean, time?: string) {
constructor(opts: KioskOpts) {
const { clean, locked, time, shareSrc } = opts;
this.clean = clean;
this.locked = locked;
this.shareSrc = shareSrc;
// TODO figure out this interaction; right now treating loaded shareSrc as readonly to keep easy
this.locked = locked || !!shareSrc;
this.time = time;
}

async downloadGameListAsync(): Promise<void> {
if (this.shareSrc) {
const [id, ...filename] = this.shareSrc.split("/");

const kioskData = await getSharedKioskData(id, filename.join("/"));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this just for leaving us the option to send the shared kiosk entries to a different file/nested file? If I'm understanding everything correctly, is the filename kiosk.json every time?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this one it's emitting as kiosk.json every time yes; I wrote it like this just to keep it simple, but we could choose to omit the filename when sharing it, and default to kiosk.json when filename is empty.

This mirrors our tutorial loading logic from shared projects / github projects -- defaulting to readme.md, but allowing a user to e.g. store multiple kiosks in one project if they wish to, just with a bit more specification involved on which file to read from.

this.games = kioskData.games;
return;
}
if (!this.clean) {
let url = configData.GameDataUrl;
if (configData.Debug) {
url = `/static/kiosk/${url}`;
}

const response = await fetch(url);
if (!response.ok) {
throw new Error(`Unable to download game list from "${url}"`);
}

try {
this.games = (await response.json()).games;
this.games.push()
}
catch (error) {
throw new Error(`Unable to process game list downloaded from "${url}": ${error}`);
Expand All @@ -68,15 +87,14 @@ export class Kiosk {
if (name.toLowerCase() === "untitled") {
return "Kiosk Game";
}

return name;
}

getGameDescription(desc: string) {
if (desc.length === 0) {
return this.defaultGameDescription
}

return desc;
}

Expand All @@ -88,7 +106,7 @@ export class Kiosk {
if (!allAddedGames[gameId]) {
let gameName;
let gameDescription;

try {
const gameDetails = await getGameDetailsAsync(gameId);
gameName = this.getGameName(gameDetails.name);
Expand All @@ -97,10 +115,10 @@ export class Kiosk {
gameName = "Kiosk Game";
gameDescription = this.defaultGameDescription;
}

const gameUploadDate = (new Date()).toLocaleString()
const newGame = new GameData(gameId, gameName, gameDescription, "None", games[gameId].id, gameUploadDate, true);

this.games.push(newGame);
gamesToAdd.push(gameName);
allAddedGames[gameId] = newGame;
Expand Down Expand Up @@ -256,8 +274,8 @@ export class Kiosk {
const launchedGameHighs = this.getHighScores(this.launchedGame);
const currentHighScore = this.mostRecentScores[0];
const lastScore = launchedGameHighs[launchedGameHighs.length - 1]?.score;
if (launchedGameHighs.length === configData.HighScoresToKeep
&& lastScore
if (launchedGameHighs.length === configData.HighScoresToKeep
&& lastScore
&& currentHighScore < lastScore) {
this.exitGame(KioskState.GameOver);

Expand Down
3 changes: 2 additions & 1 deletion kiosk/src/browserUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ export function devicePixelRatio(): number {

export function isLocal() {
return window.location.hostname === "localhost";
}
}

93 changes: 93 additions & 0 deletions kiosk/src/share.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { GameData } from "./Models/GameData";

const apiRoot = "https://www.makecode.com";
const description = "A kiosk for MakeCode Arcade";

export interface SharedKioskData {
games: GameData[];
}

export async function createKioskShareLink(kioskData: SharedKioskData) {
const payload = createShareRequest(
"kiosk",
createProjectFiles("kiosk", kioskData)
);
const url = apiRoot + "/api/scripts";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So is this how we create all share links across targets? We send a POST request to this endpoint and the payload and header of the request determine what target is used and more?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, here's the entry point for where those get spun up in webapp https://github.com/microsoft/pxt/blob/master/webapp/src/app.tsx#L4106 & the actual requests are made in these two functions depending on if it's anonymous or persistent: https://github.com/microsoft/pxt/blob/master/webapp/src/app.tsx#L4106


const result = await fetch(
url,
{
method: "POST",
body: new Blob([JSON.stringify(payload)], { type: "application/json" })
}
);

if (result.status === 200) {
const resJSON = await result.json();
// return "https://arcade.makecode.com/" + resJSON.shortid;
return `${resJSON.shortid}/kiosk.json`;
}

return "ERROR"
}


function createShareRequest(projectName: string, files: {[index: string]: string}) {
const header = {
"name": projectName,
"meta": {
},
"editor": "tsprj",
"pubId": undefined,
"pubCurrent": false,
"target": "arcade",
"id": crypto.randomUUID(),
"recentUse": Date.now(),
"modificationTime": Date.now(),
"path": projectName,
"saveId": {},
"githubCurrent": false,
"pubVersions": []
}

return {
id: header.id,
name: projectName,
target: header.target,
description: description,
editor: "tsprj",
header: JSON.stringify(header),
text: JSON.stringify(files),
meta: {
}
}
}

function createProjectFiles(projectName: string, kioskData: SharedKioskData) {
const files: {[index: string]: string} = {};

const config = {
"name": projectName,
"description": description,
"dependencies": {
"device": "*"
},
"files": [
"main.ts",
"kiosk.json"
],
"preferredEditor": "tsprj"
};
files["pxt.json"] = JSON.stringify(config, null, 4);
files["main.ts"] = " ";
files["kiosk.json"] = JSON.stringify(kioskData, undefined, 4);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Asking for more understanding. Why are each of these files needed? Are these the files a share link is expected to serve so to speak?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kiosk.json and pxt.json are actually necessary; pxt.json is the project config and is required to be a valid makecode project (I'm guessing the post request would get rejected if we don't have a pxt.json in the blob but haven't tried; could just look at the backend code to confirm). kiosk.json here is where we're storing the data we want to share (kiosk state), so that's important. I left an empty main.ts just so there wouldn't be any weirdness if someone chose to open in the editor, not sure the behavior we would get from opening a shared project with no blocks/ts/py files in it off the top of my head and easiest just to include main.ts that is in almost every project rather than think about it (easy enough to remove from files & config.files to check though)


return files;
}

export async function getSharedKioskData(shareId: string, filename: string): Promise<SharedKioskData> {
const resp = await fetch(`${apiRoot}/api/${shareId}/text`);
const proj: any = await resp.json();
const kioskData = JSON.parse(proj[filename]);
return kioskData;
}