Browse Source

Push notifications

master
Eduard Kuzmenko 3 years ago
parent
commit
9e320ea136
  1. 6
      src/index.ts
  2. 6
      src/lang.ts
  3. 9
      src/lib/appManagers/appImManager.ts
  4. 95
      src/lib/appManagers/appNotificationsManager.ts
  5. 2
      src/lib/mtproto/apiManager.ts
  6. 286
      src/lib/mtproto/mtproto.service.ts
  7. 6
      src/lib/mtproto/mtproto.worker.ts
  8. 18
      src/lib/mtproto/mtprotoworker.ts
  9. 6
      src/lib/mtproto/referenceDatabase.ts
  10. 19
      src/lib/mtproto/singleInstance.ts
  11. 254
      src/lib/mtproto/webPushApiManager.ts
  12. 10
      src/lib/rootScope.ts
  13. 39
      src/lib/serviceWorker/cache.ts
  14. 151
      src/lib/serviceWorker/index.service.ts
  15. 338
      src/lib/serviceWorker/push.ts
  16. 130
      src/lib/serviceWorker/stream.ts
  17. 10
      src/lib/serviceWorker/timeout.ts
  18. 2
      src/types.d.ts

6
src/index.ts

@ -313,6 +313,12 @@ console.timeEnd('get storage1'); */
scrollable.append(placeholder.cloneNode()); scrollable.append(placeholder.cloneNode());
} }
try {
(await import('./lib/mtproto/webPushApiManager')).default.forceUnsubscribe();
} catch(err) {
}
let pagePromise: Promise<void>; let pagePromise: Promise<void>;
//langPromise.then(async() => { //langPromise.then(async() => {
switch(authState._) { switch(authState._) {

6
src/lang.ts

@ -117,6 +117,12 @@ const lang = {
"other_value": "Forwarded %d messages" "other_value": "Forwarded %d messages"
}, },
"Notifications.New": "New notification", "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 // * android
"ActionCreateChannel": "Channel created", "ActionCreateChannel": "Channel created",

9
src/lib/appManagers/appImManager.ts

@ -171,7 +171,11 @@ export class AppImManager {
}); });
rootScope.addEventListener('history_focus', (e) => { 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); this.setInnerPeer(peerId, mid);
}); });
@ -204,8 +208,7 @@ export class AppImManager {
(popup as any).onClose = () => { (popup as any).onClose = () => {
document.body.classList.add('deactivated-backwards'); document.body.classList.add('deactivated-backwards');
singleInstance.reset(); singleInstance.activateInstance();
singleInstance.checkInstance(false);
setTimeout(() => { setTimeout(() => {
document.body.classList.remove('deactivated', 'deactivated-backwards'); document.body.classList.remove('deactivated', 'deactivated-backwards');

95
src/lib/appManagers/appNotificationsManager.ts

@ -19,11 +19,14 @@ import { isMobile } from "../../helpers/userAgent";
import { InputNotifyPeer, InputPeerNotifySettings, NotifyPeer, PeerNotifySettings, Update } from "../../layer"; import { InputNotifyPeer, InputPeerNotifySettings, NotifyPeer, PeerNotifySettings, Update } from "../../layer";
import I18n from "../langPack"; import I18n from "../langPack";
import apiManager from "../mtproto/mtprotoworker"; import apiManager from "../mtproto/mtprotoworker";
import webPushApiManager, { PushSubscriptionNotify } from "../mtproto/webPushApiManager";
import rootScope from "../rootScope"; import rootScope from "../rootScope";
import stateStorage from "../stateStorage"; import stateStorage from "../stateStorage";
import apiUpdatesManager from "./apiUpdatesManager"; import apiUpdatesManager from "./apiUpdatesManager";
import appChatsManager from "./appChatsManager";
import appPeersManager from "./appPeersManager"; import appPeersManager from "./appPeersManager";
import appStateManager from "./appStateManager"; import appStateManager from "./appStateManager";
import appUsersManager from "./appUsersManager";
type MyNotification = Notification & { type MyNotification = Notification & {
hidden?: boolean, hidden?: boolean,
@ -40,6 +43,15 @@ export type NotifyOptions = Partial<{
onclick: () => void; onclick: () => void;
}>; }>;
export type NotificationSettings = {
nodesktop: boolean,
volume: number,
novibrate: boolean,
nopreview: boolean,
nopush: boolean,
nosound: boolean
};
type ImSadAboutIt = Promise<PeerNotifySettings> | PeerNotifySettings; type ImSadAboutIt = Promise<PeerNotifySettings> | PeerNotifySettings;
export class AppNotificationsManager { export class AppNotificationsManager {
private notificationsUiSupport: boolean; private notificationsUiSupport: boolean;
@ -66,14 +78,7 @@ export class AppNotificationsManager {
private prevFavicon: string; private prevFavicon: string;
private stopped = false; private stopped = false;
private settings: Partial<{ private settings: NotificationSettings = {} as any;
nodesktop: boolean,
volume: number,
novibrate: boolean,
nopreview: boolean,
nopush: boolean,
nosound: boolean,
}> = {};
private registeredDevice: any; private registeredDevice: any;
private pushInited = false; private pushInited = false;
@ -96,11 +101,15 @@ export class AppNotificationsManager {
this.notifySoundEl.id = 'notify-sound'; this.notifySoundEl.id = 'notify-sound';
document.body.append(this.notifySoundEl); document.body.append(this.notifySoundEl);
/* rootScope.on('idle.deactivated', (newVal) => { rootScope.addEventListener('instance_deactivated', () => {
if(newVal) { this.stop();
stop(); });
rootScope.addEventListener('instance_activated', () => {
if(this.stopped) {
this.start();
} }
});*/ });
rootScope.addEventListener('idle', (newVal) => { rootScope.addEventListener('idle', (newVal) => {
if(this.stopped) { if(this.stopped) {
@ -121,40 +130,40 @@ export class AppNotificationsManager {
} }
}); });
/* rootScope.on('push_init', (tokenData) => { rootScope.addEventListener('push_init', (tokenData) => {
this.pushInited = true this.pushInited = true;
if(!this.settings.nodesktop && !this.settings.nopush) { if(!this.settings.nodesktop && !this.settings.nopush) {
if(tokenData) { if(tokenData) {
this.registerDevice(tokenData); this.registerDevice(tokenData);
} else { } else {
WebPushApiManager.subscribe(); webPushApiManager.subscribe();
} }
} else { } else {
this.unregisterDevice(tokenData); this.unregisterDevice(tokenData);
} }
}); });
rootScope.on('push_subscribe', (tokenData) => { rootScope.addEventListener('push_subscribe', (tokenData) => {
this.registerDevice(tokenData); this.registerDevice(tokenData);
}); });
rootScope.on('push_unsubscribe', (tokenData) => { rootScope.addEventListener('push_unsubscribe', (tokenData) => {
this.unregisterDevice(tokenData); this.unregisterDevice(tokenData);
}); */ });
rootScope.addEventListener('dialogs_multiupdate', () => { rootScope.addEventListener('dialogs_multiupdate', () => {
//unregisterTopMsgs() //unregisterTopMsgs()
this.topMessagesDeferred.resolve(); this.topMessagesDeferred.resolve();
}, true); }, true);
/* rootScope.on('push_notification_click', (notificationData) => { rootScope.addEventListener('push_notification_click', (notificationData) => {
if(notificationData.action === 'push_settings') { if(notificationData.action === 'push_settings') {
this.topMessagesDeferred.then(() => { /* this.topMessagesDeferred.then(() => {
$modal.open({ $modal.open({
templateUrl: templateUrl('settings_modal'), templateUrl: templateUrl('settings_modal'),
controller: 'SettingsModalController', controller: 'SettingsModalController',
windowClass: 'settings_modal_window mobile_modal', windowClass: 'settings_modal_window mobile_modal',
backdrop: 'single' backdrop: 'single'
}) })
}); }); */
return; return;
} }
@ -176,12 +185,12 @@ export class AppNotificationsManager {
return; return;
} }
const peerId = notificationData.custom && notificationData.custom.peerId; const peerId = notificationData.custom && +notificationData.custom.peerId;
console.log('click', notificationData, peerId); console.log('click', notificationData, peerId);
if(peerId) { if(peerId) {
this.topMessagesDeferred.then(() => { this.topMessagesDeferred.then(() => {
if(notificationData.custom.channel_id && if(notificationData.custom.channel_id &&
!appChatsManager.hasChat(notificationData.custom.channel_id)) { !appChatsManager.hasChat(+notificationData.custom.channel_id)) {
return; return;
} }
@ -189,12 +198,13 @@ export class AppNotificationsManager {
return; return;
} }
// rootScope.broadcast('history_focus', { rootScope.dispatchEvent('history_focus', {
// peerString: appPeersManager.getPeerString(peerId) peerId,
// }); mid: +notificationData.custom.msg_id
});
}); });
} }
}); */ });
} }
private toggleToggler(enable = rootScope.idle.isIDLE) { private toggleToggler(enable = rootScope.idle.isIDLE) {
@ -276,19 +286,19 @@ export class AppNotificationsManager {
this.settings.nopreview = updSettings[3]; this.settings.nopreview = updSettings[3];
this.settings.nopush = updSettings[4]; this.settings.nopush = updSettings[4];
/* if(this.pushInited) { if(this.pushInited) {
const needPush = !this.settings.nopush && !this.settings.nodesktop && WebPushApiManager.isAvailable || false; const needPush = !this.settings.nopush && !this.settings.nodesktop && webPushApiManager.isAvailable || false;
const hasPush = this.registeredDevice !== false; const hasPush = this.registeredDevice !== false;
if(needPush !== hasPush) { if(needPush !== hasPush) {
if(needPush) { if(needPush) {
WebPushApiManager.subscribe(); webPushApiManager.subscribe();
} else { } else {
WebPushApiManager.unsubscribe(); webPushApiManager.unsubscribe();
} }
} }
} }
WebPushApiManager.setSettings(this.settings); */ webPushApiManager.setSettings(this.settings);
}); });
appStateManager.getState().then(state => { appStateManager.getState().then(state => {
@ -457,7 +467,7 @@ export class AppNotificationsManager {
public start() { public start() {
this.updateLocalSettings(); this.updateLocalSettings();
rootScope.addEventListener('settings_updated', this.updateLocalSettings); rootScope.addEventListener('settings_updated', this.updateLocalSettings);
//WebPushApiManager.start(); webPushApiManager.start();
if(!this.notificationsUiSupport) { if(!this.notificationsUiSupport) {
return false; return false;
@ -571,7 +581,7 @@ export class AppNotificationsManager {
//console.log('notify constructed notification'); //console.log('notify constructed notification');
} catch(e) { } catch(e) {
this.notificationsUiSupport = false; this.notificationsUiSupport = false;
//WebPushApiManager.setLocalNotificationsDisabled(); webPushApiManager.setLocalNotificationsDisabled();
return; return;
} }
} /* else if('mozNotification' in navigator) { } /* else if('mozNotification' in navigator) {
@ -692,12 +702,11 @@ export class AppNotificationsManager {
this.notificationsShown = {}; this.notificationsShown = {};
this.notificationsCount = 0; this.notificationsCount = 0;
//WebPushApiManager.hidePushNotifications(); webPushApiManager.hidePushNotifications();
} }
private registerDevice(tokenData: any) { private registerDevice(tokenData: PushSubscriptionNotify) {
if(this.registeredDevice && if(this.registeredDevice && deepEqual(this.registeredDevice, tokenData)) {
deepEqual(this.registeredDevice, tokenData)) {
return false; return false;
} }
@ -711,10 +720,10 @@ export class AppNotificationsManager {
this.registeredDevice = tokenData; this.registeredDevice = tokenData;
}, (error) => { }, (error) => {
error.handled = true; error.handled = true;
}) });
} }
private unregisterDevice(tokenData: any) { private unregisterDevice(tokenData: PushSubscriptionNotify) {
if(!this.registeredDevice) { if(!this.registeredDevice) {
return false; return false;
} }
@ -724,10 +733,10 @@ export class AppNotificationsManager {
token: tokenData.tokenValue, token: tokenData.tokenValue,
other_uids: [] other_uids: []
}).then(() => { }).then(() => {
this.registeredDevice = false this.registeredDevice = false;
}, (error) => { }, (error) => {
error.handled = true error.handled = true;
}) });
} }
public getVibrateSupport() { public getVibrateSupport() {

2
src/lib/mtproto/apiManager.ts

@ -157,7 +157,7 @@ export class ApiManager {
storageKeys.push(prefix + dcId + '_auth_key'); 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 storageResult = await Promise.all(storageKeys.map(key => sessionStorage.get(key as any)));
const logoutPromises: Promise<any>[] = []; const logoutPromises: Promise<any>[] = [];

286
src/lib/mtproto/mtproto.service.ts

@ -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;

6
src/lib/mtproto/mtproto.worker.ts

@ -11,7 +11,7 @@ import apiManager from "./apiManager";
import cryptoWorker from "../crypto/cryptoworker"; import cryptoWorker from "../crypto/cryptoworker";
import networkerFactory from "./networkerFactory"; import networkerFactory from "./networkerFactory";
import apiFileManager from './apiFileManager'; 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 { ctx } from '../../helpers/userAgent';
import { socketsProxied } from './dcConfigurator'; import { socketsProxied } from './dcConfigurator';
import { notifyAll } from '../../helpers/context'; import { notifyAll } from '../../helpers/context';
@ -44,8 +44,8 @@ const taskListeners = {
} }
}, },
requestFilePart: async(task: ServiceWorkerTask) => { requestFilePart: async(task: RequestFilePartTask) => {
const responseTask: ServiceWorkerTaskResponse = { const responseTask: RequestFilePartTaskResponse = {
type: task.type, type: task.type,
id: task.id id: task.id
}; };

18
src/lib/mtproto/mtprotoworker.ts

@ -16,7 +16,7 @@ import { logger } from '../logger';
import rootScope from '../rootScope'; import rootScope from '../rootScope';
import webpWorkerController from '../webp/webpWorkerController'; import webpWorkerController from '../webp/webpWorkerController';
import type { DownloadOptions } from './apiFileManager'; import type { DownloadOptions } from './apiFileManager';
import type { ServiceWorkerTask } from './mtproto.service'; import type { ServiceWorkerTask } from '../serviceWorker/index.service';
import { UserAuth } from './mtproto_config'; import { UserAuth } from './mtproto_config';
import type { MTMessage } from './networker'; import type { MTMessage } from './networker';
import DEBUG, { MOUNT_CLASS_TO } from '../../config/debug'; import DEBUG, { MOUNT_CLASS_TO } from '../../config/debug';
@ -24,6 +24,7 @@ import Socket from './transports/websocket';
import IDBStorage from '../idb'; import IDBStorage from '../idb';
import singleInstance from './singleInstance'; import singleInstance from './singleInstance';
import sessionStorage from '../sessionStorage'; import sessionStorage from '../sessionStorage';
import webPushApiManager from './webPushApiManager';
type Task = { type Task = {
taskId: number, taskId: number,
@ -83,6 +84,7 @@ export class ApiManagerProxy extends CryptoWorkerMethods {
private sockets: Map<number, Socket> = new Map(); private sockets: Map<number, Socket> = new Map();
private taskListeners: {[taskType: string]: (task: any) => void} = {}; private taskListeners: {[taskType: string]: (task: any) => void} = {};
private taskListenersSW: {[taskType: string]: (task: any) => void} = {};
public onServiceWorkerFail: () => void; public onServiceWorkerFail: () => void;
@ -97,7 +99,8 @@ export class ApiManagerProxy extends CryptoWorkerMethods {
this.addTaskListener('clear', () => { this.addTaskListener('clear', () => {
Promise.all([ Promise.all([
IDBStorage.deleteDatabase(), IDBStorage.deleteDatabase(),
sessionStorage.clear() sessionStorage.clear(),
webPushApiManager.forceUnsubscribe()
]).finally(() => { ]).finally(() => {
location.reload(); location.reload();
}); });
@ -236,6 +239,13 @@ export class ApiManagerProxy extends CryptoWorkerMethods {
return; return;
} }
const callback = this.taskListenersSW[task.type];
if(callback) {
callback(task);
}
});
this.addServiceWorkerTaskListener('requestFilePart', (task) => {
this.postMessage(task); this.postMessage(task);
}); });
/// #endif /// #endif
@ -264,6 +274,10 @@ export class ApiManagerProxy extends CryptoWorkerMethods {
this.taskListeners[name] = callback; this.taskListeners[name] = callback;
} }
public addServiceWorkerTaskListener(name: keyof ApiManagerProxy['taskListenersSW'], callback: ApiManagerProxy['taskListenersSW'][typeof name]) {
this.taskListenersSW[name] = callback;
}
private onWorkerMessage = (e: MessageEvent) => { private onWorkerMessage = (e: MessageEvent) => {
//this.log('got message from worker:', e.data); //this.log('got message from worker:', e.data);

6
src/lib/mtproto/referenceDatabase.ts

@ -4,7 +4,7 @@
* https://github.com/morethanwords/tweb/blob/master/LICENSE * 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 type { ApiError } from "./apiManager";
import appMessagesManager from "../appManagers/appMessagesManager"; import appMessagesManager from "../appManagers/appMessagesManager";
import { Photo } from "../../layer"; import { Photo } from "../../layer";
@ -38,7 +38,7 @@ class ReferenceDatabase {
private links: {[hex: string]: ReferenceBytes} = {}; private links: {[hex: string]: ReferenceBytes} = {};
constructor() { constructor() {
apiManager.addTaskListener('requestFilePart', (task: ServiceWorkerTaskResponse) => { apiManager.addTaskListener('requestFilePart', (task: RequestFilePartTaskResponse) => {
if(task.error) { if(task.error) {
const onError = (error: ApiError) => { const onError = (error: ApiError) => {
if(error?.type === 'FILE_REFERENCE_EXPIRED') { if(error?.type === 'FILE_REFERENCE_EXPIRED') {
@ -47,7 +47,7 @@ class ReferenceDatabase {
referenceDatabase.refreshReference(bytes).then(() => { referenceDatabase.refreshReference(bytes).then(() => {
// @ts-ignore // @ts-ignore
task.originalPayload[1].file_reference = referenceDatabase.getReferenceByLink(bytes); task.originalPayload[1].file_reference = referenceDatabase.getReferenceByLink(bytes);
const newTask: ServiceWorkerTask = { const newTask: RequestFilePartTask = {
type: task.type, type: task.type,
id: task.id, id: task.id,
payload: task.originalPayload payload: task.originalPayload

19
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 <igor.beatle@gmail.com>
* https://github.com/zhukov/webogram/blob/master/LICENSE
*/
import { MOUNT_CLASS_TO } from "../../config/debug"; import { MOUNT_CLASS_TO } from "../../config/debug";
import { nextRandomInt } from "../../helpers/random"; import { nextRandomInt } from "../../helpers/random";
import { logger } from "../logger"; 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 = () => { public deactivateInstance = () => {
if(this.masterInstance || this.deactivated) { if(this.masterInstance || this.deactivated) {
return false; return false;

254
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 <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;

10
src/lib/rootScope.ts

@ -15,6 +15,8 @@ import type Chat from "../components/chat/chat";
import type { UserAuth } from "./mtproto/mtproto_config"; import type { UserAuth } from "./mtproto/mtproto_config";
import type { State, Theme } from "./appManagers/appStateManager"; import type { State, Theme } from "./appManagers/appStateManager";
import type { MyDraftMessage } from "./appManagers/appDraftsManager"; import type { MyDraftMessage } from "./appManagers/appDraftsManager";
import type { PushSubscriptionNotify } from "./mtproto/webPushApiManager";
import type { PushNotificationObject } from "./serviceWorker/push";
import EventListenerBase from "../helpers/eventListenerBase"; import EventListenerBase from "../helpers/eventListenerBase";
import { MOUNT_CLASS_TO } from "../config/debug"; import { MOUNT_CLASS_TO } from "../config/debug";
@ -113,7 +115,13 @@ export type BroadcastEvents = {
'theme_change': void, '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<{ export class RootScope extends EventListenerBase<{

39
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);
}
}

151
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<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();

338
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 <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);
}
}

130
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<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));
}

10
src/lib/serviceWorker/timeout.ts

@ -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);
}));
}

2
src/types.d.ts vendored

@ -29,6 +29,8 @@ export type WorkerTaskTemplate = {
error?: ApiError error?: ApiError
}; };
export type WorkerTaskVoidTemplate = Omit<WorkerTaskTemplate, 'id'>;
export type Modify<T, R> = Omit<T, keyof R> & R; export type Modify<T, R> = Omit<T, keyof R> & R;
//export type Parameters<T> = T extends (... args: infer T) => any ? T : never; //export type Parameters<T> = T extends (... args: infer T) => any ? T : never;

Loading…
Cancel
Save