Files
AFFiNE-Mirror/packages/frontend/apps/ios/src/app.tsx
T
DarkSky 1d36e2e4b2 feat(core): improve mobile perf (#15317)
#### PR Dependency Tree


* **PR #15317** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Virtualized mobile navigation with shell navigation and interactive
swipe menus; coordinated mobile back handling with interactive
phases/state restoration.
* Added shared auth request proxy and message-port based token handling
across mobile and worker flows.
* **Bug Fixes**
  * Hydrated remote worker error stacks for calls and observable errors.
* Improved SQLite FTS/indexer and nbstore optional text handling;
refined docs-search ref parsing and notification loading/retry.
* **Refactor / UX**
* Modal focus-preservation and pointer behavior updates; improved mobile
menu controls and back gesture plugins.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-23 00:23:21 +08:00

677 lines
22 KiB
TypeScript

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<File> =>
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 (
<Suspense>
<FrameworkRoot framework={frameworkProvider}>
<I18nProvider>
<MobileModalConfigProvider>
<AffineContext store={getCurrentStore()}>
<KeyboardThemeProvider />
<IOSBackAdapter />
<BlocksuiteMenuConfigProvider>
<RouterProvider
fallbackElement={<AppFallback />}
router={router}
future={future}
/>
</BlocksuiteMenuConfigProvider>
</AffineContext>
</MobileModalConfigProvider>
</I18nProvider>
</FrameworkRoot>
</Suspense>
);
}
function createStoreManagerClient() {
const worker = new Worker(getWorkerUrl('nbstore'));
const { port1: nativeDBApiChannelServer, port2: nativeDBApiChannelClient } =
new MessageChannel();
AsyncCall<typeof NbStoreNativeDBApis>(NbStoreNativeDBApis, {
channel: {
on(listener) {
const f = (e: MessageEvent<any>) => 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));
}