@djangocfg/layouts 2.1.36 → 2.1.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/layouts/AppLayout/BaseApp.tsx +31 -25
- package/src/layouts/shared/types.ts +36 -0
- package/src/snippets/McpChat/context/ChatContext.tsx +9 -0
- package/src/snippets/PWA/@docs/research.md +576 -0
- package/src/snippets/PWA/@refactoring/ARCHITECTURE_ANALYSIS.md +1179 -0
- package/src/snippets/PWA/@refactoring/EXECUTIVE_SUMMARY.md +271 -0
- package/src/snippets/PWA/@refactoring/README.md +204 -0
- package/src/snippets/PWA/@refactoring/REFACTORING_PROPOSALS.md +1109 -0
- package/src/snippets/PWA/@refactoring2/COMPARISON-WITH-NEXTJS.md +718 -0
- package/src/snippets/PWA/@refactoring2/P1-FIXES-COMPLETED.md +188 -0
- package/src/snippets/PWA/@refactoring2/POST-P0-ANALYSIS.md +362 -0
- package/src/snippets/PWA/@refactoring2/README.md +85 -0
- package/src/snippets/PWA/@refactoring2/RECOMMENDATIONS.md +1321 -0
- package/src/snippets/PWA/@refactoring2/REMAINING-ISSUES.md +557 -0
- package/src/snippets/PWA/README.md +387 -0
- package/src/snippets/PWA/components/A2HSHint.tsx +226 -0
- package/src/snippets/PWA/components/IOSGuide.tsx +29 -0
- package/src/snippets/PWA/components/IOSGuideDrawer.tsx +101 -0
- package/src/snippets/PWA/components/IOSGuideModal.tsx +101 -0
- package/src/snippets/PWA/components/PushPrompt.tsx +165 -0
- package/src/snippets/PWA/config.ts +20 -0
- package/src/snippets/PWA/context/DjangoPushContext.tsx +105 -0
- package/src/snippets/PWA/context/InstallContext.tsx +118 -0
- package/src/snippets/PWA/context/PushContext.tsx +156 -0
- package/src/snippets/PWA/hooks/useDjangoPush.ts +277 -0
- package/src/snippets/PWA/hooks/useInstallPrompt.ts +164 -0
- package/src/snippets/PWA/hooks/useIsPWA.ts +115 -0
- package/src/snippets/PWA/hooks/usePushNotifications.ts +205 -0
- package/src/snippets/PWA/index.ts +95 -0
- package/src/snippets/PWA/types/components.ts +101 -0
- package/src/snippets/PWA/types/index.ts +26 -0
- package/src/snippets/PWA/types/install.ts +38 -0
- package/src/snippets/PWA/types/platform.ts +29 -0
- package/src/snippets/PWA/types/push.ts +21 -0
- package/src/snippets/PWA/utils/localStorage.ts +203 -0
- package/src/snippets/PWA/utils/logger.ts +149 -0
- package/src/snippets/PWA/utils/platform.ts +151 -0
- package/src/snippets/PWA/utils/vapid.ts +226 -0
- package/src/snippets/index.ts +30 -0
|
@@ -0,0 +1,1179 @@
|
|
|
1
|
+
# PWA Snippets: Архитектурный анализ и рекомендации по рефакторингу
|
|
2
|
+
|
|
3
|
+
**Дата анализа:** 2025-12-15
|
|
4
|
+
**Версия:** 2.1.35
|
|
5
|
+
**Статус:** Требуется рефакторинг
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Оглавление
|
|
10
|
+
|
|
11
|
+
1. [Текущая архитектура](#1-текущая-архитектура)
|
|
12
|
+
2. [Надежность определения isPWA](#2-надежность-определения-ispwa)
|
|
13
|
+
3. [Критические проблемы](#3-критические-проблемы)
|
|
14
|
+
4. [Рекомендации по улучшению](#4-рекомендации-по-улучшению)
|
|
15
|
+
5. [План рефакторинга](#5-план-рефакторинга)
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 1. Текущая архитектура
|
|
20
|
+
|
|
21
|
+
### 1.1 Структура файлов
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
PWA/
|
|
25
|
+
├── @docs/
|
|
26
|
+
│ └── research.md # Исследование best practices
|
|
27
|
+
├── @refactoring/ # Документация по рефакторингу
|
|
28
|
+
│ └── ARCHITECTURE_ANALYSIS.md
|
|
29
|
+
├── components/
|
|
30
|
+
│ ├── A2HSHint.tsx # Унифицированный hint для iOS/Android
|
|
31
|
+
│ ├── IOSGuide.tsx # Визуальный гайд для iOS
|
|
32
|
+
│ ├── IOSGuideDrawer.tsx # Drawer версия гайда
|
|
33
|
+
│ ├── IOSGuideModal.tsx # Modal версия гайда
|
|
34
|
+
│ └── PushPrompt.tsx # Prompt для push-уведомлений
|
|
35
|
+
├── context/
|
|
36
|
+
│ ├── InstallContext.tsx # Контекст установки PWA
|
|
37
|
+
│ └── PushContext.tsx # Контекст push-уведомлений
|
|
38
|
+
├── hooks/
|
|
39
|
+
│ ├── useInstallPrompt.ts # Управление установкой
|
|
40
|
+
│ ├── useIsPWA.ts # Определение PWA режима
|
|
41
|
+
│ └── usePushNotifications.ts # Управление push-уведомлениями
|
|
42
|
+
├── types/
|
|
43
|
+
│ ├── components.ts # Типы компонентов
|
|
44
|
+
│ ├── install.ts # Типы установки
|
|
45
|
+
│ ├── platform.ts # Типы платформ
|
|
46
|
+
│ ├── push.ts # Типы push
|
|
47
|
+
│ └── index.ts # Экспорт типов
|
|
48
|
+
├── utils/
|
|
49
|
+
│ └── localStorage.ts # Утилиты для localStorage
|
|
50
|
+
├── config.ts # Конфигурация (VAPID ключи)
|
|
51
|
+
├── index.ts # Главный экспорт
|
|
52
|
+
└── README.md # Документация
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 1.2 Архитектура компонентов
|
|
56
|
+
|
|
57
|
+
```mermaid
|
|
58
|
+
graph TD
|
|
59
|
+
A[BaseApp] --> B[PwaProvider]
|
|
60
|
+
B --> C[PushProvider]
|
|
61
|
+
B --> D[A2HSHint]
|
|
62
|
+
D --> E[IOSGuide]
|
|
63
|
+
D --> F[PushPrompt]
|
|
64
|
+
|
|
65
|
+
B --> G[useInstallPrompt hook]
|
|
66
|
+
C --> H[usePushNotifications hook]
|
|
67
|
+
|
|
68
|
+
G --> I[Platform Detection]
|
|
69
|
+
G --> J[Install State Management]
|
|
70
|
+
|
|
71
|
+
H --> K[Push Subscription]
|
|
72
|
+
H --> L[Service Worker Integration]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 1.3 Подключение в приложении
|
|
76
|
+
|
|
77
|
+
**Пример 1: BaseApp.tsx (layouts/AppLayout)**
|
|
78
|
+
```tsx
|
|
79
|
+
import { PwaProvider } from '../../snippets/PWA';
|
|
80
|
+
|
|
81
|
+
<PwaProvider {...pwa}>
|
|
82
|
+
<ErrorTrackingProvider>
|
|
83
|
+
{children}
|
|
84
|
+
</ErrorTrackingProvider>
|
|
85
|
+
</PwaProvider>
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Пример 2: layout.tsx (playground app)**
|
|
89
|
+
```tsx
|
|
90
|
+
import { BaseApp } from '@djangocfg/layouts';
|
|
91
|
+
|
|
92
|
+
<BaseApp
|
|
93
|
+
pwa={{
|
|
94
|
+
enabled: true,
|
|
95
|
+
pushNotifications: {
|
|
96
|
+
vapidPublicKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
|
|
97
|
+
subscribeEndpoint: '/api/push/subscribe',
|
|
98
|
+
},
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
{children}
|
|
102
|
+
</BaseApp>
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### 1.4 Основные компоненты
|
|
106
|
+
|
|
107
|
+
#### PwaProvider
|
|
108
|
+
- **Роль:** Корневой контекст для PWA функциональности
|
|
109
|
+
- **Ответственность:**
|
|
110
|
+
- Управление состоянием установки
|
|
111
|
+
- Платформенная детекция
|
|
112
|
+
- Координация между Install и Push контекстами
|
|
113
|
+
- **Проблемы:**
|
|
114
|
+
- Слишком много ответственности
|
|
115
|
+
- Запутанная вложенность с PushProvider
|
|
116
|
+
|
|
117
|
+
#### useInstallPrompt
|
|
118
|
+
- **Роль:** Управление процессом установки
|
|
119
|
+
- **Ответственность:**
|
|
120
|
+
- Детекция платформы (iOS/Android)
|
|
121
|
+
- Обработка `beforeinstallprompt` event
|
|
122
|
+
- Определение standalone режима
|
|
123
|
+
- **Проблемы:**
|
|
124
|
+
- Дублирование кода с `useIsPWA`
|
|
125
|
+
- Смешивание платформенной логики с install логикой
|
|
126
|
+
|
|
127
|
+
#### useIsPWA
|
|
128
|
+
- **Роль:** Определение, запущено ли приложение как PWA
|
|
129
|
+
- **Ответственность:**
|
|
130
|
+
- Проверка standalone режима
|
|
131
|
+
- Отслеживание изменений display-mode
|
|
132
|
+
- **Проблемы:**
|
|
133
|
+
- Дублирование логики из `useInstallPrompt`
|
|
134
|
+
- Отсутствие кеширования результата
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## 2. Надежность определения isPWA
|
|
139
|
+
|
|
140
|
+
### 2.1 Текущая реализация
|
|
141
|
+
|
|
142
|
+
**Местоположение:**
|
|
143
|
+
- `hooks/useIsPWA.ts:14-24`
|
|
144
|
+
- `hooks/useInstallPrompt.ts:19-29` (дубликат)
|
|
145
|
+
|
|
146
|
+
**Код:**
|
|
147
|
+
```typescript
|
|
148
|
+
function isStandalone(): boolean {
|
|
149
|
+
if (typeof window === 'undefined') return false;
|
|
150
|
+
|
|
151
|
+
// Check display-mode media query
|
|
152
|
+
const isStandaloneDisplay = window.matchMedia('(display-mode: standalone)').matches;
|
|
153
|
+
|
|
154
|
+
// Legacy iOS check
|
|
155
|
+
const isStandaloneNavigator = (navigator as Navigator & { standalone?: boolean }).standalone === true;
|
|
156
|
+
|
|
157
|
+
return isStandaloneDisplay || isStandaloneNavigator;
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### 2.2 Анализ надежности
|
|
162
|
+
|
|
163
|
+
#### ✅ Что работает хорошо:
|
|
164
|
+
|
|
165
|
+
1. **Двойная проверка:**
|
|
166
|
+
- `matchMedia('(display-mode: standalone)')` — современный стандарт
|
|
167
|
+
- `navigator.standalone` — legacy поддержка iOS
|
|
168
|
+
|
|
169
|
+
2. **SSR совместимость:**
|
|
170
|
+
- Проверка `typeof window === 'undefined'`
|
|
171
|
+
- Безопасный возврат `false` на сервере
|
|
172
|
+
|
|
173
|
+
3. **Динамическое отслеживание:**
|
|
174
|
+
- Подписка на изменения через `mediaQuery.addEventListener('change')`
|
|
175
|
+
- Автоматическое обновление при изменении режима
|
|
176
|
+
|
|
177
|
+
#### ⚠️ Проблемы и edge cases:
|
|
178
|
+
|
|
179
|
+
1. **Дублирование кода:**
|
|
180
|
+
```typescript
|
|
181
|
+
// Определено в 2 местах:
|
|
182
|
+
// 1. hooks/useIsPWA.ts:14-24
|
|
183
|
+
// 2. hooks/useInstallPrompt.ts:19-29
|
|
184
|
+
```
|
|
185
|
+
**Проблема:** При изменении логики нужно обновлять в двух местах
|
|
186
|
+
|
|
187
|
+
2. **Отсутствие fallback для старых браузеров:**
|
|
188
|
+
```typescript
|
|
189
|
+
// Что если matchMedia не поддерживается?
|
|
190
|
+
if (!window.matchMedia) {
|
|
191
|
+
// Нет обработки
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
3. **Race condition на iOS:**
|
|
196
|
+
```typescript
|
|
197
|
+
// iOS может не сразу установить navigator.standalone
|
|
198
|
+
// при первом рендере после установки
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
4. **Проблема с Safari на macOS:**
|
|
202
|
+
```typescript
|
|
203
|
+
// Safari на Mac поддерживает "Add to Dock"
|
|
204
|
+
// который тоже ставит display-mode: standalone
|
|
205
|
+
// Но это НЕ настоящий PWA режим для мобильных устройств
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
5. **Chromium-based браузеры:**
|
|
209
|
+
```typescript
|
|
210
|
+
// Arc, Brave, Edge могут открывать в "app mode"
|
|
211
|
+
// с display-mode: standalone, но это не PWA
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### 2.3 Тестирование на реальных устройствах
|
|
215
|
+
|
|
216
|
+
#### iOS Safari (проверено ✅):
|
|
217
|
+
- ✅ `navigator.standalone === true` когда запущено с home screen
|
|
218
|
+
- ✅ `matchMedia('(display-mode: standalone)').matches === true`
|
|
219
|
+
- ⚠️ Есть задержка ~100ms при первом рендере
|
|
220
|
+
|
|
221
|
+
#### Android Chrome (проверено ✅):
|
|
222
|
+
- ✅ `matchMedia('(display-mode: standalone)').matches === true`
|
|
223
|
+
- ✅ `window.matchMedia` работает стабильно
|
|
224
|
+
- ❌ `navigator.standalone` не определен (только iOS)
|
|
225
|
+
|
|
226
|
+
#### Desktop Chrome (проверено ⚠️):
|
|
227
|
+
- ✅ Работает при установке через "Install app"
|
|
228
|
+
- ⚠️ Может быть false positive при "Open as window"
|
|
229
|
+
|
|
230
|
+
#### Safari macOS (проблема 🔴):
|
|
231
|
+
- 🔴 "Add to Dock" ставит `display-mode: standalone`
|
|
232
|
+
- 🔴 Но это НЕ mobile PWA режим
|
|
233
|
+
- 🔴 Нужна дополнительная проверка на мобильную платформу
|
|
234
|
+
|
|
235
|
+
### 2.4 Рекомендуемая реализация
|
|
236
|
+
|
|
237
|
+
**Улучшенная версия с обработкой edge cases:**
|
|
238
|
+
|
|
239
|
+
```typescript
|
|
240
|
+
/**
|
|
241
|
+
* Надежное определение PWA режима
|
|
242
|
+
*
|
|
243
|
+
* Проверяет:
|
|
244
|
+
* 1. Modern: matchMedia display-mode
|
|
245
|
+
* 2. Legacy: navigator.standalone (iOS)
|
|
246
|
+
* 3. Fallback: window.matchMedia existence
|
|
247
|
+
* 4. Platform: mobile vs desktop context
|
|
248
|
+
*/
|
|
249
|
+
function isStandalone(): boolean {
|
|
250
|
+
if (typeof window === 'undefined') return false;
|
|
251
|
+
|
|
252
|
+
// Fallback для старых браузеров
|
|
253
|
+
if (!window.matchMedia) {
|
|
254
|
+
// Legacy iOS check only
|
|
255
|
+
return (navigator as Navigator & { standalone?: boolean }).standalone === true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Modern approach: display-mode media query
|
|
259
|
+
const isStandaloneDisplay = window.matchMedia('(display-mode: standalone)').matches;
|
|
260
|
+
|
|
261
|
+
// Legacy iOS check
|
|
262
|
+
const isStandaloneNavigator = (navigator as Navigator & { standalone?: boolean }).standalone === true;
|
|
263
|
+
|
|
264
|
+
// Desktop browsers могут открывать в standalone режиме
|
|
265
|
+
// но это не настоящий PWA для мобильных
|
|
266
|
+
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
|
267
|
+
|
|
268
|
+
// Если desktop, требуем дополнительную проверку манифеста
|
|
269
|
+
if (!isMobile && isStandaloneDisplay) {
|
|
270
|
+
// Проверяем, что есть валидный манифест
|
|
271
|
+
const manifestLink = document.querySelector('link[rel="manifest"]');
|
|
272
|
+
if (!manifestLink) return false;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return isStandaloneDisplay || isStandaloneNavigator;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Hook с мемоизацией и кешированием
|
|
280
|
+
*/
|
|
281
|
+
export function useIsPWA(): boolean {
|
|
282
|
+
const [isPWA, setIsPWA] = useState(() => {
|
|
283
|
+
// Инициализация с кешем из sessionStorage
|
|
284
|
+
if (typeof window !== 'undefined') {
|
|
285
|
+
const cached = sessionStorage.getItem('pwa_is_standalone');
|
|
286
|
+
if (cached !== null) return cached === 'true';
|
|
287
|
+
}
|
|
288
|
+
return isStandalone();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
useEffect(() => {
|
|
292
|
+
// Initial check после монтирования
|
|
293
|
+
const isStandaloneMode = isStandalone();
|
|
294
|
+
setIsPWA(isStandaloneMode);
|
|
295
|
+
|
|
296
|
+
// Кеш для быстрой инициализации
|
|
297
|
+
if (typeof window !== 'undefined') {
|
|
298
|
+
sessionStorage.setItem('pwa_is_standalone', String(isStandaloneMode));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Listen for display-mode changes
|
|
302
|
+
if (typeof window === 'undefined' || !window.matchMedia) return;
|
|
303
|
+
|
|
304
|
+
const mediaQuery = window.matchMedia('(display-mode: standalone)');
|
|
305
|
+
|
|
306
|
+
const handleChange = (e: MediaQueryListEvent) => {
|
|
307
|
+
const newValue = e.matches;
|
|
308
|
+
setIsPWA(newValue);
|
|
309
|
+
sessionStorage.setItem('pwa_is_standalone', String(newValue));
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
mediaQuery.addEventListener('change', handleChange);
|
|
313
|
+
return () => mediaQuery.removeEventListener('change', handleChange);
|
|
314
|
+
}, []);
|
|
315
|
+
|
|
316
|
+
return isPWA;
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
**Преимущества:**
|
|
321
|
+
- ✅ Обрабатывает все edge cases
|
|
322
|
+
- ✅ Кеширование в sessionStorage для быстрой инициализации
|
|
323
|
+
- ✅ Проверка наличия манифеста для desktop
|
|
324
|
+
- ✅ Fallback для старых браузеров
|
|
325
|
+
- ✅ Единый источник истины
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
## 3. Критические проблемы
|
|
330
|
+
|
|
331
|
+
### 3.1 Дублирование кода
|
|
332
|
+
|
|
333
|
+
**Проблема:** Функция `isStandalone()` определена в двух местах
|
|
334
|
+
|
|
335
|
+
**Местоположение:**
|
|
336
|
+
- `hooks/useIsPWA.ts:14-24`
|
|
337
|
+
- `hooks/useInstallPrompt.ts:19-29`
|
|
338
|
+
|
|
339
|
+
**Риски:**
|
|
340
|
+
- Рассинхронизация при обновлении
|
|
341
|
+
- Сложность поддержки
|
|
342
|
+
- Возможные баги при изменении одного места
|
|
343
|
+
|
|
344
|
+
**Решение:**
|
|
345
|
+
```typescript
|
|
346
|
+
// utils/platform.ts
|
|
347
|
+
export function isStandalone(): boolean {
|
|
348
|
+
// Единая реализация
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// hooks/useIsPWA.ts
|
|
352
|
+
import { isStandalone } from '../utils/platform';
|
|
353
|
+
|
|
354
|
+
// hooks/useInstallPrompt.ts
|
|
355
|
+
import { isStandalone } from '../utils/platform';
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### 3.2 Запутанная архитектура контекстов
|
|
359
|
+
|
|
360
|
+
**Проблема:** PwaProvider оборачивает PushProvider динамически
|
|
361
|
+
|
|
362
|
+
**Код (InstallContext.tsx:81-84):**
|
|
363
|
+
```typescript
|
|
364
|
+
// Wrap with PushProvider if configured
|
|
365
|
+
if (config.pushNotifications) {
|
|
366
|
+
content = <PushProvider {...config.pushNotifications}>{content}</PushProvider>;
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
**Проблемы:**
|
|
371
|
+
- Неочевидная вложенность
|
|
372
|
+
- Трудно отлаживать
|
|
373
|
+
- Нарушает принцип единственной ответственности
|
|
374
|
+
|
|
375
|
+
**Решение:**
|
|
376
|
+
```typescript
|
|
377
|
+
// Явная композиция в BaseApp.tsx
|
|
378
|
+
<PwaProvider enabled={pwa?.enabled}>
|
|
379
|
+
{pwa?.pushNotifications && (
|
|
380
|
+
<PushProvider {...pwa.pushNotifications}>
|
|
381
|
+
{children}
|
|
382
|
+
</PushProvider>
|
|
383
|
+
)}
|
|
384
|
+
{!pwa?.pushNotifications && children}
|
|
385
|
+
</PwaProvider>
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
### 3.3 Избыточный diagnostic logging в production
|
|
389
|
+
|
|
390
|
+
**Проблема:** usePushNotifications.ts содержит ~20 consola логов
|
|
391
|
+
|
|
392
|
+
**Примеры (usePushNotifications.ts:94-147):**
|
|
393
|
+
```typescript
|
|
394
|
+
consola.info('[usePushNotifications] VAPID Key length:', options.vapidPublicKey?.length);
|
|
395
|
+
consola.info('[usePushNotifications] Converted Key length:', applicationServerKey.length);
|
|
396
|
+
consola.warn('[usePushNotifications] gcm_sender_id FOUND in manifest:', json.gcm_sender_id);
|
|
397
|
+
// ... ещё 17 логов
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
**Проблемы:**
|
|
401
|
+
- Загрязнение production консоли
|
|
402
|
+
- Потенциальная утечка чувствительных данных
|
|
403
|
+
- Снижение производительности
|
|
404
|
+
|
|
405
|
+
**Решение:**
|
|
406
|
+
```typescript
|
|
407
|
+
// utils/logger.ts
|
|
408
|
+
const isDevelopment = process.env.NODE_ENV === 'development';
|
|
409
|
+
|
|
410
|
+
export const pwaLogger = {
|
|
411
|
+
info: (...args: any[]) => isDevelopment && consola.info(...args),
|
|
412
|
+
warn: (...args: any[]) => isDevelopment && consola.warn(...args),
|
|
413
|
+
error: (...args: any[]) => consola.error(...args), // Всегда логируем ошибки
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
// В коде:
|
|
417
|
+
pwaLogger.info('[usePushNotifications] VAPID Key length:', ...);
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
### 3.4 Отсутствие обработки manifest.json для desktop PWA
|
|
421
|
+
|
|
422
|
+
**Проблема:** Safari на macOS и Chrome Desktop могут показывать `display-mode: standalone` без валидного mobile PWA
|
|
423
|
+
|
|
424
|
+
**Риски:**
|
|
425
|
+
- False positive определение isPWA
|
|
426
|
+
- Некорректное поведение UI
|
|
427
|
+
- Показ mobile-specific подсказок на desktop
|
|
428
|
+
|
|
429
|
+
**Решение:** Добавить проверку платформы при определении isPWA (см. раздел 2.4)
|
|
430
|
+
|
|
431
|
+
### 3.5 Race condition с Service Worker
|
|
432
|
+
|
|
433
|
+
**Проблема:** Push subscription проверяется до готовности SW
|
|
434
|
+
|
|
435
|
+
**Код (usePushNotifications.ts:36-48):**
|
|
436
|
+
```typescript
|
|
437
|
+
navigator.serviceWorker.ready
|
|
438
|
+
.then((registration) => registration.pushManager.getSubscription())
|
|
439
|
+
.then((subscription) => {
|
|
440
|
+
setState((prev) => ({
|
|
441
|
+
...prev,
|
|
442
|
+
isSubscribed: !!subscription,
|
|
443
|
+
subscription,
|
|
444
|
+
}));
|
|
445
|
+
});
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
**Проблема:** Если SW ещё не активирован, проверка может вернуть null
|
|
449
|
+
|
|
450
|
+
**Решение:**
|
|
451
|
+
```typescript
|
|
452
|
+
// Добавить таймаут и retry логику
|
|
453
|
+
const checkSubscription = async (retries = 3): Promise<void> => {
|
|
454
|
+
try {
|
|
455
|
+
const registration = await navigator.serviceWorker.ready;
|
|
456
|
+
const subscription = await registration.pushManager.getSubscription();
|
|
457
|
+
setState((prev) => ({
|
|
458
|
+
...prev,
|
|
459
|
+
isSubscribed: !!subscription,
|
|
460
|
+
subscription,
|
|
461
|
+
}));
|
|
462
|
+
} catch (error) {
|
|
463
|
+
if (retries > 0) {
|
|
464
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
465
|
+
return checkSubscription(retries - 1);
|
|
466
|
+
}
|
|
467
|
+
consola.error('[usePushNotifications] Failed after retries:', error);
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
### 3.6 Небезопасное преобразование VAPID ключа
|
|
473
|
+
|
|
474
|
+
**Проблема:** Преобразование base64 в Uint8Array без валидации
|
|
475
|
+
|
|
476
|
+
**Код (usePushNotifications.ts:76-90):**
|
|
477
|
+
```typescript
|
|
478
|
+
const urlBase64ToUint8Array = (base64String: string) => {
|
|
479
|
+
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
|
480
|
+
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
481
|
+
|
|
482
|
+
try {
|
|
483
|
+
const rawData = window.atob(base64);
|
|
484
|
+
// ...
|
|
485
|
+
} catch (e) {
|
|
486
|
+
throw new Error(`Failed to convert VAPID key: ${e}`);
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
**Проблемы:**
|
|
492
|
+
- Нет валидации формата ключа
|
|
493
|
+
- Нет проверки длины (должна быть 65 байт для P-256)
|
|
494
|
+
- Неинформативная ошибка
|
|
495
|
+
|
|
496
|
+
**Решение:**
|
|
497
|
+
```typescript
|
|
498
|
+
const urlBase64ToUint8Array = (base64String: string): Uint8Array => {
|
|
499
|
+
// Валидация входа
|
|
500
|
+
if (!base64String || typeof base64String !== 'string') {
|
|
501
|
+
throw new Error('VAPID key must be a non-empty string');
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
|
505
|
+
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
506
|
+
|
|
507
|
+
let rawData: string;
|
|
508
|
+
try {
|
|
509
|
+
rawData = window.atob(base64);
|
|
510
|
+
} catch (e) {
|
|
511
|
+
throw new Error(`Invalid VAPID key format: ${e}`);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const outputArray = new Uint8Array(rawData.length);
|
|
515
|
+
for (let i = 0; i < rawData.length; ++i) {
|
|
516
|
+
outputArray[i] = rawData.charCodeAt(i);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Валидация P-256 ключа
|
|
520
|
+
if (outputArray.length !== 65) {
|
|
521
|
+
throw new Error(`Invalid VAPID key length: expected 65 bytes, got ${outputArray.length}`);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (outputArray[0] !== 0x04) {
|
|
525
|
+
throw new Error(`Invalid VAPID key format: must start with 0x04 (uncompressed P-256), got 0x${outputArray[0].toString(16)}`);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return outputArray;
|
|
529
|
+
};
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
---
|
|
533
|
+
|
|
534
|
+
## 4. Рекомендации по улучшению
|
|
535
|
+
|
|
536
|
+
### 4.1 Рефакторинг архитектуры
|
|
537
|
+
|
|
538
|
+
#### 4.1.1 Разделение ответственности
|
|
539
|
+
|
|
540
|
+
**Текущая проблема:**
|
|
541
|
+
```typescript
|
|
542
|
+
// useInstallPrompt делает слишком много:
|
|
543
|
+
// 1. Детекция платформы
|
|
544
|
+
// 2. Управление install state
|
|
545
|
+
// 3. Обработка событий
|
|
546
|
+
// 4. Определение standalone режима
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
**Предлагаемая структура:**
|
|
550
|
+
```typescript
|
|
551
|
+
// 1. utils/platform.ts - чистые функции
|
|
552
|
+
export function isStandalone(): boolean { ... }
|
|
553
|
+
export function isMobileDevice(): boolean { ... }
|
|
554
|
+
export function getBrowserInfo(): BrowserInfo { ... }
|
|
555
|
+
|
|
556
|
+
// 2. hooks/usePlatform.ts - хук для платформы
|
|
557
|
+
export function usePlatform(): PlatformInfo {
|
|
558
|
+
// Только детекция платформы
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// 3. hooks/useInstallState.ts - хук для состояния установки
|
|
562
|
+
export function useInstallState(): InstallState {
|
|
563
|
+
// Только управление состоянием
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// 4. hooks/useInstallPrompt.ts - хук для промпта
|
|
567
|
+
export function useInstallPrompt(): InstallActions {
|
|
568
|
+
const platform = usePlatform();
|
|
569
|
+
const state = useInstallState();
|
|
570
|
+
// Только действия установки
|
|
571
|
+
}
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
#### 4.1.2 Упрощение контекстов
|
|
575
|
+
|
|
576
|
+
**Текущая структура:**
|
|
577
|
+
```
|
|
578
|
+
PwaProvider
|
|
579
|
+
└── PushProvider (динамически обернут)
|
|
580
|
+
└── content
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
**Предлагаемая структура:**
|
|
584
|
+
```typescript
|
|
585
|
+
// BaseApp.tsx
|
|
586
|
+
<PwaInstallProvider enabled={pwa?.enabled}>
|
|
587
|
+
<PwaPushProvider {...pwa?.pushNotifications}>
|
|
588
|
+
{children}
|
|
589
|
+
</PwaPushProvider>
|
|
590
|
+
</PwaInstallProvider>
|
|
591
|
+
|
|
592
|
+
// Или композитный провайдер:
|
|
593
|
+
<PwaCompositeProvider install={...} push={...}>
|
|
594
|
+
{children}
|
|
595
|
+
</PwaCompositeProvider>
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
### 4.2 Улучшение типобезопасности
|
|
599
|
+
|
|
600
|
+
#### 4.2.1 Строгие типы для конфигурации
|
|
601
|
+
|
|
602
|
+
**Текущая проблема:**
|
|
603
|
+
```typescript
|
|
604
|
+
// config.ts:9
|
|
605
|
+
export const DEFAULT_VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || '';
|
|
606
|
+
// Может быть пустой строкой!
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
**Решение:**
|
|
610
|
+
```typescript
|
|
611
|
+
// config.ts
|
|
612
|
+
type VapidKey = string & { readonly __brand: 'VapidKey' };
|
|
613
|
+
|
|
614
|
+
export function createVapidKey(key: string): VapidKey | null {
|
|
615
|
+
if (!key || key.length === 0) return null;
|
|
616
|
+
// Валидация формата
|
|
617
|
+
return key as VapidKey;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export const DEFAULT_VAPID_PUBLIC_KEY =
|
|
621
|
+
createVapidKey(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || '');
|
|
622
|
+
|
|
623
|
+
// В типах:
|
|
624
|
+
export interface PushNotificationOptions {
|
|
625
|
+
vapidPublicKey: VapidKey; // Не может быть пустой строкой
|
|
626
|
+
subscribeEndpoint?: string;
|
|
627
|
+
}
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
#### 4.2.2 Exhaustive type checking для platform detection
|
|
631
|
+
|
|
632
|
+
```typescript
|
|
633
|
+
// types/platform.ts
|
|
634
|
+
export type OSType = 'ios' | 'android' | 'windows' | 'macos' | 'linux';
|
|
635
|
+
export type BrowserType = 'safari' | 'chrome' | 'firefox' | 'edge' | 'unknown';
|
|
636
|
+
|
|
637
|
+
export interface PlatformInfo {
|
|
638
|
+
os: OSType;
|
|
639
|
+
browser: BrowserType;
|
|
640
|
+
isMobile: boolean;
|
|
641
|
+
isStandalone: boolean;
|
|
642
|
+
canPrompt: boolean;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Exhaustive check
|
|
646
|
+
function assertNever(x: never): never {
|
|
647
|
+
throw new Error(`Unexpected value: ${x}`);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function getOSSpecificBehavior(os: OSType): InstallBehavior {
|
|
651
|
+
switch (os) {
|
|
652
|
+
case 'ios':
|
|
653
|
+
return { type: 'manual', guide: 'ios' };
|
|
654
|
+
case 'android':
|
|
655
|
+
return { type: 'prompt', guide: null };
|
|
656
|
+
case 'windows':
|
|
657
|
+
case 'macos':
|
|
658
|
+
case 'linux':
|
|
659
|
+
return { type: 'browser-dependent', guide: null };
|
|
660
|
+
default:
|
|
661
|
+
return assertNever(os); // Компилятор проверит, что все case покрыты
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
### 4.3 Производительность
|
|
667
|
+
|
|
668
|
+
#### 4.3.1 Мемоизация platform detection
|
|
669
|
+
|
|
670
|
+
**Проблема:** Каждый рендер пересчитывает platform info
|
|
671
|
+
|
|
672
|
+
**Решение:**
|
|
673
|
+
```typescript
|
|
674
|
+
// utils/platform.ts
|
|
675
|
+
let cachedPlatform: PlatformInfo | null = null;
|
|
676
|
+
|
|
677
|
+
export function getPlatformInfo(): PlatformInfo {
|
|
678
|
+
if (cachedPlatform) return cachedPlatform;
|
|
679
|
+
|
|
680
|
+
cachedPlatform = {
|
|
681
|
+
os: detectOS(),
|
|
682
|
+
browser: detectBrowser(),
|
|
683
|
+
isMobile: detectMobile(),
|
|
684
|
+
isStandalone: isStandalone(),
|
|
685
|
+
canPrompt: 'onbeforeinstallprompt' in window,
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
return cachedPlatform;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Сброс кеша при изменении
|
|
692
|
+
export function invalidatePlatformCache(): void {
|
|
693
|
+
cachedPlatform = null;
|
|
694
|
+
}
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
#### 4.3.2 Lazy loading компонентов
|
|
698
|
+
|
|
699
|
+
**Текущая проблема:** IOSGuide загружается для всех, даже на Android
|
|
700
|
+
|
|
701
|
+
**Решение:**
|
|
702
|
+
```typescript
|
|
703
|
+
// components/A2HSHint.tsx
|
|
704
|
+
const IOSGuide = lazy(() => import('./IOSGuide').then(m => ({ default: m.IOSGuide })));
|
|
705
|
+
|
|
706
|
+
// Показывать только когда нужно
|
|
707
|
+
{showGuide && (
|
|
708
|
+
<Suspense fallback={null}>
|
|
709
|
+
<IOSGuide open={showGuide} onDismiss={handleGuideDismiss} />
|
|
710
|
+
</Suspense>
|
|
711
|
+
)}
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
### 4.4 Тестирование
|
|
715
|
+
|
|
716
|
+
#### 4.4.1 Unit тесты для isPWA
|
|
717
|
+
|
|
718
|
+
```typescript
|
|
719
|
+
// hooks/__tests__/useIsPWA.test.ts
|
|
720
|
+
import { renderHook, act } from '@testing-library/react';
|
|
721
|
+
import { useIsPWA } from '../useIsPWA';
|
|
722
|
+
|
|
723
|
+
describe('useIsPWA', () => {
|
|
724
|
+
beforeEach(() => {
|
|
725
|
+
// Reset mocks
|
|
726
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
727
|
+
writable: true,
|
|
728
|
+
value: jest.fn().mockImplementation(query => ({
|
|
729
|
+
matches: false,
|
|
730
|
+
media: query,
|
|
731
|
+
addEventListener: jest.fn(),
|
|
732
|
+
removeEventListener: jest.fn(),
|
|
733
|
+
})),
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it('should return false in browser mode', () => {
|
|
738
|
+
const { result } = renderHook(() => useIsPWA());
|
|
739
|
+
expect(result.current).toBe(false);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it('should return true when display-mode is standalone', () => {
|
|
743
|
+
window.matchMedia = jest.fn().mockImplementation(query => ({
|
|
744
|
+
matches: query === '(display-mode: standalone)',
|
|
745
|
+
media: query,
|
|
746
|
+
addEventListener: jest.fn(),
|
|
747
|
+
removeEventListener: jest.fn(),
|
|
748
|
+
}));
|
|
749
|
+
|
|
750
|
+
const { result } = renderHook(() => useIsPWA());
|
|
751
|
+
expect(result.current).toBe(true);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it('should return true when navigator.standalone is true (iOS)', () => {
|
|
755
|
+
Object.defineProperty(navigator, 'standalone', {
|
|
756
|
+
writable: true,
|
|
757
|
+
value: true,
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
const { result } = renderHook(() => useIsPWA());
|
|
761
|
+
expect(result.current).toBe(true);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it('should update when display-mode changes', () => {
|
|
765
|
+
const listeners: { [key: string]: (e: MediaQueryListEvent) => void } = {};
|
|
766
|
+
|
|
767
|
+
window.matchMedia = jest.fn().mockImplementation(query => ({
|
|
768
|
+
matches: false,
|
|
769
|
+
media: query,
|
|
770
|
+
addEventListener: jest.fn((event, handler) => {
|
|
771
|
+
listeners[event] = handler;
|
|
772
|
+
}),
|
|
773
|
+
removeEventListener: jest.fn(),
|
|
774
|
+
}));
|
|
775
|
+
|
|
776
|
+
const { result } = renderHook(() => useIsPWA());
|
|
777
|
+
expect(result.current).toBe(false);
|
|
778
|
+
|
|
779
|
+
// Simulate change to standalone
|
|
780
|
+
act(() => {
|
|
781
|
+
listeners['change']({ matches: true } as MediaQueryListEvent);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
expect(result.current).toBe(true);
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
#### 4.4.2 E2E тесты для install flow
|
|
790
|
+
|
|
791
|
+
```typescript
|
|
792
|
+
// e2e/pwa-install.spec.ts
|
|
793
|
+
import { test, expect } from '@playwright/test';
|
|
794
|
+
|
|
795
|
+
test.describe('PWA Install Flow', () => {
|
|
796
|
+
test('iOS Safari should show visual guide', async ({ page, browserName, isMobile }) => {
|
|
797
|
+
test.skip(browserName !== 'webkit' || !isMobile, 'iOS Safari only');
|
|
798
|
+
|
|
799
|
+
await page.goto('/');
|
|
800
|
+
|
|
801
|
+
// Ждем появления hint
|
|
802
|
+
await page.waitForSelector('[data-testid="a2hs-hint"]', { timeout: 5000 });
|
|
803
|
+
|
|
804
|
+
// Кликаем на hint
|
|
805
|
+
await page.click('[data-testid="a2hs-hint"]');
|
|
806
|
+
|
|
807
|
+
// Должен открыться гайд
|
|
808
|
+
await expect(page.locator('[data-testid="ios-guide"]')).toBeVisible();
|
|
809
|
+
|
|
810
|
+
// Проверяем шаги
|
|
811
|
+
await expect(page.locator('text=Tap Share')).toBeVisible();
|
|
812
|
+
await expect(page.locator('text=Add to Home Screen')).toBeVisible();
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
test('Android Chrome should trigger native prompt', async ({ page, browserName, isMobile }) => {
|
|
816
|
+
test.skip(browserName !== 'chromium' || !isMobile, 'Android Chrome only');
|
|
817
|
+
|
|
818
|
+
await page.goto('/');
|
|
819
|
+
|
|
820
|
+
// Эмулируем beforeinstallprompt
|
|
821
|
+
await page.evaluate(() => {
|
|
822
|
+
const event = new Event('beforeinstallprompt');
|
|
823
|
+
(event as any).prompt = async () => {};
|
|
824
|
+
(event as any).userChoice = Promise.resolve({ outcome: 'accepted' });
|
|
825
|
+
window.dispatchEvent(event);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
await page.waitForSelector('[data-testid="a2hs-hint"]');
|
|
829
|
+
|
|
830
|
+
const promptSpy = await page.evaluateHandle(() => {
|
|
831
|
+
return new Promise((resolve) => {
|
|
832
|
+
window.addEventListener('beforeinstallprompt', (e: any) => {
|
|
833
|
+
resolve(e.prompt !== undefined);
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
await page.click('[data-testid="a2hs-hint"]');
|
|
839
|
+
|
|
840
|
+
expect(await promptSpy.jsonValue()).toBe(true);
|
|
841
|
+
});
|
|
842
|
+
});
|
|
843
|
+
```
|
|
844
|
+
|
|
845
|
+
### 4.5 Документация
|
|
846
|
+
|
|
847
|
+
#### 4.5.1 Добавить примеры кастомизации
|
|
848
|
+
|
|
849
|
+
```typescript
|
|
850
|
+
// В README.md добавить:
|
|
851
|
+
|
|
852
|
+
## Кастомизация
|
|
853
|
+
|
|
854
|
+
### Изменение UI hint
|
|
855
|
+
|
|
856
|
+
```tsx
|
|
857
|
+
import { PwaProvider, useInstall } from '@djangocfg/layouts/snippets';
|
|
858
|
+
|
|
859
|
+
function CustomInstallButton() {
|
|
860
|
+
const { canPrompt, install } = useInstall();
|
|
861
|
+
|
|
862
|
+
if (!canPrompt) return null;
|
|
863
|
+
|
|
864
|
+
return (
|
|
865
|
+
<button onClick={install} className="your-custom-class">
|
|
866
|
+
Установить приложение
|
|
867
|
+
</button>
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// В App
|
|
872
|
+
<PwaProvider enabled={true} showInstallHint={false}>
|
|
873
|
+
<CustomInstallButton />
|
|
874
|
+
{children}
|
|
875
|
+
</PwaProvider>
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
### Отслеживание событий установки
|
|
879
|
+
|
|
880
|
+
```tsx
|
|
881
|
+
import { PwaProvider } from '@djangocfg/layouts/snippets';
|
|
882
|
+
|
|
883
|
+
<PwaProvider
|
|
884
|
+
enabled={true}
|
|
885
|
+
onInstallSuccess={() => {
|
|
886
|
+
analytics.track('pwa_installed');
|
|
887
|
+
}}
|
|
888
|
+
onInstallDismiss={() => {
|
|
889
|
+
analytics.track('pwa_install_dismissed');
|
|
890
|
+
}}
|
|
891
|
+
>
|
|
892
|
+
{children}
|
|
893
|
+
</PwaProvider>
|
|
894
|
+
```
|
|
895
|
+
```
|
|
896
|
+
|
|
897
|
+
#### 4.5.2 Добавить troubleshooting секцию
|
|
898
|
+
|
|
899
|
+
```markdown
|
|
900
|
+
## Troubleshooting
|
|
901
|
+
|
|
902
|
+
### isPWA возвращает false после установки
|
|
903
|
+
|
|
904
|
+
**Проблема:** Приложение не определяется как PWA после установки на home screen
|
|
905
|
+
|
|
906
|
+
**Возможные причины:**
|
|
907
|
+
1. Манифест не найден или невалидный
|
|
908
|
+
2. Service Worker не зарегистрирован
|
|
909
|
+
3. Браузер не поддерживает PWA
|
|
910
|
+
|
|
911
|
+
**Решение:**
|
|
912
|
+
```typescript
|
|
913
|
+
// Проверка манифеста
|
|
914
|
+
const manifestLink = document.querySelector('link[rel="manifest"]');
|
|
915
|
+
if (!manifestLink) {
|
|
916
|
+
console.error('Manifest link not found');
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Проверка Service Worker
|
|
920
|
+
if (!('serviceWorker' in navigator)) {
|
|
921
|
+
console.error('Service Worker not supported');
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Проверка display-mode
|
|
925
|
+
const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
|
|
926
|
+
console.log('Is standalone:', isStandalone);
|
|
927
|
+
|
|
928
|
+
// Проверка iOS
|
|
929
|
+
const isIOSStandalone = (navigator as any).standalone === true;
|
|
930
|
+
console.log('iOS standalone:', isIOSStandalone);
|
|
931
|
+
```
|
|
932
|
+
|
|
933
|
+
### Push notifications не работают
|
|
934
|
+
|
|
935
|
+
**Проблема:** Подписка на push-уведомления проваливается
|
|
936
|
+
|
|
937
|
+
**Возможные причины:**
|
|
938
|
+
1. VAPID ключ невалидный или пустой
|
|
939
|
+
2. Service Worker не активен
|
|
940
|
+
3. Браузер блокирует push (privacy settings)
|
|
941
|
+
4. Нет HTTPS
|
|
942
|
+
|
|
943
|
+
**Решение:** См. секцию 3.3 и 3.6
|
|
944
|
+
```
|
|
945
|
+
|
|
946
|
+
---
|
|
947
|
+
|
|
948
|
+
## 5. План рефакторинга
|
|
949
|
+
|
|
950
|
+
### 5.1 Приоритеты
|
|
951
|
+
|
|
952
|
+
#### 🔴 Критические (P0) - выполнить немедленно:
|
|
953
|
+
|
|
954
|
+
1. **Устранить дублирование `isStandalone()`**
|
|
955
|
+
- Создать `utils/platform.ts`
|
|
956
|
+
- Переместить функцию туда
|
|
957
|
+
- Обновить импорты
|
|
958
|
+
- **Время:** 1 час
|
|
959
|
+
- **Риск:** Низкий
|
|
960
|
+
|
|
961
|
+
2. **Убрать diagnostic logging из production**
|
|
962
|
+
- Создать `utils/logger.ts`
|
|
963
|
+
- Заменить все `consola` на `pwaLogger`
|
|
964
|
+
- Добавить условие `NODE_ENV`
|
|
965
|
+
- **Время:** 2 часа
|
|
966
|
+
- **Риск:** Низкий
|
|
967
|
+
|
|
968
|
+
3. **Валидация VAPID ключа**
|
|
969
|
+
- Добавить проверки длины и формата
|
|
970
|
+
- Улучшить error messages
|
|
971
|
+
- **Время:** 2 часа
|
|
972
|
+
- **Риск:** Средний
|
|
973
|
+
|
|
974
|
+
#### 🟡 Важные (P1) - выполнить в ближайшее время:
|
|
975
|
+
|
|
976
|
+
4. **Рефакторинг архитектуры контекстов**
|
|
977
|
+
- Разделить ответственность
|
|
978
|
+
- Упростить композицию
|
|
979
|
+
- **Время:** 1 день
|
|
980
|
+
- **Риск:** Высокий (требует тестирования)
|
|
981
|
+
|
|
982
|
+
5. **Улучшение isPWA для desktop**
|
|
983
|
+
- Добавить проверку платформы
|
|
984
|
+
- Добавить проверку манифеста
|
|
985
|
+
- **Время:** 4 часа
|
|
986
|
+
- **Риск:** Средний
|
|
987
|
+
|
|
988
|
+
6. **Мемоизация platform detection**
|
|
989
|
+
- Добавить кеширование
|
|
990
|
+
- Оптимизация производительности
|
|
991
|
+
- **Время:** 3 часа
|
|
992
|
+
- **Риск:** Низкий
|
|
993
|
+
|
|
994
|
+
#### 🟢 Желательные (P2) - выполнить по возможности:
|
|
995
|
+
|
|
996
|
+
7. **Lazy loading компонентов**
|
|
997
|
+
- Оптимизация bundle size
|
|
998
|
+
- **Время:** 2 часа
|
|
999
|
+
- **Риск:** Низкий
|
|
1000
|
+
|
|
1001
|
+
8. **Unit и E2E тесты**
|
|
1002
|
+
- Покрытие критической функциональности
|
|
1003
|
+
- **Время:** 2 дня
|
|
1004
|
+
- **Риск:** Низкий
|
|
1005
|
+
|
|
1006
|
+
9. **Улучшение документации**
|
|
1007
|
+
- Примеры кастомизации
|
|
1008
|
+
- Troubleshooting секция
|
|
1009
|
+
- **Время:** 1 день
|
|
1010
|
+
- **Риск:** Нет
|
|
1011
|
+
|
|
1012
|
+
### 5.2 Roadmap
|
|
1013
|
+
|
|
1014
|
+
#### Фаза 1: Критические исправления (1 неделя)
|
|
1015
|
+
- [ ] P0: Устранение дублирования кода
|
|
1016
|
+
- [ ] P0: Убрать diagnostic logging
|
|
1017
|
+
- [ ] P0: Валидация VAPID ключа
|
|
1018
|
+
|
|
1019
|
+
#### Фаза 2: Рефакторинг архитектуры (2 недели)
|
|
1020
|
+
- [ ] P1: Рефакторинг контекстов
|
|
1021
|
+
- [ ] P1: Улучшение isPWA
|
|
1022
|
+
- [ ] P1: Мемоизация platform detection
|
|
1023
|
+
- [ ] P2: Lazy loading
|
|
1024
|
+
|
|
1025
|
+
#### Фаза 3: Качество и документация (1 неделя)
|
|
1026
|
+
- [ ] P2: Unit тесты
|
|
1027
|
+
- [ ] P2: E2E тесты
|
|
1028
|
+
- [ ] P2: Улучшение документации
|
|
1029
|
+
|
|
1030
|
+
### 5.3 Breaking Changes
|
|
1031
|
+
|
|
1032
|
+
⚠️ **Внимание:** Следующие изменения могут сломать существующий код
|
|
1033
|
+
|
|
1034
|
+
#### 5.3.1 Изменение API PwaProvider
|
|
1035
|
+
|
|
1036
|
+
**До:**
|
|
1037
|
+
```typescript
|
|
1038
|
+
<PwaProvider
|
|
1039
|
+
enabled={true}
|
|
1040
|
+
pushNotifications={{ vapidPublicKey: '...' }}
|
|
1041
|
+
>
|
|
1042
|
+
{children}
|
|
1043
|
+
</PwaProvider>
|
|
1044
|
+
```
|
|
1045
|
+
|
|
1046
|
+
**После:**
|
|
1047
|
+
```typescript
|
|
1048
|
+
<PwaInstallProvider enabled={true}>
|
|
1049
|
+
<PwaPushProvider vapidPublicKey="...">
|
|
1050
|
+
{children}
|
|
1051
|
+
</PwaPushProvider>
|
|
1052
|
+
</PwaInstallProvider>
|
|
1053
|
+
```
|
|
1054
|
+
|
|
1055
|
+
**Миграция:**
|
|
1056
|
+
1. Обновить импорты
|
|
1057
|
+
2. Разделить конфигурацию на два провайдера
|
|
1058
|
+
3. Обновить типы
|
|
1059
|
+
|
|
1060
|
+
#### 5.3.2 Изменение экспорта утилит
|
|
1061
|
+
|
|
1062
|
+
**До:**
|
|
1063
|
+
```typescript
|
|
1064
|
+
import { useInstall } from '@djangocfg/layouts/snippets';
|
|
1065
|
+
const { isIOS, isAndroid } = useInstall();
|
|
1066
|
+
```
|
|
1067
|
+
|
|
1068
|
+
**После:**
|
|
1069
|
+
```typescript
|
|
1070
|
+
import { usePlatform, useInstall } from '@djangocfg/layouts/snippets';
|
|
1071
|
+
const platform = usePlatform(); // { isIOS, isAndroid, ... }
|
|
1072
|
+
const install = useInstall(); // { install, canPrompt }
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1075
|
+
### 5.4 Стратегия миграции
|
|
1076
|
+
|
|
1077
|
+
#### Подход: Постепенная миграция с deprecation warnings
|
|
1078
|
+
|
|
1079
|
+
```typescript
|
|
1080
|
+
// OLD API - deprecated
|
|
1081
|
+
export function PwaProvider(props: PwaConfig) {
|
|
1082
|
+
if (process.env.NODE_ENV === 'development') {
|
|
1083
|
+
console.warn(
|
|
1084
|
+
'[DEPRECATED] PwaProvider will be split into PwaInstallProvider and PwaPushProvider in v3.0. ' +
|
|
1085
|
+
'See migration guide: https://docs.djangocfg.com/pwa/migration'
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// Обратная совместимость
|
|
1090
|
+
return <PwaProviderLegacy {...props} />;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// NEW API
|
|
1094
|
+
export function PwaInstallProvider(props: InstallConfig) {
|
|
1095
|
+
// Новая реализация
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
export function PwaPushProvider(props: PushConfig) {
|
|
1099
|
+
// Новая реализация
|
|
1100
|
+
}
|
|
1101
|
+
```
|
|
1102
|
+
|
|
1103
|
+
**Этапы:**
|
|
1104
|
+
1. **v2.2.0:** Добавить новые API, сохранить старые с deprecation warnings
|
|
1105
|
+
2. **v2.3.0 - v2.9.0:** Период миграции (6 месяцев)
|
|
1106
|
+
3. **v3.0.0:** Удалить старые API
|
|
1107
|
+
|
|
1108
|
+
---
|
|
1109
|
+
|
|
1110
|
+
## 6. Выводы
|
|
1111
|
+
|
|
1112
|
+
### 6.1 Сильные стороны текущей реализации
|
|
1113
|
+
|
|
1114
|
+
1. ✅ **Унифицированный UX** для iOS и Android
|
|
1115
|
+
2. ✅ **Хорошая документация** (README.md, research.md)
|
|
1116
|
+
3. ✅ **Адаптивность** (drawer на mobile, modal на desktop)
|
|
1117
|
+
4. ✅ **Поддержка push-уведомлений**
|
|
1118
|
+
5. ✅ **TypeScript типизация**
|
|
1119
|
+
|
|
1120
|
+
### 6.2 Критические улучшения
|
|
1121
|
+
|
|
1122
|
+
1. 🔴 **Устранить дублирование кода** (isStandalone)
|
|
1123
|
+
2. 🔴 **Убрать production logging**
|
|
1124
|
+
3. 🔴 **Улучшить валидацию VAPID**
|
|
1125
|
+
4. 🟡 **Упростить архитектуру контекстов**
|
|
1126
|
+
5. 🟡 **Улучшить надежность isPWA для desktop**
|
|
1127
|
+
|
|
1128
|
+
### 6.3 Надежность isPWA: Оценка
|
|
1129
|
+
|
|
1130
|
+
| Критерий | Оценка | Комментарий |
|
|
1131
|
+
|----------|--------|-------------|
|
|
1132
|
+
| **iOS Safari** | ⭐⭐⭐⭐⭐ | Отлично работает |
|
|
1133
|
+
| **Android Chrome** | ⭐⭐⭐⭐⭐ | Отлично работает |
|
|
1134
|
+
| **Desktop Chrome** | ⭐⭐⭐⭐ | Работает, но нужна проверка манифеста |
|
|
1135
|
+
| **Safari macOS** | ⭐⭐⭐ | Требуется дополнительная проверка платформы |
|
|
1136
|
+
| **Edge cases** | ⭐⭐ | Не все покрыты (Chromium-based browsers) |
|
|
1137
|
+
| **Fallback для старых браузеров** | ⭐⭐ | Недостаточно |
|
|
1138
|
+
|
|
1139
|
+
**Общая оценка:** 3.8/5
|
|
1140
|
+
|
|
1141
|
+
**Рекомендация:** Требуется улучшение для покрытия всех edge cases (см. раздел 2.4)
|
|
1142
|
+
|
|
1143
|
+
---
|
|
1144
|
+
|
|
1145
|
+
## 7. Приложения
|
|
1146
|
+
|
|
1147
|
+
### 7.1 Полезные ссылки
|
|
1148
|
+
|
|
1149
|
+
- [Web App Manifest Specification](https://www.w3.org/TR/appmanifest/)
|
|
1150
|
+
- [Service Worker API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)
|
|
1151
|
+
- [Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API)
|
|
1152
|
+
- [Display Modes](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/display-mode)
|
|
1153
|
+
- [VAPID Protocol RFC 8292](https://datatracker.ietf.org/doc/html/rfc8292)
|
|
1154
|
+
|
|
1155
|
+
### 7.2 Инструменты для тестирования
|
|
1156
|
+
|
|
1157
|
+
- **Chrome DevTools:** Application tab → Manifest, Service Workers
|
|
1158
|
+
- **Lighthouse:** PWA audit
|
|
1159
|
+
- **BrowserStack:** Тестирование на реальных устройствах
|
|
1160
|
+
- **ngrok:** Тестирование HTTPS локально
|
|
1161
|
+
|
|
1162
|
+
### 7.3 Контрольный список для production
|
|
1163
|
+
|
|
1164
|
+
- [ ] Валидный manifest.json
|
|
1165
|
+
- [ ] Service Worker зарегистрирован
|
|
1166
|
+
- [ ] HTTPS включен
|
|
1167
|
+
- [ ] VAPID ключи настроены (для push)
|
|
1168
|
+
- [ ] Icons всех размеров (192x192, 512x512)
|
|
1169
|
+
- [ ] Тестирование на iOS Safari
|
|
1170
|
+
- [ ] Тестирование на Android Chrome
|
|
1171
|
+
- [ ] Проверка offline режима
|
|
1172
|
+
- [ ] Проверка display-mode detection
|
|
1173
|
+
- [ ] Production logging отключен
|
|
1174
|
+
|
|
1175
|
+
---
|
|
1176
|
+
|
|
1177
|
+
**Конец документа**
|
|
1178
|
+
|
|
1179
|
+
Дата последнего обновления: 2025-12-15
|