aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2021-12-25 00:01:18 +0100
committerLibravatar Kristóf Marussy <kristof@marussy.com>2021-12-25 00:01:18 +0100
commite321534fbea9f09b139d440584f6b84ad0afb80f (patch)
treec4b4df109589475dd5f40a0d31f47a6aa9e43195
parentfeat: Shim userAgentData in all frames and workers (diff)
downloadsophie-e321534fbea9f09b139d440584f6b84ad0afb80f.tar.gz
sophie-e321534fbea9f09b139d440584f6b84ad0afb80f.tar.zst
sophie-e321534fbea9f09b139d440584f6b84ad0afb80f.zip
refactor: Simplify script injection
Inject CSS and main world scripts synchronously to avoid race conditions with page loading. Don't try to miming userAgentData for now, since it won't bypass google's checks. However, simply omitting chrome from the user agent does bypass them, at least for now.
-rw-r--r--packages/main/src/index.ts84
-rw-r--r--packages/service-inject/src/index.ts8
-rw-r--r--packages/service-inject/src/shims/userAgentData.ts103
-rw-r--r--packages/service-inject/src/utils.ts104
-rw-r--r--packages/service-preload/src/index.ts21
-rw-r--r--packages/service-shared/src/index.ts2
-rw-r--r--packages/service-shared/src/schemas.ts7
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';
27import { readFile, readFileSync } from 'fs'; 27import { readFile, readFileSync } from 'fs';
28import { autorun } from 'mobx'; 28import { autorun } from 'mobx';
@@ -31,6 +31,7 @@ import { join } from 'path';
31import { 31import {
32 ServiceToMainIpcMessage, 32 ServiceToMainIpcMessage,
33 unreadCount, 33 unreadCount,
34 WebSource,
34} from '@sophie/service-shared'; 35} from '@sophie/service-shared';
35import { 36import {
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.
74const originalUserAgent = app.userAgentFallback; 75const originalUserAgent = app.userAgentFallback;
75const userAgent = originalUserAgent.replaceAll(/\s(sophie|Electron)\/\S+/g, ''); 76const userAgent = originalUserAgent.replaceAll(/\s(sophie|Electron)\/\S+/g, '');
76const platformInUa = userAgent.match(/\((Win|Mac|X11; L)/); 77const chromelessUserAgent = userAgent.replace(/ Chrome\/\S+/, '');
77let platform = 'Unknown';
78if (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}
91const 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.
93if (!isDevelopment) { 79if (!isDevelopment) {
94 app.userAgentFallback = userAgent; 80 app.userAgentFallback = userAgent;
95} 81}
96 82
83function getResourcePath(relativePath: string): string {
84 return join(__dirname, relativePath);
85}
86
87function getResourceUrl(relativePath: string): string {
88 return new URL(relativePath, `file://${__dirname}`).toString();
89}
90
97let serviceInjectRelativePath = '../../service-inject/dist/index.cjs'; 91let serviceInjectRelativePath = '../../service-inject/dist/index.cjs';
98let serviceInjectPath = join(__dirname, serviceInjectRelativePath); 92let serviceInjectPath = getResourcePath(serviceInjectRelativePath);
99let serviceInject: string = readFileSync(serviceInjectPath, 'utf8'); 93let serviceInject: WebSource = {
94 code: readFileSync(serviceInjectPath, 'utf8'),
95 url: getResourceUrl(serviceInjectRelativePath),
96};
100 97
101if (isDevelopment) { 98if (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
21import { shimUserAgentData } from './shims/userAgentData'; 21export {}
22
23try {
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
21import {
22 defineProtoProperty,
23 deleteProtoProperty,
24 simulateNativeClass,
25 simulateNativeFunction,
26} from '../utils';
27
28export 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
101export 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 */
29export 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 */
53export 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 */
86export 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 */
102export 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
21import { ipcRenderer } from 'electron'; 21import { ipcRenderer, webFrame } from 'electron';
22import { ServiceToMainIpcMessage } from '@sophie/service-shared'; 22import { ServiceToMainIpcMessage, webSource } from '@sophie/service-shared';
23 23
24ipcRenderer.send(ServiceToMainIpcMessage.ApiExposedInMainWorld); 24if (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
30const injectSource = webSource.safeParse(ipcRenderer.sendSync(ServiceToMainIpcMessage.ApiExposedInMainWorld));
31if (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
23export type { 23export type {
24 UnreadCount, 24 UnreadCount,
25 WebSource,
25} from './schemas'; 26} from './schemas';
26export { 27export {
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
28export type UnreadCount = z.infer<typeof unreadCount>; 28export type UnreadCount = z.infer<typeof unreadCount>;
29
30export const webSource = z.object({
31 code: z.string(),
32 url: z.string().nonempty(),
33});
34
35export type WebSource = z.infer<typeof webSource>;