mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
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:
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ const electronModule = {
|
||||
return [browserWindow];
|
||||
},
|
||||
},
|
||||
utilityProcess: {},
|
||||
nativeTheme: nativeTheme,
|
||||
ipcMain,
|
||||
shell: {} as Partial<Electron.Shell>,
|
||||
|
||||
26
apps/electron/src/main/config.ts
Normal file
26
apps/electron/src/main/config.ts
Normal 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];
|
||||
35
apps/electron/src/main/deep-link.ts
Normal file
35
apps/electron/src/main/deep-link.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
14
apps/electron/src/main/ui/events.ts
Normal file
14
apps/electron/src/main/ui/events.ts
Normal 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>;
|
||||
56
apps/electron/src/main/ui/handlers.ts
Normal file
56
apps/electron/src/main/ui/handlers.ts
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
5
apps/electron/src/main/ui/subject.ts
Normal file
5
apps/electron/src/main/ui/subject.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
export const uiSubjects = {
|
||||
onFinishLogin: new Subject<void>(),
|
||||
};
|
||||
110
apps/electron/src/main/utils.ts
Normal file
110
apps/electron/src/main/utils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user