diff --git a/src/@types/stores.types.ts b/src/@types/stores.types.ts index a2e935bf11..f9e329d15b 100644 --- a/src/@types/stores.types.ts +++ b/src/@types/stores.types.ts @@ -131,6 +131,7 @@ export interface AppStore extends TypedStore { cacheSize: () => void; debugInfo: () => void; enableLongPressServiceHint: boolean; + getSandbox: (serviceId: string) => string | undefined; } interface CommunityRecipesStore extends TypedStore { diff --git a/src/actions/app.ts b/src/actions/app.ts index 22324c0555..d9d57cb4f0 100644 --- a/src/actions/app.ts +++ b/src/actions/app.ts @@ -34,4 +34,7 @@ export default { endedDownload: {}, stopDownload: {}, togglePauseDownload: {}, + addSandboxService: {}, + deleteSandboxService: {}, + editSandboxService: {}, }; diff --git a/src/components/services/content/ServiceView.tsx b/src/components/services/content/ServiceView.tsx index b7f539a5d9..8104683edc 100644 --- a/src/components/services/content/ServiceView.tsx +++ b/src/components/services/content/ServiceView.tsx @@ -171,6 +171,7 @@ class ServiceView extends Component { setWebviewReference={setWebviewRef} detachService={detachService} isSpellcheckerEnabled={isSpellcheckerEnabled} + stores={stores} /> )} diff --git a/src/components/services/content/ServiceWebview.tsx b/src/components/services/content/ServiceWebview.tsx index 0cf34c14bc..28f7751411 100644 --- a/src/components/services/content/ServiceWebview.tsx +++ b/src/components/services/content/ServiceWebview.tsx @@ -3,6 +3,7 @@ import { action, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import { Component, type ReactElement } from 'react'; import ElectronWebView from 'react-electron-web-view'; +import type { RealStores } from 'src/stores'; import type ServiceModel from '../../../models/Service'; const debug = require('../../../preload-safe-debug')('Ferdium:Services'); @@ -15,6 +16,7 @@ interface IProps { }) => void; detachService: (options: { service: ServiceModel }) => void; isSpellcheckerEnabled: boolean; + stores?: RealStores; } @observer @@ -82,7 +84,22 @@ class ServiceWebview extends Component { } render(): ReactElement { - const { service, setWebviewReference, isSpellcheckerEnabled } = this.props; + const { service, setWebviewReference, isSpellcheckerEnabled, stores } = + this.props; + + const { sandboxServices } = stores!.settings.app; + + const { sandboxServices: sandboxes } = stores!.app; + + const checkForSandbox = () => { + const sandbox = sandboxes.find(s => s.services.includes(service.id)); + + if (sandbox) { + return `persist:sandbox-${sandbox.id}`; + } + + return service.partition; + }; const preloadScript = join( __dirname, @@ -107,7 +124,9 @@ class ServiceWebview extends Component { autosize src={service.url} preload={preloadScript} - partition={service.partition} + partition={ + sandboxServices ? checkForSandbox() : 'persist:general-session' + } onDidAttach={() => { // Force the event handler to run in a new task. // This resolves a race condition when the `did-attach` is called, diff --git a/src/components/settings/SandboxServiceTabs.tsx b/src/components/settings/SandboxServiceTabs.tsx new file mode 100644 index 0000000000..986a37deb4 --- /dev/null +++ b/src/components/settings/SandboxServiceTabs.tsx @@ -0,0 +1,156 @@ +import AddCircleIcon from '@mui/icons-material/AddCircle'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { Button, IconButton, TextField } from '@mui/material'; +import Box from '@mui/material/Box'; +import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; +import { inject, observer } from 'mobx-react'; +import { type ReactNode, type SyntheticEvent, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import type { StoresProps } from 'src/@types/ferdium-components.types'; +import SandboxTransferList from './SandboxTransferList'; + +const debug = require('../../preload-safe-debug')('Ferdium:Settings'); + +const messages = defineMessages({ + addCustomSandbox: { + id: 'sandbox.addCustomSandbox', + defaultMessage: 'Add a custom sandbox', + }, +}); + +interface TabPanelProps { + children?: ReactNode; + index: number; + value: number; +} + +function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props; + + return ( + + ); +} + +function a11yProps(index: number) { + return { + id: `vertical-tab-${index}`, + 'aria-controls': `vertical-tabpanel-${index}`, + }; +} + +interface IProps extends StoresProps {} + +function SandboxServiceTabs(props: IProps) { + const [value, setValue] = useState(0); + + const intl = useIntl(); + + const { stores, actions } = props; + + const { sandboxServices } = stores.app; + const { addSandboxService, editSandboxService, deleteSandboxService } = + actions.app; + + const handleChange = (event: SyntheticEvent, newValue: number) => { + debug('handleChange', event, newValue); + setValue(newValue); + }; + + const handleAddTab = () => { + addSandboxService(); + setValue(sandboxServices.length - 1); + }; + + return ( + + + + + + {sandboxServices?.map((tab, index) => ( + + ))} + + {sandboxServices?.map((tab, index) => ( + + + { + editSandboxService({ id: tab.id, name: e.target.value }); + }} + /> + { + deleteSandboxService({ id: tab.id }); + setValue(value ? value - 1 : 0); + }} + aria-label="delete" + color="error" + > + + + + + + ))} + + + ); +} + +export default inject('stores', 'actions')(observer(SandboxServiceTabs)); diff --git a/src/components/settings/SandboxTransferList.tsx b/src/components/settings/SandboxTransferList.tsx new file mode 100644 index 0000000000..e89f626421 --- /dev/null +++ b/src/components/settings/SandboxTransferList.tsx @@ -0,0 +1,216 @@ +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import Grid from '@mui/material/Grid'; +import List from '@mui/material/List'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +// import Paper from '@mui/material/Paper'; +import { inject, observer } from 'mobx-react'; +import { useState } from 'react'; +import type { StoresProps } from 'src/@types/ferdium-components.types'; + +function not(a: readonly string[], b: readonly string[]) { + return a.filter(value => !b.includes(value)); +} + +function intersection(a: readonly string[], b: readonly string[]) { + return a.filter(value => b.includes(value)); +} + +interface ISandboxTransferListProps extends StoresProps { + value: number; +} + +function SandboxTransferList(props: ISandboxTransferListProps) { + const { value, actions, stores } = props; + + const { editSandboxService } = actions.app; + + const { sandboxServices } = stores.app; + const { all: allServices } = stores.services; + + const selectedServices = sandboxServices[value].services; + + // Create a Set to keep track of unique not selected services + const notSelectedSet = new Set(); + + // Loop through all services and check if they are in any sandbox's selected services + allServices.forEach(service => { + let isSelected = false; + + sandboxServices.forEach(sandbox => { + if (sandbox.services.includes(service.id)) { + isSelected = true; + } + }); + + // If the service is not selected in any sandbox service, add it to the Set + if (!isSelected) { + notSelectedSet.add(service.id); + } + }); + + // Convert the Set to an array + const notSelected = [...notSelectedSet]; + + const [checked, setChecked] = useState([]); + const handleToggle = (value: string) => () => { + const currentIndex = checked.indexOf(value); + const newChecked = [...checked]; + + if (currentIndex === -1) { + newChecked.push(value); + } else { + newChecked.splice(currentIndex, 1); + } + + setChecked(newChecked); + }; + + const sandboxId = sandboxServices[value].id; + + const leftChecked = intersection(checked, selectedServices); + const rightChecked = intersection(checked, notSelected); + + const getServiceInfo = (id: string) => { + const service = allServices.find(s => s.id === id); + if (!service) { + return null; + } + return service; + }; + + const customList = (items: readonly string[]) => ( + // + + {items.map((value: string) => { + const labelId = `transfer-list-item-${value}-label`; + + return ( + + + + {getServiceInfo(value)?.name} + + + + ); + })} + + // + ); + + function handleAllRight() { + editSandboxService({ + id: sandboxId, + services: [], + }); + setChecked([]); + } + + function handleCheckedRight() { + editSandboxService({ + id: sandboxId, + services: not(selectedServices, leftChecked), + }); + setChecked(not(checked, leftChecked)); + } + + function handleCheckedLeft() { + editSandboxService({ + id: sandboxId, + services: [...selectedServices, ...rightChecked], + }); + setChecked(not(checked, rightChecked)); + } + + function handleAllLeft() { + editSandboxService({ + id: sandboxId, + services: [...selectedServices, ...notSelected], + }); + setChecked([]); + } + + return ( + + {customList(selectedServices)} + + + + + + + + + + {customList(notSelected)} + + ); +} + +export default inject('stores', 'actions')(observer(SandboxTransferList)); diff --git a/src/components/settings/settings/EditSettingsForm.tsx b/src/components/settings/settings/EditSettingsForm.tsx index 2900aa2af6..0a8d115379 100644 --- a/src/components/settings/settings/EditSettingsForm.tsx +++ b/src/components/settings/settings/EditSettingsForm.tsx @@ -44,6 +44,7 @@ import { H1, H2, H3, H5 } from '../../ui/headline'; import Icon from '../../ui/icon'; import Input from '../../ui/input/index'; import Toggle from '../../ui/toggle'; +import SandboxServiceTabs from '../SandboxServiceTabs'; const debug = require('../../../preload-safe-debug')( 'Ferdium:EditSettingsForm', @@ -294,6 +295,15 @@ const messages = defineMessages({ id: 'settings.app.buttonOpenFolderSelector', defaultMessage: 'Open folder selector', }, + sandboxServicesInfo: { + id: 'settings.app.sandboxServicesInfo', + defaultMessage: + 'By default, Ferdium sandboxes all services, meaning that each service runs in its own isolated environment (recommended). This is a security feature that prevents services from accessing each other’s data. You can create custom sandboxes to group services together by adding a custom sandbox - this way, services can share data between them if they are in the same sandbox. You can also disable sandboxing entirely for all services - allowing them to access each other’s data (not recommended).', + }, + sectionSandboxes: { + id: 'settings.app.sectionSandboxes', + defaultMessage: 'Sandboxes', + }, }); const Hr = (): ReactElement => ( @@ -437,6 +447,7 @@ class EditSettingsForm extends Component { scheduledDNDEnabled, reloadAfterResume, useSelfSignedCertificates, + sandboxServices, } = window['ferdium'].stores.settings.all.app; let cacheSize; @@ -739,6 +750,37 @@ class EditSettingsForm extends Component { + + + +

+ {intl.formatMessage(messages.sectionSandboxes)} + + beta + +

+

+ + {intl.formatMessage(messages.sandboxServicesInfo)} + +

+ + {/* @ts-expect-error */} + {sandboxServices && } +

+ {intl.formatMessage(messages.appRestartRequired)} +

)} diff --git a/src/config.ts b/src/config.ts index 0034b00a65..02ee1279bf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -434,6 +434,7 @@ export const DEFAULT_APP_SETTINGS = { locale: 'en-US', keepAllWorkspacesLoaded: false, useSelfSignedCertificates: false, + sandboxServices: true, }; export const DEFAULT_SERVICE_SETTINGS = { diff --git a/src/containers/layout/AppLayoutContainer.tsx b/src/containers/layout/AppLayoutContainer.tsx index b18aa46907..b6fe36a394 100644 --- a/src/containers/layout/AppLayoutContainer.tsx +++ b/src/containers/layout/AppLayoutContainer.tsx @@ -3,6 +3,10 @@ import { Component, type ReactElement } from 'react'; import { ThemeProvider } from 'react-jss'; import { Outlet } from 'react-router-dom'; +import { + ThemeProvider as MUIThemeProvider, + createTheme, +} from '@mui/material/styles'; import type { StoresProps } from '../../@types/ferdium-components.types'; import AppLayout from '../../components/layout/AppLayout'; import Sidebar from '../../components/layout/Sidebar'; @@ -47,6 +51,26 @@ class AppLayoutContainer extends Component { awake, } = this.props.actions.service; + // This is a workaround to fix theming on MUI components + const themeMUIDark = createTheme({ + palette: { + mode: 'dark', + primary: { + main: settings.app.accentColor, + }, + }, + }); + + const themeMUILight = createTheme({ + palette: { + mode: 'light', + primary: { + main: settings.app.accentColor, + }, + }, + }); + // --- + const { retryRequiredRequests } = this.props.actions.requests; const { installUpdate, toggleMuteApp, toggleCollapseMenu } = @@ -136,28 +160,35 @@ class AppLayoutContainer extends Component { ); return ( - - - - - + // TODO: Using 2 ThemeProviders is not ideal, but it's a workaround for now + + + + + + + ); } } diff --git a/src/containers/settings/EditSettingsScreen.tsx b/src/containers/settings/EditSettingsScreen.tsx index 31e8d64840..6bf14c05c6 100644 --- a/src/containers/settings/EditSettingsScreen.tsx +++ b/src/containers/settings/EditSettingsScreen.tsx @@ -367,6 +367,10 @@ const messages = defineMessages({ defaultMessage: 'You made a change that requires a restart. This will close Ferdium and restart it.', }, + sandboxServices: { + id: 'settings.app.form.sandboxServices', + defaultMessage: 'Use sandboxed services', + }, }); interface EditSettingsScreenProps extends StoresProps, WrappedComponentProps {} @@ -497,6 +501,7 @@ class EditSettingsScreen extends Component< beta: Boolean(settingsData.beta), // we need this info in the main process as well automaticUpdates: Boolean(settingsData.automaticUpdates), // we need this info in the main process as well locale: settingsData.locale, // we need this info in the main process as well + sandboxServices: Boolean(settingsData.sandboxServices), }; const requiredRestartKeys = [ @@ -1297,6 +1302,15 @@ class EditSettingsScreen extends Component< default: DEFAULT_APP_SETTINGS.automaticUpdates, type: 'checkbox', }, + sandboxServices: { + label: intl.formatMessage(messages.sandboxServices), + value: ifUndefined( + settings.app.sandboxServices, + DEFAULT_APP_SETTINGS.sandboxServices, + ), + default: DEFAULT_APP_SETTINGS.sandboxServices, + type: 'checkbox', + }, }, }; diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 9ea73cf2a7..a6a7b153ad 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -153,6 +153,7 @@ "password.link.signup": "Create a free account", "password.noUser": "No user with that email address was found", "password.successInfo": "Your new password was sent to your email address", + "sandbox.addCustomSandbox": "Add a custom sandbox", "service.crashHandler.action": "Reload {name}", "service.crashHandler.autoReload": "Trying to automatically restore {name} in {seconds} seconds", "service.crashHandler.headline": "Oh no!", @@ -251,6 +252,7 @@ "settings.app.form.reloadAfterResume": "Reload Ferdium after system resume", "settings.app.form.reloadAfterResumeTime": "Time to consider the system as idle/suspended (in minutes)", "settings.app.form.runInBackground": "Keep Ferdium in background when closing the window", + "settings.app.form.sandboxServices": "Use sandboxed services", "settings.app.form.scheduledDNDEnabled": "Enable scheduled Do-not-Disturb", "settings.app.form.scheduledDNDEnd": "To", "settings.app.form.scheduledDNDStart": "From", @@ -298,6 +300,7 @@ "settings.app.restart.restartLater": "Restart later", "settings.app.restart.restartNow": "Restart now", "settings.app.restartRequired": "Changes require restart", + "settings.app.sandboxServicesInfo": "By default, Ferdium sandboxes all services, meaning that each service runs in its own isolated environment (recommended). This is a security feature that prevents services from accessing each other’s data. You can create custom sandboxes to group services together by adding a custom sandbox - this way, services can share data between them if they are in the same sandbox. You can also disable sandboxing entirely for all services - allowing them to access each other’s data (not recommended).", "settings.app.scheduledDNDInfo": "Scheduled Do-not-Disturb allows you to define a period of time in which you do not want to get Notifications from Ferdium.", "settings.app.scheduledDNDTimeInfo": "Times in 24-Hour-Format. End time can be before start time (e.g. start 17:00, end 09:00) to enable Do-not-Disturb overnight.", "settings.app.sectionAccentColorSettings": "Accent Color Settings", @@ -307,6 +310,7 @@ "settings.app.sectionLanguage": "Language Settings", "settings.app.sectionMain": "Main", "settings.app.sectionPrivacy": "Privacy Settings", + "settings.app.sectionSandboxes": "Sandboxes", "settings.app.sectionServiceIconsSettings": "Service Icons Settings", "settings.app.sectionSidebarSettings": "Sidebar Settings", "settings.app.sectionUpdates": "App Updates Settings", diff --git a/src/models/Service.ts b/src/models/Service.ts index ae04b8b58d..6b664a4c70 100644 --- a/src/models/Service.ts +++ b/src/models/Service.ts @@ -17,6 +17,10 @@ import UserAgent from './UserAgent'; const debug = require('../preload-safe-debug')('Ferdium:Service'); +// Global registry for active partitions +// This is needed to prevent events of the same partition from being registered multiple times (when using custom sandboxes) +const activePartitions = new Set(); + interface DarkReaderInterface { brightness: number; contrast: number; @@ -537,6 +541,19 @@ export default class Service { }); if (webviewWebContents) { + // This is needed to prevent events of the same partition from being registered multiple times (when using custom sandboxes) + const webviewPartition = webviewWebContents.session.getStoragePath(); + if (webviewPartition) { + // Check if the partition is already active + if (activePartitions.has(webviewPartition)) { + return; + } + + // Add the partition to the active partitions + activePartitions.add(webviewPartition); + } + // ----- + // TODO: Modify this logic once https://github.com/electron/electron/issues/40674 is fixed // This is a workaround for the issue where the zoom in shortcut is not working if (!isMac) { diff --git a/src/stores/AppStore.ts b/src/stores/AppStore.ts index 51058c8ebd..b41094c021 100644 --- a/src/stores/AppStore.ts +++ b/src/stores/AppStore.ts @@ -9,10 +9,11 @@ import { } from '@electron/remote'; import AutoLaunch from 'auto-launch'; import { ipcRenderer } from 'electron'; -import { readJsonSync } from 'fs-extra'; +import { readJsonSync, readdirSync, writeJsonSync } from 'fs-extra'; import { action, computed, makeObservable, observable } from 'mobx'; import moment from 'moment'; import ms from 'ms'; +import { v4 as uuidV4 } from 'uuid'; import type { Stores } from '../@types/stores.types'; import type { Actions } from '../actions/lib/actions'; @@ -29,18 +30,17 @@ import { ferdiumVersion, userDataPath, } from '../environment-remote'; -import { getLocale } from '../helpers/i18n-helpers'; -import generatedTranslations from '../i18n/translations'; -import { cleanseJSObject } from '../jsUtils'; -import Request from './lib/Request'; -import TypedStore from './lib/TypedStore'; - import sleep from '../helpers/async-helpers'; +import { getLocale } from '../helpers/i18n-helpers'; import { getServiceIdsFromPartitions, removeServicePartitionDirectory, } from '../helpers/service-helpers'; import { openExternalUrl } from '../helpers/url-helpers'; +import generatedTranslations from '../i18n/translations'; +import { cleanseJSObject } from '../jsUtils'; +import Request from './lib/Request'; +import TypedStore from './lib/TypedStore'; const debug = require('../preload-safe-debug')('Ferdium:AppStore'); @@ -77,6 +77,12 @@ interface Download { endTime?: number; } +interface SandboxServices { + id: string; + name: string; + services: string[]; +} + export default class AppStore extends TypedStore { updateStatusTypes = { CHECKING: 'CHECKING', @@ -88,6 +94,8 @@ export default class AppStore extends TypedStore { @observable healthCheckRequest = new Request(this.api.app, 'health'); + @observable sandboxServices: SandboxServices[] = []; + @observable getAppCacheSizeRequest = new Request( this.api.local, 'getAppCacheSize', @@ -163,6 +171,16 @@ export default class AppStore extends TypedStore { this._togglePauseDownload.bind(this), ); + this.actions.app.addSandboxService.listen( + this._addSandboxService.bind(this), + ); + this.actions.app.editSandboxService.listen( + this._editSandboxService.bind(this), + ); + this.actions.app.deleteSandboxService.listen( + this._deleteSandboxService.bind(this), + ); + this.registerReactions([ this._offlineCheck.bind(this), this._setLocale.bind(this), @@ -334,6 +352,68 @@ export default class AppStore extends TypedStore { localStorage.setItem(CATALINA_NOTIFICATION_HACK_KEY, 'true'); } + + this._initializeSandboxes(); + } + + _initializeSandboxes() { + this._readSandboxes(); + + // Check partitions of the sandboxes that no longer exist + const dir = readdirSync(userDataPath('Partitions')); + dir + .filter(d => d.startsWith('sandbox-')) + .forEach(d => { + if ( + !this.sandboxServices.some(s => + s.id.includes(d.replace('sandbox-', '')), + ) + ) { + try { + removeServicePartitionDirectory(d); + } catch (error) { + console.error( + 'Error while checking service partition directory -', + error, + ); + } + } + }); + + // Check if services in sandboxes still exists, if so, remove their partitions (NOT WORKING!) + // this.sandboxServices.forEach(sandbox => { + // sandbox.services.forEach(serviceId => { + // try { + // removeServicePartitionDirectory(serviceId, true); + // } catch (error) { + // console.error( + // 'Error while checking service partition directory -', + // error, + // ); + // } + // }); + // }); + } + + _readSandboxes() { + this.sandboxServices = readJsonSync( + userDataPath('config', 'sandboxes.json'), + ); + } + + _writeSandboxes() { + // Check if services in sandboxes still exists, otherwise remove them + this.sandboxServices = this.sandboxServices.map(sandbox => ({ + ...sandbox, + services: sandbox.services.filter(serviceId => + this.stores.services.all.some(service => service.id === serviceId), + ), + })); + + writeJsonSync( + userDataPath('config', 'sandboxes.json'), + this.sandboxServices, + ); } @computed get cacheSize() { @@ -389,6 +469,10 @@ export default class AppStore extends TypedStore { }; } + @action getSandbox({ serviceId }) { + return this.sandboxServices.find(s => s.services.includes(serviceId)); + } + // Actions @action _notify({ title, options, notificationId, serviceId = null }) { if (this.stores.settings.all.app.isAppMuted) return; @@ -634,6 +718,32 @@ export default class AppStore extends TypedStore { }); } + @action _addSandboxService({ name = 'NEW SANDBOX' }) { + // Random ID + const id = uuidV4(); + + const sandboxService = { id, name, services: [] }; + + this.sandboxServices.push(sandboxService); + + this._writeSandboxes(); + return sandboxService; + } + + @action _editSandboxService({ id, name, services }) { + const sandboxService = this.sandboxServices.find(s => s.id === id); + if (sandboxService) { + sandboxService.name = name ?? sandboxService.name; + sandboxService.services = services ?? sandboxService.services; + this._writeSandboxes(); + } + } + + @action _deleteSandboxService({ id }) { + this.sandboxServices = this.sandboxServices.filter(s => s.id !== id); + this._writeSandboxes(); + } + _setLocale() { if (this.stores.user?.isLoggedIn && this.stores.user?.data.locale) { this._changeLocale(this.stores.user.data.locale);