aboutsummaryrefslogtreecommitdiffstats
path: root/src/models/Service.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/models/Service.ts')
-rw-r--r--src/models/Service.ts486
1 files changed, 486 insertions, 0 deletions
diff --git a/src/models/Service.ts b/src/models/Service.ts
new file mode 100644
index 000000000..c4165e59a
--- /dev/null
+++ b/src/models/Service.ts
@@ -0,0 +1,486 @@
1import { autorun, computed, observable } from 'mobx';
2import { ipcRenderer } from 'electron';
3import { webContents } from '@electron/remote';
4import normalizeUrl from 'normalize-url';
5import { join } from 'path';
6import ElectronWebView from 'react-electron-web-view';
7
8import { todosStore } from '../features/todos';
9import { isValidExternalURL } from '../helpers/url-helpers';
10import UserAgent from './UserAgent';
11import { DEFAULT_SERVICE_ORDER } from '../config';
12import { ifUndefined } from '../jsUtils';
13import Recipe from './Recipe';
14
15const debug = require('../preload-safe-debug')('Ferdium:Service');
16
17// TODO: Shouldn't most of these values default to what's defined in DEFAULT_SERVICE_SETTINGS?
18export default class Service {
19 id: string = '';
20
21 recipe: Recipe;
22
23 _webview: ElectronWebView | null = null;
24
25 timer: NodeJS.Timeout | null = null;
26
27 events = {};
28
29 @observable isAttached: boolean = false;
30
31 @observable isActive: boolean = false; // Is current webview active
32
33 @observable name: string = '';
34
35 @observable unreadDirectMessageCount: number = 0;
36
37 @observable unreadIndirectMessageCount: number = 0;
38
39 @observable dialogTitle: string = '';
40
41 @observable order: number = DEFAULT_SERVICE_ORDER;
42
43 @observable isEnabled: boolean = true;
44
45 @observable isMuted: boolean = false;
46
47 @observable team: string = '';
48
49 @observable customUrl: string = '';
50
51 @observable isNotificationEnabled: boolean = true;
52
53 @observable isBadgeEnabled: boolean = true;
54
55 @observable trapLinkClicks: boolean = false;
56
57 @observable isIndirectMessageBadgeEnabled: boolean = true;
58
59 @observable iconUrl: string = '';
60
61 @observable customIconUrl: string = '';
62
63 @observable hasCustomUploadedIcon: boolean = false;
64
65 @observable hasCrashed: boolean = false;
66
67 @observable isDarkModeEnabled: boolean = false;
68
69 @observable isProgressbarEnabled: boolean = true;
70
71 @observable darkReaderSettings: object = {
72 brightness: 100,
73 contrast: 90,
74 sepia: 10,
75 };
76
77 @observable spellcheckerLanguage: string | null = null;
78
79 @observable isFirstLoad: boolean = true;
80
81 @observable isLoading: boolean = true;
82
83 @observable isLoadingPage: boolean = true;
84
85 @observable isError: boolean = false;
86
87 @observable errorMessage: string = '';
88
89 @observable isUsingCustomUrl: boolean = false;
90
91 @observable isServiceAccessRestricted: boolean = false;
92
93 // todo is this used?
94 @observable restrictionType = null;
95
96 @observable isHibernationEnabled: boolean = false;
97
98 @observable isWakeUpEnabled: boolean = true;
99
100 @observable isHibernationRequested: boolean = false;
101
102 @observable onlyShowFavoritesInUnreadCount: boolean = false;
103
104 @observable lastUsed: number = Date.now(); // timestamp
105
106 @observable lastHibernated: number | null = null; // timestamp
107
108 @observable lastPoll: number = Date.now();
109
110 @observable lastPollAnswer: number = Date.now();
111
112 @observable lostRecipeConnection: boolean = false;
113
114 @observable lostRecipeReloadAttempt: number = 0;
115
116 @observable userAgentModel: UserAgent;
117
118 @observable proxy: string | null = null;
119
120 constructor(data, recipe: Recipe) {
121 if (!data) {
122 throw new Error('Service config not valid');
123 }
124
125 if (!recipe) {
126 throw new Error('Service recipe not valid');
127 }
128
129 this.recipe = recipe;
130
131 this.userAgentModel = new UserAgent(recipe.overrideUserAgent);
132
133 this.id = ifUndefined<string>(data.id, this.id);
134 this.name = ifUndefined<string>(data.name, this.name);
135 this.team = ifUndefined<string>(data.team, this.team);
136 this.customUrl = ifUndefined<string>(data.customUrl, this.customUrl);
137 this.iconUrl = ifUndefined<string>(data.iconUrl, this.iconUrl);
138 this.order = ifUndefined<number>(data.order, this.order);
139 this.isEnabled = ifUndefined<boolean>(data.isEnabled, this.isEnabled);
140 this.isNotificationEnabled = ifUndefined<boolean>(
141 data.isNotificationEnabled,
142 this.isNotificationEnabled,
143 );
144 this.isBadgeEnabled = ifUndefined<boolean>(
145 data.isBadgeEnabled,
146 this.isBadgeEnabled,
147 );
148 this.trapLinkClicks = ifUndefined<boolean>(
149 data.trapLinkClicks,
150 this.trapLinkClicks,
151 );
152 this.isIndirectMessageBadgeEnabled = ifUndefined<boolean>(
153 data.isIndirectMessageBadgeEnabled,
154 this.isIndirectMessageBadgeEnabled,
155 );
156 this.isMuted = ifUndefined<boolean>(data.isMuted, this.isMuted);
157 this.isDarkModeEnabled = ifUndefined<boolean>(
158 data.isDarkModeEnabled,
159 this.isDarkModeEnabled,
160 );
161 this.darkReaderSettings = ifUndefined<object>(
162 data.darkReaderSettings,
163 this.darkReaderSettings,
164 );
165 this.isProgressbarEnabled = ifUndefined<boolean>(
166 data.isProgressbarEnabled,
167 this.isProgressbarEnabled,
168 );
169 this.hasCustomUploadedIcon = ifUndefined<boolean>(
170 data.iconId?.length > 0,
171 this.hasCustomUploadedIcon,
172 );
173 this.onlyShowFavoritesInUnreadCount = ifUndefined<boolean>(
174 data.onlyShowFavoritesInUnreadCount,
175 this.onlyShowFavoritesInUnreadCount,
176 );
177 this.proxy = ifUndefined<string | null>(data.proxy, this.proxy);
178 this.spellcheckerLanguage = ifUndefined<string | null>(
179 data.spellcheckerLanguage,
180 this.spellcheckerLanguage,
181 );
182 this.userAgentPref = ifUndefined<string | null>(
183 data.userAgentPref,
184 this.userAgentPref,
185 );
186 this.isHibernationEnabled = ifUndefined<boolean>(
187 data.isHibernationEnabled,
188 this.isHibernationEnabled,
189 );
190 this.isWakeUpEnabled = ifUndefined<boolean>(
191 data.isWakeUpEnabled,
192 this.isWakeUpEnabled,
193 );
194
195 // Check if "Hibernate on Startup" is enabled and hibernate all services except active one
196 const { hibernateOnStartup } = window['ferdium'].stores.settings.app;
197 // The service store is probably not loaded yet so we need to use localStorage data to get active service
198 const isActive =
199 window.localStorage.service &&
200 JSON.parse(window.localStorage.service).activeService === this.id;
201 if (hibernateOnStartup && !isActive) {
202 this.isHibernationRequested = true;
203 }
204
205 autorun((): void => {
206 if (!this.isEnabled) {
207 this.webview = null;
208 this.isAttached = false;
209 this.unreadDirectMessageCount = 0;
210 this.unreadIndirectMessageCount = 0;
211 }
212
213 if (this.recipe.hasCustomUrl && this.customUrl) {
214 this.isUsingCustomUrl = true;
215 }
216 });
217 }
218
219 @computed get shareWithWebview(): object {
220 return {
221 id: this.id,
222 spellcheckerLanguage: this.spellcheckerLanguage,
223 isDarkModeEnabled: this.isDarkModeEnabled,
224 isProgressbarEnabled: this.isProgressbarEnabled,
225 darkReaderSettings: this.darkReaderSettings,
226 team: this.team,
227 url: this.url,
228 hasCustomIcon: this.hasCustomIcon,
229 onlyShowFavoritesInUnreadCount: this.onlyShowFavoritesInUnreadCount,
230 trapLinkClicks: this.trapLinkClicks,
231 };
232 }
233
234 @computed get isTodosService(): boolean {
235 return this.recipe.id === todosStore.todoRecipeId;
236 }
237
238 @computed get canHibernate(): boolean {
239 return this.isHibernationEnabled;
240 }
241
242 @computed get isHibernating(): boolean {
243 return this.canHibernate && this.isHibernationRequested;
244 }
245
246 get webview(): ElectronWebView | null {
247 if (this.isTodosService) {
248 return todosStore.webview;
249 }
250
251 return this._webview;
252 }
253
254 set webview(webview) {
255 this._webview = webview;
256 }
257
258 @computed get url(): string {
259 if (this.recipe.hasCustomUrl && this.customUrl) {
260 let url: string = '';
261 try {
262 url = normalizeUrl(this.customUrl, {
263 stripAuthentication: false,
264 stripWWW: false,
265 removeTrailingSlash: false,
266 });
267 } catch {
268 console.error(
269 `Service (${this.recipe.name}): '${this.customUrl}' is not a valid Url.`,
270 );
271 }
272
273 if (typeof this.recipe.buildUrl === 'function') {
274 url = this.recipe.buildUrl(url);
275 }
276
277 return url;
278 }
279
280 if (this.recipe.hasTeamId && this.team) {
281 return this.recipe.serviceURL.replace('{teamId}', this.team);
282 }
283
284 return this.recipe.serviceURL;
285 }
286
287 @computed get icon(): string {
288 if (this.iconUrl) {
289 return this.iconUrl;
290 }
291
292 return join(this.recipe.path, 'icon.svg');
293 }
294
295 @computed get hasCustomIcon(): boolean {
296 return Boolean(this.iconUrl);
297 }
298
299 @computed get userAgent(): string {
300 return this.userAgentModel.userAgent;
301 }
302
303 @computed get userAgentPref(): string | null {
304 return this.userAgentModel.userAgentPref;
305 }
306
307 set userAgentPref(pref) {
308 this.userAgentModel.userAgentPref = pref;
309 }
310
311 @computed get defaultUserAgent(): String {
312 return this.userAgentModel.defaultUserAgent;
313 }
314
315 @computed get partition(): string {
316 return this.recipe.partition || `persist:service-${this.id}`;
317 }
318
319 initializeWebViewEvents({ handleIPCMessage, openWindow, stores }): void {
320 const webviewWebContents = webContents.fromId(
321 this.webview.getWebContentsId(),
322 );
323
324 this.userAgentModel.setWebviewReference(this.webview);
325
326 // If the recipe has implemented 'modifyRequestHeaders',
327 // Send those headers to ipcMain so that it can be set in session
328 if (typeof this.recipe.modifyRequestHeaders === 'function') {
329 const modifiedRequestHeaders = this.recipe.modifyRequestHeaders();
330 debug(this.name, 'modifiedRequestHeaders', modifiedRequestHeaders);
331 ipcRenderer.send('modifyRequestHeaders', {
332 modifiedRequestHeaders,
333 serviceId: this.id,
334 });
335 } else {
336 debug(this.name, 'modifyRequestHeaders is not defined in the recipe');
337 }
338
339 // if the recipe has implemented 'knownCertificateHosts'
340 if (typeof this.recipe.knownCertificateHosts === 'function') {
341 const knownHosts = this.recipe.knownCertificateHosts();
342 debug(this.name, 'knownCertificateHosts', knownHosts);
343 ipcRenderer.send('knownCertificateHosts', {
344 knownHosts,
345 serviceId: this.id,
346 });
347 } else {
348 debug(this.name, 'knownCertificateHosts is not defined in the recipe');
349 }
350
351 this.webview.addEventListener('ipc-message', async e => {
352 if (e.channel === 'inject-js-unsafe') {
353 await Promise.all(
354 e.args.map(script =>
355 this.webview.executeJavaScript(
356 `"use strict"; (() => { ${script} })();`,
357 ),
358 ),
359 );
360 } else {
361 handleIPCMessage({
362 serviceId: this.id,
363 channel: e.channel,
364 args: e.args,
365 });
366 }
367 });
368
369 this.webview.addEventListener(
370 'new-window',
371 (event, url, frameName, options) => {
372 debug('new-window', event, url, frameName, options);
373 if (!isValidExternalURL(event.url)) {
374 return;
375 }
376 if (
377 event.disposition === 'foreground-tab' ||
378 event.disposition === 'background-tab'
379 ) {
380 openWindow({
381 event,
382 url,
383 frameName,
384 options,
385 });
386 } else {
387 ipcRenderer.send('open-browser-window', {
388 url: event.url,
389 serviceId: this.id,
390 });
391 }
392 },
393 );
394
395 this.webview.addEventListener('did-start-loading', event => {
396 debug('Did start load', this.name, event);
397
398 this.hasCrashed = false;
399 this.isLoading = true;
400 this.isLoadingPage = true;
401 this.isError = false;
402 });
403
404 this.webview.addEventListener('did-stop-loading', event => {
405 debug('Did stop load', this.name, event);
406
407 this.isLoading = false;
408 this.isLoadingPage = false;
409 });
410
411 // eslint-disable-next-line unicorn/consistent-function-scoping
412 const didLoad = () => {
413 this.isLoading = false;
414 this.isLoadingPage = false;
415
416 if (!this.isError) {
417 this.isFirstLoad = false;
418 }
419 };
420
421 this.webview.addEventListener('did-frame-finish-load', didLoad.bind(this));
422 this.webview.addEventListener('did-navigate', didLoad.bind(this));
423
424 this.webview.addEventListener('did-fail-load', event => {
425 debug('Service failed to load', this.name, event);
426 if (
427 event.isMainFrame &&
428 event.errorCode !== -21 &&
429 event.errorCode !== -3
430 ) {
431 this.isError = true;
432 this.errorMessage = event.errorDescription;
433 this.isLoading = false;
434 this.isLoadingPage = false;
435 }
436 });
437
438 this.webview.addEventListener('crashed', () => {
439 debug('Service crashed', this.name);
440 this.hasCrashed = true;
441 });
442
443 this.webview.addEventListener('found-in-page', ({ result }) => {
444 debug('Found in page', result);
445 this.webview.send('found-in-page', result);
446 });
447
448 webviewWebContents.on('login', (event, _, authInfo, callback) => {
449 // const authCallback = callback;
450 debug('browser login event', authInfo);
451 event.preventDefault();
452
453 if (authInfo.isProxy && authInfo.scheme === 'basic') {
454 debug('Sending service echo ping');
455 webviewWebContents.send('get-service-id');
456
457 debug('Received service id', this.id);
458
459 const ps = stores.settings.proxy[this.id];
460
461 if (ps) {
462 debug('Sending proxy auth callback for service', this.id);
463 callback(ps.user, ps.password);
464 } else {
465 debug('No proxy auth config found for', this.id);
466 }
467 }
468 });
469 }
470
471 initializeWebViewListener(): void {
472 if (this.webview && this.recipe.events) {
473 for (const eventName of Object.keys(this.recipe.events)) {
474 const eventHandler = this.recipe[this.recipe.events[eventName]];
475 if (typeof eventHandler === 'function') {
476 this.webview.addEventListener(eventName, eventHandler);
477 }
478 }
479 }
480 }
481
482 resetMessageCount(): void {
483 this.unreadDirectMessageCount = 0;
484 this.unreadIndirectMessageCount = 0;
485 }
486}