aboutsummaryrefslogtreecommitdiffstats
path: root/src/stores/AppStore.ts
diff options
context:
space:
mode:
authorLibravatar Ricardo Cino <ricardo@cino.io>2022-06-22 00:32:18 +0200
committerLibravatar GitHub <noreply@github.com>2022-06-21 22:32:18 +0000
commit73ba955e344c8ccedd43235495ef8b72b5a2b6fd (patch)
tree03766ab32fefe7e83026a14393527f1dcbaed849 /src/stores/AppStore.ts
parentdocs: add cino as a contributor for infra [skip ci] (#330) (diff)
downloadferdium-app-73ba955e344c8ccedd43235495ef8b72b5a2b6fd.tar.gz
ferdium-app-73ba955e344c8ccedd43235495ef8b72b5a2b6fd.tar.zst
ferdium-app-73ba955e344c8ccedd43235495ef8b72b5a2b6fd.zip
chore: Transform AppStore.js into Typescript (#329)
* turn actions into typescript * correct tsconfig * added TypedStore
Diffstat (limited to 'src/stores/AppStore.ts')
-rw-r--r--src/stores/AppStore.ts587
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 @@
1import { ipcRenderer } from 'electron';
2import {
3 app,
4 screen,
5 powerMonitor,
6 nativeTheme,
7 getCurrentWindow,
8 process as remoteProcess,
9} from '@electron/remote';
10import { action, computed, observable } from 'mobx';
11import moment from 'moment';
12import AutoLaunch from 'auto-launch';
13import ms from 'ms';
14import { URL } from 'url';
15import { readJsonSync } from 'fs-extra';
16
17import { Stores } from 'src/stores.types';
18import { ApiInterface } from 'src/api';
19import { Actions } from 'src/actions/lib/actions';
20import TypedStore from './lib/TypedStore';
21import Request from './lib/Request';
22import { CHECK_INTERVAL, DEFAULT_APP_SETTINGS } from '../config';
23import { cleanseJSObject } from '../jsUtils';
24import { isMac, isWindows, electronVersion, osRelease } from '../environment';
25import {
26 ferdiumVersion,
27 userDataPath,
28 ferdiumLocale,
29} from '../environment-remote';
30import { generatedTranslations } from '../i18n/translations';
31import { getLocale } from '../helpers/i18n-helpers';
32
33import {
34 getServiceIdsFromPartitions,
35 removeServicePartitionDirectory,
36} from '../helpers/service-helpers';
37import { openExternalUrl } from '../helpers/url-helpers';
38import { sleep } from '../helpers/async-helpers';
39
40const debug = require('../preload-safe-debug')('Ferdium:AppStore');
41
42const mainWindow = getCurrentWindow();
43
44const executablePath = isMac ? remoteProcess.execPath : process.execPath;
45const autoLauncher = new AutoLaunch({
46 name: 'Ferdium',
47 path: executablePath,
48});
49
50const CATALINA_NOTIFICATION_HACK_KEY =
51 '_temp_askedForCatalinaNotificationPermissions';
52
53const locales = generatedTranslations();
54
55export 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}