diff --git a/apps/core/public/imgs/app-icon-beta.ico b/apps/core/public/imgs/app-icon-beta.ico new file mode 100644 index 0000000000..4ae18e6739 Binary files /dev/null and b/apps/core/public/imgs/app-icon-beta.ico differ diff --git a/apps/core/public/imgs/app-icon-canary.ico b/apps/core/public/imgs/app-icon-canary.ico new file mode 100644 index 0000000000..d9ba6c8c48 Binary files /dev/null and b/apps/core/public/imgs/app-icon-canary.ico differ diff --git a/apps/core/public/imgs/app-icon-internal.ico b/apps/core/public/imgs/app-icon-internal.ico new file mode 100644 index 0000000000..360d2ba28c Binary files /dev/null and b/apps/core/public/imgs/app-icon-internal.ico differ diff --git a/apps/core/public/imgs/app-icon-stable.ico b/apps/core/public/imgs/app-icon-stable.ico new file mode 100644 index 0000000000..1ec4588634 Binary files /dev/null and b/apps/core/public/imgs/app-icon-stable.ico differ diff --git a/apps/core/src/components/affine/auth/index.tsx b/apps/core/src/components/affine/auth/index.tsx index 314fed2780..e8d4d7c7e9 100644 --- a/apps/core/src/components/affine/auth/index.tsx +++ b/apps/core/src/components/affine/auth/index.tsx @@ -2,7 +2,6 @@ import { AuthModal as AuthModalBase, type AuthModalProps as AuthModalBaseProps, } from '@affine/component/auth-components'; -import { isDesktop } from '@affine/env/constant'; import { atom, useAtom } from 'jotai'; import { type FC, useCallback, useEffect, useMemo } from 'react'; @@ -80,15 +79,6 @@ export const AuthModal: FC = ({ } }, [open, setAuthEmail, setAuthStore]); - useEffect(() => { - if (isDesktop) { - return window.events?.ui.onFinishLogin(() => { - setOpen(false); - }); - } - return; - }, [setOpen]); - const onSignedIn = useCallback(() => { setOpen(false); }, [setOpen]); diff --git a/apps/core/src/components/affine/desktop-login-modal/index.tsx b/apps/core/src/components/affine/desktop-login-modal/index.tsx new file mode 100644 index 0000000000..824816b405 --- /dev/null +++ b/apps/core/src/components/affine/desktop-login-modal/index.tsx @@ -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 ( + + +
+ {t['com.affine.auth.desktop.signing.in']()} +
+ + + Signing in with account {signingEmail} + +
+
+ ); +}; diff --git a/apps/core/src/components/affine/desktop-login-modal/styles.css.ts b/apps/core/src/components/affine/desktop-login-modal/styles.css.ts new file mode 100644 index 0000000000..0899875b02 --- /dev/null +++ b/apps/core/src/components/affine/desktop-login-modal/styles.css.ts @@ -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, +}); diff --git a/apps/core/src/components/affine/setting-modal/index.tsx b/apps/core/src/components/affine/setting-modal/index.tsx index 3864f02872..157db489a0 100644 --- a/apps/core/src/components/affine/setting-modal/index.tsx +++ b/apps/core/src/components/affine/setting-modal/index.tsx @@ -7,7 +7,7 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { ContactWithUsIcon } from '@blocksuite/icons'; 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 { GeneralSetting, @@ -39,7 +39,7 @@ export const SettingModal = ({ onSettingClick, }: SettingModalProps) => { const t = useAFFiNEI18N(); - const loginStatus = useCurrenLoginStatus(); + const loginStatus = useCurrentLoginStatus(); const generalSettingList = useGeneralSettingList(); diff --git a/apps/core/src/components/affine/setting-modal/setting-sidebar/index.tsx b/apps/core/src/components/affine/setting-modal/setting-sidebar/index.tsx index a7e28e132f..11d12c29b8 100644 --- a/apps/core/src/components/affine/setting-modal/setting-sidebar/index.tsx +++ b/apps/core/src/components/affine/setting-modal/setting-sidebar/index.tsx @@ -23,7 +23,7 @@ import { } from 'react'; 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 { useCurrentWorkspace } from '../../../../hooks/current/use-current-workspace'; import type { @@ -113,7 +113,7 @@ export const SettingSidebar = ({ onAccountSettingClick: () => void; }) => { const t = useAFFiNEI18N(); - const loginStatus = useCurrenLoginStatus(); + const loginStatus = useCurrentLoginStatus(); return (
{t['Settings']()}
diff --git a/apps/core/src/components/cloud/login-card.tsx b/apps/core/src/components/cloud/login-card.tsx index 881dfb4eaa..0fd90885d0 100644 --- a/apps/core/src/components/cloud/login-card.tsx +++ b/apps/core/src/components/cloud/login-card.tsx @@ -3,13 +3,13 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { CloudWorkspaceIcon } from '@blocksuite/icons'; 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 { StyledSignInButton } from '../pure/footer/styles'; export const LoginCard = () => { const t = useAFFiNEI18N(); - const loginStatus = useCurrenLoginStatus(); + const loginStatus = useCurrentLoginStatus(); if (loginStatus === 'authenticated') { return ; } diff --git a/apps/core/src/components/pure/footer/index.tsx b/apps/core/src/components/pure/footer/index.tsx index 5c87218f5a..f4686b281f 100644 --- a/apps/core/src/components/pure/footer/index.tsx +++ b/apps/core/src/components/pure/footer/index.tsx @@ -3,12 +3,12 @@ import { CloudWorkspaceIcon } from '@blocksuite/icons'; import { signIn } from 'next-auth/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 { stringToColour } from '../../../utils'; import { StyledFooter, StyledSignInButton } from './styles'; export const Footer: FC = () => { - const loginStatus = useCurrenLoginStatus(); + const loginStatus = useCurrentLoginStatus(); // const setOpen = useSetAtom(openDisableCloudAlertModalAtom); return ( diff --git a/apps/core/src/hooks/affine/use-curren-login-status.ts b/apps/core/src/hooks/affine/use-current-login-status.ts similarity index 85% rename from apps/core/src/hooks/affine/use-curren-login-status.ts rename to apps/core/src/hooks/affine/use-current-login-status.ts index 8e835dba8a..4bd5ca2e0e 100644 --- a/apps/core/src/hooks/affine/use-curren-login-status.ts +++ b/apps/core/src/hooks/affine/use-current-login-status.ts @@ -1,7 +1,7 @@ // eslint-disable-next-line @typescript-eslint/no-restricted-imports import { useSession } from 'next-auth/react'; -export function useCurrenLoginStatus(): +export function useCurrentLoginStatus(): | 'authenticated' | 'unauthenticated' | 'loading' { diff --git a/apps/core/src/pages/auth.tsx b/apps/core/src/pages/auth.tsx index f715a8ccb8..cdf2c9bbab 100644 --- a/apps/core/src/pages/auth.tsx +++ b/apps/core/src/pages/auth.tsx @@ -5,7 +5,6 @@ import { SignInSuccessPage, SignUpPage, } from '@affine/component/auth-components'; -import { isDesktop } from '@affine/env/constant'; import { changeEmailMutation, changePasswordMutation } from '@affine/graphql'; import { useMutation } from '@affine/workspace/affine/gql'; import type { ReactElement } from 'react'; @@ -13,7 +12,7 @@ import { useCallback } from 'react'; import { type LoaderFunction, redirect, useParams } from 'react-router-dom'; 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 { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper'; @@ -58,11 +57,7 @@ export const AuthPage = (): ReactElement | null => { [changePassword, user.id] ); const onOpenAffine = useCallback(() => { - if (isDesktop) { - window.apis.ui.handleFinishLogin(); - } else { - jumpToIndex(RouteLogic.REPLACE); - } + jumpToIndex(RouteLogic.REPLACE); }, [jumpToIndex]); switch (authType) { @@ -119,7 +114,7 @@ export const loader: LoaderFunction = async args => { return null; }; export const Component = () => { - const loginStatus = useCurrenLoginStatus(); + const loginStatus = useCurrentLoginStatus(); const { jumpToExpired } = useNavigateHelper(); if (loginStatus === 'unauthenticated') { diff --git a/apps/core/src/pages/invite.tsx b/apps/core/src/pages/invite.tsx index dc51c802c5..10b639caa0 100644 --- a/apps/core/src/pages/invite.tsx +++ b/apps/core/src/pages/invite.tsx @@ -11,7 +11,7 @@ import { useCallback, useEffect } from 'react'; import { type LoaderFunction, redirect, useLoaderData } from 'react-router-dom'; 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 { useAppHelper } from '../hooks/use-workspaces'; @@ -45,7 +45,7 @@ export const loader: LoaderFunction = async args => { }; export const Component = () => { - const loginStatus = useCurrenLoginStatus(); + const loginStatus = useCurrentLoginStatus(); const { jumpToSignIn } = useNavigateHelper(); const { addCloudWorkspace } = useAppHelper(); const { jumpToSubPath } = useNavigateHelper(); diff --git a/apps/core/src/pages/open-app.css.ts b/apps/core/src/pages/open-app.css.ts new file mode 100644 index 0000000000..f158a827b7 --- /dev/null +++ b/apps/core/src/pages/open-app.css.ts @@ -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, +}); diff --git a/apps/core/src/pages/open-app.tsx b/apps/core/src/pages/open-app.tsx new file mode 100644 index 0000000000..7652adb0a5 --- /dev/null +++ b/apps/core/src/pages/open-app.tsx @@ -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; +type Channel = z.infer; + +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; + +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; + +const appNames = { + stable: 'AFFiNE', + canary: 'AFFiNE Canary', + beta: 'AFFiNE Beta', + internal: 'AFFiNE Internal', +} satisfies Record; + +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 ( +
+
+ + + + + + + +
+ +
+ {appName} + +
+ + Open {appName} app now + +
+ + + {t['com.affine.auth.open.affine.try-again']()} + +
+
+ ); + } else { + return null; + } +}; diff --git a/apps/core/src/pages/sign-in.tsx b/apps/core/src/pages/sign-in.tsx index 6da782ba93..91cf56513a 100644 --- a/apps/core/src/pages/sign-in.tsx +++ b/apps/core/src/pages/sign-in.tsx @@ -6,7 +6,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { authAtom } from '../atoms'; 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 { state: { @@ -18,7 +18,7 @@ export const Component = () => { { state, email = '', emailType = 'changePassword', onceSignedIn }, setAuthAtom, ] = useAtom(authAtom); - const loginStatus = useCurrenLoginStatus(); + const loginStatus = useCurrentLoginStatus(); const location = useLocation() as LocationState; const navigate = useNavigate(); diff --git a/apps/core/src/providers/modal-provider.tsx b/apps/core/src/providers/modal-provider.tsx index 5af4fbc33f..a2c90d8792 100644 --- a/apps/core/src/providers/modal-provider.tsx +++ b/apps/core/src/providers/modal-provider.tsx @@ -1,4 +1,7 @@ +import { pushNotificationAtom } from '@affine/component/notification-center'; +import { isDesktop } from '@affine/env/constant'; import { WorkspaceSubPath } from '@affine/env/workspace'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom'; import { assertExists } from '@blocksuite/global/utils'; import { arrayMove } from '@dnd-kit/sortable'; @@ -8,7 +11,14 @@ import { } from '@toeverything/infra/atom'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; 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 { @@ -32,6 +42,12 @@ const Auth = lazy(() => })) ); +const DesktopLogin = lazy(() => + import('../components/affine/desktop-login-modal').then(module => ({ + default: module.DesktopLoginModal, + })) +); + const WorkspaceListModal = lazy(() => import('../components/pure/workspace-list-modal').then(module => ({ default: module.WorkspaceListModal, @@ -129,6 +145,49 @@ export const AuthModal = (): ReactElement => { ); }; +export const DesktopLoginModal = (): ReactElement => { + const [signingEmail, setSigningEmail] = useState(); + 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 ; +}; + export function CurrentWorkspaceModals() { const [currentWorkspace] = useCurrentWorkspace(); const [openDisableCloudAlertModal, setOpenDisableCloudAlertModal] = useAtom( @@ -263,6 +322,7 @@ export const AllWorkspaceModals = (): ReactElement => { + {isDesktop && } ); }; diff --git a/apps/core/src/router.ts b/apps/core/src/router.ts index 68dcb5ba37..7b616def9f 100644 --- a/apps/core/src/router.ts +++ b/apps/core/src/router.ts @@ -48,6 +48,10 @@ export const routes = [ path: '/signIn', lazy: () => import('./pages/sign-in'), }, + { + path: '/open-app', + lazy: () => import('./pages/open-app'), + }, { path: '*', lazy: () => import('./pages/404'), diff --git a/apps/electron/src/main/deep-link.ts b/apps/electron/src/main/deep-link.ts index 85a695248c..af9c13eb0f 100644 --- a/apps/electron/src/main/deep-link.ts +++ b/apps/electron/src/main/deep-link.ts @@ -1,8 +1,14 @@ +import path from 'node:path'; + import type { App } from 'electron'; import { buildType, isDev } from './config'; 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}`; if (isDev) { @@ -10,7 +16,16 @@ if (isDev) { } 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) => { if (url.startsWith(`${protocol}://`)) { 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) { logger.info('open affine 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); if (urlToOpen) { - handleOpenUrlInPopup(urlToOpen).catch(e => { - logger.error('failed to open url in popup', e); - }); + await handleSignIn(urlToOpen); + } + } +} + +// 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); } } } diff --git a/apps/electron/src/main/index.ts b/apps/electron/src/main/index.ts index 0aff3821f3..7df7f84daa 100644 --- a/apps/electron/src/main/index.ts +++ b/apps/electron/src/main/index.ts @@ -33,12 +33,6 @@ if (!isSingleInstance) { 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 */ diff --git a/apps/electron/src/main/main-window.ts b/apps/electron/src/main/main-window.ts index 69bbd48aee..2cb21795f4 100644 --- a/apps/electron/src/main/main-window.ts +++ b/apps/electron/src/main/main-window.ts @@ -95,7 +95,13 @@ async function createWindow() { browserWindow.on('close', e => { e.preventDefault(); - browserWindow.destroy(); + // close and destroy all windows + BrowserWindow.getAllWindows().forEach(w => { + if (!w.isDestroyed()) { + w.close(); + w.destroy(); + } + }); helperConnectionUnsub?.(); // TODO: gracefully close the app, for example, ask user to save unsaved changes }); @@ -123,44 +129,12 @@ async function createWindow() { // singleton 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 || BrowserWindow.getAllWindows().find(w => !w.isDestroyed()); - - if (browserWindow === undefined) { + if (!browserWindow || browserWindow.isDestroyed()) { browserWindow = await createWindow(); } @@ -172,17 +146,29 @@ 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 async function handleOpenUrlInHiddenWindow(url: string) { + const mainExposedMeta = getExposedMeta(); + const win = new BrowserWindow({ + width: 1200, + height: 600, + webPreferences: { + preload: join(__dirname, './preload.js'), + additionalArguments: [ + `--main-exposed-meta=` + JSON.stringify(mainExposedMeta), + // 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() { diff --git a/apps/electron/src/main/ui/events.ts b/apps/electron/src/main/ui/events.ts index eaeece0112..6947a9bb5a 100644 --- a/apps/electron/src/main/ui/events.ts +++ b/apps/electron/src/main/ui/events.ts @@ -5,10 +5,18 @@ import { uiSubjects } from './subject'; * Events triggered by application menu */ export const uiEvents = { - onFinishLogin: (fn: () => void) => { + onFinishLogin: ( + fn: (result: { success: boolean; email: string }) => void + ) => { const sub = uiSubjects.onFinishLogin.subscribe(fn); return () => { sub.unsubscribe(); }; }, + onStartLogin: (fn: (opts: { email: string }) => void) => { + const sub = uiSubjects.onStartLogin.subscribe(fn); + return () => { + sub.unsubscribe(); + }; + }, } satisfies Record; diff --git a/apps/electron/src/main/ui/handlers.ts b/apps/electron/src/main/ui/handlers.ts index e37f6690e9..d84a927d70 100644 --- a/apps/electron/src/main/ui/handlers.ts +++ b/apps/electron/src/main/ui/handlers.ts @@ -1,10 +1,8 @@ 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']) => { @@ -38,10 +36,6 @@ export const uiHandlers = { handleCloseApp: async () => { app.quit(); }, - handleFinishLogin: async () => { - closePopup(); - uiSubjects.onFinishLogin.next(); - }, getGoogleOauthCode: async () => { return getGoogleOauthCode(); }, diff --git a/apps/electron/src/main/ui/subject.ts b/apps/electron/src/main/ui/subject.ts index 17f6179d3f..1bafd830a8 100644 --- a/apps/electron/src/main/ui/subject.ts +++ b/apps/electron/src/main/ui/subject.ts @@ -1,5 +1,6 @@ import { Subject } from 'rxjs'; export const uiSubjects = { - onFinishLogin: new Subject(), + onStartLogin: new Subject<{ email: string }>(), + onFinishLogin: new Subject<{ success: boolean; email: string }>(), }; diff --git a/apps/server/src/modules/auth/next-auth-options.ts b/apps/server/src/modules/auth/next-auth-options.ts index 07c03d3d47..0e47e610ae 100644 --- a/apps/server/src/modules/auth/next-auth-options.ts +++ b/apps/server/src/modules/auth/next-auth-options.ts @@ -25,9 +25,14 @@ function getSchemaFromCallbackUrl(origin: string, callbackUrl: string) { 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) { - return `${schema}://open-url?${url}`; + const urlWithSchema = `${schema}://sign-in?${url}`; + return `${origin}/open-app?url=${encodeURIComponent(urlWithSchema)}`; } return url; } @@ -88,9 +93,10 @@ export const NextAuthOptionsProvider: FactoryProvider = { if (!callbackUrl) { 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 + const schema = getSchemaFromCallbackUrl(origin, callbackUrl); + const wrappedUrl = wrapUrlWithOpenApp(origin, url, schema); + const result = await mailer.sendMail({ to: identifier, from: provider.from, diff --git a/apps/storybook/src/stories/core.stories.tsx b/apps/storybook/src/stories/core.stories.tsx index 80f083c955..b2b3e49f5d 100644 --- a/apps/storybook/src/stories/core.stories.tsx +++ b/apps/storybook/src/stories/core.stories.tsx @@ -156,3 +156,20 @@ ImportPage.parameters = { }, }), }; + +export const OpenAppPage: StoryFn = () => { + return ; +}; +OpenAppPage.decorators = [withRouter]; +OpenAppPage.parameters = { + reactRouter: reactRouterParameters({ + routing: reactRouterOutlets(routes), + location: { + path: '/open-app', + searchParams: { + url: 'affine-beta://foo-bar.com', + open: 'false', + }, + }, + }), +}; diff --git a/packages/i18n/src/resources/en.json b/packages/i18n/src/resources/en.json index f2aae774ae..a1c8c45d8a 100644 --- a/packages/i18n/src/resources/en.json +++ b/packages/i18n/src/resources/en.json @@ -426,21 +426,30 @@ "com.affine.settings.profile.placeholder": "Input account name", "com.affine.auth.sign.in": "Sign in", "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>", "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.end": " You can click the link to create an account automatically.", "com.affine.auth.sign.up.success.title": "Your account has been created and you’re 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.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.open.affine": "Open AFFiNE", + "com.affine.auth.open.affine.prompt": "Opening <1>AFFiNE 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.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 instead.", "com.affine.auth.password.error": "Invalid password", "com.affine.auth.forget": "Forgot password", "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": "You’re 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.reset.password": "Reset Password", diff --git a/packages/infra/src/type.ts b/packages/infra/src/type.ts index 68358af17e..1da92f63cb 100644 --- a/packages/infra/src/type.ts +++ b/packages/infra/src/type.ts @@ -173,7 +173,6 @@ export type UIHandlers = { handleMinimizeApp: () => Promise; handleMaximizeApp: () => Promise; handleCloseApp: () => Promise; - handleFinishLogin: () => Promise; getGoogleOauthCode: () => Promise; }; @@ -267,7 +266,10 @@ export interface WorkspaceEvents { } 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 {