Skip to content

Commit

Permalink
Add settings section for app updater.
Browse files Browse the repository at this point in the history
  • Loading branch information
olegbl committed Oct 14, 2024
1 parent 3968441 commit 378bb5d
Show file tree
Hide file tree
Showing 5 changed files with 238 additions and 49 deletions.
6 changes: 4 additions & 2 deletions src/renderer/react/App.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import 'renderer/css/App.css';
import AppUpdaterDialog from 'renderer/react/AppUpdaterDialog';
import ErrorBoundary from 'renderer/react/ErrorBoundary';
import InstallationProgressBar from 'renderer/react/InstallationProgressBar';
import ModManagerLogs from 'renderer/react/ModManagerLogs';
import ModManagerSettings from 'renderer/react/ModManagerSettings';
import UpdaterDialog from 'renderer/react/UpdaterDialog';
import { AppUpdaterContextProvider } from 'renderer/react/context/AppUpdaterContext';
import { DataPathContextProvider } from 'renderer/react/context/DataPathContext';
import { DialogManagerContextProvider } from 'renderer/react/context/DialogContext';
import { ExtraGameLaunchArgsContextProvider } from 'renderer/react/context/ExtraGameLaunchArgsContext';
Expand Down Expand Up @@ -107,14 +108,15 @@ function Content() {
<Route element={<RootRoute />} path="/" />
</Routes>
</Router>
<UpdaterDialog />
<AppUpdaterDialog />
</>
);
}

