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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
{
|
||||
|
||||
|
After Width: | Height: | Size: 425 B |
|
After Width: | Height: | Size: 372 B |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 473 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 268 B |
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||