fix: handle web UI updates safely

This commit is contained in:
Timothy Z. 2026-07-24 15:05:12 +03:00
parent 1c4ec6dac8
commit 07b152bdfc
15 changed files with 326 additions and 260 deletions

View file

@ -49,7 +49,8 @@
"spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6",
"stremio-translations": "github:Stremio/stremio-translations#5140f0db28f61b3f80939bed36df93512ca3e841",
"url": "0.11.4",
"use-long-press": "^3.3.0"
"use-long-press": "^3.3.0",
"workbox-window": "7.4.1"
},
"devDependencies": {
"@babel/core": "7.29.0",

View file

@ -104,6 +104,9 @@ importers:
use-long-press:
specifier: ^3.3.0
version: 3.3.0(react@18.3.1)
workbox-window:
specifier: 7.4.1
version: 7.4.1
devDependencies:
'@babel/core':
specifier: 7.29.0

View file

@ -12,7 +12,6 @@ const ServicesToaster = require('./ServicesToaster');
const SearchParamsHandler = require('./SearchParamsHandler');
const DeepLinkHandler = require('./DeepLinkHandler');
const { default: UpdaterBanner } = require('./UpdaterBanner');
const { default: WebUpdateScreen } = require('./WebUpdateScreen');
const { default: ShortcutsModal } = require('./ShortcutsModal');
const { default: GamepadModal } = require('./GamepadModal');
const styles = require('./styles');
@ -199,7 +198,6 @@ const App = () => {
<SearchParamsHandler />
<DeepLinkHandler />
<UpdaterBanner className={styles['updater-banner-container']} />
<WebUpdateScreen />
<ProtectedRoutes />
</DiscordProvider>
</FullscreenProvider>

View file

@ -1,10 +1,8 @@
import React, { useEffect } from 'react';
import Icon from '@stremio/stremio-icons/react';
import React, { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useMatch } from 'react-router';
import { useBinaryState, usePlatform } from 'stremio/common';
import { Button, Transition } from 'stremio/components';
import styles from './UpdaterBanner.less';
import { UpdateBanner } from 'stremio/components';
type Props = {
className: string,
@ -16,9 +14,9 @@ const UpdaterBanner = ({ className }: Props) => {
const [visible, show, hide] = useBinaryState(false);
const isPlayer = useMatch('/player/*');
const onInstallClick = () => {
const onInstallClick = useCallback(() => {
shell.send('autoupdater-notif-clicked');
};
}, [shell]);
useEffect(() => {
shell.on('autoupdater-show-notif', show);
@ -29,21 +27,15 @@ const UpdaterBanner = ({ className }: Props) => {
}, []);
return (
<div className={className}>
<Transition when={visible && !isPlayer} name={'slide-up'}>
<div className={styles['updater-banner']}>
<div className={styles['label']}>
{ t('UPDATER_TITLE') }
</div>
<Button className={styles['button']} onClick={onInstallClick}>
{ t('UPDATER_INSTALL_BUTTON') }
</Button>
<Button className={styles['close']} onClick={hide}>
<Icon className={styles['icon']} name={'close'} />
</Button>
</div>
</Transition>
</div>
<UpdateBanner
className={className}
visible={visible && isPlayer === null}
label={t('UPDATER_TITLE')}
actionLabel={t('UPDATER_INSTALL_BUTTON')}
closeLabel={t('BUTTON_CLOSE')}
onAction={onInstallClick}
onClose={hide}
/>
);
};

View file

@ -2,6 +2,14 @@
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
.web-update-banner {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 101;
}
.web-update-screen {
position: fixed;
top: 0;
@ -30,9 +38,9 @@
}
}
@keyframes flash {
@keyframes pulse {
0% {
opacity: 0.4;
opacity: 0.55;
}
100% {
@ -40,13 +48,13 @@
}
}
@keyframes progress-fill {
@keyframes progress {
0% {
transform: scaleX(0);
transform: translateX(-100%);
}
100% {
transform: scaleX(1);
transform: translateX(250%);
}
}
@ -56,7 +64,7 @@
height: 8rem;
object-fit: contain;
object-position: center;
animation: 1s linear infinite alternate flash;
animation: 1s linear infinite alternate pulse;
}
.title {
@ -76,6 +84,7 @@
height: 0.4rem;
margin-top: 0.25rem;
border-radius: var(--border-radius);
overflow: hidden;
&::before {
content: '';
@ -93,12 +102,11 @@
.progress-value {
position: relative;
z-index: 1;
width: 100%;
width: 40%;
height: 100%;
border-radius: inherit;
background-color: var(--primary-accent-color);
transform-origin: left;
animation: 2s ease-out forwards progress-fill;
animation: 1.2s ease-in-out infinite progress;
}
@media only screen and (max-width: 30rem) {
@ -118,4 +126,18 @@
width: 15rem;
}
}
@media (prefers-reduced-motion: reduce) {
animation: none;
.logo {
animation: none;
}
.progress-value {
width: 100%;
opacity: 0.65;
animation: none;
}
}
}

View file

@ -3,40 +3,59 @@
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useMatch } from 'react-router';
import { useServiceWorkerUpdater, useTimeout } from 'stremio/common';
import { UpdateBanner } from 'stremio/components';
import useServiceWorkerUpdater from './useServiceWorkerUpdater';
import styles from './WebUpdateScreen.less';
const APPLY_DELAY = 2000;
const WebUpdateScreen = () => {
const { t } = useTranslation();
const { updateReady, applyUpdate } = useServiceWorkerUpdater();
const { state, dismissed, applyUpdate, dismissUpdate } = useServiceWorkerUpdater();
const isPlayer = useMatch('/player/*');
const timeout = useTimeout(APPLY_DELAY);
const visible = updateReady && isPlayer === null;
const autoApply = state.status === 'ready' && state.autoApply;
const promptVisible =
!dismissed &&
isPlayer === null &&
(state.status === 'ready' || state.status === 'reload-ready' || state.status === 'failed');
useEffect(() => {
if (visible) {
timeout.start(applyUpdate);
return timeout.cancel;
if (autoApply && isPlayer === null) {
applyUpdate();
}
}, [visible]);
}, [applyUpdate, autoApply, isPlayer]);
if (!visible) {
return null;
if (state.status === 'applying') {
return (
<div
className={styles['web-update-screen']}
role={'status'}
aria-live={'polite'}
aria-busy={true}
>
<img
className={styles['logo']}
src={require('/assets/images/stremio_symbol.png')}
alt={''}
/>
<div className={styles['title']}>
{t('UPDATER_TITLE')}
</div>
<div className={styles['progress']} aria-hidden={true}>
<div className={styles['progress-value']} />
</div>
</div>
);
}
return (
<div className={styles['web-update-screen']}>
<img className={styles['logo']} src={require('/assets/images/stremio_symbol.png')} alt={' '} />
<div className={styles['title']}>
{t('UPDATER_TITLE')}
</div>
<div className={styles['progress']} aria-hidden={true}>
<div className={styles['progress-value']} />
</div>
</div>
<UpdateBanner
className={styles['web-update-banner']}
visible={promptVisible}
label={t('UPDATER_TITLE')}
actionLabel={state.status === 'failed' ? t('TRY_AGAIN') : t('RELOAD_UI')}
closeLabel={t('BUTTON_CLOSE')}
onAction={applyUpdate}
onClose={dismissUpdate}
/>
);
};

View file

@ -0,0 +1,185 @@
// Copyright (C) 2017-2026 Smart code 203358507
import { useCallback, useEffect, useRef, useState } from 'react';
import { Workbox, type WorkboxLifecycleEvent, type WorkboxLifecycleWaitingEvent } from 'workbox-window';
import { usePlatform } from 'stremio/common';
import usePWA from 'stremio/common/usePWA';
const UPDATE_CHECK_INTERVAL = 30 * 60 * 1000;
const APPLY_TIMEOUT = 15 * 1000;
type UpdaterState =
| { status: 'idle' }
| { status: 'ready', autoApply: boolean }
| { status: 'applying' }
| { status: 'reload-ready' }
| { status: 'failed' };
type Runtime = {
workbox: Workbox | null,
applying: boolean,
timeout: ReturnType<typeof setTimeout> | null,
checkForUpdate: (() => void) | null,
};
const clearApplyTimeout = (runtime: Runtime) => {
if (runtime.timeout !== null) {
clearTimeout(runtime.timeout);
runtime.timeout = null;
}
};
const useServiceWorkerUpdater = () => {
const { shell } = usePlatform();
const [isIOSPWA, isStandalonePWA] = usePWA();
const [state, setState] = useState<UpdaterState>({ status: 'idle' });
const [dismissed, setDismissed] = useState(false);
const runtimeRef = useRef<Runtime>({
workbox: null,
applying: false,
timeout: null,
checkForUpdate: null,
});
const appLike = shell.active || Boolean(isIOSPWA) || Boolean(isStandalonePWA);
const dismissUpdate = useCallback(() => {
setDismissed(true);
}, []);
const applyUpdate = useCallback(() => {
if (state.status === 'reload-ready') {
window.location.reload();
return;
}
const runtime = runtimeRef.current;
if (
runtime.applying ||
runtime.workbox === null ||
(state.status !== 'ready' && state.status !== 'failed')
) {
return;
}
runtime.applying = true;
clearApplyTimeout(runtime);
setState({ status: 'applying' });
runtime.timeout = setTimeout(() => {
runtime.timeout = null;
runtime.applying = false;
setState({ status: 'failed' });
}, APPLY_TIMEOUT);
runtime.workbox.messageSkipWaiting();
}, [state.status]);
useEffect(() => {
const serviceWorkerDisabled = process.env.SERVICE_WORKER_DISABLED as string | boolean | undefined;
if (
process.env.NODE_ENV !== 'production' ||
serviceWorkerDisabled === 'true' ||
serviceWorkerDisabled === true ||
!('serviceWorker' in navigator)
) {
return;
}
const runtime = runtimeRef.current;
const workbox = new Workbox('service-worker.js');
let registered = false;
let lastUpdateCheck = Date.now();
runtime.workbox = workbox;
const onWaiting = (event: WorkboxLifecycleWaitingEvent) => {
setDismissed(false);
setState({
status: 'ready',
autoApply: appLike && event.wasWaitingBeforeRegister === true,
});
};
const onControlling = (event: WorkboxLifecycleEvent) => {
if (!event.isUpdate) {
return;
}
clearApplyTimeout(runtime);
if (runtime.applying) {
window.location.reload();
} else {
setDismissed(false);
setState({ status: 'reload-ready' });
}
};
const checkForUpdate = () => {
const now = Date.now();
if (
!registered ||
navigator.serviceWorker.controller === null ||
now - lastUpdateCheck < UPDATE_CHECK_INTERVAL
) {
return;
}
lastUpdateCheck = now;
workbox.update().catch((error) => {
console.error('SW update check failed: ', error);
});
};
runtime.checkForUpdate = checkForUpdate;
workbox.addEventListener('waiting', onWaiting);
workbox.addEventListener('controlling', onControlling);
workbox.register()
.then(() => {
registered = true;
})
.catch((error) => {
console.error('SW registration failed: ', error);
});
return () => {
registered = false;
workbox.removeEventListener('waiting', onWaiting);
workbox.removeEventListener('controlling', onControlling);
clearApplyTimeout(runtime);
runtime.applying = false;
runtime.checkForUpdate = null;
if (runtime.workbox === workbox) {
runtime.workbox = null;
}
};
}, [appLike]);
useEffect(() => {
const onForeground = () => {
setDismissed(false);
runtimeRef.current.checkForUpdate?.();
};
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
onForeground();
}
};
const onShellVisibilityChange = ({ visible }: { visible?: boolean }) => {
if (visible) {
onForeground();
}
};
document.addEventListener('visibilitychange', onVisibilityChange);
shell.on('win-visibility-changed', onShellVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange);
shell.off('win-visibility-changed', onShellVisibilityChange);
};
}, [shell]);
return {
state,
dismissed,
applyUpdate,
dismissUpdate,
};
};
export default useServiceWorkerUpdater;

