import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { useMe } from './me' import fancyNames from '@/lib/fancy-names.json' import { gql, useMutation, useQuery } from '@apollo/client' import { WALLET_LOGS } from '@/fragments/wallet' import { getWalletBy } from '@/lib/constants' const generateFancyName = () => { // 100 adjectives * 100 nouns * 10000 = 100M possible names const pickRandom = (array) => array[Math.floor(Math.random() * array.length)] const adj = pickRandom(fancyNames.adjectives) const noun = pickRandom(fancyNames.nouns) const id = Math.floor(Math.random() * fancyNames.maxSuffix) return `${adj}-${noun}-${id}` } export function detectOS () { if (!window.navigator) return '' const userAgent = window.navigator.userAgent const platform = window.navigator.userAgentData?.platform || window.navigator.platform const macosPlatforms = ['Macintosh', 'MacIntel', 'MacPPC', 'Mac68K'] const windowsPlatforms = ['Win32', 'Win64', 'Windows', 'WinCE'] const iosPlatforms = ['iPhone', 'iPad', 'iPod'] let os = null if (macosPlatforms.indexOf(platform) !== -1) { os = 'Mac OS' } else if (iosPlatforms.indexOf(platform) !== -1) { os = 'iOS' } else if (windowsPlatforms.indexOf(platform) !== -1) { os = 'Windows' } else if (/Android/.test(userAgent)) { os = 'Android' } else if (/Linux/.test(platform)) { os = 'Linux' } return os } export const LoggerContext = createContext() export const LoggerProvider = ({ children }) => { return ( {children} ) } const ServiceWorkerLoggerContext = createContext() function ServiceWorkerLoggerProvider ({ children }) { const me = useMe() const [name, setName] = useState() const [os, setOS] = useState() useEffect(() => { let name = window.localStorage.getItem('fancy-name') if (!name) { name = generateFancyName() window.localStorage.setItem('fancy-name', name) } setName(name) setOS(detectOS()) }, []) const log = useCallback(level => { return async (message, context) => { if (!me || !me.privates?.diagnostics) return const env = { userAgent: window.navigator.userAgent, // os may not be initialized yet os: os || detectOS() } const body = { level, env, // name may be undefined if it wasn't stored in local storage yet // we fallback to local storage since on page reloads, the name may wasn't fetched from local storage yet name: name || window.localStorage.getItem('fancy-name'), message, context } await fetch('/api/log', { method: 'post', headers: { 'Content-type': 'application/json' }, body: JSON.stringify(body) }).catch(console.error) } }, [me?.privates?.diagnostics, name, os]) const logger = useMemo(() => ({ info: log('info'), warn: log('warn'), error: log('error'), name }), [log, name]) useEffect(() => { // for communication between app and service worker const channel = new MessageChannel() navigator?.serviceWorker?.controller?.postMessage({ action: 'MESSAGE_PORT' }, [channel.port2]) channel.port1.onmessage = (event) => { const { message, level, context } = Object.assign({ level: 'info' }, event.data) logger[level](message, context) } }, [logger]) return ( {children} ) } export function useServiceWorkerLogger () { return useContext(ServiceWorkerLoggerContext) } const WalletLoggerContext = createContext() const WalletLogsContext = createContext() const initIndexedDB = async (dbName, storeName) => { return new Promise((resolve, reject) => { if (!window.indexedDB) { return reject(new Error('IndexedDB not supported')) } // https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB const request = window.indexedDB.open(dbName, 1) let db request.onupgradeneeded = () => { // this only runs if version was changed during open db = request.result if (!db.objectStoreNames.contains(storeName)) { const objectStore = db.createObjectStore(storeName, { autoIncrement: true }) objectStore.createIndex('ts', 'ts') objectStore.createIndex('wallet_ts', ['wallet', 'ts']) } } request.onsuccess = () => { // this gets called after onupgradeneeded finished db = request.result resolve(db) } request.onerror = () => { reject(new Error('failed to open IndexedDB')) } }) } const WalletLoggerProvider = ({ children }) => { const me = useMe() const [logs, setLogs] = useState([]) let dbName = 'app:storage' if (me) { dbName = `${dbName}:${me.id}` } const idbStoreName = 'wallet_logs' const idb = useRef() const logQueue = useRef([]) useQuery(WALLET_LOGS, { fetchPolicy: 'network-only', // required to trigger onCompleted on refetches notifyOnNetworkStatusChange: true, onCompleted: ({ walletLogs }) => { setLogs((prevLogs) => { const existingIds = prevLogs.map(({ id }) => id) const logs = walletLogs .filter(({ id }) => !existingIds.includes(id)) .map(({ createdAt, wallet: walletType, ...log }) => { return { ts: +new Date(createdAt), wallet: getWalletBy('type', walletType).logTag, ...log } }) return [...prevLogs, ...logs].sort((a, b) => a.ts - b.ts) }) } }) const [deleteServerWalletLogs] = useMutation( gql` mutation deleteWalletLogs($wallet: String) { deleteWalletLogs(wallet: $wallet) } `, { onCompleted: (_, { variables: { wallet: walletType } }) => { setLogs((logs) => { return logs.filter(l => walletType ? l.wallet !== getWalletBy('type', walletType).logTag : false) }) } } ) const saveLog = useCallback((log) => { if (!idb.current) { // IDB may not be ready yet return logQueue.current.push(log) } const tx = idb.current.transaction(idbStoreName, 'readwrite') const request = tx.objectStore(idbStoreName).add(log) request.onerror = () => console.error('failed to save log:', log) }, []) useEffect(() => { initIndexedDB(dbName, idbStoreName) .then(db => { idb.current = db // load all logs from IDB const tx = idb.current.transaction(idbStoreName, 'readonly') const store = tx.objectStore(idbStoreName) const index = store.index('ts') const request = index.getAll() request.onsuccess = () => { const logs = request.result setLogs((prevLogs) => { // sort oldest first to keep same order as logs are appended return [...prevLogs, ...logs].sort((a, b) => a.ts - b.ts) }) } // flush queued logs to IDB logQueue.current.forEach(q => { const isLog = !!q.wallet if (isLog) saveLog(q) }) logQueue.current = [] }) .catch(console.error) return () => idb.current?.close() }, []) const appendLog = useCallback((wallet, level, message) => { const log = { wallet: wallet.logTag, level, message, ts: +new Date() } saveLog(log) setLogs((prevLogs) => [...prevLogs, log]) }, [saveLog]) const deleteLogs = useCallback(async (wallet) => { if (!wallet || wallet.server) { await deleteServerWalletLogs({ variables: { wallet: wallet?.type } }) } if (!wallet || !wallet.server) { const tx = idb.current.transaction(idbStoreName, 'readwrite') const objectStore = tx.objectStore(idbStoreName) const idx = objectStore.index('wallet_ts') const request = wallet ? idx.openCursor(window.IDBKeyRange.bound([wallet.logTag, -Infinity], [wallet.logTag, Infinity])) : idx.openCursor() request.onsuccess = function (event) { const cursor = event.target.result if (cursor) { cursor.delete() cursor.continue() } else { // finished setLogs((logs) => logs.filter(l => wallet ? l.wallet !== wallet.logTag : false)) } } } }, [setLogs]) return ( {children} ) } export function useWalletLogger (wallet) { const { appendLog, deleteLogs: innerDeleteLogs } = useContext(WalletLoggerContext) const log = useCallback(level => message => { // TODO: // also send this to us if diagnostics was enabled, // very similar to how the service worker logger works. appendLog(wallet, level, message) console[level !== 'error' ? 'info' : 'error'](`[${wallet.logTag}]`, message) }, [appendLog, wallet]) const logger = useMemo(() => ({ ok: (...message) => log('ok')(message.join(' ')), info: (...message) => log('info')(message.join(' ')), error: (...message) => log('error')(message.join(' ')) }), [log, wallet]) const deleteLogs = useCallback((w) => innerDeleteLogs(w || wallet), [innerDeleteLogs, wallet]) return { logger, deleteLogs } } export function useWalletLogs (wallet) { const logs = useContext(WalletLogsContext) return logs.filter(l => !wallet || l.wallet === wallet.logTag) }