diff --git a/package-lock.json b/package-lock.json index 83a72dfe..8dc37f0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "fbw-installer", - "version": "3.4.0-dev.1", + "version": "3.4.2-dev.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fbw-installer", - "version": "3.4.0-dev.1", + "version": "3.4.2-dev.1", "license": "GPL-3.0", "dependencies": { - "@flybywiresim/fragmenter": "^0.7.4", + "@flybywiresim/fragmenter": "^0.8.0", "@reduxjs/toolkit": "^1.7.1", "@sentry/cli": "^2.31.0", "@sentry/electron": "^4.23.0", @@ -2779,9 +2779,9 @@ "dev": true }, "node_modules/@flybywiresim/fragmenter": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/@flybywiresim/fragmenter/-/fragmenter-0.7.4.tgz", - "integrity": "sha512-CBNKQyjp5w4pVyqT5H0ixI6SOyQdXBYvkLF9dQiZJ+1RxxpNBG0NN8pH+gNL38+BMdL3Z++0KpHOHvZNyy2uPg==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@flybywiresim/fragmenter/-/fragmenter-0.8.0.tgz", + "integrity": "sha512-IpFfUuJi/Ix4B8s57k0N9glMVmwVT8FOC6qT2LgL1bmrC9Xfjjfuty8IDjNZH0KjAHvolk/7TzTEhbh7qUPnpQ==", "dependencies": { "axios": "^0.27.2", "split-file": "^2.3.0", diff --git a/package.json b/package.json index bee28203..7b0dc989 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "fbw-installer", "productName": "FlyByWire Installer", - "version": "3.4.0-dev.1", + "version": "3.4.2-dev.1", "description": "Desktop application to install and customize FlyByWire addons", "configUrls": { "production": "https://cdn.flybywiresim.com/installer/config/production.json", @@ -103,7 +103,7 @@ "webpack-dev-server": "^4.4.0" }, "dependencies": { - "@flybywiresim/fragmenter": "^0.7.4", + "@flybywiresim/fragmenter": "^0.8.0", "@reduxjs/toolkit": "^1.7.1", "@sentry/cli": "^2.31.0", "@sentry/electron": "^4.23.0", diff --git a/src/renderer/components/AddonSection/Configure/TrackSelector.tsx b/src/renderer/components/AddonSection/Configure/TrackSelector.tsx index 94d1fcaa..6f890b67 100644 --- a/src/renderer/components/AddonSection/Configure/TrackSelector.tsx +++ b/src/renderer/components/AddonSection/Configure/TrackSelector.tsx @@ -21,28 +21,25 @@ type TrackProps = { }; export const Track: React.FC = ({ isSelected, isInstalled, handleSelected, addon, track }) => { - const latestVersionName = useSelector( - (state) => state.latestVersionNames[addon.key]?.[track.key]?.name ?? '', + const latestVersionName = useSelector( + (state) => state.latestVersionNames[addon.key]?.[track.key]?.name, ); return (
handleSelected(track)} > -
{track.name} - - {latestVersionName} + + {latestVersionName ?? } + {isInstalled && }
- {isInstalled && }
); }; diff --git a/src/renderer/components/AddonSection/Configure/index.tsx b/src/renderer/components/AddonSection/Configure/index.tsx index 282a4089..bd429949 100644 --- a/src/renderer/components/AddonSection/Configure/index.tsx +++ b/src/renderer/components/AddonSection/Configure/index.tsx @@ -11,8 +11,8 @@ import rehypeRaw from 'rehype-raw'; export interface ConfigureProps { routeAspectKey: string; selectedAddon: Addon; - selectedTrack: AddonTrack; - installedTrack: AddonTrack; + selectedTrack: AddonTrack | null; + installedTrack: AddonTrack | null; onTrackSelection: (track: AddonTrack) => void; } @@ -41,8 +41,8 @@ export const Configure: FC = ({ addon={selectedAddon} key={track.key} track={track} - isSelected={selectedTrack === track} - isInstalled={installedTrack === track} + isSelected={selectedTrack?.key === track.key} + isInstalled={installedTrack?.key === track.key} handleSelected={() => onTrackSelection(track)} /> ))} @@ -58,8 +58,8 @@ export const Configure: FC = ({ addon={selectedAddon} key={track.key} track={track} - isSelected={selectedTrack === track} - isInstalled={installedTrack === track} + isSelected={selectedTrack?.key === track.key} + isInstalled={installedTrack?.key === track.key} handleSelected={() => onTrackSelection(track)} /> ))} diff --git a/src/renderer/components/AddonSection/index.tsx b/src/renderer/components/AddonSection/index.tsx index c3fa2f19..64543ef6 100644 --- a/src/renderer/components/AddonSection/index.tsx +++ b/src/renderer/components/AddonSection/index.tsx @@ -2,10 +2,8 @@ import React, { FC, useCallback, useEffect, useState } from 'react'; import { setupInstallPath } from 'renderer/actions/install-path.utils'; import { DownloadItem } from 'renderer/redux/types'; import { useSelector } from 'react-redux'; -import { getCurrentInstall } from '@flybywiresim/fragmenter'; import { InstallerStore, useAppDispatch, useAppSelector } from '../../redux/store'; import { Addon, AddonCategoryDefinition, AddonTrack } from 'renderer/utils/InstallerConfiguration'; -import { Directories } from 'renderer/utils/Directories'; import { NavLink, Redirect, Route, useHistory, useParams } from 'react-router-dom'; import { Gear, InfoCircle, JournalText, Sliders } from 'react-bootstrap-icons'; import settings, { useSetting } from 'renderer/rendererSettings'; @@ -13,8 +11,6 @@ import { ipcRenderer } from 'electron'; import { AddonBar, AddonBarItem } from '../App/AddonBar'; import { NoAvailableAddonsSection } from '../NoAvailableAddonsSection'; import { ReleaseNotes } from './ReleaseNotes'; -import { setInstalledTrack } from 'renderer/redux/features/installedTrack'; -import { InstallState, setInstallStatus } from 'renderer/redux/features/installStatus'; import { setSelectedTrack } from 'renderer/redux/features/selectedTrack'; import { PromptModal, useModals } from 'renderer/components/Modal'; import ReactMarkdown from 'react-markdown'; @@ -129,13 +125,6 @@ export const AddonSection = (): JSX.Element => { const installedTrack = (installedTracks[selectedAddon.key] as AddonTrack) ?? null; - const setCurrentlyInstalledTrack = useCallback( - (newInstalledTrack: AddonTrack) => { - dispatch(setInstalledTrack({ addonKey: selectedAddon.key, installedTrack: newInstalledTrack })); - }, - [dispatch, selectedAddon.key], - ); - const setCurrentlySelectedTrack = useCallback( (newSelectedTrack: AddonTrack) => { dispatch(setSelectedTrack({ addonKey: selectedAddon.key, track: newSelectedTrack })); @@ -145,79 +134,12 @@ export const AddonSection = (): JSX.Element => { const selectedTrack = (selectedTracks[selectedAddon.key] as AddonTrack) ?? null; - const selectAndSetTrack = useCallback( - (key: string) => { - const newTrack = selectedAddon.tracks.find((track) => track.key === key); - setCurrentlySelectedTrack(newTrack); - }, - [selectedAddon.tracks, setCurrentlySelectedTrack], - ); - - const getCurrentInstallStatus = (): InstallState => { - try { - return installStates[selectedAddon.key]; - } catch (e) { - setCurrentInstallStatus({ status: InstallStatus.Unknown }); - return { status: InstallStatus.Unknown }; - } - }; - - const setCurrentInstallStatus = useCallback( - (new_state: InstallState) => { - dispatch(setInstallStatus({ addonKey: selectedAddon.key, installState: new_state })); - }, - [dispatch, selectedAddon.key], - ); - - const findInstalledTrack = useCallback((): AddonTrack => { - if (!Directories.isFragmenterInstall(selectedAddon)) { - console.log('Not installed'); - if (selectedTrack) { - selectAndSetTrack(selectedTrack.key); - return selectedTrack; - } else { - setCurrentlySelectedTrack(selectedAddon.tracks[0]); - return selectedAddon.tracks[0]; - } - } - - try { - const manifest = getCurrentInstall(Directories.inInstallLocation(selectedAddon.targetDirectory)); - console.log('Currently installed', manifest); - - let track = selectedAddon.tracks.find((track) => track.url.includes(manifest.source)); - if (!track) { - track = selectedAddon.tracks.find((track) => track.alternativeUrls?.includes(manifest.source)); - } - - console.log('Currently installed', track); - setCurrentlyInstalledTrack(track); - if (selectedTrack) { - selectAndSetTrack(selectedTrack.key); - return selectedTrack; - } else { - setCurrentlySelectedTrack(track); - return track; - } - } catch (e) { - console.error(e); - console.log('Not installed'); - if (selectedTrack) { - selectAndSetTrack(selectedTrack.key); - return selectedTrack; - } else { - setCurrentlySelectedTrack(selectedAddon.tracks[0]); - return selectedAddon.tracks[0]; - } - } - }, [selectAndSetTrack, selectedAddon, selectedTrack, setCurrentlyInstalledTrack, setCurrentlySelectedTrack]); - const download: DownloadItem = useSelector((state: InstallerStore) => state.downloads.find((download) => download.id === selectedAddon.key), ); const isDownloading = download?.progress.totalPercent >= 0; - const status = getCurrentInstallStatus()?.status; + const status = installStates[selectedAddon.key]?.status; const isInstalling = InstallStatusCategories.installing.includes(status); const isFinishingDependencyInstall = status === InstallStatus.InstallingDependencyEnding; @@ -255,11 +177,10 @@ export const AddonSection = (): JSX.Element => { }, [dispatch, publisherData, selectedAddon]); useEffect(() => { - findInstalledTrack(); if (!isInstalling) { - InstallManager.determineAddonInstallState(selectedAddon).then(setCurrentInstallStatus); + void InstallManager.refreshAddonInstallState(selectedAddon); } - }, [findInstalledTrack, isInstalling, selectedAddon, setCurrentInstallStatus]); + }, [isInstalling, selectedAddon]); useEffect(() => { if (download && isDownloading) { @@ -288,13 +209,19 @@ export const AddonSection = (): JSX.Element => { bodyText={track.warningContent} confirmColor={ButtonType.Caution} onConfirm={() => { - selectAndSetTrack(track.key); + setCurrentlySelectedTrack(track); + + // Update install state + void InstallManager.refreshAddonInstallState(selectedAddon); }} dontShowAgainSettingName="mainSettings.disableExperimentalWarning" />, ); } else { - selectAndSetTrack(track.key); + setCurrentlySelectedTrack(track); + + // Update install state + void InstallManager.refreshAddonInstallState(selectedAddon); } } }; diff --git a/src/renderer/components/App/index.tsx b/src/renderer/components/App/index.tsx index 3bb23564..54fa41a1 100644 --- a/src/renderer/components/App/index.tsx +++ b/src/renderer/components/App/index.tsx @@ -4,17 +4,13 @@ import SimpleBar from 'simplebar-react'; import { Logo } from 'renderer/components/Logo'; import { SettingsSection } from 'renderer/components/SettingsSection'; import { DebugSection } from 'renderer/components/DebugSection'; -import { GitVersions } from '@flybywiresim/api-client'; -import { DataCache } from '../../utils/DataCache'; import { InstallerUpdate } from 'renderer/components/InstallerUpdate'; import { WindowButtons } from 'renderer/components/WindowActionButtons'; -import { Addon, AddonVersion } from 'renderer/utils/InstallerConfiguration'; -import { AddonData } from 'renderer/utils/AddonData'; +import { Addon } from 'renderer/utils/InstallerConfiguration'; import { ErrorModal } from '../ErrorModal'; import { NavBar, NavBarPublisher } from 'renderer/components/App/NavBar'; import { Redirect, Route, Switch, useHistory, useLocation } from 'react-router-dom'; -import { store, useAppSelector } from 'renderer/redux/store'; -import { setAddonAndTrackLatestReleaseInfo } from 'renderer/redux/features/latestVersionNames'; +import { useAppSelector } from 'renderer/redux/store'; import settings from 'renderer/rendererSettings'; import './index.css'; import { ipcRenderer } from 'electron'; @@ -22,55 +18,7 @@ import channels from 'common/channels'; import { ModalContainer } from '../Modal'; import { PublisherSection } from 'renderer/components/PublisherSection'; import * as packageInfo from '../../../../package.json'; - -const releaseCache = new DataCache('releases', 1000 * 3600 * 24); - -/** - * Obtain releases for a specific addon - * - * @param addon - */ -export const getAddonReleases = async (addon: Addon): Promise => { - const releases = ( - await releaseCache.fetchOrCompute(async (): Promise => { - return (await GitVersions.getReleases(addon.repoOwner, addon.repoName)) - .filter((r) => /v\d/.test(r.name)) - .map((r) => ({ title: r.name, date: r.publishedAt, type: 'minor' })); - }) - ).map((r) => ({ ...r, date: new Date(r.date) })); // Local Data cache returns a string instead of Date - - releases.forEach((version, index) => { - const currentVersionTitle = version.title; - const otherVersionTitle = index === releases.length - 1 ? releases[index - 1].title : releases[index + 1].title; - - if (currentVersionTitle[1] !== otherVersionTitle[1]) { - releases[index].type = 'major'; - } else if (currentVersionTitle[3] !== otherVersionTitle[3]) { - releases[index].type = 'minor'; - } else if (currentVersionTitle[5] !== otherVersionTitle[5] && index === releases.length - 1) { - releases[index].type = 'minor'; - } else if (currentVersionTitle[5] !== otherVersionTitle[5]) { - releases[index].type = 'patch'; - } - }); - - return releases; -}; - -export const fetchLatestVersionNames = async (addon: Addon): Promise => { - const dispatch = store.dispatch; - - for (const track of addon.tracks) { - const trackLatestVersionName = await AddonData.latestVersionForTrack(addon, track); - dispatch( - setAddonAndTrackLatestReleaseInfo({ - addonKey: addon.key, - trackKey: track.key, - info: trackLatestVersionName, - }), - ); - } -}; +import { InstallManager } from 'renderer/utils/InstallManager'; const App = () => { const history = useHistory(); @@ -86,8 +34,9 @@ const App = () => { ); useEffect(() => { - addons.forEach(AddonData.configureInitialAddonState); - addons.forEach(fetchLatestVersionNames); + for (const addon of addons) { + void InstallManager.refreshAddonInstallState(addon).then(() => void InstallManager.checkForUpdates(addon)); + } if (settings.get('cache.main.lastShownSection')) { history.push(settings.get('cache.main.lastShownSection')); @@ -103,8 +52,10 @@ const App = () => { const updateCheck = setInterval( () => { ipcRenderer.send(channels.checkForInstallerUpdate); - addons.forEach(AddonData.checkForUpdates); - addons.forEach(fetchLatestVersionNames); + + for (const addon of addons) { + void InstallManager.checkForUpdates(addon); + } }, 5 * 60 * 1000, ); diff --git a/src/renderer/components/SettingsSection/Developer.tsx b/src/renderer/components/SettingsSection/Developer.tsx index 819bcfcc..d44de15e 100644 --- a/src/renderer/components/SettingsSection/Developer.tsx +++ b/src/renderer/components/SettingsSection/Developer.tsx @@ -1,5 +1,6 @@ import React, { FC, useCallback, useEffect, useState } from 'react'; import { useSetting } from 'renderer/rendererSettings'; +import { Toggle } from 'renderer/components/Toggle'; const SettingsItem: FC<{ name: string }> = ({ name, children }) => (
@@ -8,10 +9,12 @@ const SettingsItem: FC<{ name: string }> = ({ name, children }) => (
); -export const DeveloperSettings = (): JSX.Element => { +export const DeveloperSettings: React.FC = () => { const [configDownloadUrl, setConfigDownloadUrl] = useSetting('mainSettings.configDownloadUrl'); const [configDownloadUrlValid, setConfigDownloadUrlValid] = useState(false); + const [configForceUseLocal, setConfigForceUseLocal] = useSetting('mainSettings.configForceUseLocal'); + const validateUrl = useCallback(() => { try { fetch(configDownloadUrl).then((response) => { @@ -32,7 +35,7 @@ export const DeveloperSettings = (): JSX.Element => {

General Settings

-
+
{ />
+ +
+ +
+
diff --git a/src/renderer/index.tsx b/src/renderer/index.tsx index 10f1973d..8fa528b4 100644 --- a/src/renderer/index.tsx +++ b/src/renderer/index.tsx @@ -3,7 +3,7 @@ import ReactDOM from 'react-dom'; import * as Sentry from '@sentry/electron/renderer'; import { browserTracingIntegration } from '@sentry/browser'; import { Provider } from 'react-redux'; -import App, { fetchLatestVersionNames } from 'renderer/components/App'; +import App from 'renderer/components/App'; import { Configuration, InstallerConfiguration } from 'renderer/utils/InstallerConfiguration'; import { ipcRenderer } from 'electron'; import { Directories } from 'renderer/utils/Directories'; @@ -66,8 +66,6 @@ InstallerConfiguration.obtain() } else { store.dispatch(addReleases({ key: addon.key, releases: [] })); } - - fetchLatestVersionNames(addon); } } diff --git a/src/renderer/redux/features/installStatus.ts b/src/renderer/redux/features/installStatus.ts index f9663f18..4f137264 100644 --- a/src/renderer/redux/features/installStatus.ts +++ b/src/renderer/redux/features/installStatus.ts @@ -9,7 +9,10 @@ interface BaseInstallState { export interface GenericInstallState { status: Exclude< InstallStatus, - InstallStatus.InstallingDependency | InstallStatus.InstallingDependencyEnding | InstallStatus.Decompressing + | InstallStatus.InstallingDependency + | InstallStatus.InstallingDependencyEnding + | InstallStatus.Decompressing + | InstallStatus.DownloadCanceled >; } @@ -33,11 +36,17 @@ export interface DecompressingInstallState extends BaseInstallState { entry?: string; } +export interface CancelledInstallState extends BaseInstallState { + status: InstallStatus.DownloadCanceled; + timestamp: number; +} + export type InstallState = | GenericInstallState | InstallingDependencyInstallState | InstallingDependencyEndingInstallState - | DecompressingInstallState; + | DecompressingInstallState + | CancelledInstallState; const initialState: Record = {}; diff --git a/src/renderer/rendererSettings.ts b/src/renderer/rendererSettings.ts index 6790acd7..bda0b925 100644 --- a/src/renderer/rendererSettings.ts +++ b/src/renderer/rendererSettings.ts @@ -105,6 +105,7 @@ interface RendererSettings { allowSeasonalEffects: boolean; msfsBasePath: string; configDownloadUrl: string; + configForceUseLocal: boolean; }; cache: { main: { @@ -216,6 +217,10 @@ const schema: Schema = { type: 'string', default: packageInfo.configUrls.production, }, + configForceUseLocal: { + type: 'boolean', + default: false, + }, }, }, cache: { diff --git a/src/renderer/utils/AddonData.ts b/src/renderer/utils/AddonData.ts index 0e341bcb..c86a2487 100644 --- a/src/renderer/utils/AddonData.ts +++ b/src/renderer/utils/AddonData.ts @@ -1,15 +1,6 @@ import { Addon, AddonTrack, GithubBranchReleaseModel } from 'renderer/utils/InstallerConfiguration'; import { GitVersions } from '@flybywiresim/api-client'; -import { Directories } from './Directories'; -import fs from 'fs'; -import { getCurrentInstall, FragmenterUpdateChecker } from '@flybywiresim/fragmenter'; -import settings from 'renderer/rendererSettings'; -import { store } from 'renderer/redux/store'; -import { setInstalledTrack } from 'renderer/redux/features/installedTrack'; -import { setSelectedTrack } from 'renderer/redux/features/selectedTrack'; -import { InstallState, setInstallStatus } from 'renderer/redux/features/installStatus'; import yaml from 'js-yaml'; -import { InstallStatus } from 'renderer/components/AddonSection/Enums'; export type ReleaseInfo = { name: string; @@ -18,7 +9,7 @@ export type ReleaseInfo = { }; export class AddonData { - static async latestVersionForTrack(addon: Addon, track: AddonTrack): Promise { + static async latestNonFragmenterVersionForTrack(addon: Addon, track: AddonTrack): Promise { switch (track.releaseModel.type) { case 'githubRelease': return this.latestVersionForReleasedTrack(addon); @@ -58,123 +49,4 @@ export class AddonData { ).releases[0].date.getTime(), })); } - - static async configureInitialAddonState(addon: Addon): Promise { - const dispatch = store.dispatch; - - const setCurrentlyInstalledTrack = (newInstalledTrack: AddonTrack) => { - dispatch(setInstalledTrack({ addonKey: addon.key, installedTrack: newInstalledTrack })); - }; - - const setCurrentlySelectedTrack = (newSelectedTrack: AddonTrack) => { - dispatch(setSelectedTrack({ addonKey: addon.key, track: newSelectedTrack })); - }; - - const setCurrentInstallStatus = (new_state: InstallState) => { - dispatch(setInstallStatus({ addonKey: addon.key, installState: new_state })); - }; - - let selectedTrack: AddonTrack; - if (!Directories.isFragmenterInstall(addon)) { - console.log(addon.key, 'is not installed'); - selectedTrack = addon.tracks[0]; - setCurrentlySelectedTrack(selectedTrack); - } else { - console.log(addon.key, 'is installed'); - try { - const manifest = getCurrentInstall(Directories.inInstallLocation(addon.targetDirectory)); - console.log('Currently installed', manifest); - - let track = addon.tracks.find((track) => track.url.includes(manifest.source)); - if (!track) { - track = addon.tracks.find((track) => track.alternativeUrls?.includes(manifest.source)); - } - - console.log('Currently installed', track); - setCurrentlyInstalledTrack(track); - setCurrentlySelectedTrack(track); - selectedTrack = track; - } catch (e) { - console.error(e); - console.log('Not installed'); - setCurrentlySelectedTrack(addon.tracks[0]); - selectedTrack = addon.tracks[0]; - } - } - - const addonDiscovered = settings.get('cache.main.discoveredAddons.' + addon.key); - - if (addon.hidden && !addonDiscovered) { - setCurrentInstallStatus({ status: InstallStatus.Hidden }); - return; - } - - if (!selectedTrack) { - console.log(addon.key, 'has unknown install status'); - setCurrentInstallStatus({ status: InstallStatus.Unknown }); - return; - } - - console.log('Checking install status'); - - const installDir = Directories.inInstallLocation(addon.targetDirectory); - - if (!fs.existsSync(installDir)) { - console.log('no existing install dir for', addon.key); - setCurrentInstallStatus({ status: InstallStatus.NotInstalled }); - return; - } - - console.log('Checking for git install'); - if (Directories.isGitInstall(installDir)) { - setCurrentInstallStatus({ status: InstallStatus.GitInstall }); - return; - } - - try { - const updateInfo = await new FragmenterUpdateChecker().needsUpdate(selectedTrack.url, installDir, { - forceCacheBust: true, - }); - - if (updateInfo.isFreshInstall) { - setCurrentInstallStatus({ status: InstallStatus.NotInstalled }); - return; - } - - if (updateInfo.needsUpdate) { - setCurrentInstallStatus({ status: InstallStatus.NeedsUpdate }); - return; - } - - setCurrentInstallStatus({ status: InstallStatus.UpToDate }); - return; - } catch (e) { - console.error(e); - setCurrentInstallStatus({ status: InstallStatus.Unknown }); - return; - } - } - - static async checkForUpdates(addon: Addon): Promise { - console.log('Checking for updates for ' + addon.key); - - const dispatch = store.dispatch; - - const installDir = Directories.inInstallLocation(addon.targetDirectory); - - const state = store.getState(); - - if (state.installStatus[addon.key].status === InstallStatus.UpToDate) { - const updateInfo = await new FragmenterUpdateChecker().needsUpdate( - state.selectedTracks[addon.key].url, - installDir, - { - forceCacheBust: true, - }, - ); - if (updateInfo.needsUpdate) { - dispatch(setInstallStatus({ addonKey: addon.key, installState: { status: InstallStatus.NeedsUpdate } })); - } - } - } } diff --git a/src/renderer/utils/InstallManager.tsx b/src/renderer/utils/InstallManager.tsx index bf2cd484..de9e431d 100644 --- a/src/renderer/utils/InstallManager.tsx +++ b/src/renderer/utils/InstallManager.tsx @@ -12,13 +12,15 @@ import { } from 'renderer/redux/features/downloads'; import { Directories } from 'renderer/utils/Directories'; import fs from 'fs'; -import { ApplicationStatus, InstallStatus } from 'renderer/components/AddonSection/Enums'; +import { ApplicationStatus, InstallStatus, InstallStatusCategories } from 'renderer/components/AddonSection/Enums'; import { FragmenterContextEvents, FragmenterError, FragmenterInstallerEvents, FragmenterOperation, FragmenterUpdateChecker, + getCurrentInstall, + InstallManifest, } from '@flybywiresim/fragmenter'; import settings from 'renderer/rendererSettings'; import { store } from 'renderer/redux/store'; @@ -41,6 +43,8 @@ import { ErrorDialog } from 'renderer/components/Modal/ErrorDialog'; import { InstallSizeDialog } from 'renderer/components/Modal/InstallSizeDialog'; import { IncompatibleAddOnsCheck } from 'renderer/utils/IncompatibleAddOnsCheck'; import { FreeDiskSpace, FreeDiskSpaceStatus } from 'renderer/utils/FreeDiskSpace'; +import { setAddonAndTrackLatestReleaseInfo } from 'renderer/redux/features/latestVersionNames'; +import { AddonData, ReleaseInfo } from 'renderer/utils/AddonData'; type FragmenterEventArguments = Parameters< (FragmenterInstallerEvents & FragmenterContextEvents)[K] @@ -61,108 +65,10 @@ export class InstallManager { return arr; })(); - private static lowestAvailableAbortControllerID(): number { - for (let i = 0; i < this.abortControllers.length; i++) { - if ( - !store - .getState() - .downloads.map((download) => download.abortControllerID) - .includes(i) - ) { - return i; - } - } - } - - static async determineAddonInstallState(addon: Addon): Promise { - const addonSelectedTrack = this.getAddonSelectedTrack(addon); - const addonInstalledTrack = this.getAddonInstalledTrack(addon); - - if (!addonSelectedTrack) { - return { status: InstallStatus.Unknown }; - } - - console.log('Checking install status'); - - const installDir = Directories.inInstallLocation(addon.targetDirectory); - - if (!fs.existsSync(installDir)) { - return { status: InstallStatus.NotInstalled }; - } - - console.log('Checking for git install'); - if (Directories.isGitInstall(installDir)) { - return { status: InstallStatus.GitInstall }; - } - - try { - const updateInfo = await new FragmenterUpdateChecker().needsUpdate(addonSelectedTrack.url, installDir, { - forceCacheBust: true, - }); - console.log('Update info', updateInfo); - - if (addonSelectedTrack !== addonInstalledTrack && addonInstalledTrack) { - return { status: InstallStatus.TrackSwitch }; - } - if (updateInfo.isFreshInstall) { - return { status: InstallStatus.NotInstalled }; - } - - if (updateInfo.needsUpdate) { - return { status: InstallStatus.NeedsUpdate }; - } - - return { status: InstallStatus.UpToDate }; - } catch (e) { - console.error(e); - return { status: InstallStatus.Unknown }; - } - } - - static async getAddonInstallState(addon: Addon): Promise { - try { - return store.getState().installStatus[addon.key] as InstallState; - } catch (e) { - const state = await this.determineAddonInstallState(addon); - this.setCurrentInstallState(addon, state); - return state; - } - } - - static getAddonSelectedTrack(addon: Addon): AddonTrack { - try { - return store.getState().selectedTracks[addon.key] as AddonTrack; - } catch (e) { - this.setCurrentSelectedTrack(addon, null); - return null; - } - } - - static getAddonInstalledTrack(addon: Addon): AddonTrack { - try { - return store.getState().installedTracks[addon.key] as AddonTrack; - } catch (e) { - this.setCurrentlyInstalledTrack(addon, null); - return null; - } - } - - private static setCurrentInstallState(addon: Addon, installState: InstallState): void { - store.dispatch(setInstallStatus({ addonKey: addon.key, installState })); - } - - private static setCurrentSelectedTrack(addon: Addon, track: AddonTrack): void { - store.dispatch(setSelectedTrack({ addonKey: addon.key, track: track })); - } - - private static setCurrentlyInstalledTrack(addon: Addon, track: AddonTrack): void { - store.dispatch(setInstalledTrack({ addonKey: addon.key, installedTrack: track })); - } - - static async installAddon( + public static async installAddon( addon: Addon, publisher: Publisher, - showModal: (modal: JSX.Element) => Promise, + showModal: (modal: React.JSX.Element) => Promise, dependencyOf?: Addon, ): Promise { this.setCurrentInstallState(addon, { status: InstallStatus.DownloadPending }); @@ -172,13 +78,13 @@ export class InstallManager { }; const setCancelledState = () => { - this.setCurrentInstallState(addon, { status: InstallStatus.DownloadCanceled }); + this.setCurrentInstallState(addon, { status: InstallStatus.DownloadCanceled, timestamp: Date.now() }); }; const startResetStateTimer = (timeout = 3_000) => { setTimeout(async () => { store.dispatch(deleteDownload({ id: addon.key })); - this.setCurrentInstallState(addon, await this.determineAddonInstallState(addon)); + this.setCurrentInstallState(addon, await this.determineAddonInstallStatus(addon)); }, timeout); }; @@ -212,7 +118,10 @@ export class InstallManager { const dependencyAddon = Resolver.findAddon(publisherKey, addonKey); if (!dependencyAddon) { - console.error(`Addon specified dependency for unknown addon: @${publisherKey}/${addonKey}`); + console.error( + `[InstallManager](installAddon) Addon specified dependency for unknown addon: @${publisherKey}/${addonKey}`, + ); + return InstallResult.Failure; } @@ -261,21 +170,23 @@ export class InstallManager { const result = await this.installAddon(dependencyAddon, dependencyPublisher, showModal, addon); if (result === InstallResult.Failure) { - console.error('Error while installing dependency - aborting'); + console.error('[InstallManager](installAddon) Error while installing dependency - aborting'); setErrorState(); startResetStateTimer(); return InstallResult.Failure; } else if (result === InstallResult.Cancelled) { - console.log('Dependency install cancelled, canceling main addon too.'); + console.log('[InstallManager](installAddon) Dependency install cancelled, canceling main addon too.'); setCancelledState(); startResetStateTimer(); return InstallResult.Cancelled; } else { - console.log(`Dependency @${publisherKey}/${addonKey} installed successfully.`); + console.log( + `[InstallManager](installAddon) Dependency @${publisherKey}/${addonKey} installed successfully.`, + ); } } } @@ -354,17 +265,18 @@ export class InstallManager { ); if (tempDir === Directories.installLocation()) { - console.error('Community directory equals temp directory'); + console.error('[InstallManager](installAddon) Community directory equals temp directory'); + this.notifyDownload(addon, false); return InstallResult.Failure; } - console.log(`Installing track=${track.key}`); - console.log('Installing into:'); - console.log('---'); - console.log(`installDir: ${destDir}`); - console.log(`tempDir: ${tempDir}`); - console.log('---'); + console.log(`[InstallManager](installAddon) Installing track=${track.key}`); + console.log('[InstallManager](installAddon) Installing into:'); + console.log('[InstallManager](installAddon) ---'); + console.log(`[InstallManager](installAddon) installDir: ${destDir}`); + console.log(`[InstallManager](installAddon) tempDir: ${tempDir}`); + console.log('[InstallManager](installAddon) ---'); try { // Create dest dir if it doesn't exist @@ -393,7 +305,7 @@ export class InstallManager { case 'downloadStarted': { const [module] = args as FragmenterEventArguments; - console.log('Downloading started for module', module.name); + console.log('[InstallManager](installAddon) Downloading started for module', module.name); this.setCurrentInstallState(addon, { status: InstallStatus.Downloading }); @@ -460,7 +372,7 @@ export class InstallManager { case 'unzipStarted': { const [module] = args as FragmenterEventArguments; - console.log('Started unzipping module', module.name); + console.log('[InstallManager](installAddon) Started unzipping module', module.name); this.setCurrentInstallState(addon, { status: InstallStatus.Decompressing, percent: 0 }); if (dependencyOf) { @@ -497,7 +409,7 @@ export class InstallManager { case 'copyStarted': { const [module] = args as FragmenterEventArguments; - console.log('Started moving over module', module.name); + console.log('[InstallManager](installAddon) Started moving over module', module.name); if (module.name === 'full') { this.setCurrentInstallState(addon, { status: InstallStatus.DownloadEnding }); @@ -508,9 +420,9 @@ export class InstallManager { case 'retryScheduled': { const [module, retryCount, waitSeconds] = args as FragmenterEventArguments; - console.log('Scheduling a retry for module', module.name); - console.log('Retry count', retryCount); - console.log('Waiting for', waitSeconds, 'seconds'); + console.log('[InstallManager](installAddon) Scheduling a retry for module', module.name); + console.log('[InstallManager](installAddon) Retry count', retryCount); + console.log('[InstallManager](installAddon) Waiting for', waitSeconds, 'seconds'); store.dispatch(clearDownloadInterrupted({ id: addon.key })); @@ -520,20 +432,20 @@ export class InstallManager { case 'retryStarted': { const [module, retryCount] = args as FragmenterEventArguments; - console.log('Starting a retry for module', module.name); - console.log('Retry count', retryCount); + console.log('[InstallManager](installAddon) Starting a retry for module', module.name); + console.log('[InstallManager](installAddon) Retry count', retryCount); this.setCurrentInstallState(addon, { status: InstallStatus.Downloading }); break; } case 'cancelled': { - this.setCurrentInstallState(addon, { status: InstallStatus.DownloadCanceled }); + this.setCurrentInstallState(addon, { status: InstallStatus.DownloadCanceled, timestamp: Date.now() }); break; } case 'error': { const [error] = args as FragmenterEventArguments; - console.error('Error from Fragmenter:', error); + console.error('[InstallManager](installAddon) Error from Fragmenter:', error); Sentry.captureException(error); } } @@ -547,7 +459,7 @@ export class InstallManager { ipcRenderer.send(channels.installManager.cancelInstall, ourInstallID); }); - console.log('Starting fragmenter download for URL', track.url); + console.log('[InstallManager](installAddon) Starting fragmenter download for URL', track.url); const installResult = await ipcRenderer.invoke( channels.installManager.installFromUrl, @@ -562,21 +474,21 @@ export class InstallManager { throw installResult; } - console.log('Fragmenter download finished for URL', track.url); + console.log('[InstallManager](installAddon) Fragmenter download finished for URL', track.url); // Stop listening to forwarded fragmenter events ipcRenderer.removeListener(channels.installManager.fragmenterEvent, handleForwardedFragmenterEvent); // Remove installs existing under alternative names - console.log('Removing installs existing under alternative names'); + console.log('[InstallManager](installAddon) Removing installs existing under alternative names'); Directories.removeAlternativesForAddon(addon); - console.log('Finished removing installs existing under alternative names'); + console.log('[InstallManager](installAddon) Finished removing installs existing under alternative names'); this.notifyDownload(addon, true); // Flash completion text - this.setCurrentInstallState(addon, { status: InstallStatus.DownloadDone }); this.setCurrentlyInstalledTrack(addon, track); + this.setCurrentInstallState(addon, { status: InstallStatus.DownloadDone }); // If we have a background service, ask if we want to enable it if (addon.backgroundService && (addon.backgroundService.enableAutostartConfiguration ?? true)) { @@ -595,14 +507,14 @@ export class InstallManager { const isFragmenterError = FragmenterError.isFragmenterError(e); if (signal.aborted) { - console.warn('Download was cancelled'); + console.warn('[InstallManager](installAddon) Download was cancelled'); setCancelledState(); startResetStateTimer(); return InstallResult.Cancelled; } else { - console.error('Download failed, see exception below'); + console.error('[InstallManager](installAddon) Download failed, see exception below'); console.error(e); setErrorState(); @@ -624,7 +536,7 @@ export class InstallManager { return InstallResult.Success; } - static cancelDownload(addon: Addon): void { + public static cancelDownload(addon: Addon): void { let download = store.getState().downloads.find((it) => it.id === addon.key); if (!download) { for (const dependency of addon.dependencies ?? []) { @@ -641,7 +553,7 @@ export class InstallManager { } if (!download) { - throw new Error('Cannot cancel when no addon or dependency download is ongoing'); + throw new Error('[InstallManager](cancelDownload) Cannot cancel when no addon or dependency download is ongoing'); } const abortController = this.abortControllers[download.abortControllerID]; @@ -649,7 +561,7 @@ export class InstallManager { abortController?.abort(); } - static async uninstallAddon( + public static async uninstallAddon( addon: Addon, publisher: Publisher, showModal: (modal: JSX.Element) => Promise, @@ -688,11 +600,201 @@ export class InstallManager { this.setCurrentlyInstalledTrack(addon, null); } + private static getAddonInstall(directory: string): InstallManifest | null { + try { + return getCurrentInstall(directory); + } catch (e) { + return null; + } + } + + public static async getAddonInstallState(addon: Addon): Promise { + const status = store.getState().installStatus[addon.key] as InstallState; + + if (status) { + return status; + } + + return this.refreshAddonInstallState(addon); + } + + public static async refreshAddonInstallState(addon: Addon): Promise { + const currentState = store.getState().installStatus[addon.key] as InstallState; + + if (currentState?.status === InstallStatus.DownloadCanceled) { + setTimeout( + async () => { + const status = await this.determineAddonInstallStatus(addon); + this.setCurrentInstallState(addon, status); + }, + 3_000 - (Date.now() - currentState.timestamp), + ); + + return currentState; + } + + const status = await this.determineAddonInstallStatus(addon); + this.setCurrentInstallState(addon, status); + + return status; + } + + public static getAddonSelectedTrack(addon: Addon): AddonTrack { + const selectedTrack = store.getState().selectedTracks[addon.key] as AddonTrack; + + if (selectedTrack) { + return selectedTrack; + } + + this.setCurrentSelectedTrack(addon, addon.tracks[0]); + + return addon.tracks[0]; + } + + public static determineAddonInstalledTrack(addon: Addon): AddonTrack | null { + const installedTrack = store.getState().installedTracks[addon.key] as AddonTrack; + + if (installedTrack) { + return installedTrack; + } + + const install = this.getAddonInstall(Directories.inInstallLocation(addon.targetDirectory)); + + if (!install) { + return null; + } + + const matchingTrack = addon.tracks.find((it) => it.url === install.source); + + if (!matchingTrack) { + return null; + } + + this.setCurrentlyInstalledTrack(addon, matchingTrack); + this.setCurrentSelectedTrack(addon, matchingTrack); + + return matchingTrack; + } + + private static lowestAvailableAbortControllerID(): number { + for (let i = 0; i < this.abortControllers.length; i++) { + if ( + !store + .getState() + .downloads.map((download) => download.abortControllerID) + .includes(i) + ) { + return i; + } + } + } + + private static async determineAddonInstallStatus(addon: Addon): Promise { + console.log('[InstallManager](determineAddonInstallStatus) Checking install status'); + + const installDir = Directories.inInstallLocation(addon.targetDirectory); + const addonInstalledTrack = this.determineAddonInstalledTrack(addon); + const addonSelectedTrack = this.getAddonSelectedTrack(addon); + + if (!fs.existsSync(installDir)) { + console.log('[InstallManager](determineAddonInstallStatus) Is not installed'); + + return { status: InstallStatus.NotInstalled }; + } + + console.log('[InstallManager](determineAddonInstallStatus) Checking for git install'); + + if (Directories.isGitInstall(installDir)) { + console.log('[InstallManager](determineAddonInstallStatus) Is git install'); + + return { status: InstallStatus.GitInstall }; + } + + try { + const updateInfo = await new FragmenterUpdateChecker().needsUpdate(addonSelectedTrack.url, installDir, { + forceCacheBust: true, + }); + + console.log('[InstallManager](determineAddonInstallStatus) Update info', updateInfo); + + if (addonSelectedTrack !== addonInstalledTrack && addonInstalledTrack) { + return { status: InstallStatus.TrackSwitch }; + } + + if (updateInfo.isFreshInstall) { + return { status: InstallStatus.NotInstalled }; + } + + if (updateInfo.needsUpdate) { + return { status: InstallStatus.NeedsUpdate }; + } + + return { status: InstallStatus.UpToDate }; + } catch (e) { + console.error(e); + return { status: InstallStatus.Unknown }; + } + } + + public static async checkForUpdates(addon: Addon): Promise { + console.log('[InstallManager](checkForUpdates) Checking for updates for ' + addon.key); + + const installDir = Directories.inInstallLocation(addon.targetDirectory); + + const state = store.getState(); + + const addonInstallState = state.installStatus[addon.key] ?? { status: InstallStatus.Unknown }; + + if ( + InstallStatusCategories.installing.includes(addonInstallState.status) || + addonInstallState.status === InstallStatus.Unknown + ) { + return; + } + + const fragmenterUpdateChecker = new FragmenterUpdateChecker(); + + for (const track of addon.tracks) { + const updateInfo = await fragmenterUpdateChecker.needsUpdate(track.url, installDir, { forceCacheBust: true }); + + let info: ReleaseInfo; + if (track.releaseModel.type === 'fragmenter') { + info = { + name: updateInfo.distributionManifest.version, + changelogUrl: undefined, + releaseDate: Date.now(), + }; + } else { + info = await AddonData.latestNonFragmenterVersionForTrack(addon, track); + } + + store.dispatch(setAddonAndTrackLatestReleaseInfo({ addonKey: addon.key, trackKey: track.key, info })); + + if (track.key === state.installedTracks[addon.key]?.key && updateInfo.needsUpdate) { + store.dispatch(setInstallStatus({ addonKey: addon.key, installState: { status: InstallStatus.NeedsUpdate } })); + } + } + } + + private static setCurrentInstallState(addon: Addon, installState: InstallState): void { + store.dispatch(setInstallStatus({ addonKey: addon.key, installState })); + } + + private static setCurrentSelectedTrack(addon: Addon, track: AddonTrack): void { + store.dispatch(setSelectedTrack({ addonKey: addon.key, track: track })); + } + + private static setCurrentlyInstalledTrack(addon: Addon, track: AddonTrack): void { + store.dispatch(setInstalledTrack({ addonKey: addon.key, installedTrack: track })); + } + private static notifyDownload(addon: Addon, successful: boolean): void { - console.log('Requesting notification'); + console.log('[InstallManager](notifyDownload) Requesting notification'); + Notification.requestPermission() .then(() => { - console.log('Showing notification'); + console.log('InstallManager](notifyDownload) Showing notification'); + if (successful) { new Notification(`${addon.name} download complete!`, { icon: path.join(process.resourcesPath, 'extraResources', 'icon.ico'), diff --git a/src/renderer/utils/InstallerConfiguration.ts b/src/renderer/utils/InstallerConfiguration.ts index 4f1f821f..fabb4f3f 100644 --- a/src/renderer/utils/InstallerConfiguration.ts +++ b/src/renderer/utils/InstallerConfiguration.ts @@ -23,20 +23,31 @@ export type AddonVersion = { type: 'major' | 'minor' | 'patch'; }; +export type FragmenterReleaseModel = { + type: 'fragmenter'; +}; + export type GithubReleaseReleaseModel = { + /** @deprecated */ type: 'githubRelease'; }; export type GithubBranchReleaseModel = { + /** @deprecated */ type: 'githubBranch'; branch: string; }; export type CDNReleaseModel = { + /** @deprecated */ type: 'CDN'; }; -export type ReleaseModel = GithubReleaseReleaseModel | GithubBranchReleaseModel | CDNReleaseModel; +export type ReleaseModel = + | FragmenterReleaseModel + | GithubReleaseReleaseModel + | GithubBranchReleaseModel + | CDNReleaseModel; type BaseAddonTrack = { name: string; @@ -354,6 +365,12 @@ export interface Configuration { export class InstallerConfiguration { static async obtain(): Promise { + const forceUseLocalConfig = settings.get('mainSettings.configForceUseLocal') as boolean; + + if (forceUseLocalConfig) { + return this.loadConfigurationFromLocalStorage(); + } + return this.fetchConfigurationFromCdn() .then((config) => { if (this.isConfigurationValid(config)) {