From 6b2c2b8dfb86245a1747bf7977159f5129461863 Mon Sep 17 00:00:00 2001 From: Ricardo Cino Date: Thu, 23 Jun 2022 18:10:39 +0200 Subject: chore: servicesStore + models into typescript (#344) --- src/containers/auth/SetupAssistantScreen.tsx | 26 +- src/features/todos/store.js | 4 +- src/features/workspaces/models/Workspace.ts | 2 +- src/index.ts | 4 +- src/jsUtils.test.ts | 41 +- src/jsUtils.ts | 23 +- src/models/Recipe.ts | 56 +- src/models/Service.js | 478 --------- src/models/Service.ts | 486 +++++++++ src/models/UserAgent.js | 111 --- src/models/UserAgent.ts | 116 +++ src/stores.types.ts | 31 +- src/stores/ServicesStore.js | 1319 ------------------------- src/stores/ServicesStore.ts | 1356 ++++++++++++++++++++++++++ src/webview/recipe.js | 17 +- 15 files changed, 2098 insertions(+), 1972 deletions(-) delete mode 100644 src/models/Service.js create mode 100644 src/models/Service.ts delete mode 100644 src/models/UserAgent.js create mode 100644 src/models/UserAgent.ts delete mode 100644 src/stores/ServicesStore.js create mode 100644 src/stores/ServicesStore.ts diff --git a/src/containers/auth/SetupAssistantScreen.tsx b/src/containers/auth/SetupAssistantScreen.tsx index 44bd32772..8f1871776 100644 --- a/src/containers/auth/SetupAssistantScreen.tsx +++ b/src/containers/auth/SetupAssistantScreen.tsx @@ -11,22 +11,22 @@ import UserStore from '../../stores/UserStore'; interface IProps { stores: { - services?: ServicesStore, - router: RouterStore, - recipes?: RecipesStore, - user?: UserStore, - }, + services: ServicesStore; + router: RouterStore; + recipes?: RecipesStore; + user?: UserStore; + }; actions: { - user: UserStore, - service: ServicesStore, - recipe: RecipesStore, - }, + user: UserStore; + service: ServicesStore; + recipe: RecipesStore; + }; }; class SetupAssistantScreen extends Component { state = { isSettingUpServices: false, - } + }; // TODO: Why are these hardcoded here? Do they need to conform to specific services in the packaged recipes? If so, its more important to fix this services = { @@ -69,7 +69,9 @@ class SetupAssistantScreen extends Component { }; async setupServices(serviceConfig) { - const { stores: { services, router } } = this.props; + const { + stores: { services, router }, + } = this.props; this.setState({ isSettingUpServices: true, @@ -79,7 +81,7 @@ class SetupAssistantScreen extends Component { for (const config of serviceConfig) { const serviceData = { name: this.services[config.id].name, - team: config.team + team: config.team, }; await services._createService({ diff --git a/src/features/todos/store.js b/src/features/todos/store.js index 9ece76327..8c3917cc3 100644 --- a/src/features/todos/store.js +++ b/src/features/todos/store.js @@ -18,7 +18,9 @@ import { createActionBindings } from '../utils/ActionBinding'; import { IPC, TODOS_ROUTES } from './constants'; import UserAgent from '../../models/UserAgent'; -const debug = require('../../preload-safe-debug')('Ferdium:feature:todos:store'); +const debug = require('../../preload-safe-debug')( + 'Ferdium:feature:todos:store', +); export default class TodoStore extends FeatureStore { @observable stores = null; diff --git a/src/features/workspaces/models/Workspace.ts b/src/features/workspaces/models/Workspace.ts index cd3918fba..bc636011d 100644 --- a/src/features/workspaces/models/Workspace.ts +++ b/src/features/workspaces/models/Workspace.ts @@ -9,7 +9,7 @@ export default class Workspace { @observable order = null; - @observable services = []; + @observable services: string[] = []; @observable userId = null; diff --git a/src/index.ts b/src/index.ts index fa957bf10..0fccac6e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,7 +30,7 @@ import { userDataRecipesPath, userDataPath, } from './environment-remote'; -import { ifUndefinedBoolean } from './jsUtils'; +import { ifUndefined } from './jsUtils'; import { mainIpcHandler as basicAuthHandler } from './features/basicAuth'; import ipcApi from './electron/ipc-api'; @@ -91,7 +91,7 @@ const settings = new Settings('app', DEFAULT_APP_SETTINGS); const proxySettings = new Settings('proxy'); const retrieveSettingValue = (key: string, defaultValue: boolean) => - ifUndefinedBoolean(settings.get(key), defaultValue); + ifUndefined(settings.get(key), defaultValue); const liftSingleInstanceLock = retrieveSettingValue( 'liftSingleInstanceLock', diff --git a/src/jsUtils.test.ts b/src/jsUtils.test.ts index 651caee5f..34cd8f098 100644 --- a/src/jsUtils.test.ts +++ b/src/jsUtils.test.ts @@ -18,36 +18,53 @@ describe('jsUtils', () => { }); }); - describe('ifUndefinedBoolean', () => { + describe('ifUndefined', () => { it('returns the default value for undefined input', () => { - const result = jsUtils.ifUndefinedBoolean(undefined, false); + const result = jsUtils.ifUndefined(undefined, 'abc'); + expect(result).toEqual('abc'); + }); + + it('returns the default value for null input', () => { + const result = jsUtils.ifUndefined(null, 'abc'); + expect(result).toEqual('abc'); + }); + + it('returns the non-default input value for regular string input', () => { + const result = jsUtils.ifUndefined('some random string', 'abc'); + expect(result).toEqual('some random string'); + }); + }); + + describe('ifUndefined', () => { + it('returns the default value for undefined input', () => { + const result = jsUtils.ifUndefined(undefined, false); expect(result).toEqual(false); }); it('returns the default value for null input', () => { - const result = jsUtils.ifUndefinedBoolean(null, true); + const result = jsUtils.ifUndefined(null, true); expect(result).toEqual(true); }); it('returns the non-default input value for regular boolean input', () => { - const result = jsUtils.ifUndefinedBoolean(true, false); + const result = jsUtils.ifUndefined(true, false); expect(result).toEqual(true); }); }); - describe('ifUndefinedNumber', () => { + describe('ifUndefined', () => { it('returns the default value for undefined input', () => { - const result = jsUtils.ifUndefinedNumber(undefined, 123); + const result = jsUtils.ifUndefined(undefined, 123); expect(result).toEqual(123); }); it('returns the default value for null input', () => { - const result = jsUtils.ifUndefinedNumber(null, 234); + const result = jsUtils.ifUndefined(null, 234); expect(result).toEqual(234); }); it('returns the non-default input value for regular Number input', () => { - const result = jsUtils.ifUndefinedNumber(1234, 5678); + const result = jsUtils.ifUndefined(1234, 5678); expect(result).toEqual(1234); }); }); @@ -70,10 +87,10 @@ describe('jsUtils', () => { it('returns the parsed JSON for the string input', () => { const result1 = jsUtils.convertToJSON('{"a":"b","c":"d"}'); - expect(result1).toEqual({a: 'b', c: 'd'}); + expect(result1).toEqual({ a: 'b', c: 'd' }); const result2 = jsUtils.convertToJSON('[{"a":"b"},{"c":"d"}]'); - expect(result2).toEqual([{a: 'b'}, {c: 'd'}]); + expect(result2).toEqual([{ a: 'b' }, { c: 'd' }]); }); }); @@ -89,8 +106,8 @@ describe('jsUtils', () => { }); it('returns cloned object for valid input', () => { - const result = jsUtils.cleanseJSObject([{a: 'b'}, {c: 'd'}]); - expect(result).toEqual([{a: 'b'}, {c: 'd'}]); + const result = jsUtils.cleanseJSObject([{ a: 'b' }, { c: 'd' }]); + expect(result).toEqual([{ a: 'b' }, { c: 'd' }]); }); }); }); diff --git a/src/jsUtils.ts b/src/jsUtils.ts index 250d595eb..f5b39a000 100644 --- a/src/jsUtils.ts +++ b/src/jsUtils.ts @@ -1,9 +1,22 @@ -export const ifUndefinedString = (source: string | undefined | null, defaultValue: string): string => (source !== undefined && source !== null ? source : defaultValue); +// TODO: ifUndefinedString can be removed after ./src/webview/recipe.js is converted to typescript. +export const ifUndefinedString = ( + source: string | undefined | null, + defaultValue: string, +): string => (source !== undefined && source !== null ? source : defaultValue); -export const ifUndefinedBoolean = (source: boolean | undefined | null, defaultValue: boolean): boolean => Boolean(source !== undefined && source !== null ? source : defaultValue); +export const ifUndefined = ( + source: undefined | null | T, + defaultValue: T, +): T => { + if (source !== undefined && source !== null) { + return source; + } -export const ifUndefinedNumber = (source: number | undefined | null, defaultValue: number): number => Number(source !== undefined && source !== null ? source : defaultValue); + return defaultValue; +}; -export const convertToJSON = (data: string | any | undefined | null) => data && typeof data === 'string' && data.length > 0 ? JSON.parse(data) : data +export const convertToJSON = (data: string | any | undefined | null) => + data && typeof data === 'string' && data.length > 0 ? JSON.parse(data) : data; -export const cleanseJSObject = (data: any | undefined | null) => JSON.parse(JSON.stringify(data)) +export const cleanseJSObject = (data: any | undefined | null) => + JSON.parse(JSON.stringify(data)); diff --git a/src/models/Recipe.ts b/src/models/Recipe.ts index be889d22c..eb8b0b1ff 100644 --- a/src/models/Recipe.ts +++ b/src/models/Recipe.ts @@ -2,7 +2,7 @@ import semver from 'semver'; import { pathExistsSync } from 'fs-extra'; import { join } from 'path'; import { DEFAULT_SERVICE_SETTINGS } from '../config'; -import { ifUndefinedString, ifUndefinedBoolean } from '../jsUtils'; +import { ifUndefined } from '../jsUtils'; interface IRecipe { id: string; @@ -34,7 +34,7 @@ export default class Recipe { name: string = ''; - description = ''; + description: string = ''; version: string = ''; @@ -75,6 +75,19 @@ export default class Recipe { // TODO: Is this being used? local: boolean = false; + // TODO Add types for this once we know if they are neccesary to pass + // on to the initialize-recipe ipc event. + overrideUserAgent: any; + + buildUrl: any; + + modifyRequestHeaders: any; + + knownCertificateHosts: any; + + events: any; + // End todo. + // TODO: Need to reconcile which of these are optional/mandatory constructor(data: IRecipe) { if (!data) { @@ -93,61 +106,64 @@ export default class Recipe { } // from the recipe - this.id = ifUndefinedString(data.id, this.id); - this.name = ifUndefinedString(data.name, this.name); - this.version = ifUndefinedString(data.version, this.version); + this.id = ifUndefined(data.id, this.id); + this.name = ifUndefined(data.name, this.name); + this.version = ifUndefined(data.version, this.version); this.aliases = data.aliases || this.aliases; - this.serviceURL = ifUndefinedString( + this.serviceURL = ifUndefined( data.config.serviceURL, this.serviceURL, ); - this.hasDirectMessages = ifUndefinedBoolean( + this.hasDirectMessages = ifUndefined( data.config.hasDirectMessages, this.hasDirectMessages, ); - this.hasIndirectMessages = ifUndefinedBoolean( + this.hasIndirectMessages = ifUndefined( data.config.hasIndirectMessages, this.hasIndirectMessages, ); - this.hasNotificationSound = ifUndefinedBoolean( + this.hasNotificationSound = ifUndefined( data.config.hasNotificationSound, this.hasNotificationSound, ); - this.hasTeamId = ifUndefinedBoolean(data.config.hasTeamId, this.hasTeamId); - this.hasCustomUrl = ifUndefinedBoolean( + this.hasTeamId = ifUndefined( + data.config.hasTeamId, + this.hasTeamId, + ); + this.hasCustomUrl = ifUndefined( data.config.hasCustomUrl, this.hasCustomUrl, ); - this.hasHostedOption = ifUndefinedBoolean( + this.hasHostedOption = ifUndefined( data.config.hasHostedOption, this.hasHostedOption, ); - this.urlInputPrefix = ifUndefinedString( + this.urlInputPrefix = ifUndefined( data.config.urlInputPrefix, this.urlInputPrefix, ); - this.urlInputSuffix = ifUndefinedString( + this.urlInputSuffix = ifUndefined( data.config.urlInputSuffix, this.urlInputSuffix, ); - this.disablewebsecurity = ifUndefinedBoolean( + this.disablewebsecurity = ifUndefined( data.config.disablewebsecurity, this.disablewebsecurity, ); - this.autoHibernate = ifUndefinedBoolean( + this.autoHibernate = ifUndefined( data.config.autoHibernate, this.autoHibernate, ); - this.local = ifUndefinedBoolean(data.config.local, this.local); - this.message = ifUndefinedString(data.config.message, this.message); - this.allowFavoritesDelineationInUnreadCount = ifUndefinedBoolean( + this.local = ifUndefined(data.config.local, this.local); + this.message = ifUndefined(data.config.message, this.message); + this.allowFavoritesDelineationInUnreadCount = ifUndefined( data.config.allowFavoritesDelineationInUnreadCount, this.allowFavoritesDelineationInUnreadCount, ); // computed this.path = data.path; - this.partition = ifUndefinedString(data.config.partition, this.partition); + this.partition = ifUndefined(data.config.partition, this.partition); } // TODO: Need to remove this if its not used anywhere diff --git a/src/models/Service.js b/src/models/Service.js deleted file mode 100644 index 53285e440..000000000 --- a/src/models/Service.js +++ /dev/null @@ -1,478 +0,0 @@ -import { autorun, computed, observable } from 'mobx'; -import { ipcRenderer } from 'electron'; -import { webContents } from '@electron/remote'; -import normalizeUrl from 'normalize-url'; -import { join } from 'path'; - -import { todosStore } from '../features/todos'; -import { isValidExternalURL } from '../helpers/url-helpers'; -import UserAgent from './UserAgent'; -import { DEFAULT_SERVICE_ORDER } from '../config'; -import { - ifUndefinedString, - ifUndefinedBoolean, - ifUndefinedNumber, -} from '../jsUtils'; - -const debug = require('../preload-safe-debug')('Ferdium:Service'); - -// TODO: Shouldn't most of these values default to what's defined in DEFAULT_SERVICE_SETTINGS? -export default class Service { - id = ''; - - recipe = null; - - _webview = null; - - timer = null; - - events = {}; - - @observable isAttached = false; - - @observable isActive = false; // Is current webview active - - @observable name = ''; - - @observable unreadDirectMessageCount = 0; - - @observable unreadIndirectMessageCount = 0; - - @observable dialogTitle = ''; - - @observable order = DEFAULT_SERVICE_ORDER; - - @observable isEnabled = true; - - @observable isMuted = false; - - @observable team = ''; - - @observable customUrl = ''; - - @observable isNotificationEnabled = true; - - @observable isBadgeEnabled = true; - - @observable trapLinkClicks = false; - - @observable isIndirectMessageBadgeEnabled = true; - - @observable iconUrl = ''; - - @observable hasCustomUploadedIcon = false; - - @observable hasCrashed = false; - - @observable isDarkModeEnabled = false; - - @observable isProgressbarEnabled = true; - - @observable darkReaderSettings = { brightness: 100, contrast: 90, sepia: 10 }; - - @observable spellcheckerLanguage = null; - - @observable isFirstLoad = true; - - @observable isLoading = true; - - @observable isLoadingPage = true; - - @observable isError = false; - - @observable errorMessage = ''; - - @observable isUsingCustomUrl = false; - - @observable isServiceAccessRestricted = false; - - @observable restrictionType = null; - - @observable isHibernationEnabled = false; - - @observable isWakeUpEnabled = true; - - @observable isHibernationRequested = false; - - @observable onlyShowFavoritesInUnreadCount = false; - - @observable lastUsed = Date.now(); // timestamp - - @observable lastHibernated = null; // timestamp - - @observable lastPoll = Date.now(); - - @observable lastPollAnswer = Date.now(); - - @observable lostRecipeConnection = false; - - @observable lostRecipeReloadAttempt = 0; - - @observable userAgentModel = null; - - constructor(data, recipe) { - if (!data) { - throw new Error('Service config not valid'); - } - - if (!recipe) { - throw new Error('Service recipe not valid'); - } - - this.recipe = recipe; - - this.userAgentModel = new UserAgent(recipe.overrideUserAgent); - - this.id = ifUndefinedString(data.id, this.id); - this.name = ifUndefinedString(data.name, this.name); - this.team = ifUndefinedString(data.team, this.team); - this.customUrl = ifUndefinedString(data.customUrl, this.customUrl); - this.iconUrl = ifUndefinedString(data.iconUrl, this.iconUrl); - this.order = ifUndefinedNumber(data.order, this.order); - this.isEnabled = ifUndefinedBoolean(data.isEnabled, this.isEnabled); - this.isNotificationEnabled = ifUndefinedBoolean( - data.isNotificationEnabled, - this.isNotificationEnabled, - ); - this.isBadgeEnabled = ifUndefinedBoolean( - data.isBadgeEnabled, - this.isBadgeEnabled, - ); - this.trapLinkClicks = ifUndefinedBoolean( - data.trapLinkClicks, - this.trapLinkClicks, - ); - this.isIndirectMessageBadgeEnabled = ifUndefinedBoolean( - data.isIndirectMessageBadgeEnabled, - this.isIndirectMessageBadgeEnabled, - ); - this.isMuted = ifUndefinedBoolean(data.isMuted, this.isMuted); - this.isDarkModeEnabled = ifUndefinedBoolean( - data.isDarkModeEnabled, - this.isDarkModeEnabled, - ); - this.darkReaderSettings = ifUndefinedString( - data.darkReaderSettings, - this.darkReaderSettings, - ); - this.isProgressbarEnabled = ifUndefinedBoolean( - data.isProgressbarEnabled, - this.isProgressbarEnabled, - ); - this.hasCustomUploadedIcon = ifUndefinedBoolean( - data.iconId?.length > 0, - this.hasCustomUploadedIcon, - ); - this.onlyShowFavoritesInUnreadCount = ifUndefinedBoolean( - data.onlyShowFavoritesInUnreadCount, - this.onlyShowFavoritesInUnreadCount, - ); - this.proxy = ifUndefinedString(data.proxy, this.proxy); - this.spellcheckerLanguage = ifUndefinedString( - data.spellcheckerLanguage, - this.spellcheckerLanguage, - ); - this.userAgentPref = ifUndefinedString( - data.userAgentPref, - this.userAgentPref, - ); - this.isHibernationEnabled = ifUndefinedBoolean( - data.isHibernationEnabled, - this.isHibernationEnabled, - ); - this.isWakeUpEnabled = ifUndefinedBoolean( - data.isWakeUpEnabled, - this.isWakeUpEnabled, - ); - - // Check if "Hibernate on Startup" is enabled and hibernate all services except active one - const { hibernateOnStartup } = window['ferdium'].stores.settings.app; - // The service store is probably not loaded yet so we need to use localStorage data to get active service - const isActive = - window.localStorage.service && - JSON.parse(window.localStorage.service).activeService === this.id; - if (hibernateOnStartup && !isActive) { - this.isHibernationRequested = true; - } - - autorun(() => { - if (!this.isEnabled) { - this.webview = null; - this.isAttached = false; - this.unreadDirectMessageCount = 0; - this.unreadIndirectMessageCount = 0; - } - - if (this.recipe.hasCustomUrl && this.customUrl) { - this.isUsingCustomUrl = true; - } - }); - } - - @computed get shareWithWebview() { - return { - id: this.id, - spellcheckerLanguage: this.spellcheckerLanguage, - isDarkModeEnabled: this.isDarkModeEnabled, - isProgressbarEnabled: this.isProgressbarEnabled, - darkReaderSettings: this.darkReaderSettings, - team: this.team, - url: this.url, - hasCustomIcon: this.hasCustomIcon, - onlyShowFavoritesInUnreadCount: this.onlyShowFavoritesInUnreadCount, - trapLinkClicks: this.trapLinkClicks, - }; - } - - @computed get isTodosService() { - return this.recipe.id === todosStore.todoRecipeId; - } - - @computed get canHibernate() { - return this.isHibernationEnabled; - } - - @computed get isHibernating() { - return this.canHibernate && this.isHibernationRequested; - } - - get webview() { - if (this.isTodosService) { - return todosStore.webview; - } - - return this._webview; - } - - set webview(webview) { - this._webview = webview; - } - - @computed get url() { - if (this.recipe.hasCustomUrl && this.customUrl) { - let url; - try { - url = normalizeUrl(this.customUrl, { - stripAuthentication: false, - stripWWW: false, - removeTrailingSlash: false, - }); - } catch { - console.error( - `Service (${this.recipe.name}): '${this.customUrl}' is not a valid Url.`, - ); - } - - if (typeof this.recipe.buildUrl === 'function') { - url = this.recipe.buildUrl(url); - } - - return url; - } - - if (this.recipe.hasTeamId && this.team) { - return this.recipe.serviceURL.replace('{teamId}', this.team); - } - - return this.recipe.serviceURL; - } - - @computed get icon() { - if (this.iconUrl) { - return this.iconUrl; - } - - return join(this.recipe.path, 'icon.svg'); - } - - @computed get hasCustomIcon() { - return Boolean(this.iconUrl); - } - - @computed get userAgent() { - return this.userAgentModel.userAgent; - } - - @computed get userAgentPref() { - return this.userAgentModel.userAgentPref; - } - - set userAgentPref(pref) { - this.userAgentModel.userAgentPref = pref; - } - - @computed get defaultUserAgent() { - return this.userAgentModel.defaultUserAgent; - } - - @computed get partition() { - return this.recipe.partition || `persist:service-${this.id}`; - } - - initializeWebViewEvents({ handleIPCMessage, openWindow, stores }) { - const webviewWebContents = webContents.fromId( - this.webview.getWebContentsId(), - ); - - this.userAgentModel.setWebviewReference(this.webview); - - // If the recipe has implemented 'modifyRequestHeaders', - // Send those headers to ipcMain so that it can be set in session - if (typeof this.recipe.modifyRequestHeaders === 'function') { - const modifiedRequestHeaders = this.recipe.modifyRequestHeaders(); - debug(this.name, 'modifiedRequestHeaders', modifiedRequestHeaders); - ipcRenderer.send('modifyRequestHeaders', { - modifiedRequestHeaders, - serviceId: this.id, - }); - } else { - debug(this.name, 'modifyRequestHeaders is not defined in the recipe'); - } - - // if the recipe has implemented 'knownCertificateHosts' - if (typeof this.recipe.knownCertificateHosts === 'function') { - const knownHosts = this.recipe.knownCertificateHosts(); - debug(this.name, 'knownCertificateHosts', knownHosts); - ipcRenderer.send('knownCertificateHosts', { - knownHosts, - serviceId: this.id, - }); - } else { - debug(this.name, 'knownCertificateHosts is not defined in the recipe'); - } - - this.webview.addEventListener('ipc-message', async e => { - if (e.channel === 'inject-js-unsafe') { - await Promise.all( - e.args.map(script => - this.webview.executeJavaScript( - `"use strict"; (() => { ${script} })();`, - ), - ), - ); - } else { - handleIPCMessage({ - serviceId: this.id, - channel: e.channel, - args: e.args, - }); - } - }); - - this.webview.addEventListener( - 'new-window', - (event, url, frameName, options) => { - debug('new-window', event, url, frameName, options); - if (!isValidExternalURL(event.url)) { - return; - } - if ( - event.disposition === 'foreground-tab' || - event.disposition === 'background-tab' - ) { - openWindow({ - event, - url, - frameName, - options, - }); - } else { - ipcRenderer.send('open-browser-window', { - url: event.url, - serviceId: this.id, - }); - } - }, - ); - - this.webview.addEventListener('did-start-loading', event => { - debug('Did start load', this.name, event); - - this.hasCrashed = false; - this.isLoading = true; - this.isLoadingPage = true; - this.isError = false; - }); - - this.webview.addEventListener('did-stop-loading', event => { - debug('Did stop load', this.name, event); - - this.isLoading = false; - this.isLoadingPage = false; - }); - - const didLoad = () => { - this.isLoading = false; - this.isLoadingPage = false; - - if (!this.isError) { - this.isFirstLoad = false; - } - }; - - this.webview.addEventListener('did-frame-finish-load', didLoad.bind(this)); - this.webview.addEventListener('did-navigate', didLoad.bind(this)); - - this.webview.addEventListener('did-fail-load', event => { - debug('Service failed to load', this.name, event); - if ( - event.isMainFrame && - event.errorCode !== -21 && - event.errorCode !== -3 - ) { - this.isError = true; - this.errorMessage = event.errorDescription; - this.isLoading = false; - this.isLoadingPage = false; - } - }); - - this.webview.addEventListener('crashed', () => { - debug('Service crashed', this.name); - this.hasCrashed = true; - }); - - this.webview.addEventListener('found-in-page', ({ result }) => { - debug('Found in page', result); - this.webview.send('found-in-page', result); - }); - - webviewWebContents.on('login', (event, request, authInfo, callback) => { - // const authCallback = callback; - debug('browser login event', authInfo); - event.preventDefault(); - - if (authInfo.isProxy && authInfo.scheme === 'basic') { - debug('Sending service echo ping'); - webviewWebContents.send('get-service-id'); - - debug('Received service id', this.id); - - const ps = stores.settings.proxy[this.id]; - - if (ps) { - debug('Sending proxy auth callback for service', this.id); - callback(ps.user, ps.password); - } else { - debug('No proxy auth config found for', this.id); - } - } - }); - } - - initializeWebViewListener() { - if (this.webview && this.recipe.events) { - for (const eventName of Object.keys(this.recipe.events)) { - const eventHandler = this.recipe[this.recipe.events[eventName]]; - if (typeof eventHandler === 'function') { - this.webview.addEventListener(eventName, eventHandler); - } - } - } - } - - resetMessageCount() { - this.unreadDirectMessageCount = 0; - this.unreadIndirectMessageCount = 0; - } -} 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 @@ +import { autorun, computed, observable } from 'mobx'; +import { ipcRenderer } from 'electron'; +import { webContents } from '@electron/remote'; +import normalizeUrl from 'normalize-url'; +import { join } from 'path'; +import ElectronWebView from 'react-electron-web-view'; + +import { todosStore } from '../features/todos'; +import { isValidExternalURL } from '../helpers/url-helpers'; +import UserAgent from './UserAgent'; +import { DEFAULT_SERVICE_ORDER } from '../config'; +import { ifUndefined } from '../jsUtils'; +import Recipe from './Recipe'; + +const debug = require('../preload-safe-debug')('Ferdium:Service'); + +// TODO: Shouldn't most of these values default to what's defined in DEFAULT_SERVICE_SETTINGS? +export default class Service { + id: string = ''; + + recipe: Recipe; + + _webview: ElectronWebView | null = null; + + timer: NodeJS.Timeout | null = null; + + events = {}; + + @observable isAttached: boolean = false; + + @observable isActive: boolean = false; // Is current webview active + + @observable name: string = ''; + + @observable unreadDirectMessageCount: number = 0; + + @observable unreadIndirectMessageCount: number = 0; + + @observable dialogTitle: string = ''; + + @observable order: number = DEFAULT_SERVICE_ORDER; + + @observable isEnabled: boolean = true; + + @observable isMuted: boolean = false; + + @observable team: string = ''; + + @observable customUrl: string = ''; + + @observable isNotificationEnabled: boolean = true; + + @observable isBadgeEnabled: boolean = true; + + @observable trapLinkClicks: boolean = false; + + @observable isIndirectMessageBadgeEnabled: boolean = true; + + @observable iconUrl: string = ''; + + @observable customIconUrl: string = ''; + + @observable hasCustomUploadedIcon: boolean = false; + + @observable hasCrashed: boolean = false; + + @observable isDarkModeEnabled: boolean = false; + + @observable isProgressbarEnabled: boolean = true; + + @observable darkReaderSettings: object = { + brightness: 100, + contrast: 90, + sepia: 10, + }; + + @observable spellcheckerLanguage: string | null = null; + + @observable isFirstLoad: boolean = true; + + @observable isLoading: boolean = true; + + @observable isLoadingPage: boolean = true; + + @observable isError: boolean = false; + + @observable errorMessage: string = ''; + + @observable isUsingCustomUrl: boolean = false; + + @observable isServiceAccessRestricted: boolean = false; + + // todo is this used? + @observable restrictionType = null; + + @observable isHibernationEnabled: boolean = false; + + @observable isWakeUpEnabled: boolean = true; + + @observable isHibernationRequested: boolean = false; + + @observable onlyShowFavoritesInUnreadCount: boolean = false; + + @observable lastUsed: number = Date.now(); // timestamp + + @observable lastHibernated: number | null = null; // timestamp + + @observable lastPoll: number = Date.now(); + + @observable lastPollAnswer: number = Date.now(); + + @observable lostRecipeConnection: boolean = false; + + @observable lostRecipeReloadAttempt: number = 0; + + @observable userAgentModel: UserAgent; + + @observable proxy: string | null = null; + + constructor(data, recipe: Recipe) { + if (!data) { + throw new Error('Service config not valid'); + } + + if (!recipe) { + throw new Error('Service recipe not valid'); + } + + this.recipe = recipe; + + this.userAgentModel = new UserAgent(recipe.overrideUserAgent); + + this.id = ifUndefined(data.id, this.id); + this.name = ifUndefined(data.name, this.name); + this.team = ifUndefined(data.team, this.team); + this.customUrl = ifUndefined(data.customUrl, this.customUrl); + this.iconUrl = ifUndefined(data.iconUrl, this.iconUrl); + this.order = ifUndefined(data.order, this.order); + this.isEnabled = ifUndefined(data.isEnabled, this.isEnabled); + this.isNotificationEnabled = ifUndefined( + data.isNotificationEnabled, + this.isNotificationEnabled, + ); + this.isBadgeEnabled = ifUndefined( + data.isBadgeEnabled, + this.isBadgeEnabled, + ); + this.trapLinkClicks = ifUndefined( + data.trapLinkClicks, + this.trapLinkClicks, + ); + this.isIndirectMessageBadgeEnabled = ifUndefined( + data.isIndirectMessageBadgeEnabled, + this.isIndirectMessageBadgeEnabled, + ); + this.isMuted = ifUndefined(data.isMuted, this.isMuted); + this.isDarkModeEnabled = ifUndefined( + data.isDarkModeEnabled, + this.isDarkModeEnabled, + ); + this.darkReaderSettings = ifUndefined( + data.darkReaderSettings, + this.darkReaderSettings, + ); + this.isProgressbarEnabled = ifUndefined( + data.isProgressbarEnabled, + this.isProgressbarEnabled, + ); + this.hasCustomUploadedIcon = ifUndefined( + data.iconId?.length > 0, + this.hasCustomUploadedIcon, + ); + this.onlyShowFavoritesInUnreadCount = ifUndefined( + data.onlyShowFavoritesInUnreadCount, + this.onlyShowFavoritesInUnreadCount, + ); + this.proxy = ifUndefined(data.proxy, this.proxy); + this.spellcheckerLanguage = ifUndefined( + data.spellcheckerLanguage, + this.spellcheckerLanguage, + ); + this.userAgentPref = ifUndefined( + data.userAgentPref, + this.userAgentPref, + ); + this.isHibernationEnabled = ifUndefined( + data.isHibernationEnabled, + this.isHibernationEnabled, + ); + this.isWakeUpEnabled = ifUndefined( + data.isWakeUpEnabled, + this.isWakeUpEnabled, + ); + + // Check if "Hibernate on Startup" is enabled and hibernate all services except active one + const { hibernateOnStartup } = window['ferdium'].stores.settings.app; + // The service store is probably not loaded yet so we need to use localStorage data to get active service + const isActive = + window.localStorage.service && + JSON.parse(window.localStorage.service).activeService === this.id; + if (hibernateOnStartup && !isActive) { + this.isHibernationRequested = true; + } + + autorun((): void => { + if (!this.isEnabled) { + this.webview = null; + this.isAttached = false; + this.unreadDirectMessageCount = 0; + this.unreadIndirectMessageCount = 0; + } + + if (this.recipe.hasCustomUrl && this.customUrl) { + this.isUsingCustomUrl = true; + } + }); + } + + @computed get shareWithWebview(): object { + return { + id: this.id, + spellcheckerLanguage: this.spellcheckerLanguage, + isDarkModeEnabled: this.isDarkModeEnabled, + isProgressbarEnabled: this.isProgressbarEnabled, + darkReaderSettings: this.darkReaderSettings, + team: this.team, + url: this.url, + hasCustomIcon: this.hasCustomIcon, + onlyShowFavoritesInUnreadCount: this.onlyShowFavoritesInUnreadCount, + trapLinkClicks: this.trapLinkClicks, + }; + } + + @computed get isTodosService(): boolean { + return this.recipe.id === todosStore.todoRecipeId; + } + + @computed get canHibernate(): boolean { + return this.isHibernationEnabled; + } + + @computed get isHibernating(): boolean { + return this.canHibernate && this.isHibernationRequested; + } + + get webview(): ElectronWebView | null { + if (this.isTodosService) { + return todosStore.webview; + } + + return this._webview; + } + + set webview(webview) { + this._webview = webview; + } + + @computed get url(): string { + if (this.recipe.hasCustomUrl && this.customUrl) { + let url: string = ''; + try { + url = normalizeUrl(this.customUrl, { + stripAuthentication: false, + stripWWW: false, + removeTrailingSlash: false, + }); + } catch { + console.error( + `Service (${this.recipe.name}): '${this.customUrl}' is not a valid Url.`, + ); + } + + if (typeof this.recipe.buildUrl === 'function') { + url = this.recipe.buildUrl(url); + } + + return url; + } + + if (this.recipe.hasTeamId && this.team) { + return this.recipe.serviceURL.replace('{teamId}', this.team); + } + + return this.recipe.serviceURL; + } + + @computed get icon(): string { + if (this.iconUrl) { + return this.iconUrl; + } + + return join(this.recipe.path, 'icon.svg'); + } + + @computed get hasCustomIcon(): boolean { + return Boolean(this.iconUrl); + } + + @computed get userAgent(): string { + return this.userAgentModel.userAgent; + } + + @computed get userAgentPref(): string | null { + return this.userAgentModel.userAgentPref; + } + + set userAgentPref(pref) { + this.userAgentModel.userAgentPref = pref; + } + + @computed get defaultUserAgent(): String { + return this.userAgentModel.defaultUserAgent; + } + + @computed get partition(): string { + return this.recipe.partition || `persist:service-${this.id}`; + } + + initializeWebViewEvents({ handleIPCMessage, openWindow, stores }): void { + const webviewWebContents = webContents.fromId( + this.webview.getWebContentsId(), + ); + + this.userAgentModel.setWebviewReference(this.webview); + + // If the recipe has implemented 'modifyRequestHeaders', + // Send those headers to ipcMain so that it can be set in session + if (typeof this.recipe.modifyRequestHeaders === 'function') { + const modifiedRequestHeaders = this.recipe.modifyRequestHeaders(); + debug(this.name, 'modifiedRequestHeaders', modifiedRequestHeaders); + ipcRenderer.send('modifyRequestHeaders', { + modifiedRequestHeaders, + serviceId: this.id, + }); + } else { + debug(this.name, 'modifyRequestHeaders is not defined in the recipe'); + } + + // if the recipe has implemented 'knownCertificateHosts' + if (typeof this.recipe.knownCertificateHosts === 'function') { + const knownHosts = this.recipe.knownCertificateHosts(); + debug(this.name, 'knownCertificateHosts', knownHosts); + ipcRenderer.send('knownCertificateHosts', { + knownHosts, + serviceId: this.id, + }); + } else { + debug(this.name, 'knownCertificateHosts is not defined in the recipe'); + } + + this.webview.addEventListener('ipc-message', async e => { + if (e.channel === 'inject-js-unsafe') { + await Promise.all( + e.args.map(script => + this.webview.executeJavaScript( + `"use strict"; (() => { ${script} })();`, + ), + ), + ); + } else { + handleIPCMessage({ + serviceId: this.id, + channel: e.channel, + args: e.args, + }); + } + }); + + this.webview.addEventListener( + 'new-window', + (event, url, frameName, options) => { + debug('new-window', event, url, frameName, options); + if (!isValidExternalURL(event.url)) { + return; + } + if ( + event.disposition === 'foreground-tab' || + event.disposition === 'background-tab' + ) { + openWindow({ + event, + url, + frameName, + options, + }); + } else { + ipcRenderer.send('open-browser-window', { + url: event.url, + serviceId: this.id, + }); + } + }, + ); + + this.webview.addEventListener('did-start-loading', event => { + debug('Did start load', this.name, event); + + this.hasCrashed = false; + this.isLoading = true; + this.isLoadingPage = true; + this.isError = false; + }); + + this.webview.addEventListener('did-stop-loading', event => { + debug('Did stop load', this.name, event); + + this.isLoading = false; + this.isLoadingPage = false; + }); + + // eslint-disable-next-line unicorn/consistent-function-scoping + const didLoad = () => { + this.isLoading = false; + this.isLoadingPage = false; + + if (!this.isError) { + this.isFirstLoad = false; + } + }; + + this.webview.addEventListener('did-frame-finish-load', didLoad.bind(this)); + this.webview.addEventListener('did-navigate', didLoad.bind(this)); + + this.webview.addEventListener('did-fail-load', event => { + debug('Service failed to load', this.name, event); + if ( + event.isMainFrame && + event.errorCode !== -21 && + event.errorCode !== -3 + ) { + this.isError = true; + this.errorMessage = event.errorDescription; + this.isLoading = false; + this.isLoadingPage = false; + } + }); + + this.webview.addEventListener('crashed', () => { + debug('Service crashed', this.name); + this.hasCrashed = true; + }); + + this.webview.addEventListener('found-in-page', ({ result }) => { + debug('Found in page', result); + this.webview.send('found-in-page', result); + }); + + webviewWebContents.on('login', (event, _, authInfo, callback) => { + // const authCallback = callback; + debug('browser login event', authInfo); + event.preventDefault(); + + if (authInfo.isProxy && authInfo.scheme === 'basic') { + debug('Sending service echo ping'); + webviewWebContents.send('get-service-id'); + + debug('Received service id', this.id); + + const ps = stores.settings.proxy[this.id]; + + if (ps) { + debug('Sending proxy auth callback for service', this.id); + callback(ps.user, ps.password); + } else { + debug('No proxy auth config found for', this.id); + } + } + }); + } + + initializeWebViewListener(): void { + if (this.webview && this.recipe.events) { + for (const eventName of Object.keys(this.recipe.events)) { + const eventHandler = this.recipe[this.recipe.events[eventName]]; + if (typeof eventHandler === 'function') { + this.webview.addEventListener(eventName, eventHandler); + } + } + } + } + + resetMessageCount(): void { + this.unreadDirectMessageCount = 0; + this.unreadIndirectMessageCount = 0; + } +} diff --git a/src/models/UserAgent.js b/src/models/UserAgent.js deleted file mode 100644 index 3e1394b45..000000000 --- a/src/models/UserAgent.js +++ /dev/null @@ -1,111 +0,0 @@ -import { action, computed, observe, observable } from 'mobx'; - -import defaultUserAgent from '../helpers/userAgent-helpers'; - -const debug = require('../preload-safe-debug')('Ferdium:UserAgent'); - -export default class UserAgent { - _willNavigateListener = null; - - _didNavigateListener = null; - - @observable.ref webview = null; - - @observable chromelessUserAgent = false; - - @observable userAgentPref = null; - - @observable getUserAgent = null; - - constructor(overrideUserAgent = null) { - if (typeof overrideUserAgent === 'function') { - this.getUserAgent = overrideUserAgent; - } - - observe(this, 'webview', change => { - const { oldValue, newValue } = change; - if (oldValue !== null) { - this._removeWebviewEvents(oldValue); - } - if (newValue !== null) { - this._addWebviewEvents(newValue); - } - }); - } - - @computed get defaultUserAgent() { - if (typeof this.getUserAgent === 'function') { - return this.getUserAgent(); - } - const globalPref = window['ferdium'].stores.settings.all.app.userAgentPref; - if (typeof globalPref === 'string') { - const trimmed = globalPref.trim(); - if (trimmed !== '') { - return trimmed; - } - } - return defaultUserAgent(); - } - - @computed get serviceUserAgentPref() { - if (typeof this.userAgentPref === 'string') { - const trimmed = this.userAgentPref.trim(); - if (trimmed !== '') { - return trimmed; - } - } - return null; - } - - @computed get userAgentWithoutChromeVersion() { - const withChrome = this.defaultUserAgent; - return withChrome.replace(/Chrome\/[\d.]+/, 'Chrome'); - } - - @computed get userAgent() { - return ( - this.serviceUserAgentPref || - (this.chromelessUserAgent - ? this.userAgentWithoutChromeVersion - : this.defaultUserAgent) - ); - } - - @action setWebviewReference(webview) { - this.webview = webview; - } - - @action _handleNavigate(url, forwardingHack = false) { - if (url.startsWith('https://accounts.google.com')) { - if (!this.chromelessUserAgent) { - debug('Setting user agent to chromeless for url', url); - this.chromelessUserAgent = true; - this.webview.userAgent = this.userAgent; - if (forwardingHack) { - this.webview.loadURL(url); - } - } - } else if (this.chromelessUserAgent) { - debug('Setting user agent to contain chrome for url', url); - this.chromelessUserAgent = false; - this.webview.userAgent = this.userAgent; - } - } - - _addWebviewEvents(webview) { - debug('Adding event handlers'); - - this._willNavigateListener = event => this._handleNavigate(event.url, true); - webview.addEventListener('will-navigate', this._willNavigateListener); - - this._didNavigateListener = event => this._handleNavigate(event.url); - webview.addEventListener('did-navigate', this._didNavigateListener); - } - - _removeWebviewEvents(webview) { - debug('Removing event handlers'); - - webview.removeEventListener('will-navigate', this._willNavigateListener); - webview.removeEventListener('did-navigate', this._didNavigateListener); - } -} diff --git a/src/models/UserAgent.ts b/src/models/UserAgent.ts new file mode 100644 index 000000000..1d06d72b0 --- /dev/null +++ b/src/models/UserAgent.ts @@ -0,0 +1,116 @@ +import { action, computed, observe, observable } from 'mobx'; + +import ElectronWebView from 'react-electron-web-view'; +import defaultUserAgent from '../helpers/userAgent-helpers'; + +const debug = require('../preload-safe-debug')('Ferdium:UserAgent'); + +export default class UserAgent { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _willNavigateListener = (_event: any): void => {}; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _didNavigateListener = (_event: any): void => {}; + + @observable.ref webview: ElectronWebView = null; + + @observable chromelessUserAgent: boolean = false; + + @observable userAgentPref: string | null = null; + + @observable overrideUserAgent = (): string => ''; + + constructor(overrideUserAgent: any = null) { + if (typeof overrideUserAgent === 'function') { + this.overrideUserAgent = overrideUserAgent; + } + + observe(this, 'webview', change => { + const { oldValue, newValue } = change; + if (oldValue !== null) { + this._removeWebviewEvents(oldValue); + } + if (newValue !== null) { + this._addWebviewEvents(newValue); + } + }); + } + + @computed get defaultUserAgent(): string { + const replacedUserAgent = this.overrideUserAgent(); + if (replacedUserAgent.length > 0) { + return replacedUserAgent; + } + + const globalPref = window['ferdium'].stores.settings.all.app.userAgentPref; + if (typeof globalPref === 'string') { + const trimmed = globalPref.trim(); + if (trimmed !== '') { + return trimmed; + } + } + return defaultUserAgent(); + } + + @computed get serviceUserAgentPref(): string | null { + if (typeof this.userAgentPref === 'string') { + const trimmed = this.userAgentPref.trim(); + if (trimmed !== '') { + return trimmed; + } + } + return null; + } + + @computed get userAgentWithoutChromeVersion(): string { + const withChrome = this.defaultUserAgent; + return withChrome.replace(/Chrome\/[\d.]+/, 'Chrome'); + } + + @computed get userAgent(): string { + return ( + this.serviceUserAgentPref || + (this.chromelessUserAgent + ? this.userAgentWithoutChromeVersion + : this.defaultUserAgent) + ); + } + + @action setWebviewReference(webview: ElectronWebView): void { + this.webview = webview; + } + + @action _handleNavigate(url: string, forwardingHack: boolean = false): void { + if (url.startsWith('https://accounts.google.com')) { + if (!this.chromelessUserAgent) { + debug('Setting user agent to chromeless for url', url); + this.chromelessUserAgent = true; + this.webview.userAgent = this.userAgent; + if (forwardingHack) { + this.webview.loadURL(url); + } + } + } else if (this.chromelessUserAgent) { + debug('Setting user agent to contain chrome for url', url); + this.chromelessUserAgent = false; + this.webview.userAgent = this.userAgent; + } + } + + _addWebviewEvents(webview: ElectronWebView): void { + debug('Adding event handlers'); + + this._willNavigateListener = event => this._handleNavigate(event.url, true); + webview.addEventListener('will-navigate', this._willNavigateListener); + + this._didNavigateListener = event => this._handleNavigate(event.url); + webview.addEventListener('did-navigate', this._didNavigateListener); + } + + _removeWebviewEvents(webview: ElectronWebView): void { + debug('Removing event handlers'); + + webview.removeEventListener('will-navigate', this._willNavigateListener); + webview.removeEventListener('did-navigate', this._didNavigateListener); + } +} diff --git a/src/stores.types.ts b/src/stores.types.ts index 462d862d9..24eefc416 100644 --- a/src/stores.types.ts +++ b/src/stores.types.ts @@ -76,25 +76,34 @@ interface TypedStore { interface AppStore extends TypedStore { accentColor: string; + adaptableDarkMode: boolean; progressbarAccentColor: string; authRequestFailed: () => void; autoLaunchOnStart: () => void; automaticUpdates: boolean; clearAppCacheRequest: () => void; + clipboardNotifications: boolean; + darkMode: boolean; dictionaries: []; + enableSpellchecking: boolean; fetchDataInterval: 4; get(key: string): any; getAppCacheSizeRequest: () => void; healthCheckRequest: () => void; isClearingAllCache: () => void; + isAppMuted: boolean; isFocused: () => void; isFullScreen: () => void; - isOnline: () => void; + isOnline: boolean; isSystemDarkModeEnabled: () => void; isSystemMuteOverridden: () => void; locale: () => void; reloadAfterResume: boolean; reloadAfterResumeTime: number; + searchEngine: string; + spellcheckerLanguage: string; + splitMode: boolean; + splitColumns: number; timeOfflineStart: () => void; timeSuspensionStart: () => void; updateStatus: () => void; @@ -105,6 +114,7 @@ interface AppStore extends TypedStore { DOWNLOADED: 'DOWNLOADED'; FAILED: 'FAILED'; }; + universalDarkMode: boolean; cacheSize: () => void; debugInfo: () => void; } @@ -145,7 +155,9 @@ interface RecipeStore extends TypedStore { isInstalled: (id: string) => boolean; active: () => void; all: Recipe[]; + one: (id: string) => Recipe; recipeIdForServices: () => void; + _install({ recipeId: string }): Promise; } interface RequestsStore extends TypedStore { @@ -183,7 +195,7 @@ export interface ServicesStore extends TypedStore { createServiceRequest: () => void; deleteServiceRequest: () => void; allServicesRequest: CachedRequest; - filterNeedle: () => void; + filterNeedle: string; lastUsedServices: () => void; reorderServicesRequest: () => void; serviceMaintenanceTick: () => void; @@ -237,7 +249,9 @@ interface TodosStore extends TypedStore { isTodosPanelForceHidden: () => void; isTodosPanelVisible: () => void; isUsingPredefinedTodoServer: () => void; - settings: () => void; + settings: { + isFeatureEnabledByUser: boolean; + }; todoRecipeId: () => void; todoUrl: () => void; userAgent: () => void; @@ -256,7 +270,7 @@ interface UIStore extends TypedStore { isDarkThemeActive: () => void; isSplitModeActive: () => void; splitColumnsNo: () => void; - showMessageBadgesEvenWhenMuted: () => void; + showMessageBadgesEvenWhenMuted: boolean; theme: () => void; } @@ -315,12 +329,21 @@ export interface WorkspacesStore extends TypedStore { saving: boolean; filterServicesByActiveWorkspace: () => void; isFeatureActive: () => void; + isAnyWorkspaceActive: boolean; isSettingsRouteActive: () => void; isSwitchingWorkspace: () => void; isWorkspaceDrawerOpen: () => void; nextWorkspace: () => void; workspaces: Workspace[]; workspaceBeingEdited: () => void; + reorderServicesOfActiveWorkspace: ({ + oldIndex, + newIndex, + }: { + oldIndex: string; + newIndex: string; + }) => void; + settings: any; _activateLastUsedWorkspaceReaction: () => void; _allActions: any[]; _allReactions: any[]; diff --git a/src/stores/ServicesStore.js b/src/stores/ServicesStore.js deleted file mode 100644 index 999b48d92..000000000 --- a/src/stores/ServicesStore.js +++ /dev/null @@ -1,1319 +0,0 @@ -import { shell } from 'electron'; -import { action, reaction, computed, observable } from 'mobx'; -import { debounce, remove } from 'lodash'; -import ms from 'ms'; -import { ensureFileSync, pathExistsSync, writeFileSync } from 'fs-extra'; -import { join } from 'path'; - -import Store from './lib/Store'; -import Request from './lib/Request'; -import CachedRequest from './lib/CachedRequest'; -import { matchRoute } from '../helpers/routing-helpers'; -import { isInTimeframe } from '../helpers/schedule-helpers'; -import { - getRecipeDirectory, - getDevRecipeDirectory, -} from '../helpers/recipe-helpers'; -import { workspaceStore } from '../features/workspaces'; -import { DEFAULT_SERVICE_SETTINGS, KEEP_WS_LOADED_USID } from '../config'; -import { cleanseJSObject } from '../jsUtils'; -import { SPELLCHECKER_LOCALES } from '../i18n/languages'; -import { ferdiumVersion } from '../environment-remote'; - -const debug = require('../preload-safe-debug')('Ferdium:ServiceStore'); - -export default class ServicesStore extends Store { - @observable allServicesRequest = new CachedRequest(this.api.services, 'all'); - - @observable createServiceRequest = new Request(this.api.services, 'create'); - - @observable updateServiceRequest = new Request(this.api.services, 'update'); - - @observable reorderServicesRequest = new Request( - this.api.services, - 'reorder', - ); - - @observable deleteServiceRequest = new Request(this.api.services, 'delete'); - - @observable clearCacheRequest = new Request(this.api.services, 'clearCache'); - - @observable filterNeedle = null; - - // Array of service IDs that have recently been used - // [0] => Most recent, [n] => Least recent - // No service ID should be in the list multiple times, not all service IDs have to be in the list - @observable lastUsedServices = []; - - constructor(...args) { - super(...args); - - // Register action handlers - this.actions.service.setActive.listen(this._setActive.bind(this)); - this.actions.service.blurActive.listen(this._blurActive.bind(this)); - this.actions.service.setActiveNext.listen(this._setActiveNext.bind(this)); - this.actions.service.setActivePrev.listen(this._setActivePrev.bind(this)); - this.actions.service.showAddServiceInterface.listen( - this._showAddServiceInterface.bind(this), - ); - this.actions.service.createService.listen(this._createService.bind(this)); - this.actions.service.createFromLegacyService.listen( - this._createFromLegacyService.bind(this), - ); - this.actions.service.updateService.listen(this._updateService.bind(this)); - this.actions.service.deleteService.listen(this._deleteService.bind(this)); - this.actions.service.openRecipeFile.listen(this._openRecipeFile.bind(this)); - this.actions.service.clearCache.listen(this._clearCache.bind(this)); - this.actions.service.setWebviewReference.listen( - this._setWebviewReference.bind(this), - ); - this.actions.service.detachService.listen(this._detachService.bind(this)); - this.actions.service.focusService.listen(this._focusService.bind(this)); - this.actions.service.focusActiveService.listen( - this._focusActiveService.bind(this), - ); - this.actions.service.toggleService.listen(this._toggleService.bind(this)); - this.actions.service.handleIPCMessage.listen( - this._handleIPCMessage.bind(this), - ); - this.actions.service.sendIPCMessage.listen(this._sendIPCMessage.bind(this)); - this.actions.service.sendIPCMessageToAllServices.listen( - this._sendIPCMessageToAllServices.bind(this), - ); - this.actions.service.setUnreadMessageCount.listen( - this._setUnreadMessageCount.bind(this), - ); - this.actions.service.setDialogTitle.listen(this._setDialogTitle.bind(this)); - this.actions.service.openWindow.listen(this._openWindow.bind(this)); - this.actions.service.filter.listen(this._filter.bind(this)); - this.actions.service.resetFilter.listen(this._resetFilter.bind(this)); - this.actions.service.resetStatus.listen(this._resetStatus.bind(this)); - this.actions.service.reload.listen(this._reload.bind(this)); - this.actions.service.reloadActive.listen(this._reloadActive.bind(this)); - this.actions.service.reloadAll.listen(this._reloadAll.bind(this)); - this.actions.service.reloadUpdatedServices.listen( - this._reloadUpdatedServices.bind(this), - ); - this.actions.service.reorder.listen(this._reorder.bind(this)); - this.actions.service.toggleNotifications.listen( - this._toggleNotifications.bind(this), - ); - this.actions.service.toggleAudio.listen(this._toggleAudio.bind(this)); - this.actions.service.toggleDarkMode.listen(this._toggleDarkMode.bind(this)); - this.actions.service.openDevTools.listen(this._openDevTools.bind(this)); - this.actions.service.openDevToolsForActiveService.listen( - this._openDevToolsForActiveService.bind(this), - ); - this.actions.service.hibernate.listen(this._hibernate.bind(this)); - this.actions.service.awake.listen(this._awake.bind(this)); - this.actions.service.resetLastPollTimer.listen( - this._resetLastPollTimer.bind(this), - ); - this.actions.service.shareSettingsWithServiceProcess.listen( - this._shareSettingsWithServiceProcess.bind(this), - ); - - this.registerReactions([ - this._focusServiceReaction.bind(this), - this._getUnreadMessageCountReaction.bind(this), - this._mapActiveServiceToServiceModelReaction.bind(this), - this._saveActiveService.bind(this), - this._logoutReaction.bind(this), - this._handleMuteSettings.bind(this), - this._checkForActiveService.bind(this), - ]); - - // Just bind this - this._initializeServiceRecipeInWebview.bind(this); - } - - setup() { - // Single key reactions for the sake of your CPU - reaction( - () => this.stores.settings.app.enableSpellchecking, - () => { - this._shareSettingsWithServiceProcess(); - }, - ); - - reaction( - () => this.stores.settings.app.spellcheckerLanguage, - () => { - this._shareSettingsWithServiceProcess(); - }, - ); - - reaction( - () => this.stores.settings.app.darkMode, - () => { - this._shareSettingsWithServiceProcess(); - }, - ); - - reaction( - () => this.stores.settings.app.adaptableDarkMode, - () => { - this._shareSettingsWithServiceProcess(); - }, - ); - - reaction( - () => this.stores.settings.app.universalDarkMode, - () => { - this._shareSettingsWithServiceProcess(); - }, - ); - - reaction( - () => this.stores.settings.app.splitMode, - () => { - this._shareSettingsWithServiceProcess(); - }, - ); - - reaction( - () => this.stores.settings.app.splitColumns, - () => { - this._shareSettingsWithServiceProcess(); - }, - ); - - reaction( - () => this.stores.settings.app.searchEngine, - () => { - this._shareSettingsWithServiceProcess(); - }, - ); - - reaction( - () => this.stores.settings.app.clipboardNotifications, - () => { - this._shareSettingsWithServiceProcess(); - }, - ); - } - - initialize() { - super.initialize(); - - // Check services to become hibernated - this.serviceMaintenanceTick(); - } - - teardown() { - super.teardown(); - - // Stop checking services for hibernation - this.serviceMaintenanceTick.cancel(); - } - - /** - * Сheck for services to become hibernated. - */ - serviceMaintenanceTick = debounce(() => { - this._serviceMaintenance(); - this.serviceMaintenanceTick(); - debug('Service maintenance tick'); - }, ms('10s')); - - /** - * Run various maintenance tasks on services - */ - _serviceMaintenance() { - for (const service of this.enabled) { - // Defines which services should be hibernated or woken up - if (!service.isActive) { - if ( - !service.lastHibernated && - Date.now() - service.lastUsed > - ms(`${this.stores.settings.all.app.hibernationStrategy}s`) - ) { - // If service is stale, hibernate it. - this._hibernate({ serviceId: service.id }); - } - - if ( - service.isWakeUpEnabled && - service.lastHibernated && - Number(this.stores.settings.all.app.wakeUpStrategy) > 0 && - Date.now() - service.lastHibernated > - ms(`${this.stores.settings.all.app.wakeUpStrategy}s`) - ) { - // If service is in hibernation and the wakeup time has elapsed, wake it. - this._awake({ serviceId: service.id, automatic: true }); - } - } - - if ( - service.lastPoll && - service.lastPoll - service.lastPollAnswer > ms('1m') - ) { - // If service did not reply for more than 1m try to reload. - if (!service.isActive) { - if (this.stores.app.isOnline && service.lostRecipeReloadAttempt < 3) { - debug( - `Reloading service: ${service.name} (${service.id}). Attempt: ${service.lostRecipeReloadAttempt}`, - ); - // service.webview.reload(); - service.lostRecipeReloadAttempt += 1; - - service.lostRecipeConnection = false; - } - } else { - debug(`Service lost connection: ${service.name} (${service.id}).`); - service.lostRecipeConnection = true; - } - } else { - service.lostRecipeConnection = false; - service.lostRecipeReloadAttempt = 0; - } - } - } - - // Computed props - @computed get all() { - if (this.stores.user.isLoggedIn) { - const services = this.allServicesRequest.execute().result; - if (services) { - return observable( - [...services] - .slice() - .sort((a, b) => a.order - b.order) - .map((s, index) => { - s.index = index; - return s; - }), - ); - } - } - return []; - } - - @computed get enabled() { - return this.all.filter(service => service.isEnabled); - } - - @computed get allDisplayed() { - const services = this.stores.settings.all.app.showDisabledServices - ? this.all - : this.enabled; - return workspaceStore.filterServicesByActiveWorkspace(services); - } - - // This is just used to avoid unnecessary rerendering of resource-heavy webviews - @computed get allDisplayedUnordered() { - const { showDisabledServices } = this.stores.settings.all.app; - const { keepAllWorkspacesLoaded } = this.stores.workspaces.settings; - const services = this.allServicesRequest.execute().result || []; - const filteredServices = showDisabledServices - ? services - : services.filter(service => service.isEnabled); - - let displayedServices; - if (keepAllWorkspacesLoaded) { - // Keep all enabled services loaded - displayedServices = filteredServices; - } else { - // Keep all services in current workspace loaded - displayedServices = - workspaceStore.filterServicesByActiveWorkspace(filteredServices); - - // Keep all services active in workspaces that should be kept loaded - for (const workspace of this.stores.workspaces.workspaces) { - // Check if workspace needs to be kept loaded - if (workspace.services.includes(KEEP_WS_LOADED_USID)) { - // Get services for workspace - const serviceIDs = new Set( - workspace.services.filter(i => i !== KEEP_WS_LOADED_USID), - ); - const wsServices = filteredServices.filter(service => - serviceIDs.has(service.id), - ); - - displayedServices = [...displayedServices, ...wsServices]; - } - } - - // Make sure every service is in the list only once - displayedServices = displayedServices.filter( - (v, i, a) => a.indexOf(v) === i, - ); - } - - return displayedServices; - } - - @computed get filtered() { - return this.all.filter(service => - service.name.toLowerCase().includes(this.filterNeedle.toLowerCase()), - ); - } - - @computed get active() { - return this.all.find(service => service.isActive); - } - - @computed get activeSettings() { - const match = matchRoute( - '/settings/services/edit/:id', - this.stores.router.location.pathname, - ); - if (match) { - const activeService = this.one(match.id); - if (activeService) { - return activeService; - } - - debug('Service not available'); - } - - return null; - } - - @computed get isTodosServiceAdded() { - return ( - this.allDisplayed.find( - service => service.isTodosService && service.isEnabled, - ) || false - ); - } - - @computed get isTodosServiceActive() { - return this.active && this.active.isTodosService; - } - - one(id) { - return this.all.find(service => service.id === id); - } - - async _showAddServiceInterface({ recipeId }) { - this.stores.router.push(`/settings/services/add/${recipeId}`); - } - - // Actions - async _createService({ - recipeId, - serviceData, - redirect = true, - skipCleanup = false, - }) { - if (!this.stores.recipes.isInstalled(recipeId)) { - debug(`Recipe "${recipeId}" is not installed, installing recipe`); - await this.stores.recipes._install({ recipeId }); - debug(`Recipe "${recipeId}" installed`); - } - - // set default values for serviceData - serviceData = { - isEnabled: DEFAULT_SERVICE_SETTINGS.isEnabled, - isHibernationEnabled: DEFAULT_SERVICE_SETTINGS.isHibernationEnabled, - isWakeUpEnabled: DEFAULT_SERVICE_SETTINGS.isWakeUpEnabled, - isNotificationEnabled: DEFAULT_SERVICE_SETTINGS.isNotificationEnabled, - isBadgeEnabled: DEFAULT_SERVICE_SETTINGS.isBadgeEnabled, - trapLinkClicks: DEFAULT_SERVICE_SETTINGS.trapLinkClicks, - isMuted: DEFAULT_SERVICE_SETTINGS.isMuted, - customIcon: DEFAULT_SERVICE_SETTINGS.customIcon, - isDarkModeEnabled: DEFAULT_SERVICE_SETTINGS.isDarkModeEnabled, - isProgressbarEnabled: DEFAULT_SERVICE_SETTINGS.isProgressbarEnabled, - spellcheckerLanguage: - SPELLCHECKER_LOCALES[this.stores.settings.app.spellcheckerLanguage], - userAgentPref: '', - ...serviceData, - }; - - const data = skipCleanup - ? serviceData - : this._cleanUpTeamIdAndCustomUrl(recipeId, serviceData); - - const response = await this.createServiceRequest.execute(recipeId, data) - ._promise; - - this.allServicesRequest.patch(result => { - if (!result) return; - result.push(response.data); - }); - - this.actions.settings.update({ - type: 'proxy', - data: { - [`${response.data.id}`]: data.proxy, - }, - }); - - this.actionStatus = response.status || []; - - if (redirect) { - this.stores.router.push('/settings/recipes'); - } - } - - @action async _createFromLegacyService({ data }) { - const { id } = data.recipe; - const serviceData = {}; - - if (data.name) { - serviceData.name = data.name; - } - - if (data.team) { - if (!data.customURL) { - serviceData.team = data.team; - } else { - // TODO: Is this correct? - serviceData.customUrl = data.team; - } - } - - this.actions.service.createService({ - recipeId: id, - serviceData, - redirect: false, - }); - } - - @action async _updateService({ serviceId, serviceData, redirect = true }) { - const service = this.one(serviceId); - const data = this._cleanUpTeamIdAndCustomUrl( - service.recipe.id, - serviceData, - ); - const request = this.updateServiceRequest.execute(serviceId, data); - - const newData = serviceData; - if (serviceData.iconFile) { - await request._promise; - - newData.iconUrl = request.result.data.iconUrl; - newData.hasCustomUploadedIcon = true; - } - - this.allServicesRequest.patch(result => { - if (!result) return; - - // patch custom icon deletion - if (data.customIcon === 'delete') { - newData.iconUrl = ''; - newData.hasCustomUploadedIcon = false; - } - - // patch custom icon url - if (data.customIconUrl) { - newData.iconUrl = data.customIconUrl; - } - - Object.assign( - result.find(c => c.id === serviceId), - newData, - ); - }); - - await request._promise; - this.actionStatus = request.result.status; - - if (service.isEnabled) { - this._sendIPCMessage({ - serviceId, - channel: 'service-settings-update', - args: newData, - }); - } - - this.actions.settings.update({ - type: 'proxy', - data: { - [`${serviceId}`]: data.proxy, - }, - }); - - if (redirect) { - this.stores.router.push('/settings/services'); - } - } - - @action async _deleteService({ serviceId, redirect }) { - const request = this.deleteServiceRequest.execute(serviceId); - - if (redirect) { - this.stores.router.push(redirect); - } - - this.allServicesRequest.patch(result => { - remove(result, c => c.id === serviceId); - }); - - await request._promise; - this.actionStatus = request.result.status; - } - - @action async _openRecipeFile({ recipe, file }) { - // Get directory for recipe - const normalDirectory = getRecipeDirectory(recipe); - const devDirectory = getDevRecipeDirectory(recipe); - let directory; - - if (pathExistsSync(normalDirectory)) { - directory = normalDirectory; - } else if (pathExistsSync(devDirectory)) { - directory = devDirectory; - } else { - // Recipe cannot be found on drive - return; - } - - // Create and open file - const filePath = join(directory, file); - if (file === 'user.js') { - if (!pathExistsSync(filePath)) { - writeFileSync( - filePath, - `module.exports = (config, Ferdium) => { - // Write your scripts here - console.log("Hello, World!", config); -}; -`, - ); - } - } else { - ensureFileSync(filePath); - } - shell.showItemInFolder(filePath); - } - - @action async _clearCache({ serviceId }) { - this.clearCacheRequest.reset(); - const request = this.clearCacheRequest.execute(serviceId); - await request._promise; - } - - @action _setActive({ serviceId, keepActiveRoute = null }) { - if (!keepActiveRoute) this.stores.router.push('/'); - const service = this.one(serviceId); - - for (const s of this.all) { - if (s.isActive) { - s.lastUsed = Date.now(); - s.isActive = false; - } - } - service.isActive = true; - this._awake({ serviceId: service.id }); - - if ( - this.isTodosServiceActive && - !this.stores.todos.settings.isFeatureEnabledByUser - ) { - this.actions.todos.toggleTodosFeatureVisibility(); - } - - // Update list of last used services - this.lastUsedServices = this.lastUsedServices.filter( - id => id !== serviceId, - ); - this.lastUsedServices.unshift(serviceId); - - this._focusActiveService(); - } - - @action _blurActive() { - const service = this.active; - if (service) { - service.isActive = false; - } else { - debug('No service is active'); - } - } - - @action _setActiveNext() { - const nextIndex = this._wrapIndex( - this.allDisplayed.findIndex(service => service.isActive), - 1, - this.allDisplayed.length, - ); - - this._setActive({ serviceId: this.allDisplayed[nextIndex].id }); - } - - @action _setActivePrev() { - const prevIndex = this._wrapIndex( - this.allDisplayed.findIndex(service => service.isActive), - -1, - this.allDisplayed.length, - ); - - this._setActive({ serviceId: this.allDisplayed[prevIndex].id }); - } - - @action _setUnreadMessageCount({ serviceId, count }) { - const service = this.one(serviceId); - - service.unreadDirectMessageCount = count.direct; - service.unreadIndirectMessageCount = count.indirect; - } - - @action _setDialogTitle({ serviceId, dialogTitle }) { - const service = this.one(serviceId); - - service.dialogTitle = dialogTitle; - } - - @action _setWebviewReference({ serviceId, webview }) { - const service = this.one(serviceId); - if (service) { - service.webview = webview; - - if (!service.isAttached) { - debug('Webview is not attached, initializing'); - service.initializeWebViewEvents({ - handleIPCMessage: this.actions.service.handleIPCMessage, - openWindow: this.actions.service.openWindow, - stores: this.stores, - }); - service.initializeWebViewListener(); - } - service.isAttached = true; - } - } - - @action _detachService({ service }) { - service.webview = null; - service.isAttached = false; - } - - @action _focusService({ serviceId }) { - const service = this.one(serviceId); - - if (service.webview) { - service.webview.blur(); - service.webview.focus(); - } - } - - @action _focusActiveService(focusEvent = null) { - if (this.stores.user.isLoggedIn) { - // TODO: add checks to not focus service when router path is /settings or /auth - const service = this.active; - if (service) { - if (service._webview) { - document.title = `Ferdium - ${service.name} ${ - service.dialogTitle ? ` - ${service.dialogTitle}` : '' - } ${service._webview ? `- ${service._webview.getTitle()}` : ''}`; - this._focusService({ serviceId: service.id }); - if (this.stores.settings.app.splitMode && !focusEvent) { - setTimeout(() => { - document - .querySelector('.services__webview-wrapper.is-active') - .scrollIntoView({ - behavior: 'smooth', - block: 'end', - inline: 'nearest', - }); - }, 10); - } - } - } else { - debug('No service is active'); - } - } else { - this.allServicesRequest.invalidate(); - } - } - - @action _toggleService({ serviceId }) { - const service = this.one(serviceId); - - service.isEnabled = !service.isEnabled; - } - - @action _handleIPCMessage({ serviceId, channel, args }) { - const service = this.one(serviceId); - - // eslint-disable-next-line default-case - switch (channel) { - case 'hello': { - debug('Received hello event from', serviceId); - - this._initRecipePolling(service.id); - this._initializeServiceRecipeInWebview(serviceId); - this._shareSettingsWithServiceProcess(); - - break; - } - case 'alive': { - service.lastPollAnswer = Date.now(); - - break; - } - case 'message-counts': { - debug(`Received unread message info from '${serviceId}'`, args[0]); - - this.actions.service.setUnreadMessageCount({ - serviceId, - count: { - direct: args[0].direct, - indirect: args[0].indirect, - }, - }); - - break; - } - case 'active-dialog-title': { - debug(`Received active dialog title from '${serviceId}'`, args[0]); - - this.actions.service.setDialogTitle({ - serviceId, - dialogTitle: args[0], - }); - - break; - } - case 'notification': { - const { options } = args[0]; - - // Check if we are in scheduled Do-not-Disturb time - const { scheduledDNDEnabled, scheduledDNDStart, scheduledDNDEnd } = - this.stores.settings.all.app; - - if ( - scheduledDNDEnabled && - isInTimeframe(scheduledDNDStart, scheduledDNDEnd) - ) { - return; - } - - if ( - service.recipe.hasNotificationSound || - service.isMuted || - this.stores.settings.all.app.isAppMuted - ) { - Object.assign(options, { - silent: true, - }); - } - - if (service.isNotificationEnabled) { - let title = `Notification from ${service.name}`; - if (!this.stores.settings.all.app.privateNotifications) { - options.body = typeof options.body === 'string' ? options.body : ''; - title = - typeof args[0].title === 'string' ? args[0].title : service.name; - } else { - // Remove message data from notification in private mode - options.body = ''; - options.icon = '/assets/img/notification-badge.gif'; - } - - this.actions.app.notify({ - notificationId: args[0].notificationId, - title, - options, - serviceId, - }); - } - - break; - } - case 'avatar': { - const url = args[0]; - if (service.iconUrl !== url && !service.hasCustomUploadedIcon) { - service.customIconUrl = url; - - this.actions.service.updateService({ - serviceId, - serviceData: { - customIconUrl: url, - }, - redirect: false, - }); - } - - break; - } - case 'new-window': { - const url = args[0]; - - this.actions.app.openExternalUrl({ url }); - - break; - } - case 'set-service-spellchecker-language': { - if (!args) { - console.warn('Did not receive locale'); - } else { - this.actions.service.updateService({ - serviceId, - serviceData: { - spellcheckerLanguage: args[0] === 'reset' ? '' : args[0], - }, - redirect: false, - }); - } - - break; - } - case 'feature:todos': { - Object.assign(args[0].data, { serviceId }); - this.actions.todos.handleHostMessage(args[0]); - - break; - } - // No default - } - } - - @action _sendIPCMessage({ serviceId, channel, args }) { - const service = this.one(serviceId); - - // Make sure the args are clean, otherwise ElectronJS can't transmit them - const cleanArgs = cleanseJSObject(args); - - if (service.webview) { - service.webview.send(channel, cleanArgs); - } - } - - @action _sendIPCMessageToAllServices({ channel, args }) { - for (const s of this.all) { - this.actions.service.sendIPCMessage({ - serviceId: s.id, - channel, - args, - }); - } - } - - @action _openWindow({ event }) { - if (event.url !== 'about:blank') { - event.preventDefault(); - this.actions.app.openExternalUrl({ url: event.url }); - } - } - - @action _filter({ needle }) { - this.filterNeedle = needle; - } - - @action _resetFilter() { - this.filterNeedle = null; - } - - @action _resetStatus() { - this.actionStatus = []; - } - - @action _reload({ serviceId }) { - const service = this.one(serviceId); - if (!service.isEnabled) return; - - service.resetMessageCount(); - service.lostRecipeConnection = false; - - if (service.isTodosService) { - return this.actions.todos.reload(); - } - - if (!service.webview) return; - return service.webview.loadURL(service.url); - } - - @action _reloadActive() { - const service = this.active; - if (service) { - this._reload({ - serviceId: service.id, - }); - } else { - debug('No service is active'); - } - } - - @action _reloadAll() { - for (const s of this.enabled) { - this._reload({ - serviceId: s.id, - }); - } - } - - @action _reloadUpdatedServices() { - this._reloadAll(); - this.actions.ui.toggleServiceUpdatedInfoBar({ visible: false }); - } - - @action _reorder(params) { - const { workspaces } = this.stores; - if (workspaces.isAnyWorkspaceActive) { - workspaces.reorderServicesOfActiveWorkspace(params); - } else { - this._reorderService(params); - } - } - - @action _reorderService({ oldIndex, newIndex }) { - const { showDisabledServices } = this.stores.settings.all.app; - const oldEnabledSortIndex = showDisabledServices - ? oldIndex - : this.all.indexOf(this.enabled[oldIndex]); - const newEnabledSortIndex = showDisabledServices - ? newIndex - : this.all.indexOf(this.enabled[newIndex]); - - this.all.splice( - newEnabledSortIndex, - 0, - this.all.splice(oldEnabledSortIndex, 1)[0], - ); - - const services = {}; - // TODO: simplify this - for (const [index] of this.all.entries()) { - services[this.all[index].id] = index; - } - - this.reorderServicesRequest.execute(services); - this.allServicesRequest.patch(data => { - for (const s of data) { - const service = s; - - service.order = services[s.id]; - } - }); - } - - @action _toggleNotifications({ serviceId }) { - const service = this.one(serviceId); - - this.actions.service.updateService({ - serviceId, - serviceData: { - isNotificationEnabled: !service.isNotificationEnabled, - }, - redirect: false, - }); - } - - @action _toggleAudio({ serviceId }) { - const service = this.one(serviceId); - - this.actions.service.updateService({ - serviceId, - serviceData: { - isMuted: !service.isMuted, - }, - redirect: false, - }); - } - - @action _toggleDarkMode({ serviceId }) { - const service = this.one(serviceId); - - this.actions.service.updateService({ - serviceId, - serviceData: { - isDarkModeEnabled: !service.isDarkModeEnabled, - }, - redirect: false, - }); - } - - @action _openDevTools({ serviceId }) { - const service = this.one(serviceId); - if (service.isTodosService) { - this.actions.todos.openDevTools(); - } else if (service.webview) { - service.webview.openDevTools(); - } - } - - @action _openDevToolsForActiveService() { - const service = this.active; - - if (service) { - this._openDevTools({ serviceId: service.id }); - } else { - debug('No service is active'); - } - } - - @action _hibernate({ serviceId }) { - const service = this.one(serviceId); - if (!service.canHibernate) { - return; - } - - debug(`Hibernate ${service.name}`); - - service.isHibernationRequested = true; - service.lastHibernated = Date.now(); - } - - @action _awake({ serviceId, automatic }) { - const now = Date.now(); - const service = this.one(serviceId); - const automaticTag = automatic ? ' automatically ' : ' '; - debug( - `Waking up${automaticTag}from service hibernation for ${service.name}`, - ); - - if (automatic) { - // if this is an automatic wake up, use the wakeUpHibernationStrategy - // which sets the lastUsed time to an offset from now rather than to now. - // Also add an optional random splay to desync the wakeups and - // potentially reduce load. - // - // offsetNow = now - (hibernationStrategy - wakeUpHibernationStrategy) - // - // if wUHS = hS = 60, offsetNow = now. hibernation again in 60 seconds. - // - // if wUHS = 20 and hS = 60, offsetNow = now - 40. hibernation again in - // 20 seconds. - // - // possibly also include splay in wUHS before subtracting from hS. - // - const mainStrategy = this.stores.settings.all.app.hibernationStrategy; - let strategy = this.stores.settings.all.app.wakeUpHibernationStrategy; - debug(`wakeUpHibernationStrategy = ${strategy}`); - debug(`hibernationStrategy = ${mainStrategy}`); - if (!strategy || strategy < 1) { - strategy = this.stores.settings.all.app.hibernationStrategy; - } - let splay = 0; - // Add splay. This will keep the service awake a little longer. - if ( - this.stores.settings.all.app.wakeUpHibernationSplay && - Math.random() >= 0.5 - ) { - // Add 10 additional seconds 50% of the time. - splay = 10; - debug('Added splay'); - } else { - debug('skipping splay'); - } - // wake up again in strategy + splay seconds instead of mainStrategy seconds. - service.lastUsed = now - ms(`${mainStrategy - (strategy + splay)}s`); - } else { - service.lastUsed = now; - } - debug( - `Setting service.lastUsed to ${service.lastUsed} (${ - (now - service.lastUsed) / 1000 - }s ago)`, - ); - service.isHibernationRequested = false; - service.lastHibernated = null; - } - - @action _resetLastPollTimer({ serviceId = null }) { - debug( - `Reset last poll timer for ${ - serviceId ? `service: "${serviceId}"` : 'all services' - }`, - ); - - // eslint-disable-next-line unicorn/consistent-function-scoping - const resetTimer = service => { - service.lastPollAnswer = Date.now(); - service.lastPoll = Date.now(); - }; - - if (!serviceId) { - for (const service of this.allDisplayed) resetTimer(service); - } else { - const service = this.one(serviceId); - if (service) { - resetTimer(service); - } - } - } - - // Reactions - _focusServiceReaction() { - const service = this.active; - if (service) { - this.actions.service.focusService({ serviceId: service.id }); - document.title = `Ferdium - ${service.name} ${ - service.dialogTitle ? ` - ${service.dialogTitle}` : '' - } ${service._webview ? `- ${service._webview.getTitle()}` : ''}`; - } else { - debug('No service is active'); - } - } - - _saveActiveService() { - const service = this.active; - if (service) { - this.actions.settings.update({ - type: 'service', - data: { - activeService: service.id, - }, - }); - } else { - debug('No service is active'); - } - } - - _mapActiveServiceToServiceModelReaction() { - const { activeService } = this.stores.settings.all.service; - if (this.allDisplayed.length > 0) { - this.allDisplayed.map(service => - Object.assign(service, { - isActive: activeService - ? activeService === service.id - : this.allDisplayed[0].id === service.id, - }), - ); - } - } - - _getUnreadMessageCountReaction() { - const { showMessageBadgeWhenMuted } = this.stores.settings.all.app; - const { showMessageBadgesEvenWhenMuted } = this.stores.ui; - - const unreadDirectMessageCount = this.allDisplayed - .filter( - s => - (showMessageBadgeWhenMuted || s.isNotificationEnabled) && - showMessageBadgesEvenWhenMuted && - s.isBadgeEnabled, - ) - .map(s => s.unreadDirectMessageCount) - .reduce((a, b) => a + b, 0); - - const unreadIndirectMessageCount = this.allDisplayed - .filter( - s => - showMessageBadgeWhenMuted && - showMessageBadgesEvenWhenMuted && - s.isBadgeEnabled && - s.isIndirectMessageBadgeEnabled, - ) - .map(s => s.unreadIndirectMessageCount) - .reduce((a, b) => a + b, 0); - - // We can't just block this earlier, otherwise the mobx reaction won't be aware of the vars to watch in some cases - if (showMessageBadgesEvenWhenMuted) { - this.actions.app.setBadge({ - unreadDirectMessageCount, - unreadIndirectMessageCount, - }); - } - } - - _logoutReaction() { - if (!this.stores.user.isLoggedIn) { - this.actions.settings.remove({ - type: 'service', - key: 'activeService', - }); - this.allServicesRequest.invalidate().reset(); - } - } - - _handleMuteSettings() { - const { enabled } = this; - const { isAppMuted } = this.stores.settings.app; - - for (const service of enabled) { - const { isAttached } = service; - const isMuted = isAppMuted || service.isMuted; - - if (isAttached && service.webview) { - service.webview.audioMuted = isMuted; - } - } - } - - _shareSettingsWithServiceProcess() { - const settings = { - ...this.stores.settings.app, - isDarkThemeActive: this.stores.ui.isDarkThemeActive, - }; - this.actions.service.sendIPCMessageToAllServices({ - channel: 'settings-update', - args: settings, - }); - } - - _cleanUpTeamIdAndCustomUrl(recipeId, data) { - const serviceData = data; - const recipe = this.stores.recipes.one(recipeId); - - if (!recipe) return; - - if ( - recipe.hasTeamId && - recipe.hasCustomUrl && - data.team && - data.customUrl - ) { - delete serviceData.team; - } - - return serviceData; - } - - _checkForActiveService() { - if ( - !this.stores.router.location || - this.stores.router.location.pathname.includes('auth/signup') - ) { - return; - } - - if ( - this.allDisplayed.findIndex(service => service.isActive) === -1 && - this.allDisplayed.length > 0 - ) { - debug('No active service found, setting active service to index 0'); - - this._setActive({ serviceId: this.allDisplayed[0].id }); - } - } - - // Helper - _initializeServiceRecipeInWebview(serviceId) { - const service = this.one(serviceId); - - if (service.webview) { - // We need to completely clone the object, otherwise Electron won't be able to send the object via IPC - const shareWithWebview = cleanseJSObject(service.shareWithWebview); - - debug('Initialize recipe', service.recipe.id, service.name); - service.webview.send( - 'initialize-recipe', - { - ...shareWithWebview, - franzVersion: ferdiumVersion, - }, - service.recipe, - ); - } - } - - _initRecipePolling(serviceId) { - const service = this.one(serviceId); - - const delay = ms('2s'); - - if (service) { - if (service.timer !== null) { - clearTimeout(service.timer); - } - - const loop = () => { - if (!service.webview) return; - - service.webview.send('poll'); - - service.timer = setTimeout(loop, delay); - service.lastPoll = Date.now(); - }; - - loop(); - } - } - - _wrapIndex(index, delta, size) { - return (((index + delta) % size) + size) % size; - } -} diff --git a/src/stores/ServicesStore.ts b/src/stores/ServicesStore.ts new file mode 100644 index 000000000..caa44146f --- /dev/null +++ b/src/stores/ServicesStore.ts @@ -0,0 +1,1356 @@ +import { shell } from 'electron'; +import { action, reaction, computed, observable } from 'mobx'; +import { debounce, remove } from 'lodash'; +import ms from 'ms'; +import { ensureFileSync, pathExistsSync, writeFileSync } from 'fs-extra'; +import { join } from 'path'; + +import { Stores } from 'src/stores.types'; +import { ApiInterface } from 'src/api'; +import { Actions } from 'src/actions/lib/actions'; +import Request from './lib/Request'; +import CachedRequest from './lib/CachedRequest'; +import { matchRoute } from '../helpers/routing-helpers'; +import { isInTimeframe } from '../helpers/schedule-helpers'; +import { + getRecipeDirectory, + getDevRecipeDirectory, +} from '../helpers/recipe-helpers'; +import Service from '../models/Service'; +import { workspaceStore } from '../features/workspaces'; +import { DEFAULT_SERVICE_SETTINGS, KEEP_WS_LOADED_USID } from '../config'; +import { cleanseJSObject } from '../jsUtils'; +import { SPELLCHECKER_LOCALES } from '../i18n/languages'; +import { ferdiumVersion } from '../environment-remote'; +import TypedStore from './lib/TypedStore'; + +const debug = require('../preload-safe-debug')('Ferdium:ServiceStore'); + +export default class ServicesStore extends TypedStore { + @observable allServicesRequest: CachedRequest = new CachedRequest( + this.api.services, + 'all', + ); + + @observable createServiceRequest: Request = new Request( + this.api.services, + 'create', + ); + + @observable updateServiceRequest: Request = new Request( + this.api.services, + 'update', + ); + + @observable reorderServicesRequest: Request = new Request( + this.api.services, + 'reorder', + ); + + @observable deleteServiceRequest: Request = new Request( + this.api.services, + 'delete', + ); + + @observable clearCacheRequest: Request = new Request( + this.api.services, + 'clearCache', + ); + + @observable filterNeedle: string | null = null; + + // Array of service IDs that have recently been used + // [0] => Most recent, [n] => Least recent + // No service ID should be in the list multiple times, not all service IDs have to be in the list + @observable lastUsedServices: string[] = []; + + constructor(stores: Stores, api: ApiInterface, actions: Actions) { + super(stores, api, actions); + + // Register action handlers + this.actions.service.setActive.listen(this._setActive.bind(this)); + this.actions.service.blurActive.listen(this._blurActive.bind(this)); + this.actions.service.setActiveNext.listen(this._setActiveNext.bind(this)); + this.actions.service.setActivePrev.listen(this._setActivePrev.bind(this)); + this.actions.service.showAddServiceInterface.listen( + this._showAddServiceInterface.bind(this), + ); + this.actions.service.createService.listen(this._createService.bind(this)); + this.actions.service.createFromLegacyService.listen( + this._createFromLegacyService.bind(this), + ); + this.actions.service.updateService.listen(this._updateService.bind(this)); + this.actions.service.deleteService.listen(this._deleteService.bind(this)); + this.actions.service.openRecipeFile.listen(this._openRecipeFile.bind(this)); + this.actions.service.clearCache.listen(this._clearCache.bind(this)); + this.actions.service.setWebviewReference.listen( + this._setWebviewReference.bind(this), + ); + this.actions.service.detachService.listen(this._detachService.bind(this)); + this.actions.service.focusService.listen(this._focusService.bind(this)); + this.actions.service.focusActiveService.listen( + this._focusActiveService.bind(this), + ); + this.actions.service.toggleService.listen(this._toggleService.bind(this)); + this.actions.service.handleIPCMessage.listen( + this._handleIPCMessage.bind(this), + ); + this.actions.service.sendIPCMessage.listen(this._sendIPCMessage.bind(this)); + this.actions.service.sendIPCMessageToAllServices.listen( + this._sendIPCMessageToAllServices.bind(this), + ); + this.actions.service.setUnreadMessageCount.listen( + this._setUnreadMessageCount.bind(this), + ); + this.actions.service.setDialogTitle.listen(this._setDialogTitle.bind(this)); + this.actions.service.openWindow.listen(this._openWindow.bind(this)); + this.actions.service.filter.listen(this._filter.bind(this)); + this.actions.service.resetFilter.listen(this._resetFilter.bind(this)); + this.actions.service.resetStatus.listen(this._resetStatus.bind(this)); + this.actions.service.reload.listen(this._reload.bind(this)); + this.actions.service.reloadActive.listen(this._reloadActive.bind(this)); + this.actions.service.reloadAll.listen(this._reloadAll.bind(this)); + this.actions.service.reloadUpdatedServices.listen( + this._reloadUpdatedServices.bind(this), + ); + this.actions.service.reorder.listen(this._reorder.bind(this)); + this.actions.service.toggleNotifications.listen( + this._toggleNotifications.bind(this), + ); + this.actions.service.toggleAudio.listen(this._toggleAudio.bind(this)); + this.actions.service.toggleDarkMode.listen(this._toggleDarkMode.bind(this)); + this.actions.service.openDevTools.listen(this._openDevTools.bind(this)); + this.actions.service.openDevToolsForActiveService.listen( + this._openDevToolsForActiveService.bind(this), + ); + this.actions.service.hibernate.listen(this._hibernate.bind(this)); + this.actions.service.awake.listen(this._awake.bind(this)); + this.actions.service.resetLastPollTimer.listen( + this._resetLastPollTimer.bind(this), + ); + this.actions.service.shareSettingsWithServiceProcess.listen( + this._shareSettingsWithServiceProcess.bind(this), + ); + + this.registerReactions([ + this._focusServiceReaction.bind(this), + this._getUnreadMessageCountReaction.bind(this), + this._mapActiveServiceToServiceModelReaction.bind(this), + this._saveActiveService.bind(this), + this._logoutReaction.bind(this), + this._handleMuteSettings.bind(this), + this._checkForActiveService.bind(this), + ]); + + // Just bind this + this._initializeServiceRecipeInWebview.bind(this); + } + + setup() { + // Single key reactions for the sake of your CPU + reaction( + () => this.stores.settings.app.enableSpellchecking, + () => { + this._shareSettingsWithServiceProcess(); + }, + ); + + reaction( + () => this.stores.settings.app.spellcheckerLanguage, + () => { + this._shareSettingsWithServiceProcess(); + }, + ); + + reaction( + () => this.stores.settings.app.darkMode, + () => { + this._shareSettingsWithServiceProcess(); + }, + ); + + reaction( + () => this.stores.settings.app.adaptableDarkMode, + () => { + this._shareSettingsWithServiceProcess(); + }, + ); + + reaction( + () => this.stores.settings.app.universalDarkMode, + () => { + this._shareSettingsWithServiceProcess(); + }, + ); + + reaction( + () => this.stores.settings.app.splitMode, + () => { + this._shareSettingsWithServiceProcess(); + }, + ); + + reaction( + () => this.stores.settings.app.splitColumns, + () => { + this._shareSettingsWithServiceProcess(); + }, + ); + + reaction( + () => this.stores.settings.app.searchEngine, + () => { + this._shareSettingsWithServiceProcess(); + }, + ); + + reaction( + () => this.stores.settings.app.clipboardNotifications, + () => { + this._shareSettingsWithServiceProcess(); + }, + ); + } + + initialize() { + super.initialize(); + + // Check services to become hibernated + this.serviceMaintenanceTick(); + } + + teardown() { + super.teardown(); + + // Stop checking services for hibernation + this.serviceMaintenanceTick.cancel(); + } + + _serviceMaintenanceTicker() { + this._serviceMaintenance(); + this.serviceMaintenanceTick(); + debug('Service maintenance tick'); + } + + /** + * Сheck for services to become hibernated. + */ + serviceMaintenanceTick = debounce(this._serviceMaintenanceTicker, ms('10s')); + + /** + * Run various maintenance tasks on services + */ + _serviceMaintenance() { + for (const service of this.enabled) { + // Defines which services should be hibernated or woken up + if (!service.isActive) { + if ( + !service.lastHibernated && + Date.now() - service.lastUsed > + ms(`${this.stores.settings.all.app.hibernationStrategy}s`) + ) { + // If service is stale, hibernate it. + this._hibernate({ serviceId: service.id }); + } + + if ( + service.isWakeUpEnabled && + service.lastHibernated && + Number(this.stores.settings.all.app.wakeUpStrategy) > 0 && + Date.now() - service.lastHibernated > + ms(`${this.stores.settings.all.app.wakeUpStrategy}s`) + ) { + // If service is in hibernation and the wakeup time has elapsed, wake it. + this._awake({ serviceId: service.id, automatic: true }); + } + } + + if ( + service.lastPoll && + service.lastPoll - service.lastPollAnswer > ms('1m') + ) { + // If service did not reply for more than 1m try to reload. + if (!service.isActive) { + if (this.stores.app.isOnline && service.lostRecipeReloadAttempt < 3) { + debug( + `Reloading service: ${service.name} (${service.id}). Attempt: ${service.lostRecipeReloadAttempt}`, + ); + // service.webview.reload(); + service.lostRecipeReloadAttempt += 1; + + service.lostRecipeConnection = false; + } + } else { + debug(`Service lost connection: ${service.name} (${service.id}).`); + service.lostRecipeConnection = true; + } + } else { + service.lostRecipeConnection = false; + service.lostRecipeReloadAttempt = 0; + } + } + } + + // Computed props + @computed get all(): Service[] { + if (this.stores.user.isLoggedIn) { + const services = this.allServicesRequest.execute().result; + if (services) { + return observable( + [...services] + .slice() + .sort((a, b) => a.order - b.order) + .map((s, index) => { + s.index = index; + return s; + }), + ); + } + } + return []; + } + + @computed get enabled(): Service[] { + return this.all.filter(service => service.isEnabled); + } + + @computed get allDisplayed() { + const services = this.stores.settings.all.app.showDisabledServices + ? this.all + : this.enabled; + return workspaceStore.filterServicesByActiveWorkspace(services); + } + + // This is just used to avoid unnecessary rerendering of resource-heavy webviews + @computed get allDisplayedUnordered() { + const { showDisabledServices } = this.stores.settings.all.app; + const { keepAllWorkspacesLoaded } = this.stores.workspaces.settings; + const services = this.allServicesRequest.execute().result || []; + const filteredServices = showDisabledServices + ? services + : services.filter(service => service.isEnabled); + + let displayedServices; + if (keepAllWorkspacesLoaded) { + // Keep all enabled services loaded + displayedServices = filteredServices; + } else { + // Keep all services in current workspace loaded + displayedServices = + workspaceStore.filterServicesByActiveWorkspace(filteredServices); + + // Keep all services active in workspaces that should be kept loaded + for (const workspace of this.stores.workspaces.workspaces) { + // Check if workspace needs to be kept loaded + if (workspace.services.includes(KEEP_WS_LOADED_USID)) { + // Get services for workspace + const serviceIDs = new Set( + workspace.services.filter(i => i !== KEEP_WS_LOADED_USID), + ); + const wsServices = filteredServices.filter(service => + serviceIDs.has(service.id), + ); + + displayedServices = [...displayedServices, ...wsServices]; + } + } + + // Make sure every service is in the list only once + displayedServices = displayedServices.filter( + (v, i, a) => a.indexOf(v) === i, + ); + } + + return displayedServices; + } + + @computed get filtered() { + if (this.filterNeedle !== null) { + return this.all.filter(service => + service.name.toLowerCase().includes(this.filterNeedle!.toLowerCase()), + ); + } + + // Return all if there is no filterNeedle present + return this.all; + } + + @computed get active() { + return this.all.find(service => service.isActive); + } + + @computed get activeSettings() { + const match = matchRoute( + '/settings/services/edit/:id', + this.stores.router.location.pathname, + ); + if (match) { + const activeService = this.one(match.id); + if (activeService) { + return activeService; + } + + debug('Service not available'); + } + + return null; + } + + @computed get isTodosServiceAdded() { + return ( + this.allDisplayed.find( + service => service.isTodosService && service.isEnabled, + ) || false + ); + } + + @computed get isTodosServiceActive() { + return this.active && this.active.isTodosService; + } + + // TODO: This can actually return undefined as well + one(id: string): Service { + return this.all.find(service => service.id === id) as Service; + } + + async _showAddServiceInterface({ recipeId }) { + this.stores.router.push(`/settings/services/add/${recipeId}`); + } + + // Actions + async _createService({ + recipeId, + serviceData, + redirect = true, + skipCleanup = false, + }) { + if (!this.stores.recipes.isInstalled(recipeId)) { + debug(`Recipe "${recipeId}" is not installed, installing recipe`); + await this.stores.recipes._install({ recipeId }); + debug(`Recipe "${recipeId}" installed`); + } + + // set default values for serviceData + serviceData = { + isEnabled: DEFAULT_SERVICE_SETTINGS.isEnabled, + isHibernationEnabled: DEFAULT_SERVICE_SETTINGS.isHibernationEnabled, + isWakeUpEnabled: DEFAULT_SERVICE_SETTINGS.isWakeUpEnabled, + isNotificationEnabled: DEFAULT_SERVICE_SETTINGS.isNotificationEnabled, + isBadgeEnabled: DEFAULT_SERVICE_SETTINGS.isBadgeEnabled, + trapLinkClicks: DEFAULT_SERVICE_SETTINGS.trapLinkClicks, + isMuted: DEFAULT_SERVICE_SETTINGS.isMuted, + customIcon: DEFAULT_SERVICE_SETTINGS.customIcon, + isDarkModeEnabled: DEFAULT_SERVICE_SETTINGS.isDarkModeEnabled, + isProgressbarEnabled: DEFAULT_SERVICE_SETTINGS.isProgressbarEnabled, + spellcheckerLanguage: + SPELLCHECKER_LOCALES[this.stores.settings.app.spellcheckerLanguage], + userAgentPref: '', + ...serviceData, + }; + + const data = skipCleanup + ? serviceData + : this._cleanUpTeamIdAndCustomUrl(recipeId, serviceData); + + const response = await this.createServiceRequest.execute(recipeId, data) + ._promise; + + this.allServicesRequest.patch(result => { + if (!result) return; + result.push(response.data); + }); + + this.actions.settings.update({ + type: 'proxy', + data: { + [`${response.data.id}`]: data.proxy, + }, + }); + + this.actionStatus = response.status || []; + + if (redirect) { + this.stores.router.push('/settings/recipes'); + } + } + + @action async _createFromLegacyService({ data }) { + const { id } = data.recipe; + const serviceData: { + name?: string; + team?: string; + customUrl?: string; + } = {}; + + if (data.name) { + serviceData.name = data.name; + } + + if (data.team) { + if (!data.customURL) { + serviceData.team = data.team; + } else { + // TODO: Is this correct? + serviceData.customUrl = data.team; + } + } + + this.actions.service.createService({ + recipeId: id, + serviceData, + redirect: false, + }); + } + + @action async _updateService({ serviceId, serviceData, redirect = true }) { + const service = this.one(serviceId); + const data = this._cleanUpTeamIdAndCustomUrl( + service.recipe.id, + serviceData, + ); + const request = this.updateServiceRequest.execute(serviceId, data); + + const newData = serviceData; + if (serviceData.iconFile) { + await request._promise; + + newData.iconUrl = request.result.data.iconUrl; + newData.hasCustomUploadedIcon = true; + } + + this.allServicesRequest.patch(result => { + if (!result) return; + + // patch custom icon deletion + if (data.customIcon === 'delete') { + newData.iconUrl = ''; + newData.hasCustomUploadedIcon = false; + } + + // patch custom icon url + if (data.customIconUrl) { + newData.iconUrl = data.customIconUrl; + } + + Object.assign( + result.find(c => c.id === serviceId), + newData, + ); + }); + + await request._promise; + this.actionStatus = request.result.status; + + if (service.isEnabled) { + this._sendIPCMessage({ + serviceId, + channel: 'service-settings-update', + args: newData, + }); + } + + this.actions.settings.update({ + type: 'proxy', + data: { + [`${serviceId}`]: data.proxy, + }, + }); + + if (redirect) { + this.stores.router.push('/settings/services'); + } + } + + @action async _deleteService({ serviceId, redirect }): Promise { + const request = this.deleteServiceRequest.execute(serviceId); + + if (redirect) { + this.stores.router.push(redirect); + } + + this.allServicesRequest.patch(result => { + remove(result, (c: Service) => c.id === serviceId); + }); + + await request._promise; + this.actionStatus = request.result.status; + } + + @action async _openRecipeFile({ recipe, file }): Promise { + // Get directory for recipe + const normalDirectory = getRecipeDirectory(recipe); + const devDirectory = getDevRecipeDirectory(recipe); + let directory; + + if (pathExistsSync(normalDirectory)) { + directory = normalDirectory; + } else if (pathExistsSync(devDirectory)) { + directory = devDirectory; + } else { + // Recipe cannot be found on drive + return; + } + + // Create and open file + const filePath = join(directory, file); + if (file === 'user.js') { + if (!pathExistsSync(filePath)) { + writeFileSync( + filePath, + `module.exports = (config, Ferdium) => { + // Write your scripts here + console.log("Hello, World!", config); +}; +`, + ); + } + } else { + ensureFileSync(filePath); + } + shell.showItemInFolder(filePath); + } + + @action async _clearCache({ serviceId }) { + this.clearCacheRequest.reset(); + const request = this.clearCacheRequest.execute(serviceId); + await request._promise; + } + + @action _setActive({ serviceId, keepActiveRoute = null }) { + if (!keepActiveRoute) this.stores.router.push('/'); + const service = this.one(serviceId); + + for (const s of this.all) { + if (s.isActive) { + s.lastUsed = Date.now(); + s.isActive = false; + } + } + service.isActive = true; + this._awake({ serviceId: service.id }); + + if ( + this.isTodosServiceActive && + !this.stores.todos.settings.isFeatureEnabledByUser + ) { + this.actions.todos.toggleTodosFeatureVisibility(); + } + + // Update list of last used services + this.lastUsedServices = this.lastUsedServices.filter( + id => id !== serviceId, + ); + this.lastUsedServices.unshift(serviceId); + + this._focusActiveService(); + } + + @action _blurActive() { + const service = this.active; + if (service) { + service.isActive = false; + } else { + debug('No service is active'); + } + } + + @action _setActiveNext() { + const nextIndex = this._wrapIndex( + this.allDisplayed.findIndex(service => service.isActive), + 1, + this.allDisplayed.length, + ); + + this._setActive({ serviceId: this.allDisplayed[nextIndex].id }); + } + + @action _setActivePrev() { + const prevIndex = this._wrapIndex( + this.allDisplayed.findIndex(service => service.isActive), + -1, + this.allDisplayed.length, + ); + + this._setActive({ serviceId: this.allDisplayed[prevIndex].id }); + } + + @action _setUnreadMessageCount({ serviceId, count }) { + const service = this.one(serviceId); + + service.unreadDirectMessageCount = count.direct; + service.unreadIndirectMessageCount = count.indirect; + } + + @action _setDialogTitle({ serviceId, dialogTitle }) { + const service = this.one(serviceId); + + service.dialogTitle = dialogTitle; + } + + @action _setWebviewReference({ serviceId, webview }) { + const service = this.one(serviceId); + if (service) { + service.webview = webview; + + if (!service.isAttached) { + debug('Webview is not attached, initializing'); + service.initializeWebViewEvents({ + handleIPCMessage: this.actions.service.handleIPCMessage, + openWindow: this.actions.service.openWindow, + stores: this.stores, + }); + service.initializeWebViewListener(); + } + service.isAttached = true; + } + } + + @action _detachService({ service }) { + service.webview = null; + service.isAttached = false; + } + + @action _focusService({ serviceId }) { + const service = this.one(serviceId); + + if (service.webview) { + service.webview.blur(); + service.webview.focus(); + } + } + + @action _focusActiveService(focusEvent = null) { + if (this.stores.user.isLoggedIn) { + // TODO: add checks to not focus service when router path is /settings or /auth + const service = this.active; + if (service) { + if (service._webview) { + document.title = `Ferdium - ${service.name} ${ + service.dialogTitle ? ` - ${service.dialogTitle}` : '' + } ${service._webview ? `- ${service._webview.getTitle()}` : ''}`; + this._focusService({ serviceId: service.id }); + if (this.stores.settings.app.splitMode && !focusEvent) { + setTimeout(() => { + document + .querySelector('.services__webview-wrapper.is-active') + ?.scrollIntoView({ + behavior: 'smooth', + block: 'end', + inline: 'nearest', + }); + }, 10); + } + } + } else { + debug('No service is active'); + } + } else { + this.allServicesRequest.invalidate(); + } + } + + @action _toggleService({ serviceId }) { + const service = this.one(serviceId); + + service.isEnabled = !service.isEnabled; + } + + @action _handleIPCMessage({ serviceId, channel, args }) { + const service = this.one(serviceId); + + // eslint-disable-next-line default-case + switch (channel) { + case 'hello': { + debug('Received hello event from', serviceId); + + this._initRecipePolling(service.id); + this._initializeServiceRecipeInWebview(serviceId); + this._shareSettingsWithServiceProcess(); + + break; + } + case 'alive': { + service.lastPollAnswer = Date.now(); + + break; + } + case 'message-counts': { + debug(`Received unread message info from '${serviceId}'`, args[0]); + + this.actions.service.setUnreadMessageCount({ + serviceId, + count: { + direct: args[0].direct, + indirect: args[0].indirect, + }, + }); + + break; + } + case 'active-dialog-title': { + debug(`Received active dialog title from '${serviceId}'`, args[0]); + + this.actions.service.setDialogTitle({ + serviceId, + dialogTitle: args[0], + }); + + break; + } + case 'notification': { + const { options } = args[0]; + + // Check if we are in scheduled Do-not-Disturb time + const { scheduledDNDEnabled, scheduledDNDStart, scheduledDNDEnd } = + this.stores.settings.all.app; + + if ( + scheduledDNDEnabled && + isInTimeframe(scheduledDNDStart, scheduledDNDEnd) + ) { + return; + } + + if ( + service.recipe.hasNotificationSound || + service.isMuted || + this.stores.settings.all.app.isAppMuted + ) { + Object.assign(options, { + silent: true, + }); + } + + if (service.isNotificationEnabled) { + let title = `Notification from ${service.name}`; + if (!this.stores.settings.all.app.privateNotifications) { + options.body = typeof options.body === 'string' ? options.body : ''; + title = + typeof args[0].title === 'string' ? args[0].title : service.name; + } else { + // Remove message data from notification in private mode + options.body = ''; + options.icon = '/assets/img/notification-badge.gif'; + } + + this.actions.app.notify({ + notificationId: args[0].notificationId, + title, + options, + serviceId, + }); + } + + break; + } + case 'avatar': { + const url = args[0]; + if (service.iconUrl !== url && !service.hasCustomUploadedIcon) { + service.customIconUrl = url; + + this.actions.service.updateService({ + serviceId, + serviceData: { + customIconUrl: url, + }, + redirect: false, + }); + } + + break; + } + case 'new-window': { + const url = args[0]; + + this.actions.app.openExternalUrl({ url }); + + break; + } + case 'set-service-spellchecker-language': { + if (!args) { + console.warn('Did not receive locale'); + } else { + this.actions.service.updateService({ + serviceId, + serviceData: { + spellcheckerLanguage: args[0] === 'reset' ? '' : args[0], + }, + redirect: false, + }); + } + + break; + } + case 'feature:todos': { + Object.assign(args[0].data, { serviceId }); + this.actions.todos.handleHostMessage(args[0]); + + break; + } + // No default + } + } + + @action _sendIPCMessage({ serviceId, channel, args }) { + const service = this.one(serviceId); + + // Make sure the args are clean, otherwise ElectronJS can't transmit them + const cleanArgs = cleanseJSObject(args); + + if (service.webview) { + service.webview.send(channel, cleanArgs); + } + } + + @action _sendIPCMessageToAllServices({ channel, args }) { + for (const s of this.all) { + this.actions.service.sendIPCMessage({ + serviceId: s.id, + channel, + args, + }); + } + } + + @action _openWindow({ event }) { + if (event.url !== 'about:blank') { + event.preventDefault(); + this.actions.app.openExternalUrl({ url: event.url }); + } + } + + @action _filter({ needle }) { + this.filterNeedle = needle; + } + + @action _resetFilter() { + this.filterNeedle = null; + } + + @action _resetStatus() { + this.actionStatus = []; + } + + @action _reload({ serviceId }) { + const service = this.one(serviceId); + if (!service.isEnabled) return; + + service.resetMessageCount(); + service.lostRecipeConnection = false; + + if (service.isTodosService) { + return this.actions.todos.reload(); + } + + if (!service.webview) return; + return service.webview.loadURL(service.url); + } + + @action _reloadActive() { + const service = this.active; + if (service) { + this._reload({ + serviceId: service.id, + }); + } else { + debug('No service is active'); + } + } + + @action _reloadAll() { + for (const s of this.enabled) { + this._reload({ + serviceId: s.id, + }); + } + } + + @action _reloadUpdatedServices() { + this._reloadAll(); + this.actions.ui.toggleServiceUpdatedInfoBar({ visible: false }); + } + + @action _reorder(params) { + const { workspaces } = this.stores; + if (workspaces.isAnyWorkspaceActive) { + workspaces.reorderServicesOfActiveWorkspace(params); + } else { + this._reorderService(params); + } + } + + @action _reorderService({ oldIndex, newIndex }) { + const { showDisabledServices } = this.stores.settings.all.app; + const oldEnabledSortIndex = showDisabledServices + ? oldIndex + : this.all.indexOf(this.enabled[oldIndex]); + const newEnabledSortIndex = showDisabledServices + ? newIndex + : this.all.indexOf(this.enabled[newIndex]); + + this.all.splice( + newEnabledSortIndex, + 0, + this.all.splice(oldEnabledSortIndex, 1)[0], + ); + + const services = {}; + // TODO: simplify this + for (const [index] of this.all.entries()) { + services[this.all[index].id] = index; + } + + this.reorderServicesRequest.execute(services); + this.allServicesRequest.patch(data => { + for (const s of data) { + const service = s; + + service.order = services[s.id]; + } + }); + } + + @action _toggleNotifications({ serviceId }) { + const service = this.one(serviceId); + + this.actions.service.updateService({ + serviceId, + serviceData: { + isNotificationEnabled: !service.isNotificationEnabled, + }, + redirect: false, + }); + } + + @action _toggleAudio({ serviceId }) { + const service = this.one(serviceId); + + this.actions.service.updateService({ + serviceId, + serviceData: { + isMuted: !service.isMuted, + }, + redirect: false, + }); + } + + @action _toggleDarkMode({ serviceId }) { + const service = this.one(serviceId); + + this.actions.service.updateService({ + serviceId, + serviceData: { + isDarkModeEnabled: !service.isDarkModeEnabled, + }, + redirect: false, + }); + } + + @action _openDevTools({ serviceId }) { + const service = this.one(serviceId); + if (service.isTodosService) { + this.actions.todos.openDevTools(); + } else if (service.webview) { + service.webview.openDevTools(); + } + } + + @action _openDevToolsForActiveService() { + const service = this.active; + + if (service) { + this._openDevTools({ serviceId: service.id }); + } else { + debug('No service is active'); + } + } + + @action _hibernate({ serviceId }) { + const service = this.one(serviceId); + if (!service.canHibernate) { + return; + } + + debug(`Hibernate ${service.name}`); + + service.isHibernationRequested = true; + service.lastHibernated = Date.now(); + } + + @action _awake({ + serviceId, + automatic, + }: { + serviceId: string; + automatic?: boolean; + }) { + const now = Date.now(); + const service = this.one(serviceId); + const automaticTag = automatic ? ' automatically ' : ' '; + debug( + `Waking up${automaticTag}from service hibernation for ${service.name}`, + ); + + if (automatic) { + // if this is an automatic wake up, use the wakeUpHibernationStrategy + // which sets the lastUsed time to an offset from now rather than to now. + // Also add an optional random splay to desync the wakeups and + // potentially reduce load. + // + // offsetNow = now - (hibernationStrategy - wakeUpHibernationStrategy) + // + // if wUHS = hS = 60, offsetNow = now. hibernation again in 60 seconds. + // + // if wUHS = 20 and hS = 60, offsetNow = now - 40. hibernation again in + // 20 seconds. + // + // possibly also include splay in wUHS before subtracting from hS. + // + const mainStrategy = this.stores.settings.all.app.hibernationStrategy; + let strategy = this.stores.settings.all.app.wakeUpHibernationStrategy; + debug(`wakeUpHibernationStrategy = ${strategy}`); + debug(`hibernationStrategy = ${mainStrategy}`); + if (!strategy || strategy < 1) { + strategy = this.stores.settings.all.app.hibernationStrategy; + } + let splay = 0; + // Add splay. This will keep the service awake a little longer. + if ( + this.stores.settings.all.app.wakeUpHibernationSplay && + Math.random() >= 0.5 + ) { + // Add 10 additional seconds 50% of the time. + splay = 10; + debug('Added splay'); + } else { + debug('skipping splay'); + } + // wake up again in strategy + splay seconds instead of mainStrategy seconds. + service.lastUsed = now - ms(`${mainStrategy - (strategy + splay)}s`); + } else { + service.lastUsed = now; + } + debug( + `Setting service.lastUsed to ${service.lastUsed} (${ + (now - service.lastUsed) / 1000 + }s ago)`, + ); + service.isHibernationRequested = false; + service.lastHibernated = null; + } + + @action _resetLastPollTimer({ serviceId = null }) { + debug( + `Reset last poll timer for ${ + serviceId ? `service: "${serviceId}"` : 'all services' + }`, + ); + + // eslint-disable-next-line unicorn/consistent-function-scoping + const resetTimer = service => { + service.lastPollAnswer = Date.now(); + service.lastPoll = Date.now(); + }; + + if (!serviceId) { + for (const service of this.allDisplayed) resetTimer(service); + } else { + const service = this.one(serviceId); + if (service) { + resetTimer(service); + } + } + } + + // Reactions + _focusServiceReaction() { + const service = this.active; + if (service) { + this.actions.service.focusService({ serviceId: service.id }); + document.title = `Ferdium - ${service.name} ${ + service.dialogTitle ? ` - ${service.dialogTitle}` : '' + } ${service._webview ? `- ${service._webview.getTitle()}` : ''}`; + } else { + debug('No service is active'); + } + } + + _saveActiveService() { + const service = this.active; + if (service) { + this.actions.settings.update({ + type: 'service', + data: { + activeService: service.id, + }, + }); + } else { + debug('No service is active'); + } + } + + _mapActiveServiceToServiceModelReaction() { + const { activeService } = this.stores.settings.all.service; + if (this.allDisplayed.length > 0) { + this.allDisplayed.map(service => + Object.assign(service, { + isActive: activeService + ? activeService === service.id + : this.allDisplayed[0].id === service.id, + }), + ); + } + } + + _getUnreadMessageCountReaction() { + const { showMessageBadgeWhenMuted } = this.stores.settings.all.app; + const { showMessageBadgesEvenWhenMuted } = this.stores.ui; + + const unreadDirectMessageCount = this.allDisplayed + .filter( + s => + (showMessageBadgeWhenMuted || s.isNotificationEnabled) && + showMessageBadgesEvenWhenMuted && + s.isBadgeEnabled, + ) + .map(s => s.unreadDirectMessageCount) + .reduce((a, b) => a + b, 0); + + const unreadIndirectMessageCount = this.allDisplayed + .filter( + s => + showMessageBadgeWhenMuted && + showMessageBadgesEvenWhenMuted && + s.isBadgeEnabled && + s.isIndirectMessageBadgeEnabled, + ) + .map(s => s.unreadIndirectMessageCount) + .reduce((a, b) => a + b, 0); + + // We can't just block this earlier, otherwise the mobx reaction won't be aware of the vars to watch in some cases + if (showMessageBadgesEvenWhenMuted) { + this.actions.app.setBadge({ + unreadDirectMessageCount, + unreadIndirectMessageCount, + }); + } + } + + _logoutReaction() { + if (!this.stores.user.isLoggedIn) { + this.actions.settings.remove({ + type: 'service', + key: 'activeService', + }); + this.allServicesRequest.invalidate().reset(); + } + } + + _handleMuteSettings() { + const { enabled } = this; + const { isAppMuted } = this.stores.settings.app; + + for (const service of enabled) { + const { isAttached } = service; + const isMuted = isAppMuted || service.isMuted; + + if (isAttached && service.webview) { + service.webview.audioMuted = isMuted; + } + } + } + + _shareSettingsWithServiceProcess(): void { + const settings = { + ...this.stores.settings.app, + isDarkThemeActive: this.stores.ui.isDarkThemeActive, + }; + this.actions.service.sendIPCMessageToAllServices({ + channel: 'settings-update', + args: settings, + }); + } + + _cleanUpTeamIdAndCustomUrl(recipeId, data): any { + const serviceData = data; + const recipe = this.stores.recipes.one(recipeId); + + if (!recipe) return; + + if ( + recipe.hasTeamId && + recipe.hasCustomUrl && + data.team && + data.customUrl + ) { + delete serviceData.team; + } + + return serviceData; + } + + _checkForActiveService() { + if ( + !this.stores.router.location || + this.stores.router.location.pathname.includes('auth/signup') + ) { + return; + } + + if ( + this.allDisplayed.findIndex(service => service.isActive) === -1 && + this.allDisplayed.length > 0 + ) { + debug('No active service found, setting active service to index 0'); + + this._setActive({ serviceId: this.allDisplayed[0].id }); + } + } + + // Helper + _initializeServiceRecipeInWebview(serviceId) { + const service = this.one(serviceId); + + if (service.webview) { + // We need to completely clone the object, otherwise Electron won't be able to send the object via IPC + const shareWithWebview = cleanseJSObject(service.shareWithWebview); + + debug('Initialize recipe', service.recipe.id, service.name); + service.webview.send( + 'initialize-recipe', + { + ...shareWithWebview, + franzVersion: ferdiumVersion, + }, + service.recipe, + ); + } + } + + _initRecipePolling(serviceId) { + const service = this.one(serviceId); + + const delay = ms('2s'); + + if (service) { + if (service.timer !== null) { + clearTimeout(service.timer); + } + + const loop = () => { + if (!service.webview) return; + + service.webview.send('poll'); + + service.timer = setTimeout(loop, delay); + service.lastPoll = Date.now(); + }; + + loop(); + } + } + + _wrapIndex(index, delta, size) { + return (((index + delta) % size) + size) % size; + } +} diff --git a/src/webview/recipe.js b/src/webview/recipe.js index 847a720ff..9d5a97767 100644 --- a/src/webview/recipe.js +++ b/src/webview/recipe.js @@ -158,7 +158,10 @@ class RecipeController { } @computed get spellcheckerLanguage() { - return ifUndefinedString(this.settings.service.spellcheckerLanguage, this.settings.app.spellcheckerLanguage); + return ifUndefinedString( + this.settings.service.spellcheckerLanguage, + this.settings.app.spellcheckerLanguage, + ); } cldIdentifier = null; @@ -197,13 +200,13 @@ class RecipeController { // Add ability to go forward or back with mouse buttons (inside the recipe) window.addEventListener('mouseup', e => { if (e.button === 3) { - e.preventDefault() - e.stopPropagation() - window.history.back() + e.preventDefault(); + e.stopPropagation(); + window.history.back(); } else if (e.button === 4) { - e.preventDefault() - e.stopPropagation() - window.history.forward() + e.preventDefault(); + e.stopPropagation(); + window.history.forward(); } }); } -- cgit v1.2.3-54-g00ecf