diff --git a/src/hooks/useCourseData.tsx b/src/hooks/useCourseData.tsx index 3e45f27..0c7e729 100644 --- a/src/hooks/useCourseData.tsx +++ b/src/hooks/useCourseData.tsx @@ -141,7 +141,7 @@ export function useCourseData(courses: CourseBase[]) { setLastRequestTime(fetchedAt); saveDataToStorage('lastRequestTime', fetchedAt.toString()); } catch (error) { - logger.error('강의 데이터 새로고침 실패:', error); + logger.course.error('강의 데이터 새로고침 실패:', error); clearLastRequestTime(); setIsError(true); } finally { @@ -204,7 +204,7 @@ export function useCourseData(courses: CourseBase[]) { return merged; }); } catch (error) { - logger.warn('강의 추가 데이터 fetch 실패:', error); + logger.course.warn('강의 추가 데이터 fetch 실패:', error); } finally { setPendingCourseIds((prev) => { const next = new Set(prev); diff --git a/src/lib/calendarUtils.ts b/src/lib/calendarUtils.ts index 5e0b072..6a88fe5 100644 --- a/src/lib/calendarUtils.ts +++ b/src/lib/calendarUtils.ts @@ -35,11 +35,11 @@ export const getOAuthToken = async (interactive = false): Promise } // 비대화형 호출에서 토큰 없음은 미로그인 상태이므로 에러 로그 생략 if (interactive) { - logger.error('OAuth 토큰 획득 실패:', response?.error); + logger.calendar.error('OAuth 토큰 획득 실패:', response?.error); } } catch (e) { if (interactive) { - logger.error('OAuth 메시지 전송 실패:', e); + logger.calendar.error('OAuth 메시지 전송 실패:', e); } } return null; @@ -49,7 +49,7 @@ export const removeCachedAuthToken = async (token: string): Promise => { try { await chrome.runtime.sendMessage({ action: 'removeCachedAuthToken', token }); } catch (e) { - logger.error('토큰 제거 메시지 전송 실패:', e); + logger.calendar.error('토큰 제거 메시지 전송 실패:', e); } }; @@ -75,12 +75,12 @@ export async function addCalendarEvent( } if (!response.ok) { const errorBody = await response.json(); - logger.error('이벤트 추가 실패:', response.status, errorBody); + logger.calendar.error('이벤트 추가 실패:', response.status, errorBody); return { ok: false, tokenExpired: false }; } return { ok: true, tokenExpired: false }; } catch (error) { - logger.error('캘린더 이벤트 추가 오류:', error); + logger.calendar.error('캘린더 이벤트 추가 오류:', error); return { ok: false, tokenExpired: false }; } } @@ -190,7 +190,7 @@ export async function getCalendarEvents( return { events: allEvents, tokenExpired: false }; } catch (error) { - logger.error('캘린더 이벤트 가져오기 실패:', error); + logger.calendar.error('캘린더 이벤트 가져오기 실패:', error); return { events: [], tokenExpired: false }; } } diff --git a/src/lib/courseStatus/index.ts b/src/lib/courseStatus/index.ts index 705ac8f..ad8e474 100644 --- a/src/lib/courseStatus/index.ts +++ b/src/lib/courseStatus/index.ts @@ -57,7 +57,7 @@ async function fetchAndInject(courseId: string, course: CourseBase, isTracked: b setFetchCache(courseId); showStatusBar('success'); } catch (error) { - logger.error('강의 데이터 로드 오류:', error); + logger.course.error('강의 데이터 로드 오류:', error); showStatusBar('error'); } } diff --git a/src/lib/fetchCourseData.ts b/src/lib/fetchCourseData.ts index e9e2dbc..41132fc 100644 --- a/src/lib/fetchCourseData.ts +++ b/src/lib/fetchCourseData.ts @@ -9,7 +9,7 @@ import { logger } from './logger'; function settled(result: PromiseSettledResult, fallback: T): T { if (result.status === 'fulfilled') return result.value; - logger.warn(result.reason?.message ?? result.reason); + logger.course.warn(result.reason?.message ?? result.reason); return fallback; } diff --git a/src/lib/logger.ts b/src/lib/logger.ts index c33c5f2..7a1c24d 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -1,7 +1,13 @@ -const STORAGE_KEY = 'dotbugi_logs'; -const MAX_LOGS = 500; +import { createZip, type ZipFile } from './zip'; + +const OLD_STORAGE_KEY = 'dotbugi_logs'; +const STORAGE_PREFIX = 'dotbugi_logs_'; +const MAX_LOGS_PER_CATEGORY = 200; type LogLevel = 'info' | 'warn' | 'error'; +type LogCategory = 'player' | 'course' | 'calendar' | 'storage' | 'general'; + +const LOG_CATEGORIES: LogCategory[] = ['player', 'course', 'calendar', 'storage', 'general']; interface LogEntry { timestamp: string; @@ -31,40 +37,82 @@ function formatEntry(level: LogLevel, args: unknown[]): LogEntry { }; } -async function persist(entry: LogEntry) { +async function persist(category: LogCategory, entry: LogEntry) { try { - const result = await chrome.storage.local.get(STORAGE_KEY); - const logs: LogEntry[] = result[STORAGE_KEY] ?? []; + const key = STORAGE_PREFIX + category; + const result = await chrome.storage.local.get(key); + const logs: LogEntry[] = result[key] ?? []; logs.push(entry); - if (logs.length > MAX_LOGS) logs.splice(0, logs.length - MAX_LOGS); - await chrome.storage.local.set({ [STORAGE_KEY]: logs }); + if (logs.length > MAX_LOGS_PER_CATEGORY) logs.splice(0, logs.length - MAX_LOGS_PER_CATEGORY); + await chrome.storage.local.set({ [key]: logs }); } catch { // storage 접근 불가 시 무시 (e.g. 테스트 환경) } } -function log(level: LogLevel, ...args: unknown[]) { +function log(category: LogCategory, level: LogLevel, ...args: unknown[]) { const entry = formatEntry(level, args); - persist(entry); + persist(category, entry); +} + +interface CategoryLogger { + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; +} + +function createCategoryLogger(category: LogCategory): CategoryLogger { + return { + info: (...args: unknown[]) => log(category, 'info', ...args), + warn: (...args: unknown[]) => log(category, 'warn', ...args), + error: (...args: unknown[]) => log(category, 'error', ...args), + }; } -export const logger = { - info: (...args: unknown[]) => log('info', ...args), - warn: (...args: unknown[]) => log('warn', ...args), - error: (...args: unknown[]) => log('error', ...args), +export const logger: CategoryLogger & Record = { + // 기본 (general) — 기존 호출과 호환 + ...createCategoryLogger('general'), + // 카테고리별 + player: createCategoryLogger('player'), + course: createCategoryLogger('course'), + calendar: createCategoryLogger('calendar'), + storage: createCategoryLogger('storage'), + general: createCategoryLogger('general'), }; -export async function getLogs(): Promise { +// 기존 단일 키 → 카테고리별 마이그레이션 (1회성) +(async () => { try { - const result = await chrome.storage.local.get(STORAGE_KEY); - return result[STORAGE_KEY] ?? []; + const result = await chrome.storage.local.get(OLD_STORAGE_KEY); + const oldLogs: LogEntry[] | undefined = result[OLD_STORAGE_KEY]; + if (!oldLogs || oldLogs.length === 0) return; + const key = STORAGE_PREFIX + 'general'; + await chrome.storage.local.set({ [key]: oldLogs.slice(-MAX_LOGS_PER_CATEGORY) }); + await chrome.storage.local.remove(OLD_STORAGE_KEY); } catch { - return []; + // 무시 + } +})(); + +export async function getLogs(): Promise> { + try { + const keys = LOG_CATEGORIES.map((c) => STORAGE_PREFIX + c); + const result = await chrome.storage.local.get(keys); + const logs = {} as Record; + for (const category of LOG_CATEGORIES) { + logs[category] = result[STORAGE_PREFIX + category] ?? []; + } + return logs; + } catch { + const empty = {} as Record; + for (const category of LOG_CATEGORIES) empty[category] = []; + return empty; } } export async function clearLogs(): Promise { - await chrome.storage.local.remove(STORAGE_KEY); + const keys = LOG_CATEGORIES.map((c) => STORAGE_PREFIX + c); + await chrome.storage.local.remove(keys); } /** 디버깅용 스토리지 스냅샷 (민감 정보 없음, 트래킹 강의만 포함) */ @@ -94,16 +142,31 @@ async function getStorageDump(): Promise { } export async function downloadLogs(): Promise { - const [logs, storageDump] = await Promise.all([getLogs(), getStorageDump()]); - if (logs.length === 0 && storageDump === '(스토리지 접근 불가)') return; + const [allLogs, storageDump] = await Promise.all([getLogs(), getStorageDump()]); + + const encoder = new TextEncoder(); + const files: ZipFile[] = []; + + for (const category of LOG_CATEGORIES) { + const logs = allLogs[category]; + if (logs.length === 0) continue; + const text = logs + .map((l) => `[${l.timestamp}] [${l.level.toUpperCase()}] ${l.message}`) + .join('\n'); + files.push({ name: `${category}.txt`, data: encoder.encode(text) }); + } + + if (storageDump !== '(스토리지 접근 불가)') { + files.push({ name: 'storage-snapshot.json', data: encoder.encode(storageDump) }); + } + + if (files.length === 0) return; - const logText = logs.map((l) => `[${l.timestamp}] [${l.level.toUpperCase()}] ${l.message}`).join('\n'); - const text = [logText, '', '=== Storage Snapshot ===', storageDump].join('\n'); - const blob = new Blob([text], { type: 'text/plain' }); + const blob = createZip(files); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `dotbugi-logs-${new Date().toISOString().slice(0, 10)}.txt`; + a.download = `dotbugi-logs-${new Date().toISOString().slice(0, 10)}.zip`; a.click(); URL.revokeObjectURL(url); } diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 5381eb3..fd1d2ec 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -40,7 +40,7 @@ export function loadAndTransform( try { parsedData = JSON.parse(data); } catch (error) { - logger.error(`JSON 파싱 에러 (${storageKey}):`, error); + logger.storage.error(`JSON 파싱 에러 (${storageKey}):`, error); return; } } else { diff --git a/src/lib/zip.ts b/src/lib/zip.ts new file mode 100644 index 0000000..250fcaf --- /dev/null +++ b/src/lib/zip.ts @@ -0,0 +1,98 @@ +/** + * Minimal ZIP (STORE, no compression) builder. + * No external dependencies — uses standard Web APIs only. + */ + +const CRC_TABLE = /* @__PURE__ */ (() => { + const table = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let j = 0; j < 8; j++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[i] = c; + } + return table; +})(); + +function crc32(data: Uint8Array): number { + let crc = 0xffffffff; + for (let i = 0; i < data.length; i++) crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +function writeU16(view: DataView, offset: number, value: number) { + view.setUint16(offset, value, true); +} + +function writeU32(view: DataView, offset: number, value: number) { + view.setUint32(offset, value, true); +} + +export interface ZipFile { + name: string; + data: Uint8Array; +} + +export function createZip(files: ZipFile[]): Blob { + const encoder = new TextEncoder(); + const entries: { name: Uint8Array; data: Uint8Array; crc: number; offset: number }[] = []; + + // Build local file headers + data + const localParts: Uint8Array[] = []; + let offset = 0; + + for (const file of files) { + const name = encoder.encode(file.name); + const crc = crc32(file.data); + + // Local file header: 30 bytes + name + const header = new ArrayBuffer(30); + const hv = new DataView(header); + writeU32(hv, 0, 0x04034b50); // signature + writeU16(hv, 4, 20); // version needed + writeU16(hv, 8, 0); // compression: STORE + writeU32(hv, 14, crc); + writeU32(hv, 18, file.data.length); // compressed size + writeU32(hv, 22, file.data.length); // uncompressed size + writeU16(hv, 26, name.length); + + localParts.push(new Uint8Array(header), name, file.data); + entries.push({ name, data: file.data, crc, offset }); + offset += 30 + name.length + file.data.length; + } + + // Build central directory + const centralParts: Uint8Array[] = []; + let centralSize = 0; + + for (const entry of entries) { + const cd = new ArrayBuffer(46); + const cv = new DataView(cd); + writeU32(cv, 0, 0x02014b50); // signature + writeU16(cv, 4, 20); // version made by + writeU16(cv, 6, 20); // version needed + writeU16(cv, 10, 0); // compression: STORE + writeU32(cv, 16, entry.crc); + writeU32(cv, 20, entry.data.length); + writeU32(cv, 24, entry.data.length); + writeU16(cv, 28, entry.name.length); + writeU32(cv, 42, entry.offset); + + centralParts.push(new Uint8Array(cd), entry.name); + centralSize += 46 + entry.name.length; + } + + // End of central directory record: 22 bytes + const eocd = new ArrayBuffer(22); + const ev = new DataView(eocd); + writeU32(ev, 0, 0x06054b50); + writeU16(ev, 4, 0); // disk number + writeU16(ev, 6, 0); // disk with CD + writeU16(ev, 8, entries.length); + writeU16(ev, 10, entries.length); + writeU32(ev, 12, centralSize); + writeU32(ev, 16, offset); // offset of CD + + return new Blob([...localParts, ...centralParts, new Uint8Array(eocd)], { + type: 'application/zip', + }); +} diff --git a/src/popover/dashboard/Dashboard.tsx b/src/popover/dashboard/Dashboard.tsx index b59dc82..ff7bdd1 100644 --- a/src/popover/dashboard/Dashboard.tsx +++ b/src/popover/dashboard/Dashboard.tsx @@ -229,8 +229,12 @@ export default function Dashboard() { setCalendarToken(null); showTokenExpiredNotif(); } else { - getOAuthToken(false).then((token) => { - if (token) setCalendarToken(token); + // 이전에 로그인한 적이 있는 사용자만 자동 토큰 가져오기 + chrome.storage.local.get('calendarLoggedIn', (result) => { + if (!result.calendarLoggedIn) return; + getOAuthToken(false).then((token) => { + if (token) setCalendarToken(token); + }); }); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -240,6 +244,7 @@ export default function Dashboard() { const token = await getOAuthToken(true); if (token) { setCalendarToken(token); + chrome.storage.local.set({ calendarLoggedIn: true }); } }; @@ -248,10 +253,11 @@ export default function Dashboard() { try { await removeCachedAuthToken(calendarToken); } catch (e) { - logger.warn('removeCachedAuthToken failed:', e); + logger.calendar.warn('removeCachedAuthToken failed:', e); } } setCalendarToken(null); + chrome.storage.local.remove('calendarLoggedIn'); }; const handleCalendarSync = async () => { diff --git a/src/popover/player/components/PlayerIframe.tsx b/src/popover/player/components/PlayerIframe.tsx index bb211d2..100b23c 100644 --- a/src/popover/player/components/PlayerIframe.tsx +++ b/src/popover/player/components/PlayerIframe.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { logger } from '@/lib/logger'; const formatMMSS = (seconds: number) => `${Math.floor(seconds / 60)}:${Math.floor(seconds % 60).toString().padStart(2, '0')}`; @@ -40,7 +41,7 @@ export default function PlayerIframe({ videoSrc, onNextVideo, isPlaying }: Playe if (play) { video.muted = false; video.volume = 0.01; - video.play().catch((e) => console.warn('Playback failed:', e)); + video.play().catch((e) => logger.player.warn('Playback failed:', e)); } else { video.pause(); } @@ -68,24 +69,44 @@ export default function PlayerIframe({ videoSrc, onNextVideo, isPlaying }: Playe const isComplete = completeMatch?.[1] === '1'; const beforeProgress = progressMatch ? parseInt(progressMatch[1], 10) : 0; + logger.player.info('handleLoad meta:', { + src: iframe.src, + isComplete, + beforeProgress, + isPlaying: isPlayingRef.current, + }); + // is_complete면 스킵 if (isComplete && isPlayingRef.current) { + logger.player.info('Skipping completed video:', iframe.src); onNextVideoRef.current(); return; } const video = getVideoElement(); - if (!video) return; + if (!video) { + logger.player.warn('Video element not found after meta fetch'); + return; + } video.onended = null; - video.onended = () => onNextVideoRef.current(); + video.onended = () => { + logger.player.info('Video ended naturally:', { + src: iframe.src, + currentTime: video.currentTime, + duration: video.duration, + }); + onNextVideoRef.current(); + }; // 이어보기 if (!isComplete && beforeProgress > 0) { const trySeek = () => { if (controller.signal.aborted) return; if (video.readyState >= 1 && video.duration > 0) { - video.currentTime = Math.min(beforeProgress, video.duration - 1); + const seekTo = Math.min(beforeProgress, video.duration - 1); + logger.player.info('Resuming from', seekTo, 'duration:', video.duration); + video.currentTime = seekTo; } else { setTimeout(trySeek, 500); } @@ -97,6 +118,7 @@ export default function PlayerIframe({ videoSrc, onNextVideo, isPlaying }: Playe } catch { // fetch 실패 시 일반 재생 if (controller.signal.aborted) return; + logger.player.warn('Meta fetch failed, falling back to normal playback'); const video = getVideoElement(); if (!video) return; video.onended = null; @@ -146,10 +168,15 @@ export default function PlayerIframe({ videoSrc, onNextVideo, isPlaying }: Playe if (!video || !isPlayingRef.current) return; if (video.paused) { + logger.player.info('Resuming paused video in background tab'); video.play().catch(() => {}); } if (video.duration > 0 && video.currentTime >= video.duration - 0.5) { + logger.player.info('Fallback: advancing to next video', { + currentTime: video.currentTime, + duration: video.duration, + }); onNextVideoRef.current(); } }, 3_000); diff --git a/src/popover/player/components/PlayerPopoverContent.tsx b/src/popover/player/components/PlayerPopoverContent.tsx index c5029f4..36f0a42 100644 --- a/src/popover/player/components/PlayerPopoverContent.tsx +++ b/src/popover/player/components/PlayerPopoverContent.tsx @@ -48,8 +48,11 @@ export default function PlayerPopoverContent({ isPopoverOpen, isPlaying, setIsPl const onNextVideo = useCallback(() => { if (currentVideoIndex + 1 >= vods.length) { - setIsPlaying(false); - setIsDone(true); + // 마지막 영상: LMS 수강기록 전송(30초 간격)을 위해 60초 대기 후 종료 + setTimeout(() => { + setIsPlaying(false); + setIsDone(true); + }, 60_000); return; }