diff --git a/lang/en.json b/lang/en.json index 9d7cb74ec..dd7c415f3 100644 --- a/lang/en.json +++ b/lang/en.json @@ -592,6 +592,7 @@ "exportPlaylist": "Export Playlist", "makeCurationFromGame": "Make Curation from Game", "copyShortcutURL": "Copy Shortcut URL", + "downloadGame": "Download Game", "downloadPlaylistContent": "Download Playlist Entries" }, "dialog": { diff --git a/src/back/curate/util.ts b/src/back/curate/util.ts index fcf41f5f0..654d1824e 100644 --- a/src/back/curate/util.ts +++ b/src/back/curate/util.ts @@ -322,15 +322,32 @@ function isValidDate(str: string): boolean { return (/^\d{4}(-(0?[1-9]|1[012])(-(0?[1-9]|[12][0-9]|3[01]))?)?$/).test(str); } -export async function makeCurationFromGame(state: BackState, gameId: string, skipDataPack?: boolean): Promise { - const game = await fpDatabase.findGame(gameId); - const folder = uuid(); - if (game) { - const curPath = path.join(state.config.flashpointPath, CURATIONS_FOLDER_WORKING, folder); - await fs.promises.mkdir(curPath, { recursive: true }); +export async function makeCurationFromGame(state: BackState, gameId: string, skipDataPack?: boolean, taskId?: string): Promise { + try { + const game = await fpDatabase.findGame(gameId); + const folder = uuid(); + if (game) { + if (taskId) { + state.socketServer.broadcast(BackOut.UPDATE_TASK, { + id: taskId, + status: 'Preparing curation...', + progress: 0.1 + }); + } + + const curPath = path.join(state.config.flashpointPath, CURATIONS_FOLDER_WORKING, folder); + await fs.promises.mkdir(curPath, { recursive: true }); const contentFolder = path.join(curPath, 'content'); await fs.promises.mkdir(contentFolder, { recursive: true }); + if (taskId) { + state.socketServer.broadcast(BackOut.UPDATE_TASK, { + id: taskId, + status: 'Copying existing data...', + progress: 0.3 + }); + } + const imagesRoot = path.join(state.config.flashpointPath, state.preferences.imageFolderPath); // Copy images (download from remote if does not exist) const logoPath = path.join(imagesRoot, game.logoPath); @@ -379,6 +396,14 @@ export async function makeCurationFromGame(state: BackState, gameId: string, ski // Extract active data pack if exists if (game.activeDataId) { + if (taskId) { + state.socketServer.broadcast(BackOut.UPDATE_TASK, { + id: taskId, + status: 'Extracting data pack...', + progress: 0.5 + }); + } + await checkAndDownloadGameData(game.activeDataId); const activeData = await fpDatabase.findGameDataById(game.activeDataId); if (activeData && activeData.path && !skipDataPack) { @@ -428,6 +453,15 @@ export async function makeCurationFromGame(state: BackState, gameId: string, ski alreadyImported: true, warnings: await genCurationWarnings(data, state.config.flashpointPath, state.suggestions, state.languageContainer.curate, state.apiEmitters.curations.onWillGenCurationWarnings), }; + + if (taskId) { + state.socketServer.broadcast(BackOut.UPDATE_TASK, { + id: taskId, + status: 'Finalizing curation...', + progress: 0.9 + }); + } + await saveCuration(curPath, curation); state.loadedCurations.push(curation); @@ -443,8 +477,32 @@ export async function makeCurationFromGame(state: BackState, gameId: string, ski // Send back responses state.socketServer.broadcast(BackOut.CURATE_LIST_CHANGE, [curation]); + + // Mark curation task as finished + if (taskId) { + state.socketServer.broadcast(BackOut.UPDATE_TASK, { + id: taskId, + status: '', + progress: 1, + finished: true + }); + } + return curation.folder; } + } catch (error) { + log.error('Launcher', `Make Curation From Game failed: ${error instanceof Error ? error.message : String(error)}`); + + if (taskId) { + state.socketServer.broadcast(BackOut.UPDATE_TASK, { + id: taskId, + status: 'Failed to create curation', + finished: true, + error: error instanceof Error ? error.message : String(error) + }); + } + throw error; + } } export async function refreshCurationContent(state: BackState, folder: string) { diff --git a/src/back/dns.ts b/src/back/dns.ts index d7bdb3ae5..449bbdd60 100644 --- a/src/back/dns.ts +++ b/src/back/dns.ts @@ -3,6 +3,7 @@ import * as dns from 'node:dns'; import * as dnsPacket from 'dns-packet'; import _axios from 'axios'; import { Agent } from 'node:https'; +import { VERSION } from '@shared/version'; let id = 0; @@ -88,6 +89,7 @@ const agent = new Agent({ export const axios = _axios.create({ headers: { - 'User-Agent': 'Flashpoint Launcher' + 'User-Agent': 'Flashpoint Launcher/' + VERSION, + 'x-launcher-version': VERSION } }) \ No newline at end of file diff --git a/src/back/responses.ts b/src/back/responses.ts index 1d43f9374..7b8749834 100644 --- a/src/back/responses.ts +++ b/src/back/responses.ts @@ -2354,8 +2354,8 @@ export function registerRequestCallbacks(state: BackState, init: () => Promise { - return makeCurationFromGame(state, gameId); + state.socketServer.register(BackIn.CURATE_FROM_GAME, async (event, gameId, taskId) => { + return makeCurationFromGame(state, gameId, false, taskId); }); state.socketServer.register(BackIn.CURATE_CREATE_CURATION, async (event, folder, meta) => { diff --git a/src/renderer/Util.ts b/src/renderer/Util.ts index a22b4483e..36f9697fd 100644 --- a/src/renderer/Util.ts +++ b/src/renderer/Util.ts @@ -12,6 +12,7 @@ import { ViewQuery } from '@shared/library/util'; import { getGameDataFilename } from '@shared/utils/misc'; import { GENERAL_VIEW_ID } from '@renderer/store/search/slice'; import _axios from 'axios'; +import { VERSION } from '@shared/version'; export const gameDragDataType = 'json/game-drag'; @@ -313,6 +314,6 @@ export function wrapSearchTerm(text: string): string { export const axios = _axios.create({ headers: { - 'User-Agent': 'Flashpoint Launcher' + 'x-launcher-version': VERSION, } }); diff --git a/src/renderer/components/SearchBar.tsx b/src/renderer/components/SearchBar.tsx index 49548efbd..d1a59dac6 100644 --- a/src/renderer/components/SearchBar.tsx +++ b/src/renderer/components/SearchBar.tsx @@ -679,7 +679,7 @@ function SearchableSelectDropdown(props: Searcha // Split the items into 2 halves - Selected and not selected, then merge const filteredItems = React.useMemo(() => { - const lowerSearch = search.toLowerCase().replace(' ', ''); + const lowerSearch = search.toLowerCase().replace(/\s+/g, ''); const selectedItems = storedItems.filter((item) => item.value in selected); selectedItems.sort((a, b) => { if (selected[a.value] === 'whitelist' && selected[b.value] === 'blacklist') { @@ -693,7 +693,7 @@ function SearchableSelectDropdown(props: Searcha return [ ...selectedItems, - ...storedItems.filter((item) => !(item.value in selected) && item.orderVal.toLowerCase().includes(lowerSearch)), + ...storedItems.filter((item) => !(item.value in selected) && item.orderVal.toLowerCase().replace(/\s+/g, '').includes(lowerSearch)), ]; }, [search, storedItems]); diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 26f9586ae..e60c8107d 100644 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -1208,6 +1208,44 @@ export class App extends React.Component { click: () => { clipboard.writeText(gameId); } + }, + { + /* Download Game */ + label: strings.menu.downloadGame, + enabled: !window.Shared.isBackRemote, // (Local "back" only) + click: () => { + window.Shared.back.request(BackIn.GET_GAME, gameId) + .then((game) => { + if (game && game.activeDataId) { + window.Shared.back.request(BackIn.DOWNLOAD_GAME_DATA, game.activeDataId) + .then(() => { + // Refresh the game data in the sidebar after successful download + this.onUpdateActiveGameData(true, game.activeDataId); + }) + .catch((error) => { + console.error('Failed to download game:', error); + const opts: Electron.MessageBoxOptions = { + type: 'error', + title: 'Download Failed', + message: `Failed to download game: ${game.title}\nError: ${error.toString()}`, + buttons: ['Ok'], + }; + ipcRenderer.invoke(CustomIPC.SHOW_MESSAGE_BOX, opts); + }); + } else { + const opts: Electron.MessageBoxOptions = { + type: 'warning', + title: 'Cannot Download', + message: 'This game does not have downloadable content or the game data is not available.', + buttons: ['Ok'], + }; + ipcRenderer.invoke(CustomIPC.SHOW_MESSAGE_BOX, opts); + } + }) + .catch((error) => { + console.error('Failed to get game info:', error); + }); + } }, { type: 'separator' }, { /* File Location */ label: strings.menu.openFileLocation, @@ -1311,7 +1349,9 @@ export class App extends React.Component { label: strings.menu.makeCurationFromGame, enabled: this.props.preferencesData.enableEditing, click: () => { - window.Shared.back.request(BackIn.CURATE_FROM_GAME, gameId) + const task = newCurateTask('Make Curation from Game', 'Preparing curation...', this.props.addTask); + + window.Shared.back.request(BackIn.CURATE_FROM_GAME, gameId, task.id) .then((folder) => { if (folder) { // Select the new curation @@ -1321,6 +1361,13 @@ export class App extends React.Component { // Redirect to Curate once it's been made this.props.history.push(Paths.CURATE); } else { + this.props.setTask({ + id: task.id, + status: 'Failed to create curation', + finished: true, + error: 'No error provided.' + }); + ipcRenderer.invoke(CustomIPC.SHOW_MESSAGE_BOX, { title: 'Failed to create curation', message: 'Failed to create curation from this game. No error provided.' @@ -1328,6 +1375,13 @@ export class App extends React.Component { } }) .catch((err: any) => { + this.props.setTask({ + id: task.id, + status: 'Failed to create curation', + finished: true, + error: err.toString() + }); + ipcRenderer.invoke(CustomIPC.SHOW_MESSAGE_BOX, { title: 'Failed to create curation', message: `Failed to create curation from this game.\nError: ${err.toString()}` diff --git a/src/renderer/components/pages/BrowsePage.tsx b/src/renderer/components/pages/BrowsePage.tsx index 1894a34ce..b7f9d7ddb 100644 --- a/src/renderer/components/pages/BrowsePage.tsx +++ b/src/renderer/components/pages/BrowsePage.tsx @@ -86,6 +86,13 @@ export class BrowsePage extends React.Component = React.createRef(); + /** Timeouts for delayed selection/deselection to allow double-clicks */ + private gridSelectionTimeout?: NodeJS.Timeout; + private listSelectionTimeout?: NodeJS.Timeout; + + /** Timestamp of last game launch to prevent immediate deselection after launch */ + private lastGameLaunchTime: number = 0; + /** Time it takes before the current "quick search" string to reset after a change was made (in milliseconds). */ static readonly quickSearchTimeout: number = 1500; @@ -106,6 +113,16 @@ export class BrowsePage extends React.Component => { const { currentView } = this.props; - if (currentView.selectedGame?.id !== gameId && gameId) { - const game = await window.Shared.back.request(BackIn.GET_GAME, gameId); - if (game) { - if (col !== undefined && row !== undefined) { - this.props.searchActions.setGridScroll({ + + // Clear any pending selection timeout + if (this.gridSelectionTimeout) { + clearTimeout(this.gridSelectionTimeout); + this.gridSelectionTimeout = undefined; + } + + if (gameId) { + if (currentView.selectedGame?.id !== gameId) { + // Select a different game + const game = await window.Shared.back.request(BackIn.GET_GAME, gameId); + if (game) { + if (col !== undefined && row !== undefined) { + this.props.searchActions.setGridScroll({ + view: currentView.id, + col, + row + }); + } + this.props.searchActions.selectGame({ view: currentView.id, - col, - row + game, }); } - this.props.searchActions.selectGame({ - view: currentView.id, - game, - }); + } else { + // This prevents tripple-clicks from deselecting the game + const timeSinceLastLaunch = Date.now() - this.lastGameLaunchTime; + if (timeSinceLastLaunch < 500) { + return; + } + + // Deselect the same game that's already selected + this.gridSelectionTimeout = setTimeout(() => { + this.props.searchActions.selectGame({ + view: currentView.id, + game: undefined + }); + this.gridSelectionTimeout = undefined; + }, 250); } } } @@ -426,24 +468,62 @@ export class BrowsePage extends React.Component => { const { currentView } = this.props; - if (currentView.selectedGame?.id !== gameId && gameId) { - const game = await window.Shared.back.request(BackIn.GET_GAME, gameId); - if (game) { - if (row !== undefined) { - this.props.searchActions.setListScroll({ + + // Clear any pending selection timeout + if (this.listSelectionTimeout) { + clearTimeout(this.listSelectionTimeout); + this.listSelectionTimeout = undefined; + } + + if (gameId) { + if (currentView.selectedGame?.id !== gameId) { + // Select a different game + const game = await window.Shared.back.request(BackIn.GET_GAME, gameId); + if (game) { + if (row !== undefined) { + this.props.searchActions.setListScroll({ + view: currentView.id, + row + }); + } + this.props.searchActions.selectGame({ view: currentView.id, - row + game, }); } - this.props.searchActions.selectGame({ - view: currentView.id, - game, - }); + } else { + // This prevents tripple-clicks from deselecting the game + const timeSinceLastLaunch = Date.now() - this.lastGameLaunchTime; + if (timeSinceLastLaunch < 500) { + return; + } + + // Deselect the same game that's already selected + this.listSelectionTimeout = setTimeout(() => { + this.props.searchActions.selectGame({ + view: currentView.id, + game: undefined, + }); + this.listSelectionTimeout = undefined; + }, 250); } } }; onGameLaunch = async (gameId: string, override: GameLaunchOverride): Promise => { + // Record the launch time to prevent immediate deselection + this.lastGameLaunchTime = Date.now(); + + // Clear any pending deselection timeouts since we're launching the game + if (this.gridSelectionTimeout) { + clearTimeout(this.gridSelectionTimeout); + this.gridSelectionTimeout = undefined; + } + if (this.listSelectionTimeout) { + clearTimeout(this.listSelectionTimeout); + this.listSelectionTimeout = undefined; + } + await window.Shared.back.request(BackIn.LAUNCH_GAME, gameId, override); }; diff --git a/src/renderer/components/pages/HomePage.tsx b/src/renderer/components/pages/HomePage.tsx index 1a44f32d3..502b48bdd 100644 --- a/src/renderer/components/pages/HomePage.tsx +++ b/src/renderer/components/pages/HomePage.tsx @@ -471,7 +471,7 @@ export function HomePage(props: HomePageProps) { { props.main.metadataUpdate.ready && props.main.metadataUpdate.total > 0 && (
- {formatString(strings.updatedGamesReady, (props.main.metadataUpdate.total + 1).toString())} + {formatString(strings.updatedGamesReady, (props.main.metadataUpdate.total).toString())}
)}
diff --git a/src/shared/back/types.ts b/src/shared/back/types.ts index a4d89cf45..d771cf7af 100644 --- a/src/shared/back/types.ts +++ b/src/shared/back/types.ts @@ -429,7 +429,7 @@ export type BackInTemplate = SocketTemplate void; [BackIn.CURATE_EXPORT]: (curations: LoadedCuration[], taskId?: string) => void; [BackIn.CURATE_EXPORT_DATA_PACK]: (curations: LoadedCuration[], taskId?: string) => void; - [BackIn.CURATE_FROM_GAME]: (gameId: string) => string | undefined; + [BackIn.CURATE_FROM_GAME]: (gameId: string, taskId?: string) => string | undefined; [BackIn.CURATE_REFRESH_CONTENT]: (folder: string) => void; [BackIn.CURATE_GEN_WARNINGS]: (curation: CurationState) => CurationWarnings; [BackIn.CURATE_DUPLICATE]: (folders: string[]) => void; diff --git a/src/shared/lang.ts b/src/shared/lang.ts index 474f5a94f..098f67148 100644 --- a/src/shared/lang.ts +++ b/src/shared/lang.ts @@ -605,6 +605,7 @@ const langTemplate = { 'exportPlaylist', 'makeCurationFromGame', 'copyShortcutURL', + 'downloadGame', 'downloadPlaylistContent', ] as const, dialog: [