import { notify } from '@affine/component'; import { getStoreManager } from '@affine/core/blocksuite/manager/store'; import { AffineContext } from '@affine/core/components/context'; import { AppFallback } from '@affine/core/mobile/components/app-fallback'; import { MobileModalConfigProvider } from '@affine/core/mobile/components/mobile-modal-config-provider'; import { configureMobileModules } from '@affine/core/mobile/modules'; import { MobileBackCoordinator } from '@affine/core/mobile/modules/back-coordinator'; import { HapticProvider } from '@affine/core/mobile/modules/haptics'; import { VirtualKeyboardProvider } from '@affine/core/mobile/modules/virtual-keyboard'; import { router } from '@affine/core/mobile/router'; import { configureCommonModules } from '@affine/core/modules'; import { AuthProvider, AuthService, DefaultServerService, ServerScope, ServerService, ServersService, SubscriptionService, ValidatorProvider, } from '@affine/core/modules/cloud'; import { registerNativePreviewHandlers } from '@affine/core/modules/code-block-preview-renderer'; import { DocsService } from '@affine/core/modules/doc'; import { FeatureFlagService } from '@affine/core/modules/feature-flag'; import { GlobalContextService } from '@affine/core/modules/global-context'; import { I18nProvider } from '@affine/core/modules/i18n'; import { LifecycleService } from '@affine/core/modules/lifecycle'; import { NativePaywallProvider } from '@affine/core/modules/paywall'; import { configureLocalStorageStateStorageImpls, NbstoreProvider, } from '@affine/core/modules/storage'; import { PopupWindowProvider } from '@affine/core/modules/url'; import { ClientSchemeProvider } from '@affine/core/modules/url/providers/client-schema'; import { configureBrowserWorkbenchModule, WorkbenchService, } from '@affine/core/modules/workbench'; import { getAFFiNEWorkspaceSchema, WorkspacesService, } from '@affine/core/modules/workspace'; import { configureBrowserWorkspaceFlavours } from '@affine/core/modules/workspace-engine'; import { getWorkerUrl } from '@affine/env/worker'; import { refreshSubscriptionMutation, requestApplySubscriptionMutation, } from '@affine/graphql'; import { I18n } from '@affine/i18n'; import { serveAuthRequests } from '@affine/mobile-shared/auth/channel'; import { StoreManagerClient } from '@affine/nbstore/worker/client'; import { setTelemetryTransport } from '@affine/track'; import { Container } from '@blocksuite/affine/global/di'; import { docLinkBaseURLMiddleware, MarkdownAdapter, titleMiddleware, } from '@blocksuite/affine/shared/adapters'; import { registerNativeImageFilesPicker } from '@blocksuite/affine/shared/utils'; import { MarkdownTransformer } from '@blocksuite/affine/widgets/linked-doc'; import { App as CapacitorApp } from '@capacitor/app'; import { Browser } from '@capacitor/browser'; import { Capacitor } from '@capacitor/core'; import { Haptics } from '@capacitor/haptics'; import { Keyboard, KeyboardStyle } from '@capacitor/keyboard'; import { Framework, FrameworkRoot, getCurrentStore, useLiveData, useService, } from '@toeverything/infra'; import { OpClient } from '@toeverything/infra/op'; import { AsyncCall } from 'async-call-rpc'; import { AppTrackingTransparency } from 'capacitor-plugin-app-tracking-transparency'; import { useTheme } from 'next-themes'; import { Suspense, useEffect } from 'react'; import { RouterProvider } from 'react-router-dom'; import { BlocksuiteMenuConfigProvider } from './bs-menu-config'; import { AffineTheme } from './plugins/affine-theme'; import { Auth } from './plugins/auth'; import { Hashcash } from './plugins/hashcash'; import { ImagePicker } from './plugins/image-picker'; import { NavigationGesture } from './plugins/navigation-gesture'; import { NbStoreNativeDBApis } from './plugins/nbstore'; import { PayWall } from './plugins/paywall'; import { Preview } from './plugins/preview'; import { authRequestProvider, clearEndpointSession, getValidAccessToken, } from './proxy'; const storeManagerClient = createStoreManagerClient(); setTelemetryTransport(storeManagerClient.telemetry); window.addEventListener('beforeunload', () => { storeManagerClient.dispose(); }); const future = { v7_startTransition: true, } as const; const framework = new Framework(); configureCommonModules(framework); configureBrowserWorkbenchModule(framework); configureLocalStorageStateStorageImpls(framework); configureBrowserWorkspaceFlavours(framework); configureMobileModules(framework); framework.impl(NbstoreProvider, { realtime: storeManagerClient.realtime, openStore(key, options) { const { store, dispose } = storeManagerClient.open(key, options); return { store, dispose: () => { dispose(); }, }; }, }); framework.impl(PopupWindowProvider, { open: (url: string) => { Browser.open({ url, presentationStyle: 'popover', }).catch(console.error); }, }); framework.impl(ClientSchemeProvider, { getClientScheme() { return 'affine'; }, }); framework.impl(ValidatorProvider, { async validate(_challenge, resource) { const res = await Hashcash.hash({ challenge: resource }); return res.value; }, }); framework.impl(VirtualKeyboardProvider, { // We dose not provide show and hide because: // - Keyboard.show() is not implemented // - Keyboard.hide() will blur the current editor onChange: callback => { let disposeRef = { dispose: () => {}, }; Promise.all([ Keyboard.addListener('keyboardWillShow', info => { callback({ visible: info.keyboardHeight !== 0, height: info.keyboardHeight, }); }), Keyboard.addListener('keyboardWillHide', () => { callback({ visible: false, height: 0, }); }), ]) .then(handlers => { disposeRef.dispose = () => { Promise.all(handlers.map(handler => handler.remove())).catch( console.error ); }; }) .catch(console.error); return () => { disposeRef.dispose(); }; }, }); framework.impl(HapticProvider, { impact: options => Haptics.impact(options as any), vibrate: options => Haptics.vibrate(options as any), notification: options => Haptics.notification(options as any), selectionStart: () => Haptics.selectionStart(), selectionChanged: () => Haptics.selectionChanged(), selectionEnd: () => Haptics.selectionEnd(), }); framework.scope(ServerScope).override(AuthProvider, resolver => { const serverService = resolver.get(ServerService); const endpoint = serverService.server.baseUrl; return { async signInMagicLink(email, linkToken, clientNonce) { await Auth.signInMagicLink({ endpoint, email, token: linkToken, clientNonce, }); }, async signInOauth(code, state, _provider, clientNonce) { await Auth.signInOauth({ endpoint, code, state, clientNonce, }); return {}; }, async signInPassword(credential) { await Auth.signInPassword({ endpoint, ...credential, }); }, async signInOpenAppSignInCode(code) { await Auth.signInOpenApp({ endpoint, code, }); }, async signOut() { try { await Auth.signOut({ endpoint }); } finally { await clearEndpointSession(endpoint); } }, async clearSession() { await clearEndpointSession(endpoint); }, }; }); framework.impl(NativePaywallProvider, { showPaywall: async (type: 'Pro' | 'AI') => { await PayWall.showPayWall({ type }); }, }); const frameworkProvider = framework.provider(); registerNativePreviewHandlers({ renderMermaidSvg: request => Preview.renderMermaidSvg(request), renderTypstSvg: request => Preview.renderTypstSvg(request), }); registerNativeImageFilesPicker(async () => { const result = await ImagePicker.pickImages({ multiple: true }); if (result.canceled || result.files.length === 0) { return []; } const settled = await Promise.allSettled( result.files.map(async file => { const filePath = file.path.startsWith('file://') ? file.path : `file://${file.path}`; const response = await fetch(Capacitor.convertFileSrc(filePath)); if (!response.ok) { throw new Error( `Failed to read image picker file: ${file.name} (status ${response.status})` ); } const blob = await response.blob(); return new File([blob], file.name, { type: file.mimeType || blob.type || 'image/*', lastModified: file.lastModified, }); }) ); return settled .filter( (settledResult): settledResult is PromiseFulfilledResult => settledResult.status === 'fulfilled' ) .map(settledResult => settledResult.value); }); // ------ some apis for native ------ (window as any).getCurrentServerBaseUrl = () => { const globalContextService = frameworkProvider.get(GlobalContextService); const currentServerId = globalContextService.globalContext.serverId.get(); const serversService = frameworkProvider.get(ServersService); const defaultServerService = frameworkProvider.get(DefaultServerService); const currentServer = (currentServerId ? serversService.server$(currentServerId).value : null) ?? defaultServerService.server; return currentServer.baseUrl; }; (window as any).getCurrentI18nLocale = () => { return I18n.language; }; (window as any).getCurrentThemeMode = () => { return 'system'; }; (window as any).getAiButtonFeatureFlag = () => { const featureFlagService = frameworkProvider.get(FeatureFlagService); return featureFlagService.flags.enable_mobile_ai_button.value; }; (window as any).getCurrentWorkspaceId = () => { const globalContextService = frameworkProvider.get(GlobalContextService); return globalContextService.globalContext.workspaceId.get(); }; (window as any).getCurrentDocId = () => { const globalContextService = frameworkProvider.get(GlobalContextService); return globalContextService.globalContext.docId.get(); }; (window as any).getCurrentUserIdentifier = () => { const globalContextService = frameworkProvider.get(GlobalContextService); const currentServerId = globalContextService.globalContext.serverId.get(); const serversService = frameworkProvider.get(ServersService); const defaultServerService = frameworkProvider.get(DefaultServerService); const currentServer = (currentServerId ? serversService.server$(currentServerId).value : null) ?? defaultServerService.server; return currentServer.account$.value?.id; }; (window as any).getCurrentDocContentInMarkdown = async () => { const globalContextService = frameworkProvider.get(GlobalContextService); const currentWorkspaceId = globalContextService.globalContext.workspaceId.get(); const currentDocId = globalContextService.globalContext.docId.get(); const workspacesService = frameworkProvider.get(WorkspacesService); const workspaceRef = currentWorkspaceId ? workspacesService.openByWorkspaceId(currentWorkspaceId) : null; if (!workspaceRef) { return; } const { workspace, dispose: disposeWorkspace } = workspaceRef; const docsService = workspace.scope.get(DocsService); const docRef = currentDocId ? docsService.open(currentDocId) : null; if (!docRef) { return; } const { doc, release: disposeDoc } = docRef; try { const blockSuiteDoc = doc.blockSuiteDoc; const transformer = blockSuiteDoc.getTransformer([ docLinkBaseURLMiddleware(blockSuiteDoc.workspace.id), titleMiddleware(blockSuiteDoc.workspace.meta.docMetas), ]); const snapshot = transformer.docToSnapshot(blockSuiteDoc); const container = new Container(); getStoreManager() .config.init() .value.get('store') .forEach(ext => { ext.setup(container); }); const provider = container.provider(); const adapter = new MarkdownAdapter(transformer, provider); if (!snapshot) { return; } const markdownResult = await adapter.fromDocSnapshot({ snapshot, assets: transformer.assetsManager, }); return markdownResult.file; } finally { disposeDoc(); disposeWorkspace(); } }; (window as any).createNewDocByMarkdownInCurrentWorkspace = async ( markdown: string, title: string ) => { const globalContextService = frameworkProvider.get(GlobalContextService); const currentWorkspaceId = globalContextService.globalContext.workspaceId.get(); const workspacesService = frameworkProvider.get(WorkspacesService); const workspaceRef = currentWorkspaceId ? workspacesService.openByWorkspaceId(currentWorkspaceId) : null; try { const workspace = workspaceRef?.workspace; if (!workspace) { return; } const workbench = workspace.scope.get(WorkbenchService).workbench; await workspace.engine.doc.waitForDocReady(workspace.id); // wait for root doc ready const docId = await MarkdownTransformer.importMarkdownToDoc({ collection: workspace.docCollection, schema: getAFFiNEWorkspaceSchema(), markdown, extensions: getStoreManager().config.init().value.get('store'), }); const docsService = workspace.scope.get(DocsService); if (docId) { // only support page mode for now await docsService.changeDocTitle(docId, title); docsService.list.setPrimaryMode(docId, 'page'); workbench.openDoc(docId); return docId; } else { throw new Error('Failed to import doc'); } } finally { workspaceRef?.dispose(); } }; (window as any).getSubscriptionState = async () => { const globalContextService = frameworkProvider.get(GlobalContextService); const currentServerId = globalContextService.globalContext.serverId.get(); const serversService = frameworkProvider.get(ServersService); const defaultServerService = frameworkProvider.get(DefaultServerService); const currentServer = (currentServerId ? serversService.server$(currentServerId).value : null) ?? defaultServerService.server; const subscriptionService = currentServer.scope.get(SubscriptionService); await subscriptionService.subscription.waitForRevalidation(); return { pro: subscriptionService.subscription.pro$.value, ai: subscriptionService.subscription.ai$.value, }; }; (window as any).updateSubscriptionState = async () => { const globalContextService = frameworkProvider.get(GlobalContextService); const currentServerId = globalContextService.globalContext.serverId.get(); const serversService = frameworkProvider.get(ServersService); const defaultServerService = frameworkProvider.get(DefaultServerService); const currentServer = (currentServerId ? serversService.server$(currentServerId).value : null) ?? defaultServerService.server; await currentServer .gql({ query: refreshSubscriptionMutation, }) .catch(console.error); const subscriptionService = currentServer.scope.get(SubscriptionService); subscriptionService.subscription.revalidate(); }; (window as any).requestApplySubscription = async (transactionId: string) => { const globalContextService = frameworkProvider.get(GlobalContextService); const currentServerId = globalContextService.globalContext.serverId.get(); const serversService = frameworkProvider.get(ServersService); const defaultServerService = frameworkProvider.get(DefaultServerService); const currentServer = (currentServerId ? serversService.server$(currentServerId).value : null) ?? defaultServerService.server; await currentServer .gql({ query: requestApplySubscriptionMutation, variables: { transactionId }, }) .catch(console.error); const subscriptionService = currentServer.scope.get(SubscriptionService); subscriptionService.subscription.revalidate(); }; // setup application lifecycle events, and emit application start event window.addEventListener('focus', () => { frameworkProvider.get(LifecycleService).applicationFocus(); }); frameworkProvider.get(LifecycleService).applicationStart(); CapacitorApp.addListener('appStateChange', ({ isActive }) => { if (!isActive) return; const servers = frameworkProvider.get(ServersService).servers$.value; Promise.allSettled( servers.map(server => getValidAccessToken(server.baseUrl)) ).catch(console.error); }).catch(console.error); const getErrorMessage = (error: unknown, fallback: string) => { if (typeof error === 'string' && error) { return error; } if (error instanceof Error && error.message) { return error.message; } return fallback; }; const notifyAuthenticationError = (error: unknown, fallback: string) => { console.error(fallback, error); notify.error({ title: I18n['com.affine.auth.toast.title.failed'](), message: getErrorMessage(error, fallback), }); }; CapacitorApp.addListener('appUrlOpen', ({ url }) => { // try to close browser if it's open Browser.close().catch(e => console.error('Failed to close browser', e)); const urlObj = new URL(url); if (urlObj.hostname === 'authentication') { const method = urlObj.searchParams.get('method'); const payload = JSON.parse(urlObj.searchParams.get('payload') ?? 'false'); const serverBaseUrl = urlObj.searchParams.get('server'); if ( !method || (method !== 'magic-link' && method !== 'oauth') || !payload ) { notifyAuthenticationError( new Error('Invalid authentication url'), 'Invalid authentication url' ); return; } let authService = frameworkProvider .get(DefaultServerService) .server.scope.get(AuthService); if (serverBaseUrl) { const serversService = frameworkProvider.get(ServersService); const server = serversService.getServerByBaseUrl(serverBaseUrl); if (!server) { notifyAuthenticationError( new Error( `Authentication callback server not found: ${serverBaseUrl}` ), 'Authentication callback server not found' ); return; } authService = server.scope.get(AuthService); } if (method === 'oauth') { authService .signInOauth(payload.code, payload.state, payload.provider) .catch(error => notifyAuthenticationError(error, 'Failed to sign in with OAuth') ); } else if (method === 'magic-link') { authService .signInMagicLink(payload.email, payload.token) .catch(error => notifyAuthenticationError(error, 'Failed to sign in with magic link') ); } } }).catch(e => { notifyAuthenticationError(e, 'Failed to handle authentication callback'); }); AppTrackingTransparency.requestPermission().catch(e => { console.error('Failed to request app tracking transparency permission', e); }); const KeyboardThemeProvider = () => { const { resolvedTheme } = useTheme(); useEffect(() => { Keyboard.setStyle({ style: resolvedTheme === 'dark' ? KeyboardStyle.Dark : resolvedTheme === 'light' ? KeyboardStyle.Light : KeyboardStyle.Default, }).catch(e => { console.error(`Failed to set keyboard style: ${e}`); }); }, [resolvedTheme]); useEffect(() => { const themeMode = resolvedTheme === 'dark' ? 'dark' : 'light'; (window as any).getCurrentThemeMode = () => { return themeMode; }; AffineTheme.onThemeChanged({ themeMode, }).catch(e => { console.error(`Failed to sync app theme: ${e}`); }); }, [resolvedTheme]); return null; }; const IOSBackAdapter = () => { const coordinator = useService(MobileBackCoordinator); const enabled = useLiveData(coordinator.canInteractivePop$); useEffect(() => { (enabled ? NavigationGesture.enable() : NavigationGesture.disable()).catch( console.error ); }, [enabled]); useEffect(() => { let disposed = false; let remove = () => {}; NavigationGesture.addListener('gesture', event => { coordinator.handleInteractivePhase(event.phase); }) .then(handle => { if (disposed) handle.remove().catch(console.error); else remove = () => { handle.remove().catch(console.error); }; }) .catch(console.error); return () => { disposed = true; remove(); NavigationGesture.disable().catch(console.error); }; }, [coordinator]); return null; }; export function App() { return ( } router={router} future={future} /> ); } function createStoreManagerClient() { const worker = new Worker(getWorkerUrl('nbstore')); const { port1: nativeDBApiChannelServer, port2: nativeDBApiChannelClient } = new MessageChannel(); AsyncCall(NbStoreNativeDBApis, { channel: { on(listener) { const f = (e: MessageEvent) => listener(e.data); nativeDBApiChannelServer.addEventListener('message', f); return () => nativeDBApiChannelServer.removeEventListener('message', f); }, send(data) { nativeDBApiChannelServer.postMessage(data); }, }, log: false, }); nativeDBApiChannelServer.start(); worker.postMessage( { type: 'native-db-api-channel', port: nativeDBApiChannelClient }, [nativeDBApiChannelClient] ); const { port1: authTokenChannelServer, port2: authTokenChannelClient } = new MessageChannel(); serveAuthRequests(authTokenChannelServer, authRequestProvider); worker.postMessage( { type: 'auth-access-token-channel', port: authTokenChannelClient }, [authTokenChannelClient] ); return new StoreManagerClient(new OpClient(worker)); }