diff options
author | Kristóf Marussy <kristof@marussy.com> | 2022-03-14 17:59:22 +0100 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2022-03-15 03:00:05 +0100 |
commit | d2213e7eba2ec8b478c879397dc0de64d293f367 (patch) | |
tree | 5e32ece325fa11f13117b2c9e5966d7142826af4 | |
parent | feat(renderer): Back and forward mouse buttons (diff) | |
download | sophie-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.ts | 27 | ||||
-rw-r--r-- | packages/main/src/stores/Profile.ts | 8 | ||||
-rw-r--r-- | packages/main/src/stores/Service.ts | 178 | ||||
-rw-r--r-- | packages/renderer/src/components/App.tsx | 64 | ||||
-rw-r--r-- | packages/renderer/src/components/BrowserViewPlaceholder.tsx | 26 | ||||
-rw-r--r-- | packages/renderer/src/stores/Service.ts | 84 | ||||
-rw-r--r-- | packages/shared/src/index.ts | 6 | ||||
-rw-r--r-- | packages/shared/src/schemas/ServiceAction.ts | 4 | ||||
-rw-r--r-- | packages/shared/src/stores/Certificate.ts | 54 | ||||
-rw-r--r-- | packages/shared/src/stores/Profile.ts | 21 | ||||
-rw-r--r-- | packages/shared/src/stores/ServiceBase.ts | 47 | ||||
-rw-r--r-- | packages/shared/src/stores/ServiceState.ts | 72 |
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 | ||
21 | import { Profile as ProfileBase } from '@sophie/shared'; | 21 | import { Certificate, Profile as ProfileBase } from '@sophie/shared'; |
22 | import { getSnapshot, Instance } from 'mobx-state-tree'; | 22 | import { clone, getSnapshot, Instance } from 'mobx-state-tree'; |
23 | 23 | ||
24 | import type ProfileConfig from './config/ProfileConfig'; | 24 | import 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 | ||
21 | import type { UnreadCount } from '@sophie/service-shared'; | 21 | import type { UnreadCount } from '@sophie/service-shared'; |
22 | import { defineServiceModel, ServiceAction } from '@sophie/shared'; | 22 | import { |
23 | import { Instance, getSnapshot } from 'mobx-state-tree'; | 23 | CertificateSnapshotIn, |
24 | defineServiceModel, | ||
25 | ServiceAction, | ||
26 | ServiceStateSnapshotIn, | ||
27 | } from '@sophie/shared'; | ||
28 | import { Instance, getSnapshot, cast } from 'mobx-state-tree'; | ||
24 | 29 | ||
25 | import type { ServiceView } from '../infrastructure/electron/types'; | 30 | import type { ServiceView } from '../infrastructure/electron/types'; |
26 | import { getLogger } from '../utils/log'; | 31 | import { getLogger } from '../utils/log'; |
@@ -31,6 +36,13 @@ import type ServiceConfig from './config/ServiceConfig'; | |||
31 | const log = getLogger('Service'); | 36 | const log = getLogger('Service'); |
32 | 37 | ||
33 | const Service = defineServiceModel(ServiceSettings) | 38 | const 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 | ||
21 | import Box from '@mui/material/Box'; | 21 | import Box from '@mui/material/Box'; |
22 | import React from 'react'; | 22 | import Button from '@mui/material/Button'; |
23 | import { observer } from 'mobx-react-lite'; | ||
24 | import React, { useCallback } from 'react'; | ||
23 | 25 | ||
24 | import BrowserViewPlaceholder from './BrowserViewPlaceholder'; | 26 | import BrowserViewPlaceholder from './BrowserViewPlaceholder'; |
25 | import { useStore } from './StoreProvider'; | 27 | import { useStore } from './StoreProvider'; |
26 | import LocationBar from './locationBar/LocationBar'; | 28 | import LocationBar from './locationBar/LocationBar'; |
27 | import Sidebar from './sidebar/Sidebar'; | 29 | import Sidebar from './sidebar/Sidebar'; |
28 | 30 | ||
29 | export default function App(): JSX.Element { | 31 | function 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 | |||
98 | export 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 | ||
21 | import Box from '@mui/material/Box'; | 21 | import Box from '@mui/material/Box'; |
22 | import throttle from 'lodash-es/throttle'; | 22 | import throttle from 'lodash-es/throttle'; |
23 | import { observer } from 'mobx-react-lite'; | 23 | import React, { ReactNode, useCallback, useRef } from 'react'; |
24 | import React, { useCallback, useRef } from 'react'; | ||
25 | 24 | ||
26 | import { useStore } from './StoreProvider'; | 25 | import { useStore } from './StoreProvider'; |
27 | 26 | ||
28 | export default observer(() => { | 27 | function 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 | |||
74 | BrowserViewPlaceholder.defaultProps = { | ||
75 | children: undefined, | ||
76 | }; | ||
77 | |||
78 | export 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 | ||
26 | import ServiceSettings from './ServiceSettings'; | 26 | import ServiceSettings from './ServiceSettings'; |
27 | 27 | ||
28 | const Service = defineServiceModel(ServiceSettings).actions((self) => ({ | 28 | const 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 | ||
31 | export { ThemeSource } from './schemas/ThemeSource'; | 31 | export { ThemeSource } from './schemas/ThemeSource'; |
32 | 32 | ||
33 | export type { CertificateSnapshotIn } from './stores/Certificate'; | ||
34 | export { default as Certificate } from './stores/Certificate'; | ||
35 | |||
33 | export type { | 36 | export 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 | ||
67 | export type { ServiceStateSnapshotIn } from './stores/ServiceState'; | ||
68 | export { default as ServiceState } from './stores/ServiceState'; | ||
69 | |||
64 | export type { | 70 | export 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 | |||
21 | import { IAnyModelType, Instance, SnapshotIn, types } from 'mobx-state-tree'; | ||
22 | |||
23 | const 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 | |||
33 | const 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 | */ | ||
50 | interface Certificate extends Instance<typeof Certificate> {} | ||
51 | |||
52 | export default Certificate; | ||
53 | |||
54 | export 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 | ||
21 | import { Instance, types } from 'mobx-state-tree'; | 21 | import { Instance, types } from 'mobx-state-tree'; |
22 | 22 | ||
23 | import Certificate, { CertificateSnapshotIn } from './Certificate'; | ||
23 | import ProfileSettings from './ProfileSettings'; | 24 | import ProfileSettings from './ProfileSettings'; |
24 | 25 | ||
25 | const Profile = /* @__PURE__ */ (() => | 26 | const 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 | ||
21 | import { IAnyModelType, Instance, types } from 'mobx-state-tree'; | 21 | import { IAnyModelType, Instance, types } from 'mobx-state-tree'; |
22 | 22 | ||
23 | import type { CertificateSnapshotIn } from './Certificate'; | ||
24 | import type Profile from './Profile'; | ||
23 | import ServiceSettingsBase from './ServiceSettingsBase'; | 25 | import ServiceSettingsBase from './ServiceSettingsBase'; |
26 | import ServiceState from './ServiceState'; | ||
24 | 27 | ||
25 | export function defineServiceModel<TS extends IAnyModelType>(settings: TS) { | 28 | export 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 | |||
21 | import { Instance, SnapshotIn, types } from 'mobx-state-tree'; | ||
22 | |||
23 | import Certificate from './Certificate'; | ||
24 | |||
25 | const 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 | */ | ||
68 | type ServiceState = Instance<typeof ServiceState>; | ||
69 | |||
70 | export default ServiceState; | ||
71 | |||
72 | export type ServiceStateSnapshotIn = SnapshotIn<typeof ServiceState>; | ||