View file

@ -31,7 +31,6 @@ const useTorrent = require('./useTorrent');
const useTranslate = require('./useTranslate');
const { default: useOrientation } = require('./useOrientation');
const { default: useLanguageSorting } = require('./useLanguageSorting');
const { registerServiceWorker, useServiceWorkerUpdater } = require('./useServiceWorkerUpdater');
module.exports = {
FileDropProvider,
@ -77,6 +76,4 @@ module.exports = {
useTranslate,
useOrientation,
useLanguageSorting,
registerServiceWorker,
useServiceWorkerUpdater,
};

View file

@ -1,193 +0,0 @@
// Copyright (C) 2017-2026 Smart code 203358507
import { useSyncExternalStore } from 'react';
type Snapshot = {
updateReady: boolean,
reloadPending: boolean,
};
const INITIAL_SNAPSHOT: Snapshot = {
updateReady: false,
reloadPending: false,
};
let snapshot = INITIAL_SNAPSHOT;
let registration: ServiceWorkerRegistration | null = null;
let waitingWorker: ServiceWorker | null = null;
let promptReady = false;
let reloadPending = false;
let reloading = false;
let applyingUpdate = false;
let promptOnNextUpdateFound = false;
let promptOnNextUpdateFoundTimeout: ReturnType<typeof setTimeout> | null = null;
const listeners = new Set<() => void>();
const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
const getSnapshot = () => snapshot;
const emitSnapshot = () => {
const nextSnapshot = {
updateReady: promptReady && (waitingWorker !== null || reloadPending),
reloadPending,
};
if (snapshot.updateReady !== nextSnapshot.updateReady || snapshot.reloadPending !== nextSnapshot.reloadPending) {
snapshot = nextSnapshot;
listeners.forEach((listener) => listener());
}
};
const clearPromptOnNextUpdateFound = () => {
promptOnNextUpdateFound = false;
if (promptOnNextUpdateFoundTimeout !== null) {
clearTimeout(promptOnNextUpdateFoundTimeout);
promptOnNextUpdateFoundTimeout = null;
}
};
const markPromptOnNextUpdateFound = () => {
clearPromptOnNextUpdateFound();
promptOnNextUpdateFound = true;
promptOnNextUpdateFoundTimeout = setTimeout(clearPromptOnNextUpdateFound, 60000);
};
const consumePromptOnNextUpdateFound = () => {
const shouldPrompt = promptOnNextUpdateFound;
clearPromptOnNextUpdateFound();
return shouldPrompt;
};
const reloadPage = () => {
if (reloading) {
return;
}
reloading = true;
window.location.reload();
};
const setWaitingWorker = (worker: ServiceWorker, shouldPrompt: boolean) => {
waitingWorker = worker;
promptReady = shouldPrompt || promptReady;
emitSnapshot();
};
const trackInstalling = (worker: ServiceWorker | null, shouldPrompt: boolean) => {
if (worker === null) {
return;
}
const onStateChange = () => {
if (worker.state === 'installed' && navigator.serviceWorker.controller !== null) {
setWaitingWorker(worker, shouldPrompt);
}
};
worker.addEventListener('statechange', onStateChange);
onStateChange();
};
const checkForUpdate = () => {
if (registration === null || navigator.serviceWorker.controller === null) {
return;
}
markPromptOnNextUpdateFound();
registration.update().catch((error) => {
clearPromptOnNextUpdateFound();
console.error('SW update check failed: ', error);
});
};
const showPendingUpdate = () => {
if (waitingWorker !== null || reloadPending) {
promptReady = true;
emitSnapshot();
return true;
}
return false;
};
const applyUpdate = () => {
if (reloadPending) {
reloadPage();
return;
}
if (waitingWorker !== null) {
applyingUpdate = true;
waitingWorker.postMessage({ type: 'SKIP_WAITING' });
}
};
const registerServiceWorker = async () => {
let hadController = navigator.serviceWorker.controller !== null;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!hadController) {
hadController = true;
return;
}
waitingWorker = null;
if (applyingUpdate) {
reloadPage();
return;
}
reloadPending = true;
promptReady = false;
emitSnapshot();
});
try {
registration = await navigator.serviceWorker.register('service-worker.js');
registration.addEventListener('updatefound', () => {
trackInstalling(registration?.installing ?? null, consumePromptOnNextUpdateFound());
});
trackInstalling(registration.installing, true);
if (registration.waiting !== null && navigator.serviceWorker.controller !== null) {
setWaitingWorker(registration.waiting, true);
} else {
checkForUpdate();
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && !showPendingUpdate()) {
checkForUpdate();
}
});
} catch (error) {
console.error('SW registration failed: ', error);
}
};
const useServiceWorkerUpdater = () => {
const updaterSnapshot = useSyncExternalStore(subscribe, getSnapshot);
return {
...updaterSnapshot,
applyUpdate,
};
};
export {
registerServiceWorker,
useServiceWorkerUpdater,
};

