snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

View File

@@ -0,0 +1,928 @@
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 { ShareImportController } from '@affine/core/mobile/components/share-import-controller';
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 { getCurrentNativeUserIdentifier } from '@affine/core/mobile/utils/native-user-identifier';
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 { GlobalDialogService } from '@affine/core/modules/dialogs';
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, UrlService } 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 {
OAuthProviderType,
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 { shareInboxProvider } from './plugins/share-inbox';
import {
authRequestProvider,
clearEndpointSession,
getValidAccessToken,
} from './proxy';
const storeManagerClient = createStoreManagerClient();
setTelemetryTransport(storeManagerClient.telemetry);
window.addEventListener('beforeunload', () => {
storeManagerClient.dispose();
});
const waitForSubscriptionRevalidation = async (
subscriptionService: SubscriptionService,
fallbackMessage: string
) => {
await subscriptionService.subscription.waitForRevalidation();
const error = subscriptionService.subscription.error$.value;
if (error) {
throw error instanceof Error
? error
: new Error(getErrorMessage(error, fallbackMessage));
}
};
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: () => {},
};
let viewportDispose = () => {};
let pluginKeyboardHeight = 0;
let pluginKeyboardVisible = false;
const getViewportKeyboardHeight = () => {
const viewport = window.visualViewport;
if (!viewport) {
return 0;
}
return Math.max(
0,
window.innerHeight - viewport.height - viewport.offsetTop
);
};
const emitKeyboardState = () => {
const viewportKeyboardHeight = getViewportKeyboardHeight();
const effectiveKeyboardHeight = Math.max(
pluginKeyboardHeight,
viewportKeyboardHeight
);
callback({
visible: pluginKeyboardVisible || effectiveKeyboardHeight > 0,
height: effectiveKeyboardHeight,
});
};
const viewport = window.visualViewport;
if (viewport) {
const handleViewportChange = () => {
emitKeyboardState();
};
viewport.addEventListener('resize', handleViewportChange);
viewport.addEventListener('scroll', handleViewportChange);
viewportDispose = () => {
viewport.removeEventListener('resize', handleViewportChange);
viewport.removeEventListener('scroll', handleViewportChange);
};
}
Promise.all([
Keyboard.addListener('keyboardWillShow', info => {
pluginKeyboardVisible = info.keyboardHeight !== 0;
pluginKeyboardHeight = info.keyboardHeight;
emitKeyboardState();
}),
Keyboard.addListener('keyboardWillHide', () => {
pluginKeyboardVisible = false;
pluginKeyboardHeight = 0;
emitKeyboardState();
}),
])
.then(handlers => {
disposeRef.dispose = () => {
Promise.all(handlers.map(handler => handler.remove())).catch(
console.error
);
};
})
.catch(console.error);
emitKeyboardState();
return () => {
disposeRef.dispose();
viewportDispose();
};
},
});
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();
let cancelActiveRequestSignIn: (() => void) | null = null;
let activeNativeSignInPromise: Promise<string | null> | null = null;
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 ------
const getCurrentServerForNative = () => {
const globalContextService = frameworkProvider.get(GlobalContextService);
const globalContext = globalContextService.globalContext;
const currentServerId = globalContext.serverId.get();
const currentWorkspaceFlavour = globalContext.workspaceFlavour.get();
const serversService = frameworkProvider.get(ServersService);
const defaultServerService = frameworkProvider.get(DefaultServerService);
if (currentWorkspaceFlavour && currentWorkspaceFlavour !== 'local') {
const workspaceServer = serversService.server$(
currentWorkspaceFlavour
).value;
if (workspaceServer) {
return workspaceServer;
}
}
return (
(currentServerId ? serversService.server$(currentServerId).value : null) ??
defaultServerService.server
);
};
(window as any).getCurrentServerBaseUrl = () => {
return getCurrentServerForNative().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).waitForSelectedSources = async (documentIds: string[]) => {
const globalContextService = frameworkProvider.get(GlobalContextService);
const globalContext = globalContextService.globalContext;
const currentWorkspaceId = globalContext.workspaceId.get();
const currentWorkspaceFlavour = globalContext.workspaceFlavour.get();
const workspacesService = frameworkProvider.get(WorkspacesService);
const workspaceRef = currentWorkspaceId
? workspacesService.openByWorkspaceId(
currentWorkspaceId,
currentWorkspaceFlavour
)
: null;
if (!workspaceRef) {
throw new Error('Current workspace is unavailable');
}
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
const { workspace } = workspaceRef;
await Promise.race([
Promise.all(
[workspace.id, 'db$docProperties', ...new Set(documentIds)].map(docId =>
workspace.engine.doc.waitForSynced(docId)
)
),
new Promise<never>((_, reject) => {
timeout = setTimeout(
() => reject(new Error('Selected source synchronization timed out')),
15000
);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
workspaceRef.dispose();
}
};
(window as any).getCurrentUserIdentifier = async () => {
const { authService } = getCurrentNativeSignInContext();
return await getCurrentNativeUserIdentifier(authService);
};
(window as any).cancelRequestSignIn = () => {
if (!cancelActiveRequestSignIn) {
return false;
}
cancelActiveRequestSignIn();
return true;
};
const getCurrentNativeSignInContext = () => {
const currentServer = getCurrentServerForNative();
const authService = currentServer.scope.get(AuthService);
return { authService, currentServer };
};
(window as any).nativeStartOAuthSignIn = async (
provider: 'Google' | 'Apple'
) => {
const { authService } = getCurrentNativeSignInContext();
const urlService = frameworkProvider.get(UrlService);
const scheme = urlService.getClientScheme();
const oauthProvider =
provider === 'Apple' ? OAuthProviderType.Apple : OAuthProviderType.Google;
const options = await authService.oauthPreflight(
oauthProvider,
scheme ?? 'web'
);
return options.url;
};
(window as any).nativeCheckEmailSignInMethods = async (email: string) => {
const { authService } = getCurrentNativeSignInContext();
const { methods } = await authService.checkUserByEmail(email);
return {
hasPassword: !!methods.password.available,
canUseMagicLink: !!methods.magicLink.available,
};
};
(window as any).nativeSendEmailMagicLink = async (email: string) => {
const { authService } = getCurrentNativeSignInContext();
await authService.sendEmailMagicLink(email);
return true;
};
(window as any).nativeSignInWithMagicLink = async (
email: string,
token: string
) => {
const { authService } = getCurrentNativeSignInContext();
await authService.signInMagicLink(email, token, false);
const session = await authService.session.waitForAuthenticated();
return session.session.account.id;
};
(window as any).nativeSignInWithPassword = async (
email: string,
password: string
) => {
const { authService } = getCurrentNativeSignInContext();
await authService.signInPassword({ email, password });
const session = await authService.session.waitForAuthenticated();
return session.session.account.id;
};
(window as any).nativeOpenSelfHostedSignIn = async () => {
const globalDialogService = frameworkProvider.get(GlobalDialogService);
globalDialogService.open('sign-in', { step: 'addSelfhosted' });
return true;
};
const showNativeSignIn = async () => {
const { authService } = getCurrentNativeSignInContext();
const account = authService.session.account$.value;
if (account?.id) {
return account.id;
}
if (activeNativeSignInPromise) {
const result = await activeNativeSignInPromise;
const authenticatedAccount = authService.session.account$.value;
if (authenticatedAccount?.id) {
return authenticatedAccount.id;
}
return result;
}
let cancelRequestSignIn!: () => void;
const cancelledSignIn = new Promise<{ success: false }>(resolve => {
cancelRequestSignIn = () => resolve({ success: false });
});
cancelActiveRequestSignIn = cancelRequestSignIn;
activeNativeSignInPromise = (async () => {
const result = await Promise.race([
Auth.showNativeSignIn(),
cancelledSignIn,
]);
if (!result.success) {
return null;
}
const authenticatedAccount = authService.session.account$.value;
if (authenticatedAccount?.id) {
return authenticatedAccount.id;
}
const session = await authService.session.waitForAuthenticated();
return session.session.account.id;
})();
try {
return await activeNativeSignInPromise;
} finally {
if (cancelActiveRequestSignIn === cancelRequestSignIn) {
cancelActiveRequestSignIn = null;
}
activeNativeSignInPromise = null;
}
};
(window as any).showNativeSignIn = showNativeSignIn;
(window as any).requestSignIn = async () => {
return await showNativeSignIn();
};
(window as any).getCurrentDocContentInMarkdown = async () => {
const globalContextService = frameworkProvider.get(GlobalContextService);
const globalContext = globalContextService.globalContext;
const currentWorkspaceId = globalContext.workspaceId.get();
const currentWorkspaceFlavour = globalContext.workspaceFlavour.get();
const currentDocId = globalContext.docId.get();
const workspacesService = frameworkProvider.get(WorkspacesService);
const workspaceRef = currentWorkspaceId
? workspacesService.openByWorkspaceId(
currentWorkspaceId,
currentWorkspaceFlavour
)
: null;
if (!workspaceRef) {
return;
}
const { workspace, dispose: disposeWorkspace } = workspaceRef;
const docsService = workspace.scope.get(DocsService);
const docRef = currentDocId ? docsService.open(currentDocId) : null;
if (!docRef) {
disposeWorkspace();
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 globalContext = globalContextService.globalContext;
const currentWorkspaceId = globalContext.workspaceId.get();
const currentWorkspaceFlavour = globalContext.workspaceFlavour.get();
const workspacesService = frameworkProvider.get(WorkspacesService);
const workspaceRef = currentWorkspaceId
? workspacesService.openByWorkspaceId(
currentWorkspaceId,
currentWorkspaceFlavour
)
: null;
try {
const workspace = workspaceRef?.workspace;
if (!workspace) {
return;
}
const workbench = workspace.scope.get(WorkbenchService).workbench;
await workspace.engine.doc.waitForDocReady(workspace.id);
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) {
throw new Error('Failed to import doc');
}
await docsService.changeDocTitle(docId, title);
docsService.list.setPrimaryMode(docId, 'page');
workbench.openDoc(docId);
return docId;
} 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 waitForSubscriptionRevalidation(
subscriptionService,
'Unable to refresh subscription state.'
);
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,
});
const subscriptionService = currentServer.scope.get(SubscriptionService);
await waitForSubscriptionRevalidation(
subscriptionService,
'Unable to refresh subscription state.'
);
};
(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 },
});
const subscriptionService = currentServer.scope.get(SubscriptionService);
await waitForSubscriptionRevalidation(
subscriptionService,
'Unable to refresh subscription state after purchase.'
);
};
// 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),
});
};
const handleAuthenticationCallback = async (url: string) => {
const urlObj = new URL(url);
if (urlObj.hostname !== 'authentication') {
return;
}
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) {
throw new Error('Invalid authentication url');
}
let authService = frameworkProvider
.get(DefaultServerService)
.server.scope.get(AuthService);
if (serverBaseUrl) {
const serversService = frameworkProvider.get(ServersService);
const server = serversService.getServerByBaseUrl(serverBaseUrl);
if (!server) {
throw new Error(
`Authentication callback server not found: ${serverBaseUrl}`
);
}
authService = server.scope.get(AuthService);
}
if (method === 'oauth') {
await authService.signInOauth(
payload.code,
payload.state,
payload.provider
);
} else if (method === 'magic-link') {
await authService.signInMagicLink(payload.email, payload.token);
}
};
(window as any).nativeHandleAuthenticationCallback = async (url: string) => {
await handleAuthenticationCallback(url);
return true;
};
CapacitorApp.addListener('appUrlOpen', ({ url }) => {
// try to close browser if it's open
Browser.close().catch(e => console.error('Failed to close browser', e));
handleAuthenticationCallback(url).catch(error =>
notifyAuthenticationError(error, 'Failed to handle authentication callback')
);
}).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, theme } = 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(() => {
if (!theme && !resolvedTheme) {
return;
}
const themeMode: 'dark' | 'light' | 'system' =
theme === 'dark' || theme === 'light' || theme === 'system'
? theme
: resolvedTheme === 'dark'
? 'dark'
: resolvedTheme === 'light'
? 'light'
: 'system';
(window as any).getCurrentThemeMode = () => {
return themeMode;
};
AffineTheme.onThemeChanged({
themeMode,
}).catch(e => {
console.error(`Failed to sync app theme: ${e}`);
});
}, [resolvedTheme, theme]);
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 />
<ShareImportController provider={shareInboxProvider} />
<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));
}

