feat(web): in-app PWA update prompt when new service worker is available (#946)

* feat(web): in-app PWA update prompt when new service worker is available (closes #938)

User-controlled reload with a persistent banner, visibility-triggered SW
checks, and an expandable rationale. Switches registerType to prompt.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): align vite.config with soup layers for clean driver merge

Keeps registerType prompt while matching garden IWER stubs and PWA
share_target shape expected by feat/pwa-share-target in the manifest.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Revert "fix(web): align vite.config with soup layers for clean driver merge"

This reverts commit 6f0915b0884d029a2413d8819a4dfe81d7c4e595.

* fix(web): make PWA reload apply waiting service worker updates

Handle SKIP_WAITING in injectManifest sw.ts and reload via controllerchange
with a timed fallback when vite-plugin-pwa prompt mode does not navigate.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): satisfy setTimeout mock typing in PWA reload tests

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): register PWA service worker before auth gates

Mount PwaUpdateProvider at app root and show the update banner on login
and error screens so registerSW runs for logged-out users too.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): offset PWA update banner below top status banners

Reserve top-12 when syncing or reconnecting so the reload prompt stays
visible above SyncingBanner and ReconnectingBanner.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): offset PWA update banner below voice error banner

Use PwaUpdateBannerWithStatusOffset inside VoiceProvider so voice errors
share the same top-12 reservation as sync and reconnect banners.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-06-18 10:15:10 +08:00
committed by GitHub
co-authored by Cursor
parent 78155a9d27
commit 5f27abddd4
12 changed files with 649 additions and 41 deletions
+7 -4
View File
@@ -56,11 +56,14 @@ An offline indicator appears when you lose connection.
### Auto-Update
HAPI automatically checks for updates:
HAPI checks for updates in the background and lets you choose when to reload:
- Updates are checked hourly in the background
- When a new version is available, you'll see a prompt
- Click "Reload" to get the latest version
- Updates are checked hourly and when you return to the tab
- When a new version is available, a persistent in-app banner appears at the top
- Tap **Reload** when you're ready to apply the update — the banner stays until you do
- Expand **"Why can't I dismiss this?"** on the banner for the rationale
HAPI uses a user-controlled reload instead of forcing an automatic refresh, so you choose when to reload. The banner cannot be dismissed without upgrading, so you won't forget you're on an old build.
### Background Sync
+30 -13
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { Outlet, useLocation, useMatchRoute, useRouter } from '@tanstack/react-router'
import { useQueryClient } from '@tanstack/react-query'
import { getTelegramWebApp, isTelegramApp } from '@/hooks/useTelegram'
@@ -23,11 +23,13 @@ import { getAppGlobalSseSubscription, getAppSessionSseSubscription } from '@/lib
import { LoginPrompt } from '@/components/LoginPrompt'
import { InstallPrompt } from '@/components/InstallPrompt'
import { OfflineBanner } from '@/components/OfflineBanner'
import { PwaUpdateBanner, PwaUpdateBannerWithStatusOffset } from '@/components/PwaUpdateBanner'
import { SyncingBanner } from '@/components/SyncingBanner'
import { ReconnectingBanner } from '@/components/ReconnectingBanner'
import { VoiceErrorBanner } from '@/components/VoiceErrorBanner'
import { LoadingState } from '@/components/LoadingState'
import { ToastContainer } from '@/components/ToastContainer'
import { PwaUpdateProvider } from '@/lib/pwa-update-context'
import { ToastProvider, useToast } from '@/lib/toast-context'
import type { SyncEvent } from '@/types/api'
@@ -35,10 +37,21 @@ type ToastEvent = Extract<SyncEvent, { type: 'toast' }>
const REQUIRE_SERVER_URL = requireHubUrlForLogin()
function withPwaBanner(content: ReactNode) {
return (
<>
<PwaUpdateBanner />
{content}
</>
)
}
export function App() {
return (
<ToastProvider>
<PwaUpdateProvider>
<AppInner />
</PwaUpdateProvider>
</ToastProvider>
)
}
@@ -341,16 +354,16 @@ function AppInner() {
// Loading auth source
if (isAuthSourceLoading) {
return (
return withPwaBanner(
<div className="h-full flex items-center justify-center p-4">
<LoadingState label={t('loading')} className="text-sm" />
</div>
</div>,
)
}
// No auth source (browser environment, not logged in)
if (!authSource) {
return (
return withPwaBanner(
<LoginPrompt
onLogin={setAccessToken}
baseUrl={baseUrl}
@@ -358,12 +371,12 @@ function AppInner() {
setServerUrl={setServerUrl}
clearServerUrl={clearServerUrl}
requireServerUrl={REQUIRE_SERVER_URL}
/>
/>,
)
}
if (needsBinding) {
return (
return withPwaBanner(
<LoginPrompt
mode="bind"
onBind={bind}
@@ -373,16 +386,16 @@ function AppInner() {
clearServerUrl={clearServerUrl}
requireServerUrl={REQUIRE_SERVER_URL}
error={authError ?? undefined}
/>
/>,
)
}
// Authenticating (also covers the gap before useAuth effect starts)
if (isAuthLoading || (authSource && !token && !authError)) {
return (
return withPwaBanner(
<div className="h-full flex items-center justify-center p-4">
<LoadingState label={t('authorizing')} className="text-sm" />
</div>
</div>,
)
}
@@ -390,7 +403,7 @@ function AppInner() {
if (authError || !token || !api) {
// If using access token and auth failed, show login again
if (authSource.type === 'accessToken') {
return (
return withPwaBanner(
<LoginPrompt
onLogin={setAccessToken}
baseUrl={baseUrl}
@@ -399,12 +412,12 @@ function AppInner() {
clearServerUrl={clearServerUrl}
requireServerUrl={REQUIRE_SERVER_URL}
error={authError ?? t('login.error.authFailed')}
/>
/>,
)
}
// Telegram auth failed
return (
return withPwaBanner(
<div className="p-4 space-y-3">
<div className="text-base font-semibold">{t('login.title')}</div>
<div className="text-sm text-red-600">
@@ -413,13 +426,17 @@ function AppInner() {
<div className="text-xs text-[var(--app-hint)]">
Open this page from Telegram using the bot's "Open App" button (not "Open in browser").
</div>
</div>
</div>,
)
}
return (
<AppContextProvider value={{ api, token, baseUrl }}>
<VoiceProvider>
<PwaUpdateBannerWithStatusOffset
isSyncing={isSyncing}
isReconnecting={sseDisconnected && !isSyncing}
/>
<SyncingBanner isSyncing={isSyncing} />
<ReconnectingBanner
isReconnecting={sseDisconnected && !isSyncing}
+139
View File
@@ -0,0 +1,139 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/lib/i18n-context'
import { PwaUpdateBanner, PwaUpdateBannerWithStatusOffset } from '@/components/PwaUpdateBanner'
const usePwaUpdateMock = vi.fn()
const useVoiceOptionalMock = vi.fn()
vi.mock('@/lib/pwa-update-context', () => ({
usePwaUpdateContext: () => usePwaUpdateMock(),
}))
vi.mock('@/lib/voice-context', () => ({
useVoiceOptional: () => useVoiceOptionalMock(),
}))
vi.mock('@/hooks/useOnlineStatus', () => ({
useOnlineStatus: () => true,
}))
vi.mock('@/hooks/usePlatform', () => ({
usePlatform: () => ({
haptic: {
impact: vi.fn(),
notification: vi.fn(),
},
}),
}))
function renderBanner() {
return render(
<I18nProvider>
<PwaUpdateBanner />
</I18nProvider>,
)
}
describe('PwaUpdateBanner', () => {
beforeEach(() => {
vi.clearAllMocks()
useVoiceOptionalMock.mockReturnValue(null)
Object.defineProperty(window, 'localStorage', {
value: {
getItem: vi.fn(() => 'en'),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
key: vi.fn(() => null),
length: 0,
},
configurable: true,
})
})
afterEach(() => {
cleanup()
})
it('does not render when no update is available', () => {
usePwaUpdateMock.mockReturnValue({
needRefresh: false,
reload: vi.fn(),
})
renderBanner()
expect(screen.queryByTestId('pwa-update-banner')).not.toBeInTheDocument()
})
it('renders a reload-only banner with no dismiss action', () => {
const reload = vi.fn()
usePwaUpdateMock.mockReturnValue({
needRefresh: true,
reload,
})
renderBanner()
expect(screen.getByTestId('pwa-update-banner')).toBeInTheDocument()
expect(screen.getByText('New version available')).toBeInTheDocument()
expect(screen.getByText('Reload to get the latest HAPI')).toBeInTheDocument()
expect(screen.getAllByRole('button')).toHaveLength(1)
fireEvent.click(screen.getByRole('button', { name: 'Reload' }))
expect(reload).toHaveBeenCalledTimes(1)
})
it('honors a custom top offset when provided', () => {
usePwaUpdateMock.mockReturnValue({
needRefresh: true,
reload: vi.fn(),
})
render(
<I18nProvider>
<PwaUpdateBanner topClassName="top-12" />
</I18nProvider>,
)
expect(screen.getByTestId('pwa-update-banner')).toHaveClass('top-12')
})
it('offsets below voice error banners when shown inside the voice provider', () => {
usePwaUpdateMock.mockReturnValue({
needRefresh: true,
reload: vi.fn(),
})
useVoiceOptionalMock.mockReturnValue({
status: 'error',
errorMessage: 'Mic failed',
})
render(
<I18nProvider>
<PwaUpdateBannerWithStatusOffset isSyncing={false} isReconnecting={false} />
</I18nProvider>,
)
expect(screen.getByTestId('pwa-update-banner')).toHaveClass('top-12')
})
it('expands the rationale section when the disclosure is opened', () => {
usePwaUpdateMock.mockReturnValue({
needRefresh: true,
reload: vi.fn(),
})
renderBanner()
const disclosure = screen.getByText("Why can't I dismiss this?")
expect(screen.queryByText(/agent running/i)).not.toBeVisible()
fireEvent.click(disclosure)
expect(screen.getByText(/agent running/i)).toBeVisible()
expect(screen.getByText(/finish what you are doing first/i)).toBeVisible()
})
})
+73
View File
@@ -0,0 +1,73 @@
import { useOnlineStatus } from '@/hooks/useOnlineStatus'
import { usePlatform } from '@/hooks/usePlatform'
import { usePwaUpdateContext } from '@/lib/pwa-update-context'
import { useTranslation } from '@/lib/use-translation'
import { useVoiceOptional } from '@/lib/voice-context'
export function PwaUpdateBanner({ topClassName }: { topClassName?: string } = {}) {
const { t } = useTranslation()
const { needRefresh, reload } = usePwaUpdateContext()
const isOnline = useOnlineStatus()
const { haptic } = usePlatform()
if (!needRefresh) {
return null
}
const topClass = topClassName ?? (isOnline ? 'top-2' : 'top-10')
return (
<div
data-testid="pwa-update-banner"
className={`fixed left-4 right-4 bg-[var(--app-secondary-bg)] border border-[var(--app-border)] rounded-lg p-4 shadow-lg z-50 ${topClass}`}
>
<div className="flex items-center justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[var(--app-fg)]">
{t('pwa.update.title')}
</p>
<p className="text-xs text-[var(--app-hint)] mt-0.5">
{t('pwa.update.body')}
</p>
</div>
<button
type="button"
onClick={() => {
haptic.impact('light')
reload()
}}
className="shrink-0 px-4 py-2 bg-[var(--app-fg)] text-[var(--app-bg)] rounded-lg text-sm font-medium active:opacity-80"
>
{t('pwa.update.reload')}
</button>
</div>
<details className="mt-3 border-t border-[var(--app-border)] pt-2">
<summary className="cursor-pointer text-xs text-[var(--app-link)] active:opacity-60 list-none [&::-webkit-details-marker]:hidden">
{t('pwa.update.whyToggle')}
</summary>
<p className="mt-2 text-xs text-[var(--app-hint)] leading-relaxed">
{t('pwa.update.whyBody')}
</p>
</details>
</div>
)
}
export function PwaUpdateBannerWithStatusOffset({
isSyncing,
isReconnecting,
}: {
isSyncing: boolean
isReconnecting: boolean
}) {
const voice = useVoiceOptional()
const hasTopStatusBanner =
isSyncing ||
isReconnecting ||
Boolean(voice && voice.status === 'error' && voice.errorMessage)
return (
<PwaUpdateBanner topClassName={hasTopStatusBanner ? 'top-12' : undefined} />
)
}
+230
View File
@@ -0,0 +1,230 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
PWA_UPDATE_CHECK_INTERVAL_MS,
PWA_UPDATE_RELOAD_FALLBACK_MS,
requestPwaUpdateReload,
setupRegistrationUpdateChecks,
usePwaUpdate,
} from '@/hooks/usePwaUpdate'
const registerSWMock = vi.fn()
const serviceWorkerListeners = new Map<string, Set<EventListener>>()
vi.mock('virtual:pwa-register', () => ({
registerSW: (options: Parameters<typeof registerSWMock>[0]) => registerSWMock(options),
}))
beforeEach(() => {
serviceWorkerListeners.clear()
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: {
addEventListener: (type: string, listener: EventListener) => {
const bucket = serviceWorkerListeners.get(type) ?? new Set<EventListener>()
bucket.add(listener)
serviceWorkerListeners.set(type, bucket)
},
removeEventListener: (type: string, listener: EventListener) => {
serviceWorkerListeners.get(type)?.delete(listener)
},
},
})
})
describe('setupRegistrationUpdateChecks', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('checks for updates on an hourly interval', () => {
const registration = {
update: vi.fn().mockResolvedValue(undefined),
} as unknown as ServiceWorkerRegistration
const cleanup = setupRegistrationUpdateChecks(registration)
vi.advanceTimersByTime(PWA_UPDATE_CHECK_INTERVAL_MS)
expect(registration.update).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(PWA_UPDATE_CHECK_INTERVAL_MS)
expect(registration.update).toHaveBeenCalledTimes(2)
cleanup()
})
it('checks for updates when the tab becomes visible', () => {
const registration = {
update: vi.fn().mockResolvedValue(undefined),
} as unknown as ServiceWorkerRegistration
const cleanup = setupRegistrationUpdateChecks(registration)
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'hidden',
})
document.dispatchEvent(new Event('visibilitychange'))
expect(registration.update).not.toHaveBeenCalled()
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'visible',
})
document.dispatchEvent(new Event('visibilitychange'))
expect(registration.update).toHaveBeenCalledTimes(1)
cleanup()
})
it('removes listeners and clears the interval on cleanup', () => {
const registration = {
update: vi.fn().mockResolvedValue(undefined),
} as unknown as ServiceWorkerRegistration
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener')
const clearIntervalSpy = vi.spyOn(window, 'clearInterval')
const cleanup = setupRegistrationUpdateChecks(registration)
cleanup()
expect(removeEventListenerSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function))
expect(clearIntervalSpy).toHaveBeenCalled()
})
})
describe('requestPwaUpdateReload', () => {
it('reloads immediately when updateSW is unavailable', async () => {
const reloadPage = vi.fn()
await requestPwaUpdateReload(null, { reloadPage })
expect(reloadPage).toHaveBeenCalledTimes(1)
})
it('calls updateSW and reloads on controllerchange', async () => {
const updateSW = vi.fn().mockImplementation(async () => {
for (const listener of serviceWorkerListeners.get('controllerchange') ?? []) {
listener(new Event('controllerchange'))
}
})
const reloadPage = vi.fn()
await requestPwaUpdateReload(updateSW, { reloadPage })
expect(updateSW).toHaveBeenCalledWith(true)
expect(reloadPage).toHaveBeenCalledTimes(1)
})
it('falls back to reload when controllerchange never fires', async () => {
vi.useFakeTimers()
const updateSW = vi.fn().mockResolvedValue(undefined)
const reloadPage = vi.fn()
const pending = requestPwaUpdateReload(updateSW, {
reloadPage,
setTimeoutFn: vi.fn((callback, delay) => {
expect(delay).toBe(PWA_UPDATE_RELOAD_FALLBACK_MS)
return setTimeout(callback, delay)
}) as typeof setTimeout,
})
await pending
vi.runAllTimers()
expect(updateSW).toHaveBeenCalledWith(true)
expect(reloadPage).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
})
describe('usePwaUpdate', () => {
let capturedOptions: {
onNeedRefresh?: () => void
onRegistered?: (registration: ServiceWorkerRegistration | undefined) => void
} = {}
const updateSW = vi.fn().mockResolvedValue(undefined)
beforeEach(() => {
capturedOptions = {}
updateSW.mockClear()
registerSWMock.mockImplementation((options) => {
capturedOptions = options
return updateSW
})
})
it('registers the service worker and exposes refresh state', () => {
const { result } = renderHook(() => usePwaUpdate())
expect(registerSWMock).toHaveBeenCalledTimes(1)
expect(result.current.needRefresh).toBe(false)
act(() => {
capturedOptions.onNeedRefresh?.()
})
expect(result.current.needRefresh).toBe(true)
})
it('reloads through updateSW when reload is called', async () => {
const updateSW = vi.fn().mockImplementation(async () => {
for (const listener of serviceWorkerListeners.get('controllerchange') ?? []) {
listener(new Event('controllerchange'))
}
})
registerSWMock.mockImplementation((options) => {
capturedOptions = options
return updateSW
})
const { result } = renderHook(() => usePwaUpdate())
await act(async () => {
result.current.reload()
})
expect(updateSW).toHaveBeenCalledWith(true)
})
it('keeps needRefresh true until a successful reload clears the page', () => {
const { result } = renderHook(() => usePwaUpdate())
act(() => {
capturedOptions.onNeedRefresh?.()
})
expect(result.current.needRefresh).toBe(true)
act(() => {
result.current.reload()
})
expect(updateSW).toHaveBeenCalledWith(true)
expect(result.current.needRefresh).toBe(true)
})
it('wires registration update checks from onRegistered', () => {
vi.useFakeTimers()
const registration = {
update: vi.fn().mockResolvedValue(undefined),
} as unknown as ServiceWorkerRegistration
renderHook(() => usePwaUpdate())
act(() => {
capturedOptions.onRegistered?.(registration)
})
vi.advanceTimersByTime(PWA_UPDATE_CHECK_INTERVAL_MS)
expect(registration.update).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
})
+123
View File
@@ -0,0 +1,123 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { registerSW } from 'virtual:pwa-register'
export const PWA_UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
export const PWA_UPDATE_RELOAD_FALLBACK_MS = 2000
export async function requestPwaUpdateReload(
updateSW: ((reloadPage?: boolean) => Promise<void>) | null | undefined,
options: {
reloadPage?: () => void
setTimeoutFn?: typeof setTimeout
clearTimeoutFn?: typeof clearTimeout
} = {},
): Promise<void> {
const reloadPage = options.reloadPage ?? (() => window.location.reload())
const setTimeoutFn = options.setTimeoutFn ?? setTimeout
const clearTimeoutFn = options.clearTimeoutFn ?? clearTimeout
if (!updateSW) {
reloadPage()
return
}
let reloaded = false
const doReload = () => {
if (reloaded) {
return
}
reloaded = true
reloadPage()
}
const onControllerChange = () => {
navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange)
doReload()
}
navigator.serviceWorker.addEventListener('controllerchange', onControllerChange)
let fallbackTimer: ReturnType<typeof setTimeout> | undefined
try {
await updateSW(true)
} catch (error) {
console.error('PWA update failed', error)
navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange)
if (fallbackTimer !== undefined) {
clearTimeoutFn(fallbackTimer)
}
doReload()
return
}
fallbackTimer = setTimeoutFn(() => {
navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange)
doReload()
}, PWA_UPDATE_RELOAD_FALLBACK_MS)
}
export function setupRegistrationUpdateChecks(
registration: ServiceWorkerRegistration,
): () => void {
const intervalId = window.setInterval(() => {
void registration.update()
}, PWA_UPDATE_CHECK_INTERVAL_MS)
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void registration.update()
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
window.clearInterval(intervalId)
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}
export function usePwaUpdate() {
const [needRefresh, setNeedRefresh] = useState(false)
const updateSWRef = useRef<((reloadPage?: boolean) => Promise<void>) | null>(null)
const cleanupRef = useRef<(() => void) | null>(null)
useEffect(() => {
const updateSW = registerSW({
onNeedRefresh() {
setNeedRefresh(true)
},
onOfflineReady() {
console.log('App ready for offline use')
},
onRegistered(registration) {
cleanupRef.current?.()
cleanupRef.current = null
if (!registration) {
return
}
cleanupRef.current = setupRegistrationUpdateChecks(registration)
},
onRegisterError(error) {
console.error('SW registration error:', error)
},
})
updateSWRef.current = updateSW
return () => {
cleanupRef.current?.()
cleanupRef.current = null
updateSWRef.current = null
}
}, [])
const reload = useCallback(() => {
void requestPwaUpdateReload(updateSWRef.current)
}, [])
return { needRefresh, reload }
}
+5
View File
@@ -466,6 +466,11 @@ export default {
'reconnecting.reason.closed': 'stream closed',
'reconnecting.reason.heartbeatTimeout': 'heartbeat timeout',
'reconnecting.reason.visibilityRecovery': 'resuming after background',
'pwa.update.title': 'New version available',
'pwa.update.body': 'Reload to get the latest HAPI',
'pwa.update.reload': 'Reload',
'pwa.update.whyToggle': "Why can't I dismiss this?",
'pwa.update.whyBody': 'HAPI will not reload your tab automatically while you may have an agent running, a permission waiting, or a message in progress. Running an old web build against the current server can cause sync bugs and failed actions. This banner stays visible until you reload so you are not stuck on a stale version by accident — but you choose when to tap Reload and finish what you are doing first.',
// Send blocked
'send.blocked.title': 'Cannot send message',
+5
View File
@@ -470,6 +470,11 @@ export default {
'reconnecting.reason.closed': '流连接已关闭',
'reconnecting.reason.heartbeatTimeout': '心跳超时',
'reconnecting.reason.visibilityRecovery': '后台恢复中',
'pwa.update.title': '新版本可用',
'pwa.update.body': '重新加载以获取最新版 HAPI',
'pwa.update.reload': '重新加载',
'pwa.update.whyToggle': '为什么不能关闭此提示?',
'pwa.update.whyBody': '当你可能有正在运行的智能体、待处理的权限请求或未发送的消息时,HAPI 不会自动重新加载标签页。旧版网页与当前服务器一起运行可能导致同步错误和操作失败。此横幅会一直保持显示,直到你重新加载,以免你在不知情的情况下停留在旧版本 — 但何时点击「重新加载」由你决定,可以先完成手头的工作。',
// Send blocked
'send.blocked.title': '无法发送消息',
+24
View File
@@ -0,0 +1,24 @@
import { createContext, useContext, type ReactNode } from 'react'
import { usePwaUpdate } from '@/hooks/usePwaUpdate'
type PwaUpdateContextValue = ReturnType<typeof usePwaUpdate>
const PwaUpdateContext = createContext<PwaUpdateContextValue | null>(null)
export function PwaUpdateProvider({ children }: { children: ReactNode }) {
const value = usePwaUpdate()
return (
<PwaUpdateContext.Provider value={value}>
{children}
</PwaUpdateContext.Provider>
)
}
export function usePwaUpdateContext() {
const value = useContext(PwaUpdateContext)
if (!value) {
throw new Error('usePwaUpdateContext must be used within PwaUpdateProvider')
}
return value
}
-22
View File
@@ -4,7 +4,6 @@ import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { RouterProvider, createMemoryHistory } from '@tanstack/react-router'
import './index.css'
import { registerSW } from 'virtual:pwa-register'
import { initializeFontScale } from '@/hooks/useFontScale'
import { getTelegramWebApp, isTelegramEnvironment, loadTelegramSdk } from './hooks/useTelegram'
import { queryClient } from './lib/query-client'
@@ -52,27 +51,6 @@ async function bootstrap() {
restoreSpaRedirect()
}
const updateSW = registerSW({
onNeedRefresh() {
if (confirm('New version available! Reload to update?')) {
updateSW(true)
}
},
onOfflineReady() {
console.log('App ready for offline use')
},
onRegistered(registration) {
if (registration) {
setInterval(() => {
registration.update()
}, 60 * 60 * 1000)
}
},
onRegisterError(error) {
console.error('SW registration error:', error)
}
})
const history = isTelegram
? createMemoryHistory({ initialEntries: [getInitialPath()] })
: undefined
+10
View File
@@ -91,6 +91,16 @@ registerRoute(
})
)
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting()
}
})
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim())
})
self.addEventListener('push', (event) => {
const payload = event.data?.json() as PushPayload | undefined
if (!payload) {
+2 -1
View File
@@ -65,7 +65,8 @@ export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
// User-controlled reload avoids mid-session surprise reloads (autoUpdate reloads all tabs).
registerType: 'prompt',
includeAssets: ['favicon.ico', 'apple-touch-icon-180x180.png', 'mask-icon.svg'],
strategies: 'injectManifest',
srcDir: 'src',