Eduard Kuzmenko
3 years ago
18 changed files with 1046 additions and 343 deletions
@ -1,286 +0,0 @@ |
|||||||
/* |
|
||||||
* https://github.com/morethanwords/tweb
|
|
||||||
* Copyright (C) 2019-2021 Eduard Kuzmenko |
|
||||||
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
|
||||||
*/ |
|
||||||
|
|
||||||
/// #if MTPROTO_SW
|
|
||||||
import './mtproto.worker'; |
|
||||||
/// #endif
|
|
||||||
//import CacheStorageController from '../cacheStorage';
|
|
||||||
import { isSafari } from '../../helpers/userAgent'; |
|
||||||
import { logger, LogTypes } from '../logger'; |
|
||||||
import type { DownloadOptions } from './apiFileManager'; |
|
||||||
import type { WorkerTaskTemplate } from '../../types'; |
|
||||||
import { notifySomeone } from '../../helpers/context'; |
|
||||||
import type { InputFileLocation, UploadFile } from '../../layer'; |
|
||||||
import { CancellablePromise, deferredPromise } from '../../helpers/cancellablePromise'; |
|
||||||
|
|
||||||
const log = logger('SW', LogTypes.Error | LogTypes.Debug | LogTypes.Log | LogTypes.Warn); |
|
||||||
const ctx = self as any as ServiceWorkerGlobalScope; |
|
||||||
|
|
||||||
const deferredPromises: {[taskId: number]: CancellablePromise<any>} = {}; |
|
||||||
|
|
||||||
export interface ServiceWorkerTask extends WorkerTaskTemplate { |
|
||||||
type: 'requestFilePart', |
|
||||||
payload: [number, InputFileLocation, number, number] |
|
||||||
}; |
|
||||||
|
|
||||||
export interface ServiceWorkerTaskResponse extends WorkerTaskTemplate { |
|
||||||
type: 'requestFilePart', |
|
||||||
payload?: UploadFile.uploadFile, |
|
||||||
originalPayload?: ServiceWorkerTask['payload'] |
|
||||||
}; |
|
||||||
|
|
||||||
/// #if !MTPROTO_SW
|
|
||||||
ctx.addEventListener('message', (e) => { |
|
||||||
const task = e.data as ServiceWorkerTaskResponse; |
|
||||||
const promise = deferredPromises[task.id]; |
|
||||||
|
|
||||||
if(task.error) { |
|
||||||
promise.reject(task.error); |
|
||||||
} else { |
|
||||||
promise.resolve(task.payload); |
|
||||||
} |
|
||||||
|
|
||||||
delete deferredPromises[task.id]; |
|
||||||
}); |
|
||||||
/// #endif
|
|
||||||
|
|
||||||
//const cacheStorage = new CacheStorageController('cachedAssets');
|
|
||||||
let taskId = 0; |
|
||||||
|
|
||||||
function isCorrectResponse(response: Response) { |
|
||||||
return response.ok && response.status === 200; |
|
||||||
} |
|
||||||
|
|
||||||
async function requestCache(event: FetchEvent) { |
|
||||||
try { |
|
||||||
const cache = await ctx.caches.open('cachedAssets'); |
|
||||||
const file = await cache.match(event.request, {ignoreVary: true}); |
|
||||||
|
|
||||||
if(file && isCorrectResponse(file)) { |
|
||||||
return file; |
|
||||||
} |
|
||||||
|
|
||||||
const headers: HeadersInit = {'Vary': '*'}; |
|
||||||
let response = await fetch(event.request, {headers}); |
|
||||||
if(isCorrectResponse(response)) { |
|
||||||
cache.put(event.request, response.clone()); |
|
||||||
} else if(response.status === 304) { // possible fix for 304 in Safari
|
|
||||||
const url = event.request.url.replace(/\?.+$/, '') + '?' + (Math.random() * 100000 | 0); |
|
||||||
response = await fetch(url, {headers}); |
|
||||||
if(isCorrectResponse(response)) { |
|
||||||
cache.put(event.request, response.clone()); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return response; |
|
||||||
} catch(err) { |
|
||||||
return fetch(event.request); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
const onFetch = (event: FetchEvent): void => { |
|
||||||
if(event.request.url.indexOf(location.origin + '/') === 0 && event.request.url.match(/\.(js|css|jpe?g|json|wasm|png|mp3|svg|tgs|ico|woff2?|ttf|webmanifest?)(?:\?.*)?$/)) { |
|
||||||
return event.respondWith(requestCache(event)); |
|
||||||
} |
|
||||||
|
|
||||||
try { |
|
||||||
const [, url, scope, params] = /http[:s]+\/\/.*?(\/(.*?)(?:$|\/(.*)$))/.exec(event.request.url) || []; |
|
||||||
|
|
||||||
//log.debug('[fetch]:', event);
|
|
||||||
|
|
||||||
switch(scope) { |
|
||||||
case 'stream': { |
|
||||||
const range = parseRange(event.request.headers.get('Range')); |
|
||||||
let [offset, end] = range; |
|
||||||
|
|
||||||
const info: DownloadOptions = JSON.parse(decodeURIComponent(params)); |
|
||||||
//const fileName = getFileNameByLocation(info.location);
|
|
||||||
|
|
||||||
// ! если грузить очень большое видео чанками по 512Кб в мобильном Safari, то стрим не запустится
|
|
||||||
const limitPart = info.size > (75 * 1024 * 1024) ? STREAM_CHUNK_UPPER_LIMIT : STREAM_CHUNK_MIDDLE_LIMIT; |
|
||||||
|
|
||||||
/* if(info.size > limitPart && isSafari && offset === limitPart) { |
|
||||||
//end = info.size - 1;
|
|
||||||
//offset = info.size - 1 - limitPart;
|
|
||||||
offset = info.size - (info.size % limitPart); |
|
||||||
} */ |
|
||||||
|
|
||||||
//log.debug('[stream]', url, offset, end);
|
|
||||||
|
|
||||||
event.respondWith(Promise.race([ |
|
||||||
timeout(45 * 1000), |
|
||||||
|
|
||||||
new Promise<Response>((resolve, reject) => { |
|
||||||
// safari workaround
|
|
||||||
const possibleResponse = responseForSafariFirstRange(range, info.mimeType, info.size); |
|
||||||
if(possibleResponse) { |
|
||||||
return resolve(possibleResponse); |
|
||||||
} |
|
||||||
|
|
||||||
const limit = end && end < limitPart ? alignLimit(end - offset + 1) : limitPart; |
|
||||||
const alignedOffset = alignOffset(offset, limit); |
|
||||||
|
|
||||||
//log.debug('[stream] requestFilePart:', /* info.dcId, info.location, */ alignedOffset, limit);
|
|
||||||
|
|
||||||
const task: ServiceWorkerTask = { |
|
||||||
type: 'requestFilePart', |
|
||||||
id: taskId++, |
|
||||||
payload: [info.dcId, info.location, alignedOffset, limit] |
|
||||||
}; |
|
||||||
|
|
||||||
|
|
||||||
const deferred = deferredPromises[task.id] = deferredPromise<UploadFile.uploadFile>(); |
|
||||||
deferred.then(result => { |
|
||||||
let ab = result.bytes as Uint8Array; |
|
||||||
|
|
||||||
//log.debug('[stream] requestFilePart result:', result);
|
|
||||||
|
|
||||||
const headers: Record<string, string> = { |
|
||||||
'Accept-Ranges': 'bytes', |
|
||||||
'Content-Range': `bytes ${alignedOffset}-${alignedOffset + ab.byteLength - 1}/${info.size || '*'}`, |
|
||||||
'Content-Length': `${ab.byteLength}`, |
|
||||||
}; |
|
||||||
|
|
||||||
if(info.mimeType) headers['Content-Type'] = info.mimeType; |
|
||||||
|
|
||||||
if(isSafari) { |
|
||||||
ab = ab.slice(offset - alignedOffset, end - alignedOffset + 1); |
|
||||||
headers['Content-Range'] = `bytes ${offset}-${offset + ab.byteLength - 1}/${info.size || '*'}`; |
|
||||||
headers['Content-Length'] = `${ab.byteLength}`; |
|
||||||
} |
|
||||||
|
|
||||||
// simulate slow connection
|
|
||||||
//setTimeout(() => {
|
|
||||||
resolve(new Response(ab, { |
|
||||||
status: 206, |
|
||||||
statusText: 'Partial Content', |
|
||||||
headers, |
|
||||||
})); |
|
||||||
//}, 2.5e3);
|
|
||||||
}).catch(err => {}); |
|
||||||
|
|
||||||
notifySomeone(task); |
|
||||||
}) |
|
||||||
])); |
|
||||||
break; |
|
||||||
} |
|
||||||
} |
|
||||||
} catch(err) { |
|
||||||
event.respondWith(new Response('', { |
|
||||||
status: 500, |
|
||||||
statusText: 'Internal Server Error', |
|
||||||
})); |
|
||||||
} |
|
||||||
}; |
|
||||||
|
|
||||||
const onChangeState = () => { |
|
||||||
ctx.onfetch = onFetch; |
|
||||||
}; |
|
||||||
|
|
||||||
ctx.addEventListener('install', (event) => { |
|
||||||
log('installing'); |
|
||||||
|
|
||||||
/* initCache(); |
|
||||||
|
|
||||||
event.waitUntil( |
|
||||||
initNetwork(), |
|
||||||
); */ |
|
||||||
event.waitUntil(ctx.skipWaiting()); // Activate worker immediately
|
|
||||||
}); |
|
||||||
|
|
||||||
ctx.addEventListener('activate', (event) => { |
|
||||||
log('activating', ctx); |
|
||||||
|
|
||||||
/* if (!ctx.cache) initCache(); |
|
||||||
if (!ctx.network) initNetwork(); */ |
|
||||||
|
|
||||||
event.waitUntil(ctx.caches.delete('cachedAssets')); |
|
||||||
event.waitUntil(ctx.clients.claim()); |
|
||||||
}); |
|
||||||
|
|
||||||
function timeout(delay: number): Promise<Response> { |
|
||||||
return new Promise(((resolve) => { |
|
||||||
setTimeout(() => { |
|
||||||
resolve(new Response('', { |
|
||||||
status: 408, |
|
||||||
statusText: 'Request timed out.', |
|
||||||
})); |
|
||||||
}, delay); |
|
||||||
})); |
|
||||||
} |
|
||||||
|
|
||||||
function responseForSafariFirstRange(range: [number, number], mimeType: string, size: number): Response { |
|
||||||
if(range[0] === 0 && range[1] === 1) { |
|
||||||
return new Response(new Uint8Array(2).buffer, { |
|
||||||
status: 206, |
|
||||||
statusText: 'Partial Content', |
|
||||||
headers: { |
|
||||||
'Accept-Ranges': 'bytes', |
|
||||||
'Content-Range': `bytes 0-1/${size || '*'}`, |
|
||||||
'Content-Length': '2', |
|
||||||
'Content-Type': mimeType || 'video/mp4', |
|
||||||
}, |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
return null; |
|
||||||
} |
|
||||||
|
|
||||||
ctx.onerror = (error) => { |
|
||||||
log.error('error:', error); |
|
||||||
}; |
|
||||||
|
|
||||||
ctx.onunhandledrejection = (error) => { |
|
||||||
log.error('onunhandledrejection:', error); |
|
||||||
}; |
|
||||||
|
|
||||||
ctx.onoffline = ctx.ononline = onChangeState; |
|
||||||
|
|
||||||
onChangeState(); |
|
||||||
|
|
||||||
/* const STREAM_CHUNK_UPPER_LIMIT = 256 * 1024; |
|
||||||
const SMALLEST_CHUNK_LIMIT = 256 * 4; */ |
|
||||||
/* const STREAM_CHUNK_UPPER_LIMIT = 1024 * 1024; |
|
||||||
const SMALLEST_CHUNK_LIMIT = 1024 * 4; */ |
|
||||||
const STREAM_CHUNK_MIDDLE_LIMIT = 512 * 1024; |
|
||||||
const STREAM_CHUNK_UPPER_LIMIT = 1024 * 1024; |
|
||||||
const SMALLEST_CHUNK_LIMIT = 512 * 4; |
|
||||||
|
|
||||||
function parseRange(header: string): [number, number] { |
|
||||||
if(!header) return [0, 0]; |
|
||||||
const [, chunks] = header.split('='); |
|
||||||
const ranges = chunks.split(', '); |
|
||||||
const [offset, end] = ranges[0].split('-'); |
|
||||||
|
|
||||||
return [+offset, +end || 0]; |
|
||||||
} |
|
||||||
|
|
||||||
function alignOffset(offset: number, base = SMALLEST_CHUNK_LIMIT) { |
|
||||||
return offset - (offset % base); |
|
||||||
} |
|
||||||
|
|
||||||
function alignLimit(limit: number) { |
|
||||||
return 2 ** Math.ceil(Math.log(limit) / Math.log(2)); |
|
||||||
} |
|
||||||
|
|
||||||
/* ctx.addEventListener('push', (event) => { |
|
||||||
console.log('[Service Worker] Push Received.'); |
|
||||||
console.log(`[Service Worker] Push had this data: "${event.data.text()}"`, event, event.data); |
|
||||||
|
|
||||||
const title = 'Push Push Push'; |
|
||||||
const options = {}; |
|
||||||
// const options = {
|
|
||||||
// body: 'Yay it works.',
|
|
||||||
// icon: 'images/icon.png',
|
|
||||||
// badge: 'images/badge.png'
|
|
||||||
// };
|
|
||||||
|
|
||||||
event.waitUntil(ctx.registration.showNotification(title, options)); |
|
||||||
}); */ |
|
||||||
|
|
||||||
//export default () => {};
|
|
||||||
|
|
||||||
//MOUNT_CLASS_TO.onFetch = onFetch;
|
|
@ -0,0 +1,254 @@ |
|||||||
|
/* |
||||||
|
* https://github.com/morethanwords/tweb
|
||||||
|
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||||
|
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||||
|
* |
||||||
|
* Originally from: |
||||||
|
* https://github.com/zhukov/webogram
|
||||||
|
* Copyright (C) 2014 Igor Zhukov <igor.beatle@gmail.com> |
||||||
|
* https://github.com/zhukov/webogram/blob/master/LICENSE
|
||||||
|
*/ |
||||||
|
|
||||||
|
import type { NotificationSettings } from "../appManagers/appNotificationsManager"; |
||||||
|
import { MOUNT_CLASS_TO } from "../../config/debug"; |
||||||
|
import { copy } from "../../helpers/object"; |
||||||
|
import { logger } from "../logger"; |
||||||
|
import rootScope from "../rootScope"; |
||||||
|
import { ServiceWorkerNotificationsClearTask, ServiceWorkerPingTask, ServiceWorkerPushClickTask } from "../serviceWorker/index.service"; |
||||||
|
import apiManager from "./mtprotoworker"; |
||||||
|
import I18n, { LangPackKey } from "../langPack"; |
||||||
|
import { isMobile } from "../../helpers/userAgent"; |
||||||
|
|
||||||
|
export type PushSubscriptionNotifyType = 'init' | 'subscribe' | 'unsubscribe'; |
||||||
|
export type PushSubscriptionNotifyEvent = `push_${PushSubscriptionNotifyType}`; |
||||||
|
|
||||||
|
export type PushSubscriptionNotify = { |
||||||
|
tokenType: number, |
||||||
|
tokenValue: string |
||||||
|
}; |
||||||
|
|
||||||
|
export class WebPushApiManager { |
||||||
|
public isAvailable = true; |
||||||
|
private isPushEnabled = false; |
||||||
|
private localNotificationsAvailable = true; |
||||||
|
private started = false; |
||||||
|
private settings: NotificationSettings & {baseUrl?: string} = {} as any; |
||||||
|
private isAliveTO: any; |
||||||
|
private isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1; |
||||||
|
private userVisibleOnly = this.isFirefox ? false : true; |
||||||
|
private log = logger('PM'); |
||||||
|
|
||||||
|
constructor() { |
||||||
|
if(!('PushManager' in window) || |
||||||
|
!('Notification' in window) || |
||||||
|
!('serviceWorker' in navigator)) { |
||||||
|
this.log.warn('Push messaging is not supported.'); |
||||||
|
this.isAvailable = false; |
||||||
|
this.localNotificationsAvailable = false; |
||||||
|
} |
||||||
|
|
||||||
|
if(this.isAvailable && Notification.permission === 'denied') { |
||||||
|
this.log.warn('The user has blocked notifications.'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public start() { |
||||||
|
if(!this.started) { |
||||||
|
this.started = true; |
||||||
|
this.getSubscription(); |
||||||
|
this.setUpServiceWorkerChannel(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public setLocalNotificationsDisabled() { |
||||||
|
this.localNotificationsAvailable = false; |
||||||
|
} |
||||||
|
|
||||||
|
public getSubscription() { |
||||||
|
if(!this.isAvailable) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
navigator.serviceWorker.ready.then((reg) => { |
||||||
|
reg.pushManager.getSubscription().then((subscription) => { |
||||||
|
this.isPushEnabled = !!subscription; |
||||||
|
this.pushSubscriptionNotify('init', subscription); |
||||||
|
}).catch((err) => { |
||||||
|
this.log.error('Error during getSubscription()', err); |
||||||
|
}); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
public subscribe = () => { |
||||||
|
if(!this.isAvailable) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
navigator.serviceWorker.ready.then((reg) => { |
||||||
|
reg.pushManager.subscribe({userVisibleOnly: this.userVisibleOnly}).then((subscription) => { |
||||||
|
// The subscription was successful
|
||||||
|
this.isPushEnabled = true; |
||||||
|
this.pushSubscriptionNotify('subscribe', subscription); |
||||||
|
}).catch((e) => { |
||||||
|
if(Notification.permission === 'denied') { |
||||||
|
this.log('Permission for Notifications was denied'); |
||||||
|
} else { |
||||||
|
this.log('Unable to subscribe to push.', e); |
||||||
|
if(!this.userVisibleOnly) { |
||||||
|
this.userVisibleOnly = true; |
||||||
|
setTimeout(this.subscribe, 0); |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
public unsubscribe() { |
||||||
|
if(!this.isAvailable) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
navigator.serviceWorker.ready.then((reg) => { |
||||||
|
reg.pushManager.getSubscription().then((subscription) => { |
||||||
|
this.isPushEnabled = false; |
||||||
|
|
||||||
|
if(subscription) { |
||||||
|
this.pushSubscriptionNotify('unsubscribe', subscription); |
||||||
|
|
||||||
|
setTimeout(() => { |
||||||
|
subscription.unsubscribe().then((successful) => { |
||||||
|
this.isPushEnabled = false; |
||||||
|
}).catch((e) => { |
||||||
|
this.log.error('Unsubscription error: ', e); |
||||||
|
}); |
||||||
|
}, 3000); |
||||||
|
} |
||||||
|
}).catch((e) => { |
||||||
|
this.log.error('Error thrown while unsubscribing from ' + |
||||||
|
'push messaging.', e); |
||||||
|
}); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
public forceUnsubscribe() { |
||||||
|
if(!this.isAvailable) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
navigator.serviceWorker.ready.then((reg) => { |
||||||
|
reg.pushManager.getSubscription().then((subscription) => { |
||||||
|
this.log.warn('force unsubscribe', subscription); |
||||||
|
if(subscription) { |
||||||
|
subscription.unsubscribe().then((successful) => { |
||||||
|
this.log.warn('force unsubscribe successful', successful); |
||||||
|
this.isPushEnabled = false; |
||||||
|
}).catch((e) => { |
||||||
|
this.log.error('Unsubscription error: ', e); |
||||||
|
}); |
||||||
|
} |
||||||
|
}).catch((e) => { |
||||||
|
this.log.error('Error thrown while unsubscribing from ' + |
||||||
|
'push messaging.', e); |
||||||
|
}); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
public isAliveNotify = () => { |
||||||
|
if(!this.isAvailable || rootScope.idle && rootScope.idle.deactivated) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
this.settings.baseUrl = (location.href || '').replace(/#.*$/, '') + '#/im'; |
||||||
|
|
||||||
|
const lang: ServiceWorkerPingTask['payload']['lang'] = {} as any; |
||||||
|
const ACTIONS_LANG_MAP: Record<keyof ServiceWorkerPingTask['payload']['lang'], LangPackKey> = { |
||||||
|
push_action_mute1d: isMobile ? 'PushNotification.Action.Mute1d.Mobile' : 'PushNotification.Action.Mute1d', |
||||||
|
push_action_settings: isMobile ? 'PushNotification.Action.Settings.Mobile' : 'PushNotification.Action.Settings', |
||||||
|
push_message_nopreview: 'PushNotification.Message.NoPreview' |
||||||
|
}; |
||||||
|
|
||||||
|
for(const action in ACTIONS_LANG_MAP) { |
||||||
|
lang[action as keyof typeof ACTIONS_LANG_MAP] = I18n.format(ACTIONS_LANG_MAP[action as keyof typeof ACTIONS_LANG_MAP], true); |
||||||
|
} |
||||||
|
|
||||||
|
const task: ServiceWorkerPingTask = { |
||||||
|
type: 'ping', |
||||||
|
payload: { |
||||||
|
localNotifications: this.localNotificationsAvailable, |
||||||
|
lang: lang, |
||||||
|
settings: this.settings |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
if(navigator.serviceWorker.controller) { |
||||||
|
navigator.serviceWorker.controller.postMessage(task); |
||||||
|
} |
||||||
|
|
||||||
|
this.isAliveTO = setTimeout(this.isAliveNotify, 10000); |
||||||
|
} |
||||||
|
|
||||||
|
public setSettings(newSettings: WebPushApiManager['settings']) { |
||||||
|
this.settings = copy(newSettings); |
||||||
|
clearTimeout(this.isAliveTO); |
||||||
|
this.isAliveNotify(); |
||||||
|
} |
||||||
|
|
||||||
|
public hidePushNotifications() { |
||||||
|
if(!this.isAvailable) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if(navigator.serviceWorker.controller) { |
||||||
|
const task: ServiceWorkerNotificationsClearTask = {type: 'notifications_clear'}; |
||||||
|
navigator.serviceWorker.controller.postMessage(task); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public setUpServiceWorkerChannel() { |
||||||
|
if(!this.isAvailable) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
apiManager.addServiceWorkerTaskListener('push_click', (task: ServiceWorkerPushClickTask) => { |
||||||
|
if(rootScope.idle && rootScope.idle.deactivated) { |
||||||
|
// AppRuntimeManager.reload(); // WARNING
|
||||||
|
location.reload(); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
rootScope.dispatchEvent('push_notification_click', task.payload); |
||||||
|
}); |
||||||
|
|
||||||
|
navigator.serviceWorker.ready.then(this.isAliveNotify); |
||||||
|
} |
||||||
|
|
||||||
|
public pushSubscriptionNotify(event: PushSubscriptionNotifyType, subscription?: PushSubscription) { |
||||||
|
if(subscription) { |
||||||
|
const subscriptionObj: PushSubscriptionJSON = subscription.toJSON(); |
||||||
|
if(!subscriptionObj || |
||||||
|
!subscriptionObj.endpoint || |
||||||
|
!subscriptionObj.keys || |
||||||
|
!subscriptionObj.keys.p256dh || |
||||||
|
!subscriptionObj.keys.auth) { |
||||||
|
this.log.warn('Invalid push subscription', subscriptionObj); |
||||||
|
this.unsubscribe(); |
||||||
|
this.isAvailable = false; |
||||||
|
this.pushSubscriptionNotify(event); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
this.log.warn('Push', event, subscriptionObj); |
||||||
|
rootScope.dispatchEvent(('push_' + event) as PushSubscriptionNotifyEvent, { |
||||||
|
tokenType: 10, |
||||||
|
tokenValue: JSON.stringify(subscriptionObj) |
||||||
|
}); |
||||||
|
} else { |
||||||
|
this.log.warn('Push', event, false); |
||||||
|
rootScope.dispatchEvent(('push_' + event) as PushSubscriptionNotifyEvent, false as any); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const webPushApiManager = new WebPushApiManager(); |
||||||
|
MOUNT_CLASS_TO && (MOUNT_CLASS_TO.webPushApiManager = webPushApiManager); |
||||||
|
export default webPushApiManager; |
@ -0,0 +1,39 @@ |
|||||||
|
/* |
||||||
|
* https://github.com/morethanwords/tweb
|
||||||
|
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||||
|
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||||
|
*/ |
||||||
|
|
||||||
|
const ctx = self as any as ServiceWorkerGlobalScope; |
||||||
|
export const CACHE_ASSETS_NAME = 'cachedAssets'; |
||||||
|
|
||||||
|
function isCorrectResponse(response: Response) { |
||||||
|
return response.ok && response.status === 200; |
||||||
|
} |
||||||
|
|
||||||
|
export async function requestCache(event: FetchEvent) { |
||||||
|
try { |
||||||
|
const cache = await ctx.caches.open(CACHE_ASSETS_NAME); |
||||||
|
const file = await cache.match(event.request, {ignoreVary: true}); |
||||||
|
|
||||||
|
if(file && isCorrectResponse(file)) { |
||||||
|
return file; |
||||||
|
} |
||||||
|
|
||||||
|
const headers: HeadersInit = {'Vary': '*'}; |
||||||
|
let response = await fetch(event.request, {headers}); |
||||||
|
if(isCorrectResponse(response)) { |
||||||
|
cache.put(event.request, response.clone()); |
||||||
|
} else if(response.status === 304) { // possible fix for 304 in Safari
|
||||||
|
const url = event.request.url.replace(/\?.+$/, '') + '?' + (Math.random() * 100000 | 0); |
||||||
|
response = await fetch(url, {headers}); |
||||||
|
if(isCorrectResponse(response)) { |
||||||
|
cache.put(event.request, response.clone()); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return response; |
||||||
|
} catch(err) { |
||||||
|
return fetch(event.request); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,151 @@ |
|||||||
|
/* |
||||||
|
* https://github.com/morethanwords/tweb
|
||||||
|
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||||
|
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||||
|
*/ |
||||||
|
|
||||||
|
/// #if MTPROTO_SW
|
||||||
|
import '../mtproto/mtproto.worker'; |
||||||
|
/// #endif
|
||||||
|
//import CacheStorageController from '../cacheStorage';
|
||||||
|
import type { WorkerTaskTemplate, WorkerTaskVoidTemplate } from '../../types'; |
||||||
|
import type { InputFileLocation, UploadFile } from '../../layer'; |
||||||
|
import type { WebPushApiManager } from '../mtproto/webPushApiManager'; |
||||||
|
import { logger, LogTypes } from '../logger'; |
||||||
|
import { CancellablePromise } from '../../helpers/cancellablePromise'; |
||||||
|
import './push'; |
||||||
|
import { CACHE_ASSETS_NAME, requestCache } from './cache'; |
||||||
|
import onStreamFetch from './stream'; |
||||||
|
import { closeAllNotifications, onPing, PushNotificationObject } from './push'; |
||||||
|
|
||||||
|
export const log = logger('SW', LogTypes.Error | LogTypes.Debug | LogTypes.Log | LogTypes.Warn); |
||||||
|
const ctx = self as any as ServiceWorkerGlobalScope; |
||||||
|
export const deferredPromises: {[taskId: number]: CancellablePromise<any>} = {}; |
||||||
|
|
||||||
|
export interface RequestFilePartTask extends WorkerTaskTemplate { |
||||||
|
type: 'requestFilePart', |
||||||
|
payload: [number, InputFileLocation, number, number] |
||||||
|
}; |
||||||
|
|
||||||
|
export interface RequestFilePartTaskResponse extends WorkerTaskTemplate { |
||||||
|
type: 'requestFilePart', |
||||||
|
payload?: UploadFile.uploadFile, |
||||||
|
originalPayload?: RequestFilePartTask['payload'] |
||||||
|
}; |
||||||
|
|
||||||
|
export interface ServiceWorkerPingTask extends WorkerTaskVoidTemplate { |
||||||
|
type: 'ping', |
||||||
|
payload: { |
||||||
|
localNotifications: boolean, |
||||||
|
lang: { |
||||||
|
push_action_mute1d: string |
||||||
|
push_action_settings: string |
||||||
|
push_message_nopreview: string |
||||||
|
}, |
||||||
|
settings: WebPushApiManager['settings'] |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
export interface ServiceWorkerNotificationsClearTask extends WorkerTaskVoidTemplate { |
||||||
|
type: 'notifications_clear' |
||||||
|
}; |
||||||
|
|
||||||
|
export interface ServiceWorkerPushClickTask extends WorkerTaskVoidTemplate { |
||||||
|
type: 'push_click', |
||||||
|
payload: PushNotificationObject |
||||||
|
}; |
||||||
|
|
||||||
|
export type ServiceWorkerTask = RequestFilePartTaskResponse | ServiceWorkerPingTask | ServiceWorkerNotificationsClearTask; |
||||||
|
|
||||||
|
/// #if !MTPROTO_SW
|
||||||
|
const taskListeners: { |
||||||
|
[type in ServiceWorkerTask['type']]: (task: any, event: ExtendableMessageEvent) => void |
||||||
|
} = { |
||||||
|
notifications_clear: () => { |
||||||
|
closeAllNotifications(); |
||||||
|
}, |
||||||
|
ping: (task: ServiceWorkerPingTask, event) => { |
||||||
|
onPing(task, event); |
||||||
|
}, |
||||||
|
requestFilePart: (task: RequestFilePartTaskResponse) => { |
||||||
|
const promise = deferredPromises[task.id]; |
||||||
|
|
||||||
|
if(task.error) { |
||||||
|
promise.reject(task.error); |
||||||
|
} else { |
||||||
|
promise.resolve(task.payload); |
||||||
|
} |
||||||
|
|
||||||
|
delete deferredPromises[task.id]; |
||||||
|
} |
||||||
|
}; |
||||||
|
ctx.addEventListener('message', (e) => { |
||||||
|
const task = e.data as ServiceWorkerTask; |
||||||
|
const callback = taskListeners[task.type]; |
||||||
|
if(callback) { |
||||||
|
callback(task, e); |
||||||
|
} |
||||||
|
}); |
||||||
|
/// #endif
|
||||||
|
|
||||||
|
//const cacheStorage = new CacheStorageController('cachedAssets');
|
||||||
|
let taskId = 0; |
||||||
|
|
||||||
|
export function getTaskId() { |
||||||
|
return taskId; |
||||||
|
} |
||||||
|
|
||||||
|
export function incrementTaskId() { |
||||||
|
return taskId++; |
||||||
|
} |
||||||
|
|
||||||
|
const onFetch = (event: FetchEvent): void => { |
||||||
|
if(event.request.url.indexOf(location.origin + '/') === 0 && event.request.url.match(/\.(js|css|jpe?g|json|wasm|png|mp3|svg|tgs|ico|woff2?|ttf|webmanifest?)(?:\?.*)?$/)) { |
||||||
|
return event.respondWith(requestCache(event)); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const [, url, scope, params] = /http[:s]+\/\/.*?(\/(.*?)(?:$|\/(.*)$))/.exec(event.request.url) || []; |
||||||
|
|
||||||
|
//log.debug('[fetch]:', event);
|
||||||
|
|
||||||
|
switch(scope) { |
||||||
|
case 'stream': { |
||||||
|
onStreamFetch(event, params); |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
} catch(err) { |
||||||
|
event.respondWith(new Response('', { |
||||||
|
status: 500, |
||||||
|
statusText: 'Internal Server Error', |
||||||
|
})); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const onChangeState = () => { |
||||||
|
ctx.onfetch = onFetch; |
||||||
|
}; |
||||||
|
|
||||||
|
ctx.addEventListener('install', (event) => { |
||||||
|
log('installing'); |
||||||
|
event.waitUntil(ctx.skipWaiting()); // Activate worker immediately
|
||||||
|
}); |
||||||
|
|
||||||
|
ctx.addEventListener('activate', (event) => { |
||||||
|
log('activating', ctx); |
||||||
|
event.waitUntil(ctx.caches.delete(CACHE_ASSETS_NAME)); |
||||||
|
event.waitUntil(ctx.clients.claim()); |
||||||
|
}); |
||||||
|
|
||||||
|
ctx.onerror = (error) => { |
||||||
|
log.error('error:', error); |
||||||
|
}; |
||||||
|
|
||||||
|
ctx.onunhandledrejection = (error) => { |
||||||
|
log.error('onunhandledrejection:', error); |
||||||
|
}; |
||||||
|
|
||||||
|
ctx.onoffline = ctx.ononline = onChangeState; |
||||||
|
|
||||||
|
onChangeState(); |
@ -0,0 +1,338 @@ |
|||||||
|
/* |
||||||
|
* https://github.com/morethanwords/tweb
|
||||||
|
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||||
|
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||||
|
* |
||||||
|
* Originally from: |
||||||
|
* https://github.com/zhukov/webogram
|
||||||
|
* Copyright (C) 2014 Igor Zhukov <igor.beatle@gmail.com> |
||||||
|
* https://github.com/zhukov/webogram/blob/master/LICENSE
|
||||||
|
*/ |
||||||
|
|
||||||
|
import { Database } from "../../config/databases"; |
||||||
|
import DATABASE_STATE from "../../config/databases/state"; |
||||||
|
import { isFirefox } from "../../helpers/userAgent"; |
||||||
|
import IDBStorage from "../idb"; |
||||||
|
import { log, ServiceWorkerPingTask, ServiceWorkerPushClickTask } from "./index.service"; |
||||||
|
|
||||||
|
const ctx = self as any as ServiceWorkerGlobalScope; |
||||||
|
const defaultBaseUrl = location.protocol + '//' + location.hostname + location.pathname.split('/').slice(0, -1).join('/') + '/'; |
||||||
|
|
||||||
|
export type PushNotificationObject = { |
||||||
|
loc_key: string, |
||||||
|
loc_args: string[], |
||||||
|
//user_id: number, // should be number
|
||||||
|
custom: { |
||||||
|
channel_id?: string, // should be number
|
||||||
|
chat_id?: string, // should be number
|
||||||
|
from_id?: string, // should be number
|
||||||
|
msg_id: string, |
||||||
|
peerId?: string // should be number
|
||||||
|
}, |
||||||
|
sound?: string, |
||||||
|
random_id: number, |
||||||
|
badge?: string, // should be number
|
||||||
|
description: string, |
||||||
|
mute: string, // should be number
|
||||||
|
title: string, |
||||||
|
|
||||||
|
action?: 'mute1d' | 'push_settings', // will be set before postMessage to main thread
|
||||||
|
}; |
||||||
|
|
||||||
|
class SomethingGetter<T extends Database<any>, Storage extends Record<string, any>> { |
||||||
|
private cache: Partial<Storage> = {}; |
||||||
|
private storage: IDBStorage<T>; |
||||||
|
|
||||||
|
constructor( |
||||||
|
db: T, |
||||||
|
storeName: typeof db['stores'][number]['name'], |
||||||
|
private defaults: { |
||||||
|
[Property in keyof Storage]: ((value: Storage[Property]) => Storage[Property]) | Storage[Property] |
||||||
|
} |
||||||
|
) { |
||||||
|
this.storage = new IDBStorage<T>(db, storeName); |
||||||
|
} |
||||||
|
|
||||||
|
public async get<T extends keyof Storage>(key: T) { |
||||||
|
if(this.cache[key] !== undefined) { |
||||||
|
return this.cache[key]; |
||||||
|
} |
||||||
|
|
||||||
|
let value: Storage[T]; |
||||||
|
try { |
||||||
|
value = await this.storage.get(key as string); |
||||||
|
} catch(err) { |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
if(this.cache[key] !== undefined) { |
||||||
|
return this.cache[key]; |
||||||
|
} |
||||||
|
|
||||||
|
if(value === undefined) { |
||||||
|
const callback = this.defaults[key]; |
||||||
|
value = typeof(callback) === 'function' ? callback() : callback; |
||||||
|
} |
||||||
|
|
||||||
|
return this.cache[key] = value; |
||||||
|
} |
||||||
|
|
||||||
|
public async set<T extends keyof Storage>(key: T, value: Storage[T]) { |
||||||
|
this.cache[key] = value; |
||||||
|
|
||||||
|
try { |
||||||
|
this.storage.save(key as string, value); |
||||||
|
} catch(err) { |
||||||
|
|
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
type PushStorage = { |
||||||
|
push_mute_until: number, |
||||||
|
push_last_alive: number, |
||||||
|
push_lang: any |
||||||
|
push_settings: any |
||||||
|
}; |
||||||
|
|
||||||
|
const getter = new SomethingGetter<typeof DATABASE_STATE, PushStorage>(DATABASE_STATE, 'session', { |
||||||
|
push_mute_until: 0, |
||||||
|
push_last_alive: 0, |
||||||
|
push_lang: {}, |
||||||
|
push_settings: {} |
||||||
|
}); |
||||||
|
|
||||||
|
ctx.addEventListener('push', (event) => { |
||||||
|
const obj: PushNotificationObject = event.data.json(); |
||||||
|
log('push', obj); |
||||||
|
|
||||||
|
let hasActiveWindows = false; |
||||||
|
const checksPromise = Promise.all([ |
||||||
|
getter.get('push_mute_until'), |
||||||
|
getter.get('push_last_alive'), |
||||||
|
ctx.clients.matchAll({type: 'window'}) |
||||||
|
]).then((result) => { |
||||||
|
const [muteUntil, lastAliveTime, clientList] = result; |
||||||
|
|
||||||
|
log('matched clients', clientList); |
||||||
|
hasActiveWindows = clientList.length > 0; |
||||||
|
if(hasActiveWindows) { |
||||||
|
throw 'Supress notification because some instance is alive'; |
||||||
|
} |
||||||
|
|
||||||
|
const nowTime = Date.now(); |
||||||
|
if(userInvisibleIsSupported() && |
||||||
|
muteUntil && |
||||||
|
nowTime < muteUntil) { |
||||||
|
throw `Supress notification because mute for ${Math.ceil((muteUntil - nowTime) / 60000)} min`; |
||||||
|
} |
||||||
|
|
||||||
|
if(!obj.badge) { |
||||||
|
throw 'No badge?'; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
checksPromise.catch(reason => { |
||||||
|
log(reason); |
||||||
|
}); |
||||||
|
|
||||||
|
const notificationPromise = checksPromise.then(() => { |
||||||
|
return Promise.all([getter.get('push_settings'), getter.get('push_lang')]) |
||||||
|
}).then((result) => { |
||||||
|
return fireNotification(obj, result[0], result[1]); |
||||||
|
}); |
||||||
|
|
||||||
|
const closePromise = notificationPromise.catch(() => { |
||||||
|
log('Closing all notifications on push', hasActiveWindows); |
||||||
|
if(userInvisibleIsSupported() || hasActiveWindows) { |
||||||
|
return closeAllNotifications(); |
||||||
|
} |
||||||
|
|
||||||
|
return ctx.registration.showNotification('Telegram', { |
||||||
|
tag: 'unknown_peer' |
||||||
|
}).then(() => { |
||||||
|
if(hasActiveWindows) { |
||||||
|
return closeAllNotifications(); |
||||||
|
} |
||||||
|
|
||||||
|
setTimeout(() => closeAllNotifications(), hasActiveWindows ? 0 : 100); |
||||||
|
}).catch((error) => { |
||||||
|
log.error('Show notification error', error); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
event.waitUntil(closePromise); |
||||||
|
}); |
||||||
|
|
||||||
|
ctx.addEventListener('notificationclick', (event) => { |
||||||
|
const notification = event.notification; |
||||||
|
log('On notification click: ', notification.tag); |
||||||
|
notification.close(); |
||||||
|
|
||||||
|
const action = event.action as PushNotificationObject['action']; |
||||||
|
if(action === 'mute1d' && userInvisibleIsSupported()) { |
||||||
|
log('[SW] mute for 1d'); |
||||||
|
getter.set('push_mute_until', Date.now() + 86400e3); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const data: PushNotificationObject = notification.data; |
||||||
|
if(!data) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const promise = ctx.clients.matchAll({ |
||||||
|
type: 'window' |
||||||
|
}).then((clientList) => { |
||||||
|
data.action = action; |
||||||
|
pendingNotification = {type: 'push_click', payload: data}; |
||||||
|
for(let i = 0; i < clientList.length; i++) { |
||||||
|
const client = clientList[i]; |
||||||
|
if('focus' in client) { |
||||||
|
client.focus(); |
||||||
|
client.postMessage(pendingNotification); |
||||||
|
pendingNotification = undefined; |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if(ctx.clients.openWindow) { |
||||||
|
return getter.get('push_settings').then((settings) => { |
||||||
|
return ctx.clients.openWindow(settings.baseUrl || defaultBaseUrl); |
||||||
|
}); |
||||||
|
} |
||||||
|
}).catch((error) => { |
||||||
|
log.error('Clients.matchAll error', error); |
||||||
|
}) |
||||||
|
|
||||||
|
event.waitUntil(promise); |
||||||
|
}); |
||||||
|
|
||||||
|
ctx.addEventListener('notificationclose', onCloseNotification); |
||||||
|
|
||||||
|
let notifications: Set<Notification> = new Set(); |
||||||
|
let pendingNotification: ServiceWorkerPushClickTask; |
||||||
|
function pushToNotifications(notification: Notification) { |
||||||
|
if(!notifications.has(notification)) { |
||||||
|
notifications.add(notification); |
||||||
|
// @ts-ignore
|
||||||
|
notification.onclose = onCloseNotification; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function onCloseNotification(event: NotificationEvent) { |
||||||
|
removeFromNotifications(event.notification) |
||||||
|
} |
||||||
|
|
||||||
|
function removeFromNotifications(notification: Notification) { |
||||||
|
notifications.delete(notification); |
||||||
|
} |
||||||
|
|
||||||
|
export function closeAllNotifications() { |
||||||
|
for(const notification of notifications) { |
||||||
|
try { |
||||||
|
notification.close(); |
||||||
|
} catch(e) {} |
||||||
|
} |
||||||
|
|
||||||
|
let promise: Promise<void>; |
||||||
|
if('getNotifications' in ctx.registration) { |
||||||
|
promise = ctx.registration.getNotifications({}).then((notifications) => { |
||||||
|
for(let i = 0, len = notifications.length; i < len; ++i) { |
||||||
|
try { |
||||||
|
notifications[i].close(); |
||||||
|
} catch(e) {} |
||||||
|
} |
||||||
|
}).catch((error) => { |
||||||
|
log.error('Offline register SW error', error); |
||||||
|
}); |
||||||
|
} else { |
||||||
|
promise = Promise.resolve(); |
||||||
|
} |
||||||
|
|
||||||
|
notifications.clear(); |
||||||
|
|
||||||
|
return promise; |
||||||
|
} |
||||||
|
|
||||||
|
function userInvisibleIsSupported() { |
||||||
|
return isFirefox; |
||||||
|
} |
||||||
|
|
||||||
|
function fireNotification(obj: PushNotificationObject, settings: PushStorage['push_settings'], lang: PushStorage['push_lang']) { |
||||||
|
const icon = 'assets/img/logo_filled_rounded.png'; |
||||||
|
let title = obj.title || 'Telegram'; |
||||||
|
let body = obj.description || ''; |
||||||
|
let peerId: number; |
||||||
|
|
||||||
|
if(obj.custom) { |
||||||
|
if(obj.custom.channel_id) { |
||||||
|
peerId = -obj.custom.channel_id; |
||||||
|
} else if(obj.custom.chat_id) { |
||||||
|
peerId = -obj.custom.chat_id; |
||||||
|
} else { |
||||||
|
peerId = +obj.custom.from_id || 0; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
obj.custom.peerId = '' + peerId; |
||||||
|
let tag = 'peer' + peerId; |
||||||
|
|
||||||
|
if(settings && settings.nopreview) { |
||||||
|
title = 'Telegram'; |
||||||
|
body = lang.push_message_nopreview || 'You have a new message'; |
||||||
|
tag = 'unknown_peer'; |
||||||
|
} |
||||||
|
|
||||||
|
log('show notify', title, body, icon, obj); |
||||||
|
|
||||||
|
const actions: (Omit<NotificationAction, 'action'> & {action: PushNotificationObject['action']})[] = [{ |
||||||
|
action: 'mute1d', |
||||||
|
title: lang.push_action_mute1d || 'Mute for 24H' |
||||||
|
}/* , { |
||||||
|
action: 'push_settings', |
||||||
|
title: lang.push_action_settings || 'Settings' |
||||||
|
} */]; |
||||||
|
|
||||||
|
const notificationPromise = ctx.registration.showNotification(title, { |
||||||
|
body, |
||||||
|
icon, |
||||||
|
tag, |
||||||
|
data: obj, |
||||||
|
actions |
||||||
|
}); |
||||||
|
|
||||||
|
return notificationPromise.then((event) => { |
||||||
|
// @ts-ignore
|
||||||
|
if(event && event.notification) { |
||||||
|
// @ts-ignore
|
||||||
|
pushToNotifications(event.notification); |
||||||
|
} |
||||||
|
}).catch((error) => { |
||||||
|
log.error('Show notification promise', error); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
export function onPing(task: ServiceWorkerPingTask, event: ExtendableMessageEvent) { |
||||||
|
const client = event.ports && event.ports[0] || event.source; |
||||||
|
const payload = task.payload; |
||||||
|
|
||||||
|
if(payload.localNotifications) { |
||||||
|
getter.set('push_last_alive', Date.now()); |
||||||
|
} |
||||||
|
|
||||||
|
if(pendingNotification && |
||||||
|
client && |
||||||
|
'postMessage' in client) { |
||||||
|
client.postMessage(pendingNotification, []); |
||||||
|
pendingNotification = undefined; |
||||||
|
} |
||||||
|
|
||||||
|
if(payload.lang) { |
||||||
|
getter.set('push_lang', payload.lang); |
||||||
|
} |
||||||
|
|
||||||
|
if(payload.settings) { |
||||||
|
getter.set('push_settings', payload.settings); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,130 @@ |
|||||||
|
/* |
||||||
|
* https://github.com/morethanwords/tweb
|
||||||
|
* Copyright (C) 2019-2021 Eduard Kuzmenko |
||||||
|
* https://github.com/morethanwords/tweb/blob/master/LICENSE
|
||||||
|
*/ |
||||||
|
|
||||||
|
import { deferredPromise } from "../../helpers/cancellablePromise"; |
||||||
|
import { notifySomeone } from "../../helpers/context"; |
||||||
|
import { isSafari } from "../../helpers/userAgent"; |
||||||
|
import { UploadFile } from "../../layer"; |
||||||
|
import { DownloadOptions } from "../mtproto/apiFileManager"; |
||||||
|
import { RequestFilePartTask, deferredPromises, incrementTaskId } from "./index.service"; |
||||||
|
import timeout from "./timeout"; |
||||||
|
|
||||||
|
export default function onStreamFetch(event: FetchEvent, params: string) { |
||||||
|
const range = parseRange(event.request.headers.get('Range')); |
||||||
|
let [offset, end] = range; |
||||||
|
|
||||||
|
const info: DownloadOptions = JSON.parse(decodeURIComponent(params)); |
||||||
|
//const fileName = getFileNameByLocation(info.location);
|
||||||
|
|
||||||
|
// ! если грузить очень большое видео чанками по 512Кб в мобильном Safari, то стрим не запустится
|
||||||
|
const limitPart = info.size > (75 * 1024 * 1024) ? STREAM_CHUNK_UPPER_LIMIT : STREAM_CHUNK_MIDDLE_LIMIT; |
||||||
|
|
||||||
|
/* if(info.size > limitPart && isSafari && offset === limitPart) { |
||||||
|
//end = info.size - 1;
|
||||||
|
//offset = info.size - 1 - limitPart;
|
||||||
|
offset = info.size - (info.size % limitPart); |
||||||
|
} */ |
||||||
|
|
||||||
|
//log.debug('[stream]', url, offset, end);
|
||||||
|
|
||||||
|
event.respondWith(Promise.race([ |
||||||
|
timeout(45 * 1000), |
||||||
|
|
||||||
|
new Promise<Response>((resolve, reject) => { |
||||||
|
// safari workaround
|
||||||
|
const possibleResponse = responseForSafariFirstRange(range, info.mimeType, info.size); |
||||||
|
if(possibleResponse) { |
||||||
|
return resolve(possibleResponse); |
||||||
|
} |
||||||
|
|
||||||
|
const limit = end && end < limitPart ? alignLimit(end - offset + 1) : limitPart; |
||||||
|
const alignedOffset = alignOffset(offset, limit); |
||||||
|
|
||||||
|
//log.debug('[stream] requestFilePart:', /* info.dcId, info.location, */ alignedOffset, limit);
|
||||||
|
|
||||||
|
const task: RequestFilePartTask = { |
||||||
|
type: 'requestFilePart', |
||||||
|
id: incrementTaskId(), |
||||||
|
payload: [info.dcId, info.location, alignedOffset, limit] |
||||||
|
}; |
||||||
|
|
||||||
|
|
||||||
|
const deferred = deferredPromises[task.id] = deferredPromise<UploadFile.uploadFile>(); |
||||||
|
deferred.then(result => { |
||||||
|
let ab = result.bytes as Uint8Array; |
||||||
|
|
||||||
|
//log.debug('[stream] requestFilePart result:', result);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { |
||||||
|
'Accept-Ranges': 'bytes', |
||||||
|
'Content-Range': `bytes ${alignedOffset}-${alignedOffset + ab.byteLength - 1}/${info.size || '*'}`, |
||||||
|
'Content-Length': `${ab.byteLength}`, |
||||||
|
}; |
||||||
|
|
||||||
|
if(info.mimeType) headers['Content-Type'] = info.mimeType; |
||||||
|
|
||||||
|
if(isSafari) { |
||||||
|
ab = ab.slice(offset - alignedOffset, end - alignedOffset + 1); |
||||||
|
headers['Content-Range'] = `bytes ${offset}-${offset + ab.byteLength - 1}/${info.size || '*'}`; |
||||||
|
headers['Content-Length'] = `${ab.byteLength}`; |
||||||
|
} |
||||||
|
|
||||||
|
// simulate slow connection
|
||||||
|
//setTimeout(() => {
|
||||||
|
resolve(new Response(ab, { |
||||||
|
status: 206, |
||||||
|
statusText: 'Partial Content', |
||||||
|
headers, |
||||||
|
})); |
||||||
|
//}, 2.5e3);
|
||||||
|
}).catch(err => {}); |
||||||
|
|
||||||
|
notifySomeone(task); |
||||||
|
}) |
||||||
|
])); |
||||||
|
} |
||||||
|
|
||||||
|
function responseForSafariFirstRange(range: [number, number], mimeType: string, size: number): Response { |
||||||
|
if(range[0] === 0 && range[1] === 1) { |
||||||
|
return new Response(new Uint8Array(2).buffer, { |
||||||
|
status: 206, |
||||||
|
statusText: 'Partial Content', |
||||||
|
headers: { |
||||||
|
'Accept-Ranges': 'bytes', |
||||||
|
'Content-Range': `bytes 0-1/${size || '*'}`, |
||||||
|
'Content-Length': '2', |
||||||
|
'Content-Type': mimeType || 'video/mp4', |
||||||
|
}, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
/* const STREAM_CHUNK_UPPER_LIMIT = 256 * 1024; |
||||||
|
const SMALLEST_CHUNK_LIMIT = 256 * 4; */ |
||||||
|
/* const STREAM_CHUNK_UPPER_LIMIT = 1024 * 1024; |
||||||
|
const SMALLEST_CHUNK_LIMIT = 1024 * 4; */ |
||||||
|
const STREAM_CHUNK_MIDDLE_LIMIT = 512 * 1024; |
||||||
|
const STREAM_CHUNK_UPPER_LIMIT = 1024 * 1024; |
||||||
|
const SMALLEST_CHUNK_LIMIT = 512 * 4; |
||||||
|
|
||||||
|
function parseRange(header: string): [number, number] { |
||||||
|
if(!header) return [0, 0]; |
||||||
|
const [, chunks] = header.split('='); |
||||||
|
const ranges = chunks.split(', '); |
||||||
|
const [offset, end] = ranges[0].split('-'); |
||||||
|
|
||||||
|
return [+offset, +end || 0]; |
||||||
|
} |
||||||
|
|
||||||
|
function alignOffset(offset: number, base = SMALLEST_CHUNK_LIMIT) { |
||||||
|
return offset - (offset % base); |
||||||
|
} |
||||||
|
|
||||||
|
function alignLimit(limit: number) { |
||||||
|
return 2 ** Math.ceil(Math.log(limit) / Math.log(2)); |
||||||
|
} |
@ -0,0 +1,10 @@ |
|||||||
|
export default function timeout(delay: number): Promise<Response> { |
||||||
|
return new Promise(((resolve) => { |
||||||
|
setTimeout(() => { |
||||||
|
resolve(new Response('', { |
||||||
|
status: 408, |
||||||
|
statusText: 'Request timed out.', |
||||||
|
})); |
||||||
|
}, delay); |
||||||
|
})); |
||||||
|
} |
Loading…
Reference in new issue