diff --git a/lang/en.json b/lang/en.json index ae759c9bb..eff26753e 100644 --- a/lang/en.json +++ b/lang/en.json @@ -545,7 +545,8 @@ "showImage": "Show Image", "searching": "Searching...", "loading": "Loading...", - "ok": "OK" + "ok": "OK", + "allow": "Allow" }, "menu": { "viewThumbnailInFolder": "View Thumbnail in Folder", @@ -639,7 +640,8 @@ "badAntiVirus": "You appear to be using '{0}'.\nThis particular Antivirus is known to cause issues, and we recommend adding an exception to the Flashpoint folder.\n\nIf you only see a white screen when running Flash games you may need to reinstall Flashpoint to restore deleted files.\n\nSee the Wiki or Help tab for detailed instructions.", "openWiki": "Open Wiki", "openDiscord": "Join Discord Server", - "doNotShowAgain": "Do not show again" + "doNotShowAgain": "Do not show again", + "extFpfssConsent": "Extension with ID {0} is requesting access to your FPFSS token. Allow?\nThis means the extension will be able access your FPFSS account and perform actions on your behalf." }, "libraries": { "arcade": "Games", @@ -659,5 +661,9 @@ "techDesc": "Adds support for all other tech - Shockwave, Unity, Java, HTML5, etc.", "screenshots": "Logos & Screenshots", "screenshotsDesc": "Adds logos for Grid view and screenshots for all games." + }, + "extensions": { + "fpssConsentRevokeTitle": "Revoke access to FPFSS", + "fpssConsentRevokeDesc": "Withdraw this extension's access to FPFSS" } } diff --git a/src/back/extensions/ApiImplementation.ts b/src/back/extensions/ApiImplementation.ts index 51df02f10..8cb0c2be0 100644 --- a/src/back/extensions/ApiImplementation.ts +++ b/src/back/extensions/ApiImplementation.ts @@ -21,7 +21,7 @@ import { } from '@back/util/misc'; import { BrowsePageLayout, ScreenshotPreviewMode } from '@shared/BrowsePageLayout'; import { ILogEntry, LogLevel } from '@shared/Log/interface'; -import { BackOut } from '@shared/back/types'; +import { BackOut, FpfssUser } from '@shared/back/types'; import { CURATIONS_FOLDER_WORKING } from '@shared/constants'; import { CurationMeta } from '@shared/curate/types'; import { getContentFolderByKey } from '@shared/curate/util'; @@ -613,7 +613,32 @@ export function createApiFactory(extId: string, extManifest: IExtensionManifest, }, extractGameFileByUrl: (url: string) => { return '' as any; // UNIMPLEMENTED - } + }, + }; + + const extFpfss: typeof flashpoint.fpfss = { + getAccessToken: async (): Promise => { + if (!state.socketServer.lastClient) { + throw new Error('No connected client to handle FPFSS action.'); + } + try { + const user = await state.socketServer.request(state.socketServer.lastClient, BackOut.FPFSS_ACTION, extId); + if (user && user.accessToken) { + return user.accessToken; + } else { + throw new Error('Failed to get access token or user cancelled.'); + } + } catch (error) { + const client = state.socketServer.lastClient; + const openDialog = state.socketServer.showMessageBoxBack(state, client); + await openDialog({ + largeMessage: true, + message: (error instanceof Error) ? error.message : String(error), + buttons: [state.languageContainer.misc.ok] + }); + throw error; + } + }, }; // Create API Module to give to caller @@ -645,6 +670,7 @@ export function createApiFactory(extId: string, extManifest: IExtensionManifest, services: extServices, dialogs: extDialogs, middleware: extMiddlewares, + fpfss: extFpfss, // Events onDidInit: apiEmitters.onDidInit.extEvent(extManifest.displayName || extManifest.name), diff --git a/src/back/index.ts b/src/back/index.ts index ac8fd2a6f..6bb7f89e2 100644 --- a/src/back/index.ts +++ b/src/back/index.ts @@ -6,7 +6,7 @@ import { stringifyArray } from '@shared/Util'; import * as os from 'os'; -import { BackIn, BackInit, BackInitArgs, BackOut, BackResParams, ComponentState, ComponentStatus, DownloadDetails } from '@shared/back/types'; +import { BackIn, BackInit, BackInitArgs, BackOut, BackResParams, ComponentState, ComponentStatus, DownloadDetails, FpfssUser } from '@shared/back/types'; import { getContentFolderByKey, getCurationFolder } from '@shared/curate/util'; import { ILogoSet, LogoSet } from '@shared/extensions/interfaces'; import { IBackProcessInfo, RecursivePartial } from '@shared/interfaces'; diff --git a/src/back/responses.ts b/src/back/responses.ts index 618ffd410..774cdeaa8 100644 --- a/src/back/responses.ts +++ b/src/back/responses.ts @@ -124,6 +124,7 @@ import { import { uuid } from './util/uuid'; import { FPFSS_INFO_FILENAME } from '@shared/curate/fpfss'; import { createSearchFilter } from '@back/util/search'; +import { FpfssUser } from '@shared/back/types'; const axios = axiosImport.default; diff --git a/src/back/types.ts b/src/back/types.ts index 4f6b19552..69ed62bf1 100644 --- a/src/back/types.ts +++ b/src/back/types.ts @@ -1,4 +1,4 @@ -import { BackInit, ComponentStatus } from '@shared/back/types'; +import { BackInit, ComponentStatus, FpfssUser } from '@shared/back/types'; import { AppConfigData, AppExtConfigData } from '@shared/config/interfaces'; import { ExecMapping, GamePropSuggestions, IBackProcessInfo, INamedBackProcessInfo } from '@shared/interfaces'; import { LangContainer, LangFile } from '@shared/lang'; diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 12b5365ab..36ea21a93 100644 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -1,14 +1,22 @@ import * as remote from '@electron/remote'; +import { WithCurateProps } from '@renderer/containers/withCurateState'; +import { WithFpfssProps } from '@renderer/containers/withFpfss'; +import { WithSearchProps } from '@renderer/containers/withSearch'; +import { WithViewProps } from '@renderer/containers/withView'; +import { cancelDialog, RANDOM_GAME_ROW_COUNT, resolveDialog, updateDialogField } from '@renderer/store/main/slice'; import { BackIn, BackInit, BackOut, FpfssUser } from '@shared/back/types'; -import { APP_TITLE, LOGOS, SCREENSHOTS, VIEW_PAGE_SIZE } from '@shared/constants'; +import { APP_TITLE, LOGOS, SCREENSHOTS } from '@shared/constants'; import { CustomIPC, IService, ProcessState, WindowIPC } from '@shared/interfaces'; import { LangContainer } from '@shared/lang'; import { memoizeOne } from '@shared/memoize'; +import { Paths } from '@shared/Paths'; import { updatePreferencesData } from '@shared/preferences/util'; import { setTheme } from '@shared/Theme'; import { getFileServerURL, mapFpfssGameToLocal, mapLocalToFpfssGame, recursiveReplace, sizeToString } from '@shared/Util'; import { arrayShallowStrictEquals } from '@shared/utils/compare'; import { debounce } from '@shared/utils/debounce'; +import { newGame } from '@shared/utils/misc'; +import { formatString } from '@shared/utils/StringFormatter'; import axios from 'axios'; import { clipboard, ipcRenderer, Menu, MenuItemConstructorOptions } from 'electron'; import { @@ -17,23 +25,13 @@ import { DialogState, Game, Playlist, - PlaylistGame, ViewGame + PlaylistGame } from 'flashpoint-launcher'; import * as fs from 'fs-extra'; import * as path from 'path'; import * as React from 'react'; +import { IWithShortcut } from 'react-keybind'; import { RouteComponentProps } from 'react-router-dom'; -import { FloatingContainer } from './FloatingContainer'; -import { ConnectedFpfssEditGame } from './FpfssEditGame'; -import { InputField } from './InputField'; -import { OpenIcon } from './OpenIcon'; -import { newCurateTask } from './pages/CuratePage'; -import { placeholderProgressData, ProgressBar } from './ProgressComponents'; -import { ResizableSidebar, SidebarResizeEvent } from './ResizableSidebar'; -import { SimpleButton } from './SimpleButton'; -import { SplashScreen } from './SplashScreen'; -import { TaskBar } from './TaskBar'; -import { TitleBar } from './TitleBar'; import { ConnectedFooter } from '../containers/ConnectedFooter'; import { ConnectedRightBrowseSidebar } from '../containers/ConnectedRightBrowseSidebar'; import Header from '../containers/HeaderContainer'; @@ -41,23 +39,25 @@ import { WithMainStateProps } from '../containers/withMainState'; import { WithPreferencesProps } from '../containers/withPreferences'; import { WithTagCategoriesProps } from '../containers/withTagCategories'; import { WithTasksProps } from '../containers/withTasks'; +import { ProgressData } from '../context/ProgressContext'; import { CreditsFile } from '../credits/CreditsFile'; -import { fpfssLogin } from '../fpfss'; -import { Paths } from '@shared/Paths'; +import { fpfssLogin, getFpfssConsentExt, saveFpfssConsentExt } from '../fpfss'; import { AppRouter, AppRouterProps } from '../router'; -import { getViewName, getGameImagePath, getGameImageURL, getGamePath, joinLibraryRoute } from '../Util'; +import { getGameImagePath, getGameImageURL, getGamePath, getViewName, joinLibraryRoute } from '../Util'; import { LangContext } from '../util/lang'; import { queueOne } from '../util/queue'; +import { FloatingContainer } from './FloatingContainer'; +import { ConnectedFpfssEditGame } from './FpfssEditGame'; +import { InputField } from './InputField'; +import { OpenIcon } from './OpenIcon'; +import { newCurateTask } from './pages/CuratePage'; +import { placeholderProgressData, ProgressBar } from './ProgressComponents'; +import { ResizableSidebar, SidebarResizeEvent } from './ResizableSidebar'; +import { SimpleButton } from './SimpleButton'; +import { SplashScreen } from './SplashScreen'; +import { TaskBar } from './TaskBar'; +import { TitleBar } from './TitleBar'; import uuid = require('uuid'); -import { ProgressData } from '../context/ProgressContext'; -import { IWithShortcut } from 'react-keybind'; -import { newGame } from '@shared/utils/misc'; -import { cancelDialog, RANDOM_GAME_ROW_COUNT, resolveDialog, updateDialogField } from '@renderer/store/main/slice'; -import { WithSearchProps } from '@renderer/containers/withSearch'; -import { WithCurateProps } from '@renderer/containers/withCurateState'; -import { WithFpfssProps } from '@renderer/containers/withFpfss'; -import { WithViewProps } from '@renderer/containers/withView'; -import { RequestState } from '@renderer/store/search/slice'; // Hide the right sidebar if the page is inside these paths const hiddenRightSidebarPages = [Paths.ABOUT, Paths.CURATE, Paths.CONFIG, Paths.MANUAL, Paths.LOGS, Paths.TAGS, Paths.CATEGORIES]; @@ -663,8 +663,44 @@ export class App extends React.Component { } }); }); - } + window.Shared.back.register(BackOut.FPFSS_ACTION, async (event, extId: string) => { + return new Promise((resolve, reject) => { + const previousConsent = getFpfssConsentExt(extId); + if (previousConsent) { + this.performFpfssAction(async (user) => { + resolve(user); + }); + } else { + const msg = formatString(this.props.main.lang.dialog.extFpfssConsent, extId) as string; + remote.dialog.showMessageBox({ + type: 'question', + title: 'FPFSS Extension Access', + message: msg, + buttons: [this.props.main.lang.misc.yes, this.props.main.lang.misc.no], + defaultId: 1, + cancelId: 1 + }) + .then(({ response }) => { + if (response === 0) { + this.performFpfssAction(async (user) => { + if (user) { + saveFpfssConsentExt(extId, true); + resolve(user); + } else { + reject(new Error('Launcher was unable to get FPFSS token')); + } + }); + } else { + reject(new Error('User denied access to FPFSS token')); + } + }).catch((error) => { + reject(new Error('Failed to show FPFSS consent dialog: ' + error)); + }); + } + }); + }); + } init() { window.Shared.back.onStateChange = (state) => { @@ -1185,7 +1221,7 @@ export class App extends React.Component { buttons: ['Ok'], }; if (error.code === 'ENOENT') { - opts.title = this.context.dialog.fileNotFound; + opts.title = this.props.main.lang.dialog.fileNotFound; opts.message = ( 'Failed to find the game file.\n' + 'If you are using Flashpoint Infinity, make sure you download the game first.\n' diff --git a/src/renderer/components/pages/ConfigPage.tsx b/src/renderer/components/pages/ConfigPage.tsx index 9e19e66b2..cc363e2bc 100644 --- a/src/renderer/components/pages/ConfigPage.tsx +++ b/src/renderer/components/pages/ConfigPage.tsx @@ -1,18 +1,26 @@ +import { faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { WithPreferencesProps } from '@renderer/containers/withPreferences'; +import { WithSearchProps } from '@renderer/containers/withSearch'; import { WithTagCategoriesProps } from '@renderer/containers/withTagCategories'; +import { GENERAL_VIEW_ID } from '@renderer/store/search/slice'; import { BackIn } from '@shared/back/types'; +import { ScreenshotPreviewMode } from '@shared/BrowsePageLayout'; import { AppExtConfigData } from '@shared/config/interfaces'; -import { CustomIPC } from '@shared/interfaces'; -import { ipcRenderer } from 'electron'; import { ExtConfigurationProp, ExtensionContribution, IExtensionDescription, ILogoSet } from '@shared/extensions/interfaces'; +import { CustomIPC } from '@shared/interfaces'; import { autoCode, LangContainer, LangFile } from '@shared/lang'; import { memoizeOne } from '@shared/memoize'; +import { Paths } from '@shared/Paths'; import { updatePreferencesData, updatePreferencesDataAsync } from '@shared/preferences/util'; import { ITheme } from '@shared/ThemeFile'; import { deepCopy } from '@shared/Util'; +import * as Coerce from '@shared/utils/Coerce'; import { formatString } from '@shared/utils/StringFormatter'; +import { ipcRenderer } from 'electron'; import { AppPathOverride, TagFilterGroup } from 'flashpoint-launcher'; import * as React from 'react'; +import { clearFpfssConsentExt, getFpfssConsentExt, saveFpfssConsentExt } from '../../fpfss'; import { getExtIconURL, getExtremeIconURL, @@ -34,16 +42,9 @@ import { ConfirmElement, ConfirmElementArgs } from '../ConfirmElement'; import { FloatingContainer } from '../FloatingContainer'; import { InputField } from '../InputField'; import { OpenIcon } from '../OpenIcon'; -import { TagFilterGroupEditor } from '../TagFilterGroupEditor'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons'; -import * as Coerce from '@shared/utils/Coerce'; -import { Spinner } from '../Spinner'; import { SimpleButton } from '../SimpleButton'; -import { ScreenshotPreviewMode } from '@shared/BrowsePageLayout'; -import { WithSearchProps } from '@renderer/containers/withSearch'; -import { Paths } from '@shared/Paths'; -import { GENERAL_VIEW_ID } from '@renderer/store/search/slice'; +import { Spinner } from '../Spinner'; +import { TagFilterGroupEditor } from '../TagFilterGroupEditor'; const { num } = Coerce; @@ -86,6 +87,8 @@ type ConfigPageState = { editorOpen: boolean; /** Progress for nuking tags */ nukeInProgress: boolean; + /** FPFSS Consents to extensions */ + fpfssConsentMap: Record; }; /** @@ -100,12 +103,19 @@ export class ConfigPage extends React.Component { + map[ext.id] = getFpfssConsentExt(ext.id); + return map; + }, {} as Record); + this.state = { isFlashpointPathValid: undefined, flashpointPath: configData.flashpointPath, useCustomTitlebar: configData.useCustomTitlebar, editorOpen: false, nukeInProgress: false, + fpfssConsentMap: fpfssConsentMap, }; } @@ -122,7 +132,7 @@ export class ConfigPage extends React.Component +

{strings.extensionsHeader}

{ extensions.length > 0 ? (
@@ -710,8 +720,13 @@ export class ConfigPage extends React.Component { + renderExtensionsMemo = memoizeOne((extensions: IExtensionDescription[], strings: LangContainer['config'], fpfssConsents: Record): JSX.Element[] => { + const allStrings = this.context; return extensions.map((ext) => { + console.log(fpfssConsents); + + const fpfssConsent = fpfssConsents[ext.id]; + const shortContribs = []; if (ext.contributes) { if (ext.contributes.devScripts && ext.contributes.devScripts.length > 0) { @@ -744,8 +759,7 @@ export class ConfigPage extends React.Component -
+
{ ext.icon ? ( @@ -762,9 +776,17 @@ export class ConfigPage extends React.Component
-
+

{ext.description}

+
+ {(fpfssConsent === true) ? ( + this.onExtFPFSSConsentChange(ext.id, 'revoke')} /> + ) : undefined }
); @@ -826,6 +848,19 @@ export class ConfigPage extends React.Component ); }; + + onExtFPFSSConsentChange = (extId: string, action: string): void => { + const updatedConsent = action === 'grant' ? true : undefined; + if (updatedConsent) { + saveFpfssConsentExt(extId, true); + } else if (action === 'revoke') { + clearFpfssConsentExt(extId); + } + + const newMap = { ...this.state.fpfssConsentMap }; + newMap[extId] = updatedConsent; + this.setState({ fpfssConsentMap: newMap }); + }; onShowExtremeChange = (isChecked: boolean): void => { updatePreferencesData({ browsePageShowExtreme: isChecked }); diff --git a/src/renderer/fpfss.ts b/src/renderer/fpfss.ts index 2ba0fcdbe..ff11a4e48 100644 --- a/src/renderer/fpfss.ts +++ b/src/renderer/fpfss.ts @@ -11,7 +11,7 @@ export async function fpfssLogin(createDialog: typeof mainActions.createDialog, const tokenUrl = `${fpfssBaseUrl}/auth/device`; const data = { 'client_id': 'flashpoint-launcher', - 'scope': 'identity game:read game:edit submission:read submission:read-files', + 'scope': 'identity game:read game:edit submission:read submission:read-files index:read', }; const formData = new URLSearchParams(data).toString(); const res = await axios.post(tokenUrl, formData, { @@ -111,3 +111,56 @@ export async function fpfssLogin(createDialog: typeof mainActions.createDialog, cancelDialog(dialog.id); }); } + +/** + * + * @param extId The extension ID + * @returns The consent status for the extension + */ + +export function getFpfssConsentExt(extId: string): boolean | undefined { + const consentData = localStorage.getItem('fpfss:extension_consent'); + if (!consentData) { + return undefined; + } + + try { + const consentMap = consentData ? JSON.parse(consentData) : {}; + return consentMap[extId]; + } catch (error) { + console.error('Failed to parse consent data:', error); + return undefined; + } +} + +/** +* +* @param extId The extension ID +* @param status The consent status +*/ +export function saveFpfssConsentExt(extId: string, status: boolean): void { + try { + const consentData = localStorage.getItem('fpfss:extension_consent'); + const consentMap = consentData ? JSON.parse(consentData) : {}; + consentMap[extId] = status; + localStorage.setItem('fpfss:extension_consent', JSON.stringify(consentMap)); + } catch (error) { + console.error('Failed to parse or save consent data:', error); + } +} + +/** + * @param extId The extension ID + */ +export function clearFpfssConsentExt(extId: string): void { + const consentData = localStorage.getItem('fpfss:extension_consent'); + if (!consentData) { return; } + + try { + const consentMap = JSON.parse(consentData); + delete consentMap[extId]; + localStorage.setItem('fpfss:extension_consent', JSON.stringify(consentMap)); + } catch (error) { + console.error('Failed to parse consent data:', error); + } +} \ No newline at end of file diff --git a/src/shared/back/types.ts b/src/shared/back/types.ts index ee2b79455..482dd14e8 100644 --- a/src/shared/back/types.ts +++ b/src/shared/back/types.ts @@ -197,6 +197,7 @@ export enum BackIn { // Tests TEST_RECONNECTIONS, + } export enum BackOut { @@ -278,6 +279,8 @@ export enum BackOut { CANCEL_DIALOG, UPDATE_DIALOG_MESSAGE, UPDATE_DIALOG_FIELD_VALUE, + + FPFSS_ACTION } export const BackRes = { @@ -436,6 +439,7 @@ export type BackInTemplate = SocketTemplate void; + }> export type BackOutTemplate = SocketTemplate void; [BackOut.UPDATE_DIALOG_MESSAGE]: (message: string, dialogId: string) => void; [BackOut.UPDATE_DIALOG_FIELD_VALUE]: (dialogId: string, name: string, value: any) => void; + + [BackOut.FPFSS_ACTION]: (extId: string) => FpfssUser | undefined; }> export type BackResTemplate = BackOutTemplate & BackInTemplate; diff --git a/src/shared/lang.ts b/src/shared/lang.ts index f20b08b7e..e9400b156 100644 --- a/src/shared/lang.ts +++ b/src/shared/lang.ts @@ -559,6 +559,7 @@ const langTemplate = { 'searching', 'loading', 'ok', + 'allow' ] as const, menu: [ 'viewThumbnailInFolder', @@ -656,7 +657,14 @@ const langTemplate = { 'openWiki', 'openDiscord', 'doNotShowAgain', + 'extFpfssConsent', ] as const, + extensions: [ + 'fpssConsentRevokeTitle', + 'fpssConsentRevokeDesc', + 'fpssConsentGrantTitle', + 'fpssConsentGrantDesc', + ] // libraries: [], // (This is dynamically populated in run-time) } as const; diff --git a/typings/flashpoint-launcher.d.ts b/typings/flashpoint-launcher.d.ts index 93d378f8d..fb2104389 100644 --- a/typings/flashpoint-launcher.d.ts +++ b/typings/flashpoint-launcher.d.ts @@ -1645,4 +1645,8 @@ declare module 'flashpoint-launcher' { */ upgradeConfig(version: string, config: any): any; } + + namespace fpfss { + function getAccessToken(): Promise; + } }