diff --git a/extensions/core-ruffle/package-lock.json b/extensions/core-ruffle/package-lock.json index f9012a6e8..9f533f1e5 100644 --- a/extensions/core-ruffle/package-lock.json +++ b/extensions/core-ruffle/package-lock.json @@ -8,9 +8,11 @@ "name": "core-ruffle", "version": "1.0.0", "dependencies": { - "arch": "^2.2.0" + "arch": "^2.2.0", + "mustache": "^4.2.0" }, "devDependencies": { + "@types/mustache": "^4.2.3", "@types/node": "18.x", "@typescript-eslint/eslint-plugin": "^5.21.0", "@typescript-eslint/parser": "^5.21.0", @@ -256,6 +258,12 @@ "integrity": "sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ==", "dev": true }, + "node_modules/@types/mustache": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.2.3.tgz", + "integrity": "sha512-MG+oI3oelPKLN2gpkel08v6Tp6zU2zZQRq+eSpRsFtLNTd2kxZolOHQTmQQs0wqXTLOqs+ri3tRUaagH5u0quw==", + "dev": true + }, "node_modules/@types/node": { "version": "18.18.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.18.1.tgz", @@ -4257,6 +4265,14 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "bin": { + "mustache": "bin/mustache" + } + }, "node_modules/mute-stdout": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mute-stdout/-/mute-stdout-1.0.1.tgz", diff --git a/extensions/core-ruffle/package.json b/extensions/core-ruffle/package.json index 416ca495f..1f8332a97 100644 --- a/extensions/core-ruffle/package.json +++ b/extensions/core-ruffle/package.json @@ -4,31 +4,7 @@ "description": "Required for Ruffle playback of Flash games", "version": "1.0.0", "main": "./dist/extension.js", - "contributes": { - "contextButtons": [ - { - "context": "curation", - "runWithNoCuration": true, - "name": "Clear WinINet Cache", - "command": "core-curation.clear-wininet-cache" - }, - { - "context": "curation", - "name": "Migrate to FP Navigator", - "command": "core-curation.fix-requirements" - }, - { - "context": "curation", - "name": "Load Data Pack Into Curation", - "command": "core-curation.load-data-pack" - }, { - "context": "curation", - "runWithNoCuration": true, - "name": "Migrate Data Packs", - "command": "core-curation.migrate-data-packs" - } - ] - }, + "contributes": {}, "scripts": { "build": "webpack --mode development", "watch": "webpack --watch --mode development", @@ -36,9 +12,11 @@ "lint": "eslint src --ext ts" }, "dependencies": { - "arch": "^2.2.0" + "arch": "^2.2.0", + "mustache": "^4.2.0" }, "devDependencies": { + "@types/mustache": "^4.2.3", "@types/node": "18.x", "@typescript-eslint/eslint-plugin": "^5.21.0", "@typescript-eslint/parser": "^5.21.0", diff --git a/extensions/core-ruffle/src/extension.ts b/extensions/core-ruffle/src/extension.ts index 86791a4ff..c4172883b 100644 --- a/extensions/core-ruffle/src/extension.ts +++ b/extensions/core-ruffle/src/extension.ts @@ -3,7 +3,8 @@ import * as path from 'path'; import * as fs from 'fs'; import { downloadFile, getGithubAsset, getPlatformRegex } from './util'; import { AssetFile } from './types'; -import { RuffleStandaloneMiddleware } from './middleware'; +import { RuffleStandaloneMiddleware } from './middleware/standalone'; +import { RuffleWebEmbedMiddleware } from './middleware/embed'; export async function activate(context: flashpoint.ExtensionContext): Promise { // const registerSub = (d: flashpoint.Disposable) => { flashpoint.registerDisposable(context.subscriptions, d); }; @@ -15,6 +16,9 @@ export async function activate(context: flashpoint.ExtensionContext): Promise {}; const standaloneAssetFile = await getGithubAsset(getPlatformRegex(), logVoid); diff --git a/extensions/core-ruffle/src/middleware/embed.ts b/extensions/core-ruffle/src/middleware/embed.ts new file mode 100644 index 000000000..f2a94cdd2 --- /dev/null +++ b/extensions/core-ruffle/src/middleware/embed.ts @@ -0,0 +1,139 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import * as flashpoint from 'flashpoint-launcher'; +import { ConfigSchema, Game, GameLaunchInfo, GameMiddlewareConfig, GameMiddlewareDefaultConfig, IGameMiddleware } from 'flashpoint-launcher'; +import { Readable } from 'stream'; +import { buildBasicTemplate } from '../template/basic'; +import * as path from 'path'; + +// Config Schema used to configure the middleware per game +const schema: ConfigSchema = [ + { + type: 'string', + key: 'template', + title: 'Template', + description: 'Template to use when creating embed webpage', + options: ['Automatic', 'Basic'], + default: 'Automatic', + }, + { + type: 'string', + key: 'url', + title: 'Webpage URL', + description: 'URL to serve the webpage from', + optional: true, + } +]; + +// Stored config value map +type RuffleEmbedConfig = { + /** Player Options */ + template: 'Automatic' | 'Basic'; + url: string; +}; + +const DEFAULT_CONFIG: Partial = { + template: 'Automatic' +}; + +export class RuffleWebEmbedMiddleware implements IGameMiddleware { + id = 'com.ruffle.middleware-embed'; + name = 'Ruffle Web Embed'; + + constructor( + private ruffleWebRoot: string + ) {} + + isValidVersion(version: string): boolean { + return true; + } + + async isValid(game: Game): Promise { + if (game.activeDataId && game.activeDataId >= 0) { + const gameData = await flashpoint.gameData.findOne(game.activeDataId); + if (gameData?.launchCommand.endsWith('.swf')) { + return true; + } + } + return false; + } + + async execute(gameLaunchInfo: GameLaunchInfo, middlewareConfig: GameMiddlewareConfig): Promise { + // Cast our config values to the correct type + const config = { + ...DEFAULT_CONFIG, + ...middlewareConfig.config + } as Partial; + + const launchArgs = coerceToStringArray(gameLaunchInfo.launchInfo.gameArgs); + const sourceUrl = launchArgs[0]; + + // Generate a webpage for the given configuration + let data: string | null = null; + const pageUrl = config.url ? config.url : genEmbedUrl(launchArgs[0]); + switch (config.template) { + default: { + // Save generated embed to file + data = buildBasicTemplate(gameLaunchInfo.game, sourceUrl); + break; + } + } + + if (!data ) { + throw 'Webpage generated data was empty?'; + } + + // Save generated webpage + const rs = new Readable(); + rs.push(data); + rs.push(null); + await flashpoint.middleware.writeGameFileByUrl(pageUrl, rs); + + // Replace launch arg + launchArgs[0] = pageUrl; + + // Copy required ruffle script + await flashpoint.middleware.copyGameFilesByUrl('http://ruffle.rs/', path.join(this.ruffleWebRoot, 'latest')); + + // Overwrite launch values + gameLaunchInfo.launchInfo.gamePath = path.join(flashpoint.config.flashpointPath, 'FPSoftware\\StartChrome.bat'); + gameLaunchInfo.launchInfo.gameArgs = launchArgs; + + return gameLaunchInfo; + } + + getDefaultConfig(game: Game): GameMiddlewareDefaultConfig { + return { + version: 'latest', + config: this.getConfigSchema('latest'), + }; + } + + getConfigSchema(version: string): ConfigSchema { + // Only 1 kind of schema for now + return schema; + } + + upgradeConfig(version: string, config: any) { + // UNIMPLEMENTED + return config; + } +} + +function coerceToStringArray(arr: string[] | string): string[] { + if (Array.isArray(arr)) { + return arr; + } else { + return [arr]; + } +} + +// Creates an embed at the root of the current domain +function genEmbedUrl(launchUrl: string): string { + const url = new URL(launchUrl); + if (url.pathname === '/embed.html') { + url.pathname = '/embed_backup.html'; + } else { + url.pathname = '/embed.html'; + } + return url.href; +} diff --git a/extensions/core-ruffle/src/middleware.ts b/extensions/core-ruffle/src/middleware/standalone.ts similarity index 99% rename from extensions/core-ruffle/src/middleware.ts rename to extensions/core-ruffle/src/middleware/standalone.ts index 659688db6..36ca0c195 100644 --- a/extensions/core-ruffle/src/middleware.ts +++ b/extensions/core-ruffle/src/middleware/standalone.ts @@ -4,7 +4,7 @@ import { ConfigSchema, Game, GameLaunchInfo, GameMiddlewareConfig, GameMiddlewar import * as os from 'os'; import * as fs from 'fs'; import * as path from 'path'; -import { downloadFile, getGithubReleaseAsset, getPlatformRegex } from './util'; +import { downloadFile, getGithubReleaseAsset, getPlatformRegex } from '../util'; // Config Schema used to configure the middleware per game const schema: ConfigSchema = [ @@ -154,6 +154,7 @@ type FlashVar = { key: string; value: string; } + export class RuffleStandaloneMiddleware implements IGameMiddleware { id = 'com.ruffle.middleware-standalone'; name = 'Ruffle Standalone'; diff --git a/extensions/core-ruffle/src/template/basic.ts b/extensions/core-ruffle/src/template/basic.ts new file mode 100644 index 000000000..e48edaf4f --- /dev/null +++ b/extensions/core-ruffle/src/template/basic.ts @@ -0,0 +1,11 @@ +import * as flashpoint from 'flashpoint-launcher'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as mustache from 'mustache'; + +export function buildBasicTemplate(game: flashpoint.Game, sourceUrl: string) { + const templatesRoot = path.join(flashpoint.extensionPath, 'static', 'templates'); + const data = fs.readFileSync(path.join(templatesRoot, 'basic.mustache'), 'utf8'); + const styleData = fs.readFileSync(path.join(templatesRoot, 'basic.css'), 'utf8'); + return mustache.render(data, { title: game.title, sourceUrl, styleData }); +} diff --git a/extensions/core-ruffle/static/templates/basic.css b/extensions/core-ruffle/static/templates/basic.css new file mode 100644 index 000000000..55e45cb29 --- /dev/null +++ b/extensions/core-ruffle/static/templates/basic.css @@ -0,0 +1,32 @@ +:root { + font-family: Helvetica, Arial, sans-serif; +} + +body { + background-color: lightblue; +} +.page-content { + display: flex; + flex-direction: column; + align-items: center; +} +#embed-container { + display: flex; + max-width: 80%; + max-height: 80%; + border: 3px solid black; + border-radius: 2px; +} +.game-title { + font-size: 2rem; + font-weight: bold; + text-align: center; + margin-top: 1rem; + margin-bottom: 1rem; +} +.button-row { + margin-top: 1rem; + display: flex; + flex-direction: row; + justify-content: left; +} \ No newline at end of file diff --git a/extensions/core-ruffle/static/templates/basic.mustache b/extensions/core-ruffle/static/templates/basic.mustache new file mode 100644 index 000000000..1340cf7bf --- /dev/null +++ b/extensions/core-ruffle/static/templates/basic.mustache @@ -0,0 +1,37 @@ + + + + {{title}} + + + +
+
{{title}}
+
+
+ +
+ + + +
+ + \ No newline at end of file diff --git a/src/back/extensions/ApiImplementation.ts b/src/back/extensions/ApiImplementation.ts index 4957ebac3..33e641fbd 100644 --- a/src/back/extensions/ApiImplementation.ts +++ b/src/back/extensions/ApiImplementation.ts @@ -35,6 +35,7 @@ import { overwritePreferenceData } from '@shared/preferences/util'; import { formatString } from '@shared/utils/StringFormatter'; import * as flashpoint from 'flashpoint-launcher'; import * as fs from 'fs'; +import * as fsExtra from 'fs-extra'; import { extractFull } from 'node-7z'; import * as path from 'path'; import { loadCurationArchive } from '..'; @@ -42,6 +43,7 @@ import { newExtLog } from './ExtensionUtils'; import { Command, RegisteredMiddleware } from './types'; import uuid = require('uuid'); import { awaitDialog } from '@back/util/dialog'; +import stream = require('stream'); /** * Create a Flashpoint API implementation specific to an extension, used during module load interception @@ -528,11 +530,52 @@ export function createApiFactory(extId: string, extManifest: IExtensionManifest, registeredMiddleware.extId = extId; state.registry.middlewares.set(middleware.id, registeredMiddleware); }, - writeGameFile: async (path: string, stream: ReadableStream) => { - + writeGameFile: async (filePath: string, rs: stream.Readable) => { + // Append to overrides directory + const fullPath = path.join(state.config.flashpointPath, state.config.middlewareOverridePath, filePath); + await fs.promises.mkdir(path.dirname(fullPath), { recursive: true }); + // Write file + const ws = fs.createWriteStream(fullPath); + log.debug('Launcher', 'Writing override file to ' + fullPath); + return new Promise((resolve, reject) => { + ws.on('error', reject); + ws.on('finish', resolve); + rs.pipe(ws); + }); }, - writeGameFileByUrl: async (url: string, stream: ReadableStream) => { - + writeGameFileByUrl: async (url: string, rs: stream.Readable) => { + // Convert url to file path + let filePath = url; + if (url.startsWith('https://')) { + filePath = url.substring('https://'.length); + } + if (url.startsWith('http://')) { + filePath = url.substring('http://'.length); + } + const fullPath = path.join(state.config.flashpointPath, state.config.middlewareOverridePath, filePath); + await fs.promises.mkdir(path.dirname(fullPath), { recursive: true }); + log.debug('Launcher', 'Writing override file to ' + fullPath); + // Write file + const ws = fs.createWriteStream(fullPath); + return new Promise((resolve, reject) => { + ws.on('error', reject); + ws.on('finish', resolve); + rs.pipe(ws); + }); + }, + copyGameFilesByUrl: async (url: string, source: string) => { + // Convert url to file path + let filePath = url; + if (url.startsWith('https://')) { + filePath = url.substring('https://'.length); + } + if (url.startsWith('http://')) { + filePath = url.substring('http://'.length); + } + const fullPath = path.join(state.config.flashpointPath, state.config.middlewareOverridePath, filePath); + await fs.promises.mkdir(path.dirname(fullPath), { recursive: true }); + log.debug('Launcher', `Copying override from "${source}" to "${fullPath}"`); + return fsExtra.copy(source, fullPath); }, extractGameFile: (path: string) => { return '' as any; // UNIMPLEMENTED diff --git a/src/back/importGame.ts b/src/back/importGame.ts index 05732f1f7..505d31de8 100644 --- a/src/back/importGame.ts +++ b/src/back/importGame.ts @@ -394,6 +394,10 @@ async function createGameFromCurationMeta(gameId: string, gameMeta: CurationMeta activeDataOnDisk: false }); game.addApps = addApps.map(addApp => createAddAppFromCurationMeta(addApp, game)); + game.tagsStr = ''; + if (game.tags.length > 0) { + game.tagsStr = game.tags.map(t => t.primaryAlias.name).join('; '); + } return game; } diff --git a/src/shared/config/util.ts b/src/shared/config/util.ts index 424136e05..86fcca0a6 100644 --- a/src/shared/config/util.ts +++ b/src/shared/config/util.ts @@ -21,7 +21,8 @@ const configDataDefaultBase: Readonly = Object.freeze({ logsBaseUrl: 'https://logs.unstable.life/', updatesEnabled: true, gotdUrl: 'https://download.unstable.life/gotd.json', - gotdShowAll: false + gotdShowAll: false, + middlewareOverridePath: 'Legacy/middleware_overrides/', }); /** @@ -68,17 +69,18 @@ export function overwriteConfigData( input: data, onError: onError && (e => onError(`Error while parsing Config: ${e.toString()}`)), }); - parser.prop('flashpointPath', v => source.flashpointPath = parseVarStr(str(v))); - parser.prop('useCustomTitlebar', v => source.useCustomTitlebar = !!v); - parser.prop('startServer', v => source.startServer = !!v); - parser.prop('backPortMin', v => source.backPortMin = num(v)); - parser.prop('backPortMax', v => source.backPortMax = num(v)); - parser.prop('imagesPortMin', v => source.imagesPortMin = num(v)); - parser.prop('imagesPortMax', v => source.imagesPortMax = num(v)); - parser.prop('logsBaseUrl', v => source.logsBaseUrl = parseVarStr(str(v))); - parser.prop('updatesEnabled', v => source.updatesEnabled = !!v); - parser.prop('gotdUrl', v => source.gotdUrl = str(v)); - parser.prop('gotdShowAll', v => source.gotdShowAll = !!v); + parser.prop('flashpointPath', v => source.flashpointPath = parseVarStr(str(v))); + parser.prop('useCustomTitlebar', v => source.useCustomTitlebar = !!v); + parser.prop('startServer', v => source.startServer = !!v); + parser.prop('backPortMin', v => source.backPortMin = num(v)); + parser.prop('backPortMax', v => source.backPortMax = num(v)); + parser.prop('imagesPortMin', v => source.imagesPortMin = num(v)); + parser.prop('imagesPortMax', v => source.imagesPortMax = num(v)); + parser.prop('logsBaseUrl', v => source.logsBaseUrl = parseVarStr(str(v))); + parser.prop('updatesEnabled', v => source.updatesEnabled = !!v); + parser.prop('gotdUrl', v => source.gotdUrl = str(v)); + parser.prop('gotdShowAll', v => source.gotdShowAll = !!v); + parser.prop('middlewareOverridePath', v => source.middlewareOverridePath = str(v)); // Do some alterations source.flashpointPath = fixSlashes(source.flashpointPath); // (Clean path) // Return diff --git a/tests/unit/back/configuration.test.ts b/tests/unit/back/configuration.test.ts index 9fadc55ed..2a0ab37a0 100644 --- a/tests/unit/back/configuration.test.ts +++ b/tests/unit/back/configuration.test.ts @@ -31,7 +31,8 @@ describe('Configuration Files', () => { 'logsBaseUrl': 'https://example.com/', 'updatesEnabled': true, 'gotdUrl': 'dogga', - 'gotdShowAll': true + 'gotdShowAll': true, + 'middlewareOverridePath': 'test' }; const newData: AppConfigData = deepCopy(getDefaultConfigData('win32')); overwriteConfigData(newData, data); diff --git a/typings/flashpoint-launcher.d.ts b/typings/flashpoint-launcher.d.ts index 45f70d4dd..d4a1677ae 100644 --- a/typings/flashpoint-launcher.d.ts +++ b/typings/flashpoint-launcher.d.ts @@ -3,6 +3,8 @@ // Definitions by: Colin Berry // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped +/// + /** * Based off Microsoft VSCode's extension system (MIT Licensed) https://github.com/Microsoft/vscode * Module is created during runtime and injected. Please read the documentation below to understand how to use these types. @@ -18,6 +20,8 @@ // tslint:disable:no-declare-current-package declare module 'flashpoint-launcher' { + import { Readable } from "stream"; + /** Version of the Flashpoint Launcher */ const version: string; @@ -499,25 +503,31 @@ declare module 'flashpoint-launcher' { * @param path Relative path to game file * @param stream Data stream */ - function writeGameFile(path: string, stream: ReadableStream): Promise; + function writeGameFile(path: string, stream: Readable): Promise; /** * Write data to a game file path, built based on url * @param url Game File URL * @param stream Data stream */ - function writeGameFileByUrl(url: string, stream: ReadableStream): Promise; + function writeGameFileByUrl(url: string, stream: Readable): Promise; + /** + * Copy file / directory to a given url + * @param url Game File URL + * @param path Source file / directory path + */ + function copyGameFilesByUrl(url: string, path: string): Promise; /** * Extract a game file from a gamezip * @param path Relative path to game file * @returns Readabale data stream */ - function extractGameFile(path: string): Promise; + function extractGameFile(path: string): Promise; /** * Extract a game file from a gamezip, find path via url * @param url Game File URL * @returns Readable data stream */ - function extractGameFileByUrl(url: string): Promise; + function extractGameFileByUrl(url: string): Promise; } // Events @@ -956,6 +966,8 @@ declare module 'flashpoint-launcher' { gotdUrl: string; /** Show GOTD entries later than Today */ gotdShowAll: boolean; + /** Middleware override path */ + middlewareOverridePath: string; }; export type TagFilterGroup = {