View file

@ -1,4 +1,4 @@
.updater-banner {
.update-banner {
height: 4rem;
display: flex;
align-items: center;
@ -43,4 +43,4 @@
height: 2rem;
}
}
}
}

View file

@ -0,0 +1,37 @@
// Copyright (C) 2017-2026 Smart code 203358507
import React from 'react';
import Icon from '@stremio/stremio-icons/react';
import Button from '../Button';
import Transition from '../Transition';
import styles from './UpdateBanner.less';
type Props = {
className?: string,
visible: boolean,
label: string,
actionLabel: string,
closeLabel: string,
onAction: () => void,
onClose: () => void,
};
const UpdateBanner = ({ className, visible, label, actionLabel, closeLabel, onAction, onClose }: Props) => (
<div className={className}>
<Transition when={visible} name={'slide-up'}>
<div className={styles['update-banner']} role={'status'}>
<div className={styles['label']}>
{label}
</div>
<Button className={styles['button']} onClick={onAction}>
{actionLabel}
</Button>
<Button className={styles['close']} title={closeLabel} onClick={onClose}>
<Icon className={styles['icon']} name={'close'} />
</Button>
</div>
</Transition>
</div>
);
export default UpdateBanner;

View file

@ -0,0 +1,3 @@
import UpdateBanner from './UpdateBanner';
export default UpdateBanner;

