feat: support google cloud login in client (#1822)

Co-authored-by: Himself65 <himself65@outlook.com>
Co-authored-by: Peng Xiao <pengxiao@outlook.com>
This commit is contained in:
Horus 2023-04-12 02:42:36 +08:00 committed by GitHub
parent 024c469a2c
commit c0669359ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 252 additions and 42 deletions

View File

@ -40,6 +40,9 @@ env:
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID }}
NEXT_PUBLIC_FIREBASE_APP_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_APP_ID }}
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID }}
AFFINE_GOOGLE_CLIENT_ID: ${{ secrets.AFFINE_GOOGLE_CLIENT_ID }}
AFFINE_GOOGLE_CLIENT_SECRET: ${{ secrets.AFFINE_GOOGLE_CLIENT_SECRET }}
NODE_API_SERVER: 'https://app.affine.pro'
jobs:
make-macos:

View File

@ -11,3 +11,4 @@ resources/web-static
!.yarn/releases
!.yarn/sdks
!.yarn/versions
dev.json

View File

@ -0,0 +1,32 @@
import type { RequestInit } from 'undici';
import { fetch, ProxyAgent } from 'undici';
const redirectUri = 'https://affine.pro/client/auth-callback';
export const oauthEndpoint = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${process.env.AFFINE_GOOGLE_CLIENT_ID}&redirect_uri=${redirectUri}&response_type=code&scope=openid https://www.googleapis.com/auth/userinfo.email profile&access_type=offline`;
const tokenEndpoint = 'https://oauth2.googleapis.com/token';
export const exchangeToken = async (code: string) => {
const httpProxy = process.env.HTTP_PROXY || process.env.http_proxy;
const proxyAgent = httpProxy ? new ProxyAgent(httpProxy) : undefined;
const postData = {
code,
client_id: process.env.AFFINE_GOOGLE_CLIENT_ID || '',
client_secret: process.env.AFFINE_GOOGLE_CLIENT_SECRET || '',
redirect_uri: redirectUri,
grant_type: 'authorization_code',
};
const requestOptions: RequestInit = {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(postData).toString(),
dispatcher: proxyAgent,
};
return fetch(tokenEndpoint, requestOptions).then(response => {
return response.json();
});
};

View File

@ -2,13 +2,19 @@ import * as os from 'node:os';
import path from 'node:path';
import { Storage } from '@affine/octobase-node';
import { app, shell } from 'electron';
import { BrowserWindow, ipcMain, nativeTheme } from 'electron';
import fs from 'fs-extra';
import { parse } from 'url';
import { exchangeToken, oauthEndpoint } from './google-auth';
const AFFINE_ROOT = path.join(os.homedir(), '.affine');
fs.ensureDirSync(AFFINE_ROOT);
const logger = console;
// todo: rethink this
export const appState = {
storage: new Storage(path.join(AFFINE_ROOT, 'test.db')),
@ -21,6 +27,7 @@ export const registerHandlers = () => {
ipcMain.handle('ui:theme-change', async (_, theme) => {
nativeTheme.themeSource = theme;
logger.info('theme change', theme);
});
ipcMain.handle('ui:sidebar-visibility-change', async (_, visible) => {
@ -30,5 +37,38 @@ export const registerHandlers = () => {
// hide window buttons when sidebar is not visible
w.setWindowButtonVisibility(visible);
});
logger.info('sidebar visibility change', visible);
});
ipcMain.handle('ui:google-sign-in', async () => {
logger.info('starting google sign in ...');
shell.openExternal(oauthEndpoint);
return new Promise<string>((resolve, reject) => {
const handleOpenUrl = async (_: any, url: string) => {
const mainWindow = BrowserWindow.getAllWindows().find(
w => !w.isDestroyed()
);
const urlObj = parse(url.replace('??', '?'), true);
if (!mainWindow || !url.startsWith('affine://')) return;
const token = (await exchangeToken(urlObj.query['code'] as string)) as {
id_token: string;
};
app.removeListener('open-url', handleOpenUrl);
resolve(token.id_token);
logger.info('google sign in', token);
};
app.on('open-url', handleOpenUrl);
setTimeout(() => {
reject(new Error('Timed out'));
app.removeListener('open-url', handleOpenUrl);
}, 60000);
});
});
ipcMain.handle('main:env-update', async (_, env, value) => {
process.env[env] = value;
});
};

