diff --git a/docs/guide/pwa.md b/docs/guide/pwa.md index 5b6dfd58..631795f0 100644 --- a/docs/guide/pwa.md +++ b/docs/guide/pwa.md @@ -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 diff --git a/web/src/App.tsx b/web/src/App.tsx index 3a11e6c2..2ff3e6c2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 const REQUIRE_SERVER_URL = requireHubUrlForLogin() +function withPwaBanner(content: ReactNode) { + return ( + <> + + {content} + + ) +} + export function App() { return ( - + + + ) } @@ -341,16 +354,16 @@ function AppInner() { // Loading auth source if (isAuthSourceLoading) { - return ( + return withPwaBanner(
-
+ , ) } // No auth source (browser environment, not logged in) if (!authSource) { - return ( + return withPwaBanner( + />, ) } if (needsBinding) { - return ( + return withPwaBanner( + />, ) } // Authenticating (also covers the gap before useAuth effect starts) if (isAuthLoading || (authSource && !token && !authError)) { - return ( + return withPwaBanner(
-
+ , ) } @@ -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( + />, ) } // Telegram auth failed - return ( + return withPwaBanner(
{t('login.title')}
@@ -413,13 +426,17 @@ function AppInner() {
Open this page from Telegram using the bot's "Open App" button (not "Open in browser").
-
+
, ) } return ( + ({ + 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( + + + , + ) +} + +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( + + + , + ) + + 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( + + + , + ) + + 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() + }) +}) diff --git a/web/src/components/PwaUpdateBanner.tsx b/web/src/components/PwaUpdateBanner.tsx new file mode 100644 index 00000000..1f78c8aa --- /dev/null +++ b/web/src/components/PwaUpdateBanner.tsx @@ -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 ( +
+
+
+

+ {t('pwa.update.title')} +

+

+ {t('pwa.update.body')} +

+
+ +
+ +
+ + {t('pwa.update.whyToggle')} + +

+ {t('pwa.update.whyBody')} +

+
+
+ ) +} + +export function PwaUpdateBannerWithStatusOffset({ + isSyncing, + isReconnecting, +}: { + isSyncing: boolean + isReconnecting: boolean +}) { + const voice = useVoiceOptional() + const hasTopStatusBanner = + isSyncing || + isReconnecting || + Boolean(voice && voice.status === 'error' && voice.errorMessage) + + return ( + + ) +} diff --git a/web/src/hooks/usePwaUpdate.test.ts b/web/src/hooks/usePwaUpdate.test.ts new file mode 100644 index 00000000..d5733b69 --- /dev/null +++ b/web/src/hooks/usePwaUpdate.test.ts @@ -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>() + +vi.mock('virtual:pwa-register', () => ({ + registerSW: (options: Parameters[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() + 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() + }) +}) diff --git a/web/src/hooks/usePwaUpdate.ts b/web/src/hooks/usePwaUpdate.ts new file mode 100644 index 00000000..5a4e1891 --- /dev/null +++ b/web/src/hooks/usePwaUpdate.ts @@ -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) | null | undefined, + options: { + reloadPage?: () => void + setTimeoutFn?: typeof setTimeout + clearTimeoutFn?: typeof clearTimeout + } = {}, +): Promise { + 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 | 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) | 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 } +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 5e4e9286..fc3c75e7 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -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', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 301a3d72..5fb4e08d 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -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': '无法发送消息', diff --git a/web/src/lib/pwa-update-context.tsx b/web/src/lib/pwa-update-context.tsx new file mode 100644 index 00000000..725cfdb9 --- /dev/null +++ b/web/src/lib/pwa-update-context.tsx @@ -0,0 +1,24 @@ +import { createContext, useContext, type ReactNode } from 'react' +import { usePwaUpdate } from '@/hooks/usePwaUpdate' + +type PwaUpdateContextValue = ReturnType + +const PwaUpdateContext = createContext(null) + +export function PwaUpdateProvider({ children }: { children: ReactNode }) { + const value = usePwaUpdate() + + return ( + + {children} + + ) +} + +export function usePwaUpdateContext() { + const value = useContext(PwaUpdateContext) + if (!value) { + throw new Error('usePwaUpdateContext must be used within PwaUpdateProvider') + } + return value +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 569e77ef..6c5aee2e 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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 diff --git a/web/src/sw.ts b/web/src/sw.ts index ebe55dc0..021d4a70 100644 --- a/web/src/sw.ts +++ b/web/src/sw.ts @@ -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) { diff --git a/web/vite.config.ts b/web/vite.config.ts index 58ab1422..04a254fb 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -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',