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
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:
928
packages/frontend/apps/ios/src/app.tsx
Normal file
928
packages/frontend/apps/ios/src/app.tsx
Normal 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));
|
||||
}
|
||||
21
packages/frontend/apps/ios/src/bs-menu-config.tsx
Normal file
21
packages/frontend/apps/ios/src/bs-menu-config.tsx
Normal 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;
|
||||
};
|
||||
30
packages/frontend/apps/ios/src/index.tsx
Normal file
30
packages/frontend/apps/ios/src/index.tsx
Normal 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);
|
||||
}
|
||||
76
packages/frontend/apps/ios/src/nbstore.worker.ts
Normal file
76
packages/frontend/apps/ios/src/nbstore.worker.ts
Normal 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);
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { NativeThemeMode } from './theme-mode';
|
||||
|
||||
export interface AffineThemePlugin {
|
||||
onThemeChanged(options: { themeMode: NativeThemeMode }): Promise<void>;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -0,0 +1 @@
|
||||
export type NativeThemeMode = 'dark' | 'light' | 'system';
|
||||
29
packages/frontend/apps/ios/src/plugins/auth/definitions.ts
Normal file
29
packages/frontend/apps/ios/src/plugins/auth/definitions.ts
Normal 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>;
|
||||
}
|
||||
8
packages/frontend/apps/ios/src/plugins/auth/index.ts
Normal file
8
packages/frontend/apps/ios/src/plugins/auth/index.ts
Normal 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 };
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface HashcashPlugin {
|
||||
hash(options: {
|
||||
challenge: string;
|
||||
bits?: number;
|
||||
}): Promise<{ value: string }>;
|
||||
}
|
||||
8
packages/frontend/apps/ios/src/plugins/hashcash/index.ts
Normal file
8
packages/frontend/apps/ios/src/plugins/hashcash/index.ts
Normal 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 };
|
||||
@@ -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;
|
||||
}>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { registerPlugin } from '@capacitor/core';
|
||||
|
||||
import type { ImagePickerPlugin } from './definitions';
|
||||
|
||||
const ImagePicker = registerPlugin<ImagePickerPlugin>('ImagePicker');
|
||||
|
||||
export * from './definitions';
|
||||
export { ImagePicker };
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { registerPlugin } from '@capacitor/core';
|
||||
|
||||
import type { NavigationGesturePlugin } from './definitions';
|
||||
|
||||
const NavigationGesture =
|
||||
registerPlugin<NavigationGesturePlugin>('NavigationGesture');
|
||||
|
||||
export * from './definitions';
|
||||
export { NavigationGesture };
|
||||
228
packages/frontend/apps/ios/src/plugins/nbstore/definitions.ts
Normal file
228
packages/frontend/apps/ios/src/plugins/nbstore/definitions.ts
Normal 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>;
|
||||
}
|
||||
465
packages/frontend/apps/ios/src/plugins/nbstore/index.ts
Normal file
465
packages/frontend/apps/ios/src/plugins/nbstore/index.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface PayWallPlugin {
|
||||
showPayWall(options: {
|
||||
type: 'Pro' | 'AI';
|
||||
}): Promise<{ success: boolean; type: string }>;
|
||||
}
|
||||
8
packages/frontend/apps/ios/src/plugins/paywall/index.ts
Normal file
8
packages/frontend/apps/ios/src/plugins/paywall/index.ts
Normal 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 };
|
||||
@@ -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 }>;
|
||||
}
|
||||
8
packages/frontend/apps/ios/src/plugins/preview/index.ts
Normal file
8
packages/frontend/apps/ios/src/plugins/preview/index.ts
Normal 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 };
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
76
packages/frontend/apps/ios/src/plugins/share-inbox/index.ts
Normal file
76
packages/frontend/apps/ios/src/plugins/share-inbox/index.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
34
packages/frontend/apps/ios/src/proxy.ts
Normal file
34
packages/frontend/apps/ios/src/proxy.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
1
packages/frontend/apps/ios/src/setup-worker.ts
Normal file
1
packages/frontend/apps/ios/src/setup-worker.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@affine/core/bootstrap/browser';
|
||||
88
packages/frontend/apps/ios/src/setup.ts
Normal file
88
packages/frontend/apps/ios/src/setup.ts
Normal 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],
|
||||
];
|
||||
Reference in New Issue
Block a user