diff --git a/src/index.ts b/src/index.ts index 93ff04ae..b5ef9c91 100644 --- a/src/index.ts +++ b/src/index.ts @@ -313,6 +313,12 @@ console.timeEnd('get storage1'); */ scrollable.append(placeholder.cloneNode()); } + try { + (await import('./lib/mtproto/webPushApiManager')).default.forceUnsubscribe(); + } catch(err) { + + } + let pagePromise: Promise; //langPromise.then(async() => { switch(authState._) { diff --git a/src/lang.ts b/src/lang.ts index 7155492d..2780bc7a 100644 --- a/src/lang.ts +++ b/src/lang.ts @@ -117,6 +117,12 @@ const lang = { "other_value": "Forwarded %d messages" }, "Notifications.New": "New notification", + "PushNotification.Action.Mute1d": "Mute background alerts for 1 day", + "PushNotification.Action.Settings": "Background alerts settings", + "PushNotification.Action.Mute1d.Mobile": "Mute for 24H", + "PushNotification.Action.Settings.Mobile": "Alerts settings", + "PushNotification.Message.NoPreview": "You have a new message", + //"PushNotification.Action.Mute1d.Success": "Notification settings were successfully saved.", // * android "ActionCreateChannel": "Channel created", diff --git a/src/lib/appManagers/appImManager.ts b/src/lib/appManagers/appImManager.ts index 095db992..1789cce5 100644 --- a/src/lib/appManagers/appImManager.ts +++ b/src/lib/appManagers/appImManager.ts @@ -171,7 +171,11 @@ export class AppImManager { }); rootScope.addEventListener('history_focus', (e) => { - const {peerId, mid} = e; + let {peerId, mid} = e; + if(mid) { + mid = appMessagesManager.generateMessageId(mid); // because mid can come from notification, i.e. server message id + } + this.setInnerPeer(peerId, mid); }); @@ -204,8 +208,7 @@ export class AppImManager { (popup as any).onClose = () => { document.body.classList.add('deactivated-backwards'); - singleInstance.reset(); - singleInstance.checkInstance(false); + singleInstance.activateInstance(); setTimeout(() => { document.body.classList.remove('deactivated', 'deactivated-backwards'); diff --git a/src/lib/appManagers/appNotificationsManager.ts b/src/lib/appManagers/appNotificationsManager.ts index 113f9a4a..d60f4bdf 100644 --- a/src/lib/appManagers/appNotificationsManager.ts +++ b/src/lib/appManagers/appNotificationsManager.ts @@ -19,11 +19,14 @@ import { isMobile } from "../../helpers/userAgent"; import { InputNotifyPeer, InputPeerNotifySettings, NotifyPeer, PeerNotifySettings, Update } from "../../layer"; import I18n from "../langPack"; import apiManager from "../mtproto/mtprotoworker"; +import webPushApiManager, { PushSubscriptionNotify } from "../mtproto/webPushApiManager"; import rootScope from "../rootScope"; import stateStorage from "../stateStorage"; import apiUpdatesManager from "./apiUpdatesManager"; +import appChatsManager from "./appChatsManager"; import appPeersManager from "./appPeersManager"; import appStateManager from "./appStateManager"; +import appUsersManager from "./appUsersManager"; type MyNotification = Notification & { hidden?: boolean, @@ -40,6 +43,15 @@ export type NotifyOptions = Partial<{ onclick: () => void; }>; +export type NotificationSettings = { + nodesktop: boolean, + volume: number, + novibrate: boolean, + nopreview: boolean, + nopush: boolean, + nosound: boolean +}; + type ImSadAboutIt = Promise | PeerNotifySettings; export class AppNotificationsManager { private notificationsUiSupport: boolean; @@ -66,14 +78,7 @@ export class AppNotificationsManager { private prevFavicon: string; private stopped = false; - private settings: Partial<{ - nodesktop: boolean, - volume: number, - novibrate: boolean, - nopreview: boolean, - nopush: boolean, - nosound: boolean, - }> = {}; + private settings: NotificationSettings = {} as any; private registeredDevice: any; private pushInited = false; @@ -96,11 +101,15 @@ export class AppNotificationsManager { this.notifySoundEl.id = 'notify-sound'; document.body.append(this.notifySoundEl); - /* rootScope.on('idle.deactivated', (newVal) => { - if(newVal) { - stop(); + rootScope.addEventListener('instance_deactivated', () => { + this.stop(); + }); + + rootScope.addEventListener('instance_activated', () => { + if(this.stopped) { + this.start(); } - });*/ + }); rootScope.addEventListener('idle', (newVal) => { if(this.stopped) { @@ -121,40 +130,40 @@ export class AppNotificationsManager { } }); - /* rootScope.on('push_init', (tokenData) => { - this.pushInited = true + rootScope.addEventListener('push_init', (tokenData) => { + this.pushInited = true; if(!this.settings.nodesktop && !this.settings.nopush) { if(tokenData) { this.registerDevice(tokenData); } else { - WebPushApiManager.subscribe(); + webPushApiManager.subscribe(); } } else { this.unregisterDevice(tokenData); } }); - rootScope.on('push_subscribe', (tokenData) => { + rootScope.addEventListener('push_subscribe', (tokenData) => { this.registerDevice(tokenData); }); - rootScope.on('push_unsubscribe', (tokenData) => { + rootScope.addEventListener('push_unsubscribe', (tokenData) => { this.unregisterDevice(tokenData); - }); */ + }); rootScope.addEventListener('dialogs_multiupdate', () => { //unregisterTopMsgs() this.topMessagesDeferred.resolve(); }, true); - /* rootScope.on('push_notification_click', (notificationData) => { + rootScope.addEventListener('push_notification_click', (notificationData) => { if(notificationData.action === 'push_settings') { - this.topMessagesDeferred.then(() => { + /* this.topMessagesDeferred.then(() => { $modal.open({ templateUrl: templateUrl('settings_modal'), controller: 'SettingsModalController', windowClass: 'settings_modal_window mobile_modal', backdrop: 'single' }) - }); + }); */ return; } @@ -176,12 +185,12 @@ export class AppNotificationsManager { return; } - const peerId = notificationData.custom && notificationData.custom.peerId; + const peerId = notificationData.custom && +notificationData.custom.peerId; console.log('click', notificationData, peerId); if(peerId) { this.topMessagesDeferred.then(() => { if(notificationData.custom.channel_id && - !appChatsManager.hasChat(notificationData.custom.channel_id)) { + !appChatsManager.hasChat(+notificationData.custom.channel_id)) { return; } @@ -189,12 +198,13 @@ export class AppNotificationsManager { return; } - // rootScope.broadcast('history_focus', { - // peerString: appPeersManager.getPeerString(peerId) - // }); + rootScope.dispatchEvent('history_focus', { + peerId, + mid: +notificationData.custom.msg_id + }); }); } - }); */ + }); } private toggleToggler(enable = rootScope.idle.isIDLE) { @@ -276,19 +286,19 @@ export class AppNotificationsManager { this.settings.nopreview = updSettings[3]; this.settings.nopush = updSettings[4]; - /* if(this.pushInited) { - const needPush = !this.settings.nopush && !this.settings.nodesktop && WebPushApiManager.isAvailable || false; + if(this.pushInited) { + const needPush = !this.settings.nopush && !this.settings.nodesktop && webPushApiManager.isAvailable || false; const hasPush = this.registeredDevice !== false; if(needPush !== hasPush) { if(needPush) { - WebPushApiManager.subscribe(); + webPushApiManager.subscribe(); } else { - WebPushApiManager.unsubscribe(); + webPushApiManager.unsubscribe(); } } } - WebPushApiManager.setSettings(this.settings); */ + webPushApiManager.setSettings(this.settings); }); appStateManager.getState().then(state => { @@ -457,7 +467,7 @@ export class AppNotificationsManager { public start() { this.updateLocalSettings(); rootScope.addEventListener('settings_updated', this.updateLocalSettings); - //WebPushApiManager.start(); + webPushApiManager.start(); if(!this.notificationsUiSupport) { return false; @@ -571,7 +581,7 @@ export class AppNotificationsManager { //console.log('notify constructed notification'); } catch(e) { this.notificationsUiSupport = false; - //WebPushApiManager.setLocalNotificationsDisabled(); + webPushApiManager.setLocalNotificationsDisabled(); return; } } /* else if('mozNotification' in navigator) { @@ -692,12 +702,11 @@ export class AppNotificationsManager { this.notificationsShown = {}; this.notificationsCount = 0; - //WebPushApiManager.hidePushNotifications(); + webPushApiManager.hidePushNotifications(); } - private registerDevice(tokenData: any) { - if(this.registeredDevice && - deepEqual(this.registeredDevice, tokenData)) { + private registerDevice(tokenData: PushSubscriptionNotify) { + if(this.registeredDevice && deepEqual(this.registeredDevice, tokenData)) { return false; } @@ -711,10 +720,10 @@ export class AppNotificationsManager { this.registeredDevice = tokenData; }, (error) => { error.handled = true; - }) + }); } - private unregisterDevice(tokenData: any) { + private unregisterDevice(tokenData: PushSubscriptionNotify) { if(!this.registeredDevice) { return false; } @@ -724,10 +733,10 @@ export class AppNotificationsManager { token: tokenData.tokenValue, other_uids: [] }).then(() => { - this.registeredDevice = false + this.registeredDevice = false; }, (error) => { - error.handled = true - }) + error.handled = true; + }); } public getVibrateSupport() { diff --git a/src/lib/mtproto/apiManager.ts b/src/lib/mtproto/apiManager.ts index 1272d1f8..33c3f540 100644 --- a/src/lib/mtproto/apiManager.ts +++ b/src/lib/mtproto/apiManager.ts @@ -157,7 +157,7 @@ export class ApiManager { storageKeys.push(prefix + dcId + '_auth_key'); } - // WebPushApiManager.forceUnsubscribe(); // WARNING + // WebPushApiManager.forceUnsubscribe(); // WARNING // moved to worker's master const storageResult = await Promise.all(storageKeys.map(key => sessionStorage.get(key as any))); const logoutPromises: Promise[] = []; diff --git a/src/lib/mtproto/mtproto.service.ts b/src/lib/mtproto/mtproto.service.ts deleted file mode 100644 index 8ee83458..00000000 --- a/src/lib/mtproto/mtproto.service.ts +++ /dev/null @@ -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} = {}; - -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((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(); - deferred.then(result => { - let ab = result.bytes as Uint8Array; - - //log.debug('[stream] requestFilePart result:', result); - - const headers: Record = { - '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 { - 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; diff --git a/src/lib/mtproto/mtproto.worker.ts b/src/lib/mtproto/mtproto.worker.ts index 4c3460e7..036d2daf 100644 --- a/src/lib/mtproto/mtproto.worker.ts +++ b/src/lib/mtproto/mtproto.worker.ts @@ -11,7 +11,7 @@ import apiManager from "./apiManager"; import cryptoWorker from "../crypto/cryptoworker"; import networkerFactory from "./networkerFactory"; import apiFileManager from './apiFileManager'; -import type { ServiceWorkerTask, ServiceWorkerTaskResponse } from './mtproto.service'; +import type { RequestFilePartTask, RequestFilePartTaskResponse } from '../serviceWorker/index.service'; import { ctx } from '../../helpers/userAgent'; import { socketsProxied } from './dcConfigurator'; import { notifyAll } from '../../helpers/context'; @@ -44,8 +44,8 @@ const taskListeners = { } }, - requestFilePart: async(task: ServiceWorkerTask) => { - const responseTask: ServiceWorkerTaskResponse = { + requestFilePart: async(task: RequestFilePartTask) => { + const responseTask: RequestFilePartTaskResponse = { type: task.type, id: task.id }; diff --git a/src/lib/mtproto/mtprotoworker.ts b/src/lib/mtproto/mtprotoworker.ts index fae97f91..cb2de0f4 100644 --- a/src/lib/mtproto/mtprotoworker.ts +++ b/src/lib/mtproto/mtprotoworker.ts @@ -16,7 +16,7 @@ import { logger } from '../logger'; import rootScope from '../rootScope'; import webpWorkerController from '../webp/webpWorkerController'; import type { DownloadOptions } from './apiFileManager'; -import type { ServiceWorkerTask } from './mtproto.service'; +import type { ServiceWorkerTask } from '../serviceWorker/index.service'; import { UserAuth } from './mtproto_config'; import type { MTMessage } from './networker'; import DEBUG, { MOUNT_CLASS_TO } from '../../config/debug'; @@ -24,6 +24,7 @@ import Socket from './transports/websocket'; import IDBStorage from '../idb'; import singleInstance from './singleInstance'; import sessionStorage from '../sessionStorage'; +import webPushApiManager from './webPushApiManager'; type Task = { taskId: number, @@ -83,6 +84,7 @@ export class ApiManagerProxy extends CryptoWorkerMethods { private sockets: Map = new Map(); private taskListeners: {[taskType: string]: (task: any) => void} = {}; + private taskListenersSW: {[taskType: string]: (task: any) => void} = {}; public onServiceWorkerFail: () => void; @@ -97,7 +99,8 @@ export class ApiManagerProxy extends CryptoWorkerMethods { this.addTaskListener('clear', () => { Promise.all([ IDBStorage.deleteDatabase(), - sessionStorage.clear() + sessionStorage.clear(), + webPushApiManager.forceUnsubscribe() ]).finally(() => { location.reload(); }); @@ -235,7 +238,14 @@ export class ApiManagerProxy extends CryptoWorkerMethods { if(!isObject(task)) { return; } - + + const callback = this.taskListenersSW[task.type]; + if(callback) { + callback(task); + } + }); + + this.addServiceWorkerTaskListener('requestFilePart', (task) => { this.postMessage(task); }); /// #endif @@ -264,6 +274,10 @@ export class ApiManagerProxy extends CryptoWorkerMethods { this.taskListeners[name] = callback; } + public addServiceWorkerTaskListener(name: keyof ApiManagerProxy['taskListenersSW'], callback: ApiManagerProxy['taskListenersSW'][typeof name]) { + this.taskListenersSW[name] = callback; + } + private onWorkerMessage = (e: MessageEvent) => { //this.log('got message from worker:', e.data); diff --git a/src/lib/mtproto/referenceDatabase.ts b/src/lib/mtproto/referenceDatabase.ts index 47683d39..7d4cfc4f 100644 --- a/src/lib/mtproto/referenceDatabase.ts +++ b/src/lib/mtproto/referenceDatabase.ts @@ -4,7 +4,7 @@ * https://github.com/morethanwords/tweb/blob/master/LICENSE */ -import type { ServiceWorkerTask, ServiceWorkerTaskResponse } from "./mtproto.service"; +import type { RequestFilePartTask, RequestFilePartTaskResponse } from "../serviceWorker/index.service"; import type { ApiError } from "./apiManager"; import appMessagesManager from "../appManagers/appMessagesManager"; import { Photo } from "../../layer"; @@ -38,7 +38,7 @@ class ReferenceDatabase { private links: {[hex: string]: ReferenceBytes} = {}; constructor() { - apiManager.addTaskListener('requestFilePart', (task: ServiceWorkerTaskResponse) => { + apiManager.addTaskListener('requestFilePart', (task: RequestFilePartTaskResponse) => { if(task.error) { const onError = (error: ApiError) => { if(error?.type === 'FILE_REFERENCE_EXPIRED') { @@ -47,7 +47,7 @@ class ReferenceDatabase { referenceDatabase.refreshReference(bytes).then(() => { // @ts-ignore task.originalPayload[1].file_reference = referenceDatabase.getReferenceByLink(bytes); - const newTask: ServiceWorkerTask = { + const newTask: RequestFilePartTask = { type: task.type, id: task.id, payload: task.originalPayload diff --git a/src/lib/mtproto/singleInstance.ts b/src/lib/mtproto/singleInstance.ts index 1e4b5852..e41c57ef 100644 --- a/src/lib/mtproto/singleInstance.ts +++ b/src/lib/mtproto/singleInstance.ts @@ -1,3 +1,14 @@ +/* + * 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 + * https://github.com/zhukov/webogram/blob/master/LICENSE + */ + import { MOUNT_CLASS_TO } from "../../config/debug"; import { nextRandomInt } from "../../helpers/random"; import { logger } from "../logger"; @@ -57,6 +68,14 @@ export class SingleInstance { } }; + public activateInstance() { + if(this.deactivated) { + this.reset(); + this.checkInstance(false); + rootScope.dispatchEvent('instance_activated'); + } + } + public deactivateInstance = () => { if(this.masterInstance || this.deactivated) { return false; diff --git a/src/lib/mtproto/webPushApiManager.ts b/src/lib/mtproto/webPushApiManager.ts new file mode 100644 index 00000000..e5e3015a --- /dev/null +++ b/src/lib/mtproto/webPushApiManager.ts @@ -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 + * 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 = { + 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; diff --git a/src/lib/rootScope.ts b/src/lib/rootScope.ts index 8478cf8c..61584d71 100644 --- a/src/lib/rootScope.ts +++ b/src/lib/rootScope.ts @@ -15,6 +15,8 @@ import type Chat from "../components/chat/chat"; import type { UserAuth } from "./mtproto/mtproto_config"; import type { State, Theme } from "./appManagers/appStateManager"; import type { MyDraftMessage } from "./appManagers/appDraftsManager"; +import type { PushSubscriptionNotify } from "./mtproto/webPushApiManager"; +import type { PushNotificationObject } from "./serviceWorker/push"; import EventListenerBase from "../helpers/eventListenerBase"; import { MOUNT_CLASS_TO } from "../config/debug"; @@ -113,7 +115,13 @@ export type BroadcastEvents = { 'theme_change': void, - 'instance_deactivated': void + 'instance_activated': void, + 'instance_deactivated': void, + + 'push_notification_click': PushNotificationObject, + 'push_init': PushSubscriptionNotify, + 'push_subscribe': PushSubscriptionNotify, + 'push_unsubscribe': PushSubscriptionNotify, }; export class RootScope extends EventListenerBase<{ diff --git a/src/lib/serviceWorker/cache.ts b/src/lib/serviceWorker/cache.ts new file mode 100644 index 00000000..dc91505b --- /dev/null +++ b/src/lib/serviceWorker/cache.ts @@ -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); + } +} diff --git a/src/lib/serviceWorker/index.service.ts b/src/lib/serviceWorker/index.service.ts new file mode 100644 index 00000000..40993737 --- /dev/null +++ b/src/lib/serviceWorker/index.service.ts @@ -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} = {}; + +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(); diff --git a/src/lib/serviceWorker/push.ts b/src/lib/serviceWorker/push.ts new file mode 100644 index 00000000..e18cc214 --- /dev/null +++ b/src/lib/serviceWorker/push.ts @@ -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 + * 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, Storage extends Record> { + private cache: Partial = {}; + private storage: IDBStorage; + + 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(db, storeName); + } + + public async get(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(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(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 = 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; + 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 & {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); + } +} diff --git a/src/lib/serviceWorker/stream.ts b/src/lib/serviceWorker/stream.ts new file mode 100644 index 00000000..839e2309 --- /dev/null +++ b/src/lib/serviceWorker/stream.ts @@ -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((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(); + deferred.then(result => { + let ab = result.bytes as Uint8Array; + + //log.debug('[stream] requestFilePart result:', result); + + const headers: Record = { + '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)); +} diff --git a/src/lib/serviceWorker/timeout.ts b/src/lib/serviceWorker/timeout.ts new file mode 100644 index 00000000..01b3e1f4 --- /dev/null +++ b/src/lib/serviceWorker/timeout.ts @@ -0,0 +1,10 @@ +export default function timeout(delay: number): Promise { + return new Promise(((resolve) => { + setTimeout(() => { + resolve(new Response('', { + status: 408, + statusText: 'Request timed out.', + })); + }, delay); + })); +} diff --git a/src/types.d.ts b/src/types.d.ts index 4964d120..97056aad 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -29,6 +29,8 @@ export type WorkerTaskTemplate = { error?: ApiError }; +export type WorkerTaskVoidTemplate = Omit; + export type Modify = Omit & R; //export type Parameters = T extends (... args: infer T) => any ? T : never;