feat: add open app route (#3899)

This commit is contained in:
Peng Xiao
2023-08-30 06:40:25 +08:00
committed by GitHub
parent 71b195d9a9
commit 800f3c3cb6
29 changed files with 486 additions and 104 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -2,7 +2,6 @@ import {
AuthModal as AuthModalBase, AuthModal as AuthModalBase,
type AuthModalProps as AuthModalBaseProps, type AuthModalProps as AuthModalBaseProps,
} from '@affine/component/auth-components'; } from '@affine/component/auth-components';
import { isDesktop } from '@affine/env/constant';
import { atom, useAtom } from 'jotai'; import { atom, useAtom } from 'jotai';
import { type FC, useCallback, useEffect, useMemo } from 'react'; import { type FC, useCallback, useEffect, useMemo } from 'react';
@@ -80,15 +79,6 @@ export const AuthModal: FC<AuthModalBaseProps & AuthProps> = ({
} }
}, [open, setAuthEmail, setAuthStore]); }, [open, setAuthEmail, setAuthStore]);
useEffect(() => {
if (isDesktop) {
return window.events?.ui.onFinishLogin(() => {
setOpen(false);
});
}
return;
}, [setOpen]);
const onSignedIn = useCallback(() => { const onSignedIn = useCallback(() => {
setOpen(false); setOpen(false);
}, [setOpen]); }, [setOpen]);

View File

@@ -0,0 +1,26 @@
import { Modal, ModalWrapper } from '@affine/component';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import * as styles from './styles.css';
export const DesktopLoginModal = ({
signingEmail,
}: {
signingEmail?: string;
}) => {
const t = useAFFiNEI18N();
return (
<Modal open={!!signingEmail}>
<ModalWrapper className={styles.root}>
<div className={styles.title}>
{t['com.affine.auth.desktop.signing.in']()}
</div>
<Trans i18nKey="com.affine.auth.desktop.signing.in.message">
Signing in with account {signingEmail}
</Trans>
</ModalWrapper>
</Modal>
);
};

View File

@@ -0,0 +1,11 @@
import { style } from '@vanilla-extract/css';
export const root = style({
padding: '20px 24px',
});
export const title = style({
fontSize: 'var(--affine-font-h-6)',
fontWeight: 600,
marginBottom: 12,
});

View File

