feat: implement progressive web app support with offline capabilities

Add complete PWA implementation including service worker registration,
offline support, and installation prompts:

- Add vite-plugin-pwa and workbox-window dependencies for PWA tooling
- Configure VitePWA plugin with web app manifest and app metadata
- Set up Workbox caching strategies for API endpoints and CDN assets
- Implement service worker auto-update with user-triggered refresh
- Create usePWAInstall hook to handle beforeinstallprompt events
- Create useOnlineStatus hook for monitoring network connectivity
- Add InstallPrompt component with haptic feedback integration
- Add OfflineBanner component to notify users of offline status
- Configure PWA icons and assets (64x64, 192x192, 512x512 variants)
- Add TypeScript type declarations for virtual PWA register module
- Integrate PWA components and service worker into App.tsx and main.tsx
- Add PWA meta tags and viewport configuration to index.html
This commit is contained in:
weishu
2025-12-18 13:40:36 +08:00
parent 9be0a96af4
commit ea9f0f3b1a
19 changed files with 906 additions and 7 deletions
+561 -3
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -14,5 +14,9 @@
"typecheck:server": "cd server && bun run typecheck",
"typecheck:web": "cd web && bun run typecheck",
"test": "cd cli && bun run test"
},
"devDependencies": {
"vite-plugin-pwa": "^1.2.0",
"workbox-window": "^7.4.0"
}
}
+19
View File
@@ -6,6 +6,25 @@
name="viewport"
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<!-- PWA Meta Tags -->
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#1c1c1e" media="(prefers-color-scheme: dark)" />
<meta name="description" content="AI-powered development assistant" />
<!-- iOS PWA Meta Tags -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Happy" />
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
<!-- Favicon -->
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="icon" href="/icon.svg" sizes="any" type="image/svg+xml" />
<!-- Safari Pinned Tab -->
<link rel="mask-icon" href="/mask-icon.svg" color="#111827" />
<title>Happy Mini App</title>
<script type="importmap">
{
Binary file not shown.

After

Width:  |  Height:  |  Size: 425 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="64" fill="#2563eb"/>
<text x="256" y="340" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="320" font-weight="bold" fill="#fff">H</text>
</svg>

After

Width:  |  Height:  |  Size: 280 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<circle cx="256" cy="256" r="256" fill="#000"/>
<text x="256" y="340" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="320" font-weight="bold" fill="#fff">H</text>
</svg>

After

Width:  |  Height:  |  Size: 272 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 B

+9 -3
View File
@@ -11,6 +11,8 @@ import { SessionChat } from '@/components/SessionChat'
import { MachineList } from '@/components/MachineList'
import { SpawnSession } from '@/components/SpawnSession'
import { LoginPrompt } from '@/components/LoginPrompt'
import { InstallPrompt } from '@/components/InstallPrompt'
import { OfflineBanner } from '@/components/OfflineBanner'
type Screen =
| { type: 'sessions' }
@@ -471,8 +473,10 @@ export function App() {
: null
return (
<div className="h-full flex flex-col">
{screen.type === 'sessions' ? (
<>
<OfflineBanner />
<div className="h-full flex flex-col">
{screen.type === 'sessions' ? (
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{sessionsError ? <div className="text-sm text-red-600">{sessionsError}</div> : null}
<SessionList
@@ -587,6 +591,8 @@ export function App() {
/>
</div>
)}
</div>
</div>
<InstallPrompt />
</>
)
}
+40
View File
@@ -0,0 +1,40 @@
import { usePWAInstall } from '@/hooks/usePWAInstall'
import { usePlatform } from '@/hooks/usePlatform'
export function InstallPrompt() {
const { canInstall, promptInstall, isStandalone } = usePWAInstall()
const { isTelegram, haptic } = usePlatform()
if (isTelegram || isStandalone || !canInstall) {
return null
}
const handleInstall = async () => {
haptic.impact('light')
const success = await promptInstall()
if (success) {
haptic.notification('success')
}
}
return (
<div className="fixed bottom-4 left-4 right-4 bg-[var(--app-secondary-bg)] border border-[var(--app-border)] rounded-lg p-4 shadow-lg z-50">
<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)]">
Install Happy App
</p>
<p className="text-xs text-[var(--app-hint)] mt-0.5">
Add to home screen for the best experience
</p>
</div>
<button
onClick={handleInstall}
className="shrink-0 px-4 py-2 bg-[var(--app-button)] text-[var(--app-button-text)] rounded-lg text-sm font-medium active:opacity-80"
>
Install
</button>
</div>
</div>
)
}
+15
View File
@@ -0,0 +1,15 @@
import { useOnlineStatus } from '@/hooks/useOnlineStatus'
export function OfflineBanner() {
const isOnline = useOnlineStatus()
if (isOnline) {
return null
}
return (
<div className="fixed top-0 left-0 right-0 bg-amber-500 text-white text-center py-2 text-sm font-medium z-50">
You're offline. Some features may be unavailable.
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { useSyncExternalStore } from 'react'
function subscribe(callback: () => void): () => void {
window.addEventListener('online', callback)
window.addEventListener('offline', callback)
return () => {
window.removeEventListener('online', callback)
window.removeEventListener('offline', callback)
}
}
function getSnapshot(): boolean {
return navigator.onLine
}
function getServerSnapshot(): boolean {
return true
}
export function useOnlineStatus(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}
+84
View File
@@ -0,0 +1,84 @@
import { useCallback, useEffect, useState } from 'react'
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
type InstallState = 'idle' | 'available' | 'installing' | 'installed'
export function usePWAInstall(): {
installState: InstallState
canInstall: boolean
isStandalone: boolean
promptInstall: () => Promise<boolean>
} {
const [installState, setInstallState] = useState<InstallState>('idle')
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
const isStandalone =
typeof window !== 'undefined' &&
(window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true)
useEffect(() => {
if (isStandalone) {
setInstallState('installed')
return
}
const handleBeforeInstallPrompt = (e: Event) => {
e.preventDefault()
setDeferredPrompt(e as BeforeInstallPromptEvent)
setInstallState('available')
}
const handleAppInstalled = () => {
setInstallState('installed')
setDeferredPrompt(null)
}
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.addEventListener('appinstalled', handleAppInstalled)
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.removeEventListener('appinstalled', handleAppInstalled)
}
}, [isStandalone])
const promptInstall = useCallback(async (): Promise<boolean> => {
if (!deferredPrompt) {
return false
}
// Clear immediately to prevent re-entrancy while userChoice is pending
const prompt = deferredPrompt
setDeferredPrompt(null)
setInstallState('installing')
try {
await prompt.prompt()
const { outcome } = await prompt.userChoice
if (outcome === 'accepted') {
setInstallState('installed')
return true
} else {
// User dismissed, wait for a new beforeinstallprompt event
setInstallState('idle')
return false
}
} catch {
setInstallState('idle')
return false
}
}, [deferredPrompt])
return {
installState,
canInstall: installState === 'available',
isStandalone,
promptInstall
}
}
+22
View File
@@ -2,6 +2,28 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import { App } from './App'
import './index.css'
import { registerSW } from 'virtual:pwa-register'
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)
}
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
+13
View File
@@ -0,0 +1,13 @@
/// <reference types="vite-plugin-pwa/client" />
declare module 'virtual:pwa-register' {
export type RegisterSWOptions = {
immediate?: boolean
onNeedRefresh?: () => void
onOfflineReady?: () => void
onRegistered?: (registration: ServiceWorkerRegistration | undefined) => void
onRegisterError?: (error: Error) => void
}
export function registerSW(options?: RegisterSWOptions): (reloadPage?: boolean) => Promise<void>
}
+109 -1
View File
@@ -1,9 +1,117 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
import { resolve } from 'node:path'
export default defineConfig({
plugins: [react()],
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'apple-touch-icon-180x180.png', 'mask-icon.svg'],
manifest: {
name: 'Happy Mini App',
short_name: 'Happy',
description: 'AI-powered development assistant',
theme_color: '#ffffff',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait',
scope: '/',
start_url: '/',
icons: [
{
src: 'pwa-64x64.png',
sizes: '64x64',
type: 'image/png'
},
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
},
{
src: 'maskable-icon-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
runtimeCaching: [
{
urlPattern: /^\/api\/sessions$/,
handler: 'NetworkFirst',
options: {
cacheName: 'api-sessions',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 5
},
networkTimeoutSeconds: 10
}
},
{
urlPattern: /^\/api\/sessions\/[^/]+$/,
handler: 'NetworkFirst',
options: {
cacheName: 'api-session-detail',
expiration: {
maxEntries: 20,
maxAgeSeconds: 60 * 5
},
networkTimeoutSeconds: 10
}
},
{
urlPattern: /^\/api\/machines$/,
handler: 'NetworkFirst',
options: {
cacheName: 'api-machines',
expiration: {
maxEntries: 5,
maxAgeSeconds: 60 * 10
},
networkTimeoutSeconds: 10
}
},
{
urlPattern: /^https:\/\/cdn\.socket\.io\/.*/,
handler: 'CacheFirst',
options: {
cacheName: 'cdn-socketio',
expiration: {
maxEntries: 5,
maxAgeSeconds: 60 * 60 * 24 * 30
}
}
},
{
urlPattern: /^https:\/\/telegram\.org\/.*/,
handler: 'CacheFirst',
options: {
cacheName: 'cdn-telegram',
expiration: {
maxEntries: 5,
maxAgeSeconds: 60 * 60 * 24 * 7
}
}
}
]
},
devOptions: {
enabled: true,
type: 'module'
}
})
],
base: '/',
resolve: {
alias: {