feat!: affine cloud support (#3813)

Co-authored-by: Hongtao Lye <codert.sn@gmail.com>
Co-authored-by: liuyi <forehalo@gmail.com>
Co-authored-by: LongYinan <lynweklm@gmail.com>
Co-authored-by: X1a0t <405028157@qq.com>
Co-authored-by: JimmFly <yangjinfei001@gmail.com>
Co-authored-by: Peng Xiao <pengxiao@outlook.com>
Co-authored-by: xiaodong zuo <53252747+zuoxiaodong0815@users.noreply.github.com>
Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
Co-authored-by: Qi <474021214@qq.com>
Co-authored-by: danielchim <kahungchim@gmail.com>
This commit is contained in:
Alex Yang
2023-08-29 05:07:05 -05:00
committed by GitHub
parent d0145c6f38
commit 2f6c4e3696
414 changed files with 19469 additions and 7591 deletions

View File

@@ -56,14 +56,14 @@ function setupRendererConnection(rendererPort: Electron.MessagePortMain) {
for (const [namespace, namespaceEvents] of Object.entries(events)) {
for (const [key, eventRegister] of Object.entries(namespaceEvents)) {
const subscription = eventRegister((...args: any[]) => {
const unsub = eventRegister((...args: any[]) => {
const chan = `${namespace}:${key}`;
rpc.postEvent(chan, ...args).catch(err => {
console.error(err);
});
});
process.on('exit', () => {
subscription();
unsub();
});
}
}

View File

@@ -95,6 +95,7 @@ const electronModule = {
return [browserWindow];
},
},
utilityProcess: {},
nativeTheme: nativeTheme,
ipcMain,
shell: {} as Partial<Electron.Shell>,

View File

@@ -0,0 +1,26 @@
import { z } from 'zod';
export const ReleaseTypeSchema = z.enum([
'stable',
'beta',
'canary',
'internal',
]);
export const envBuildType = (process.env.BUILD_TYPE || 'canary')
.trim()
.toLowerCase();
export const buildType = ReleaseTypeSchema.parse(envBuildType);
export const mode = process.env.NODE_ENV;
export const isDev = mode === 'development';
const API_URL_MAPPING = {
stable: `https://app.affine.pro`,
beta: `https://ambassador.affine.pro`,
canary: `https://affine.fail`,
internal: `https://affine.fail`,
};
export const CLOUD_BASE_URL =
process.env.DEV_SERVER_URL || API_URL_MAPPING[buildType];

View File

@@ -0,0 +1,35 @@
import type { App } from 'electron';
import { buildType, isDev } from './config';
import { logger } from './logger';
import { handleOpenUrlInPopup } from './main-window';
let protocol = buildType === 'stable' ? 'affine' : `affine-${buildType}`;
if (isDev) {
protocol = 'affine-dev';
}
export function setupDeepLink(app: App) {
app.setAsDefaultProtocolClient(protocol);
app.on('open-url', (event, url) => {
if (url.startsWith(`${protocol}://`)) {
event.preventDefault();
handleAffineUrl(url).catch(e => {
logger.error('failed to handle affine url', e);
});
}
});
}
async function handleAffineUrl(url: string) {
logger.info('open affine url', url);
const urlObj = new URL(url);
if (urlObj.hostname === 'open-url') {
const urlToOpen = urlObj.search.slice(1);
if (urlToOpen) {
handleOpenUrlInPopup(urlToOpen).catch(e => {
logger.error('failed to open url in popup', e);
});
}
}
}

View File

@@ -2,11 +2,13 @@ import { app, BrowserWindow } from 'electron';
import { applicationMenuEvents } from './application-menu';
import { logger } from './logger';
import { uiEvents } from './ui';
import { updaterEvents } from './updater/event';
export const allEvents = {
applicationMenu: applicationMenuEvents,
updater: updaterEvents,
ui: uiEvents,
};
function getActiveWindows() {

View File

@@ -38,7 +38,14 @@ class HelperProcessManager {
// a rpc server for the main process -> helper process
rpc?: _AsyncVersionOf<HelperToMain>;
static instance = new HelperProcessManager();
static _instance: HelperProcessManager | null = null;
static get instance() {
if (!this._instance) {
this._instance = new HelperProcessManager();
}
return this._instance;
}
private constructor() {
const helperProcess = utilityProcess.fork(HELPER_PROCESS_PATH);

View File

@@ -3,6 +3,7 @@ import './security-restrictions';
import { app } from 'electron';
import { createApplicationMenu } from './application-menu/create';
import { setupDeepLink } from './deep-link';
import { registerEvents } from './events';
import { registerHandlers } from './handlers';
import { ensureHelperProcess } from './helper-process';
@@ -38,10 +39,6 @@ app.on('second-instance', () => {
);
});
app.on('open-url', (_, _url) => {
// todo: handle `affine://...` urls
});
/**
* Shout down background process if all windows was closed
*/
@@ -55,11 +52,13 @@ app.on('window-all-closed', () => {
* @see https://www.electronjs.org/docs/v14-x-y/api/app#event-activate-macos Event: 'activate'
*/
app.on('activate', () => {
restoreOrCreateWindow().catch(err => {
console.error(err);
});
restoreOrCreateWindow().catch(e =>
console.error('Failed to restore or create window:', e)
);
});
setupDeepLink(app);
/**
* Create app window when background process will be ready
*/

View File

@@ -5,9 +5,11 @@ import electronWindowState from 'electron-window-state';
import { join } from 'path';
import { isMacOS, isWindows } from '../shared/utils';
import { CLOUD_BASE_URL } from './config';
import { getExposedMeta } from './exposed';
import { ensureHelperProcess } from './helper-process';
import { logger } from './logger';
import { parseCookie } from './utils';
const IS_DEV: boolean =
process.env.NODE_ENV === 'development' && !process.env.CI;
@@ -108,7 +110,7 @@ async function createWindow() {
/**
* URL for main window.
*/
const pageUrl = process.env.DEV_SERVER_URL || 'file://.'; // see protocol.ts
const pageUrl = CLOUD_BASE_URL; // see protocol.ts
logger.info('loading page at', pageUrl);
@@ -120,13 +122,43 @@ async function createWindow() {
}
// singleton
let browserWindow: Electron.BrowserWindow | undefined;
let browserWindow: BrowserWindow | undefined;
let popup: BrowserWindow | undefined;
function createPopupWindow() {
if (!popup || popup?.isDestroyed()) {
const mainExposedMeta = getExposedMeta();
popup = new BrowserWindow({
width: 1200,
height: 600,
alwaysOnTop: true,
resizable: false,
webPreferences: {
preload: join(__dirname, './preload.js'),
additionalArguments: [
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
// popup window does not need helper process, right?
],
},
});
popup.on('close', e => {
e.preventDefault();
popup?.destroy();
popup = undefined;
});
browserWindow?.webContents.once('did-finish-load', () => {
closePopup();
});
}
return popup;
}
/**
* Restore existing BrowserWindow or Create new BrowserWindow
*/
export async function restoreOrCreateWindow() {
browserWindow = BrowserWindow.getAllWindows().find(w => !w.isDestroyed());
browserWindow =
browserWindow || BrowserWindow.getAllWindows().find(w => !w.isDestroyed());
if (browserWindow === undefined) {
browserWindow = await createWindow();
@@ -139,3 +171,25 @@ export async function restoreOrCreateWindow() {
return browserWindow;
}
export async function handleOpenUrlInPopup(url: string) {
const popup = createPopupWindow();
await popup.loadURL(url);
}
export function closePopup() {
if (!popup?.isDestroyed()) {
popup?.close();
popup?.destroy();
popup = undefined;
}
}
export function reloadApp() {
browserWindow?.reload();
}
export async function setCookie(origin: string, cookie: string) {
const window = await restoreOrCreateWindow();
await window.webContents.session.cookies.set(parseCookie(cookie, origin));
}

View File

@@ -1,51 +1,73 @@
import { protocol, session } from 'electron';
import { net, protocol, session } from 'electron';
import { join } from 'path';
protocol.registerSchemesAsPrivileged([
{
scheme: 'assets',
privileges: {
secure: false,
corsEnabled: true,
supportFetchAPI: true,
standard: true,
bypassCSP: true,
},
},
]);
import { CLOUD_BASE_URL } from './config';
import { setCookie } from './main-window';
import { simpleGet } from './utils';
function toAbsolutePath(url: string) {
let realpath: string;
const webStaticDir = join(__dirname, '../resources/web-static');
if (url.startsWith('./')) {
// if is a file type, load the file in resources
if (url.split('/').at(-1)?.includes('.')) {
realpath = join(webStaticDir, decodeURIComponent(url));
const NETWORK_REQUESTS = ['/api', '/ws', '/socket.io', '/graphql'];
const webStaticDir = join(__dirname, '../resources/web-static');
function isNetworkResource(pathname: string) {
return NETWORK_REQUESTS.some(opt => pathname.startsWith(opt));
}
async function handleHttpRequest(request: Request) {
const clonedRequest = Object.assign(request.clone(), {
bypassCustomProtocolHandlers: true,
});
const { pathname, origin } = new URL(request.url);
if (
!origin.startsWith(CLOUD_BASE_URL) ||
isNetworkResource(pathname) ||
process.env.DEV_SERVER_URL // when debugging locally
) {
// note: I don't find a good way to get over with 302 redirect
// by default in net.fetch, or don't know if there is a way to
// bypass http request handling to browser instead ...
if (pathname.startsWith('/api/auth/callback')) {
const originResponse = await simpleGet(request.url);
// hack: use window.webContents.session.cookies to set cookies
// since return set-cookie header in response doesn't work here
for (const [, cookie] of originResponse.headers.filter(
p => p[0] === 'set-cookie'
)) {
await setCookie(origin, cookie);
}
return new Response(originResponse.body, {
headers: originResponse.headers,
status: originResponse.statusCode,
});
} else {
// else, fallback to load the index.html instead
realpath = join(webStaticDir, 'index.html');
// just pass through (proxy)
return net.fetch(request.url, clonedRequest);
}
} else {
realpath = join(webStaticDir, decodeURIComponent(url));
// this will be file types (in the web-static folder)
let filepath = '';
// if is a file type, load the file in resources
if (pathname.split('/').at(-1)?.includes('.')) {
filepath = join(webStaticDir, decodeURIComponent(pathname));
} else {
// else, fallback to load the index.html instead
filepath = join(webStaticDir, 'index.html');
}
return net.fetch('file://' + filepath, clonedRequest);
}
return realpath;
}
export function registerProtocol() {
protocol.interceptFileProtocol('file', (request, callback) => {
const url = request.url.replace(/^file:\/\//, '');
const realpath = toAbsolutePath(url);
callback(realpath);
return true;
// it seems that there is some issue to postMessage between renderer with custom protocol & helper process
protocol.handle('http', request => {
return handleHttpRequest(request);
});
protocol.registerFileProtocol('assets', (request, callback) => {
const url = request.url.replace(/^assets:\/\//, '');
const realpath = toAbsolutePath(url);
callback(realpath);
return true;
protocol.handle('https', request => {
return handleHttpRequest(request);
});
// hack for CORS
// todo: should use a whitelist
session.defaultSession.webRequest.onHeadersReceived(
(responseDetails, callback) => {
const { responseHeaders } = responseDetails;

View File

@@ -0,0 +1,14 @@
import type { MainEventRegister } from '../type';
import { uiSubjects } from './subject';
/**
* Events triggered by application menu
*/
export const uiEvents = {
onFinishLogin: (fn: () => void) => {
const sub = uiSubjects.onFinishLogin.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
} satisfies Record<string, MainEventRegister>;

View File

@@ -0,0 +1,56 @@
import { app, BrowserWindow, nativeTheme } from 'electron';
import { isMacOS } from '../../shared/utils';
import { closePopup } from '../main-window';
import type { NamespaceHandlers } from '../type';
import { getGoogleOauthCode } from './google-auth';
import { uiSubjects } from './subject';
export const uiHandlers = {
handleThemeChange: async (_, theme: (typeof nativeTheme)['themeSource']) => {
nativeTheme.themeSource = theme;
},
handleSidebarVisibilityChange: async (_, visible: boolean) => {
if (isMacOS()) {
const windows = BrowserWindow.getAllWindows();
windows.forEach(w => {
// hide window buttons when sidebar is not visible
w.setWindowButtonVisibility(visible);
});
}
},
handleMinimizeApp: async () => {
const windows = BrowserWindow.getAllWindows();
windows.forEach(w => {
w.minimize();
});
},
handleMaximizeApp: async () => {
const windows = BrowserWindow.getAllWindows();
windows.forEach(w => {
if (w.isMaximized()) {
w.unmaximize();
} else {
w.maximize();
}
});
},
handleCloseApp: async () => {
app.quit();
},
handleFinishLogin: async () => {
closePopup();
uiSubjects.onFinishLogin.next();
},
getGoogleOauthCode: async () => {
return getGoogleOauthCode();
},
/**
* @deprecated Remove this when bookmark block plugin is migrated to plugin-infra
*/
getBookmarkDataByLink: async (_, link: string) => {
return globalThis.asyncCall[
'com.blocksuite.bookmark-block.get-bookmark-data-by-link'
](link);
},
} satisfies NamespaceHandlers;

View File

@@ -1,50 +1,3 @@
import { app, BrowserWindow, nativeTheme } from 'electron';
import { isMacOS } from '../../shared/utils';
import type { NamespaceHandlers } from '../type';
import { getGoogleOauthCode } from './google-auth';
export const uiHandlers = {
handleThemeChange: async (_, theme: (typeof nativeTheme)['themeSource']) => {
nativeTheme.themeSource = theme;
},
handleSidebarVisibilityChange: async (_, visible: boolean) => {
if (isMacOS()) {
const windows = BrowserWindow.getAllWindows();
windows.forEach(w => {
// hide window buttons when sidebar is not visible
w.setWindowButtonVisibility(visible);
});
}
},
handleMinimizeApp: async () => {
const windows = BrowserWindow.getAllWindows();
windows.forEach(w => {
w.minimize();
});
},
handleMaximizeApp: async () => {
const windows = BrowserWindow.getAllWindows();
windows.forEach(w => {
if (w.isMaximized()) {
w.unmaximize();
} else {
w.maximize();
}
});
},
handleCloseApp: async () => {
app.quit();
},
getGoogleOauthCode: async () => {
return getGoogleOauthCode();
},
/**
* @deprecated Remove this when bookmark block plugin is migrated to plugin-infra
*/
getBookmarkDataByLink: async (_, link: string) => {
return globalThis.asyncCall[
'com.blocksuite.bookmark-block.get-bookmark-data-by-link'
](link);
},
} satisfies NamespaceHandlers;
export * from './events';
export * from './handlers';
export * from './subject';

View File

@@ -0,0 +1,5 @@
import { Subject } from 'rxjs';
export const uiSubjects = {
onFinishLogin: new Subject<void>(),
};

View File

@@ -0,0 +1,110 @@
import http from 'node:http';
import https from 'node:https';
import type { CookiesSetDetails } from 'electron';
export function parseCookie(
cookieString: string,
url: string
): CookiesSetDetails {
const [nameValuePair, ...attributes] = cookieString
.split('; ')
.map(part => part.trim());
const [name, value] = nameValuePair.split('=');
const details: CookiesSetDetails = { url, name, value };
attributes.forEach(attribute => {
const [key, val] = attribute.split('=');
switch (key.toLowerCase()) {
case 'domain':
details.domain = val;
break;
case 'path':
details.path = val;
break;
case 'secure':
details.secure = true;
break;
case 'httponly':
details.httpOnly = true;
break;
case 'expires':
details.expirationDate = new Date(val).getTime() / 1000; // Convert to seconds
break;
case 'samesite':
if (
['unspecified', 'no_restriction', 'lax', 'strict'].includes(
val.toLowerCase()
)
) {
details.sameSite = val.toLowerCase() as
| 'unspecified'
| 'no_restriction'
| 'lax'
| 'strict';
}
break;
default:
// Handle other cookie attributes if needed
break;
}
});
return details;
}
/**
* Send a GET request to a specified URL.
* This function uses native http/https modules instead of fetch to
* bypassing set-cookies headers
*/
export async function simpleGet(requestUrl: string): Promise<{
body: string;
headers: [string, string][];
statusCode: number;
}> {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(requestUrl);
const protocol = parsedUrl.protocol === 'https:' ? https : http;
const options = {
hostname: parsedUrl.hostname,
port: parsedUrl.port,
path: parsedUrl.pathname + parsedUrl.search,
method: 'GET',
};
const req = protocol.request(options, res => {
let data = '';
res.on('data', chunk => {
data += chunk;
});
res.on('end', () => {
resolve({
body: data,
headers: toStandardHeaders(res.headers),
statusCode: res.statusCode || 200,
});
});
});
req.on('error', error => {
reject(error);
});
req.end();
});
function toStandardHeaders(headers: http.IncomingHttpHeaders) {
const result: [string, string][] = [];
for (const [key, value] of Object.entries(headers)) {
if (Array.isArray(value)) {
value.forEach(v => {
result.push([key, v]);
});
} else {
result.push([key, value || '']);
}
}
return result;
}
}