diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..658ad00 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: "3.4" + +services: + riwa-ionic: + image: nginx:alpine + container_name: riwa-ionic + ports: + - 8808:8808 + restart: always + network_mode: bridge + volumes: + # ssl + - ./ssl/:/etc/nginx/ssl/ + # dist + - ./dist:/usr/share/nginx/html/ + # nginx conf + - ./nginx.conf:/etc/nginx/nginx.conf diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..3286986 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,121 @@ +# Generated by nginxconfig.io +# See nginxconfig.txt for the configuration share link + +user nginx; +pid /var/run/nginx.pid; +worker_processes auto; +worker_rlimit_nofile 65535; + +# Load modules +include /etc/nginx/modules-enabled/*.conf; + +events { + multi_accept on; + worker_connections 65535; +} + +http { + charset utf-8; + sendfile on; + tcp_nopush on; + tcp_nodelay on; + server_tokens off; + log_not_found off; + types_hash_max_size 2048; + types_hash_bucket_size 64; + client_max_body_size 16M; + + # MIME + include mime.types; + default_type application/octet-stream; + + # Logging + access_log off; + error_log /dev/null; + + # SSL + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:10m; + ssl_session_tickets off; + + # Mozilla Intermediate configuration + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + + # OCSP Stapling + ssl_stapling on; + ssl_stapling_verify on; + resolver 1.1.1.1 1.0.0.1 8.8.8.8 8.8.4.4 208.67.222.222 208.67.220.220 valid=60s; + resolver_timeout 2s; + + # Load configs + include /etc/nginx/conf.d/*.conf; + + server { + listen 8808 ssl; + server_name hdbpage.top; + root /usr/share/nginx/html; + + # SSL + ssl_certificate /etc/nginx/ssl/hdbpage.top.pem; + ssl_certificate_key /etc/nginx/ssl/hdbpage.top.key; + ssl_session_timeout 10m; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + # security headers + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Content-Security-Policy "default-src 'self' http: https: ws: wss: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always; + add_header Permissions-Policy "interest-cohort=()" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + + # . files + location ~ /\.(?!well-known) { + deny all; + } + + # logging + access_log /var/log/nginx/access.log combined buffer=512k flush=1m; + error_log /var/log/nginx/error.log warn; + + # index.html fallback + location / { + try_files $uri $uri/ /index.html; + } + + # favicon.ico + location = /favicon.ico { + log_not_found off; + } + + # robots.txt + location = /robots.txt { + log_not_found off; + } + + # Disable HTML caching + location ~* \.(?:html?)$ { + add_header Cache-Control "no-cache"; + } + + # assets, media + location ~* \.(?:css(\.map)?|js(\.map)?|jpe?g|png|gif|ico|cur|heic|webp|tiff?|mp3|m4a|aac|ogg|midi?|wav|mp4|mov|webm|mpe?g|avi|ogv|flv|wmv)$ { + expires 7d; + } + + # svg, fonts + location ~* \.(?:svgz?|ttf|ttc|otf|eot|woff2?)$ { + add_header Access-Control-Allow-Origin "*"; + expires 7d; + } + + # gzip + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml; + } +} \ No newline at end of file diff --git a/src/components/pwa-install-button/index.vue b/src/components/pwa-install-button/index.vue new file mode 100644 index 0000000..5e4dc5e --- /dev/null +++ b/src/components/pwa-install-button/index.vue @@ -0,0 +1,42 @@ + + + diff --git a/src/components/pwa-install-button/ios-install-guide.vue b/src/components/pwa-install-button/ios-install-guide.vue new file mode 100644 index 0000000..24adb33 --- /dev/null +++ b/src/components/pwa-install-button/ios-install-guide.vue @@ -0,0 +1,48 @@ + + + diff --git a/src/composables/usePWAInstall.ts b/src/composables/usePWAInstall.ts new file mode 100644 index 0000000..f22702a --- /dev/null +++ b/src/composables/usePWAInstall.ts @@ -0,0 +1,101 @@ +interface BeforeInstallPromptEvent extends Event { + prompt: () => Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; +} + +export function usePWAInstall() { + const deferredPrompt = ref(null); + const showInstallButton = ref(false); + const isInstalled = ref(false); + + // 检查是否已安装 + function checkIfInstalled() { + // 检查是否在独立模式下运行(已安装) + if (window.matchMedia("(display-mode: standalone)").matches) { + isInstalled.value = true; + console.log("[PWA] Already installed (standalone mode)"); + return true; + } + + // 检查 iOS Safari 独立模式 + if ((window.navigator as any).standalone === true) { + isInstalled.value = true; + console.log("[PWA] Already installed (iOS standalone)"); + return true; + } + + return false; + } + + // 检测是否是 iOS 设备 + function isIOS() { + return /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream; + } + + // 检测是否是 iOS Safari(未安装) + function isIOSSafari() { + return isIOS() && !(window.navigator as any).standalone; + } + + // 在全局作用域设置监听器(不在 onMounted 中) + if (typeof window !== "undefined") { + // 检查是否已安装 + if (!checkIfInstalled()) { + // 监听安装提示事件(仅 Android/Chrome) + window.addEventListener("beforeinstallprompt", (e: Event) => { + console.log("[PWA] beforeinstallprompt event fired"); + e.preventDefault(); + deferredPrompt.value = e as BeforeInstallPromptEvent; + showInstallButton.value = true; + }); + + // 监听安装成功事件 + window.addEventListener("appinstalled", () => { + console.log("[PWA] App installed successfully"); + deferredPrompt.value = null; + showInstallButton.value = false; + isInstalled.value = true; + }); + + // iOS 设备也显示安装按钮(iOS 不支持 beforeinstallprompt) + if (isIOSSafari()) { + console.log("[PWA] iOS Safari detected, showing install button"); + showInstallButton.value = true; + } + } + } + + async function promptInstall() { + // iOS 设备返回特殊标识,由组件处理 + if (isIOSSafari()) { + console.log("[PWA] iOS: Showing manual install guide"); + return { outcome: "ios-instruction" as const }; + } + + // Android/Chrome 设备 + if (!deferredPrompt.value) { + console.log("[PWA] No deferred prompt available"); + return { outcome: "not-available" as const }; + } + + console.log("[PWA] Showing install prompt"); + await deferredPrompt.value.prompt(); + const { outcome } = await deferredPrompt.value.userChoice; + console.log("[PWA] User choice:", outcome); + + if (outcome === "accepted") { + showInstallButton.value = false; + deferredPrompt.value = null; + } + + return { outcome }; + } + + return { + showInstallButton: computed(() => showInstallButton.value && !isInstalled.value), + isInstalled, + promptInstall, + isIOS: isIOS(), + isIOSSafari: isIOSSafari(), + }; +}