View File

@@ -0,0 +1,21 @@
import { MobileBackCoordinator } from '@affine/core/mobile/modules/back-coordinator';
import { onMenuOpen } from '@blocksuite/affine/components/context-menu';
import { useService } from '@toeverything/infra';
import { type PropsWithChildren, useEffect } from 'react';
export const BlocksuiteMenuConfigProvider = ({
children,
}: PropsWithChildren) => {
const coordinator = useService(MobileBackCoordinator);
useEffect(() => {
return onMenuOpen(() => {
return coordinator.registerVisual({
interactive: false,
handle: () => false,
}).dispose;
});
}, [coordinator]);
return children;
};

View File

@@ -0,0 +1,30 @@
import './setup';
import '@affine/component/theme';
import '@affine/core/mobile/styles/mobile.css';
import { Telemetry } from '@affine/core/components/telemetry';
import { bindNativeDBApis } from '@affine/nbstore/sqlite';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './app';
import { NbStoreNativeDBApis } from './plugins/nbstore';
bindNativeDBApis(NbStoreNativeDBApis);
function mountApp() {
// oxlint-disable-next-line typescript/no-non-null-assertion
const root = document.getElementById('app')!;
createRoot(root).render(
<StrictMode>
<Telemetry />
<App />
</StrictMode>
);
}
try {
mountApp();
} catch (err) {
console.error('Failed to bootstrap app', err);
}