View File

@ -1,11 +1,21 @@
import './security-restrictions';
import { app } from 'electron';
import path from 'path';
import { registerHandlers } from './app-state';
import { restoreOrCreateWindow } from './main-window';
import { registerProtocol } from './protocol';
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient('affine', process.execPath, [
path.resolve(process.argv[1]),
]);
}
} else {
app.setAsDefaultProtocolClient('affine');
}
/**
* Prevent multiple instances
*/
@ -15,7 +25,13 @@ if (!isSingleInstance) {
process.exit(0);
}
app.on('second-instance', restoreOrCreateWindow);
app.on('second-instance', (event, argv) => {
restoreOrCreateWindow();
});
app.on('open-url', async (_, url) => {
// todo: handle `affine://...` urls
});
/**
* Disable Hardware Acceleration for more power-save
@ -45,7 +61,6 @@ app
.then(registerHandlers)
.then(restoreOrCreateWindow)
.catch(e => console.error('Failed create window:', e));
/**
* Check new app version in production mode only
*/

View File

@ -10,6 +10,14 @@ app.on('web-contents-created', (_, contents) => {
* @see https://www.electronjs.org/docs/latest/tutorial/security#13-disable-or-limit-navigation
*/
contents.on('will-navigate', (event, url) => {
if (
(process.env.DEV_SERVER_URL &&
url.startsWith(process.env.DEV_SERVER_URL)) ||
url.startsWith('affine://') ||
url.startsWith('file://.')
) {
return;
}
// Prevent navigation
event.preventDefault();
shell.openExternal(url).catch(console.error);

View File

@ -7,6 +7,6 @@ interface Window {
*
* @see https://github.com/cawa-93/dts-for-context-bridge
*/
readonly apis: { workspaceSync: (id: string) => Promise<any>; onThemeChange: (theme: string) => Promise<any>; onSidebarVisibilityChange: (visible: boolean) => Promise<any>; };
readonly apis: { workspaceSync: (id: string) => Promise<any>; onThemeChange: (theme: string) => Promise<any>; onSidebarVisibilityChange: (visible: boolean) => Promise<any>; googleSignIn: () => Promise<string>; updateEnv: (env: string, value: string) => void; };
readonly appInfo: { electron: boolean; isMacOS: boolean; };
}

View File

@ -21,7 +21,6 @@ import { isMacOS } from '../../utils';
*
* @see https://github.com/cawa-93/dts-for-context-bridge
*/
contextBridge.exposeInMainWorld('apis', {
workspaceSync: (id: string) => ipcRenderer.invoke('octo:workspace-sync', id),
// ui
@ -30,6 +29,18 @@ contextBridge.exposeInMainWorld('apis', {
onSidebarVisibilityChange: (visible: boolean) =>
ipcRenderer.invoke('ui:sidebar-visibility-change', visible),
/**
* Try sign in using Google and return a Google IDToken
*/
googleSignIn: (): Promise<string> => ipcRenderer.invoke('ui:google-sign-in'),
/**
* Secret backdoor to update environment variables in main process
*/
updateEnv: (env: string, value: string) => {
ipcRenderer.invoke('main:env-update', env, value);
},
});
contextBridge.exposeInMainWorld('appInfo', {

View File

@ -36,6 +36,7 @@
"@electron-forge/maker-zip": "^6.1.1",
"@electron-forge/shared-types": "^6.1.1",
"@electron/rebuild": "^3.2.10",
"@electron/remote": "2.0.9",
"dts-for-context-bridge": "^0.7.1",
"electron": "24.0.0",
"esbuild": "^0.17.16",
@ -44,7 +45,19 @@
"dependencies": {
"cross-env": "7.0.3",
"electron-window-state": "^5.0.3",
"fs-extra": "^11.1.1"
"firebase": "^9.18.0",
"fs-extra": "^11.1.1",
"undici": "^5.21.2"
},
"build": {
"protocols": [
{
"name": "affine",
"schemes": [
"affine"
]
}
]
},
"packageManager": "yarn@3.5.0"
}

View File

@ -2,7 +2,6 @@ import fs from 'node:fs';
import path from 'node:path';
import * as url from 'node:url';
const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
// const __dirname = new URL('.', import.meta.url).pathname;
const { node } = JSON.parse(
fs.readFileSync(
path.join(__dirname, '../electron-vendors.autogen.json'),
@ -20,22 +19,36 @@ const nativeNodeModulesPlugin = {
},
};
/** @type {import('esbuild').BuildOptions} */
export const mainConfig = {
entryPoints: ['layers/main/src/index.ts'],
outdir: 'dist/layers/main',
bundle: true,
target: `node${node}`,
platform: 'node',
external: ['electron'],
plugins: [nativeNodeModulesPlugin],
};
// List of env that will be replaced by esbuild
const ENV_MACROS = ['AFFINE_GOOGLE_CLIENT_ID', 'AFFINE_GOOGLE_CLIENT_SECRET'];
export const preloadConfig = {
entryPoints: ['layers/preload/src/index.ts'],
outdir: 'dist/layers/preload',
bundle: true,
target: `node${node}`,
platform: 'node',
external: ['electron'],
/** @return {{main: import('esbuild').BuildOptions, preload: import('esbuild').BuildOptions}} */
export default () => {
const define = Object.fromEntries(
ENV_MACROS.map(key => [
'process.env.' + key,
JSON.stringify(process.env[key] ?? ''),
])
);
return {
main: {
entryPoints: ['layers/main/src/index.ts'],
outdir: 'dist/layers/main',
bundle: true,
target: `node${node}`,
platform: 'node',
external: ['electron'],
plugins: [nativeNodeModulesPlugin],
define: define,
},
preload: {
entryPoints: ['layers/preload/src/index.ts'],
outdir: 'dist/layers/preload',
bundle: true,
target: `node${node}`,
platform: 'node',
external: ['electron'],
define: define,
},
};
};

View File

@ -1,10 +1,16 @@
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { generateAsync } from 'dts-for-context-bridge';
import electronPath from 'electron';
import * as esbuild from 'esbuild';
import { mainConfig, preloadConfig } from './common.mjs';
import commonFn from './common.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/** @type 'production' | 'development'' */
const mode = (process.env.NODE_ENV = process.env.NODE_ENV || 'development');
@ -17,6 +23,17 @@ const stderrFilterPatterns = [
/ExtensionLoadWarning/,
];
// these are set before calling commonFn so we have a chance to override them
try {
const devJson = readFileSync(path.resolve(__dirname, '../dev.json'), 'utf-8');
const devEnv = JSON.parse(devJson);
Object.assign(process.env, devEnv);
} catch (err) {
console.warn(
`Could not read dev.json. Some functions may not work as expected.`
);
}
// hard-coded for now:
// fixme(xp): report error if app is not running on port 8080
process.env.DEV_SERVER_URL = `http://localhost:8080`;
@ -35,26 +52,28 @@ function spawnOrReloadElectron() {
spawnProcess.stdout.on(
'data',
d => d.toString().trim() && console.warn(d.toString(), { timestamp: true })
d => d.toString().trim() && console.warn(d.toString())
);
spawnProcess.stderr.on('data', d => {
const data = d.toString().trim();
if (!data) return;
const mayIgnore = stderrFilterPatterns.some(r => r.test(data));
if (mayIgnore) return;
console.error(data, { timestamp: true });
console.error(data);
});
// Stops the watch script when the application has been quit
spawnProcess.on('exit', process.exit);
}
const common = commonFn();
async function main() {
async function watchPreload(onInitialBuild) {
const preloadBuild = await esbuild.context({
...preloadConfig,
...common.preload,
plugins: [
...(preloadConfig.plugins ?? []),
...(common.preload.plugins ?? []),
{
name: 'affine-dev:reload-app-on-preload-change',
setup(build) {
@ -81,13 +100,14 @@ async function main() {
async function watchMain() {
const mainBuild = await esbuild.context({
...mainConfig,
...common.main,
define: {
...common.main.define,
'process.env.NODE_ENV': `"${mode}"`,
'process.env.DEV_SERVER_URL': `"${process.env.DEV_SERVER_URL}"`,
},
plugins: [
...(mainConfig.plugins ?? []),
...(common.main.plugins ?? []),
{
name: 'affine-dev:reload-app-on-main-change',
setup(build) {

View File

@ -5,7 +5,7 @@ import path from 'node:path';
import * as esbuild from 'esbuild';
import { mainConfig, preloadConfig } from './common.mjs';
import commonFn from './common.mjs';
const repoRootDir = path.join(__dirname, '..', '..', '..');
const electronRootDir = path.join(__dirname, '..');
@ -62,13 +62,13 @@ async function cleanup() {
}
async function buildLayers() {
await esbuild.build({
...preloadConfig,
});
const common = commonFn();
await esbuild.build(common.preload);
await esbuild.build({
...mainConfig,
...common.main,
define: {
...common.main.define,
'process.env.NODE_ENV': `"production"`,
},
});

View File

@ -123,12 +123,15 @@ __metadata:
"@electron-forge/maker-zip": ^6.1.1
"@electron-forge/shared-types": ^6.1.1
"@electron/rebuild": ^3.2.10
"@electron/remote": 2.0.9
cross-env: 7.0.3
dts-for-context-bridge: ^0.7.1
electron: 24.0.0
electron-window-state: ^5.0.3
esbuild: ^0.17.16
firebase: ^9.18.0
fs-extra: ^11.1.1
undici: ^5.21.2
zx: ^7.2.1
languageName: unknown
linkType: soft
@ -2234,6 +2237,15 @@ __metadata:
languageName: node
linkType: hard
"@electron/remote@npm:2.0.9":
version: 2.0.9
resolution: "@electron/remote@npm:2.0.9"
peerDependencies:
electron: ">= 13.0.0"
checksum: 7949c528df0ecc9661c0b43e7f2586befb9eddfb53739adf204dc2097ba4331fb08b01f6904636e4c566d28b051a80a55718fb643ee2d2df0793c0c05278dbd4
languageName: node
linkType: hard
"@electron/universal@npm:^1.3.2":
version: 1.3.4
resolution: "@electron/universal@npm:1.3.4"
@ -7268,6 +7280,15 @@ __metadata:
languageName: node
linkType: hard
"busboy@npm:^1.6.0":
version: 1.6.0
resolution: "busboy@npm:1.6.0"
dependencies:
streamsearch: ^1.1.0
checksum: 32801e2c0164e12106bf236291a00795c3c4e4b709ae02132883fe8478ba2ae23743b11c5735a0aae8afe65ac4b6ca4568b91f0d9fed1fdbc32ede824a73746e
languageName: node
linkType: hard
"bytes@npm:3.0.0":
version: 3.0.0
resolution: "bytes@npm:3.0.0"
@ -9636,7 +9657,7 @@ __metadata:
languageName: node
linkType: hard
"firebase@npm:^9.19.1":
"firebase@npm:^9.18.0, firebase@npm:^9.19.1":
version: 9.19.1
resolution: "firebase@npm:9.19.1"
dependencies:
@ -15960,6 +15981,13 @@ __metadata:
languageName: node
linkType: hard
"streamsearch@npm:^1.1.0":
version: 1.1.0
resolution: "streamsearch@npm:1.1.0"
checksum: 1cce16cea8405d7a233d32ca5e00a00169cc0e19fbc02aa839959985f267335d435c07f96e5e0edd0eadc6d39c98d5435fb5bbbdefc62c41834eadc5622ad942
languageName: node
linkType: hard
"string-argv@npm:~0.3.1":
version: 0.3.1
resolution: "string-argv@npm:0.3.1"
@ -16668,6 +16696,15 @@ __metadata:
languageName: node
linkType: hard
"undici@npm:^5.21.2":
version: 5.21.2
resolution: "undici@npm:5.21.2"
dependencies:
busboy: ^1.6.0
checksum: baceaa9e610966631e86ad2869b657556dd465438eed55e8079cec2a306ecbeecfde2d6e37e43baf96a4c59588ebef50476131e96e018dcc0a7f5db7e6a06c85
languageName: node
linkType: hard
"unfetch@npm:^4.2.0":
version: 4.2.0
resolution: "unfetch@npm:4.2.0"

View File

@ -31,7 +31,6 @@ export const StyledHeader = styled('div')<{ hasWarning: boolean }>(
padding: '0 20px',
...displayFlex('space-between', 'center'),
background: theme.colors.pageBackground,
transition: 'background-color 0.5s',
zIndex: 99,
position: 'relative',
};

View File

@ -12,8 +12,8 @@ import { jotaiStore } from '@affine/workspace/atom';
import { isValidIPAddress } from '../utils';
let prefixUrl = '/';
if (typeof window === 'undefined') {
// SSR
if (typeof window === 'undefined' || environment.isDesktop) {
// SSR or Desktop
const serverAPI = config.serverAPI;
if (isValidIPAddress(serverAPI.split(':')[0])) {
// This is for Server side rendering support
@ -21,6 +21,7 @@ if (typeof window === 'undefined') {
} else {
prefixUrl = serverAPI;
}
prefixUrl = prefixUrl.endsWith('/') ? prefixUrl : prefixUrl + '/';
} else {
const params = new URLSearchParams(window.location.search);
params.get('prefixUrl') && (prefixUrl = params.get('prefixUrl') as string);

View File

@ -1,4 +1,5 @@
import { DebugLogger } from '@affine/debug';
import { getEnvironment } from '@affine/env';
import { assertExists } from '@blocksuite/global/utils';
import { Slot } from '@blocksuite/store';
import { initializeApp } from 'firebase/app';
@ -9,6 +10,7 @@ import {
getAuth as getFirebaseAuth,
GithubAuthProvider,
GoogleAuthProvider,
signInWithCredential,
signInWithPopup,
} from 'firebase/auth';
import { decode } from 'js-base64';
@ -64,6 +66,13 @@ export const setLoginStorage = (login: LoginResponse) => {
);
};
const signInWithElectron = async (firebaseAuth: FirebaseAuth) => {
const code = await window.apis?.googleSignIn();
const credential = GoogleAuthProvider.credential(code);
const user = await signInWithCredential(firebaseAuth, credential);
return await user.user.getIdToken();
};
export const clearLoginStorage = () => {
localStorage.removeItem(STORAGE_KEY);
};
@ -152,6 +161,7 @@ export function createAffineAuth(prefix = '/') {
method: SignMethod
): Promise<LoginResponse | null> => {
const auth = getAuth();
const environment = getEnvironment();
if (!auth) {
throw new Error('Failed to initialize firebase');
}
@ -167,9 +177,14 @@ export function createAffineAuth(prefix = '/') {
throw new Error('Unsupported sign method');
}
try {
const response = await signInWithPopup(auth, provider);
const idToken = await response.user.getIdToken();
logger.debug(idToken);
let idToken: string | undefined;
if (environment.isDesktop) {
idToken = await signInWithElectron(auth);
} else {
const response = await signInWithPopup(auth, provider);
idToken = await response.user.getIdToken();
}
logger.debug('idToken', idToken);
return fetch(prefix + 'api/user/token', {
method: 'POST',
headers: {

View File

@ -1,3 +1,5 @@
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
/// <reference path='../../../apps/electron/layers/preload/preload.d.ts' />
import type { Workspace as RemoteWorkspace } from '@affine/workspace/affine/api';
import type { Workspace as BlockSuiteWorkspace } from '@blocksuite/store';
import type { FC, PropsWithChildren } from 'react';