diff options
-rw-r--r-- | packages/main/src/index.ts | 84 | ||||
-rw-r--r-- | packages/service-inject/src/index.ts | 8 | ||||
-rw-r--r-- | packages/service-inject/src/shims/userAgentData.ts | 103 | ||||
-rw-r--r-- | packages/service-inject/src/utils.ts | 104 | ||||
-rw-r--r-- | packages/service-preload/src/index.ts | 21 | ||||
-rw-r--r-- | packages/service-shared/src/index.ts | 2 | ||||
-rw-r--r-- | packages/service-shared/src/schemas.ts | 7 |
7 files changed, 59 insertions, 270 deletions
diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts index 02d6c97..0c0a585 100644 --- a/packages/main/src/index.ts +++ b/packages/main/src/index.ts | |||
@@ -22,7 +22,7 @@ import { | |||
22 | app, | 22 | app, |
23 | BrowserView, | 23 | BrowserView, |
24 | BrowserWindow, | 24 | BrowserWindow, |
25 | IpcMainEvent, | 25 | ipcMain, |
26 | } from 'electron'; | 26 | } from 'electron'; |
27 | import { readFile, readFileSync } from 'fs'; | 27 | import { readFile, readFileSync } from 'fs'; |
28 | import { autorun } from 'mobx'; | 28 | import { autorun } from 'mobx'; |
@@ -31,6 +31,7 @@ import { join } from 'path'; | |||
31 | import { | 31 | import { |
32 | ServiceToMainIpcMessage, | 32 | ServiceToMainIpcMessage, |
33 | unreadCount, | 33 | unreadCount, |
34 | WebSource, | ||
34 | } from '@sophie/service-shared'; | 35 | } from '@sophie/service-shared'; |
35 | import { | 36 | import { |
36 | browserViewBounds, | 37 | browserViewBounds, |
@@ -73,30 +74,26 @@ app.commandLine.appendSwitch( | |||
73 | // Remove sophie and electron from the user-agent string to avoid detection. | 74 | // Remove sophie and electron from the user-agent string to avoid detection. |
74 | const originalUserAgent = app.userAgentFallback; | 75 | const originalUserAgent = app.userAgentFallback; |
75 | const userAgent = originalUserAgent.replaceAll(/\s(sophie|Electron)\/\S+/g, ''); | 76 | const userAgent = originalUserAgent.replaceAll(/\s(sophie|Electron)\/\S+/g, ''); |
76 | const platformInUa = userAgent.match(/\((Win|Mac|X11; L)/); | 77 | const chromelessUserAgent = userAgent.replace(/ Chrome\/\S+/, ''); |
77 | let platform = 'Unknown'; | ||
78 | if (platformInUa !== null) { | ||
79 | switch (platformInUa[1]) { | ||
80 | case 'Win': | ||
81 | platform = 'Windows'; | ||
82 | break; | ||
83 | case 'Mac': | ||
84 | platform = 'macOS'; | ||
85 | break; | ||
86 | case 'X11; L': | ||
87 | platform = 'Linux'; | ||
88 | break; | ||
89 | } | ||
90 | } | ||
91 | const chromiumVersion = process.versions.chrome.split('.')[0]; | ||
92 | // Removing the electron version breaks redux devtools, so we only do this in production. | 78 | // Removing the electron version breaks redux devtools, so we only do this in production. |
93 | if (!isDevelopment) { | 79 | if (!isDevelopment) { |
94 | app.userAgentFallback = userAgent; | 80 | app.userAgentFallback = userAgent; |
95 | } | 81 | } |
96 | 82 | ||
83 | function getResourcePath(relativePath: string): string { | ||
84 | return join(__dirname, relativePath); | ||
85 | } | ||
86 | |||
87 | function getResourceUrl(relativePath: string): string { | ||
88 | return new URL(relativePath, `file://${__dirname}`).toString(); | ||
89 | } | ||
90 | |||
97 | let serviceInjectRelativePath = '../../service-inject/dist/index.cjs'; | 91 | let serviceInjectRelativePath = '../../service-inject/dist/index.cjs'; |
98 | let serviceInjectPath = join(__dirname, serviceInjectRelativePath); | 92 | let serviceInjectPath = getResourcePath(serviceInjectRelativePath); |
99 | let serviceInject: string = readFileSync(serviceInjectPath, 'utf8'); | 93 | let serviceInject: WebSource = { |
94 | code: readFileSync(serviceInjectPath, 'utf8'), | ||
95 | url: getResourceUrl(serviceInjectRelativePath), | ||
96 | }; | ||
100 | 97 | ||
101 | if (isDevelopment) { | 98 | if (isDevelopment) { |
102 | installDevToolsExtensions(app); | 99 | installDevToolsExtensions(app); |
@@ -111,12 +108,12 @@ function createWindow(): Promise<unknown> { | |||
111 | show: false, | 108 | show: false, |
112 | webPreferences: { | 109 | webPreferences: { |
113 | sandbox: true, | 110 | sandbox: true, |
114 | preload: join(__dirname, '../../preload/dist/index.cjs'), | 111 | preload: getResourcePath('../../preload/dist/index.cjs'), |
115 | }, | 112 | }, |
116 | }); | 113 | }); |
117 | 114 | ||
118 | if (isDevelopment) { | 115 | if (isDevelopment) { |
119 | // openDevToolsWhenReady(mainWindow); | 116 | openDevToolsWhenReady(mainWindow); |
120 | } | 117 | } |
121 | 118 | ||
122 | mainWindow.on('ready-to-show', () => { | 119 | mainWindow.on('ready-to-show', () => { |
@@ -131,7 +128,7 @@ function createWindow(): Promise<unknown> { | |||
131 | webPreferences: { | 128 | webPreferences: { |
132 | sandbox: true, | 129 | sandbox: true, |
133 | nodeIntegrationInSubFrames: true, | 130 | nodeIntegrationInSubFrames: true, |
134 | preload: join(__dirname, '../../service-preload/dist/index.cjs'), | 131 | preload: getResourcePath('../../service-preload/dist/index.cjs'), |
135 | partition: 'persist:service', | 132 | partition: 'persist:service', |
136 | }, | 133 | }, |
137 | }); | 134 | }); |
@@ -142,17 +139,6 @@ function createWindow(): Promise<unknown> { | |||
142 | }); | 139 | }); |
143 | mainWindow.setBrowserView(browserView); | 140 | mainWindow.setBrowserView(browserView); |
144 | 141 | ||
145 | browserView.webContents.on( | ||
146 | 'did-frame-navigate', | ||
147 | (_event, _url, _statusCode, _statusText, isMainFrame, _processId, routingId) => { | ||
148 | const { webContents: { mainFrame } } = browserView; | ||
149 | const frame = isMainFrame | ||
150 | ? mainFrame | ||
151 | : mainFrame.framesInSubtree.find((f) => f.routingId === routingId); | ||
152 | frame?.executeJavaScript(serviceInject).catch((err) => console.log(err)); | ||
153 | } | ||
154 | ); | ||
155 | |||
156 | webContents.on('ipc-message', (_event, channel, ...args) => { | 142 | webContents.on('ipc-message', (_event, channel, ...args) => { |
157 | try { | 143 | try { |
158 | switch (channel) { | 144 | switch (channel) { |
@@ -168,7 +154,7 @@ function createWindow(): Promise<unknown> { | |||
168 | case RendererToMainIpcMessage.ReloadAllServices: | 154 | case RendererToMainIpcMessage.ReloadAllServices: |
169 | readFile(serviceInjectPath, 'utf8', (err, data) => { | 155 | readFile(serviceInjectPath, 'utf8', (err, data) => { |
170 | if (err === null) { | 156 | if (err === null) { |
171 | serviceInject = data; | 157 | serviceInject.code = data; |
172 | } else { | 158 | } else { |
173 | console.error('Error while reloading', serviceInjectPath, err); | 159 | console.error('Error while reloading', serviceInjectPath, err); |
174 | } | 160 | } |
@@ -188,10 +174,17 @@ function createWindow(): Promise<unknown> { | |||
188 | webContents.send(MainToRendererIpcMessage.SharedStorePatch, patch); | 174 | webContents.send(MainToRendererIpcMessage.SharedStorePatch, patch); |
189 | }); | 175 | }); |
190 | 176 | ||
177 | ipcMain.on(ServiceToMainIpcMessage.ApiExposedInMainWorld, (event) => { | ||
178 | event.returnValue = event.sender.id == browserView.webContents.id | ||
179 | ? serviceInject | ||
180 | : null; | ||
181 | }); | ||
182 | |||
191 | browserView.webContents.on('ipc-message', (_event, channel, ...args) => { | 183 | browserView.webContents.on('ipc-message', (_event, channel, ...args) => { |
192 | try { | 184 | try { |
193 | switch (channel) { | 185 | switch (channel) { |
194 | case ServiceToMainIpcMessage.ApiExposedInMainWorld: | 186 | case ServiceToMainIpcMessage.ApiExposedInMainWorld: |
187 | // Synchronous message must be handled with `ipcMain.on` | ||
195 | break; | 188 | break; |
196 | case ServiceToMainIpcMessage.SetUnreadCount: | 189 | case ServiceToMainIpcMessage.SetUnreadCount: |
197 | console.log('Unread count:', unreadCount.parse(args[0])); | 190 | console.log('Unread count:', unreadCount.parse(args[0])); |
@@ -205,37 +198,22 @@ function createWindow(): Promise<unknown> { | |||
205 | } | 198 | } |
206 | }); | 199 | }); |
207 | 200 | ||
208 | // Inject CSS to simulate `browserView.setBackgroundColor`. | ||
209 | // This is injected before the page loads, so the styles from the website will overwrite it. | ||
210 | browserView.webContents.on('did-navigate', () => { | ||
211 | browserView.webContents.insertCSS( | ||
212 | 'html { background-color: #fff; }', | ||
213 | { | ||
214 | cssOrigin: 'author', | ||
215 | }, | ||
216 | ); | ||
217 | }); | ||
218 | |||
219 | browserView.webContents.session.webRequest.onBeforeSendHeaders(({ url, requestHeaders }, callback) => { | 201 | browserView.webContents.session.webRequest.onBeforeSendHeaders(({ url, requestHeaders }, callback) => { |
220 | if (url.match(/accounts\.google/)) { | 202 | if (url.match(/^[^:]+:\/\/accounts\.google\.[^.\/]+\//)) { |
221 | requestHeaders['User-Agent'] = userAgent.replace(/ Chrome\/\S+/, ''); | 203 | requestHeaders['User-Agent'] = chromelessUserAgent; |
222 | } else { | 204 | } else { |
223 | requestHeaders['User-Agent'] = userAgent; | 205 | requestHeaders['User-Agent'] = userAgent; |
224 | } | 206 | } |
225 | requestHeaders['User-Agent'] = userAgent; | ||
226 | requestHeaders['Sec-CH-UA'] = `" Not A;Brand";v="99", "Chromium";v="${chromiumVersion}"`; | ||
227 | requestHeaders['Sec-CH-UA-Mobile'] = '?0'; | ||
228 | requestHeaders['Sec-CH-UA-Platform'] = platform; | ||
229 | callback({ requestHeaders }); | 207 | callback({ requestHeaders }); |
230 | }); | 208 | }); |
231 | 209 | ||
232 | const pageUrl = (isDevelopment && import.meta.env.VITE_DEV_SERVER_URL !== undefined) | 210 | const pageUrl = (isDevelopment && import.meta.env.VITE_DEV_SERVER_URL !== undefined) |
233 | ? import.meta.env.VITE_DEV_SERVER_URL | 211 | ? import.meta.env.VITE_DEV_SERVER_URL |
234 | : new URL('../renderer/dist/index.html', `file://${__dirname}`).toString(); | 212 | : getResourceUrl('../renderer/dist/index.html'); |
235 | 213 | ||
236 | return Promise.all([ | 214 | return Promise.all([ |
237 | mainWindow.loadURL(pageUrl), | 215 | mainWindow.loadURL(pageUrl), |
238 | browserView.webContents.loadURL('https://gmail.com').then(() => browserView.webContents.openDevTools()), | 216 | browserView.webContents.loadURL('https://git.marussy.com/sophie/about'), |
239 | ]); | 217 | ]); |
240 | } | 218 | } |
241 | 219 | ||
diff --git a/packages/service-inject/src/index.ts b/packages/service-inject/src/index.ts index f699f11..a7ada84 100644 --- a/packages/service-inject/src/index.ts +++ b/packages/service-inject/src/index.ts | |||
@@ -18,10 +18,4 @@ | |||
18 | * SPDX-License-Identifier: AGPL-3.0-only | 18 | * SPDX-License-Identifier: AGPL-3.0-only |
19 | */ | 19 | */ |
20 | 20 | ||
21 | import { shimUserAgentData } from './shims/userAgentData'; | 21 | export {} |
22 | |||
23 | try { | ||
24 | shimUserAgentData('96', 'Linux'); | ||
25 | } catch (err) { | ||
26 | console.log('Failed to execute injected script:', err); | ||
27 | } | ||
diff --git a/packages/service-inject/src/shims/userAgentData.ts b/packages/service-inject/src/shims/userAgentData.ts deleted file mode 100644 index 7e2c825..0000000 --- a/packages/service-inject/src/shims/userAgentData.ts +++ /dev/null | |||
@@ -1,103 +0,0 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2021-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 { | ||
22 | defineProtoProperty, | ||
23 | deleteProtoProperty, | ||
24 | simulateNativeClass, | ||
25 | simulateNativeFunction, | ||
26 | } from '../utils'; | ||
27 | |||
28 | export function shimUserAgentData(chromeVersion: string | null, platform: string): void { | ||
29 | const brands = [ | ||
30 | { | ||
31 | brand: ' Not A; Brand', | ||
32 | version: '99', | ||
33 | }, | ||
34 | ]; | ||
35 | if (chromeVersion !== null) { | ||
36 | brands.push({ | ||
37 | brand: 'Chromium', | ||
38 | version: '96', | ||
39 | }); | ||
40 | } | ||
41 | const mobile = false; | ||
42 | |||
43 | const simulatedNavigatorUa = simulateNativeClass('NavigatorUAData', function NavigatorUAData() { | ||
44 | // Nothing to initiailize. | ||
45 | }, { | ||
46 | brands: { | ||
47 | configurable: true, | ||
48 | enumerable: true, | ||
49 | get: simulateNativeFunction('brands', () => brands), | ||
50 | }, | ||
51 | mobile: { | ||
52 | configurable: true, | ||
53 | enumerable: true, | ||
54 | get: simulateNativeFunction('mobile', () => mobile), | ||
55 | }, | ||
56 | platform: { | ||
57 | configurable: true, | ||
58 | enumerable: true, | ||
59 | get: simulateNativeFunction('platform', () => platform), | ||
60 | }, | ||
61 | getHighEntropyValues: { | ||
62 | configurable: true, | ||
63 | enumerable: false, | ||
64 | value: simulateNativeFunction('getHighEntropyValues', (...args: unknown[]) => { | ||
65 | if (args.length == 0) { | ||
66 | throw new TypeError("Failed to execute 'getHighEntropyValues' on 'NavigatorUAData': 1 argument required, but only 0 present."); | ||
67 | } | ||
68 | const hints = Array.from(args[0] as Iterable<string>); | ||
69 | if (hints.length === 0) { | ||
70 | return {}; | ||
71 | } | ||
72 | const data: Record<string, unknown> = { | ||
73 | brands, | ||
74 | mobile, | ||
75 | } | ||
76 | if (hints.includes('platform')) { | ||
77 | data['platform'] = platform; | ||
78 | } | ||
79 | return Promise.resolve(data); | ||
80 | }) | ||
81 | }, | ||
82 | toJSON: { | ||
83 | configurable: true, | ||
84 | enumerable: false, | ||
85 | value: simulateNativeFunction('toJSON', () => ({ | ||
86 | brands, | ||
87 | mobile, | ||
88 | })), | ||
89 | writable: false, | ||
90 | }, | ||
91 | }); | ||
92 | |||
93 | const simulatedUserAgentData = Reflect.construct(simulatedNavigatorUa, []); | ||
94 | defineProtoProperty(globalThis.navigator, 'userAgentData', { | ||
95 | configurable: true, | ||
96 | enumerable: true, | ||
97 | get: simulateNativeFunction('userAgentData', () => simulatedUserAgentData), | ||
98 | }); | ||
99 | } | ||
100 | |||
101 | export function deleteUserAgentData(): void { | ||
102 | deleteProtoProperty(globalThis.navigator, 'userAgentData'); | ||
103 | } | ||
diff --git a/packages/service-inject/src/utils.ts b/packages/service-inject/src/utils.ts deleted file mode 100644 index 4bb3fba..0000000 --- a/packages/service-inject/src/utils.ts +++ /dev/null | |||
@@ -1,104 +0,0 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2021-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 | /** | ||
22 | * Simulates a function defined in native code, i.e., one with | ||
23 | * `[native code]` in its `toString`. | ||
24 | * | ||
25 | * @param name The name of the function. | ||
26 | * @param f The function to transform. | ||
27 | * @return The transformed function. | ||
28 | */ | ||
29 | export function simulateNativeFunction<T, P extends unknown[]>( | ||
30 | name: string, | ||
31 | f: (this: null, ...args: P) => T, | ||
32 | ): (...args: P) => T { | ||
33 | // Bound functions say `[native code]`, but unfortunately they omit the function name: | ||
34 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/toString#description | ||
35 | // The type of `f` contains type variables, so we need some magic type casting. | ||
36 | const boundFunc = f.bind(null as ThisParameterType<typeof f>); | ||
37 | Object.defineProperty(boundFunc, 'name', { | ||
38 | configurable: true, | ||
39 | enumerable: false, | ||
40 | value: name, | ||
41 | writable: false, | ||
42 | }); | ||
43 | return boundFunc; | ||
44 | } | ||
45 | |||
46 | /** | ||
47 | * Simulates a native class available on `globalThis`. | ||
48 | * | ||
49 | * @param name The name of the class. | ||
50 | * @param constructor The constructor function. Must already be a constructor (a named `function`). | ||
51 | * @param properties The properties to define on the prototype. | ||
52 | */ | ||
53 | export function simulateNativeClass( | ||
54 | name: string, | ||
55 | constructor: () => void, | ||
56 | properties: PropertyDescriptorMap, | ||
57 | ) { | ||
58 | Object.defineProperties(constructor.prototype, { | ||
59 | [Symbol.toStringTag]: { | ||
60 | configurable: true, | ||
61 | enumerable: false, | ||
62 | value: name, | ||
63 | writable: false, | ||
64 | }, | ||
65 | ...properties, | ||
66 | }); | ||
67 | const simulatedConstructor = simulateNativeFunction(name, constructor); | ||
68 | Object.defineProperty(globalThis, name, { | ||
69 | configurable: true, | ||
70 | enumerable: true, | ||
71 | value: simulatedConstructor, | ||
72 | writable: true, | ||
73 | }); | ||
74 | return simulatedConstructor; | ||
75 | } | ||
76 | |||
77 | /** | ||
78 | * Defines a property on the prototype of an object. | ||
79 | * | ||
80 | * Only use this with singleton objects, e.g., `window.navigator`. | ||
81 | * | ||
82 | * @param o The object to modify. Must be a singleton. | ||
83 | * @param property The key of the property being defined or modified. | ||
84 | * @param attributes The descriptor of the property being defined or modified. | ||
85 | */ | ||
86 | export function defineProtoProperty( | ||
87 | o: object, | ||
88 | property: PropertyKey, | ||
89 | attributes: PropertyDescriptor, | ||
90 | ): void { | ||
91 | Object.defineProperty(Object.getPrototypeOf(o), property, attributes); | ||
92 | } | ||
93 | |||
94 | /** | ||
95 | * Deletes a property from the prototype of an object. | ||
96 | * | ||
97 | * Only use this with singleton objects, e.g., `window.navigator`. | ||
98 | * | ||
99 | * @param o The object to modify. Must be a singleton. | ||
100 | * @param property The key of the property being deleted. | ||
101 | */ | ||
102 | export function deleteProtoProperty(o: object, property: PropertyKey): void { | ||
103 | Reflect.deleteProperty(Object.getPrototypeOf(o), property); | ||
104 | } | ||
diff --git a/packages/service-preload/src/index.ts b/packages/service-preload/src/index.ts index 3f54c0b..e42c406 100644 --- a/packages/service-preload/src/index.ts +++ b/packages/service-preload/src/index.ts | |||
@@ -18,7 +18,22 @@ | |||
18 | * SPDX-License-Identifier: AGPL-3.0-only | 18 | * SPDX-License-Identifier: AGPL-3.0-only |
19 | */ | 19 | */ |
20 | 20 | ||
21 | import { ipcRenderer } from 'electron'; | 21 | import { ipcRenderer, webFrame } from 'electron'; |
22 | import { ServiceToMainIpcMessage } from '@sophie/service-shared'; | 22 | import { ServiceToMainIpcMessage, webSource } from '@sophie/service-shared'; |
23 | 23 | ||
24 | ipcRenderer.send(ServiceToMainIpcMessage.ApiExposedInMainWorld); | 24 | if (webFrame.parent === null) { |
25 | // Inject CSS to simulate `browserView.setBackgroundColor`. | ||
26 | // This is injected before the page loads, so the styles from the website will overwrite it. | ||
27 | webFrame.insertCSS('html { background-color: #fff; }'); | ||
28 | } | ||
29 | |||
30 | const injectSource = webSource.safeParse(ipcRenderer.sendSync(ServiceToMainIpcMessage.ApiExposedInMainWorld)); | ||
31 | if (injectSource.success) { | ||
32 | webFrame.executeJavaScriptInIsolatedWorld(0, [ | ||
33 | injectSource.data, | ||
34 | ]).catch((err) => { | ||
35 | console.log('Failed to inject source:', err); | ||
36 | }); | ||
37 | } else { | ||
38 | console.log('Invalid source to inject:', injectSource.error); | ||
39 | } | ||
diff --git a/packages/service-shared/src/index.ts b/packages/service-shared/src/index.ts index c517959..564ebe8 100644 --- a/packages/service-shared/src/index.ts +++ b/packages/service-shared/src/index.ts | |||
@@ -22,7 +22,9 @@ export { ServiceToMainIpcMessage } from './ipc'; | |||
22 | 22 | ||
23 | export type { | 23 | export type { |
24 | UnreadCount, | 24 | UnreadCount, |
25 | WebSource, | ||
25 | } from './schemas'; | 26 | } from './schemas'; |
26 | export { | 27 | export { |
27 | unreadCount, | 28 | unreadCount, |
29 | webSource, | ||
28 | } from './schemas'; | 30 | } from './schemas'; |
diff --git a/packages/service-shared/src/schemas.ts b/packages/service-shared/src/schemas.ts index 1513e43..586750c 100644 --- a/packages/service-shared/src/schemas.ts +++ b/packages/service-shared/src/schemas.ts | |||
@@ -26,3 +26,10 @@ export const unreadCount = z.object({ | |||
26 | }); | 26 | }); |
27 | 27 | ||
28 | export type UnreadCount = z.infer<typeof unreadCount>; | 28 | export type UnreadCount = z.infer<typeof unreadCount>; |
29 | |||
30 | export const webSource = z.object({ | ||
31 | code: z.string(), | ||
32 | url: z.string().nonempty(), | ||
33 | }); | ||
34 | |||
35 | export type WebSource = z.infer<typeof webSource>; | ||