Eduard Kuzmenko
4 years ago
18 changed files with 1046 additions and 343 deletions
@ -1,286 +0,0 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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