feat(electron): onboarding at first launch logic for client and web (#5183)
- Added a simple abstraction of persistent storage class.
- Different persistence solutions are provided for web and client.
- web: stored in localStorage
- client: stored in the application directory as `.json` file
- Define persistent app-config schema
- Add a new hook that can interactive with persistent-app-config reactively
This commit is contained in:
@@ -40,6 +40,11 @@
|
|||||||
"import": "./dist/type.js",
|
"import": "./dist/type.js",
|
||||||
"require": "./dist/type.cjs"
|
"require": "./dist/type.cjs"
|
||||||
},
|
},
|
||||||
|
"./app-config-storage": {
|
||||||
|
"type": "./dist/src/app-config-storage.d.ts",
|
||||||
|
"import": "./dist/app-config-storage.js",
|
||||||
|
"require": "./dist/app-config-storage.cjs"
|
||||||
|
},
|
||||||
"./__internal__/*": {
|
"./__internal__/*": {
|
||||||
"type": "./dist/src/__internal__/*.d.ts",
|
"type": "./dist/src/__internal__/*.d.ts",
|
||||||
"import": "./dist/__internal__/*.js",
|
"import": "./dist/__internal__/*.js",
|
||||||
|
|||||||
88
packages/common/infra/src/app-config-storage.ts
Normal file
88
packages/common/infra/src/app-config-storage.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const _appConfigSchema = z.object({
|
||||||
|
/** whether to show onboarding first */
|
||||||
|
onBoarding: z.boolean().optional().default(true),
|
||||||
|
});
|
||||||
|
export type AppConfigSchema = z.infer<typeof _appConfigSchema>;
|
||||||
|
export const defaultAppConfig = _appConfigSchema.parse({});
|
||||||
|
|
||||||
|
const _storage: Record<number, any> = {};
|
||||||
|
let _inMemoryId = 0;
|
||||||
|
|
||||||
|
interface StorageOptions<T> {
|
||||||
|
/** default config */
|
||||||
|
config: T;
|
||||||
|
get?: () => T;
|
||||||
|
set?: (data: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage for app configuration, stored in memory by default
|
||||||
|
*/
|
||||||
|
class Storage<T> {
|
||||||
|
private _cfg: T;
|
||||||
|
private readonly _id = _inMemoryId++;
|
||||||
|
private readonly _options;
|
||||||
|
|
||||||
|
constructor(options: StorageOptions<T>) {
|
||||||
|
this._options = {
|
||||||
|
get: () => _storage[this._id],
|
||||||
|
set: (data: T) => (_storage[this._id] = data),
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
this._cfg = this.get() ?? options.config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* update entire config
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
set(data: T) {
|
||||||
|
try {
|
||||||
|
this._options.set(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('failed to save config', err);
|
||||||
|
}
|
||||||
|
this._cfg = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(): T;
|
||||||
|
get(key: keyof T): T[keyof T];
|
||||||
|
/**
|
||||||
|
* get config, if key is provided, return the value of the key
|
||||||
|
* @param key
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
get(key?: keyof T): T | T[keyof T] {
|
||||||
|
if (!key) {
|
||||||
|
try {
|
||||||
|
const cfg = this._options.get();
|
||||||
|
if (!cfg) {
|
||||||
|
this.set(this._options.config);
|
||||||
|
return this._options.config;
|
||||||
|
}
|
||||||
|
return cfg;
|
||||||
|
} catch (err) {
|
||||||
|
return this._cfg;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return this.get()[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* update a key in config
|
||||||
|
* @param key
|
||||||
|
* @param value
|
||||||
|
*/
|
||||||
|
patch(key: keyof T, value: any) {
|
||||||
|
this.set({ ...this.get(), [key]: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
get value(): T {
|
||||||
|
return this.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AppConfigStorage extends Storage<AppConfigSchema> {}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
ClipboardHandlers,
|
ClipboardHandlers,
|
||||||
|
ConfigStorageHandlers,
|
||||||
DBHandlers,
|
DBHandlers,
|
||||||
DebugHandlers,
|
DebugHandlers,
|
||||||
DialogHandlers,
|
DialogHandlers,
|
||||||
@@ -49,3 +50,8 @@ export abstract class WorkspaceHandlerManager extends HandlerManager<
|
|||||||
'workspace',
|
'workspace',
|
||||||
WorkspaceHandlers
|
WorkspaceHandlers
|
||||||
> {}
|
> {}
|
||||||
|
|
||||||
|
export abstract class ConfigStorageHandlerManager extends HandlerManager<
|
||||||
|
'configStorage',
|
||||||
|
ConfigStorageHandlers
|
||||||
|
> {}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type Buffer from 'buffer';
|
|||||||
import type { WritableAtom } from 'jotai';
|
import type { WritableAtom } from 'jotai';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { AppConfigSchema } from './app-config-storage.js';
|
||||||
import type { TypedEventEmitter } from './core/event-emitter.js';
|
import type { TypedEventEmitter } from './core/event-emitter.js';
|
||||||
|
|
||||||
type Buffer = Buffer.Buffer;
|
type Buffer = Buffer.Buffer;
|
||||||
@@ -175,6 +176,7 @@ export type UIHandlers = {
|
|||||||
handleCloseApp: () => Promise<any>;
|
handleCloseApp: () => Promise<any>;
|
||||||
getGoogleOauthCode: () => Promise<any>;
|
getGoogleOauthCode: () => Promise<any>;
|
||||||
getChallengeResponse: (resource: string) => Promise<string>;
|
getChallengeResponse: (resource: string) => Promise<string>;
|
||||||
|
handleOpenMainApp: () => Promise<any>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ClipboardHandlers = {
|
export type ClipboardHandlers = {
|
||||||
@@ -211,6 +213,11 @@ export type WorkspaceHandlers = {
|
|||||||
clone: (id: string, newId: string) => Promise<void>;
|
clone: (id: string, newId: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ConfigStorageHandlers = {
|
||||||
|
set: (config: AppConfigSchema | Partial<AppConfigSchema>) => Promise<void>;
|
||||||
|
get: () => Promise<AppConfigSchema>;
|
||||||
|
};
|
||||||
|
|
||||||
export type UnwrapManagerHandlerToServerSide<
|
export type UnwrapManagerHandlerToServerSide<
|
||||||
ElectronEvent extends {
|
ElectronEvent extends {
|
||||||
frameId: number;
|
frameId: number;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default defineConfig({
|
|||||||
type: resolve(root, 'src/type.ts'),
|
type: resolve(root, 'src/type.ts'),
|
||||||
'core/event-emitter': resolve(root, 'src/core/event-emitter.ts'),
|
'core/event-emitter': resolve(root, 'src/core/event-emitter.ts'),
|
||||||
'preload/electron': resolve(root, 'src/preload/electron.ts'),
|
'preload/electron': resolve(root, 'src/preload/electron.ts'),
|
||||||
|
'app-config-storage': resolve(root, 'src/app-config-storage.ts'),
|
||||||
'__internal__/plugin': resolve(root, 'src/__internal__/plugin.ts'),
|
'__internal__/plugin': resolve(root, 'src/__internal__/plugin.ts'),
|
||||||
},
|
},
|
||||||
formats: ['es', 'cjs'],
|
formats: ['es', 'cjs'],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
useNavigationType,
|
useNavigationType,
|
||||||
} from 'react-router-dom';
|
} from 'react-router-dom';
|
||||||
|
|
||||||
|
import { appConfigProxy } from '../hooks/use-app-config-storage';
|
||||||
import { performanceLogger } from '../shared';
|
import { performanceLogger } from '../shared';
|
||||||
|
|
||||||
const performanceSetupLogger = performanceLogger.namespace('setup');
|
const performanceSetupLogger = performanceLogger.namespace('setup');
|
||||||
@@ -47,5 +48,12 @@ export function setup() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// load persistent config for electron
|
||||||
|
// TODO: should be sync, but it's not necessary for now
|
||||||
|
environment.isDesktop &&
|
||||||
|
appConfigProxy
|
||||||
|
.getSync()
|
||||||
|
.catch(() => console.error('failed to load app config'));
|
||||||
|
|
||||||
performanceSetupLogger.info('done');
|
performanceSetupLogger.info('done');
|
||||||
}
|
}
|
||||||
|
|||||||
83
packages/frontend/core/src/hooks/use-app-config-storage.ts
Normal file
83
packages/frontend/core/src/hooks/use-app-config-storage.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
type AppConfigSchema,
|
||||||
|
AppConfigStorage,
|
||||||
|
defaultAppConfig,
|
||||||
|
} from '@toeverything/infra/app-config-storage';
|
||||||
|
import { type Dispatch, useEffect, useState } from 'react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper class to get/set app config from main process
|
||||||
|
*/
|
||||||
|
class AppConfigProxy {
|
||||||
|
value: AppConfigSchema = defaultAppConfig;
|
||||||
|
|
||||||
|
async getSync(): Promise<AppConfigSchema> {
|
||||||
|
return (this.value = await window.apis.configStorage.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSync(): Promise<void> {
|
||||||
|
await window.apis.configStorage.set(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(): AppConfigSchema {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(data: AppConfigSchema) {
|
||||||
|
this.value = data;
|
||||||
|
this.setSync().catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const appConfigProxy = new AppConfigProxy();
|
||||||
|
|
||||||
|
const storage = environment.isDesktop
|
||||||
|
? new AppConfigStorage({
|
||||||
|
config: defaultAppConfig,
|
||||||
|
get: () => appConfigProxy.get(),
|
||||||
|
set: v => appConfigProxy.set(v),
|
||||||
|
})
|
||||||
|
: new AppConfigStorage({
|
||||||
|
config: defaultAppConfig,
|
||||||
|
get: () => JSON.parse(localStorage.getItem('app_config') ?? 'null'),
|
||||||
|
set: config => localStorage.setItem('app_config', JSON.stringify(config)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const appConfigStorage = storage;
|
||||||
|
|
||||||
|
export function useAppConfigStorage(): [
|
||||||
|
AppConfigSchema,
|
||||||
|
Dispatch<AppConfigSchema>,
|
||||||
|
];
|
||||||
|
export function useAppConfigStorage(
|
||||||
|
key: keyof AppConfigSchema
|
||||||
|
): [AppConfigSchema[typeof key], Dispatch<AppConfigSchema[typeof key]>];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get reactive app config
|
||||||
|
* @param key
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function useAppConfigStorage(key?: keyof AppConfigSchema) {
|
||||||
|
const [_config, _setConfig] = useState(storage.get());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
storage.set(_config);
|
||||||
|
}, [_config]);
|
||||||
|
|
||||||
|
const value = useMemo(() => (key ? _config[key] : _config), [_config, key]);
|
||||||
|
|
||||||
|
const setValue = useMemo(() => {
|
||||||
|
if (key) {
|
||||||
|
return (value: AppConfigSchema[typeof key]) => {
|
||||||
|
_setConfig(cfg => ({ ...cfg, [key]: value }));
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return (config: AppConfigSchema) => {
|
||||||
|
_setConfig(config);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [_setConfig, key]);
|
||||||
|
|
||||||
|
return [value, setValue];
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@ import { Menu } from '@affine/component/ui/menu';
|
|||||||
import { workspaceListAtom } from '@affine/workspace/atom';
|
import { workspaceListAtom } from '@affine/workspace/atom';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { lazy, useEffect } from 'react';
|
import { lazy, useEffect } from 'react';
|
||||||
|
import { type LoaderFunction, redirect } from 'react-router-dom';
|
||||||
|
|
||||||
import { createFirstAppData } from '../bootstrap/first-app-data';
|
import { createFirstAppData } from '../bootstrap/first-app-data';
|
||||||
import { UserWithWorkspaceList } from '../components/pure/workspace-slider-bar/user-with-workspace-list';
|
import { UserWithWorkspaceList } from '../components/pure/workspace-slider-bar/user-with-workspace-list';
|
||||||
|
import { appConfigStorage } from '../hooks/use-app-config-storage';
|
||||||
import { useNavigateHelper } from '../hooks/use-navigate-helper';
|
import { useNavigateHelper } from '../hooks/use-navigate-helper';
|
||||||
import { WorkspaceSubPath } from '../shared';
|
import { WorkspaceSubPath } from '../shared';
|
||||||
|
|
||||||
@@ -14,6 +16,13 @@ const AllWorkspaceModals = lazy(() =>
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const loader: LoaderFunction = async () => {
|
||||||
|
if (!environment.isDesktop && appConfigStorage.get('onBoarding')) {
|
||||||
|
return redirect('/onboarding');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
export const Component = () => {
|
export const Component = () => {
|
||||||
const list = useAtomValue(workspaceListAtom);
|
const list = useAtomValue(workspaceListAtom);
|
||||||
const { openPage } = useNavigateHelper();
|
const { openPage } = useNavigateHelper();
|
||||||
|
|||||||
52
packages/frontend/core/src/pages/onboarding.tsx
Normal file
52
packages/frontend/core/src/pages/onboarding.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Button } from '@affine/component/ui/button';
|
||||||
|
import { redirect } from 'react-router-dom';
|
||||||
|
|
||||||
|
import {
|
||||||
|
appConfigStorage,
|
||||||
|
useAppConfigStorage,
|
||||||
|
} from '../hooks/use-app-config-storage';
|
||||||
|
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
|
||||||
|
|
||||||
|
export const loader = () => {
|
||||||
|
if (!environment.isDesktop && !appConfigStorage.get('onBoarding')) {
|
||||||
|
// onboarding is off, redirect to index
|
||||||
|
return redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Component = () => {
|
||||||
|
const { jumpToIndex } = useNavigateHelper();
|
||||||
|
const [onBoarding, setOnboarding] = useAppConfigStorage('onBoarding');
|
||||||
|
|
||||||
|
const openApp = () => {
|
||||||
|
if (environment.isDesktop) {
|
||||||
|
window.apis.ui.handleOpenMainApp().catch(err => {
|
||||||
|
console.log('failed to open main app', err);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
jumpToIndex(RouteLogic.REPLACE);
|
||||||
|
setOnboarding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '8px',
|
||||||
|
height: '100vh',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button onClick={() => setOnboarding(!onBoarding)}>
|
||||||
|
Toggle onboarding
|
||||||
|
</Button>
|
||||||
|
onboarding page, onboarding mode is {onBoarding ? 'on' : 'off'}
|
||||||
|
<Button onClick={openApp}>Enter App</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -65,6 +65,10 @@ export const routes = [
|
|||||||
path: '/desktop-signin',
|
path: '/desktop-signin',
|
||||||
lazy: () => import('./pages/desktop-signin'),
|
lazy: () => import('./pages/desktop-signin'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/onboarding',
|
||||||
|
lazy: () => import('./pages/onboarding'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '*',
|
path: '*',
|
||||||
lazy: () => import('./pages/404'),
|
lazy: () => import('./pages/404'),
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NamespaceHandlers } from '../type';
|
||||||
|
import { persistentConfig } from './persist';
|
||||||
|
|
||||||
|
export const configStorageHandlers = {
|
||||||
|
get: async () => persistentConfig.get(),
|
||||||
|
set: async (_, v) => persistentConfig.set(v),
|
||||||
|
} satisfies NamespaceHandlers;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './handlers';
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import {
|
||||||
|
AppConfigStorage,
|
||||||
|
defaultAppConfig,
|
||||||
|
} from '@toeverything/infra/app-config-storage';
|
||||||
|
import { app } from 'electron';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const FILENAME = 'config.json';
|
||||||
|
const FILEPATH = path.join(app.getPath('userData'), FILENAME);
|
||||||
|
|
||||||
|
export const persistentConfig = new AppConfigStorage({
|
||||||
|
config: defaultAppConfig,
|
||||||
|
get: () => JSON.parse(fs.readFileSync(FILEPATH, 'utf-8')),
|
||||||
|
set: data => fs.writeFileSync(FILEPATH, JSON.stringify(data, null, 2)),
|
||||||
|
});
|
||||||
1
packages/frontend/electron/src/main/constants.ts
Normal file
1
packages/frontend/electron/src/main/constants.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const mainWindowOrigin = process.env.DEV_SERVER_URL || 'file://.';
|
||||||
@@ -3,11 +3,11 @@ import path from 'node:path';
|
|||||||
import { type App, type BrowserWindow, ipcMain } from 'electron';
|
import { type App, type BrowserWindow, ipcMain } from 'electron';
|
||||||
|
|
||||||
import { buildType, CLOUD_BASE_URL, isDev } from './config';
|
import { buildType, CLOUD_BASE_URL, isDev } from './config';
|
||||||
|
import { mainWindowOrigin } from './constants';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
import {
|
import {
|
||||||
getMainWindow,
|
getMainWindow,
|
||||||
handleOpenUrlInHiddenWindow,
|
handleOpenUrlInHiddenWindow,
|
||||||
mainWindowOrigin,
|
|
||||||
removeCookie,
|
removeCookie,
|
||||||
setCookie,
|
setCookie,
|
||||||
} from './main-window';
|
} from './main-window';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { app, BrowserWindow } from 'electron';
|
|||||||
|
|
||||||
import { applicationMenuEvents } from './application-menu';
|
import { applicationMenuEvents } from './application-menu';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
import { uiEvents } from './ui';
|
import { uiEvents } from './ui/events';
|
||||||
import { updaterEvents } from './updater/event';
|
import { updaterEvents } from './updater/event';
|
||||||
|
|
||||||
export const allEvents = {
|
export const allEvents = {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
ClipboardHandlerManager,
|
ClipboardHandlerManager,
|
||||||
|
ConfigStorageHandlerManager,
|
||||||
DebugHandlerManager,
|
DebugHandlerManager,
|
||||||
ExportHandlerManager,
|
ExportHandlerManager,
|
||||||
UIHandlerManager,
|
UIHandlerManager,
|
||||||
@@ -9,9 +10,10 @@ import type {
|
|||||||
import { ipcMain } from 'electron';
|
import { ipcMain } from 'electron';
|
||||||
|
|
||||||
import { clipboardHandlers } from './clipboard';
|
import { clipboardHandlers } from './clipboard';
|
||||||
|
import { configStorageHandlers } from './config-storage';
|
||||||
import { exportHandlers } from './export';
|
import { exportHandlers } from './export';
|
||||||
import { getLogFilePath, logger, revealLogFile } from './logger';
|
import { getLogFilePath, logger, revealLogFile } from './logger';
|
||||||
import { uiHandlers } from './ui';
|
import { uiHandlers } from './ui/handlers';
|
||||||
import { updaterHandlers } from './updater';
|
import { updaterHandlers } from './updater';
|
||||||
|
|
||||||
export const debugHandlers = {
|
export const debugHandlers = {
|
||||||
@@ -44,6 +46,10 @@ type AllHandlers = {
|
|||||||
Electron.IpcMainInvokeEvent,
|
Electron.IpcMainInvokeEvent,
|
||||||
UpdaterHandlerManager
|
UpdaterHandlerManager
|
||||||
>;
|
>;
|
||||||
|
configStorage: UnwrapManagerHandlerToServerSide<
|
||||||
|
Electron.IpcMainInvokeEvent,
|
||||||
|
ConfigStorageHandlerManager
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Note: all of these handlers will be the single-source-of-truth for the apis exposed to the renderer process
|
// Note: all of these handlers will be the single-source-of-truth for the apis exposed to the renderer process
|
||||||
@@ -53,6 +59,7 @@ export const allHandlers = {
|
|||||||
clipboard: clipboardHandlers,
|
clipboard: clipboardHandlers,
|
||||||
export: exportHandlers,
|
export: exportHandlers,
|
||||||
updater: updaterHandlers,
|
updater: updaterHandlers,
|
||||||
|
configStorage: configStorageHandlers,
|
||||||
} satisfies AllHandlers;
|
} satisfies AllHandlers;
|
||||||
|
|
||||||
export const registerHandlers = () => {
|
export const registerHandlers = () => {
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import { registerEvents } from './events';
|
|||||||
import { registerHandlers } from './handlers';
|
import { registerHandlers } from './handlers';
|
||||||
import { ensureHelperProcess } from './helper-process';
|
import { ensureHelperProcess } from './helper-process';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
import { initMainWindow as initMainWindow } from './main-window';
|
|
||||||
import { registerProtocol } from './protocol';
|
import { registerProtocol } from './protocol';
|
||||||
import { registerUpdater } from './updater';
|
import { registerUpdater } from './updater';
|
||||||
|
import { launch } from './windows-manager/launcher';
|
||||||
|
import { launchStage } from './windows-manager/stage';
|
||||||
|
|
||||||
app.enableSandbox();
|
app.enableSandbox();
|
||||||
|
|
||||||
@@ -26,11 +27,9 @@ if (overrideSession) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (require('electron-squirrel-startup')) app.quit();
|
if (require('electron-squirrel-startup')) app.quit();
|
||||||
// allow tests to overwrite app name through passing args
|
|
||||||
if (process.argv.includes('--app-name')) {
|
if (process.env.SKIP_ONBOARDING) {
|
||||||
const appNameIndex = process.argv.indexOf('--app-name');
|
launchStage.value = 'main';
|
||||||
const appName = process.argv[appNameIndex + 1];
|
|
||||||
app.setName(appName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,13 +52,9 @@ app.on('window-all-closed', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see https://www.electronjs.org/docs/v14-x-y/api/app#event-activate-macos Event: 'activate'
|
* @see https://www.electronjs.org/docs/latest/api/app#event-activate-macos Event: 'activate'
|
||||||
*/
|
*/
|
||||||
app.on('activate', () => {
|
app.on('activate', launch);
|
||||||
initMainWindow().catch(e =>
|
|
||||||
console.error('Failed to restore or create window:', e)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
setupDeepLink(app);
|
setupDeepLink(app);
|
||||||
|
|
||||||
@@ -72,7 +67,7 @@ app
|
|||||||
.then(registerHandlers)
|
.then(registerHandlers)
|
||||||
.then(registerEvents)
|
.then(registerEvents)
|
||||||
.then(ensureHelperProcess)
|
.then(ensureHelperProcess)
|
||||||
.then(initMainWindow)
|
.then(launch)
|
||||||
.then(createApplicationMenu)
|
.then(createApplicationMenu)
|
||||||
.then(registerUpdater)
|
.then(registerUpdater)
|
||||||
.catch(e => console.error('Failed create window:', e));
|
.catch(e => console.error('Failed create window:', e));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import electronWindowState from 'electron-window-state';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
import { isMacOS, isWindows } from '../shared/utils';
|
import { isMacOS, isWindows } from '../shared/utils';
|
||||||
|
import { mainWindowOrigin } from './constants';
|
||||||
import { ensureHelperProcess } from './helper-process';
|
import { ensureHelperProcess } from './helper-process';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
import { uiSubjects } from './ui/subject';
|
import { uiSubjects } from './ui/subject';
|
||||||
@@ -15,8 +16,6 @@ const IS_DEV: boolean =
|
|||||||
|
|
||||||
const DEV_TOOL = process.env.DEV_TOOL === 'true';
|
const DEV_TOOL = process.env.DEV_TOOL === 'true';
|
||||||
|
|
||||||
export const mainWindowOrigin = process.env.DEV_SERVER_URL || 'file://.';
|
|
||||||
|
|
||||||
// todo: not all window need all of the exposed meta
|
// todo: not all window need all of the exposed meta
|
||||||
const getWindowAdditionalArguments = async () => {
|
const getWindowAdditionalArguments = async () => {
|
||||||
const { getExposedMeta } = await import('./exposed');
|
const { getExposedMeta } = await import('./exposed');
|
||||||
|
|||||||
78
packages/frontend/electron/src/main/onboarding.ts
Normal file
78
packages/frontend/electron/src/main/onboarding.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { assert } from 'console';
|
||||||
|
import { BrowserWindow } from 'electron';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
import { mainWindowOrigin } from './constants';
|
||||||
|
// import { getExposedMeta } from './exposed';
|
||||||
|
import { ensureHelperProcess } from './helper-process';
|
||||||
|
import { logger } from './logger';
|
||||||
|
|
||||||
|
// todo: not all window need all of the exposed meta
|
||||||
|
const getWindowAdditionalArguments = async () => {
|
||||||
|
const { getExposedMeta } = await import('./exposed');
|
||||||
|
const mainExposedMeta = getExposedMeta();
|
||||||
|
const helperProcessManager = await ensureHelperProcess();
|
||||||
|
const helperExposedMeta = await helperProcessManager.rpc?.getMeta();
|
||||||
|
return [
|
||||||
|
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
|
||||||
|
`--helper-exposed-meta=` + JSON.stringify(helperExposedMeta),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
async function createOnboardingWindow(additionalArguments: string[]) {
|
||||||
|
logger.info('creating onboarding window');
|
||||||
|
|
||||||
|
const helperProcessManager = await ensureHelperProcess();
|
||||||
|
const helperExposedMeta = await helperProcessManager.rpc?.getMeta();
|
||||||
|
|
||||||
|
assert(helperExposedMeta, 'helperExposedMeta should be defined');
|
||||||
|
|
||||||
|
const browserWindow = new BrowserWindow({
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
frame: false,
|
||||||
|
show: false,
|
||||||
|
closable: false,
|
||||||
|
minimizable: false,
|
||||||
|
maximizable: false,
|
||||||
|
fullscreenable: false,
|
||||||
|
skipTaskbar: true,
|
||||||
|
// transparent: true,
|
||||||
|
webPreferences: {
|
||||||
|
webgl: true,
|
||||||
|
preload: join(__dirname, './preload.js'),
|
||||||
|
additionalArguments: additionalArguments,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
browserWindow.on('ready-to-show', () => {
|
||||||
|
browserWindow.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
await browserWindow.loadURL(
|
||||||
|
`${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}onboarding`
|
||||||
|
);
|
||||||
|
|
||||||
|
return browserWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
let onBoardingWindow$: Promise<BrowserWindow> | undefined;
|
||||||
|
|
||||||
|
export async function getOrCreateOnboardingWindow() {
|
||||||
|
const additionalArguments = await getWindowAdditionalArguments();
|
||||||
|
if (
|
||||||
|
!onBoardingWindow$ ||
|
||||||
|
(await onBoardingWindow$.then(w => w.isDestroyed()))
|
||||||
|
) {
|
||||||
|
onBoardingWindow$ = createOnboardingWindow(additionalArguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
return onBoardingWindow$;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOnboardingWindow() {
|
||||||
|
if (!onBoardingWindow$) return;
|
||||||
|
const window = await onBoardingWindow$;
|
||||||
|
if (window.isDestroyed()) return;
|
||||||
|
return window;
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@ import { app, nativeTheme } from 'electron';
|
|||||||
import { getLinkPreview } from 'link-preview-js';
|
import { getLinkPreview } from 'link-preview-js';
|
||||||
|
|
||||||
import { isMacOS } from '../../shared/utils';
|
import { isMacOS } from '../../shared/utils';
|
||||||
|
import { persistentConfig } from '../config-storage/persist';
|
||||||
import { logger } from '../logger';
|
import { logger } from '../logger';
|
||||||
import { getMainWindow } from '../main-window';
|
import { getMainWindow, initMainWindow } from '../main-window';
|
||||||
|
import { getOnboardingWindow } from '../onboarding';
|
||||||
import type { NamespaceHandlers } from '../type';
|
import type { NamespaceHandlers } from '../type';
|
||||||
|
import { launchStage } from '../windows-manager/stage';
|
||||||
import { getChallengeResponse } from './challenge';
|
import { getChallengeResponse } from './challenge';
|
||||||
import { getGoogleOauthCode } from './google-auth';
|
import { getGoogleOauthCode } from './google-auth';
|
||||||
|
|
||||||
@@ -46,6 +49,16 @@ export const uiHandlers = {
|
|||||||
getChallengeResponse: async (_, challenge: string) => {
|
getChallengeResponse: async (_, challenge: string) => {
|
||||||
return getChallengeResponse(challenge);
|
return getChallengeResponse(challenge);
|
||||||
},
|
},
|
||||||
|
handleOpenMainApp: async () => {
|
||||||
|
if (launchStage.value === 'onboarding') {
|
||||||
|
launchStage.value = 'main';
|
||||||
|
persistentConfig.patch('onBoarding', false);
|
||||||
|
}
|
||||||
|
initMainWindow().catch(logger.error);
|
||||||
|
getOnboardingWindow()
|
||||||
|
.then(w => w?.destroy())
|
||||||
|
.catch(logger.error);
|
||||||
|
},
|
||||||
getBookmarkDataByLink: async (_, link: string) => {
|
getBookmarkDataByLink: async (_, link: string) => {
|
||||||
if (
|
if (
|
||||||
(link.startsWith('https://x.com/') ||
|
(link.startsWith('https://x.com/') ||
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { initMainWindow } from '../main-window';
|
||||||
|
import {
|
||||||
|
getOnboardingWindow,
|
||||||
|
getOrCreateOnboardingWindow,
|
||||||
|
} from '../onboarding';
|
||||||
|
import { launchStage } from './stage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch app depending on launch stage
|
||||||
|
*/
|
||||||
|
export function launch() {
|
||||||
|
const stage = launchStage.value;
|
||||||
|
if (stage === 'main') {
|
||||||
|
initMainWindow().catch(e => {
|
||||||
|
console.error('Failed to restore or create window:', e);
|
||||||
|
});
|
||||||
|
|
||||||
|
getOnboardingWindow()
|
||||||
|
.then(w => w?.destroy())
|
||||||
|
.catch(e => console.error('Failed to destroy onboarding window:', e));
|
||||||
|
}
|
||||||
|
if (stage === 'onboarding')
|
||||||
|
getOrCreateOnboardingWindow().catch(e => {
|
||||||
|
console.error('Failed to restore or create onboarding window:', e);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { persistentConfig } from '../config-storage/persist';
|
||||||
|
import type { LaunchStage } from './types';
|
||||||
|
|
||||||
|
export const launchStage: { value: LaunchStage } = {
|
||||||
|
value: persistentConfig.get('onBoarding') ? 'onboarding' : 'main',
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type LaunchStage = 'main' | 'onboarding';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { BrowserWindow } from 'electron';
|
||||||
|
|
||||||
|
import type { LaunchStage } from './types';
|
||||||
|
|
||||||
|
export const windows$: Record<LaunchStage, Promise<BrowserWindow> | undefined> =
|
||||||
|
{
|
||||||
|
main: undefined,
|
||||||
|
onboarding: undefined,
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test } from '@affine-test/kit/playwright';
|
import { skipOnboarding, test } from '@affine-test/kit/playwright';
|
||||||
import {
|
import {
|
||||||
addUserToWorkspace,
|
addUserToWorkspace,
|
||||||
createRandomUser,
|
createRandomUser,
|
||||||
@@ -56,6 +56,7 @@ test('can enable share page', async ({ page, browser }) => {
|
|||||||
// check share page is accessible
|
// check share page is accessible
|
||||||
{
|
{
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext();
|
||||||
|
await skipOnboarding(context);
|
||||||
const url: string = await page.evaluate(() =>
|
const url: string = await page.evaluate(() =>
|
||||||
navigator.clipboard.readText()
|
navigator.clipboard.readText()
|
||||||
);
|
);
|
||||||
@@ -97,6 +98,7 @@ test('share page with default edgeless', async ({ page, browser }) => {
|
|||||||
// check share page is accessible
|
// check share page is accessible
|
||||||
{
|
{
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext();
|
||||||
|
await skipOnboarding(context);
|
||||||
const url: string = await page.evaluate(() =>
|
const url: string = await page.evaluate(() =>
|
||||||
navigator.clipboard.readText()
|
navigator.clipboard.readText()
|
||||||
);
|
);
|
||||||
@@ -135,6 +137,7 @@ test('can collaborate with other user and name should display when editing', asy
|
|||||||
const workspaceId = currentUrl.split('/')[4];
|
const workspaceId = currentUrl.split('/')[4];
|
||||||
const userB = await createRandomUser();
|
const userB = await createRandomUser();
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext();
|
||||||
|
await skipOnboarding(context);
|
||||||
const page2 = await context.newPage();
|
const page2 = await context.newPage();
|
||||||
await loginUser(page2, userB.email);
|
await loginUser(page2, userB.email);
|
||||||
await addUserToWorkspace(workspaceId, userB.id, 1 /* READ */);
|
await addUserToWorkspace(workspaceId, userB.id, 1 /* READ */);
|
||||||
@@ -205,6 +208,7 @@ test('can sync collections between different browser', async ({
|
|||||||
|
|
||||||
{
|
{
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext();
|
||||||
|
await skipOnboarding(context);
|
||||||
const page2 = await context.newPage();
|
const page2 = await context.newPage();
|
||||||
await loginUser(page2, user.email);
|
await loginUser(page2, user.email);
|
||||||
await page2.goto(page.url());
|
await page2.goto(page.url());
|
||||||
@@ -268,6 +272,7 @@ test('can sync svg between different browsers', async ({ page, browser }) => {
|
|||||||
|
|
||||||
{
|
{
|
||||||
const context = await browser.newContext();
|
const context = await browser.newContext();
|
||||||
|
await skipOnboarding(context);
|
||||||
const page2 = await context.newPage();
|
const page2 = await context.newPage();
|
||||||
await loginUser(page2, user.email);
|
await loginUser(page2, user.email);
|
||||||
await page2.goto(page.url());
|
await page2.goto(page.url());
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/* eslint-disable unicorn/prefer-dom-node-dataset */
|
/* eslint-disable unicorn/prefer-dom-node-dataset */
|
||||||
|
import { test } from '@affine-test/kit/playwright';
|
||||||
import { openHomePage } from '@affine-test/kit/utils/load-page';
|
import { openHomePage } from '@affine-test/kit/utils/load-page';
|
||||||
import {
|
import {
|
||||||
clickNewPageButton,
|
clickNewPageButton,
|
||||||
getBlockSuiteEditorTitle,
|
getBlockSuiteEditorTitle,
|
||||||
waitForEditorLoad,
|
waitForEditorLoad,
|
||||||
} from '@affine-test/kit/utils/page-logic';
|
} from '@affine-test/kit/utils/page-logic';
|
||||||
import type { Page } from '@playwright/test';
|
import { expect, type Page } from '@playwright/test';
|
||||||
import { expect, test } from '@playwright/test';
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|
||||||
async function importImage(page: Page, url: string) {
|
async function importImage(page: Page, url: string) {
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { openHomePage } from '@affine-test/kit/utils/load-page';
|
|||||||
import { waitForEditorLoad } from '@affine-test/kit/utils/page-logic';
|
import { waitForEditorLoad } from '@affine-test/kit/utils/page-logic';
|
||||||
import { expect } from '@playwright/test';
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.use({
|
||||||
|
colorScheme: 'light',
|
||||||
|
});
|
||||||
|
|
||||||
// default could be anything, according to the system
|
// default could be anything, according to the system
|
||||||
test('default white', async ({ browser }) => {
|
test('default white', async ({ page }) => {
|
||||||
const context = await browser.newContext({
|
|
||||||
colorScheme: 'light',
|
|
||||||
});
|
|
||||||
const page = await context.newPage();
|
|
||||||
await openHomePage(page);
|
await openHomePage(page);
|
||||||
await waitForEditorLoad(page);
|
await waitForEditorLoad(page);
|
||||||
const root = page.locator('html');
|
const root = page.locator('html');
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { patchDataEnhancement } from '@affine-test/kit/e2e-enhance/initializer';
|
import { patchDataEnhancement } from '@affine-test/kit/e2e-enhance/initializer';
|
||||||
import { SnapshotStorage } from '@affine-test/kit/e2e-enhance/snapshot';
|
import { SnapshotStorage } from '@affine-test/kit/e2e-enhance/snapshot';
|
||||||
|
import { test } from '@affine-test/kit/playwright';
|
||||||
import { clickEdgelessModeButton } from '@affine-test/kit/utils/editor';
|
import { clickEdgelessModeButton } from '@affine-test/kit/utils/editor';
|
||||||
import { coreUrl } from '@affine-test/kit/utils/load-page';
|
import { coreUrl } from '@affine-test/kit/utils/load-page';
|
||||||
import { waitForEditorLoad } from '@affine-test/kit/utils/page-logic';
|
import { waitForEditorLoad } from '@affine-test/kit/utils/page-logic';
|
||||||
import { clickSideBarAllPageButton } from '@affine-test/kit/utils/sidebar';
|
import { clickSideBarAllPageButton } from '@affine-test/kit/utils/sidebar';
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
async function open404PageToInitData(page: Page, version: string) {
|
async function open404PageToInitData(page: Page, version: string) {
|
||||||
const snapshotStorage = new SnapshotStorage(version);
|
const snapshotStorage = new SnapshotStorage(version);
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ export const test = base.extend<{
|
|||||||
env.DEV_SERVER_URL = process.env.DEV_SERVER_URL;
|
env.DEV_SERVER_URL = process.env.DEV_SERVER_URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
env.SKIP_ONBOARDING = '1';
|
||||||
|
|
||||||
const electronApp = await electron.launch({
|
const electronApp = await electron.launch({
|
||||||
args: [clonedDist],
|
args: [clonedDist],
|
||||||
env,
|
env,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import path, { resolve } from 'node:path';
|
|||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
|
||||||
import type { Workspace } from '@blocksuite/store';
|
import type { Workspace } from '@blocksuite/store';
|
||||||
import { test as baseTest } from '@playwright/test';
|
import { type BrowserContext, test as baseTest } from '@playwright/test';
|
||||||
|
|
||||||
export const rootDir = resolve(__dirname, '..', '..');
|
export const rootDir = resolve(__dirname, '..', '..');
|
||||||
// assert that the rootDir is the root of the project
|
// assert that the rootDir is the root of the project
|
||||||
@@ -32,6 +32,12 @@ type CurrentWorkspace = {
|
|||||||
blockSuiteWorkspace: Workspace;
|
blockSuiteWorkspace: Workspace;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const skipOnboarding = async (context: BrowserContext) => {
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
window.localStorage.setItem('app_config', '{"onBoarding":false}');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const test = baseTest.extend<{
|
export const test = baseTest.extend<{
|
||||||
workspace: {
|
workspace: {
|
||||||
current: () => Promise<CurrentWorkspace>;
|
current: () => Promise<CurrentWorkspace>;
|
||||||
@@ -59,6 +65,9 @@ export const test = baseTest.extend<{
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
context: async ({ context }, use) => {
|
context: async ({ context }, use) => {
|
||||||
|
// workaround for skipping onboarding redirect on the web
|
||||||
|
await skipOnboarding(context);
|
||||||
|
|
||||||
if (enableCoverage) {
|
if (enableCoverage) {
|
||||||
await context.addInitScript(() =>
|
await context.addInitScript(() =>
|
||||||
window.addEventListener('beforeunload', () =>
|
window.addEventListener('beforeunload', () =>
|
||||||
|
|||||||
2
tools/@types/env/__all.d.ts
vendored
2
tools/@types/env/__all.d.ts
vendored
@@ -1,5 +1,6 @@
|
|||||||
import type { Environment, RuntimeConfig } from '@affine/env/global';
|
import type { Environment, RuntimeConfig } from '@affine/env/global';
|
||||||
import type {
|
import type {
|
||||||
|
ConfigStorageHandlerManager,
|
||||||
DBHandlerManager,
|
DBHandlerManager,
|
||||||
DebugHandlerManager,
|
DebugHandlerManager,
|
||||||
DialogHandlerManager,
|
DialogHandlerManager,
|
||||||
@@ -24,6 +25,7 @@ declare global {
|
|||||||
ui: UnwrapManagerHandlerToClientSide<UIHandlerManager>;
|
ui: UnwrapManagerHandlerToClientSide<UIHandlerManager>;
|
||||||
updater: UnwrapManagerHandlerToClientSide<UpdaterHandlerManager>;
|
updater: UnwrapManagerHandlerToClientSide<UpdaterHandlerManager>;
|
||||||
workspace: UnwrapManagerHandlerToClientSide<WorkspaceHandlerManager>;
|
workspace: UnwrapManagerHandlerToClientSide<WorkspaceHandlerManager>;
|
||||||
|
configStorage: UnwrapManagerHandlerToClientSide<ConfigStorageHandlerManager>;
|
||||||
};
|
};
|
||||||
events: EventMap;
|
events: EventMap;
|
||||||
affine: {
|
affine: {
|
||||||
|
|||||||
Reference in New Issue
Block a user