@vibexnpm/talkx 2.3.1
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/README.md +327 -0
- package/dist/index.d.ts +1771 -0
- package/dist/talkflow-sdk.esm.js +2 -0
- package/dist/talkflow-sdk.esm.js.map +1 -0
- package/dist/talkflow-sdk.standalone.js +2 -0
- package/dist/talkflow-sdk.standalone.js.map +1 -0
- package/dist/talkflow-sdk.umd.js +2 -0
- package/dist/talkflow-sdk.umd.js.map +1 -0
- package/package.json +51 -0
- package/src/TalkFlowClient.js +481 -0
- package/src/chat/ChatClient.js +2221 -0
- package/src/constants.js +411 -0
- package/src/core/ConnectionManager.js +517 -0
- package/src/index.js +97 -0
- package/src/push/PushManager.js +893 -0
- package/src/talkflow/delegates.js +112 -0
- package/src/talkflow/eventForwarding.js +93 -0
- package/src/talkflow/session.js +355 -0
- package/src/utils/ApiClient.js +305 -0
- package/src/utils/EventEmitter.js +113 -0
- package/src/utils/Logger.js +88 -0
- package/src/utils/jwtUtils.js +213 -0
- package/src/webrtc/MediaStreamManager.js +478 -0
- package/src/webrtc/PeerConnectionManager.js +467 -0
- package/src/webrtc/WebRTCClient.js +1041 -0
- package/types/index.d.ts +1771 -0
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PushManager
|
|
3
|
+
* FCM 웹 푸시 알림 관리 (Firebase 초기화, 서비스 워커, 권한, 토큰)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import Logger from '../utils/Logger.js';
|
|
7
|
+
import { LogLevel } from '../constants.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 푸시 활성화 실패 사유 분류.
|
|
11
|
+
*
|
|
12
|
+
* <p>호출자가 {@code error.message} 문자열 매칭 대신 {@code error.code} 로 분기할 수 있도록
|
|
13
|
+
* 표준화된 enum 값을 제공한다.</p>
|
|
14
|
+
*
|
|
15
|
+
* <ul>
|
|
16
|
+
* <li>{@code UNSUPPORTED_BROWSER} — Notification / Service Worker 미지원 환경</li>
|
|
17
|
+
* <li>{@code FIREBASE_NOT_INSTALLED} — firebase 패키지 동적 import 실패</li>
|
|
18
|
+
* <li>{@code SW_REGISTER_FAILED} — 서비스 워커 등록 실패 (firebase-messaging-sw.js 누락 등)</li>
|
|
19
|
+
* <li>{@code PERMISSION_DENIED} — 브라우저 알림 권한 거부 (이미 거부 상태 또는 이번 요청에서 거부)</li>
|
|
20
|
+
* <li>{@code TOKEN_FAILED} — FCM 토큰 발급 실패</li>
|
|
21
|
+
* <li>{@code SERVER_REGISTER_FAILED} — TalkFlow 서버에 디바이스 토큰 등록 실패</li>
|
|
22
|
+
* </ul>
|
|
23
|
+
*/
|
|
24
|
+
export const PushErrorCode = Object.freeze({
|
|
25
|
+
UNSUPPORTED_BROWSER: 'UNSUPPORTED_BROWSER',
|
|
26
|
+
FIREBASE_NOT_INSTALLED: 'FIREBASE_NOT_INSTALLED',
|
|
27
|
+
SW_REGISTER_FAILED: 'SW_REGISTER_FAILED',
|
|
28
|
+
PERMISSION_DENIED: 'PERMISSION_DENIED',
|
|
29
|
+
TOKEN_FAILED: 'TOKEN_FAILED',
|
|
30
|
+
SERVER_REGISTER_FAILED: 'SERVER_REGISTER_FAILED'
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 푸시 활성화 과정에서 발생한 분류된 에러.
|
|
35
|
+
*
|
|
36
|
+
* <p>일반 {@link Error} 와 달리 {@code code} 필드로 분류되어 있어
|
|
37
|
+
* 호출자가 사유별로 다른 UX (권한 거부 → 설정 가이드, 토큰 실패 → 재시도 등) 를 적용할 수 있다.</p>
|
|
38
|
+
*/
|
|
39
|
+
export class PushError extends Error {
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} code - {@link PushErrorCode} 값 중 하나
|
|
42
|
+
* @param {string} message - 사람이 읽는 메시지
|
|
43
|
+
* @param {Error} [cause] - 원인 에러 (네트워크 실패 등)
|
|
44
|
+
*/
|
|
45
|
+
constructor(code, message, cause) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = 'PushError';
|
|
48
|
+
this.code = code;
|
|
49
|
+
if (cause !== undefined) {
|
|
50
|
+
this.cause = cause;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// TalkFlow 기본 Firebase 설정 (고객별 분리 시 서버에서 내려받는 구조로 확장 가능)
|
|
56
|
+
const DEFAULT_FIREBASE_CONFIG = {
|
|
57
|
+
apiKey: "AIzaSyB8VhRg6rSvUI7K3Ua7h6sBpLvaQGmIkRc",
|
|
58
|
+
authDomain: "chatting-c3e5d.firebaseapp.com",
|
|
59
|
+
projectId: "chatting-c3e5d",
|
|
60
|
+
storageBucket: "chatting-c3e5d.firebasestorage.app",
|
|
61
|
+
messagingSenderId: "1020496565673",
|
|
62
|
+
appId: "1:1020496565673:web:5039167257fd83f5ce20b8"
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const DEFAULT_VAPID_KEY = 'BGP8qaSm1ntjWd4n9pc0lX_rw4BmMxm9u4pvRIANCitbmYaV0iy-gn05suTKBo88kUBejxdxM8sb6x2nt3avu8c';
|
|
66
|
+
const PENDING_NAVIGATION_DB_NAME = 'talkflowPush';
|
|
67
|
+
const PENDING_NAVIGATION_STORE_NAME = 'pendingNavigation';
|
|
68
|
+
// SW 와 동일한 key 규칙 사용 — 멀티테넌트에서 다른 projectId 의 pending 을 건드리지 않도록 한다.
|
|
69
|
+
const PENDING_NAVIGATION_KEY_PREFIX = 'pending-room';
|
|
70
|
+
const PENDING_NAVIGATION_LEGACY_KEY = 'pending-room';
|
|
71
|
+
|
|
72
|
+
// deviceId 저장 — 같은 IndexedDB 의 별도 store 사용 (XSS 방어 + 기존 인프라 재사용).
|
|
73
|
+
// DB 버전은 2 로 bump — v1 에서 pendingNavigation 만 있던 상태에서 deviceInfo store 추가.
|
|
74
|
+
const DEVICE_STORE_NAME = 'deviceInfo';
|
|
75
|
+
const DEVICE_ID_KEY = 'device-id';
|
|
76
|
+
const DEVICE_ID_DB_VERSION = 2;
|
|
77
|
+
// 기존 localStorage 에 저장된 deviceId 를 IndexedDB 로 옮긴 뒤 제거하기 위한 legacy key.
|
|
78
|
+
const LEGACY_LOCAL_STORAGE_DEVICE_ID_KEY = 'talkflow_device_id';
|
|
79
|
+
|
|
80
|
+
function buildPendingNavigationKey(projectId) {
|
|
81
|
+
if (projectId && typeof projectId === 'string' && projectId.trim() !== '') {
|
|
82
|
+
return PENDING_NAVIGATION_KEY_PREFIX + ':' + projectId;
|
|
83
|
+
}
|
|
84
|
+
return PENDING_NAVIGATION_LEGACY_KEY;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function openPendingNavigationDb() {
|
|
88
|
+
if (typeof indexedDB === 'undefined') {
|
|
89
|
+
return Promise.resolve(null);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const request = indexedDB.open(PENDING_NAVIGATION_DB_NAME, DEVICE_ID_DB_VERSION);
|
|
94
|
+
|
|
95
|
+
request.onupgradeneeded = () => {
|
|
96
|
+
const db = request.result;
|
|
97
|
+
// v1 → v2: deviceInfo store 추가.
|
|
98
|
+
// 기존 pendingNavigation store 는 그대로 유지 (contains 체크로 멱등).
|
|
99
|
+
if (!db.objectStoreNames.contains(PENDING_NAVIGATION_STORE_NAME)) {
|
|
100
|
+
db.createObjectStore(PENDING_NAVIGATION_STORE_NAME, { keyPath: 'id' });
|
|
101
|
+
}
|
|
102
|
+
if (!db.objectStoreNames.contains(DEVICE_STORE_NAME)) {
|
|
103
|
+
db.createObjectStore(DEVICE_STORE_NAME, { keyPath: 'id' });
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
request.onsuccess = () => resolve(request.result);
|
|
108
|
+
request.onerror = () => reject(request.error || new Error('IndexedDB open failed'));
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* IndexedDB 에서 deviceId 를 읽는다.
|
|
114
|
+
* @returns {Promise<string|null>} 저장된 deviceId, 없으면 null. IndexedDB 사용 불가 시 null.
|
|
115
|
+
*/
|
|
116
|
+
function readDeviceIdFromIndexedDb() {
|
|
117
|
+
return openPendingNavigationDb().then(db => {
|
|
118
|
+
if (!db) return null;
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const tx = db.transaction(DEVICE_STORE_NAME, 'readonly');
|
|
121
|
+
const store = tx.objectStore(DEVICE_STORE_NAME);
|
|
122
|
+
const request = store.get(DEVICE_ID_KEY);
|
|
123
|
+
request.onsuccess = () => {
|
|
124
|
+
const record = request.result;
|
|
125
|
+
resolve(record && record.value ? record.value : null);
|
|
126
|
+
};
|
|
127
|
+
request.onerror = () => reject(request.error || new Error('IndexedDB read failed'));
|
|
128
|
+
tx.oncomplete = () => db.close();
|
|
129
|
+
tx.onerror = () => db.close();
|
|
130
|
+
tx.onabort = () => db.close();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* IndexedDB 에 deviceId 를 저장한다.
|
|
137
|
+
*
|
|
138
|
+
* <p><b>반환값 의미</b>:</p>
|
|
139
|
+
* <ul>
|
|
140
|
+
* <li>{@code true} — transaction 이 complete 까지 성공적으로 완료됨.</li>
|
|
141
|
+
* <li>{@code false} — open / transaction 실패, IDB 사용 불가, 기타 예외.</li>
|
|
142
|
+
* </ul>
|
|
143
|
+
*
|
|
144
|
+
* <p>호출자는 반환값이 {@code true} 일 때만 "영속성 확보" 로 간주해야 한다.
|
|
145
|
+
* 특히 legacy localStorage 정리는 read-back 검증까지 거친 뒤 수행.</p>
|
|
146
|
+
*
|
|
147
|
+
* @param {string} deviceId
|
|
148
|
+
* @returns {Promise<boolean>}
|
|
149
|
+
*/
|
|
150
|
+
function writeDeviceIdToIndexedDb(deviceId) {
|
|
151
|
+
return openPendingNavigationDb().then(db => {
|
|
152
|
+
if (!db) return false;
|
|
153
|
+
return new Promise(resolve => {
|
|
154
|
+
try {
|
|
155
|
+
const tx = db.transaction(DEVICE_STORE_NAME, 'readwrite');
|
|
156
|
+
tx.objectStore(DEVICE_STORE_NAME).put({ id: DEVICE_ID_KEY, value: deviceId });
|
|
157
|
+
tx.oncomplete = () => { db.close(); resolve(true); };
|
|
158
|
+
tx.onerror = () => { db.close(); resolve(false); };
|
|
159
|
+
tx.onabort = () => { db.close(); resolve(false); };
|
|
160
|
+
} catch (e) {
|
|
161
|
+
try { db.close(); } catch (_) { /* ignore */ }
|
|
162
|
+
resolve(false);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}).catch(() => false);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 신규 deviceId 생성.
|
|
170
|
+
*/
|
|
171
|
+
function generateDeviceId() {
|
|
172
|
+
const random = (typeof crypto !== 'undefined' && crypto.randomUUID)
|
|
173
|
+
? crypto.randomUUID()
|
|
174
|
+
: Date.now().toString(36) + Math.random().toString(36).substring(2);
|
|
175
|
+
return 'web-' + random;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function consumePendingRoomFromIndexedDB(projectId) {
|
|
179
|
+
const db = await openPendingNavigationDb();
|
|
180
|
+
if (!db) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 1차: projectId 별 key 를 조회한다.
|
|
185
|
+
// 2차 (mixed rollout 호환): projectId 가 있지만 1차 miss 인 경우,
|
|
186
|
+
// legacy 전역 key 도 fallback 으로 조회한다. 서버 배포 전 또는 구버전 SW 에서 발생한
|
|
187
|
+
// projectId 없는 payload 는 legacy key (pending-room) 에 저장되어 있을 수 있다.
|
|
188
|
+
// legacy record 는 반드시 record.projectId 가 비어있어야 안전하게 소비한다 —
|
|
189
|
+
// 다른 프로젝트의 값이 섞일 여지를 원천 차단.
|
|
190
|
+
const primaryKey = buildPendingNavigationKey(projectId);
|
|
191
|
+
const shouldTryLegacyFallback = !!projectId && primaryKey !== PENDING_NAVIGATION_LEGACY_KEY;
|
|
192
|
+
|
|
193
|
+
return new Promise((resolve, reject) => {
|
|
194
|
+
const transaction = db.transaction(PENDING_NAVIGATION_STORE_NAME, 'readwrite');
|
|
195
|
+
const store = transaction.objectStore(PENDING_NAVIGATION_STORE_NAME);
|
|
196
|
+
|
|
197
|
+
let pendingRoomId = null;
|
|
198
|
+
let finalizedKey = null;
|
|
199
|
+
|
|
200
|
+
const primaryRequest = store.get(primaryKey);
|
|
201
|
+
|
|
202
|
+
primaryRequest.onsuccess = () => {
|
|
203
|
+
const record = primaryRequest.result;
|
|
204
|
+
// 저장된 record 의 projectId 와 요청한 projectId 가 일치할 때만 소비.
|
|
205
|
+
// key 분리가 정상 작동하면 이 체크는 리던던트이지만, 레거시 전역 key 로 저장된
|
|
206
|
+
// 레코드를 다른 프로젝트가 실수로 소비하는 것을 막기 위한 방어.
|
|
207
|
+
if (record && record.roomId) {
|
|
208
|
+
const recordProjectId = record.projectId || null;
|
|
209
|
+
const requestedProjectId = projectId || null;
|
|
210
|
+
if (recordProjectId === requestedProjectId) {
|
|
211
|
+
pendingRoomId = record.roomId;
|
|
212
|
+
finalizedKey = primaryKey;
|
|
213
|
+
store.delete(primaryKey);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 1차 miss — legacy fallback 시도 (mixed rollout 호환 경로).
|
|
219
|
+
if (!shouldTryLegacyFallback) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const legacyRequest = store.get(PENDING_NAVIGATION_LEGACY_KEY);
|
|
224
|
+
legacyRequest.onsuccess = () => {
|
|
225
|
+
const legacyRecord = legacyRequest.result;
|
|
226
|
+
// legacy record 는 projectId 가 null/undefined 인 경우에만 안전하게 소비.
|
|
227
|
+
// SW v1 또는 구버전 서버가 projectId 없이 저장한 경우에 해당한다.
|
|
228
|
+
if (
|
|
229
|
+
legacyRecord &&
|
|
230
|
+
legacyRecord.roomId &&
|
|
231
|
+
!legacyRecord.projectId
|
|
232
|
+
) {
|
|
233
|
+
pendingRoomId = legacyRecord.roomId;
|
|
234
|
+
finalizedKey = PENDING_NAVIGATION_LEGACY_KEY;
|
|
235
|
+
store.delete(PENDING_NAVIGATION_LEGACY_KEY);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
// legacyRequest.onerror 는 별도 처리하지 않는다 — primary 가 성공했다면 transaction 은
|
|
239
|
+
// 이미 유효하고, 실패하면 transaction.onerror 로 넘어간다.
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
primaryRequest.onerror = () => {
|
|
243
|
+
reject(primaryRequest.error || new Error('IndexedDB read failed'));
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
transaction.oncomplete = () => {
|
|
247
|
+
db.close();
|
|
248
|
+
// finalizedKey 를 쓰진 않지만, 추후 디버깅/로그 확장 시 어느 key 를 소비했는지 구분 가능.
|
|
249
|
+
void finalizedKey;
|
|
250
|
+
resolve(pendingRoomId);
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
transaction.onerror = () => {
|
|
254
|
+
db.close();
|
|
255
|
+
reject(transaction.error || new Error('IndexedDB transaction failed'));
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
transaction.onabort = () => {
|
|
259
|
+
db.close();
|
|
260
|
+
reject(transaction.error || new Error('IndexedDB transaction aborted'));
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
class PushManager {
|
|
266
|
+
/**
|
|
267
|
+
* @param {Object} options
|
|
268
|
+
* @param {Object} options.apiClient - ApiClient 인스턴스
|
|
269
|
+
* @param {string} [options.projectId] - 프로젝트 ID (멀티테넌트 환경에서 알림 라우팅 격리용)
|
|
270
|
+
* @param {Object} [options.firebaseConfig] - Firebase 설정 (기본: TalkFlow 설정)
|
|
271
|
+
* @param {string} [options.vapidKey] - VAPID Key (기본: TalkFlow 키)
|
|
272
|
+
* @param {string} [options.serviceWorkerPath] - 서비스 워커 경로 (기본: '/firebase-messaging-sw.js')
|
|
273
|
+
* @param {number} [options.logLevel] - 로그 레벨
|
|
274
|
+
*/
|
|
275
|
+
constructor(options) {
|
|
276
|
+
this.apiClient = options.apiClient;
|
|
277
|
+
this.projectId = options.projectId || null;
|
|
278
|
+
this.firebaseConfig = options.firebaseConfig || DEFAULT_FIREBASE_CONFIG;
|
|
279
|
+
this.vapidKey = options.vapidKey || DEFAULT_VAPID_KEY;
|
|
280
|
+
this.serviceWorkerPath = options.serviceWorkerPath || '/firebase-messaging-sw.js';
|
|
281
|
+
this.logger = new Logger(options.logLevel || LogLevel.WARN, 'PushManager');
|
|
282
|
+
|
|
283
|
+
this._messaging = null;
|
|
284
|
+
this._currentToken = null;
|
|
285
|
+
this._enabled = false;
|
|
286
|
+
this._enablePromise = null;
|
|
287
|
+
this._foregroundMessageUnsubscribe = null;
|
|
288
|
+
this._swRegistration = null;
|
|
289
|
+
this._projectRegisteredToSW = false;
|
|
290
|
+
this._visibilityHandler = null;
|
|
291
|
+
this._deviceIdCache = null; // IndexedDB 조회 결과 메모리 캐시 (세션 단위)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* 저장된 pending roomId 를 소비한다.
|
|
296
|
+
*
|
|
297
|
+
* @param {string} [projectId] - 소비할 projectId. 지정 시 해당 projectId 로 저장된 pending 만 소비.
|
|
298
|
+
* 미지정 시 레거시 전역 key 만 조회 (구버전 호환).
|
|
299
|
+
* @returns {Promise<string|null>}
|
|
300
|
+
*/
|
|
301
|
+
static async consumePendingRoom(projectId) {
|
|
302
|
+
return consumePendingRoomFromIndexedDB(projectId);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* 현재 브라우저의 푸시 권한 상태를 조회한다.
|
|
307
|
+
*
|
|
308
|
+
* <p>{@link PushManager} 인스턴스가 없어도 (즉, {@code enablePushNotifications()} 호출 전) 호출 가능하다.
|
|
309
|
+
* 호출자가 enable 호출 <b>전에</b> UI 분기를 결정할 때 사용한다.</p>
|
|
310
|
+
*
|
|
311
|
+
* <p>반환값:</p>
|
|
312
|
+
* <ul>
|
|
313
|
+
* <li>{@code 'granted'} — 이미 허용. enable 호출 시 권한창 안 뜨고 토큰 발급으로 즉시 진행.</li>
|
|
314
|
+
* <li>{@code 'default'} — 아직 묻지 않음. enable 호출 시 권한창 표시.</li>
|
|
315
|
+
* <li>{@code 'denied'} — 거부됨. enable 호출 시 즉시 PERMISSION_DENIED 로 실패 (브라우저 정책상 권한창 안 뜸).</li>
|
|
316
|
+
* <li>{@code 'unsupported'} — Notification 또는 Service Worker 미지원 환경 (Safari iOS 등).</li>
|
|
317
|
+
* </ul>
|
|
318
|
+
*
|
|
319
|
+
* @returns {'granted'|'denied'|'default'|'unsupported'}
|
|
320
|
+
*/
|
|
321
|
+
static getPermissionState() {
|
|
322
|
+
if (typeof window === 'undefined' || typeof Notification === 'undefined') {
|
|
323
|
+
return 'unsupported';
|
|
324
|
+
}
|
|
325
|
+
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
|
|
326
|
+
return 'unsupported';
|
|
327
|
+
}
|
|
328
|
+
return Notification.permission; // 'granted' | 'denied' | 'default'
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* 푸시 알림 활성화.
|
|
333
|
+
* Firebase 초기화 → 서비스 워커 등록 → 권한 요청 → 토큰 획득 → 서버 등록.
|
|
334
|
+
*
|
|
335
|
+
* <p>토큰은 SDK 내부에서만 관리되므로 호출자는 반환값을 받을 필요가 없습니다.
|
|
336
|
+
* 상태 확인은 {@link #isEnabled} 로, 에러는 throw 로 전달됩니다.</p>
|
|
337
|
+
*
|
|
338
|
+
* @returns {Promise<void>}
|
|
339
|
+
* @throws {Error} 브라우저 미지원, 권한 거부, Firebase 미설치 등
|
|
340
|
+
*/
|
|
341
|
+
async enable() {
|
|
342
|
+
if (this._enabled) {
|
|
343
|
+
this.logger.debug('푸시 알림이 이미 활성화되어 있습니다.');
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (this._enablePromise) {
|
|
348
|
+
this.logger.debug('푸시 알림 활성화가 이미 진행 중입니다.');
|
|
349
|
+
return this._enablePromise;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
this._enablePromise = this._enableInternal();
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
return await this._enablePromise;
|
|
356
|
+
} finally {
|
|
357
|
+
this._enablePromise = null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async _enableInternal() {
|
|
362
|
+
// 브라우저 환경 체크
|
|
363
|
+
if (typeof window === 'undefined' || !('Notification' in window)) {
|
|
364
|
+
throw new PushError(
|
|
365
|
+
PushErrorCode.UNSUPPORTED_BROWSER,
|
|
366
|
+
'푸시 알림은 브라우저 환경에서만 사용 가능합니다'
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (!('serviceWorker' in navigator)) {
|
|
371
|
+
throw new PushError(
|
|
372
|
+
PushErrorCode.UNSUPPORTED_BROWSER,
|
|
373
|
+
'이 브라우저는 서비스 워커를 지원하지 않습니다'
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// 권한이 이미 거부된 상태면 requestPermission() 은 즉시 'denied' 를 반환하고
|
|
378
|
+
// 권한창은 뜨지 않는다. 명확한 분류 에러로 즉시 throw 하여 호출자가
|
|
379
|
+
// "브라우저 설정에서 풀어달라" 가이드를 띄울 수 있도록 한다.
|
|
380
|
+
if (Notification.permission === 'denied') {
|
|
381
|
+
throw new PushError(
|
|
382
|
+
PushErrorCode.PERMISSION_DENIED,
|
|
383
|
+
'알림 권한이 이미 거부되어 있습니다. 브라우저 사이트 설정에서 허용해주세요.'
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Firebase 동적 로드
|
|
388
|
+
let firebaseApp, firebaseMessaging;
|
|
389
|
+
try {
|
|
390
|
+
firebaseApp = await import('firebase/app');
|
|
391
|
+
firebaseMessaging = await import('firebase/messaging');
|
|
392
|
+
} catch (e) {
|
|
393
|
+
throw new PushError(
|
|
394
|
+
PushErrorCode.FIREBASE_NOT_INSTALLED,
|
|
395
|
+
'firebase 패키지가 설치되지 않았습니다. npm install firebase 를 실행하세요.',
|
|
396
|
+
e
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Firebase 초기화 (이미 초기화된 경우 기존 앱 사용)
|
|
401
|
+
// 고객 앱의 기존 Firebase와 충돌 방지 — named app으로 분리
|
|
402
|
+
let app;
|
|
403
|
+
try {
|
|
404
|
+
app = firebaseApp.getApp('talkflow');
|
|
405
|
+
} catch {
|
|
406
|
+
app = firebaseApp.initializeApp(this.firebaseConfig, 'talkflow');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// 서비스 워커 등록
|
|
410
|
+
const registration = await this._registerServiceWorker();
|
|
411
|
+
|
|
412
|
+
// 알림 권한 요청
|
|
413
|
+
// - 'default' 상태: 브라우저가 권한창을 표시하고 사용자 응답을 대기
|
|
414
|
+
// - 'granted' 상태: 즉시 'granted' 반환 (창 안 뜸)
|
|
415
|
+
// - 'denied' 상태: 위 가드에서 이미 throw 했으므로 여기 도달 불가
|
|
416
|
+
const permission = await Notification.requestPermission();
|
|
417
|
+
if (permission !== 'granted') {
|
|
418
|
+
throw new PushError(
|
|
419
|
+
PushErrorCode.PERMISSION_DENIED,
|
|
420
|
+
'알림 권한이 거부되었습니다. 브라우저 설정에서 허용해주세요.'
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// FCM 토큰 획득
|
|
425
|
+
this._messaging = firebaseMessaging.getMessaging(app);
|
|
426
|
+
|
|
427
|
+
const tokenOptions = { serviceWorkerRegistration: registration };
|
|
428
|
+
if (this.vapidKey) {
|
|
429
|
+
tokenOptions.vapidKey = this.vapidKey;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
try {
|
|
433
|
+
this._currentToken = await firebaseMessaging.getToken(this._messaging, tokenOptions);
|
|
434
|
+
} catch (e) {
|
|
435
|
+
throw new PushError(
|
|
436
|
+
PushErrorCode.TOKEN_FAILED,
|
|
437
|
+
'FCM 토큰 획득 중 오류 발생: ' + (e?.message || e),
|
|
438
|
+
e
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (!this._currentToken) {
|
|
443
|
+
throw new PushError(
|
|
444
|
+
PushErrorCode.TOKEN_FAILED,
|
|
445
|
+
'FCM 토큰 획득 실패 (응답이 비어있음)'
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// 서버에 토큰 등록
|
|
450
|
+
await this._registerTokenToServer(this._currentToken);
|
|
451
|
+
|
|
452
|
+
// 포그라운드 메시지 수신 핸들러
|
|
453
|
+
if (this._foregroundMessageUnsubscribe) {
|
|
454
|
+
this._foregroundMessageUnsubscribe();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
this._foregroundMessageUnsubscribe = firebaseMessaging.onMessage(this._messaging, (payload) => {
|
|
458
|
+
this.logger.debug('포그라운드 푸시 수신:', payload);
|
|
459
|
+
this._onForegroundMessage(payload);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// SW 등록 객체를 보관 — 이후 projectId 재등록에 사용한다.
|
|
463
|
+
this._swRegistration = registration;
|
|
464
|
+
|
|
465
|
+
// SW 에 projectId 등록.
|
|
466
|
+
// 멀티테넌트 환경에서 notificationclick 시 SW 가 "어느 창이 어느 project 인지"를 알아야
|
|
467
|
+
// 다른 프로젝트 창에 NAVIGATE_TO_ROOM 을 보내지 않는다.
|
|
468
|
+
await this._registerProjectToSW();
|
|
469
|
+
|
|
470
|
+
// SW terminate 로 Map 이 소실될 수 있으므로 visibility 복귀 시 재등록.
|
|
471
|
+
this._installVisibilityReRegister();
|
|
472
|
+
|
|
473
|
+
// SW 업데이트 체크 (CDN handler 갱신 전파)
|
|
474
|
+
try {
|
|
475
|
+
await registration.update();
|
|
476
|
+
} catch (e) {
|
|
477
|
+
this.logger.debug('SW 업데이트 체크 실패 (무시):', e.message);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// SW 버전 감지 — 구버전 경고 용도로만 사용.
|
|
481
|
+
// 이 값으로 서버/SDK 가 기능 분기(image 지원 여부 등)를 판단하면 안 된다.
|
|
482
|
+
const swVersion = await this._getSWVersion(registration);
|
|
483
|
+
if (!swVersion) {
|
|
484
|
+
this.logger.warn(
|
|
485
|
+
'[TalkFlow] 서비스 워커가 구버전(v1)입니다. ' +
|
|
486
|
+
'이미지 미리보기 등 리치 알림 기능을 사용하려면 firebase-messaging-sw.js 를 ' +
|
|
487
|
+
'v2 로 업데이트하세요. 가이드: https://docs.talkflow.ai/push/sw-upgrade'
|
|
488
|
+
);
|
|
489
|
+
} else {
|
|
490
|
+
this.logger.debug('SW 버전:', swVersion);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
this._enabled = true;
|
|
494
|
+
this.logger.info('푸시 알림 활성화 완료');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* 서비스 워커 등록.
|
|
499
|
+
* updateViaCache: 'none' 으로 등록하여 registration.update() 시
|
|
500
|
+
* importScripts 로 로드하는 CDN handler 까지 캐시 없이 갱신되도록 한다.
|
|
501
|
+
* https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register
|
|
502
|
+
* @private
|
|
503
|
+
*/
|
|
504
|
+
async _registerServiceWorker() {
|
|
505
|
+
try {
|
|
506
|
+
const registration = await navigator.serviceWorker.register(
|
|
507
|
+
this.serviceWorkerPath,
|
|
508
|
+
{ updateViaCache: 'none' }
|
|
509
|
+
);
|
|
510
|
+
this.logger.debug('서비스 워커 등록 완료:', this.serviceWorkerPath);
|
|
511
|
+
return registration;
|
|
512
|
+
} catch (error) {
|
|
513
|
+
throw new PushError(
|
|
514
|
+
PushErrorCode.SW_REGISTER_FAILED,
|
|
515
|
+
`서비스 워커 등록 실패: ${this.serviceWorkerPath}\n` +
|
|
516
|
+
'프로젝트 public 폴더에 firebase-messaging-sw.js 파일을 배치하세요.',
|
|
517
|
+
error
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* 서버에 디바이스 토큰 등록
|
|
524
|
+
* @private
|
|
525
|
+
*/
|
|
526
|
+
async _registerTokenToServer(token) {
|
|
527
|
+
try {
|
|
528
|
+
const deviceId = await this._getDeviceId();
|
|
529
|
+
await this.apiClient.post('/api/v1/push/devices', {
|
|
530
|
+
deviceToken: token,
|
|
531
|
+
deviceType: 'WEB',
|
|
532
|
+
deviceId
|
|
533
|
+
});
|
|
534
|
+
this.logger.info('디바이스 토큰 서버 등록 완료');
|
|
535
|
+
} catch (error) {
|
|
536
|
+
this.logger.error('디바이스 토큰 서버 등록 실패:', error);
|
|
537
|
+
// PushError 가 아닌 경우 (네트워크 / 서버 5xx 등) SERVER_REGISTER_FAILED 로 wrap.
|
|
538
|
+
// 이미 PushError 면 그대로 전파 (분류 보존).
|
|
539
|
+
if (error instanceof PushError) {
|
|
540
|
+
throw error;
|
|
541
|
+
}
|
|
542
|
+
throw new PushError(
|
|
543
|
+
PushErrorCode.SERVER_REGISTER_FAILED,
|
|
544
|
+
'디바이스 토큰 서버 등록 실패: ' + (error?.message || error),
|
|
545
|
+
error
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* 브라우저 고유 식별자 조회/생성 (IndexedDB 기반).
|
|
552
|
+
*
|
|
553
|
+
* <p>순서:</p>
|
|
554
|
+
* <ol>
|
|
555
|
+
* <li>IndexedDB 에서 기존 deviceId 조회</li>
|
|
556
|
+
* <li>없으면 localStorage 에 있는 legacy 값 있는지 확인 (기존 SDK 버전 설치 이력)</li>
|
|
557
|
+
* <li>legacy 있으면 IndexedDB 로 이관 + localStorage 키 제거</li>
|
|
558
|
+
* <li>둘 다 없으면 신규 생성 + IndexedDB 저장</li>
|
|
559
|
+
* </ol>
|
|
560
|
+
*
|
|
561
|
+
* <p>IndexedDB 사용 불가 환경 (private mode 등) 에서는 in-memory fallback — 세션 단위
|
|
562
|
+
* 새 deviceId 사용. 완벽한 영속성은 없지만 기본 기능은 유지.</p>
|
|
563
|
+
*
|
|
564
|
+
* @private
|
|
565
|
+
* @returns {Promise<string>}
|
|
566
|
+
*/
|
|
567
|
+
async _getDeviceId() {
|
|
568
|
+
// 메모리 캐시 — 동일 세션 내 반복 호출 방지.
|
|
569
|
+
if (this._deviceIdCache) {
|
|
570
|
+
return this._deviceIdCache;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
try {
|
|
574
|
+
// 1) IndexedDB 에서 조회
|
|
575
|
+
let deviceId = await readDeviceIdFromIndexedDb();
|
|
576
|
+
|
|
577
|
+
// 2) 없으면 localStorage legacy 값 확인 + 이관
|
|
578
|
+
if (!deviceId && typeof localStorage !== 'undefined') {
|
|
579
|
+
const legacy = localStorage.getItem(LEGACY_LOCAL_STORAGE_DEVICE_ID_KEY);
|
|
580
|
+
if (legacy) {
|
|
581
|
+
deviceId = legacy;
|
|
582
|
+
// IDB 저장 성공 + read-back 검증 둘 다 통과한 경우에만 localStorage 정리.
|
|
583
|
+
// 실패하거나 검증 miss 면 legacy 값 보존해 다음 방문에 재시도 가능.
|
|
584
|
+
const saved = await writeDeviceIdToIndexedDb(deviceId);
|
|
585
|
+
if (saved) {
|
|
586
|
+
const readBack = await readDeviceIdFromIndexedDb();
|
|
587
|
+
if (readBack === deviceId) {
|
|
588
|
+
try {
|
|
589
|
+
localStorage.removeItem(LEGACY_LOCAL_STORAGE_DEVICE_ID_KEY);
|
|
590
|
+
this.logger.debug('deviceId migrated from localStorage to IndexedDB');
|
|
591
|
+
} catch (_) { /* quota/private mode 예외 무시 — legacy 남아도 다음에 재이관 시도 */ }
|
|
592
|
+
} else {
|
|
593
|
+
// 저장 tx 는 complete 됐는데 read-back 은 다르게 나오는 극단 케이스.
|
|
594
|
+
// localStorage 보존 — 다음 방문에 재시도.
|
|
595
|
+
this.logger.warn('deviceId IDB read-back 불일치, localStorage 보존');
|
|
596
|
+
}
|
|
597
|
+
} else {
|
|
598
|
+
this.logger.warn('deviceId IDB 저장 실패 (private mode / quota 등), localStorage 보존');
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// 3) 그래도 없으면 신규 생성 + 저장 시도.
|
|
604
|
+
// 저장 실패해도 in-memory 캐시로 세션 단위 유효하므로 푸시 등록은 진행됨.
|
|
605
|
+
// (영속화 실패는 다음 방문에 새 deviceId 생성 → 서버 쪽 device 매핑 갱신은 불가피)
|
|
606
|
+
if (!deviceId) {
|
|
607
|
+
deviceId = generateDeviceId();
|
|
608
|
+
const saved = await writeDeviceIdToIndexedDb(deviceId);
|
|
609
|
+
if (!saved) {
|
|
610
|
+
this.logger.warn('신규 deviceId IDB 저장 실패, 이번 세션만 유효');
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
this._deviceIdCache = deviceId;
|
|
615
|
+
return deviceId;
|
|
616
|
+
} catch (error) {
|
|
617
|
+
// IndexedDB 장애 (open / read / transaction throw 등) 시 fallback.
|
|
618
|
+
// IDB 가 현재 실패하므로 localStorage 를 삭제하지 않는다 — 다음 방문에 IDB 회복되면
|
|
619
|
+
// try 블록의 이관 경로가 다시 시도됨.
|
|
620
|
+
this.logger.warn('IndexedDB deviceId 조회/저장 실패, fallback:', error);
|
|
621
|
+
if (!this._deviceIdCache) {
|
|
622
|
+
// 1) localStorage legacy 우선 확인 — 기존 사용자 device 연속성 유지.
|
|
623
|
+
// IDB 가 실패해도 localStorage 가 읽히면 기존 deviceId 재사용.
|
|
624
|
+
let fallbackId = null;
|
|
625
|
+
if (typeof localStorage !== 'undefined') {
|
|
626
|
+
try {
|
|
627
|
+
fallbackId = localStorage.getItem(LEGACY_LOCAL_STORAGE_DEVICE_ID_KEY);
|
|
628
|
+
} catch (_) { /* private mode 등 — localStorage 도 접근 불가 */ }
|
|
629
|
+
}
|
|
630
|
+
// 2) legacy 도 없으면 신규 생성 (in-memory 단일 세션).
|
|
631
|
+
this._deviceIdCache = fallbackId || generateDeviceId();
|
|
632
|
+
}
|
|
633
|
+
return this._deviceIdCache;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* 포그라운드 메시지 수신 콜백.
|
|
639
|
+
* TalkFlowClient에서 오버라이드하여 activeRoom 기반 suppress 처리.
|
|
640
|
+
* @param {Object} payload - FCM 메시지 페이로드
|
|
641
|
+
*/
|
|
642
|
+
_onForegroundMessage(payload) {
|
|
643
|
+
// TalkFlowClient에서 오버라이드
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* 서비스 워커가 cold start 시 저장한 pending roomId 를 소비하고 삭제한다.
|
|
648
|
+
* 앱 부팅 시 호출하여 알림 클릭으로 열려야 할 방으로 라우팅할 때 사용한다.
|
|
649
|
+
*
|
|
650
|
+
* <p>내부 projectId 가 설정되어 있으면 해당 프로젝트의 pending 만 소비한다.
|
|
651
|
+
* 다른 프로젝트의 pending 은 건드리지 않아 그 프로젝트가 나중에 꺼낼 수 있다.</p>
|
|
652
|
+
*
|
|
653
|
+
* @returns {Promise<string|null>} pending roomId 또는 null
|
|
654
|
+
*/
|
|
655
|
+
async consumePendingRoom() {
|
|
656
|
+
return PushManager.consumePendingRoom(this.projectId);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* 현재 푸시 상태를 정리한다.
|
|
661
|
+
* 로그아웃 또는 다른 사용자로 전환할 때 foreground listener 와 활성화 상태를 초기화한다.
|
|
662
|
+
* SW 에 등록된 projectId 도 해제한다.
|
|
663
|
+
*/
|
|
664
|
+
reset() {
|
|
665
|
+
if (this._foregroundMessageUnsubscribe) {
|
|
666
|
+
try {
|
|
667
|
+
this._foregroundMessageUnsubscribe();
|
|
668
|
+
} catch (error) {
|
|
669
|
+
this.logger.debug('포그라운드 푸시 리스너 해제 실패 (무시):', error?.message || error);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// visibility 재등록 핸들러 제거 — reset 이후 새 enable 전까지 자동 재등록이 일어나지 않도록.
|
|
674
|
+
this._uninstallVisibilityReRegister();
|
|
675
|
+
|
|
676
|
+
// SW 의 projectId 등록도 해제 (best-effort).
|
|
677
|
+
this._unregisterProjectFromSW();
|
|
678
|
+
|
|
679
|
+
this._foregroundMessageUnsubscribe = null;
|
|
680
|
+
this._enablePromise = null;
|
|
681
|
+
this._messaging = null;
|
|
682
|
+
this._currentToken = null;
|
|
683
|
+
this._enabled = false;
|
|
684
|
+
this._swRegistration = null;
|
|
685
|
+
this._projectRegisteredToSW = false;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* 활성 SW 에 현재 projectId 를 등록한다.
|
|
690
|
+
* SW 는 Client.id → projectId Map 을 유지해 notificationclick 시 올바른 창을 선택한다.
|
|
691
|
+
* @private
|
|
692
|
+
*/
|
|
693
|
+
async _registerProjectToSW() {
|
|
694
|
+
if (!this.projectId) {
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
try {
|
|
698
|
+
const readyRegistration = await navigator.serviceWorker.ready;
|
|
699
|
+
const sw =
|
|
700
|
+
readyRegistration.active ||
|
|
701
|
+
(this._swRegistration && this._swRegistration.active);
|
|
702
|
+
if (!sw) {
|
|
703
|
+
this.logger.debug('SW active worker 없음 — projectId 등록 스킵');
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
sw.postMessage({ type: 'TALKFLOW_REGISTER_PROJECT', projectId: this.projectId });
|
|
707
|
+
this._projectRegisteredToSW = true;
|
|
708
|
+
} catch (error) {
|
|
709
|
+
this.logger.debug('SW projectId 등록 실패 (무시):', error?.message || error);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* SW 에 등록된 projectId 해제 (best-effort).
|
|
715
|
+
* reset 시 호출되며, navigator.serviceWorker 접근이 실패해도 예외를 삼킨다.
|
|
716
|
+
* @private
|
|
717
|
+
*/
|
|
718
|
+
_unregisterProjectFromSW() {
|
|
719
|
+
if (!this._projectRegisteredToSW) {
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
try {
|
|
723
|
+
if (typeof navigator === 'undefined' || !navigator.serviceWorker) {
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
const controller = navigator.serviceWorker.controller;
|
|
727
|
+
if (!controller) {
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
controller.postMessage({ type: 'TALKFLOW_UNREGISTER_PROJECT' });
|
|
731
|
+
} catch (error) {
|
|
732
|
+
this.logger.debug('SW projectId 해제 실패 (무시):', error?.message || error);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* visibility 복귀 시 SW 에 projectId 를 재등록한다.
|
|
738
|
+
* SW 는 idle 시 terminate 되며 이때 clientProjectMap 이 소실되므로,
|
|
739
|
+
* 창이 다시 visible 상태가 될 때 방어적으로 재등록한다.
|
|
740
|
+
* @private
|
|
741
|
+
*/
|
|
742
|
+
_installVisibilityReRegister() {
|
|
743
|
+
if (typeof document === 'undefined' || this._visibilityHandler) {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
this._visibilityHandler = () => {
|
|
747
|
+
if (document.visibilityState === 'visible' && this._enabled && this.projectId) {
|
|
748
|
+
// best-effort — 재등록 실패는 다음 visibility 이벤트에 다시 시도된다.
|
|
749
|
+
this._registerProjectToSW().catch(() => {});
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
document.addEventListener('visibilitychange', this._visibilityHandler);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* @private
|
|
757
|
+
*/
|
|
758
|
+
_uninstallVisibilityReRegister() {
|
|
759
|
+
if (typeof document === 'undefined' || !this._visibilityHandler) {
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
try {
|
|
763
|
+
document.removeEventListener('visibilitychange', this._visibilityHandler);
|
|
764
|
+
} catch (error) {
|
|
765
|
+
// 무시
|
|
766
|
+
}
|
|
767
|
+
this._visibilityHandler = null;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// noinspection JSUnusedGlobalSymbols
|
|
771
|
+
/**
|
|
772
|
+
* 현재 FCM 토큰
|
|
773
|
+
* @returns {string|null}
|
|
774
|
+
*/
|
|
775
|
+
getToken() {
|
|
776
|
+
return this._currentToken;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* 푸시 활성화 여부
|
|
781
|
+
* @returns {boolean}
|
|
782
|
+
*/
|
|
783
|
+
isEnabled() {
|
|
784
|
+
return this._enabled;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* 현재 사용자의 등록된 디바이스 목록 조회 (푸시 on/off UI 용).
|
|
789
|
+
*
|
|
790
|
+
* <p>서버가 내려주는 필드: {@code deviceId}, {@code deviceType}, {@code enabled}, {@code createdAt}.
|
|
791
|
+
* {@code deviceToken} 은 보안상 응답에 포함되지 않는다.</p>
|
|
792
|
+
*
|
|
793
|
+
* @returns {Promise<Array<{deviceId: string, deviceType: string, enabled: boolean, createdAt: string}>>}
|
|
794
|
+
*/
|
|
795
|
+
async getMyDevices() {
|
|
796
|
+
const response = await this.apiClient.get('/api/v1/push/devices/me');
|
|
797
|
+
return response && typeof response === 'object' && 'data' in response
|
|
798
|
+
? response.data
|
|
799
|
+
: response;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* 특정 디바이스의 푸시 수신 여부 토글.
|
|
804
|
+
*
|
|
805
|
+
* <p>본인 소유 디바이스만 수정 가능 (서버가 {@code (projectId, userId, deviceId)} 조합으로 검증).
|
|
806
|
+
* 소유자 불일치 또는 미등록 디바이스 시 서버가 404 로 응답 → 호출자에 throw.</p>
|
|
807
|
+
*
|
|
808
|
+
* @param {string} deviceId - 대상 디바이스 ID (현재 디바이스면 {@link #setCurrentDeviceEnabled} 사용 권장)
|
|
809
|
+
* @param {boolean} enabled - true 면 수신, false 면 수신 중단
|
|
810
|
+
* @returns {Promise<void>}
|
|
811
|
+
*/
|
|
812
|
+
async setDeviceEnabled(deviceId, enabled) {
|
|
813
|
+
await this.apiClient.patch('/api/v1/push/devices/me/enabled', {
|
|
814
|
+
deviceId,
|
|
815
|
+
enabled
|
|
816
|
+
});
|
|
817
|
+
this.logger.info(`디바이스 푸시 enabled=${enabled}: deviceId=${deviceId}`);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* 현재 디바이스의 푸시 수신 여부 토글 (편의 메서드).
|
|
822
|
+
*
|
|
823
|
+
* <p>SDK 가 관리하는 현재 브라우저의 deviceId 로 {@link #setDeviceEnabled} 를 호출한다.
|
|
824
|
+
* {@link #enable} 이 선행되지 않아도 호출 가능 — 서버에 디바이스가 등록돼 있으면 동작.</p>
|
|
825
|
+
*
|
|
826
|
+
* @param {boolean} enabled
|
|
827
|
+
* @returns {Promise<void>}
|
|
828
|
+
*/
|
|
829
|
+
async setCurrentDeviceEnabled(enabled) {
|
|
830
|
+
const deviceId = await this._getDeviceId();
|
|
831
|
+
await this.setDeviceEnabled(deviceId, enabled);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* 등록된 서비스 워커의 TalkFlow 버전을 조회.
|
|
836
|
+
*
|
|
837
|
+
* navigator.serviceWorker.ready 를 기다려 active worker 가 보장된 상태에서,
|
|
838
|
+
* readyRegistration → register() 반환 registration 양쪽의
|
|
839
|
+
* waiting → installing → active 순으로 SW 를 찾는다.
|
|
840
|
+
* v1→v2 교체 직후 새 SW 가 waiting/installing 상태에 있을 때
|
|
841
|
+
* 구 active(v1) 대신 신규(v2)를 우선 감지하여 거짓 경고를 방지한다.
|
|
842
|
+
* 같은 scope 에서는 두 객체가 동일 참조이지만, 타이밍 차이에 대한 방어 코드.
|
|
843
|
+
*
|
|
844
|
+
* v1(구버전) SW 는 메시지 핸들러가 없으므로 2초 timeout 후 null 반환.
|
|
845
|
+
*
|
|
846
|
+
* @param {ServiceWorkerRegistration} registration - register() 가 반환한 등록 객체
|
|
847
|
+
* @returns {Promise<string|null>} 버전 문자열 또는 null (구버전/미응답)
|
|
848
|
+
* @private
|
|
849
|
+
*/
|
|
850
|
+
async _getSWVersion(registration) {
|
|
851
|
+
try {
|
|
852
|
+
const readyRegistration = await navigator.serviceWorker.ready;
|
|
853
|
+
|
|
854
|
+
const sw =
|
|
855
|
+
readyRegistration.waiting ||
|
|
856
|
+
readyRegistration.installing ||
|
|
857
|
+
readyRegistration.active ||
|
|
858
|
+
registration.waiting ||
|
|
859
|
+
registration.installing ||
|
|
860
|
+
registration.active;
|
|
861
|
+
if (!sw) return null;
|
|
862
|
+
|
|
863
|
+
return new Promise((resolve) => {
|
|
864
|
+
const timeout = setTimeout(() => resolve(null), 2000);
|
|
865
|
+
|
|
866
|
+
const channel = new MessageChannel();
|
|
867
|
+
channel.port1.onmessage = (event) => {
|
|
868
|
+
clearTimeout(timeout);
|
|
869
|
+
resolve(event.data?.version || null);
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
try {
|
|
873
|
+
sw.postMessage({ type: 'TALKFLOW_SW_VERSION' }, [channel.port2]);
|
|
874
|
+
} catch (e) {
|
|
875
|
+
clearTimeout(timeout);
|
|
876
|
+
resolve(null);
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
} catch {
|
|
880
|
+
return null;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* 로그 레벨 설정
|
|
886
|
+
* @param {number} level
|
|
887
|
+
*/
|
|
888
|
+
setLogLevel(level) {
|
|
889
|
+
this.logger.setLevel(level);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
export default PushManager;
|