@@ -7,7 +7,7 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ContactWithUsIcon } from '@blocksuite/icons'; import { ContactWithUsIcon } from '@blocksuite/icons';
import { Suspense, useCallback } from 'react'; import { Suspense, useCallback } from 'react';
import { useCurrenLoginStatus } from '../../../hooks/affine/use-curren-login-status'; import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
import { AccountSetting } from './account-setting'; import { AccountSetting } from './account-setting';
import { import {
GeneralSetting, GeneralSetting,
@@ -39,7 +39,7 @@ export const SettingModal = ({
onSettingClick, onSettingClick,
}: SettingModalProps) => { }: SettingModalProps) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const loginStatus = useCurrenLoginStatus(); const loginStatus = useCurrentLoginStatus();
const generalSettingList = useGeneralSettingList(); const generalSettingList = useGeneralSettingList();

View File

@@ -23,7 +23,7 @@ import {
} from 'react'; } from 'react';
import { authAtom } from '../../../../atoms'; import { authAtom } from '../../../../atoms';
import { useCurrenLoginStatus } from '../../../../hooks/affine/use-curren-login-status'; import { useCurrentLoginStatus } from '../../../../hooks/affine/use-current-login-status';
import { useCurrentUser } from '../../../../hooks/affine/use-current-user'; import { useCurrentUser } from '../../../../hooks/affine/use-current-user';
import { useCurrentWorkspace } from '../../../../hooks/current/use-current-workspace'; import { useCurrentWorkspace } from '../../../../hooks/current/use-current-workspace';
import type { import type {
@@ -113,7 +113,7 @@ export const SettingSidebar = ({
onAccountSettingClick: () => void; onAccountSettingClick: () => void;
}) => { }) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const loginStatus = useCurrenLoginStatus(); const loginStatus = useCurrentLoginStatus();
return ( return (
<div className={settingSlideBar} data-testid="settings-sidebar"> <div className={settingSlideBar} data-testid="settings-sidebar">
<div className={sidebarTitle}>{t['Settings']()}</div> <div className={sidebarTitle}>{t['Settings']()}</div>

View File

@@ -3,13 +3,13 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloudWorkspaceIcon } from '@blocksuite/icons'; import { CloudWorkspaceIcon } from '@blocksuite/icons';
import { signIn } from 'next-auth/react'; import { signIn } from 'next-auth/react';
import { useCurrenLoginStatus } from '../../hooks/affine/use-curren-login-status'; import { useCurrentLoginStatus } from '../../hooks/affine/use-current-login-status';
import { useCurrentUser } from '../../hooks/affine/use-current-user'; import { useCurrentUser } from '../../hooks/affine/use-current-user';
import { StyledSignInButton } from '../pure/footer/styles'; import { StyledSignInButton } from '../pure/footer/styles';
export const LoginCard = () => { export const LoginCard = () => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const loginStatus = useCurrenLoginStatus(); const loginStatus = useCurrentLoginStatus();
if (loginStatus === 'authenticated') { if (loginStatus === 'authenticated') {
return <UserCard />; return <UserCard />;
} }

View File

@@ -3,12 +3,12 @@ import { CloudWorkspaceIcon } from '@blocksuite/icons';
import { signIn } from 'next-auth/react'; import { signIn } from 'next-auth/react';
import { type CSSProperties, type FC, forwardRef, useCallback } from 'react'; import { type CSSProperties, type FC, forwardRef, useCallback } from 'react';
import { useCurrenLoginStatus } from '../../../hooks/affine/use-curren-login-status'; import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
// import { openDisableCloudAlertModalAtom } from '../../../atoms'; // import { openDisableCloudAlertModalAtom } from '../../../atoms';
import { stringToColour } from '../../../utils'; import { stringToColour } from '../../../utils';
import { StyledFooter, StyledSignInButton } from './styles'; import { StyledFooter, StyledSignInButton } from './styles';
export const Footer: FC = () => { export const Footer: FC = () => {
const loginStatus = useCurrenLoginStatus(); const loginStatus = useCurrentLoginStatus();
// const setOpen = useSetAtom(openDisableCloudAlertModalAtom); // const setOpen = useSetAtom(openDisableCloudAlertModalAtom);
return ( return (

View File

@@ -1,7 +1,7 @@
// eslint-disable-next-line @typescript-eslint/no-restricted-imports // eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
export function useCurrenLoginStatus(): export function useCurrentLoginStatus():
| 'authenticated' | 'authenticated'
| 'unauthenticated' | 'unauthenticated'
| 'loading' { | 'loading' {

View File

@@ -5,7 +5,6 @@ import {
SignInSuccessPage, SignInSuccessPage,
SignUpPage, SignUpPage,
} from '@affine/component/auth-components'; } from '@affine/component/auth-components';
import { isDesktop } from '@affine/env/constant';
import { changeEmailMutation, changePasswordMutation } from '@affine/graphql'; import { changeEmailMutation, changePasswordMutation } from '@affine/graphql';
import { useMutation } from '@affine/workspace/affine/gql'; import { useMutation } from '@affine/workspace/affine/gql';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
@@ -13,7 +12,7 @@ import { useCallback } from 'react';
import { type LoaderFunction, redirect, useParams } from 'react-router-dom'; import { type LoaderFunction, redirect, useParams } from 'react-router-dom';
import { z } from 'zod'; import { z } from 'zod';
import { useCurrenLoginStatus } from '../hooks/affine/use-curren-login-status'; import { useCurrentLoginStatus } from '../hooks/affine/use-current-login-status';
import { useCurrentUser } from '../hooks/affine/use-current-user'; import { useCurrentUser } from '../hooks/affine/use-current-user';
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper'; import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
@@ -58,11 +57,7 @@ export const AuthPage = (): ReactElement | null => {
[changePassword, user.id] [changePassword, user.id]
); );
const onOpenAffine = useCallback(() => { const onOpenAffine = useCallback(() => {
if (isDesktop) { jumpToIndex(RouteLogic.REPLACE);
window.apis.ui.handleFinishLogin();
} else {
jumpToIndex(RouteLogic.REPLACE);
}
}, [jumpToIndex]); }, [jumpToIndex]);
switch (authType) { switch (authType) {
@@ -119,7 +114,7 @@ export const loader: LoaderFunction = async args => {
return null; return null;
}; };
export const Component = () => { export const Component = () => {
const loginStatus = useCurrenLoginStatus(); const loginStatus = useCurrentLoginStatus();
const { jumpToExpired } = useNavigateHelper(); const { jumpToExpired } = useNavigateHelper();
if (loginStatus === 'unauthenticated') { if (loginStatus === 'unauthenticated') {

View File

@@ -11,7 +11,7 @@ import { useCallback, useEffect } from 'react';
import { type LoaderFunction, redirect, useLoaderData } from 'react-router-dom'; import { type LoaderFunction, redirect, useLoaderData } from 'react-router-dom';
import { authAtom } from '../atoms'; import { authAtom } from '../atoms';
import { useCurrenLoginStatus } from '../hooks/affine/use-curren-login-status'; import { useCurrentLoginStatus } from '../hooks/affine/use-current-login-status';
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper'; import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
import { useAppHelper } from '../hooks/use-workspaces'; import { useAppHelper } from '../hooks/use-workspaces';
@@ -45,7 +45,7 @@ export const loader: LoaderFunction = async args => {
}; };
export const Component = () => { export const Component = () => {
const loginStatus = useCurrenLoginStatus(); const loginStatus = useCurrentLoginStatus();
const { jumpToSignIn } = useNavigateHelper(); const { jumpToSignIn } = useNavigateHelper();
const { addCloudWorkspace } = useAppHelper(); const { addCloudWorkspace } = useAppHelper();
const { jumpToSubPath } = useNavigateHelper(); const { jumpToSubPath } = useNavigateHelper();

View File

@@ -0,0 +1,58 @@
import { style } from '@vanilla-extract/css';
export const root = style({
height: '100vh',
width: '100vw',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontSize: 'var(--affine-font-base)',
position: 'relative',
});
export const affineLogo = style({
color: 'inherit',
});
export const topNav = style({
position: 'absolute',
top: 0,
left: 0,
right: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 120px',
});
export const topNavLinks = style({
display: 'flex',
columnGap: 4,
});
export const topNavLink = style({
color: 'var(--affine-text-primary-color)',
fontSize: 'var(--affine-font-sm)',
fontWeight: 500,
textDecoration: 'none',
padding: '4px 18px',
});
export const tryAgainLink = style({
color: 'var(--affine-link-color)',
fontWeight: 500,
textDecoration: 'none',
fontSize: 'var(--affine-font-sm)',
});
export const centerContent = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginTop: 40,
});
export const prompt = style({
marginTop: 20,
marginBottom: 12,
});

View File

@@ -0,0 +1,151 @@
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Logo1Icon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button';
import { useCallback, useEffect, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { z } from 'zod';
import * as styles from './open-app.css';
let lastOpened = '';
const appSchemas = z.enum([
'affine',
'affine-canary',
'affine-beta',
'affine-internal',
'affine-dev',
]);
const appChannelSchema = z.enum(['stable', 'canary', 'beta', 'internal']);
type Schema = z.infer<typeof appSchemas>;
type Channel = z.infer<typeof appChannelSchema>;
const schemaToChanel = {
affine: 'stable',
'affine-canary': 'canary',
'affine-beta': 'beta',
'affine-internal': 'internal',
'affine-dev': 'canary', // dev does not have a dedicated app. use canary as the placeholder.
} as Record<Schema, Channel>;
const appIconMap = {
stable: '/imgs/app-icon-stable.ico',
canary: '/imgs/app-icon-canary.ico',
beta: '/imgs/app-icon-beta.ico',
internal: '/imgs/app-icon-internal.ico',
} satisfies Record<Channel, string>;
const appNames = {
stable: 'AFFiNE',
canary: 'AFFiNE Canary',
beta: 'AFFiNE Beta',
internal: 'AFFiNE Internal',
} satisfies Record<Channel, string>;
export const Component = () => {
const t = useAFFiNEI18N();
const [params] = useSearchParams();
const urlToOpen = useMemo(() => params.get('url'), [params]);
const autoOpen = useMemo(() => params.get('open') !== 'false', [params]);
const channel = useMemo(() => {
const urlObj = new URL(urlToOpen || '');
const maybeSchema = appSchemas.safeParse(urlObj.protocol.replace(':', ''));
return schemaToChanel[maybeSchema.success ? maybeSchema.data : 'affine'];
}, [urlToOpen]);
const appIcon = appIconMap[channel];
const appName = appNames[channel];
const openDownloadLink = useCallback(() => {
const url = `https://affine.pro/download?channel=${channel}`;
open(url, '_blank');
}, [channel]);
useEffect(() => {
if (!urlToOpen || lastOpened === urlToOpen || !autoOpen) {
return;
}
lastOpened = urlToOpen;
open(urlToOpen, '_blank');
}, [urlToOpen, autoOpen]);
if (urlToOpen) {
return (
<div className={styles.root}>
<div className={styles.topNav}>
<a
href="https://affine.pro"
target="_blank"
rel="noreferrer"
className={styles.affineLogo}
>
<Logo1Icon width={24} height={24} />
</a>
<div className={styles.topNavLinks}>
<a
href="https://affine.pro"
target="_blank"
rel="noreferrer"
className={styles.topNavLink}
>
Official Website
</a>
<a
href="https://community.affine.pro/home"
target="_blank"
rel="noreferrer"
className={styles.topNavLink}
>
AFFiNE Community
</a>
<a
href="https://affine.pro/blog"
target="_blank"
rel="noreferrer"
className={styles.topNavLink}
>
Blog
</a>
<a
href="https://affine.pro/about-us"
target="_blank"
rel="noreferrer"
className={styles.topNavLink}
>
Contact us
</a>
</div>
<Button onClick={openDownloadLink}>
{t['com.affine.auth.open.affine.download-app']()}
</Button>
</div>
<div className={styles.centerContent}>
<img src={appIcon} alt={appName} width={120} height={120} />
<div className={styles.prompt}>
<Trans i18nKey="com.affine.auth.open.affine.prompt">
Open {appName} app now
</Trans>
</div>
<a
className={styles.tryAgainLink}
href={urlToOpen}
target="_blank"
rel="noreferrer"
>
{t['com.affine.auth.open.affine.try-again']()}
</a>
</div>
</div>
);
} else {
return null;
}
};

View File

@@ -6,7 +6,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { authAtom } from '../atoms'; import { authAtom } from '../atoms';
import { AuthPanel } from '../components/affine/auth'; import { AuthPanel } from '../components/affine/auth';
import { useCurrenLoginStatus } from '../hooks/affine/use-curren-login-status'; import { useCurrentLoginStatus } from '../hooks/affine/use-current-login-status';
interface LocationState { interface LocationState {
state: { state: {
@@ -18,7 +18,7 @@ export const Component = () => {
{ state, email = '', emailType = 'changePassword', onceSignedIn }, { state, email = '', emailType = 'changePassword', onceSignedIn },
setAuthAtom, setAuthAtom,
] = useAtom(authAtom); ] = useAtom(authAtom);
const loginStatus = useCurrenLoginStatus(); const loginStatus = useCurrentLoginStatus();
const location = useLocation() as LocationState; const location = useLocation() as LocationState;
const navigate = useNavigate(); const navigate = useNavigate();

View File

@@ -1,4 +1,7 @@
import { pushNotificationAtom } from '@affine/component/notification-center';
import { isDesktop } from '@affine/env/constant';
import { WorkspaceSubPath } from '@affine/env/workspace'; import { WorkspaceSubPath } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom'; import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
import { assertExists } from '@blocksuite/global/utils'; import { assertExists } from '@blocksuite/global/utils';
import { arrayMove } from '@dnd-kit/sortable'; import { arrayMove } from '@dnd-kit/sortable';
@@ -8,7 +11,14 @@ import {
} from '@toeverything/infra/atom'; } from '@toeverything/infra/atom';
import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { lazy, Suspense, useCallback, useTransition } from 'react'; import {
lazy,
Suspense,
useCallback,
useEffect,
useState,
useTransition,
} from 'react';
import type { SettingAtom } from '../atoms'; import type { SettingAtom } from '../atoms';
import { import {
@@ -32,6 +42,12 @@ const Auth = lazy(() =>
})) }))
); );
const DesktopLogin = lazy(() =>
import('../components/affine/desktop-login-modal').then(module => ({
default: module.DesktopLoginModal,
}))
);
const WorkspaceListModal = lazy(() => const WorkspaceListModal = lazy(() =>
import('../components/pure/workspace-list-modal').then(module => ({ import('../components/pure/workspace-list-modal').then(module => ({
default: module.WorkspaceListModal, default: module.WorkspaceListModal,
@@ -129,6 +145,49 @@ export const AuthModal = (): ReactElement => {
); );
}; };
export const DesktopLoginModal = (): ReactElement => {
const [signingEmail, setSigningEmail] = useState<string>();
const setAuthAtom = useSetAtom(authAtom);
const pushNotification = useSetAtom(pushNotificationAtom);
const t = useAFFiNEI18N();
// hack for closing the potentially opened auth modal
const closeAuthModal = useCallback(() => {
setAuthAtom(prev => ({ ...prev, openModal: false }));
}, [setAuthAtom]);
useEffect(() => {
return window.events?.ui.onStartLogin(opts => {
setSigningEmail(opts.email);
});
}, []);
useEffect(() => {
return window.events?.ui.onFinishLogin(({ success, email }) => {
if (email !== signingEmail) {
return;
}
setSigningEmail(undefined);
closeAuthModal();
if (success) {
pushNotification({
title: t['com.affine.auth.toast.title.signed-in'](),
message: t['com.affine.auth.toast.message.signed-in'](),
type: 'success',
});
} else {
pushNotification({
title: t['com.affine.auth.toast.title.failed'](),
message: t['com.affine.auth.toast.message.failed'](),
type: 'error',
});
}
});
}, [closeAuthModal, pushNotification, signingEmail, t]);
return <DesktopLogin signingEmail={signingEmail} />;
};
export function CurrentWorkspaceModals() { export function CurrentWorkspaceModals() {
const [currentWorkspace] = useCurrentWorkspace(); const [currentWorkspace] = useCurrentWorkspace();
const [openDisableCloudAlertModal, setOpenDisableCloudAlertModal] = useAtom( const [openDisableCloudAlertModal, setOpenDisableCloudAlertModal] = useAtom(
@@ -263,6 +322,7 @@ export const AllWorkspaceModals = (): ReactElement => {
<Suspense> <Suspense>
<AuthModal /> <AuthModal />
</Suspense> </Suspense>
{isDesktop && <DesktopLoginModal />}
</> </>
); );
}; };

View File

@@ -48,6 +48,10 @@ export const routes = [
path: '/signIn', path: '/signIn',
lazy: () => import('./pages/sign-in'), lazy: () => import('./pages/sign-in'),
}, },
{
path: '/open-app',
lazy: () => import('./pages/open-app'),
},
{ {
path: '*', path: '*',
lazy: () => import('./pages/404'), lazy: () => import('./pages/404'),

View File

@@ -1,8 +1,14 @@
import path from 'node:path';
import type { App } from 'electron'; import type { App } from 'electron';
import { buildType, isDev } from './config'; import { buildType, isDev } from './config';
import { logger } from './logger'; import { logger } from './logger';
import { handleOpenUrlInPopup } from './main-window'; import {
handleOpenUrlInHiddenWindow,
restoreOrCreateWindow,
} from './main-window';
import { uiSubjects } from './ui';
let protocol = buildType === 'stable' ? 'affine' : `affine-${buildType}`; let protocol = buildType === 'stable' ? 'affine' : `affine-${buildType}`;
if (isDev) { if (isDev) {
@@ -10,7 +16,16 @@ if (isDev) {
} }
export function setupDeepLink(app: App) { export function setupDeepLink(app: App) {
app.setAsDefaultProtocolClient(protocol); if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient(protocol, process.execPath, [
path.resolve(process.argv[1]),
]);
}
} else {
app.setAsDefaultProtocolClient(protocol);
}
app.on('open-url', (event, url) => { app.on('open-url', (event, url) => {
if (url.startsWith(`${protocol}://`)) { if (url.startsWith(`${protocol}://`)) {
event.preventDefault(); event.preventDefault();
@@ -19,17 +34,72 @@ export function setupDeepLink(app: App) {
}); });
} }
}); });
// on windows & linux, we need to listen for the second-instance event
app.on('second-instance', (event, commandLine) => {
restoreOrCreateWindow()
.then(() => {
const url = commandLine.pop();
if (url?.startsWith(`${protocol}://`)) {
event.preventDefault();
handleAffineUrl(url).catch(e => {
logger.error('failed to handle affine url', e);
});
}
})
.catch(e => console.error('Failed to restore or create window:', e));
});
} }
async function handleAffineUrl(url: string) { async function handleAffineUrl(url: string) {
logger.info('open affine url', url); logger.info('open affine url', url);
const urlObj = new URL(url); const urlObj = new URL(url);
if (urlObj.hostname === 'open-url') { logger.info('handle affine schema action', urlObj.hostname);
// handle more actions here
// hostname is the action name
if (urlObj.hostname === 'sign-in') {
const urlToOpen = urlObj.search.slice(1); const urlToOpen = urlObj.search.slice(1);
if (urlToOpen) { if (urlToOpen) {
handleOpenUrlInPopup(urlToOpen).catch(e => { await handleSignIn(urlToOpen);
logger.error('failed to open url in popup', e); }
}); }
}
// todo: move to another place?
async function handleSignIn(url: string) {
if (url) {
try {
const mainWindow = await restoreOrCreateWindow();
mainWindow.show();
const urlObj = new URL(url);
const email = urlObj.searchParams.get('email');
if (!email) {
logger.error('no email in url', url);
return;
}
uiSubjects.onStartLogin.next({
email,
});
const window = await handleOpenUrlInHiddenWindow(url);
logger.info('opened url in hidden window', window.webContents.getURL());
// check path
// - if path === /auth/{signIn,signUp}, we know sign in succeeded
// - if path === expired, we know sign in failed
const finalUrl = new URL(window.webContents.getURL());
console.log('final url', finalUrl);
// hack: wait for the hidden window to send broadcast message to the main window
// that's how next-auth works for cross-tab communication
setTimeout(() => {
window.destroy();
}, 3000);
uiSubjects.onFinishLogin.next({
success: ['/auth/signIn', '/auth/signUp'].includes(finalUrl.pathname),
email,
});
} catch (e) {
logger.error('failed to open url in popup', e);
} }
} }
} }

View File

@@ -33,12 +33,6 @@ if (!isSingleInstance) {
process.exit(0); process.exit(0);
} }
app.on('second-instance', () => {
restoreOrCreateWindow().catch(e =>
console.error('Failed to restore or create window:', e)
);
});
/** /**
* Shout down background process if all windows was closed * Shout down background process if all windows was closed
*/ */

View File

@@ -95,7 +95,13 @@ async function createWindow() {
browserWindow.on('close', e => { browserWindow.on('close', e => {
e.preventDefault(); e.preventDefault();
browserWindow.destroy(); // close and destroy all windows
BrowserWindow.getAllWindows().forEach(w => {
if (!w.isDestroyed()) {
w.close();
w.destroy();
}
});
helperConnectionUnsub?.(); helperConnectionUnsub?.();
// TODO: gracefully close the app, for example, ask user to save unsaved changes // TODO: gracefully close the app, for example, ask user to save unsaved changes
}); });
@@ -123,44 +129,12 @@ async function createWindow() {
// singleton // singleton
let browserWindow: 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 * Restore existing BrowserWindow or Create new BrowserWindow
*/ */
export async function restoreOrCreateWindow() { export async function restoreOrCreateWindow() {
browserWindow = if (!browserWindow || browserWindow.isDestroyed()) {
browserWindow || BrowserWindow.getAllWindows().find(w => !w.isDestroyed());
if (browserWindow === undefined) {
browserWindow = await createWindow(); browserWindow = await createWindow();
} }
@@ -172,17 +146,29 @@ export async function restoreOrCreateWindow() {
return browserWindow; return browserWindow;
} }
export async function handleOpenUrlInPopup(url: string) { export async function handleOpenUrlInHiddenWindow(url: string) {
const popup = createPopupWindow(); const mainExposedMeta = getExposedMeta();
await popup.loadURL(url); const win = new BrowserWindow({
} width: 1200,
height: 600,
export function closePopup() { webPreferences: {
if (!popup?.isDestroyed()) { preload: join(__dirname, './preload.js'),
popup?.close(); additionalArguments: [
popup?.destroy(); `--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
popup = undefined; // popup window does not need helper process, right?
} ],
},
show: false,
});
win.on('close', e => {
e.preventDefault();
if (!win.isDestroyed()) {
win.destroy();
}
});
logger.info('loading page at', url);
await win.loadURL(url);
return win;
} }
export function reloadApp() { export function reloadApp() {

View File

@@ -5,10 +5,18 @@ import { uiSubjects } from './subject';
* Events triggered by application menu * Events triggered by application menu
*/ */
export const uiEvents = { export const uiEvents = {
onFinishLogin: (fn: () => void) => { onFinishLogin: (
fn: (result: { success: boolean; email: string }) => void
) => {
const sub = uiSubjects.onFinishLogin.subscribe(fn); const sub = uiSubjects.onFinishLogin.subscribe(fn);
return () => { return () => {
sub.unsubscribe(); sub.unsubscribe();
}; };
}, },
onStartLogin: (fn: (opts: { email: string }) => void) => {
const sub = uiSubjects.onStartLogin.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
} satisfies Record<string, MainEventRegister>; } satisfies Record<string, MainEventRegister>;

View File

@@ -1,10 +1,8 @@
import { app, BrowserWindow, nativeTheme } from 'electron'; import { app, BrowserWindow, nativeTheme } from 'electron';
import { isMacOS } from '../../shared/utils'; import { isMacOS } from '../../shared/utils';
import { closePopup } from '../main-window';
import type { NamespaceHandlers } from '../type'; import type { NamespaceHandlers } from '../type';
import { getGoogleOauthCode } from './google-auth'; import { getGoogleOauthCode } from './google-auth';
import { uiSubjects } from './subject';
export const uiHandlers = { export const uiHandlers = {
handleThemeChange: async (_, theme: (typeof nativeTheme)['themeSource']) => { handleThemeChange: async (_, theme: (typeof nativeTheme)['themeSource']) => {
@@ -38,10 +36,6 @@ export const uiHandlers = {
handleCloseApp: async () => { handleCloseApp: async () => {
app.quit(); app.quit();
}, },
handleFinishLogin: async () => {
closePopup();
uiSubjects.onFinishLogin.next();
},
getGoogleOauthCode: async () => { getGoogleOauthCode: async () => {
return getGoogleOauthCode(); return getGoogleOauthCode();
}, },

View File

@@ -1,5 +1,6 @@
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
export const uiSubjects = { export const uiSubjects = {
onFinishLogin: new Subject<void>(), onStartLogin: new Subject<{ email: string }>(),
onFinishLogin: new Subject<{ success: boolean; email: string }>(),
}; };

View File

@@ -25,9 +25,14 @@ function getSchemaFromCallbackUrl(origin: string, callbackUrl: string) {
return searchParams.has('schema') ? searchParams.get('schema') : null; return searchParams.has('schema') ? searchParams.get('schema') : null;
} }
function wrapUrlWithSchema(url: string, schema: string | null) { function wrapUrlWithOpenApp(
origin: string,
url: string,
schema: string | null
) {
if (schema) { if (schema) {
return `${schema}://open-url?${url}`; const urlWithSchema = `${schema}://sign-in?${url}`;
return `${origin}/open-app?url=${encodeURIComponent(urlWithSchema)}`;
} }
return url; return url;
} }
@@ -88,9 +93,10 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
if (!callbackUrl) { if (!callbackUrl) {
throw new Error('callbackUrl is not set'); throw new Error('callbackUrl is not set');
} }
const schema = getSchemaFromCallbackUrl(origin, callbackUrl);
const wrappedUrl = wrapUrlWithSchema(url, schema);
// hack: check if link is opened via desktop // hack: check if link is opened via desktop
const schema = getSchemaFromCallbackUrl(origin, callbackUrl);
const wrappedUrl = wrapUrlWithOpenApp(origin, url, schema);
const result = await mailer.sendMail({ const result = await mailer.sendMail({
to: identifier, to: identifier,
from: provider.from, from: provider.from,

View File

@@ -156,3 +156,20 @@ ImportPage.parameters = {
}, },
}), }),
}; };
export const OpenAppPage: StoryFn = () => {
return <FakeApp />;
};
OpenAppPage.decorators = [withRouter];
OpenAppPage.parameters = {
reactRouter: reactRouterParameters({
routing: reactRouterOutlets(routes),
location: {
path: '/open-app',
searchParams: {
url: 'affine-beta://foo-bar.com',
open: 'false',
},
},
}),
};

View File

@@ -426,21 +426,30 @@
"com.affine.settings.profile.placeholder": "Input account name", "com.affine.settings.profile.placeholder": "Input account name",
"com.affine.auth.sign.in": "Sign in", "com.affine.auth.sign.in": "Sign in",
"com.affine.auth.sign.up": "Sign up", "com.affine.auth.sign.up": "Sign up",
"com.affine.auth.desktop.signing.in": "Signing in...",
"com.affine.auth.desktop.signing.in.message": "Signing in with account <1></1>",
"com.affine.auth.sign.up.sent.email.subtitle": "Create your account", "com.affine.auth.sign.up.sent.email.subtitle": "Create your account",
"com.affine.auth.sign.sent.email.message.start": "An email with a magic link has been sent to ", "com.affine.auth.sign.sent.email.message.start": "An email with a magic link has been sent to ",
"com.affine.auth.sign.sent.email.message.end": " You can click the link to create an account automatically.", "com.affine.auth.sign.sent.email.message.end": " You can click the link to create an account automatically.",
"com.affine.auth.sign.up.success.title": "Your account has been created and youre now signed in!", "com.affine.auth.sign.up.success.title": "Your account has been created and youre now signed in!",
"com.affine.auth.sign.up.success.subtitle": "The app will automatically open or redirect to the web version. if you encounter any issues, you can also click the button below to manually open the AFFiNE app.", "com.affine.auth.sign.up.success.subtitle": "The app will automatically open or redirect to the web version. If you encounter any issues, you can also click the button below to manually open the AFFiNE app.",
"com.affine.auth.page.sent.email.title": "Welcome to AFFiNE Cloud, you are almost there!", "com.affine.auth.page.sent.email.title": "Welcome to AFFiNE Cloud, you are almost there!",
"com.affine.auth.page.sent.email.subtitle": "Please set a password of 8-20 characters with both letters and numbers to continue signing up with ", "com.affine.auth.page.sent.email.subtitle": "Please set a password of 8-20 characters with both letters and numbers to continue signing up with ",
"com.affine.auth.later": "Later", "com.affine.auth.later": "Later",
"com.affine.auth.open.affine": "Open AFFiNE", "com.affine.auth.open.affine": "Open AFFiNE",
"com.affine.auth.open.affine.prompt": "Opening <1>AFFiNE</1> app now",
"com.affine.auth.open.affine.try-again": "Try again",
"com.affine.auth.open.affine.download-app": "Download App",
"com.affine.auth.sign.in.sent.email.subtitle": "Confirm your email", "com.affine.auth.sign.in.sent.email.subtitle": "Confirm your email",
"com.affine.auth.sign.auth.code.message": "If you haven't received the email, please check your spam folder.", "com.affine.auth.sign.auth.code.message": "If you haven't received the email, please check your spam folder.",
"com.affine.auth.sign.auth.code.message.password": "If you haven't received the email, please check your spam folder. Or <1>sign in with password</1> instead.", "com.affine.auth.sign.auth.code.message.password": "If you haven't received the email, please check your spam folder. Or <1>sign in with password</1> instead.",
"com.affine.auth.password.error": "Invalid password", "com.affine.auth.password.error": "Invalid password",
"com.affine.auth.forget": "Forgot password", "com.affine.auth.forget": "Forgot password",
"com.affine.auth.has.signed": " has signed in!", "com.affine.auth.has.signed": " has signed in!",
"com.affine.auth.toast.title.signed-in": "Signed in",
"com.affine.auth.toast.message.signed-in": "You have been signed in, start to sync your data with AFFiNE Cloud!",
"com.affine.auth.toast.title.failed": "Unable to sign in",
"com.affine.auth.toast.message.failed": "Server error, please try again later.",
"com.affine.auth.signed.success.title": "Youre almost there!", "com.affine.auth.signed.success.title": "Youre almost there!",
"com.affine.auth.signed.success.subtitle": "You have successfully signed in. The app will automatically open or redirect to the web version. if you encounter any issues, you can also click the button below to manually open the AFFiNE app.", "com.affine.auth.signed.success.subtitle": "You have successfully signed in. The app will automatically open or redirect to the web version. if you encounter any issues, you can also click the button below to manually open the AFFiNE app.",
"com.affine.auth.reset.password": "Reset Password", "com.affine.auth.reset.password": "Reset Password",

View File

@@ -173,7 +173,6 @@ export type UIHandlers = {
handleMinimizeApp: () => Promise<any>; handleMinimizeApp: () => Promise<any>;
handleMaximizeApp: () => Promise<any>; handleMaximizeApp: () => Promise<any>;
handleCloseApp: () => Promise<any>; handleCloseApp: () => Promise<any>;
handleFinishLogin: () => Promise<void>;
getGoogleOauthCode: () => Promise<any>; getGoogleOauthCode: () => Promise<any>;
}; };
@@ -267,7 +266,10 @@ export interface WorkspaceEvents {
} }
export interface UIEvents { export interface UIEvents {
onFinishLogin: (fn: () => void) => () => void; onStartLogin: (fn: (options: { email: string }) => void) => () => void;
onFinishLogin: (
fn: (result: { success: boolean; email: string }) => void
) => () => void;
} }
export interface EventMap { export interface EventMap {