View file

@ -29,6 +29,7 @@ import ShortcutsGroup from './ShortcutsGroup';
import TextInput from './TextInput';
import Toggle from './Toggle';
import Transition from './Transition';
import UpdateBanner from './UpdateBanner';
import Video from './Video';
import ActionsGroup from './ActionsGroup';
@ -65,6 +66,7 @@ export {
TextInput,
Toggle,
Transition,
UpdateBanner,
Video,
ActionsGroup
};

View file

@ -18,8 +18,9 @@ const i18n = require('i18next');
const { initReactI18next } = require('react-i18next');
const stremioTranslations = require('stremio-translations');
const App = require('./App');
const { default: WebUpdateScreen } = require('./App/WebUpdateScreen');
const { CoreProvider } = require('./core');
const { FileDropProvider, PlatformProvider, registerServiceWorker } = require('./common');
const { FileDropProvider, PlatformProvider } = require('./common');
const translations = Object.fromEntries(Object.entries(stremioTranslations()).map(([key, value]) => [key, {
translation: value
@ -48,14 +49,13 @@ root.render(
<CoreProvider appInfo={appInfo}>
<FileDropProvider>
<HashRouter>
<App />
<>
<WebUpdateScreen />
<App />
</>
</HashRouter>
</FileDropProvider>
</CoreProvider>
</PlatformProvider>
</React.StrictMode>
);
if (process.env.NODE_ENV === 'production' && process.env.SERVICE_WORKER_DISABLED !== 'true' && process.env.SERVICE_WORKER_DISABLED !== true && 'serviceWorker' in navigator) {
window.addEventListener('load', registerServiceWorker);
}

View file

@ -224,7 +224,7 @@ module.exports = (env, argv) => ({
new WorkboxPlugin.GenerateSW({
maximumFileSizeToCacheInBytes: 20000000,
clientsClaim: true,
skipWaiting: false
skipWaiting: true
}),
new CopyWebpackPlugin({
patterns: [