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:
Cats Juice
2023-12-19 07:17:54 +00:00
parent e0d328676d
commit 15dd20ef48
32 changed files with 470 additions and 29 deletions

View File

@@ -10,6 +10,7 @@ import {
useNavigationType,
} from 'react-router-dom';
import { appConfigProxy } from '../hooks/use-app-config-storage';
import { performanceLogger } from '../shared';
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');
}

View 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];
}

View File

@@ -2,9 +2,11 @@ import { Menu } from '@affine/component/ui/menu';
import { workspaceListAtom } from '@affine/workspace/atom';
import { useAtomValue } from 'jotai';
import { lazy, useEffect } from 'react';
import { type LoaderFunction, redirect } from 'react-router-dom';
import { createFirstAppData } from '../bootstrap/first-app-data';
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 { 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 = () => {
const list = useAtomValue(workspaceListAtom);
const { openPage } = useNavigateHelper();

View 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>
);
};

View File

@@ -65,6 +65,10 @@ export const routes = [
path: '/desktop-signin',
lazy: () => import('./pages/desktop-signin'),
},
{
path: '/onboarding',
lazy: () => import('./pages/onboarding'),
},
{
path: '*',
lazy: () => import('./pages/404'),

View File

@@ -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;

View File

@@ -0,0 +1 @@
export * from './handlers';

View File

@@ -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)),
});

View File

@@ -0,0 +1 @@
export const mainWindowOrigin = process.env.DEV_SERVER_URL || 'file://.';

View File

@@ -3,11 +3,11 @@ import path from 'node:path';
import { type App, type BrowserWindow, ipcMain } from 'electron';
import { buildType, CLOUD_BASE_URL, isDev } from './config';
import { mainWindowOrigin } from './constants';
import { logger } from './logger';
import {
getMainWindow,
handleOpenUrlInHiddenWindow,
mainWindowOrigin,
removeCookie,
setCookie,
} from './main-window';

View File

@@ -2,7 +2,7 @@ import { app, BrowserWindow } from 'electron';
import { applicationMenuEvents } from './application-menu';
import { logger } from './logger';
import { uiEvents } from './ui';
import { uiEvents } from './ui/events';
import { updaterEvents } from './updater/event';
export const allEvents = {

View File

@@ -1,5 +1,6 @@
import type {
ClipboardHandlerManager,
ConfigStorageHandlerManager,
DebugHandlerManager,
ExportHandlerManager,
UIHandlerManager,
@@ -9,9 +10,10 @@ import type {
import { ipcMain } from 'electron';
import { clipboardHandlers } from './clipboard';
import { configStorageHandlers } from './config-storage';
import { exportHandlers } from './export';
import { getLogFilePath, logger, revealLogFile } from './logger';
import { uiHandlers } from './ui';
import { uiHandlers } from './ui/handlers';
import { updaterHandlers } from './updater';
export const debugHandlers = {
@@ -44,6 +46,10 @@ type AllHandlers = {
Electron.IpcMainInvokeEvent,
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
@@ -53,6 +59,7 @@ export const allHandlers = {
clipboard: clipboardHandlers,
export: exportHandlers,
updater: updaterHandlers,
configStorage: configStorageHandlers,
} satisfies AllHandlers;
export const registerHandlers = () => {

View File

@@ -11,9 +11,10 @@ import { registerEvents } from './events';
import { registerHandlers } from './handlers';
import { ensureHelperProcess } from './helper-process';
import { logger } from './logger';
import { initMainWindow as initMainWindow } from './main-window';
import { registerProtocol } from './protocol';
import { registerUpdater } from './updater';
import { launch } from './windows-manager/launcher';
import { launchStage } from './windows-manager/stage';
app.enableSandbox();
@@ -26,11 +27,9 @@ if (overrideSession) {
}
if (require('electron-squirrel-startup')) app.quit();
// allow tests to overwrite app name through passing args
if (process.argv.includes('--app-name')) {
const appNameIndex = process.argv.indexOf('--app-name');
const appName = process.argv[appNameIndex + 1];
app.setName(appName);
if (process.env.SKIP_ONBOARDING) {
launchStage.value = 'main';
}
/**
@@ -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', () => {
initMainWindow().catch(e =>
console.error('Failed to restore or create window:', e)
);
});
app.on('activate', launch);
setupDeepLink(app);
@@ -72,7 +67,7 @@ app
.then(registerHandlers)
.then(registerEvents)
.then(ensureHelperProcess)
.then(initMainWindow)
.then(launch)
.then(createApplicationMenu)
.then(registerUpdater)
.catch(e => console.error('Failed create window:', e));

View File

@@ -5,6 +5,7 @@ import electronWindowState from 'electron-window-state';
import { join } from 'path';
import { isMacOS, isWindows } from '../shared/utils';
import { mainWindowOrigin } from './constants';
import { ensureHelperProcess } from './helper-process';
import { logger } from './logger';
import { uiSubjects } from './ui/subject';
@@ -15,8 +16,6 @@ const IS_DEV: boolean =
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
const getWindowAdditionalArguments = async () => {
const { getExposedMeta } = await import('./exposed');

View 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;
}

View File

@@ -2,9 +2,12 @@ import { app, nativeTheme } from 'electron';
import { getLinkPreview } from 'link-preview-js';
import { isMacOS } from '../../shared/utils';
import { persistentConfig } from '../config-storage/persist';
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 { launchStage } from '../windows-manager/stage';
import { getChallengeResponse } from './challenge';
import { getGoogleOauthCode } from './google-auth';
@@ -46,6 +49,16 @@ export const uiHandlers = {
getChallengeResponse: async (_, challenge: string) => {
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) => {
if (
(link.startsWith('https://x.com/') ||

View File

@@ -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);
});
}

View File

@@ -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',
};

View File

@@ -0,0 +1 @@
export type LaunchStage = 'main' | 'onboarding';

View File

@@ -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,
};