aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2022-03-14 17:59:22 +0100
committerLibravatar Kristóf Marussy <kristof@marussy.com>2022-03-15 03:00:05 +0100
commitd2213e7eba2ec8b478c879397dc0de64d293f367 (patch)
tree5e32ece325fa11f13117b2c9e5966d7142826af4
parentfeat(renderer): Back and forward mouse buttons (diff)
downloadsophie-d2213e7eba2ec8b478c879397dc0de64d293f367.tar.gz
sophie-d2213e7eba2ec8b478c879397dc0de64d293f367.tar.zst
sophie-d2213e7eba2ec8b478c879397dc0de64d293f367.zip
feat: Temporary certificate acceptance backend
We use the 'certificate-error' event of webContents to detect certificate verification errors and display a message to manually trust the certificate. Certificates are trusted per profile and only until Sophie is restarted. We still need to build the associated UI, the current one is just a rough prototype for debugging. Signed-off-by: Kristóf Marussy <kristof@marussy.com>
-rw-r--r--packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts27
-rw-r--r--packages/main/src/stores/Profile.ts8
-rw-r--r--packages/main/src/stores/Service.ts178
-rw-r--r--packages/renderer/src/components/App.tsx64
-rw-r--r--packages/renderer/src/components/BrowserViewPlaceholder.tsx26
-rw-r--r--packages/renderer/src/stores/Service.ts84
-rw-r--r--packages/shared/src/index.ts6
-rw-r--r--packages/shared/src/schemas/ServiceAction.ts4
-rw-r--r--packages/shared/src/stores/Certificate.ts54
-rw-r--r--packages/shared/src/stores/Profile.ts21
-rw-r--r--packages/shared/src/stores/ServiceBase.ts47
-rw-r--r--packages/shared/src/stores/ServiceState.ts72
12 files changed, 434 insertions, 157 deletions
diff --git a/packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts b/packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts
index d90ff19..edcf758 100644
--- a/packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts
+++ b/packages/main/src/infrastructure/electron/impl/ElectronServiceView.ts
@@ -93,6 +93,33 @@ export default class ElectronServiceView implements ServiceView {
93 }, 93 },
94 ); 94 );
95 95
96 /**
97 * We use the `'certificate-error'` event instead of `session.setCertificateVerifyProc`
98 * because:
99 *
100 * 1. `'certificate-error'` is bound to the `webContents`, so we can display the certificate
101 * in the place of the correct service. Note that chromium still manages certificate trust
102 * per session, so we can't have different trusted certificates for each service of a
103 * profile.
104 * 2. The results of `'certificate-error'` are _not_ cached, so we can initially reject
105 * the certificate but we can still accept it once the user trusts it temporarily.
106 */
107 webContents.on(
108 'certificate-error',
109 (event, url, error, certificate, callback, isMainFrame) => {
110 if (service.isCertificateTemporarilyTrusted(certificate)) {
111 event.preventDefault();
112 callback(true);
113 return;
114 }
115 if (isMainFrame) {
116 setLocation(url);
117 service.setCertificateError(error, certificate);
118 }
119 callback(false);
120 },
121 );
122
96 webContents.on('page-title-updated', (_event, title) => { 123 webContents.on('page-title-updated', (_event, title) => {
97 service.setTitle(title); 124 service.setTitle(title);
98 }); 125 });
diff --git a/packages/main/src/stores/Profile.ts b/packages/main/src/stores/Profile.ts
index 836f4a8..405a5d4 100644
--- a/packages/main/src/stores/Profile.ts
+++ b/packages/main/src/stores/Profile.ts
@@ -18,8 +18,8 @@
18 * SPDX-License-Identifier: AGPL-3.0-only 18 * SPDX-License-Identifier: AGPL-3.0-only
19 */ 19 */
20 20
21import { Profile as ProfileBase } from '@sophie/shared'; 21import { Certificate, Profile as ProfileBase } from '@sophie/shared';
22import { getSnapshot, Instance } from 'mobx-state-tree'; 22import { clone, getSnapshot, Instance } from 'mobx-state-tree';
23 23
24import type ProfileConfig from './config/ProfileConfig'; 24import type ProfileConfig from './config/ProfileConfig';
25 25
@@ -28,6 +28,10 @@ const Profile = ProfileBase.views((self) => ({
28 const { id, settings } = self; 28 const { id, settings } = self;
29 return { ...getSnapshot(settings), id }; 29 return { ...getSnapshot(settings), id };
30 }, 30 },
31})).actions((self) => ({
32 temporarilyTrustCertificate(certificate: Certificate): void {
33 self.temporarilyTrustedCertificates.push(clone(certificate));
34 },
31})); 35}));
32 36
33/* 37/*
diff --git a/packages/main/src/stores/Service.ts b/packages/main/src/stores/Service.ts
index d98e52e..9b2bf1e 100644
--- a/packages/main/src/stores/Service.ts
+++ b/packages/main/src/stores/Service.ts
@@ -19,8 +19,13 @@
19 */ 19 */
20 20
21import type { UnreadCount } from '@sophie/service-shared'; 21import type { UnreadCount } from '@sophie/service-shared';
22import { defineServiceModel, ServiceAction } from '@sophie/shared'; 22import {
23import { Instance, getSnapshot } from 'mobx-state-tree'; 23 CertificateSnapshotIn,
24 defineServiceModel,
25 ServiceAction,
26 ServiceStateSnapshotIn,
27} from '@sophie/shared';
28import { Instance, getSnapshot, cast } from 'mobx-state-tree';
24 29
25import type { ServiceView } from '../infrastructure/electron/types'; 30import type { ServiceView } from '../infrastructure/electron/types';
26import { getLogger } from '../utils/log'; 31import { getLogger } from '../utils/log';
@@ -31,6 +36,13 @@ import type ServiceConfig from './config/ServiceConfig';
31const log = getLogger('Service'); 36const log = getLogger('Service');
32 37
33const Service = defineServiceModel(ServiceSettings) 38const Service = defineServiceModel(ServiceSettings)
39 .volatile(
40 (): {
41 serviceView: ServiceView | undefined;
42 } => ({
43 serviceView: undefined,
44 }),
45 )
34 .views((self) => ({ 46 .views((self) => ({
35 get config(): ServiceConfig { 47 get config(): ServiceConfig {
36 const { id, settings } = self; 48 const { id, settings } = self;
@@ -42,18 +54,16 @@ const Service = defineServiceModel(ServiceSettings)
42 get shouldBeLoaded(): boolean { 54 get shouldBeLoaded(): boolean {
43 return !self.crashed; 55 return !self.crashed;
44 }, 56 },
57 }))
58 .views((self) => ({
45 get shouldBeVisible(): boolean { 59 get shouldBeVisible(): boolean {
46 return this.shouldBeLoaded && !self.failed; 60 return self.shouldBeLoaded && !self.hasError;
47 }, 61 },
48 })) 62 }))
49 .volatile(
50 (): {
51 serviceView: ServiceView | undefined;
52 } => ({
53 serviceView: undefined,
54 }),
55 )
56 .actions((self) => ({ 63 .actions((self) => ({
64 setServiceView(serviceView: ServiceView | undefined): void {
65 self.serviceView = serviceView;
66 },
57 setLocation({ 67 setLocation({
58 url, 68 url,
59 canGoBack, 69 canGoBack,
@@ -70,21 +80,6 @@ const Service = defineServiceModel(ServiceSettings)
70 setTitle(title: string): void { 80 setTitle(title: string): void {
71 self.title = title; 81 self.title = title;
72 }, 82 },
73 startLoading(): void {
74 self.state = { type: 'loading' };
75 },
76 finishLoading(): void {
77 if (self.loading) {
78 // Do not overwrite crashed state if the service haven't been reloaded yet.
79 self.state = { type: 'loaded' };
80 }
81 },
82 setFailed(errorCode: number, errorDesc: string): void {
83 self.state = { type: 'failed', errorCode, errorDesc };
84 },
85 setCrashed(reason: string, exitCode: number): void {
86 self.state = { type: 'crashed', reason, exitCode };
87 },
88 setUnreadCount({ direct, indirect }: UnreadCount): void { 83 setUnreadCount({ direct, indirect }: UnreadCount): void {
89 if (direct !== undefined) { 84 if (direct !== undefined) {
90 self.directMessageCount = direct; 85 self.directMessageCount = direct;
@@ -93,55 +88,124 @@ const Service = defineServiceModel(ServiceSettings)
93 self.indirectMessageCount = indirect; 88 self.indirectMessageCount = indirect;
94 } 89 }
95 }, 90 },
96 setServiceView(serviceView: ServiceView | undefined): void { 91 }))
97 self.serviceView = serviceView; 92 .actions((self) => {
98 }, 93 function setState(state: ServiceStateSnapshotIn): void {
99 goBack(): void { 94 self.state = cast(state);
100 self.serviceView?.goBack(); 95 }
101 }, 96
102 goForward(): void { 97 return {
103 self.serviceView?.goForward(); 98 startLoading(): void {
99 setState({ type: 'loading' });
100 },
101 finishLoading(): void {
102 if (self.loading) {
103 // Do not overwrite any error state state if the service haven't been reloaded yet.
104 setState({ type: 'loaded' });
105 }
106 },
107 setFailed(errorCode: number, errorDesc: string): void {
108 if (!self.hasError) {
109 setState({
110 type: 'failed',
111 errorCode,
112 errorDesc,
113 });
114 }
115 },
116 setCertificateError(
117 errorCode: string,
118 certificate: CertificateSnapshotIn,
119 ): void {
120 if (!self.crashed && self.state.type !== 'certificateError') {
121 setState({
122 type: 'certificateError',
123 errorCode,
124 certificate,
125 });
126 }
127 },
128 setCrashed(reason: string, exitCode: number): void {
129 if (!self.crashed) {
130 setState({
131 type: 'crashed',
132 reason,
133 exitCode,
134 });
135 }
136 },
137 goBack(): void {
138 self.serviceView?.goBack();
139 },
140 goForward(): void {
141 self.serviceView?.goForward();
142 },
143 reload(ignoreCache = false): void {
144 if (self.serviceView === undefined) {
145 setState({ type: 'initializing' });
146 } else {
147 self.serviceView?.reload(ignoreCache);
148 }
149 },
150 stop(): void {
151 self.serviceView?.stop();
152 },
153 go(url: string): void {
154 if (self.serviceView === undefined) {
155 self.currentUrl = url;
156 setState({ type: 'initializing' });
157 } else {
158 self.serviceView?.loadURL(url);
159 }
160 },
161 };
162 })
163 .actions((self) => ({
164 goHome(): void {
165 self.go(self.settings.url);
104 }, 166 },
105 reload(ignoreCache = false): void { 167 temporarilyTrustCurrentCertificate(fingerprint: string): void {
106 if (self.serviceView === undefined) { 168 if (self.state.type !== 'certificateError') {
107 self.state = { type: 'initializing' }; 169 log.error('Tried to trust certificate without any certificate error');
108 } else { 170 return;
109 self.serviceView?.reload(ignoreCache);
110 } 171 }
111 }, 172 if (self.state.certificate.fingerprint !== fingerprint) {
112 stop(): void { 173 log.error(
113 self.serviceView?.stop(); 174 'Tried to trust certificate',
114 }, 175 fingerprint,
115 go(url: string): void { 176 'but the currently pending fingerprint is',
116 if (self.serviceView === undefined) { 177 self.state.certificate.fingerprint,
117 self.currentUrl = url; 178 );
118 self.state = { type: 'initializing' }; 179 return;
119 } else {
120 self.serviceView?.loadURL(url);
121 } 180 }
181 self.settings.profile.temporarilyTrustCertificate(self.state.certificate);
182 self.state.trust = 'accepted';
183 self.reload();
122 }, 184 },
123 goHome(): void { 185 }))
124 this.go(self.settings.url); 186 .actions((self) => ({
125 },
126 dispatch(action: ServiceAction): void { 187 dispatch(action: ServiceAction): void {
127 switch (action.action) { 188 switch (action.action) {
128 case 'back': 189 case 'back':
129 this.goBack(); 190 self.goBack();
130 break; 191 break;
131 case 'forward': 192 case 'forward':
132 this.goForward(); 193 self.goForward();
133 break; 194 break;
134 case 'reload': 195 case 'reload':
135 this.reload(action.ignoreCache); 196 self.reload(action.ignoreCache);
136 break; 197 break;
137 case 'stop': 198 case 'stop':
138 this.stop(); 199 self.stop();
139 break; 200 break;
140 case 'go-home': 201 case 'go-home':
141 this.goHome(); 202 self.goHome();
142 break; 203 break;
143 case 'go': 204 case 'go':
144 this.go(action.url); 205 self.go(action.url);
206 break;
207 case 'temporarily-trust-current-certificate':
208 self.temporarilyTrustCurrentCertificate(action.fingerprint);
145 break; 209 break;
146 default: 210 default:
147 log.error('Unknown action to dispatch', action); 211 log.error('Unknown action to dispatch', action);
diff --git a/packages/renderer/src/components/App.tsx b/packages/renderer/src/components/App.tsx
index af2e4ec..b647a80 100644
--- a/packages/renderer/src/components/App.tsx
+++ b/packages/renderer/src/components/App.tsx
@@ -19,38 +19,46 @@
19 */ 19 */
20 20
21import Box from '@mui/material/Box'; 21import Box from '@mui/material/Box';
22import React from 'react'; 22import Button from '@mui/material/Button';
23import { observer } from 'mobx-react-lite';
24import React, { useCallback } from 'react';
23 25
24import BrowserViewPlaceholder from './BrowserViewPlaceholder'; 26import BrowserViewPlaceholder from './BrowserViewPlaceholder';
25import { useStore } from './StoreProvider'; 27import { useStore } from './StoreProvider';
26import LocationBar from './locationBar/LocationBar'; 28import LocationBar from './locationBar/LocationBar';
27import Sidebar from './sidebar/Sidebar'; 29import Sidebar from './sidebar/Sidebar';
28 30
29export default function App(): JSX.Element { 31function App(): JSX.Element {
30 const store = useStore(); 32 const {
33 settings: { selectedService },
34 } = useStore();
31 35
32 function onClick(event: React.MouseEvent<HTMLDivElement, MouseEvent>): void { 36 const handleBackForwardMouseButtons = useCallback(
33 switch (event.button) { 37 (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
34 case 3: 38 switch (event.button) {
35 store.settings.selectedService?.goBack(); 39 case 3:
36 break; 40 selectedService?.goBack();
37 case 4: 41 break;
38 store.settings.selectedService?.goForward(); 42 case 4:
39 break; 43 selectedService?.goForward();
40 default: 44 break;
41 // Allow the event to propagate. 45 default:
42 return; 46 // Allow the event to propagate.
43 } 47 return;
44 event.preventDefault(); 48 }
45 event.stopPropagation(); 49 event.preventDefault();
46 } 50 event.stopPropagation();
51 },
52 [selectedService],
53 );
47 54
48 return ( 55 return (
49 <Box 56 <Box
50 onClick={(event) => onClick(event)} 57 onClick={handleBackForwardMouseButtons}
51 onAuxClick={(event) => onClick(event)} 58 onAuxClick={handleBackForwardMouseButtons}
52 sx={{ 59 sx={{
53 display: 'flex', 60 display: 'flex',
61 overflow: 'hidden',
54 flexDirection: 'row', 62 flexDirection: 'row',
55 alignItems: 'stretch', 63 alignItems: 'stretch',
56 height: '100vh', 64 height: '100vh',
@@ -69,8 +77,22 @@ export default function App(): JSX.Element {
69 }} 77 }}
70 > 78 >
71 <LocationBar /> 79 <LocationBar />
72 <BrowserViewPlaceholder /> 80 <BrowserViewPlaceholder>
81 <p>{JSON.stringify(selectedService?.state)}</p>
82 {selectedService?.state.type === 'certificateError' && (
83 <Button
84 disabled={selectedService.state.trust !== 'pending'}
85 onClick={() =>
86 selectedService?.temporarilyTrustCurrentCertificate()
87 }
88 >
89 Trust certificate
90 </Button>
91 )}
92 </BrowserViewPlaceholder>
73 </Box> 93 </Box>
74 </Box> 94 </Box>
75 ); 95 );
76} 96}
97
98export default observer(App);
diff --git a/packages/renderer/src/components/BrowserViewPlaceholder.tsx b/packages/renderer/src/components/BrowserViewPlaceholder.tsx
index c07ed15..1f5f9f4 100644
--- a/packages/renderer/src/components/BrowserViewPlaceholder.tsx
+++ b/packages/renderer/src/components/BrowserViewPlaceholder.tsx
@@ -20,12 +20,15 @@
20 20
21import Box from '@mui/material/Box'; 21import Box from '@mui/material/Box';
22import throttle from 'lodash-es/throttle'; 22import throttle from 'lodash-es/throttle';
23import { observer } from 'mobx-react-lite'; 23import React, { ReactNode, useCallback, useRef } from 'react';
24import React, { useCallback, useRef } from 'react';
25 24
26import { useStore } from './StoreProvider'; 25import { useStore } from './StoreProvider';
27 26
28export default observer(() => { 27function BrowserViewPlaceholder({
28 children,
29}: {
30 children?: ReactNode;
31}): JSX.Element {
29 const store = useStore(); 32 const store = useStore();
30 33
31 // eslint-disable-next-line react-hooks/exhaustive-deps -- react-hooks doesn't support `throttle`. 34 // eslint-disable-next-line react-hooks/exhaustive-deps -- react-hooks doesn't support `throttle`.
@@ -62,11 +65,14 @@ export default observer(() => {
62 ); 65 );
63 66
64 return ( 67 return (
65 <Box 68 <Box flex={1} ref={ref}>
66 sx={{ 69 {children}
67 flex: 1, 70 </Box>
68 }}
69 ref={ref}
70 />
71 ); 71 );
72}); 72}
73
74BrowserViewPlaceholder.defaultProps = {
75 children: undefined,
76};
77
78export default BrowserViewPlaceholder;
diff --git a/packages/renderer/src/stores/Service.ts b/packages/renderer/src/stores/Service.ts
index 7878ea0..695cff4 100644
--- a/packages/renderer/src/stores/Service.ts
+++ b/packages/renderer/src/stores/Service.ts
@@ -25,47 +25,59 @@ import getEnv from '../env/getEnv';
25 25
26import ServiceSettings from './ServiceSettings'; 26import ServiceSettings from './ServiceSettings';
27 27
28const Service = defineServiceModel(ServiceSettings).actions((self) => ({ 28const Service = defineServiceModel(ServiceSettings).actions((self) => {
29 dispatch(serviceAction: ServiceAction): void { 29 function dispatch(serviceAction: ServiceAction): void {
30 getEnv(self).dispatchMainAction({ 30 getEnv(self).dispatchMainAction({
31 action: 'dispatch-service-action', 31 action: 'dispatch-service-action',
32 serviceId: self.id, 32 serviceId: self.id,
33 serviceAction, 33 serviceAction,
34 }); 34 });
35 }, 35 }
36 goBack(): void { 36
37 this.dispatch({ 37 return {
38 action: 'back', 38 goBack(): void {
39 }); 39 dispatch({
40 }, 40 action: 'back',
41 goForward(): void { 41 });
42 this.dispatch({ 42 },
43 action: 'forward', 43 goForward(): void {
44 }); 44 dispatch({
45 }, 45 action: 'forward',
46 reload(ignoreCache = false): void { 46 });
47 this.dispatch({ 47 },
48 action: 'reload', 48 reload(ignoreCache = false): void {
49 ignoreCache, 49 dispatch({
50 }); 50 action: 'reload',
51 }, 51 ignoreCache,
52 stop(): void { 52 });
53 this.dispatch({ 53 },
54 action: 'stop', 54 stop(): void {
55 }); 55 dispatch({
56 }, 56 action: 'stop',
57 go(url: string): void { 57 });
58 this.dispatch({ 58 },
59 action: 'go', 59 go(url: string): void {
60 url, 60 dispatch({
61 }); 61 action: 'go',
62 }, 62 url,
63 goHome(): void { 63 });
64 this.dispatch({ 64 },
65 action: 'go-home', 65 goHome(): void {
66 }); 66 dispatch({
67 }, 67 action: 'go-home',
68})); 68 });
69 },
70 temporarilyTrustCurrentCertificate(): void {
71 if (self.state.type !== 'certificateError') {
72 throw new Error('No certificate to accept');
73 }
74 dispatch({
75 action: 'temporarily-trust-current-certificate',
76 fingerprint: self.state.certificate.fingerprint,
77 });
78 },
79 };
80});
69 81
70/* 82/*
71 eslint-disable-next-line @typescript-eslint/no-redeclare -- 83 eslint-disable-next-line @typescript-eslint/no-redeclare --
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index fa3fbfd..f7c5bcf 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -30,6 +30,9 @@ export { ServiceAction } from './schemas/ServiceAction';
30 30
31export { ThemeSource } from './schemas/ThemeSource'; 31export { ThemeSource } from './schemas/ThemeSource';
32 32
33export type { CertificateSnapshotIn } from './stores/Certificate';
34export { default as Certificate } from './stores/Certificate';
35
33export type { 36export type {
34 GlobalSettingsSnapshotIn, 37 GlobalSettingsSnapshotIn,
35 GlobalSettingsSnapshotOut, 38 GlobalSettingsSnapshotOut,
@@ -61,6 +64,9 @@ export {
61 defineServiceSettingsModel, 64 defineServiceSettingsModel,
62} from './stores/ServiceSettingsBase'; 65} from './stores/ServiceSettingsBase';
63 66
67export type { ServiceStateSnapshotIn } from './stores/ServiceState';
68export { default as ServiceState } from './stores/ServiceState';
69
64export type { 70export type {
65 SharedStoreListener, 71 SharedStoreListener,
66 SharedStoreSnapshotIn, 72 SharedStoreSnapshotIn,
diff --git a/packages/shared/src/schemas/ServiceAction.ts b/packages/shared/src/schemas/ServiceAction.ts
index a4a7049..8961bfe 100644
--- a/packages/shared/src/schemas/ServiceAction.ts
+++ b/packages/shared/src/schemas/ServiceAction.ts
@@ -42,6 +42,10 @@ export const ServiceAction = /* @__PURE__ */ (() =>
42 action: z.literal('go'), 42 action: z.literal('go'),
43 url: z.string(), 43 url: z.string(),
44 }), 44 }),
45 z.object({
46 action: z.literal('temporarily-trust-current-certificate'),
47 fingerprint: z.string(),
48 }),
45 ]))(); 49 ]))();
46 50
47/* 51/*
diff --git a/packages/shared/src/stores/Certificate.ts b/packages/shared/src/stores/Certificate.ts
new file mode 100644
index 0000000..8b2d007
--- /dev/null
+++ b/packages/shared/src/stores/Certificate.ts
@@ -0,0 +1,54 @@
1/*
2 * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com>
3 *
4 * This file is part of Sophie.
5 *
6 * Sophie is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU Affero General Public License as
8 * published by the Free Software Foundation, version 3.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU Affero General Public License for more details.
14 *
15 * You should have received a copy of the GNU Affero General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 *
18 * SPDX-License-Identifier: AGPL-3.0-only
19 */
20
21import { IAnyModelType, Instance, SnapshotIn, types } from 'mobx-state-tree';
22
23const CertificatePrincipal = /* @__PURE__ */ (() =>
24 types.model('CertificatePrincipal', {
25 commonName: types.string,
26 organizations: types.array(types.string),
27 organizationUnits: types.array(types.string),
28 locality: types.string,
29 state: types.string,
30 country: types.string,
31 }))();
32
33const Certificate = /* @__PURE__ */ (() =>
34 types.model('Certificate', {
35 data: types.string,
36 issuer: CertificatePrincipal,
37 issuerName: types.string,
38 issuerCert: types.maybe(types.late((): IAnyModelType => Certificate)),
39 subjectName: types.string,
40 serialNumber: types.string,
41 validStart: types.number,
42 validExpiry: types.number,
43 fingerprint: types.string,
44 }))();
45
46/*
47 eslint-disable-next-line @typescript-eslint/no-redeclare --
48 Intentionally naming the type the same as the store definition.
49*/
50interface Certificate extends Instance<typeof Certificate> {}
51
52export default Certificate;
53
54export interface CertificateSnapshotIn extends SnapshotIn<typeof Certificate> {}
diff --git a/packages/shared/src/stores/Profile.ts b/packages/shared/src/stores/Profile.ts
index 49c5195..611ca6f 100644
--- a/packages/shared/src/stores/Profile.ts
+++ b/packages/shared/src/stores/Profile.ts
@@ -20,13 +20,26 @@
20 20
21import { Instance, types } from 'mobx-state-tree'; 21import { Instance, types } from 'mobx-state-tree';
22 22
23import Certificate, { CertificateSnapshotIn } from './Certificate';
23import ProfileSettings from './ProfileSettings'; 24import ProfileSettings from './ProfileSettings';
24 25
25const Profile = /* @__PURE__ */ (() => 26const Profile = /* @__PURE__ */ (() =>
26 types.model('Profile', { 27 types
27 id: types.identifier, 28 .model('Profile', {
28 settings: ProfileSettings, 29 id: types.identifier,
29 }))(); 30 settings: ProfileSettings,
31 temporarilyTrustedCertificates: types.array(Certificate),
32 })
33 .views((self) => ({
34 isCertificateTemporarilyTrusted(
35 certificate: CertificateSnapshotIn,
36 ): boolean {
37 return self.temporarilyTrustedCertificates.some(
38 (trustedCertificate) =>
39 trustedCertificate.fingerprint === certificate.fingerprint,
40 );
41 },
42 })))();
30 43
31/* 44/*
32 eslint-disable-next-line @typescript-eslint/no-redeclare -- 45 eslint-disable-next-line @typescript-eslint/no-redeclare --
diff --git a/packages/shared/src/stores/ServiceBase.ts b/packages/shared/src/stores/ServiceBase.ts
index 4a17bc5..c69f339 100644
--- a/packages/shared/src/stores/ServiceBase.ts
+++ b/packages/shared/src/stores/ServiceBase.ts
@@ -20,7 +20,10 @@
20 20
21import { IAnyModelType, Instance, types } from 'mobx-state-tree'; 21import { IAnyModelType, Instance, types } from 'mobx-state-tree';
22 22
23import type { CertificateSnapshotIn } from './Certificate';
24import type Profile from './Profile';
23import ServiceSettingsBase from './ServiceSettingsBase'; 25import ServiceSettingsBase from './ServiceSettingsBase';
26import ServiceState from './ServiceState';
24 27
25export function defineServiceModel<TS extends IAnyModelType>(settings: TS) { 28export function defineServiceModel<TS extends IAnyModelType>(settings: TS) {
26 return types 29 return types
@@ -31,30 +34,7 @@ export function defineServiceModel<TS extends IAnyModelType>(settings: TS) {
31 canGoBack: false, 34 canGoBack: false,
32 canGoForward: false, 35 canGoForward: false,
33 title: types.maybe(types.string), 36 title: types.maybe(types.string),
34 state: types.optional( 37 state: ServiceState,
35 types.union(
36 types.model({
37 type: types.literal('initializing'),
38 }),
39 types.model({
40 type: types.literal('loading'),
41 }),
42 types.model({
43 type: types.literal('loaded'),
44 }),
45 types.model({
46 type: types.literal('failed'),
47 errorCode: types.integer,
48 errorDesc: types.string,
49 }),
50 types.model({
51 type: types.literal('crashed'),
52 reason: types.string,
53 exitCode: types.integer,
54 }),
55 ),
56 { type: 'initializing' },
57 ),
58 directMessageCount: 0, 38 directMessageCount: 0,
59 indirectMessageCount: 0, 39 indirectMessageCount: 0,
60 }) 40 })
@@ -64,12 +44,25 @@ export function defineServiceModel<TS extends IAnyModelType>(settings: TS) {
64 self.state.type === 'initializing' || self.state.type === 'loading' 44 self.state.type === 'initializing' || self.state.type === 'loading'
65 ); 45 );
66 }, 46 },
67 get failed(): boolean {
68 return self.state.type === 'failed';
69 },
70 get crashed(): boolean { 47 get crashed(): boolean {
71 return self.state.type === 'crashed'; 48 return self.state.type === 'crashed';
72 }, 49 },
50 }))
51 .views((self) => ({
52 get hasError(): boolean {
53 return (
54 self.crashed ||
55 self.state.type === 'failed' ||
56 self.state.type === 'certificateError'
57 );
58 },
59 isCertificateTemporarilyTrusted(
60 certificate: CertificateSnapshotIn,
61 ): boolean {
62 return (
63 self.settings.profile as Profile
64 ).isCertificateTemporarilyTrusted(certificate);
65 },
73 })); 66 }));
74} 67}
75 68
diff --git a/packages/shared/src/stores/ServiceState.ts b/packages/shared/src/stores/ServiceState.ts
new file mode 100644
index 0000000..ad49321
--- /dev/null
+++ b/packages/shared/src/stores/ServiceState.ts
@@ -0,0 +1,72 @@
1/*
2 * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com>
3 *
4 * This file is part of Sophie.
5 *
6 * Sophie is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU Affero General Public License as
8 * published by the Free Software Foundation, version 3.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU Affero General Public License for more details.
14 *
15 * You should have received a copy of the GNU Affero General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 *
18 * SPDX-License-Identifier: AGPL-3.0-only
19 */
20
21import { Instance, SnapshotIn, types } from 'mobx-state-tree';
22
23import Certificate from './Certificate';
24
25const ServiceState = /* @__PURE__ */ (() =>
26 types.optional(
27 types.union(
28 types.model('ServiceInitializingState', {
29 type: types.literal('initializing'),
30 }),
31 types.model('ServiceLoadingState', {
32 type: types.literal('loading'),
33 }),
34 types.model('ServiceLoadedState', {
35 type: types.literal('loaded'),
36 }),
37 types.model('ServiceFailedState', {
38 type: types.literal('failed'),
39 errorCode: types.integer,
40 errorDesc: types.string,
41 }),
42 types.model('ServiceCertificateErrorState', {
43 type: types.literal('certificateError'),
44 errorCode: types.string,
45 certificate: Certificate,
46 trust: types.optional(
47 types.union(
48 types.literal('pending'),
49 types.literal('rejected'),
50 types.literal('accepted'),
51 ),
52 'pending',
53 ),
54 }),
55 types.model({
56 type: types.literal('crashed'),
57 reason: types.string,
58 exitCode: types.integer,
59 }),
60 ),
61 { type: 'initializing' },
62 ))();
63
64/*
65 eslint-disable-next-line @typescript-eslint/no-redeclare --
66 Intentionally naming the type the same as the store definition.
67*/
68type ServiceState = Instance<typeof ServiceState>;
69
70export default ServiceState;
71
72export type ServiceStateSnapshotIn = SnapshotIn<typeof ServiceState>;