diff options
author | Ricardo Cino <ricardo@cino.io> | 2022-06-23 18:10:39 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-06-23 16:10:39 +0000 |
commit | 6b2c2b8dfb86245a1747bf7977159f5129461863 (patch) | |
tree | 28944f62a962d8a658262ea902f8554d4419fa9e /src/models/Service.ts | |
parent | chore: featureStore and GlobalErrorStore JS => TS (diff) | |
download | ferdium-app-6b2c2b8dfb86245a1747bf7977159f5129461863.tar.gz ferdium-app-6b2c2b8dfb86245a1747bf7977159f5129461863.tar.zst ferdium-app-6b2c2b8dfb86245a1747bf7977159f5129461863.zip |
chore: servicesStore + models into typescript (#344)
Diffstat (limited to 'src/models/Service.ts')
-rw-r--r-- | src/models/Service.ts | 486 |
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 @@ | |||
1 | import { autorun, computed, observable } from 'mobx'; | ||
2 | import { ipcRenderer } from 'electron'; | ||
3 | import { webContents } from '@electron/remote'; | ||
4 | import normalizeUrl from 'normalize-url'; | ||
5 | import { join } from 'path'; | ||
6 | import ElectronWebView from 'react-electron-web-view'; | ||
7 | |||
8 | import { todosStore } from '../features/todos'; | ||
9 | import { isValidExternalURL } from '../helpers/url-helpers'; | ||
10 | import UserAgent from './UserAgent'; | ||
11 | import { DEFAULT_SERVICE_ORDER } from '../config'; | ||
12 | import { ifUndefined } from '../jsUtils'; | ||
13 | import Recipe from './Recipe'; | ||
14 | |||
15 | const 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? | ||
18 | export 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 | } | ||