View File

@@ -0,0 +1,76 @@
import './setup-worker';
import { MessagePortAuthProvider } from '@affine/mobile-shared/auth/channel';
import { installAuthRequestProxy } from '@affine/mobile-shared/auth/request';
import { broadcastChannelStorages } from '@affine/nbstore/broadcast-channel';
import {
cloudStorages,
configureSocketAuthMethod,
} from '@affine/nbstore/cloud';
import { idbStoragesIndexerOnly } from '@affine/nbstore/idb';
import {
bindNativeDBApis,
type NativeDBApis,
sqliteStorages,
} from '@affine/nbstore/sqlite';
import {
StoreManagerConsumer,
type WorkerManagerOps,
} from '@affine/nbstore/worker/consumer';
import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
import { AsyncCall } from 'async-call-rpc';
const authProvider = new MessagePortAuthProvider();
installAuthRequestProxy(authProvider);
configureSocketAuthMethod((endpoint, cb) => {
authProvider
.getValidAccessToken(endpoint)
.then(token => cb(token ? { token, tokenType: 'jwt' } : {}))
.catch(() => cb({ error: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE' }));
});
globalThis.addEventListener('message', e => {
if (e.data.type === 'auth-access-token-channel') {
authProvider.setPort(e.ports[0] as MessagePort);
return;
}
if (e.data.type === 'native-db-api-channel') {
const port = e.ports[0] as MessagePort;
const rpc = AsyncCall<NativeDBApis>(
{},
{
channel: {
on(listener) {
const f = (e: MessageEvent<any>) => {
listener(e.data);
};
port.addEventListener('message', f);
return () => {
port.removeEventListener('message', f);
};
},
send(data) {
port.postMessage(data);
},
},
}
);
bindNativeDBApis(rpc);
port.start();
}
});
const consumer = new OpConsumer<WorkerManagerOps>(
globalThis as MessageCommunicapable
);
const storeManager = new StoreManagerConsumer([
...idbStoragesIndexerOnly,
...sqliteStorages,
...broadcastChannelStorages,
...cloudStorages,
]);
storeManager.bindConsumer(consumer);

View File

@@ -0,0 +1,5 @@
import type { NativeThemeMode } from './theme-mode';
export interface AffineThemePlugin {
onThemeChanged(options: { themeMode: NativeThemeMode }): Promise<void>;
}

View File

@@ -0,0 +1,9 @@
import { registerPlugin } from '@capacitor/core';
import type { AffineThemePlugin } from './definitions';
const AffineTheme = registerPlugin<AffineThemePlugin>('AffineTheme');
export * from './definitions';
export * from './theme-mode';
export { AffineTheme };

View File

@@ -0,0 +1 @@
export type NativeThemeMode = 'dark' | 'light' | 'system';

View File

@@ -0,0 +1,29 @@
export interface AuthPlugin {
showNativeSignIn(): Promise<{ success: boolean }>;
signInMagicLink(options: {
endpoint: string;
email: string;
token: string;
clientNonce?: string;
}): Promise<void>;
signInOauth(options: {
endpoint: string;
code: string;
state: string;
clientNonce?: string;
}): Promise<void>;
signInPassword(options: {
endpoint: string;
email: string;
password: string;
verifyToken?: string;
challenge?: string;
}): Promise<void>;
signInOpenApp(options: { endpoint: string; code: string }): Promise<void>;
signOut(options: { endpoint: string }): Promise<void>;
getValidAccessToken(options: {
endpoint: string;
}): Promise<{ token?: string | null }>;
refreshAccessToken(options: { endpoint: string }): Promise<{ token: string }>;
clearEndpointSession(options: { endpoint: string }): Promise<void>;
}

View File

@@ -0,0 +1,8 @@
import { registerPlugin } from '@capacitor/core';
import type { AuthPlugin } from './definitions';
const Auth = registerPlugin<AuthPlugin>('Auth');
export * from './definitions';
export { Auth };

View File

@@ -0,0 +1,6 @@
export interface HashcashPlugin {
hash(options: {
challenge: string;
bits?: number;
}): Promise<{ value: string }>;
}

View File

@@ -0,0 +1,8 @@
import { registerPlugin } from '@capacitor/core';
import type { HashcashPlugin } from './definitions';
const Hashcash = registerPlugin<HashcashPlugin>('Hashcash');
export * from './definitions';
export { Hashcash };

View File

@@ -0,0 +1,13 @@
export interface PickedImageFile {
path: string;
name: string;
mimeType: string;
lastModified: number;
}
export interface ImagePickerPlugin {
pickImages(options?: { multiple?: boolean }): Promise<{
files: PickedImageFile[];
canceled?: boolean;
}>;
}

View File

@@ -0,0 +1,8 @@
import { registerPlugin } from '@capacitor/core';
import type { ImagePickerPlugin } from './definitions';
const ImagePicker = registerPlugin<ImagePickerPlugin>('ImagePicker');
export * from './definitions';
export { ImagePicker };

View File

@@ -0,0 +1,14 @@
import type { PluginListenerHandle } from '@capacitor/core';
export interface NavigationGesturePlugin {
isEnabled: () => Promise<boolean>;
enable: () => Promise<void>;
disable: () => Promise<void>;
addListener(
event: 'gesture',
listener: (event: {
phase: 'begin' | 'progress' | 'commit' | 'cancel';
progress: number;
}) => void
): Promise<PluginListenerHandle>;
}

View File

@@ -0,0 +1,9 @@
import { registerPlugin } from '@capacitor/core';
import type { NavigationGesturePlugin } from './definitions';
const NavigationGesture =
registerPlugin<NavigationGesturePlugin>('NavigationGesture');
export * from './definitions';
export { NavigationGesture };

View File

@@ -0,0 +1,228 @@
import type { CrawlResult, DocIndexedClock } from '@affine/nbstore';
import type {
NativeIndexField,
NativeIndexHit,
NativeIndexQuery,
NativeIndexSearchOptions,
NativeIndexSearchResult,
} from '@affine/nbstore/sqlite';
type NativeIndexDocument = { id: string; fields: NativeIndexField[] };
type NativeIndexAggregateResult = {
total: number;
buckets: {
key: string;
count: number;
score: number;
hits: NativeIndexHit[];
}[];
};
export interface Blob {
key: string;
// base64 encoded data, or "__AFFINE_BLOB_FILE__:<absolutePath>" for large blobs
data: string;
mime: string;
size: number;
createdAt: number;
}
export interface SetBlob {
key: string;
// base64 encoded data
data: string;
mime: string;
}
export interface ListedBlob {
key: string;
mime: string;
size: number;
createdAt: number;
}
export interface DocClock {
docId: string;
timestamp: number;
}
export interface NbStorePlugin {
connect: (options: {
id: string;
spaceId: string;
spaceType: string;
peer: string;
}) => Promise<void>;
disconnect: (options: { id: string }) => Promise<void>;
setSpaceId: (options: { id: string; spaceId: string }) => Promise<void>;
pushUpdate: (options: {
id: string;
docId: string;
// base64 encoded data
data: string;
}) => Promise<{ timestamp: number }>;
getDocSnapshot: (options: { id: string; docId: string }) => Promise<
| {
docId: string;
// base64 encoded data
bin: string;
timestamp: number;
}
| undefined
>;
setDocSnapshot: (options: {
id: string;
docId: string;
// base64 encoded data
bin: string;
timestamp: number;
}) => Promise<{ success: boolean }>;
getDocUpdates: (options: { id: string; docId: string }) => Promise<{
updates: {
docId: string;
timestamp: number;
// base64 encoded data
bin: string;
}[];
}>;
markUpdatesMerged: (options: {
id: string;
docId: string;
timestamps: number[];
}) => Promise<{ count: number }>;
deleteDoc: (options: { id: string; docId: string }) => Promise<void>;
getDocClocks: (options: { id: string; after?: number | null }) => Promise<{
clocks: {
docId: string;
timestamp: number;
}[];
}>;
getDocClock: (options: { id: string; docId: string }) => Promise<
| {
docId: string;
timestamp: number;
}
| undefined
>;
getBlob: (options: { id: string; key: string }) => Promise<Blob | null>;
setBlob: (options: { id: string } & SetBlob) => Promise<void>;
deleteBlob: (options: {
id: string;
key: string;
permanently: boolean;
}) => Promise<void>;
releaseBlobs: (options: { id: string }) => Promise<void>;
listBlobs: (options: { id: string }) => Promise<{ blobs: Array<ListedBlob> }>;
getPeerRemoteClocks: (options: {
id: string;
peer: string;
}) => Promise<{ clocks: Array<DocClock> }>;
getPeerRemoteClock: (options: {
id: string;
peer: string;
docId: string;
}) => Promise<DocClock | null>;
setPeerRemoteClock: (options: {
id: string;
peer: string;
docId: string;
timestamp: number;
}) => Promise<void>;
getPeerPushedClocks: (options: {
id: string;
peer: string;
}) => Promise<{ clocks: Array<DocClock> }>;
getPeerPushedClock: (options: {
id: string;
peer: string;
docId: string;
}) => Promise<DocClock | null>;
setPeerPushedClock: (options: {
id: string;
peer: string;
docId: string;
timestamp: number;
}) => Promise<void>;
getPeerPulledRemoteClocks: (options: {
id: string;
peer: string;
}) => Promise<{ clocks: Array<DocClock> }>;
getPeerPulledRemoteClock: (options: {
id: string;
peer: string;
docId: string;
}) => Promise<DocClock | null>;
setPeerPulledRemoteClock: (options: {
id: string;
peer: string;
docId: string;
timestamp: number;
}) => Promise<void>;
getBlobUploadedAt: (options: {
id: string;
peer: string;
blobId: string;
}) => Promise<{ uploadedAt: number | null }>;
setBlobUploadedAt: (options: {
id: string;
peer: string;
blobId: string;
uploadedAt: number | null;
}) => Promise<void>;
clearClocks: (options: { id: string }) => Promise<void>;
crawlDocData: (options: {
id: string;
docId: string;
}) => Promise<CrawlResult>;
indexUpsert: (options: {
id: string;
table: string;
document: NativeIndexDocument;
}) => Promise<void>;
indexDelete: (options: {
id: string;
table: string;
docId: string;
}) => Promise<void>;
indexSearch: (options: {
id: string;
table: string;
query: NativeIndexQuery;
options: NativeIndexSearchOptions;
}) => Promise<NativeIndexSearchResult>;
indexAggregate: (options: {
id: string;
table: string;
query: NativeIndexQuery;
field: string;
limit: number;
offset: number;
hits?: NativeIndexSearchOptions;
}) => Promise<NativeIndexAggregateResult>;
indexDeleteByQuery: (options: {
id: string;
table: string;
query: NativeIndexQuery;
}) => Promise<{ deleted: number }>;
indexFlush: (options: { id: string }) => Promise<void>;
indexVersion: () => Promise<{ indexVersion: number }>;
getDocIndexedClock: (options: {
id: string;
docId: string;
}) => Promise<DocIndexedClock | null>;
setDocIndexedClock: (options: {
id: string;
docId: string;
indexedClock: number;
indexerVersion: number;
}) => Promise<void>;
setDocIndexedClocks: (options: {
id: string;
clocks: Array<{ docId: string; timestamp: number; indexerVersion: number }>;
}) => Promise<void>;
clearDocIndexedClock: (options: {
id: string;
docId: string;
}) => Promise<void>;
}

View File

@@ -0,0 +1,465 @@
import {
base64ToUint8Array,
uint8ArrayToBase64,
} from '@affine/core/modules/workspace-engine';
import {
decodePayload,
MOBILE_BLOB_FILE_PREFIX,
} from '@affine/mobile-shared/nbstore/payload';
import {
type BlobRecord,
type CrawlResult,
type DocClock,
type DocIndexedClock,
type DocRecord,
type ListedBlobRecord,
parseUniversalId,
} from '@affine/nbstore';
import { type NativeDBApis } from '@affine/nbstore/sqlite';
import { registerPlugin } from '@capacitor/core';
import type { NbStorePlugin } from './definitions';
export * from './definitions';
export const NbStore = registerPlugin<NbStorePlugin>('NbStoreDocStorage');
export const NbStoreNativeDBApis: NativeDBApis = {
connect: async function (id: string): Promise<void> {
const { peer, type, id: spaceId } = parseUniversalId(id);
return await NbStore.connect({ id, spaceId, spaceType: type, peer });
},
disconnect: function (id: string): Promise<void> {
return NbStore.disconnect({ id });
},
pushUpdate: async function (
id: string,
docId: string,
update: Uint8Array
): Promise<Date> {
const { timestamp } = await NbStore.pushUpdate({
id,
docId,
data: await uint8ArrayToBase64(update),
});
return new Date(timestamp);
},
getDocSnapshot: async function (
id: string,
docId: string
): Promise<DocRecord | null> {
const snapshot = await NbStore.getDocSnapshot({ id, docId });
return snapshot
? {
bin: base64ToUint8Array(snapshot.bin),
docId: snapshot.docId,
timestamp: new Date(snapshot.timestamp),
}
: null;
},
setDocSnapshot: async function (
id: string,
snapshot: DocRecord
): Promise<boolean> {
const { success } = await NbStore.setDocSnapshot({
id,
docId: snapshot.docId,
bin: await uint8ArrayToBase64(snapshot.bin),
timestamp: snapshot.timestamp.getTime(),
});
return success;
},
getDocUpdates: async function (
id: string,
docId: string
): Promise<DocRecord[]> {
const { updates } = await NbStore.getDocUpdates({ id, docId });
return updates.map(update => ({
bin: base64ToUint8Array(update.bin),
docId: update.docId,
timestamp: new Date(update.timestamp),
}));
},
markUpdatesMerged: async function (
id: string,
docId: string,
updates: Date[]
): Promise<number> {
const { count } = await NbStore.markUpdatesMerged({
id,
docId,
timestamps: updates.map(t => t.getTime()),
});
return count;
},
deleteDoc: async function (id: string, docId: string): Promise<void> {
await NbStore.deleteDoc({
id,
docId,
});
},
getDocClocks: async function (
id: string,
after?: Date | undefined | null
): Promise<DocClock[]> {
const clocks = (
await NbStore.getDocClocks({
id,
after: after?.getTime(),
})
).clocks;
return clocks.map(c => ({
docId: c.docId,
timestamp: new Date(c.timestamp),
}));
},
getDocClock: async function (
id: string,
docId: string
): Promise<DocClock | null> {
const clock = await NbStore.getDocClock({
id,
docId,
});
return clock
? {
timestamp: new Date(clock.timestamp),
docId: clock.docId,
}
: null;
},
getBlob: async function (
id: string,
key: string
): Promise<BlobRecord | null> {
const record = await NbStore.getBlob({
id,
key,
});
if (!record) {
return null;
}
let refreshedBlobPromise: ReturnType<typeof NbStore.getBlob> | undefined;
return {
data: await decodePayload(record.data, MOBILE_BLOB_FILE_PREFIX, {
onTokenReadFailure: async () => {
refreshedBlobPromise ??= NbStore.getBlob({ id, key });
return (await refreshedBlobPromise)?.data;
},
}),
key: record.key,
mime: record.mime,
createdAt: new Date(record.createdAt),
};
},
setBlob: async function (id: string, blob: BlobRecord): Promise<void> {
await NbStore.setBlob({
id,
data: await uint8ArrayToBase64(blob.data),
key: blob.key,
mime: blob.mime,
});
},
deleteBlob: async function (
id: string,
key: string,
permanently: boolean
): Promise<void> {
await NbStore.deleteBlob({
id,
key,
permanently,
});
},
releaseBlobs: async function (id: string): Promise<void> {
await NbStore.releaseBlobs({
id,
});
},
listBlobs: async function (id: string): Promise<ListedBlobRecord[]> {
const listed = await NbStore.listBlobs({
id,
});
return listed.blobs.map(b => ({
key: b.key,
mime: b.mime,
size: b.size,
createdAt: new Date(b.createdAt),
}));
},
getPeerRemoteClocks: async function (
id: string,
peer: string
): Promise<DocClock[]> {
const clocks = (
await NbStore.getPeerRemoteClocks({
id,
peer,
})
).clocks;
return clocks.map(c => ({
docId: c.docId,
timestamp: new Date(c.timestamp),
}));
},
getPeerRemoteClock: async function (id: string, peer: string, docId: string) {
const clock = await NbStore.getPeerRemoteClock({
id,
peer,
docId,
});
return clock
? {
docId: clock.docId,
timestamp: new Date(clock.timestamp),
}
: null;
},
setPeerRemoteClock: async function (
id: string,
peer: string,
docId: string,
clock: Date
): Promise<void> {
await NbStore.setPeerRemoteClock({
id,
peer,
docId,
timestamp: clock.getTime(),
});
},
getPeerPulledRemoteClocks: async function (
id: string,
peer: string
): Promise<DocClock[]> {
const clocks = (
await NbStore.getPeerPulledRemoteClocks({
id,
peer,
})
).clocks;
return clocks.map(c => ({
docId: c.docId,
timestamp: new Date(c.timestamp),
}));
},
getPeerPulledRemoteClock: async function (
id: string,
peer: string,
docId: string
) {
const clock = await NbStore.getPeerPulledRemoteClock({
id,
peer,
docId,
});
return clock
? {
docId: clock.docId,
timestamp: new Date(clock.timestamp),
}
: null;
},
setPeerPulledRemoteClock: async function (
id: string,
peer: string,
docId: string,
clock: Date
): Promise<void> {
await NbStore.setPeerPulledRemoteClock({
id,
peer,
docId,
timestamp: clock.getTime(),
});
},
getPeerPushedClocks: async function (
id: string,
peer: string
): Promise<DocClock[]> {
const clocks = (
await NbStore.getPeerPushedClocks({
id,
peer,
})
).clocks;
return clocks.map(c => ({
docId: c.docId,
timestamp: new Date(c.timestamp),
}));
},
getPeerPushedClock: async function (
id: string,
peer: string,
docId: string
): Promise<DocClock | null> {
const clock = await NbStore.getPeerPushedClock({
id,
peer,
docId,
});
return clock
? {
docId: clock.docId,
timestamp: new Date(clock.timestamp),
}
: null;
},
setPeerPushedClock: async function (
id: string,
peer: string,
docId: string,
clock: Date
): Promise<void> {
await NbStore.setPeerPushedClock({
id,
peer,
docId,
timestamp: clock.getTime(),
});
},
clearClocks: async function (id: string): Promise<void> {
await NbStore.clearClocks({
id,
});
},
getBlobUploadedAt: async function (
id: string,
peer: string,
blobId: string
): Promise<Date | null> {
const result = await NbStore.getBlobUploadedAt({
id,
peer,
blobId,
});
return result.uploadedAt ? new Date(result.uploadedAt) : null;
},
setBlobUploadedAt: async function (
id: string,
peer: string,
blobId: string,
uploadedAt: Date | null
): Promise<void> {
await NbStore.setBlobUploadedAt({
id,
peer,
blobId,
uploadedAt: uploadedAt ? uploadedAt.getTime() : null,
});
},
crawlDocData: async function (
id: string,
docId: string
): Promise<CrawlResult> {
return await NbStore.crawlDocData({ id, docId });
},
indexUpsert: async function (
id: string,
table: string,
document
): Promise<void> {
await NbStore.indexUpsert({
id,
table,
document,
});
},
indexDelete: async function (
id: string,
table: string,
docId: string
): Promise<void> {
await NbStore.indexDelete({
id,
table,
docId,
});
},
indexSearch: async function (id: string, table: string, query, options) {
return await NbStore.indexSearch({
id,
table,
query,
options,
});
},
indexAggregate: async function (
id: string,
table: string,
query,
field: string,
limit: number,
offset: number,
hits
) {
return await NbStore.indexAggregate({
id,
table,
query,
field,
limit,
offset,
hits,
});
},
indexDeleteByQuery: async function (
id: string,
table: string,
query
): Promise<number> {
const { deleted } = await NbStore.indexDeleteByQuery({
id,
table,
query,
});
return deleted;
},
indexFlush: async function (id: string): Promise<void> {
await NbStore.indexFlush({
id,
});
},
indexVersion: function (): Promise<number> {
return NbStore.indexVersion().then(res => res.indexVersion);
},
getDocIndexedClock: function (
id: string,
docId: string
): Promise<DocIndexedClock | null> {
return NbStore.getDocIndexedClock({ id, docId }).then(clock =>
clock ? { ...clock, timestamp: new Date(clock.timestamp) } : null
);
},
setDocIndexedClock: function (
id: string,
docId: string,
indexedClock: Date,
indexerVersion: number
): Promise<void> {
return NbStore.setDocIndexedClock({
id,
docId,
indexedClock: indexedClock.getTime(),
indexerVersion,
});
},
setDocIndexedClocks: function (id, clocks): Promise<void> {
return NbStore.setDocIndexedClocks({
id,
clocks: clocks.map(clock => ({
...clock,
timestamp: clock.timestamp.getTime(),
})),
});
},
clearDocIndexedClock: function (id: string, docId: string): Promise<void> {
return NbStore.clearDocIndexedClock({
id,
docId,
});
},
};

View File

@@ -0,0 +1,5 @@
export interface PayWallPlugin {
showPayWall(options: {
type: 'Pro' | 'AI';
}): Promise<{ success: boolean; type: string }>;
}

View File

@@ -0,0 +1,8 @@
import { registerPlugin } from '@capacitor/core';
import type { PayWallPlugin } from './definitions';
const PayWall = registerPlugin<PayWallPlugin>('PayWall');
export * from './definitions';
export { PayWall };

View File

@@ -0,0 +1,16 @@
export interface PreviewPlugin {
renderMermaidSvg(options: {
code: string;
options?: {
theme?: string;
fontFamily?: string;
fontSize?: number;
};
}): Promise<{ svg: string }>;
renderTypstSvg(options: {
code: string;
options?: {
fontUrls?: string[];
};
}): Promise<{ svg: string }>;
}

View File

@@ -0,0 +1,8 @@
import { registerPlugin } from '@capacitor/core';
import type { PreviewPlugin } from './definitions';
const Preview = registerPlugin<PreviewPlugin>('Preview');
export * from './definitions';
export { Preview };

View File

@@ -0,0 +1,24 @@
import type {
ShareImportTarget,
ShareInboxEntry,
ShareWorkspaceMode,
} from '@affine/core/mobile/components/share-import-controller/types';
export interface ShareInboxPlugin {
updateWorkspaceMode(options: { mode: ShareWorkspaceMode }): Promise<void>;
listPending(): Promise<{ items: ShareInboxEntry[] }>;
updateTarget(options: {
itemId: string;
target: ShareImportTarget;
}): Promise<void>;
resolveAttachment(options: { itemId: string }): Promise<{
itemId?: string;
fileUrl?: string;
relativePath?: string;
name?: string;
mimeType?: string;
size?: number;
}>;
complete(options: { itemId: string; docId: string }): Promise<void>;
setError(options: { itemId: string; error: string }): Promise<void>;
}

View File

@@ -0,0 +1,82 @@
/** @vitest-environment happy-dom */
import { describe, expect, test, vi } from 'vitest';
import { resolveShareInboxAttachment } from './index';
const resolved = {
itemId: 'item',
fileUrl: 'file:///safe/image.png',
relativePath: 'item/image.png',
name: 'image.png',
mimeType: 'image/png',
size: 3,
};
describe('resolveShareInboxAttachment', () => {
test.each([
[{ ...resolved, itemId: 'other' }, 'mismatched item'],
[{ ...resolved, fileUrl: undefined }, 'missing file URL'],
[{ ...resolved, relativePath: undefined }, 'missing relative path'],
[{ ...resolved, mimeType: undefined }, 'missing MIME'],
[{ ...resolved, name: undefined }, 'missing name'],
[{ ...resolved, size: undefined }, 'missing size'],
[{ ...resolved, size: 0 }, 'empty size'],
])('does not fetch %s', async (value, _description) => {
const fetchFile = vi.fn();
await expect(
resolveShareInboxAttachment('item', value, {
convertFileSrc: value => value,
fetchFile,
})
).resolves.toBeUndefined();
expect(fetchFile).not.toHaveBeenCalled();
});
test('rejects missing and non-ok native file responses', async () => {
const fetchFile = vi
.fn()
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(new Response(null, { status: 404 }));
const options = { convertFileSrc: (value: string) => value, fetchFile };
await expect(
resolveShareInboxAttachment('item', resolved, options)
).resolves.toBeUndefined();
await expect(
resolveShareInboxAttachment('item', resolved, options)
).resolves.toBeUndefined();
});
test('rejects a size mismatch', async () => {
await expect(
resolveShareInboxAttachment('item', resolved, {
convertFileSrc: value => value,
fetchFile: vi.fn().mockResolvedValue(new Response(new Blob(['four']))),
})
).resolves.toBeUndefined();
});
test('returns a byte-preserving File without data URLs or FileReader', async () => {
const bytes = new Uint8Array([1, 2, 3]);
const fetchFile = vi
.fn()
.mockResolvedValue(
new Response(new Blob([bytes], { type: 'application/octet-stream' }))
);
const file = await resolveShareInboxAttachment('item', resolved, {
convertFileSrc: value => `capacitor://${value}`,
fetchFile,
});
expect(file).toBeInstanceOf(File);
expect(file && new Uint8Array(await file.arrayBuffer())).toEqual(bytes);
expect(file).toMatchObject({
name: 'image.png',
type: 'image/png',
size: 3,
});
expect(fetchFile).toHaveBeenCalledWith(
'capacitor://file:///safe/image.png'
);
});
});

View File

@@ -0,0 +1,76 @@
import type { ShareInboxProvider } from '@affine/core/mobile/components/share-import-controller';
import { Capacitor, registerPlugin } from '@capacitor/core';
import type { ShareInboxPlugin } from './definitions';
const plugin = registerPlugin<ShareInboxPlugin>('ShareInbox');
type AttachmentResolution = Awaited<
ReturnType<ShareInboxPlugin['resolveAttachment']>
>;
export async function resolveShareInboxAttachment(
itemId: string,
resolution: AttachmentResolution,
{
convertFileSrc,
fetchFile,
}: {
convertFileSrc: (path: string) => string;
fetchFile: (url: string) => Promise<Response | undefined>;
}
): Promise<File | undefined> {
const {
fileUrl,
relativePath,
mimeType,
name,
size,
itemId: resolvedItemId,
} = resolution;
if (
!fileUrl ||
!relativePath ||
!mimeType ||
!name ||
typeof size !== 'number' ||
!Number.isSafeInteger(size) ||
size <= 0 ||
resolvedItemId !== itemId
) {
return undefined;
}
const response = await fetchFile(convertFileSrc(fileUrl));
if (!response?.ok) return undefined;
const blob = await response.blob();
if (blob.size !== size) return undefined;
return new File([blob], name, { type: mimeType });
}
export const shareInboxProvider: ShareInboxProvider = {
async updateWorkspaceMode(mode) {
await plugin.updateWorkspaceMode({ mode });
},
async listPending() {
return (await plugin.listPending()).items;
},
async updateTarget(itemId, target) {
await plugin.updateTarget({ itemId, target });
},
async resolveAttachment(itemId) {
return resolveShareInboxAttachment(
itemId,
await plugin.resolveAttachment({ itemId }),
{
convertFileSrc: Capacitor.convertFileSrc,
fetchFile: url => fetch(url),
}
);
},
async complete(itemId, docId) {
await plugin.complete({ itemId, docId });
},
async setError(itemId, error) {
await plugin.setError({ itemId, error });
},
};

View File

@@ -0,0 +1,34 @@
import { canonicalAuthEndpoint } from '@affine/mobile-shared/auth/endpoint';
import {
type AuthRequestProvider,
installAuthRequestProxy,
} from '@affine/mobile-shared/auth/request';
import { Auth } from './plugins/auth';
export const authRequestProvider: AuthRequestProvider = {
async getValidAccessToken(endpoint) {
const { token } = await Auth.getValidAccessToken({
endpoint: canonicalAuthEndpoint(endpoint),
});
return token ?? null;
},
async refreshAccessToken(endpoint) {
const { token } = await Auth.refreshAccessToken({
endpoint: canonicalAuthEndpoint(endpoint),
});
return token;
},
};
installAuthRequestProxy(authRequestProvider);
export function getValidAccessToken(endpoint: string) {
return authRequestProvider.getValidAccessToken(endpoint);
}
export async function clearEndpointSession(endpoint: string) {
await Auth.clearEndpointSession({
endpoint: canonicalAuthEndpoint(endpoint),
});
}

View File

@@ -0,0 +1 @@
import '@affine/core/bootstrap/browser';

View File

@@ -0,0 +1,88 @@
import '@affine/core/bootstrap/browser';
import '@affine/core/bootstrap/cleanup';
import './proxy';
import { viewportRuntimeConfig } from '@blocksuite/affine/std/gfx';
// iOS WKWebView terminates the web content process when edgeless compositing
// memory (GPU-side IOSurface tiles) spikes. Two distinct triggers exist:
// 1. Resting canvas pixel memory — bounded by CANVAS_DPR_CAP_BY_ZOOM below.
// 2. The transient GPU/DOM churn of a *fast* gesture at extreme zoom-out,
// where the whole document composites at once.
//
// These overrides are applied once at module load, before any editor or
// readonly preview mounts, so every Viewport instance is constructed with the
// mobile-safe limits. Setting them at construction (rather than mutating a live
// Viewport afterward) avoids both the race condition and the wrong-instance
// problem that previously left the preview viewport on desktop defaults.
//
// Strategy (multi-layer, stability first):
// - The dpr cap (below) is the real memory lever: canvas backing-store memory
// scales with dpr^2, so forcing dpr 1 across the zoom-out range is what
// keeps the compositing budget bounded and stops the web process crashing.
// - ZOOM_MIN 0.4 bounds how small content can get (and keeps the live zoom in
// the dpr-1 bucket); it is a guardrail, not the primary crash fix.
// - OVERSCAN_RATIO pre-rasterizes a margin around the visible area on the
// *canvas* path, so a pan/zoom moves into content that is *already* painted
// instead of blanking out and waiting for the post-gesture refresh. This is
// what fixes "connectors/elements vanish for 1-2s". Canvas overscan grows
// backing-store area, so keep it modest and rely on the dpr cap to bound
// mobile memory.
// - OVERSCAN_RATIO_BLOCK is the *separate* knob for DOM block mounting, which
// is expensive: each mounted block is its own composited layer subtree, so
// enlarging this multiplies resident memory and is what drives the iOS
// jetsam kill. On-device diagnostics showed the active-block count doubling
// (~16 → ~32) right before each crash when block mounting shared the wide
// canvas margin. Kept at 0 so blocks mount on the exact visible bound while
// connectors still pre-paint via the wider canvas margin above.
viewportRuntimeConfig.ZOOM_MIN = 0.4;
viewportRuntimeConfig.VIEWPORT_REFRESH_PIXEL_THRESHOLD = 60;
viewportRuntimeConfig.VIEWPORT_REFRESH_MAX_INTERVAL = 300;
viewportRuntimeConfig.SKIP_REFRESH_DURING_GESTURE = true;
viewportRuntimeConfig.LOW_ZOOM_GESTURE_ACTIVE_BLOCK_LIMIT = 1;
// Pre-paint a 20% margin on every side of the viewport for the *canvas* render
// path. This keeps nearby connectors/shapes warm during orientation and zoom
// gestures, but trims the backing-store and paint budget versus the previous
// 35% setting so low-zoom survival mode has less work to recover from on iOS.
viewportRuntimeConfig.OVERSCAN_RATIO = 0.2;
// Keep DOM block mounting on the exact visible bound (no overscan). Each mounted
// block adds a composited layer subtree to the WebContent process; widening this
// is what doubled the active-block count (~16 → ~32) and triggered the iOS
// jetsam memory kill in on-device logs. Connectors/elements still pre-paint via
// the generous canvas OVERSCAN_RATIO above, so visibility is preserved without
// paying the block-mounting memory cost.
viewportRuntimeConfig.OVERSCAN_RATIO_BLOCK = 0;
// After a gesture ends, blocks and canvases are repainted once. The default
// 800ms felt sluggish on device (elements/connectors took ~3-4s to settle once
// the trailing 200ms panning/zooming debounce and rescheduling were factored
// in). Shorten the post-gesture refresh so the total settle time — ~200ms
// debounce + this delay — lands under ~500ms. Both the canvas and block timers
// read this same value, so connectors and elements reappear together instead of
// staggered.
viewportRuntimeConfig.POST_GESTURE_REFRESH_DELAY = 220;
// At far-out zoom each block is tiny on screen, so a full retina backing store
// (width * devicePixelRatio) is wasted pixels — and on iOS that waste is what
// pushes WKWebView's compositing budget over the edge and crashes the web
// content process during pan. Cap the canvas backing-store dpr the further out
// we zoom: the smaller the content, the less resolution it needs.
//
// Canvas memory scales with backing-store area and dpr^2. With a fixed viewport
// and overscan ratio, the dpr bucket dominates: on-device diagnostics showed
// zoom 0.4 with dpr 2 hitting ~7.2 mp and crashing on the first fast zoom-out;
// the same scene at dpr 1 sits near ~1.8 mp and is stable. Raising ZOOM_MIN
// alone cannot fix it — it only moves the zoom between buckets.
//
// Therefore force dpr 1 for the entire mobile zoom-out range (zoom < 0.5, which
// covers the 0.4 floor) to keep the compositing budget bounded. Connectors are
// slightly thinner at the floor as a result; stability is the hard requirement
// here, so that trade is accepted. dpr 2 is kept for the near-1.0 range where
// content is large and crispness matters. Buckets are checked low-to-high; the
// first matching `zoom < threshold` wins.
viewportRuntimeConfig.CANVAS_DPR_CAP_BY_ZOOM = [
[0.5, 1],
[0.8, 2],
];