240 lines
6.3 KiB
TypeScript
240 lines
6.3 KiB
TypeScript
import { useState, type ReactElement } from 'react';
|
|
import cls from 'classnames';
|
|
import log from 'electron-log/renderer';
|
|
|
|
import { type UpdaterStatus, type ModsStatus } from '~main/types';
|
|
import { formatFileSize } from '~common/utils';
|
|
import { api } from '~renderer/utils/api';
|
|
import { useT } from '~renderer/i18n';
|
|
|
|
import Button from './styled/Button';
|
|
import DialogButton from './styled/DialogButton';
|
|
import ClientDirDialog from './ClientDirDialog';
|
|
|
|
const formatDuration = (seconds: number) => {
|
|
const s = Math.max(0, Math.round(seconds));
|
|
if (s < 60) return `${s}s`;
|
|
const m = Math.floor(s / 60);
|
|
const rem = s % 60;
|
|
if (m < 60) return rem ? `${m}m ${rem}s` : `${m}m`;
|
|
const h = Math.floor(m / 60);
|
|
const minRem = m % 60;
|
|
return minRem ? `${h}h ${minRem}m` : `${h}h`;
|
|
};
|
|
|
|
const formatPercent = (progress: number) => `${(progress * 100).toFixed(1)}%`;
|
|
|
|
const ProgressDetails = ({ status }: { status: UpdaterStatus }) => {
|
|
const t = useT();
|
|
const { bytesDone, bytesTotal, bytesPerSecond, etaSeconds, progress } =
|
|
status;
|
|
if (bytesTotal === undefined || bytesDone === undefined) return null;
|
|
|
|
const pct =
|
|
progress !== undefined && progress >= 0 ? formatPercent(progress) : '—';
|
|
|
|
return (
|
|
<p className="s1 text-blueGray">
|
|
<span className="tw-color">{pct}</span>
|
|
<span>
|
|
{' '}
|
|
· {formatFileSize(bytesDone)} / {formatFileSize(bytesTotal)}
|
|
</span>
|
|
{bytesPerSecond !== undefined && bytesPerSecond > 0 && (
|
|
<span> · {formatFileSize(bytesPerSecond)}/s</span>
|
|
)}
|
|
<span>
|
|
{' · '}
|
|
{etaSeconds !== undefined
|
|
? `~${formatDuration(etaSeconds)} ${t('launch.remaining')}`
|
|
: t('launch.calculating')}
|
|
</span>
|
|
</p>
|
|
);
|
|
};
|
|
|
|
const LaunchPanel = () => {
|
|
const t = useT();
|
|
const [status, setStatus] = useState<UpdaterStatus>({ state: 'verifying' });
|
|
api.updater.observe.useSubscription(undefined, {
|
|
onData: setStatus,
|
|
onError: err => log.error('Updater subscription error:', err)
|
|
});
|
|
|
|
const { data: pref } = api.preferences.get.useQuery();
|
|
|
|
const [modsStatus, setModsStatus] = useState<ModsStatus>();
|
|
api.mods.observe.useSubscription(undefined, {
|
|
onData: setModsStatus
|
|
});
|
|
|
|
const verify = api.updater.verify.useMutation();
|
|
const update = api.updater.update.useMutation();
|
|
const start = api.launcher.start.useMutation();
|
|
const applyMods = api.mods.applyAll.useMutation();
|
|
|
|
const props: Record<
|
|
UpdaterStatus['state'],
|
|
{ button: ReactElement; helperText?: ReactElement }
|
|
> = {
|
|
verifying: { button: <Button disabled>{t('launch.verifying')}</Button> },
|
|
serverUnreachable: {
|
|
button: pref?.version ? (
|
|
<Button onClick={() => start.mutateAsync()}>{t('launch.play')}</Button>
|
|
) : (
|
|
<Button onClick={() => verify.mutateAsync()}>
|
|
{t('launch.retry')}
|
|
</Button>
|
|
),
|
|
helperText: (
|
|
<div className="-mb-2">
|
|
<p>
|
|
<span className="text-orange">{t('launch.errorLabel')}</span>{' '}
|
|
{t('launch.serverFail')}
|
|
</p>
|
|
<p className="s1 text-blueGray">
|
|
{pref?.version
|
|
? t('launch.localVersion', { version: pref.version })
|
|
: t('launch.tryLater')}
|
|
</p>
|
|
</div>
|
|
)
|
|
},
|
|
noClient: {
|
|
button: (
|
|
<DialogButton
|
|
clickAway
|
|
dialog={close => <ClientDirDialog close={close} />}
|
|
>
|
|
{open => (
|
|
<Button primary onClick={open}>
|
|
{t('launch.install')}
|
|
</Button>
|
|
)}
|
|
</DialogButton>
|
|
)
|
|
},
|
|
updateAvailable: {
|
|
button: (
|
|
<Button onClick={() => update.mutateAsync()}>
|
|
{t('launch.update')}
|
|
</Button>
|
|
),
|
|
helperText: (
|
|
<div className="-mb-2 flex flex-col gap-1">
|
|
<p>{t('launch.updateAvailable')}</p>
|
|
<p className="s1 text-blueGray">
|
|
{status.progress !== undefined &&
|
|
status.bytesDone !== undefined &&
|
|
status.bytesTotal !== undefined && (
|
|
<>
|
|
<span className="tw-color">
|
|
{formatPercent(status.progress)}
|
|
</span>
|
|
<span>
|
|
{' '}
|
|
· {formatFileSize(status.bytesDone)} /{' '}
|
|
{formatFileSize(status.bytesTotal)} {t('launch.onDisk')} ·{' '}
|
|
</span>
|
|
</>
|
|
)}
|
|
<span className="break-all">{status.message}</span>{' '}
|
|
{t('launch.remaining')}
|
|
</p>
|
|
</div>
|
|
)
|
|
},
|
|
updating: {
|
|
button: <Button disabled>{t('launch.updating')}</Button>,
|
|
helperText: (
|
|
<div className="-mb-2 flex flex-col gap-1">
|
|
{status.message && (
|
|
<p className="s1 truncate text-blueGray">{status.message}</p>
|
|
)}
|
|
<ProgressDetails status={status} />
|
|
</div>
|
|
)
|
|
},
|
|
upToDate: {
|
|
button: modsStatus?.dirty ? (
|
|
<Button
|
|
primary
|
|
onClick={() => applyMods.mutateAsync()}
|
|
disabled={applyMods.isLoading || modsStatus?.state === 'busy'}
|
|
>
|
|
{modsStatus?.state === 'busy'
|
|
? t('launch.applying')
|
|
: t('launch.update')}
|
|
</Button>
|
|
) : (
|
|
<Button primary onClick={() => start.mutateAsync()}>
|
|
{t('launch.play')}
|
|
</Button>
|
|
),
|
|
helperText: (
|
|
<div className="-mb-2">
|
|
{modsStatus?.dirty ? (
|
|
<p>{t('launch.modsChanged')}</p>
|
|
) : (
|
|
<p>{t('launch.upToDate')}</p>
|
|
)}
|
|
<p className="s1 text-blueGray">
|
|
{t('launch.version', { version: pref?.version ?? '' })}
|
|
</p>
|
|
</div>
|
|
)
|
|
},
|
|
failed: {
|
|
button: (
|
|
<Button onClick={() => verify.mutateAsync()}>
|
|
{t('launch.retry')}
|
|
</Button>
|
|
),
|
|
helperText: (
|
|
<div className="-mb-2">
|
|
<p>
|
|
<span className="text-orange">{t('launch.errorLabel')}</span>{' '}
|
|
{status.message}
|
|
</p>
|
|
<p className="s1 text-blueGray">{t('launch.verifyHint')}</p>
|
|
</div>
|
|
)
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex gap-3">
|
|
<div className="flex flex-grow flex-col justify-end gap-3">
|
|
{props[status.state].helperText ??
|
|
(status.message && (
|
|
<p className="s1 -mb-2 text-blueGray">{status.message}</p>
|
|
))}
|
|
{start.data && !start.data.ok && start.data.error && (
|
|
<p className="s1 -mb-2 text-orange">{start.data.error}</p>
|
|
)}
|
|
<div className="tw-loading-wrapper">
|
|
{status.progress !== undefined && (
|
|
<div
|
|
className={cls('tw-loading', {
|
|
'tw-loading-unknown': status.progress === -1
|
|
})}
|
|
style={
|
|
status.progress !== -1
|
|
? {
|
|
clipPath: `inset(0 ${
|
|
100 - Math.ceil(Math.abs(status.progress) * 100)
|
|
}% 0 0)`
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{props[status.state].button}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default LaunchPanel;
|