diff options
Diffstat (limited to 'src/stores/AppStore.ts')
-rw-r--r-- | src/stores/AppStore.ts | 587 |
1 files changed, 587 insertions, 0 deletions
diff --git a/src/stores/AppStore.ts b/src/stores/AppStore.ts new file mode 100644 index 000000000..5659460c6 --- /dev/null +++ b/src/stores/AppStore.ts | |||
@@ -0,0 +1,587 @@ | |||
1 | import { ipcRenderer } from 'electron'; | ||
2 | import { | ||
3 | app, | ||
4 | screen, | ||
5 | powerMonitor, | ||
6 | nativeTheme, | ||
7 | getCurrentWindow, | ||
8 | process as remoteProcess, | ||
9 | } from '@electron/remote'; | ||
10 | import { action, computed, observable } from 'mobx'; | ||
11 | import moment from 'moment'; | ||
12 | import AutoLaunch from 'auto-launch'; | ||
13 | import ms from 'ms'; | ||
14 | import { URL } from 'url'; | ||
15 | import { readJsonSync } from 'fs-extra'; | ||
16 | |||
17 | import { Stores } from 'src/stores.types'; | ||
18 | import { ApiInterface } from 'src/api'; | ||
19 | import { Actions } from 'src/actions/lib/actions'; | ||
20 | import TypedStore from './lib/TypedStore'; | ||
21 | import Request from './lib/Request'; | ||
22 | import { CHECK_INTERVAL, DEFAULT_APP_SETTINGS } from '../config'; | ||
23 | import { cleanseJSObject } from '../jsUtils'; | ||
24 | import { isMac, isWindows, electronVersion, osRelease } from '../environment'; | ||
25 | import { | ||
26 | ferdiumVersion, | ||
27 | userDataPath, | ||
28 | ferdiumLocale, | ||
29 | } from '../environment-remote'; | ||
30 | import { generatedTranslations } from '../i18n/translations'; | ||
31 | import { getLocale } from '../helpers/i18n-helpers'; | ||
32 | |||
33 | import { | ||
34 | getServiceIdsFromPartitions, | ||
35 | removeServicePartitionDirectory, | ||
36 | } from '../helpers/service-helpers'; | ||
37 | import { openExternalUrl } from '../helpers/url-helpers'; | ||
38 | import { sleep } from '../helpers/async-helpers'; | ||
39 | |||
40 | const debug = require('../preload-safe-debug')('Ferdium:AppStore'); | ||
41 | |||
42 | const mainWindow = getCurrentWindow(); | ||
43 | |||
44 | const executablePath = isMac ? remoteProcess.execPath : process.execPath; | ||
45 | const autoLauncher = new AutoLaunch({ | ||
46 | name: 'Ferdium', | ||
47 | path: executablePath, | ||
48 | }); | ||
49 | |||
50 | const CATALINA_NOTIFICATION_HACK_KEY = | ||
51 | '_temp_askedForCatalinaNotificationPermissions'; | ||
52 | |||
53 | const locales = generatedTranslations(); | ||
54 | |||
55 | export default class AppStore extends TypedStore { | ||
56 | updateStatusTypes = { | ||
57 | CHECKING: 'CHECKING', | ||
58 | AVAILABLE: 'AVAILABLE', | ||
59 | NOT_AVAILABLE: 'NOT_AVAILABLE', | ||
60 | DOWNLOADED: 'DOWNLOADED', | ||
61 | FAILED: 'FAILED', | ||
62 | }; | ||
63 | |||
64 | @observable healthCheckRequest = new Request(this.api.app, 'health'); | ||
65 | |||
66 | @observable getAppCacheSizeRequest = new Request( | ||
67 | this.api.local, | ||
68 | 'getAppCacheSize', | ||
69 | ); | ||
70 | |||
71 | @observable clearAppCacheRequest = new Request(this.api.local, 'clearCache'); | ||
72 | |||
73 | @observable autoLaunchOnStart = true; | ||
74 | |||
75 | @observable isOnline = navigator.onLine; | ||
76 | |||
77 | @observable authRequestFailed = false; | ||
78 | |||
79 | @observable timeSuspensionStart = moment(); | ||
80 | |||
81 | @observable timeOfflineStart; | ||
82 | |||
83 | @observable updateStatus = ''; | ||
84 | |||
85 | @observable locale = ferdiumLocale; | ||
86 | |||
87 | @observable isSystemMuteOverridden = false; | ||
88 | |||
89 | @observable isSystemDarkModeEnabled = false; | ||
90 | |||
91 | @observable isClearingAllCache = false; | ||
92 | |||
93 | @observable isFullScreen = mainWindow.isFullScreen(); | ||
94 | |||
95 | @observable isFocused = true; | ||
96 | |||
97 | dictionaries = []; | ||
98 | |||
99 | fetchDataInterval: null | NodeJS.Timer = null; | ||
100 | |||
101 | constructor(stores: Stores, api: ApiInterface, actions: Actions) { | ||
102 | super(stores, api, actions); | ||
103 | |||
104 | // Register action handlers | ||
105 | this.actions.app.notify.listen(this._notify.bind(this)); | ||
106 | this.actions.app.setBadge.listen(this._setBadge.bind(this)); | ||
107 | this.actions.app.launchOnStartup.listen(this._launchOnStartup.bind(this)); | ||
108 | this.actions.app.openExternalUrl.listen(this._openExternalUrl.bind(this)); | ||
109 | this.actions.app.checkForUpdates.listen(this._checkForUpdates.bind(this)); | ||
110 | this.actions.app.installUpdate.listen(this._installUpdate.bind(this)); | ||
111 | this.actions.app.resetUpdateStatus.listen( | ||
112 | this._resetUpdateStatus.bind(this), | ||
113 | ); | ||
114 | this.actions.app.healthCheck.listen(this._healthCheck.bind(this)); | ||
115 | this.actions.app.muteApp.listen(this._muteApp.bind(this)); | ||
116 | this.actions.app.toggleMuteApp.listen(this._toggleMuteApp.bind(this)); | ||
117 | this.actions.app.clearAllCache.listen(this._clearAllCache.bind(this)); | ||
118 | |||
119 | this.registerReactions([ | ||
120 | this._offlineCheck.bind(this), | ||
121 | this._setLocale.bind(this), | ||
122 | this._muteAppHandler.bind(this), | ||
123 | this._handleFullScreen.bind(this), | ||
124 | this._handleLogout.bind(this), | ||
125 | ]); | ||
126 | } | ||
127 | |||
128 | async setup(): Promise<void> { | ||
129 | this._appStartsCounter(); | ||
130 | // Focus the active service | ||
131 | window.addEventListener('focus', this.actions.service.focusActiveService); | ||
132 | |||
133 | // Online/Offline handling | ||
134 | window.addEventListener('online', () => { | ||
135 | this.isOnline = true; | ||
136 | }); | ||
137 | window.addEventListener('offline', () => { | ||
138 | this.isOnline = false; | ||
139 | }); | ||
140 | |||
141 | mainWindow.on('enter-full-screen', () => { | ||
142 | this.isFullScreen = true; | ||
143 | }); | ||
144 | mainWindow.on('leave-full-screen', () => { | ||
145 | this.isFullScreen = false; | ||
146 | }); | ||
147 | |||
148 | this.isOnline = navigator.onLine; | ||
149 | |||
150 | // Check if Ferdium should launch on start | ||
151 | // Needs to be delayed a bit | ||
152 | this._autoStart(); | ||
153 | |||
154 | // Check if system is muted | ||
155 | // There are no events to subscribe so we need to poll everey 5s | ||
156 | this._systemDND(); | ||
157 | setInterval(() => this._systemDND(), ms('5s')); | ||
158 | |||
159 | this.fetchDataInterval = setInterval(() => { | ||
160 | this.stores.user.getUserInfoRequest.invalidate({ | ||
161 | immediately: true, | ||
162 | }); | ||
163 | this.stores.features.featuresRequest.invalidate({ | ||
164 | immediately: true, | ||
165 | }); | ||
166 | }, ms('60m')); | ||
167 | |||
168 | // Check for updates once every 4 hours | ||
169 | setInterval(() => this._checkForUpdates(), CHECK_INTERVAL); | ||
170 | // Check for an update in 30s (need a delay to prevent Squirrel Installer lock file issues) | ||
171 | setTimeout(() => this._checkForUpdates(), ms('30s')); | ||
172 | ipcRenderer.on('autoUpdate', (_, data) => { | ||
173 | if (this.updateStatus !== this.updateStatusTypes.FAILED) { | ||
174 | if (data.available) { | ||
175 | this.updateStatus = this.updateStatusTypes.AVAILABLE; | ||
176 | if (isMac && this.stores.settings.app.automaticUpdates) { | ||
177 | app.dock.bounce(); | ||
178 | } | ||
179 | } | ||
180 | |||
181 | if (data.available !== undefined && !data.available) { | ||
182 | this.updateStatus = this.updateStatusTypes.NOT_AVAILABLE; | ||
183 | } | ||
184 | |||
185 | if (data.downloaded) { | ||
186 | this.updateStatus = this.updateStatusTypes.DOWNLOADED; | ||
187 | if (isMac && this.stores.settings.app.automaticUpdates) { | ||
188 | app.dock.bounce(); | ||
189 | } | ||
190 | } | ||
191 | |||
192 | if (data.error) { | ||
193 | if (data.error.message && data.error.message.startsWith('404')) { | ||
194 | this.updateStatus = this.updateStatusTypes.NOT_AVAILABLE; | ||
195 | console.warn( | ||
196 | 'Updater warning: there seems to be unpublished pre-release(s) available on GitHub', | ||
197 | data.error, | ||
198 | ); | ||
199 | } else { | ||
200 | console.error('Updater error:', data.error); | ||
201 | this.updateStatus = this.updateStatusTypes.FAILED; | ||
202 | } | ||
203 | } | ||
204 | } | ||
205 | }); | ||
206 | |||
207 | // Handle deep linking (ferdium://) | ||
208 | ipcRenderer.on('navigateFromDeepLink', (_, data) => { | ||
209 | debug('Navigate from deep link', data); | ||
210 | let { url } = data; | ||
211 | if (!url) return; | ||
212 | |||
213 | url = url.replace(/\/$/, ''); | ||
214 | |||
215 | this.stores.router.push(url); | ||
216 | }); | ||
217 | |||
218 | ipcRenderer.on('muteApp', () => { | ||
219 | this._toggleMuteApp(); | ||
220 | }); | ||
221 | |||
222 | this.locale = this._getDefaultLocale(); | ||
223 | |||
224 | setTimeout(() => { | ||
225 | this._healthCheck(); | ||
226 | }, 1000); | ||
227 | |||
228 | this.isSystemDarkModeEnabled = nativeTheme.shouldUseDarkColors; | ||
229 | |||
230 | ipcRenderer.on('isWindowFocused', (_, isFocused) => { | ||
231 | debug('Setting is focused to', isFocused); | ||
232 | this.isFocused = isFocused; | ||
233 | }); | ||
234 | |||
235 | powerMonitor.on('suspend', () => { | ||
236 | debug('System suspended starting timer'); | ||
237 | |||
238 | this.timeSuspensionStart = moment(); | ||
239 | }); | ||
240 | |||
241 | powerMonitor.on('resume', () => { | ||
242 | debug('System resumed, last suspended on', this.timeSuspensionStart); | ||
243 | this.actions.service.resetLastPollTimer(); | ||
244 | |||
245 | const idleTime = this.stores.settings.app.reloadAfterResumeTime; | ||
246 | |||
247 | if ( | ||
248 | this.timeSuspensionStart.add(idleTime, 'm').isBefore(moment()) && | ||
249 | this.stores.settings.app.reloadAfterResume | ||
250 | ) { | ||
251 | debug('Reloading services, user info and features'); | ||
252 | |||
253 | setInterval(() => { | ||
254 | debug('Reload app interval is starting'); | ||
255 | if (this.isOnline) { | ||
256 | window.location.reload(); | ||
257 | } | ||
258 | }, ms('2s')); | ||
259 | } | ||
260 | }); | ||
261 | |||
262 | // macOS catalina notifications hack | ||
263 | // notifications got stuck after upgrade but forcing a notification | ||
264 | // via `new Notification` triggered the permission request | ||
265 | if (isMac && !localStorage.getItem(CATALINA_NOTIFICATION_HACK_KEY)) { | ||
266 | debug('Triggering macOS Catalina notification permission trigger'); | ||
267 | // eslint-disable-next-line no-new | ||
268 | new window.Notification('Welcome to Ferdium 5', { | ||
269 | body: 'Have a wonderful day & happy messaging.', | ||
270 | }); | ||
271 | |||
272 | localStorage.setItem(CATALINA_NOTIFICATION_HACK_KEY, 'true'); | ||
273 | } | ||
274 | } | ||
275 | |||
276 | @computed get cacheSize() { | ||
277 | return this.getAppCacheSizeRequest.execute().result; | ||
278 | } | ||
279 | |||
280 | @computed get debugInfo() { | ||
281 | const settings = cleanseJSObject(this.stores.settings.app); | ||
282 | settings.lockedPassword = '******'; | ||
283 | |||
284 | return { | ||
285 | host: { | ||
286 | platform: process.platform, | ||
287 | release: osRelease, | ||
288 | screens: screen.getAllDisplays(), | ||
289 | }, | ||
290 | ferdium: { | ||
291 | version: ferdiumVersion, | ||
292 | electron: electronVersion, | ||
293 | installedRecipes: this.stores.recipes.all.map(recipe => ({ | ||
294 | id: recipe.id, | ||
295 | version: recipe.version, | ||
296 | })), | ||
297 | devRecipes: this.stores.recipePreviews.dev.map(recipe => ({ | ||
298 | id: recipe.id, | ||
299 | version: recipe.version, | ||
300 | })), | ||
301 | services: this.stores.services.all.map(service => ({ | ||
302 | id: service.id, | ||
303 | recipe: service.recipe.id, | ||
304 | isAttached: service.isAttached, | ||
305 | isActive: service.isActive, | ||
306 | isEnabled: service.isEnabled, | ||
307 | isHibernating: service.isHibernating, | ||
308 | hasCrashed: service.hasCrashed, | ||
309 | isDarkModeEnabled: service.isDarkModeEnabled, | ||
310 | isProgressbarEnabled: service.isProgressbarEnabled, | ||
311 | })), | ||
312 | messages: this.stores.globalError.messages, | ||
313 | workspaces: this.stores.workspaces.workspaces.map(workspace => ({ | ||
314 | id: workspace.id, | ||
315 | services: workspace.services, | ||
316 | })), | ||
317 | windowSettings: readJsonSync(userDataPath('window-state.json')), | ||
318 | settings, | ||
319 | features: this.stores.features.features, | ||
320 | user: this.stores.user.data.id, | ||
321 | }, | ||
322 | }; | ||
323 | } | ||
324 | |||
325 | // Actions | ||
326 | @action _notify({ title, options, notificationId, serviceId = null }) { | ||
327 | if (this.stores.settings.all.app.isAppMuted) return; | ||
328 | |||
329 | // TODO: is there a simple way to use blobs for notifications without storing them on disk? | ||
330 | if (options.icon && options.icon.startsWith('blob:')) { | ||
331 | delete options.icon; | ||
332 | } | ||
333 | |||
334 | const notification = new window.Notification(title, options); | ||
335 | |||
336 | debug('New notification', title, options); | ||
337 | |||
338 | notification.addEventListener('click', () => { | ||
339 | if (serviceId) { | ||
340 | this.actions.service.sendIPCMessage({ | ||
341 | channel: `notification-onclick:${notificationId}`, | ||
342 | args: {}, | ||
343 | serviceId, | ||
344 | }); | ||
345 | |||
346 | this.actions.service.setActive({ | ||
347 | serviceId, | ||
348 | }); | ||
349 | |||
350 | if (!mainWindow.isVisible()) { | ||
351 | mainWindow.show(); | ||
352 | } | ||
353 | if (mainWindow.isMinimized()) { | ||
354 | mainWindow.restore(); | ||
355 | } | ||
356 | mainWindow.focus(); | ||
357 | |||
358 | debug('Notification click handler'); | ||
359 | } | ||
360 | }); | ||
361 | } | ||
362 | |||
363 | @action _setBadge({ unreadDirectMessageCount, unreadIndirectMessageCount }) { | ||
364 | let indicator = unreadDirectMessageCount; | ||
365 | |||
366 | if (indicator === 0 && unreadIndirectMessageCount !== 0) { | ||
367 | indicator = '•'; | ||
368 | } else if ( | ||
369 | unreadDirectMessageCount === 0 && | ||
370 | unreadIndirectMessageCount === 0 | ||
371 | ) { | ||
372 | indicator = 0; | ||
373 | } else { | ||
374 | indicator = Number.parseInt(indicator, 10); | ||
375 | } | ||
376 | |||
377 | ipcRenderer.send('updateAppIndicator', { | ||
378 | indicator, | ||
379 | }); | ||
380 | } | ||
381 | |||
382 | @action _launchOnStartup({ enable }) { | ||
383 | this.autoLaunchOnStart = enable; | ||
384 | |||
385 | try { | ||
386 | if (enable) { | ||
387 | debug('enabling launch on startup', executablePath); | ||
388 | autoLauncher.enable(); | ||
389 | } else { | ||
390 | debug('disabling launch on startup'); | ||
391 | autoLauncher.disable(); | ||
392 | } | ||
393 | } catch (error) { | ||
394 | console.warn(error); | ||
395 | } | ||
396 | } | ||
397 | |||
398 | // Ideally(?) this should be merged with the 'shell-helpers' functionality | ||
399 | @action _openExternalUrl({ url }) { | ||
400 | openExternalUrl(new URL(url)); | ||
401 | } | ||
402 | |||
403 | @action _checkForUpdates() { | ||
404 | if ( | ||
405 | this.isOnline && | ||
406 | this.stores.settings.app.automaticUpdates && | ||
407 | (isMac || isWindows || process.env.APPIMAGE) | ||
408 | ) { | ||
409 | debug('_checkForUpdates: sending event to autoUpdate:check'); | ||
410 | this.updateStatus = this.updateStatusTypes.CHECKING; | ||
411 | ipcRenderer.send('autoUpdate', { | ||
412 | action: 'check', | ||
413 | }); | ||
414 | } | ||
415 | |||
416 | if (this.isOnline && this.stores.settings.app.automaticUpdates) { | ||
417 | this.actions.recipe.update(); | ||
418 | } | ||
419 | } | ||
420 | |||
421 | @action _installUpdate() { | ||
422 | debug('_installUpdate: sending event to autoUpdate:install'); | ||
423 | ipcRenderer.send('autoUpdate', { | ||
424 | action: 'install', | ||
425 | }); | ||
426 | } | ||
427 | |||
428 | @action _resetUpdateStatus() { | ||
429 | this.updateStatus = ''; | ||
430 | } | ||
431 | |||
432 | @action _healthCheck() { | ||
433 | this.healthCheckRequest.execute(); | ||
434 | } | ||
435 | |||
436 | @action _muteApp({ isMuted, overrideSystemMute = true }) { | ||
437 | this.isSystemMuteOverridden = overrideSystemMute; | ||
438 | this.actions.settings.update({ | ||
439 | type: 'app', | ||
440 | data: { | ||
441 | isAppMuted: isMuted, | ||
442 | }, | ||
443 | }); | ||
444 | } | ||
445 | |||
446 | @action _toggleMuteApp() { | ||
447 | this._muteApp({ | ||
448 | isMuted: !this.stores.settings.all.app.isAppMuted, | ||
449 | }); | ||
450 | } | ||
451 | |||
452 | @action async _clearAllCache() { | ||
453 | this.isClearingAllCache = true; | ||
454 | const clearAppCache = this.clearAppCacheRequest.execute(); | ||
455 | const allServiceIds = await getServiceIdsFromPartitions(); | ||
456 | const allOrphanedServiceIds = allServiceIds.filter( | ||
457 | id => | ||
458 | !this.stores.services.all.some( | ||
459 | s => id.replace('service-', '') === s.id, | ||
460 | ), | ||
461 | ); | ||
462 | |||
463 | try { | ||
464 | await Promise.all( | ||
465 | allOrphanedServiceIds.map(id => removeServicePartitionDirectory(id)), | ||
466 | ); | ||
467 | } catch (error) { | ||
468 | console.log('Error while deleting service partition directory -', error); | ||
469 | } | ||
470 | await Promise.all( | ||
471 | this.stores.services.all.map(s => | ||
472 | this.actions.service.clearCache({ | ||
473 | serviceId: s.id, | ||
474 | }), | ||
475 | ), | ||
476 | ); | ||
477 | |||
478 | await clearAppCache._promise; | ||
479 | |||
480 | await sleep(ms('1s')); | ||
481 | |||
482 | this.getAppCacheSizeRequest.execute(); | ||
483 | |||
484 | this.isClearingAllCache = false; | ||
485 | } | ||
486 | |||
487 | // Reactions | ||
488 | _offlineCheck() { | ||
489 | if (!this.isOnline) { | ||
490 | this.timeOfflineStart = moment(); | ||
491 | } else { | ||
492 | const deltaTime = moment().diff(this.timeOfflineStart); | ||
493 | |||
494 | if (deltaTime > ms('30m')) { | ||
495 | this.actions.service.reloadAll(); | ||
496 | } | ||
497 | } | ||
498 | } | ||
499 | |||
500 | _setLocale() { | ||
501 | if (this.stores.user.isLoggedIn && this.stores.user.data.locale) { | ||
502 | this.locale = this.stores.user.data.locale; | ||
503 | } else if (!this.locale) { | ||
504 | this.locale = this._getDefaultLocale(); | ||
505 | } | ||
506 | |||
507 | moment.locale(this.locale); | ||
508 | debug(`Set locale to "${this.locale}"`); | ||
509 | } | ||
510 | |||
511 | _getDefaultLocale() { | ||
512 | return getLocale({ | ||
513 | locale: ferdiumLocale, | ||
514 | locales, | ||
515 | fallbackLocale: DEFAULT_APP_SETTINGS.fallbackLocale, | ||
516 | }); | ||
517 | } | ||
518 | |||
519 | _muteAppHandler() { | ||
520 | const { showMessageBadgesEvenWhenMuted } = this.stores.ui; | ||
521 | |||
522 | if (!showMessageBadgesEvenWhenMuted) { | ||
523 | this.actions.app.setBadge({ | ||
524 | unreadDirectMessageCount: 0, | ||
525 | unreadIndirectMessageCount: 0, | ||
526 | }); | ||
527 | } | ||
528 | } | ||
529 | |||
530 | _handleFullScreen() { | ||
531 | const body = document.querySelector('body'); | ||
532 | |||
533 | if (body) { | ||
534 | if (this.isFullScreen) { | ||
535 | body.classList.add('isFullScreen'); | ||
536 | } else { | ||
537 | body.classList.remove('isFullScreen'); | ||
538 | } | ||
539 | } | ||
540 | } | ||
541 | |||
542 | _handleLogout() { | ||
543 | if (!this.stores.user.isLoggedIn && this.fetchDataInterval !== null) { | ||
544 | clearInterval(this.fetchDataInterval); | ||
545 | } | ||
546 | } | ||
547 | |||
548 | // Helpers | ||
549 | _appStartsCounter() { | ||
550 | this.actions.settings.update({ | ||
551 | type: 'stats', | ||
552 | data: { | ||
553 | appStarts: (this.stores.settings.all.stats.appStarts || 0) + 1, | ||
554 | }, | ||
555 | }); | ||
556 | } | ||
557 | |||
558 | async _autoStart() { | ||
559 | this.autoLaunchOnStart = await this._checkAutoStart(); | ||
560 | |||
561 | if (this.stores.settings.all.stats.appStarts === 1) { | ||
562 | debug('Set app to launch on start'); | ||
563 | this.actions.app.launchOnStartup({ | ||
564 | enable: true, | ||
565 | }); | ||
566 | } | ||
567 | } | ||
568 | |||
569 | async _checkAutoStart() { | ||
570 | return autoLauncher.isEnabled() || false; | ||
571 | } | ||
572 | |||
573 | async _systemDND() { | ||
574 | debug('Checking if Do Not Disturb Mode is on'); | ||
575 | const dnd = await ipcRenderer.invoke('get-dnd'); | ||
576 | debug('Do not disturb mode is', dnd); | ||
577 | if ( | ||
578 | dnd !== this.stores.settings.all.app.isAppMuted && | ||
579 | !this.isSystemMuteOverridden | ||
580 | ) { | ||
581 | this.actions.app.muteApp({ | ||
582 | isMuted: dnd, | ||
583 | overrideSystemMute: false, | ||
584 | }); | ||
585 | } | ||
586 | } | ||
587 | } | ||