// from inner to outer
const CONTEXT_PROVIDERS = [
// installation & updates
AppUpdaterContextProvider,
NexusModsContextProvider,
UpdatesContextProvider,
InstallContextProvider,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import type { IUpdaterAPI, Update } from 'bridge/Updater';
import { useEventAPIListener } from 'renderer/EventAPI';
import { consumeAPI } from 'renderer/IPC';
import { useAppUpdaterContext } from 'renderer/react/context/AppUpdaterContext';
import {
useDialog,
useDialogContext,
} from 'renderer/react/context/DialogContext';
import useAsyncCallback from 'renderer/react/hooks/useAsyncCallback';
import useSavedState from 'renderer/react/hooks/useSavedState';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Button,
Expand All @@ -18,21 +15,6 @@ import {
LinearProgress,
} from '@mui/material';

const UpdaterAPI = consumeAPI<IUpdaterAPI>('UpdaterAPI');

function useUpdate(): [Update | null, () => void] {
const [update, setUpdate] = useState<Update | null>(null);
const onCheckForUpdates = useCallback(() => {
UpdaterAPI.getLatestUpdate()
.then((update) => {
setUpdate(update);
})
.catch(console.error);
}, []);
useEffect(onCheckForUpdates, [onCheckForUpdates]);
return [update, onCheckForUpdates];
}

type UpdaterState =
| { event: 'cleanup' }
| { event: 'extract' }
Expand Down Expand Up @@ -135,36 +117,29 @@ function ProgressDialog({
);
}

export default function UpdaterDialog() {
const [ignoredUpdateVersion, setIgnoredUpdateVersion] = useSavedState<
string | void
>('ignored-update', undefined);
const [update] = useUpdate();
export default function AppUpdaterDialog() {
const {
isDialogEnabled,
update,
ignoredUpdateVersion,
onIgnoreUpdate,
onInstallUpdate,
} = useAppUpdaterContext();
const updaterState = useUpdaterState();

const isUpdateIgnored =
update != null && update.version === ignoredUpdateVersion;

const onIgnore = useCallback(() => {
setIgnoredUpdateVersion(update?.version);
}, [setIgnoredUpdateVersion, update?.version]);

const onInstall = useAsyncCallback(async () => {
if (update != null) {
await UpdaterAPI.installUpdate(update);
}
}, [update]);

const [showNotificationDialog] = useDialog(
useMemo(
() => (
<NotificationDialog
onIgnore={onIgnore}
onInstall={onInstall}
onIgnore={onIgnoreUpdate}
onInstall={onInstallUpdate}
version={update?.version ?? ''}
/>
),
[onIgnore, onInstall, update?.version],
[onIgnoreUpdate, onInstallUpdate, update?.version],
),
);

Expand All @@ -176,7 +151,7 @@ export default function UpdaterDialog() {
);

useEffect(() => {
if (update == null || isUpdateIgnored) {
if (update == null || isUpdateIgnored || !isDialogEnabled) {
return;
}

Expand All @@ -187,6 +162,7 @@ export default function UpdaterDialog() {

showNotificationDialog();
}, [
isDialogEnabled,
isUpdateIgnored,
showNotificationDialog,
showProgressDialog,
Expand Down
65 changes: 60 additions & 5 deletions src/renderer/react/ModManagerSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import BridgeAPI from 'renderer/BridgeAPI';
import { useAppUpdaterContext } from 'renderer/react/context/AppUpdaterContext';
import { useDataPath } from 'renderer/react/context/DataPathContext';
import { useExtraGameLaunchArgs } from 'renderer/react/context/ExtraGameLaunchArgsContext';
import {
Expand Down Expand Up @@ -113,11 +114,12 @@ export default function ModManagerSettings(_props: Props): JSX.Element {
unregisterAsNxmProtocolHandler,
} = useNexusAuthState();

console.log(
'DEBUG',
preExtractedDataPath.toLowerCase(),
outputPath.toLowerCase(),
);
const {
update,
onInstallUpdate,
isDialogEnabled: isAppUpdaterDialogEnabled,
setIsDialogEnabled: setIsAppUpdaterDialogEnabled,
} = useAppUpdaterContext();

return (
<List
Expand Down Expand Up @@ -499,6 +501,59 @@ export default function ModManagerSettings(_props: Props): JSX.Element {
</Stack>
</StyledAccordionDetails>
</StyledAccordion>
<StyledAccordion
defaultExpanded={false}
disableGutters={true}
elevation={0}
square={true}
>
<StyledAccordionSummary
aria-controls="updates-content"
expandIcon={<ExpandMore />}
id="updates-header"
>
<Typography sx={{ marginLeft: 1 }}>Updates</Typography>
</StyledAccordionSummary>
<StyledAccordionDetails id="updates-content">
<ListItemButton
onClick={() => {
setIsAppUpdaterDialogEnabled(!isAppUpdaterDialogEnabled);
}}
>
<ListItemIcon>
<Checkbox
checked={isAppUpdaterDialogEnabled}
disableRipple={true}
edge="start"
inputProps={{
'aria-labelledby': 'enable-app-updater-dialog',
}}
tabIndex={-1}
/>
</ListItemIcon>
<ListItemText
id="enable-app-updater-dialog"
primary="Enable D2RMM Update Notifications"
/>
</ListItemButton>
{update == null ? null : (
<Alert severity="warning">
<Typography>
<strong>New Update Available</strong> Do you want to update to
version {update!.version} of D2RMM?
</Typography>
<Button
color="warning"
onClick={onInstallUpdate}
sx={{ marginTop: 1 }}
variant="contained"
>
Update
</Button>
</Alert>
)}
</StyledAccordionDetails>
</StyledAccordion>
</List>
);
}
Expand Down
136 changes: 136 additions & 0 deletions src/renderer/react/context/AppUpdaterContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import type { IUpdaterAPI, Update } from 'bridge/Updater';
import { consumeAPI } from 'renderer/IPC';
import useAsyncCallback from 'renderer/react/hooks/useAsyncCallback';
import { useSavedStateJSON } from 'renderer/react/hooks/useSavedState';
import React, { useCallback, useEffect, useMemo, useState } from 'react';

const UpdaterAPI = consumeAPI<IUpdaterAPI>('UpdaterAPI');

function useUpdate(): [Update | null, () => void] {
const [update, setUpdate] = useState<Update | null>(null);

const onCheckForUpdates = useCallback(() => {
UpdaterAPI.getLatestUpdate()
.then((update) => {
setUpdate(update);
})
.catch(console.error);
}, []);

useEffect(onCheckForUpdates, [onCheckForUpdates]);

return [update, onCheckForUpdates];
}

export type IAppUpdaterSettings = {
isDialogEnabled: boolean;
ignoredUpdateVersion: string;
};

export type ISetAppUpdaterSettings = React.Dispatch<
React.SetStateAction<IAppUpdaterSettings>
>;

function useSettings(): [IAppUpdaterSettings, ISetAppUpdaterSettings] {
return useSavedStateJSON<IAppUpdaterSettings>('app-updater-settings', {
isDialogEnabled: true,
ignoredUpdateVersion: '',
});
}

export type IAppUpdaterContext = {
isDialogEnabled: boolean;
setIsDialogEnabled: React.Dispatch<React.SetStateAction<boolean>>;
ignoredUpdateVersion: string;
setIgnoredUpdateVersion: React.Dispatch<React.SetStateAction<string>>;
update: Update | null;
onCheckForUpdates: () => void;
onIgnoreUpdate: () => void;
onInstallUpdate: () => void;
};

export const Context = React.createContext<IAppUpdaterContext | null>(null);

export function AppUpdaterContextProvider({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
const [{ isDialogEnabled, ignoredUpdateVersion }, setSettings] =
useSettings();

const setIsDialogEnabled = useCallback(
(action: React.SetStateAction<boolean>) => {
setSettings((settings) => ({
...settings,
isDialogEnabled:
typeof action === 'function'
? action(settings.isDialogEnabled)
: action,
}));
},
[setSettings],
);

const setIgnoredUpdateVersion = useCallback(
(action: React.SetStateAction<string>) => {
setSettings((settings) => ({
...settings,
ignoredUpdateVersion:
typeof action === 'function'
? action(settings.ignoredUpdateVersion)
: action,
}));
},
[setSettings],
);

const [update, onCheckForUpdates] = useUpdate();

const onIgnoreUpdate = useCallback(() => {
if (update?.version != null) {
setIgnoredUpdateVersion(update?.version);
}
}, [setIgnoredUpdateVersion, update?.version]);

const onInstallUpdate = useAsyncCallback(async () => {
if (update != null) {
await UpdaterAPI.installUpdate(update);
}
}, [update]);

const context = useMemo(
(): IAppUpdaterContext => ({
isDialogEnabled,
setIsDialogEnabled,
ignoredUpdateVersion,
setIgnoredUpdateVersion,
update,
onCheckForUpdates,
onIgnoreUpdate,
onInstallUpdate,
}),
[
isDialogEnabled,
setIsDialogEnabled,
ignoredUpdateVersion,
setIgnoredUpdateVersion,
update,
onCheckForUpdates,
onIgnoreUpdate,
onInstallUpdate,
],
);

return <Context.Provider value={context}>{children}</Context.Provider>;
}

export function useAppUpdaterContext(): IAppUpdaterContext {
const context = React.useContext(Context);
if (context == null) {
throw new Error(
'useAppUpdater must be used within a UpdatesContextProvider',
);
}
return context;
}
28 changes: 24 additions & 4 deletions src/renderer/react/hooks/useSavedState.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useEffect, useState } from 'react';

function serializeImplicit<T>(value: T): string {
function defaultSerialize<T>(value: T): string {
return String(value);
}

function deserializeImplicit<T>(value: string): T {
function defaultDeserialize<T>(value: string): T {
// TODO: just use JSON.stringify / JSON.parse for all saved state
// but needs a migration path for existing users

Expand All @@ -23,8 +23,8 @@ function deserializeImplicit<T>(value: string): T {
export default function useSavedState<T>(
key: string,
initialValue: T,
serialize: (value: T) => string = serializeImplicit,
deserialize: (value: string) => T = deserializeImplicit,
serialize: (value: T) => string = defaultSerialize,
deserialize: (value: string) => T = defaultDeserialize,
): [T, React.Dispatch<React.SetStateAction<T>>] {
const [value, setValue] = useState<T>(() => {
try {
Expand All @@ -46,3 +46,23 @@ export default function useSavedState<T>(

return [value, setValue];
}

function defaultSerializeJSON<T>(value: T): string {
return JSON.stringify(value);
}

function defaultDeserializeJSON<T>(value: string): T {
return JSON.parse(value) as unknown as T;
}

export function useSavedStateJSON<T>(
key: string,
initialValue: T,
): [T, React.Dispatch<React.SetStateAction<T>>] {
return useSavedState(
key,
initialValue,
defaultSerializeJSON,
defaultDeserializeJSON,
);
}

0 comments on commit 378bb5d

Please sign in to comment.