refactor: new project struct (#8199)

packages/frontend/web -> packages/frontend/apps/web
packages/frontend/mobile -> packages/frontend/apps/mobile
packages/frontend/electron -> packages/frontend/apps/electron
This commit is contained in:
EYHN
2024-09-12 07:42:57 +00:00
parent 7c4eab6cd3
commit cc5a6e6d40
291 changed files with 139 additions and 134 deletions
@@ -0,0 +1,17 @@
*.autogen.*
dist
e2e-dist-*
resources/web-static
# yarn (3)
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
dev.json
zip-out
+32
View File
@@ -0,0 +1,32 @@
# AFFiNE Electron App
## Development
To run AFFiNE Desktop Client Application locally, run the following commands:
```sh
# in repo root
yarn install
yarn workspace @affine/native build
yarn dev
# in packages/frontend/apps/electron
yarn generate-assets
yarn dev # or yarn prod for production build
```
## Troubleshooting
If you have trouble building electron during `yarn install`, try setting mirror environment variable:
```sh
export ELECTRON_MIRROR="https://registry.npmmirror.com/-/binary/electron/"
```
## Credits
Most of the boilerplate code is generously borrowed from the following
- [vite-electron-builder](https://github.com/cawa-93/vite-electron-builder)
- [Turborepo basic example](https://github.com/vercel/turborepo/tree/main/examples/basic)
- [yerba](https://github.com/t3dotgg/yerba)
@@ -0,0 +1,4 @@
owner: toeverything
repo: AFFiNE
provider: custom
private: false
@@ -0,0 +1,171 @@
import cp from 'node:child_process';
import { rm, symlink } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { utils } from '@electron-forge/core';
import {
appIdMap,
arch,
buildType,
icnsPath,
iconUrl,
iconX64PngPath,
icoPath,
platform,
productName,
} from './scripts/make-env.js';
const fromBuildIdentifier = utils.fromBuildIdentifier;
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const makers = [
!process.env.SKIP_BUNDLE &&
platform === 'darwin' && {
name: '@electron-forge/maker-dmg',
config: {
format: 'ULFO',
icon: icnsPath,
name: 'AFFiNE',
'icon-size': 128,
background: path.join(
__dirname,
'./resources/icons/dmg-background.png'
),
contents: [
{
x: 176,
y: 192,
type: 'file',
path: path.join(
__dirname,
'out',
buildType,
`${productName}-darwin-${arch}`,
`${productName}.app`
),
},
{ x: 432, y: 192, type: 'link', path: '/Applications' },
],
iconSize: 118,
file: path.join(
__dirname,
'out',
buildType,
`${productName}-darwin-${arch}`,
`${productName}.app`
),
},
},
{
name: '@electron-forge/maker-zip',
config: {
name: 'affine',
iconUrl: icoPath,
setupIcon: icoPath,
platforms: ['darwin', 'linux', 'win32'],
},
},
!process.env.SKIP_BUNDLE && {
name: '@electron-forge/maker-squirrel',
config: {
name: productName,
setupIcon: icoPath,
iconUrl: iconUrl,
loadingGif: './resources/icons/affine_installing.gif',
},
},
!process.env.SKIP_BUNDLE && {
name: '@pengx17/electron-forge-maker-appimage',
platforms: ['linux'],
config: {
icons: [
{
file: iconX64PngPath,
size: 64,
},
],
},
},
].filter(Boolean);
/**
* @type {import('@electron-forge/shared-types').ForgeConfig}
*/
export default {
buildIdentifier: buildType,
packagerConfig: {
name: productName,
appBundleId: fromBuildIdentifier(appIdMap),
icon: icnsPath,
osxSign: {
identity: 'Developer ID Application: TOEVERYTHING PTE. LTD.',
'hardened-runtime': true,
},
osxNotarize: process.env.APPLE_ID
? {
tool: 'notarytool',
appleId: process.env.APPLE_ID,
appleIdPassword: process.env.APPLE_PASSWORD,
teamId: process.env.APPLE_TEAM_ID,
}
: undefined,
// We need the following line for updater
extraResource: ['./resources/app-update.yml'],
protocols: [
{
name: productName,
schemes: [productName.toLowerCase()],
},
],
asar: true,
},
makers,
plugins: [{ name: '@electron-forge/plugin-auto-unpack-natives', config: {} }],
hooks: {
readPackageJson: async (_, packageJson) => {
// we want different package name for canary build
// so stable and canary will not share the same app data
packageJson.productName = productName;
},
prePackage: async () => {
if (!process.env.HOIST_NODE_MODULES) {
await rm(path.join(__dirname, 'node_modules'), {
recursive: true,
force: true,
});
await symlink(
path.join(__dirname, '..', '..', '..', 'node_modules'),
path.join(__dirname, 'node_modules')
);
}
},
generateAssets: async (_, platform, arch) => {
if (process.env.SKIP_GENERATE_ASSETS) {
return;
}
// TODO(@Peng): right now we do not need the following
// it is for octobase-node, but we dont use it for now.
if (platform === 'darwin' && arch === 'arm64') {
// In GitHub Actions runner, MacOS is always x64
// we need to manually set TARGET to aarch64-apple-darwin
process.env.TARGET = 'aarch64-apple-darwin';
}
cp.spawnSync('yarn', ['generate-assets'], {
stdio: 'inherit',
env: {
...process.env,
NODE_OPTIONS: (process.env.NODE_OPTIONS ?? '').replace(
'--loader ts-node/esm',
''
),
},
cwd: __dirname,
});
},
},
};
@@ -0,0 +1,98 @@
{
"name": "@affine/electron",
"private": true,
"version": "0.16.0",
"author": "toeverything",
"repository": {
"url": "https://github.com/toeverything/AFFiNE",
"type": "git"
},
"description": "AFFiNE Desktop App",
"homepage": "https://github.com/toeverything/AFFiNE",
"scripts": {
"start": "electron .",
"dev": "DEV_SERVER_URL=http://localhost:8080 node --loader ts-node/esm/transpile-only ./scripts/dev.ts",
"dev:prod": "yarn node --loader ts-node/esm/transpile-only scripts/dev.ts",
"build": "NODE_ENV=production node --loader ts-node/esm/transpile-only scripts/build-layers.ts",
"build:dev": "NODE_ENV=development node --loader ts-node/esm/transpile-only scripts/build-layers.ts",
"generate-assets": "node --loader ts-node/esm/transpile-only scripts/generate-assets.ts",
"package": "cross-env NODE_OPTIONS=\"--loader ts-node/esm/transpile-only\" electron-forge package",
"make": "cross-env NODE_OPTIONS=\"--loader ts-node/esm/transpile-only\" electron-forge make",
"make-squirrel": "node --loader ts-node/esm/transpile-only scripts/make-squirrel.ts",
"make-nsis": "node --loader ts-node/esm/transpile-only scripts/make-nsis.ts"
},
"main": "./dist/main.js",
"devDependencies": {
"@affine-test/kit": "workspace:*",
"@affine/component": "workspace:*",
"@affine/core": "workspace:*",
"@affine/env": "workspace:*",
"@affine/i18n": "workspace:*",
"@affine/native": "workspace:*",
"@blocksuite/block-std": "0.17.8",
"@blocksuite/blocks": "0.17.8",
"@blocksuite/presets": "0.17.8",
"@blocksuite/store": "0.17.8",
"@electron-forge/cli": "^7.3.0",
"@electron-forge/core": "^7.3.0",
"@electron-forge/core-utils": "^7.3.0",
"@electron-forge/maker-deb": "^7.3.0",
"@electron-forge/maker-dmg": "^7.3.0",
"@electron-forge/maker-squirrel": "^7.3.0",
"@electron-forge/maker-zip": "^7.3.0",
"@electron-forge/plugin-auto-unpack-natives": "^7.3.0",
"@electron-forge/shared-types": "^7.3.0",
"@emotion/react": "^11.11.4",
"@pengx17/electron-forge-maker-appimage": "^1.2.0",
"@sentry/electron": "^5.3.0",
"@sentry/esbuild-plugin": "^2.16.1",
"@sentry/react": "^8.0.0",
"@toeverything/infra": "workspace:*",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react-swc": "^3.6.0",
"builder-util-runtime": "^9.2.5-alpha.2",
"core-js": "^3.36.1",
"cross-env": "^7.0.3",
"electron": "^31.0.0",
"electron-log": "^5.1.2",
"electron-squirrel-startup": "1.0.1",
"electron-window-state": "^5.0.3",
"esbuild": "^0.23.0",
"fs-extra": "^11.2.0",
"glob": "^11.0.0",
"jotai": "^2.8.0",
"lodash-es": "^4.17.21",
"nanoid": "^5.0.7",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.3",
"rxjs": "^7.8.1",
"semver": "^7.6.0",
"tree-kill": "^1.2.2",
"ts-node": "^10.9.2",
"undici": "^6.12.0",
"uuid": "^10.0.0",
"vitest": "2.0.5",
"which": "^4.0.0",
"zod": "^3.22.4"
},
"dependencies": {
"async-call-rpc": "^6.4.2",
"electron-updater": "^6.2.1",
"link-preview-js": "^3.0.5",
"yjs": "patch:yjs@npm%3A13.6.18#~/.yarn/patches/yjs-npm-13.6.18-ad0d5f7c43.patch"
},
"build": {
"protocols": [
{
"name": "affine",
"schemes": [
"affine"
]
}
]
},
"peerDependencies": {
"ts-node": "*"
}
}
@@ -0,0 +1,17 @@
{
"name": "@affine/electron",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"root": "packages/frontend/apps/electron",
"sourceRoot": "packages/frontend/apps/electron/src",
"targets": {
"build": {
"executor": "nx:run-script",
"dependsOn": ["^build"],
"options": {
"script": "build"
},
"outputs": ["{projectRoot}/dist"]
}
}
}
@@ -0,0 +1,120 @@
import '@affine/component/theme/global.css';
import '@affine/component/theme/theme.css';
import { AffineContext } from '@affine/component/context';
import { GlobalLoading } from '@affine/component/global-loading';
import { AppFallback } from '@affine/core/components/affine/app-container';
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
import { configureCommonModules } from '@affine/core/modules';
import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header';
import { configureElectronStateStorageImpls } from '@affine/core/modules/storage';
import { CustomThemeModifier } from '@affine/core/modules/theme-editor';
import { configureSqliteUserspaceStorageProvider } from '@affine/core/modules/userspace';
import { configureDesktopWorkbenchModule } from '@affine/core/modules/workbench';
import {
configureBrowserWorkspaceFlavours,
configureSqliteWorkspaceEngineStorageProvider,
} from '@affine/core/modules/workspace-engine';
import { router } from '@affine/core/router';
import {
performanceLogger,
performanceRenderLogger,
} from '@affine/core/shared';
import { Telemetry } from '@affine/core/telemetry';
import createEmotionCache from '@affine/core/utils/create-emotion-cache';
import { createI18n, setUpLanguage } from '@affine/i18n';
import { CacheProvider } from '@emotion/react';
import {
Framework,
FrameworkRoot,
getCurrentStore,
LifecycleService,
} from '@toeverything/infra';
import { Suspense } from 'react';
import { RouterProvider } from 'react-router-dom';
const desktopWhiteList = [
'/open-app/signin-redirect',
'/open-app/url',
'/upgrade-success',
'/ai-upgrade-success',
'/share',
'/oauth',
'/magic-link',
];
if (
!environment.isElectron &&
environment.isDebug &&
desktopWhiteList.every(path => !location.pathname.startsWith(path))
) {
document.body.innerHTML = `<h1 style="color:red;font-size:5rem;text-align:center;">Don't run electron entry in browser.</h1>`;
throw new Error('Wrong distribution');
}
const performanceI18nLogger = performanceLogger.namespace('i18n');
const cache = createEmotionCache();
const future = {
v7_startTransition: true,
} as const;
async function loadLanguage() {
performanceI18nLogger.info('start');
const i18n = createI18n();
document.documentElement.lang = i18n.language;
performanceI18nLogger.info('set up');
await setUpLanguage(i18n);
performanceI18nLogger.info('done');
}
let languageLoadingPromise: Promise<void> | null = null;
const framework = new Framework();
configureCommonModules(framework);
configureElectronStateStorageImpls(framework);
configureBrowserWorkspaceFlavours(framework);
configureSqliteWorkspaceEngineStorageProvider(framework);
configureSqliteUserspaceStorageProvider(framework);
configureDesktopWorkbenchModule(framework);
configureAppTabsHeaderModule(framework);
const frameworkProvider = framework.provider();
// setup application lifecycle events, and emit application start event
window.addEventListener('focus', () => {
frameworkProvider.get(LifecycleService).applicationFocus();
});
frameworkProvider.get(LifecycleService).applicationStart();
export function App() {
performanceRenderLogger.debug('App');
if (!languageLoadingPromise) {
languageLoadingPromise = loadLanguage().catch(console.error);
}
return (
<Suspense>
<FrameworkRoot framework={frameworkProvider}>
<CacheProvider value={cache}>
<AffineContext store={getCurrentStore()}>
<Telemetry />
<CustomThemeModifier />
<GlobalLoading />
<RouterProvider
fallbackElement={<AppFallback />}
router={router}
future={future}
/>
{environment.isWindows && (
<div style={{ position: 'fixed', right: 0, top: 0, zIndex: 5 }}>
<WindowsAppControls />
</div>
)}
</AffineContext>
</CacheProvider>
</FrameworkRoot>
</Suspense>
);
}
@@ -0,0 +1,29 @@
button,
input,
select,
textarea
/* [role='button'] */ {
-webkit-app-region: no-drag;
}
#webpack-dev-server-client-overlay {
-webkit-app-region: no-drag;
}
html[data-active='true'] {
opacity: 1;
}
html:is([data-active='false'], [data-dragging='true']) * {
-webkit-app-region: no-drag !important;
}
html[data-active='false'] {
opacity: 0;
transition: opacity 0.2s 0.1s;
}
html[data-active='true']:has([data-blur-background='true']) {
opacity: 1;
transition: opacity 0.2s;
}
@@ -0,0 +1,132 @@
import './polyfill/dispose';
import '@affine/core/bootstrap/preload';
import './global.css';
import { appConfigProxy } from '@affine/core/hooks/use-app-config-storage';
import { performanceLogger } from '@affine/core/shared';
import { apis, appInfo, events } from '@affine/electron-api';
import {
init,
reactRouterV6BrowserTracingIntegration,
setTags,
} from '@sentry/react';
import { debounce } from 'lodash-es';
import { StrictMode, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import {
createRoutesFromChildren,
matchRoutes,
useLocation,
useNavigationType,
} from 'react-router-dom';
import { App } from './app';
const performanceMainLogger = performanceLogger.namespace('main');
function main() {
performanceMainLogger.info('start');
// load persistent config for electron
// TODO(@Peng): should be sync, but it's not necessary for now
appConfigProxy
.getSync()
.catch(() => console.error('failed to load app config'));
// skip bootstrap setup for desktop onboarding
if (
appInfo?.windowName === 'onboarding' ||
appInfo?.windowName === 'theme-editor'
) {
performanceMainLogger.info('skip setup');
} else {
performanceMainLogger.info('setup start');
if (window.SENTRY_RELEASE || environment.isDebug) {
// https://docs.sentry.io/platforms/javascript/guides/electron/
init({
dsn: process.env.SENTRY_DSN,
environment: process.env.BUILD_TYPE ?? 'development',
integrations: [
reactRouterV6BrowserTracingIntegration({
useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
],
});
setTags({
appVersion: runtimeConfig.appVersion,
editorVersion: runtimeConfig.editorVersion,
});
apis?.ui.handleNetworkChange(navigator.onLine);
window.addEventListener('offline', () => {
apis?.ui.handleNetworkChange(false);
});
window.addEventListener('online', () => {
apis?.ui.handleNetworkChange(true);
});
}
const handleMaximized = (maximized: boolean | undefined) => {
document.documentElement.dataset.maximized = String(maximized);
};
const handleFullscreen = (fullscreen: boolean | undefined) => {
document.documentElement.dataset.fullscreen = String(fullscreen);
};
apis?.ui.isMaximized().then(handleMaximized).catch(console.error);
apis?.ui.isFullScreen().then(handleFullscreen).catch(console.error);
events?.ui.onMaximized(handleMaximized);
events?.ui.onFullScreen(handleFullscreen);
const tabId = appInfo?.viewId;
const handleActiveTabChange = (active: boolean) => {
document.documentElement.dataset.active = String(active);
};
if (tabId) {
apis?.ui
.isActiveTab()
.then(active => {
handleActiveTabChange(active);
events?.ui.onActiveTabChanged(id => {
handleActiveTabChange(id === tabId);
});
})
.catch(console.error);
}
const handleResize = debounce(() => {
apis?.ui.handleWindowResize().catch(console.error);
}, 50);
window.addEventListener('resize', handleResize);
performanceMainLogger.info('setup done');
window.addEventListener('dragstart', () => {
document.documentElement.dataset.dragging = 'true';
});
window.addEventListener('dragend', () => {
document.documentElement.dataset.dragging = 'false';
});
}
mountApp();
}
function mountApp() {
performanceMainLogger.info('import app');
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const root = document.getElementById('app')!;
performanceMainLogger.info('render app');
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>
);
}
try {
main();
} catch (err) {
console.error('Failed to bootstrap app', err);
}
@@ -0,0 +1,2 @@
import 'core-js/modules/esnext.symbol.async-dispose';
import 'core-js/modules/esnext.symbol.dispose';
@@ -0,0 +1,75 @@
import '@affine/component/theme/global.css';
import '@affine/component/theme/theme.css';
import '@affine/core/bootstrap/preload';
import '../global.css';
import { ThemeProvider } from '@affine/component/theme-provider';
import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header';
import { configureElectronStateStorageImpls } from '@affine/core/modules/storage';
import { performanceLogger } from '@affine/core/shared';
import { apis, events } from '@affine/electron-api';
import { createI18n, setUpLanguage } from '@affine/i18n';
import {
configureGlobalStorageModule,
Framework,
FrameworkRoot,
} from '@toeverything/infra';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { ShellRoot } from './shell';
const framework = new Framework();
configureGlobalStorageModule(framework);
configureElectronStateStorageImpls(framework);
configureAppTabsHeaderModule(framework);
const frameworkProvider = framework.provider();
const logger = performanceLogger.namespace('shell');
async function loadLanguage() {
const i18n = createI18n();
document.documentElement.lang = i18n.language;
await setUpLanguage(i18n);
}
async function main() {
const handleMaximized = (maximized: boolean | undefined) => {
document.documentElement.dataset.maximized = String(maximized);
};
const handleFullscreen = (fullscreen: boolean | undefined) => {
document.documentElement.dataset.fullscreen = String(fullscreen);
};
const handleActive = (active: boolean | undefined) => {
document.documentElement.dataset.active = String(active);
};
apis?.ui.isMaximized().then(handleMaximized).catch(console.error);
apis?.ui.isFullScreen().then(handleFullscreen).catch(console.error);
events?.ui.onMaximized(handleMaximized);
events?.ui.onFullScreen(handleFullscreen);
events?.ui.onTabShellViewActiveChange(handleActive);
await loadLanguage();
mountApp();
}
function mountApp() {
const root = document.getElementById('app');
if (!root) {
throw new Error('Root element not found');
}
logger.info('render app');
createRoot(root).render(
<StrictMode>
<FrameworkRoot framework={frameworkProvider}>
<ThemeProvider>
<ShellRoot />
</ThemeProvider>
</FrameworkRoot>
</StrictMode>
);
}
main().catch(console.error);
@@ -0,0 +1,39 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { createVar, style } from '@vanilla-extract/css';
export const sidebarOffsetVar = createVar();
export const root = style({
width: '100vw',
height: '100vh',
display: 'flex',
flexDirection: 'column',
background: cssVarV2('layer/background/primary'),
selectors: {
'&[data-translucent="true"]': {
background: 'transparent',
},
},
});
export const appTabsHeader = style({
zIndex: 1,
});
export const fallbackRoot = style({
position: 'absolute',
paddingTop: 52,
width: '100%',
height: '100%',
zIndex: 0,
});
export const splitViewFallback = style({
width: '100%',
height: '100%',
position: 'absolute',
bottom: 0,
right: 0,
zIndex: 0,
background: cssVarV2('layer/background/primary'),
});
@@ -0,0 +1,22 @@
import { AppFallback } from '@affine/core/components/affine/app-container';
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
import { AppTabsHeader } from '@affine/core/modules/app-tabs-header';
import { SplitViewFallback } from '@affine/core/modules/workbench/view/split-view/split-view';
import * as styles from './shell.css';
export function ShellRoot() {
const { appSettings } = useAppSettingHelper();
const translucent =
environment.isElectron &&
environment.isMacOs &&
appSettings.enableBlurBackground;
return (
<div className={styles.root} data-translucent={translucent}>
<AppTabsHeader mode="shell" className={styles.appTabsHeader} />
<AppFallback className={styles.fallbackRoot}>
<SplitViewFallback className={styles.splitViewFallback} />
</AppFallback>
</div>
);
}
@@ -0,0 +1,30 @@
{
"extends": "../../../../../tsconfig.json",
"compilerOptions": {
"composite": true,
"target": "ESNext",
"module": "ESNext",
"resolveJsonModule": true,
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"noEmit": false,
"outDir": "../lib",
"allowJs": true
},
"references": [
{
"path": "../../../../common/env"
},
{
"path": "../../../core"
},
{
"path": "../../../component"
},
{
"path": "../../../../common/infra"
}
],
"include": ["."],
"exclude": ["dist"]
}
@@ -0,0 +1,4 @@
owner: toeverything
repo: AFFiNE
provider: custom
private: false
Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 805 KiB

@@ -0,0 +1,39 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import * as esbuild from 'esbuild';
import { config, mode, rootDir } from './common';
async function buildLayers() {
const common = config();
const define: Record<string, string> = {
...common.define,
'process.env.NODE_ENV': `"${mode}"`,
'process.env.BUILD_TYPE': `"${process.env.BUILD_TYPE || 'stable'}"`,
};
if (process.env.BUILD_TYPE_OVERRIDE) {
define['process.env.BUILD_TYPE_OVERRIDE'] =
`"${process.env.BUILD_TYPE_OVERRIDE}"`;
}
const metafile = process.env.METAFILE;
const result = await esbuild.build({
...common,
define: define,
metafile: !!metafile,
});
if (metafile) {
await fs.writeFile(
path.resolve(rootDir, `metafile-${Date.now()}.json`),
JSON.stringify(result.metafile, null, 2)
);
}
}
await buildLayers();
console.log('Build layers done');
@@ -0,0 +1,97 @@
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { getRuntimeConfig } from '@affine/cli/src/webpack/runtime-config';
import { sentryEsbuildPlugin } from '@sentry/esbuild-plugin';
import type { BuildOptions, Plugin } from 'esbuild';
export const electronDir = fileURLToPath(new URL('..', import.meta.url));
export const rootDir = resolve(electronDir, '..', '..', '..');
export const NODE_MAJOR_VERSION = 18;
export const mode = (process.env.NODE_ENV =
process.env.NODE_ENV || 'development');
export const config = (): BuildOptions => {
const define: Record<string, string> = {};
define['REPLACE_ME_BUILD_ENV'] = `"${process.env.BUILD_TYPE ?? 'stable'}"`;
define['runtimeConfig'] = JSON.stringify(
getRuntimeConfig({
channel: (process.env.BUILD_TYPE as any) ?? 'canary',
distribution: 'desktop',
mode:
process.env.NODE_ENV === 'production' ? 'production' : 'development',
static: false,
})
);
if (process.env.GITHUB_SHA) {
define['process.env.GITHUB_SHA'] = `"${process.env.GITHUB_SHA}"`;
}
const plugins: Plugin[] = [];
if (
process.env.SENTRY_AUTH_TOKEN &&
process.env.SENTRY_ORG &&
process.env.SENTRY_PROJECT
) {
plugins.push(
sentryEsbuildPlugin({
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
})
);
}
plugins.push({
name: 'no-side-effects',
setup(build) {
build.onResolve({ filter: /\.js/ }, async args => {
if (args.pluginData) return; // Ignore this if we called ourselves
const { path, ...rest } = args;
// mark all blocksuite packages as side-effect free
// because they will include a lot of files that are not used in node_modules
if (rest.resolveDir.includes('blocksuite')) {
rest.pluginData = true; // Avoid infinite recursion
const result = await build.resolve(path, rest);
result.sideEffects = false;
return result;
}
return null;
});
},
});
return {
entryPoints: [
resolve(electronDir, './src/main/index.ts'),
resolve(electronDir, './src/preload/index.ts'),
resolve(electronDir, './src/helper/index.ts'),
],
entryNames: '[dir]',
outdir: resolve(electronDir, './dist'),
bundle: true,
target: `node${NODE_MAJOR_VERSION}`,
platform: 'node',
external: ['electron', 'electron-updater', 'yjs', 'semver'],
format: 'cjs',
loader: {
'.node': 'copy',
},
define,
assetNames: '[name]',
treeShaking: true,
sourcemap: 'linked',
plugins,
};
};
@@ -0,0 +1,105 @@
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
import { spawn } from 'node:child_process';
import { resolve } from 'node:path';
import type { BuildContext } from 'esbuild';
import * as esbuild from 'esbuild';
import kill from 'tree-kill';
import { config, electronDir, rootDir } from './common';
// this means we don't spawn electron windows, mainly for testing
const watchMode = process.argv.includes('--watch');
/** Messages on stderr that match any of the contained patterns will be stripped from output */
const stderrFilterPatterns = [
// warning about devtools extension
// https://github.com/cawa-93/vite-electron-builder/issues/492
// https://github.com/MarshallOfSound/electron-devtools-installer/issues/143
/ExtensionLoadWarning/,
];
let spawnProcess: ChildProcessWithoutNullStreams | null = null;
function spawnOrReloadElectron() {
if (watchMode) {
return;
}
if (spawnProcess !== null && spawnProcess.pid) {
spawnProcess.off('exit', process.exit);
kill(spawnProcess.pid);
spawnProcess = null;
}
const ext = process.platform === 'win32' ? '.cmd' : '';
const exe = resolve(rootDir, 'node_modules', '.bin', `electron${ext}`);
spawnProcess = spawn(exe, ['.'], {
cwd: electronDir,
env: process.env,
shell: true,
});
spawnProcess.stdout.on('data', d => {
const str = d.toString().trim();
if (str) {
console.log(str);
}
});
spawnProcess.stderr.on('data', d => {
const data = d.toString().trim();
if (!data) return;
const mayIgnore = stderrFilterPatterns.some(r => r.test(data));
if (mayIgnore) return;
console.error(data);
});
// Stops the watch script when the application has quit
spawnProcess.on('exit', code => {
if (code && code !== 0) {
console.log(`Electron exited with code ${code}`);
}
});
}
const common = config();
async function watchLayers() {
let initialBuild = false;
return new Promise<BuildContext>(resolve => {
const buildContextPromise = esbuild.context({
...common,
plugins: [
...(common.plugins ?? []),
{
name: 'electron-dev:reload-app-on-layers-change',
setup(build) {
build.onEnd(() => {
if (initialBuild) {
console.log(`[layers] has changed, [re]launching electron...`);
spawnOrReloadElectron();
} else {
buildContextPromise.then(resolve);
initialBuild = true;
}
});
},
},
],
});
buildContextPromise.then(async buildContext => {
await buildContext.watch();
});
});
}
await watchLayers();
if (watchMode) {
console.log(`Watching for changes...`);
} else {
console.log('Starting electron...');
spawnOrReloadElectron();
console.log(`Electron is started, watching for changes...`);
}
+103
View File
@@ -0,0 +1,103 @@
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'fs-extra';
import { glob } from 'glob';
const require = createRequire(import.meta.url);
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const repoRootDir = path.join(__dirname, '..', '..', '..', '..');
const electronRootDir = path.join(__dirname, '..');
const publicDistDir = path.join(electronRootDir, 'resources');
const webDir = path.join(
repoRootDir,
'packages',
'frontend',
'electron',
'renderer'
);
const affineWebOutDir = path.join(webDir, 'dist');
const publicAffineOutDir = path.join(publicDistDir, `web-static`);
const releaseVersionEnv = process.env.RELEASE_VERSION || '';
console.log('build with following variables', {
repoRootDir,
electronRootDir,
publicDistDir,
affineSrcDir: webDir,
affineSrcOutDir: affineWebOutDir,
publicAffineOutDir,
releaseVersionEnv,
});
// step 0: check version match
const electronPackageJson = require(`${electronRootDir}/package.json`);
if (releaseVersionEnv && electronPackageJson.version !== releaseVersionEnv) {
throw new Error(
`Version mismatch, expected ${releaseVersionEnv} but got ${electronPackageJson.version}`
);
}
// copy web dist files to electron dist
process.env.DISTRIBUTION = 'desktop';
const cwd = repoRootDir;
const { SKIP_NX_CACHE } = process.env;
const nxFlag = SKIP_NX_CACHE ? '--skip-nx-cache' : '';
// step 1: build web dist
if (!process.env.SKIP_WEB_BUILD) {
spawnSync('yarn', ['nx', 'build', '@affine/web', nxFlag], {
stdio: 'inherit',
env: process.env,
cwd,
shell: true,
});
spawnSync('yarn', ['workspace', '@affine/electron', 'build'], {
stdio: 'inherit',
env: process.env,
cwd,
shell: true,
});
// step 1.5: amend sourceMappingURL to allow debugging in devtools
await glob('**/*.{js,css}', { cwd: affineWebOutDir }).then(files => {
return Promise.all(
files.map(async file => {
const dir = path.dirname(file);
const fullpath = path.join(affineWebOutDir, file);
let content = await fs.readFile(fullpath, 'utf-8');
// replace # sourceMappingURL=76-6370cd185962bc89.js.map
// to # sourceMappingURL=assets://./{dir}/76-6370cd185962bc89.js.map
content = content.replace(/# sourceMappingURL=(.*)\.map/g, (_, p1) => {
return `# sourceMappingURL=assets://./${dir}/${p1}.map`;
});
try {
await fs.writeFile(fullpath, content);
console.log('amended sourceMappingURL for', fullpath);
} catch (e) {
// do not crash the build
console.error('error writing file', fullpath, e);
}
})
);
});
await fs.move(affineWebOutDir, publicAffineOutDir, { overwrite: true });
}
// step 2: update app-updater.yml content with build type in resources folder
if (process.env.BUILD_TYPE === 'internal') {
const appUpdaterYml = path.join(publicDistDir, 'app-update.yml');
const appUpdaterYmlContent = await fs.readFile(appUpdaterYml, 'utf-8');
const newAppUpdaterYmlContent = appUpdaterYmlContent.replace(
'AFFiNE',
'AFFiNE-Releases'
);
await fs.writeFile(appUpdaterYml, newAppUpdaterYmlContent);
}
@@ -0,0 +1,63 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
const filenamesMapping = {
windows: 'latest.yml',
macos: 'latest-mac.yml',
linux: 'latest-linux.yml',
};
const generateYml = platform => {
const yml = {
version: process.env.RELEASE_VERSION ?? '0.0.0',
files: [],
};
const regex = new RegExp(`^affine-.*-${platform}-.*.(exe|zip|dmg|appimage)$`);
const files = fs.readdirSync(process.cwd()).filter(file => regex.test(file));
const outputFileName = filenamesMapping[platform];
files.forEach(fileName => {
const filePath = path.join(process.cwd(), './', fileName);
try {
const fileData = fs.readFileSync(filePath);
const hash = crypto
.createHash('sha512')
.update(fileData)
.digest('base64');
const size = fs.statSync(filePath).size;
yml.files.push({
url: fileName,
sha512: hash,
size: size,
});
} catch {}
});
// path & sha512 are deprecated
yml.path = yml.files[0].url;
yml.sha512 = yml.files[0].sha512;
yml.releaseDate = new Date().toISOString();
const ymlStr =
`version: ${yml.version}\n` +
`files:\n` +
yml.files
.map(file => {
return (
` - url: ${file.url}\n` +
` sha512: ${file.sha512}\n` +
` size: ${file.size}\n`
);
})
.join('') +
`path: ${yml.path}\n` +
`sha512: ${yml.sha512}\n` +
`releaseDate: ${yml.releaseDate}\n`;
fs.writeFileSync(outputFileName, ymlStr);
};
generateYml('windows');
generateYml('macos');
generateYml('linux');
@@ -0,0 +1,15 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const outputRoot = fileURLToPath(
new URL(
'../out/canary/AFFiNE-canary-darwin-arm64/AFFiNE-canary.app/Contents/Resources',
import.meta.url
)
);
// todo: use asar package to check contents
fs.existsSync(path.resolve(outputRoot, 'app.asar'));
console.log('Output check passed');
@@ -0,0 +1,70 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { z } from 'zod';
const ReleaseTypeSchema = z.enum(['stable', 'beta', 'canary', 'internal']);
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
const ROOT = path.resolve(__dirname, '..');
const envBuildType = (process.env.BUILD_TYPE || 'canary').trim().toLowerCase();
const buildType = ReleaseTypeSchema.parse(envBuildType);
const stableBuild = buildType === 'stable';
const productName = !stableBuild ? `AFFiNE-${buildType}` : 'AFFiNE';
const icoPath = path.join(
ROOT,
!stableBuild
? `./resources/icons/icon_${buildType}.ico`
: './resources/icons/icon.ico'
);
const iconX64PngPath = path.join(
ROOT,
`./resources/icons/icon_${buildType}_64x64.png`
);
const icnsPath = path.join(
ROOT,
!stableBuild
? `./resources/icons/icon_${buildType}.icns`
: './resources/icons/icon.icns'
);
const iconPngPath = path.join(ROOT, './resources/icons/icon.png');
const iconUrl = `https://cdn.affine.pro/app-icons/icon_${buildType}.ico`;
const arch =
process.argv.indexOf('--arch') > 0
? process.argv[process.argv.indexOf('--arch') + 1]
: process.arch;
const platform =
process.argv.indexOf('--platform') > 0
? process.argv[process.argv.indexOf('--platform') + 1]
: process.platform;
const appIdMap = {
internal: 'pro.affine.internal',
canary: 'pro.affine.canary',
beta: 'pro.affine.beta',
stable: 'pro.affine.app',
};
export {
appIdMap,
arch,
buildType,
icnsPath,
iconPngPath,
iconUrl,
iconX64PngPath,
icoPath,
platform,
productName,
REPO_ROOT,
ROOT,
stableBuild,
};
@@ -0,0 +1,92 @@
import path from 'node:path';
import { buildForge } from 'app-builder-lib';
import debug from 'debug';
import fs from 'fs-extra';
import {
appIdMap,
arch,
buildType,
iconPngPath,
icoPath,
platform,
productName,
REPO_ROOT,
ROOT,
} from './make-env.js';
const log = debug('make-nsis');
async function make() {
const appName = productName;
const makeDir = path.resolve(ROOT, 'out', buildType, 'make');
const outPath = path.resolve(makeDir, `nsis.windows/${arch}`);
const appDirectory = path.resolve(
ROOT,
'out',
buildType,
`${appName}-${platform}-${arch}`
);
await fs.ensureDir(outPath);
await fs.emptyDir(outPath);
// create tmp dir
const tmpPath = await fs.mkdtemp(appName);
// copy app to tmp dir
log(`Copying app to ${tmpPath}`);
await fs.copy(appDirectory, tmpPath);
log(`Calling app-builder-lib's buildForge() with ${tmpPath}`);
const output = await buildForge(
{ dir: tmpPath },
{
win: [`nsis:${arch}`],
// @ts-expect-error - upstream type is wrong
publish: null, // buildForge will incorrectly publish the build
config: {
appId: appIdMap[buildType],
productName,
executableName: productName,
icon: iconPngPath,
extraMetadata: {
// do not use package.json's name
name: productName,
},
nsis: {
differentialPackage: false,
perMachine: false,
oneClick: false,
license: path.resolve(REPO_ROOT, 'LICENSE'),
include: path.resolve(ROOT, 'scripts', 'nsis-installer.nsh'),
installerIcon: icoPath,
allowToChangeInstallationDirectory: true,
installerSidebar: path.resolve(
ROOT,
'resources',
'icons',
'nsis-sidebar.bmp'
),
},
},
}
);
// Move the output to the actual output folder, app-builder-lib might get it wrong
log('making nsis.windows done:', output);
const result: Array<string> = [];
for (const file of output) {
const filePath = path.resolve(outPath, path.basename(file));
result.push(filePath);
await fs.move(file, filePath);
}
// cleanup
await fs.remove(tmpPath);
}
make();
@@ -0,0 +1,81 @@
import path from 'node:path';
import debug from 'debug';
import type { Options as ElectronWinstallerOptions } from 'electron-winstaller';
import { convertVersion, createWindowsInstaller } from 'electron-winstaller';
import fs from 'fs-extra';
import {
arch,
buildType,
iconUrl,
icoPath,
platform,
productName,
ROOT,
} from './make-env.js';
const log = debug('make-squirrel');
// taking from https://github.com/electron/forge/blob/main/packages/maker/squirrel/src/MakerSquirrel.ts
// it was for forge's maker, but can be used standalone as well
async function make() {
const appName = productName;
const makeDir = path.resolve(ROOT, 'out', buildType, 'make');
const outPath = path.resolve(makeDir, `squirrel.windows/${arch}`);
const appDirectory = path.resolve(
ROOT,
'out',
buildType,
`${appName}-${platform}-${arch}`
);
await fs.ensureDir(outPath);
const packageJSON = await fs.readJson(path.resolve(ROOT, 'package.json'));
const winstallerConfig: ElectronWinstallerOptions = {
name: appName,
title: appName,
noMsi: true,
exe: `${appName}.exe`,
setupExe: `${appName}-${packageJSON.version} Setup.exe`,
version: packageJSON.version,
appDirectory: appDirectory,
outputDirectory: outPath,
iconUrl: iconUrl,
setupIcon: icoPath,
loadingGif: path.resolve(ROOT, './resources/icons/affine_installing.gif'),
};
await createWindowsInstaller(winstallerConfig);
const nupkgVersion = convertVersion(packageJSON.version);
const artifacts = [
path.resolve(outPath, 'RELEASES'),
path.resolve(outPath, winstallerConfig.setupExe || `${appName}Setup.exe`),
path.resolve(
outPath,
`${winstallerConfig.name}-${nupkgVersion}-full.nupkg`
),
];
const deltaPath = path.resolve(
outPath,
`${winstallerConfig.name}-${nupkgVersion}-delta.nupkg`
);
if (
(winstallerConfig.remoteReleases && !winstallerConfig.noDelta) ||
(await fs.pathExists(deltaPath))
) {
artifacts.push(deltaPath);
}
const msiPath = path.resolve(
outPath,
winstallerConfig.setupMsi || `${appName}Setup.msi`
);
if (!winstallerConfig.noMsi && (await fs.pathExists(msiPath))) {
artifacts.push(msiPath);
}
log('making squirrel.windows done:', artifacts);
return artifacts;
}
make();
@@ -0,0 +1 @@
ManifestDPIAware true
@@ -0,0 +1,3 @@
{
"type": "module"
}
@@ -0,0 +1,246 @@
import type { InsertRow } from '@affine/native';
import { SqliteConnection } from '@affine/native';
import type { ByteKVBehavior } from '@toeverything/infra/storage';
import { logger } from '../logger';
/**
* A base class for SQLite DB adapter that provides basic methods around updates & blobs
*/
export class SQLiteAdapter {
db: SqliteConnection | null = null;
constructor(public readonly path: string) {}
async connectIfNeeded() {
if (!this.db) {
this.db = new SqliteConnection(this.path);
await this.db.connect();
logger.info(`[SQLiteAdapter]`, 'connected:', this.path);
}
return this.db;
}
async destroy() {
const { db } = this;
this.db = null;
// log after close will sometimes crash the app when quitting
logger.info(`[SQLiteAdapter]`, 'destroyed:', this.path);
await db?.close();
}
async addBlob(key: string, data: Uint8Array) {
try {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return;
}
await this.db.addBlob(key, data);
} catch (error) {
logger.error('addBlob', error);
}
}
async getBlob(key: string) {
try {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return null;
}
const blob = await this.db.getBlob(key);
return blob?.data ?? null;
} catch (error) {
logger.error('getBlob', error);
return null;
}
}
async deleteBlob(key: string) {
try {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return;
}
await this.db.deleteBlob(key);
} catch (error) {
logger.error(`${this.path} delete blob failed`, error);
}
}
async getBlobKeys() {
try {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return [];
}
return await this.db.getBlobKeys();
} catch (error) {
logger.error(`getBlobKeys failed`, error);
return [];
}
}
async getUpdates(docId?: string) {
try {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return [];
}
return await this.db.getUpdates(docId);
} catch (error) {
logger.error('getUpdates', error);
return [];
}
}
async getAllUpdates() {
try {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return [];
}
return await this.db.getAllUpdates();
} catch (error) {
logger.error('getAllUpdates', error);
return [];
}
}
// add a single update to SQLite
async addUpdateToSQLite(updates: InsertRow[]) {
// batch write instead write per key stroke?
try {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return;
}
const start = performance.now();
await this.db.insertUpdates(updates);
logger.debug(
`[SQLiteAdapter] addUpdateToSQLite`,
'length:',
updates.length,
'docids',
updates.map(u => u.docId),
performance.now() - start,
'ms'
);
} catch (error) {
logger.error('addUpdateToSQLite', this.path, error);
}
}
async deleteUpdates(docId?: string) {
try {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return;
}
await this.db.deleteUpdates(docId);
} catch (error) {
logger.error('deleteUpdates', error);
}
}
async getUpdatesCount(docId?: string) {
try {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return 0;
}
return await this.db.getUpdatesCount(docId);
} catch (error) {
logger.error('getUpdatesCount', error);
return 0;
}
}
async replaceUpdates(docId: string | null | undefined, updates: InsertRow[]) {
try {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return;
}
await this.db.replaceUpdates(docId, updates);
} catch (error) {
logger.error('replaceUpdates', error);
}
}
serverClock: ByteKVBehavior = {
get: async key => {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return null;
}
const blob = await this.db.getServerClock(key);
return blob?.data ?? null;
},
set: async (key, data) => {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return;
}
await this.db.setServerClock(key, data);
},
keys: async () => {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return [];
}
return await this.db.getServerClockKeys();
},
del: async key => {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return;
}
await this.db.delServerClock(key);
},
clear: async () => {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return;
}
await this.db.clearServerClock();
},
};
syncMetadata: ByteKVBehavior = {
get: async key => {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return null;
}
const blob = await this.db.getSyncMetadata(key);
return blob?.data ?? null;
},
set: async (key, data) => {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return;
}
await this.db.setSyncMetadata(key, data);
},
keys: async () => {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return [];
}
return await this.db.getSyncMetadataKeys();
},
del: async key => {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return;
}
await this.db.delSyncMetadata(key);
},
clear: async () => {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return;
}
await this.db.clearSyncMetadata();
},
};
}
@@ -0,0 +1,43 @@
import { logger } from '../logger';
import type { SpaceType } from './types';
import type { WorkspaceSQLiteDB } from './workspace-db-adapter';
import { openWorkspaceDatabase } from './workspace-db-adapter';
// export for testing
export const db$Map = new Map<
`${SpaceType}:${string}`,
Promise<WorkspaceSQLiteDB>
>();
async function getWorkspaceDB(spaceType: SpaceType, id: string) {
const cacheId = `${spaceType}:${id}` as const;
let db = await db$Map.get(cacheId);
if (!db) {
const promise = openWorkspaceDatabase(spaceType, id);
db$Map.set(cacheId, promise);
const _db = (db = await promise);
const cleanup = () => {
db$Map.delete(cacheId);
_db
.destroy()
.then(() => {
logger.info('[ensureSQLiteDB] db connection closed', _db.workspaceId);
})
.catch(err => {
logger.error('[ensureSQLiteDB] destroy db failed', err);
});
};
db.update$.subscribe({
complete: cleanup,
});
process.on('beforeExit', cleanup);
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return db!;
}
export function ensureSQLiteDB(spaceType: SpaceType, id: string) {
return getWorkspaceDB(spaceType, id);
}
@@ -0,0 +1,130 @@
import { mainRPC } from '../main-rpc';
import type { MainEventRegister } from '../type';
import { ensureSQLiteDB } from './ensure-db';
import type { SpaceType } from './types';
export * from './ensure-db';
export const dbHandlers = {
getDocAsUpdates: async (
spaceType: SpaceType,
workspaceId: string,
subdocId: string
) => {
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
return spaceDB.getDocAsUpdates(subdocId);
},
applyDocUpdate: async (
spaceType: SpaceType,
workspaceId: string,
update: Uint8Array,
subdocId: string
) => {
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
return spaceDB.addUpdateToSQLite(update, subdocId);
},
deleteDoc: async (
spaceType: SpaceType,
workspaceId: string,
subdocId: string
) => {
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
return spaceDB.deleteUpdate(subdocId);
},
addBlob: async (
spaceType: SpaceType,
workspaceId: string,
key: string,
data: Uint8Array
) => {
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
return spaceDB.addBlob(key, data);
},
getBlob: async (spaceType: SpaceType, workspaceId: string, key: string) => {
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
return spaceDB.getBlob(key);
},
deleteBlob: async (
spaceType: SpaceType,
workspaceId: string,
key: string
) => {
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
return spaceDB.deleteBlob(key);
},
getBlobKeys: async (spaceType: SpaceType, workspaceId: string) => {
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
return spaceDB.getBlobKeys();
},
getDefaultStorageLocation: async () => {
return await mainRPC.getPath('sessionData');
},
getServerClock: async (
spaceType: SpaceType,
workspaceId: string,
key: string
) => {
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
return spaceDB.adapter.serverClock.get(key);
},
setServerClock: async (
spaceType: SpaceType,
workspaceId: string,
key: string,
data: Uint8Array
) => {
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
return spaceDB.adapter.serverClock.set(key, data);
},
getServerClockKeys: async (spaceType: SpaceType, workspaceId: string) => {
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
return spaceDB.adapter.serverClock.keys();
},
clearServerClock: async (spaceType: SpaceType, workspaceId: string) => {
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
return spaceDB.adapter.serverClock.clear();
},
delServerClock: async (
spaceType: SpaceType,
workspaceId: string,
key: string
) => {
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
return spaceDB.adapter.serverClock.del(key);
},
getSyncMetadata: async (
spaceType: SpaceType,
workspaceId: string,
key: string
) => {
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
return spaceDB.adapter.syncMetadata.get(key);
},
setSyncMetadata: async (
spaceType: SpaceType,
workspaceId: string,
key: string,
data: Uint8Array
) => {
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
return spaceDB.adapter.syncMetadata.set(key, data);
},
getSyncMetadataKeys: async (spaceType: SpaceType, workspaceId: string) => {
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
return spaceDB.adapter.syncMetadata.keys();
},
clearSyncMetadata: async (spaceType: SpaceType, workspaceId: string) => {
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
return spaceDB.adapter.syncMetadata.clear();
},
delSyncMetadata: async (
spaceType: SpaceType,
workspaceId: string,
key: string
) => {
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
return spaceDB.adapter.syncMetadata.del(key);
},
};
export const dbEvents = {} satisfies Record<string, MainEventRegister>;
@@ -0,0 +1,17 @@
import { applyUpdate, Doc as YDoc, encodeStateAsUpdate, transact } from 'yjs';
export function mergeUpdate(updates: Uint8Array[]) {
if (updates.length === 0) {
return new Uint8Array();
}
if (updates.length === 1) {
return updates[0];
}
const yDoc = new YDoc();
transact(yDoc, () => {
for (const update of updates) {
applyUpdate(yDoc, update);
}
});
return encodeStateAsUpdate(yDoc);
}
@@ -0,0 +1 @@
export type SpaceType = 'userspace' | 'workspace';
@@ -0,0 +1,134 @@
import { AsyncLock } from '@toeverything/infra/utils';
import { Subject } from 'rxjs';
import { applyUpdate, Doc as YDoc } from 'yjs';
import { logger } from '../logger';
import { getWorkspaceMeta } from '../workspace/meta';
import { SQLiteAdapter } from './db-adapter';
import { mergeUpdate } from './merge-update';
import type { SpaceType } from './types';
const TRIM_SIZE = 1;
export class WorkspaceSQLiteDB {
lock = new AsyncLock();
update$ = new Subject<void>();
adapter = new SQLiteAdapter(this.path);
constructor(
public path: string,
public workspaceId: string
) {}
async transaction<T>(cb: () => Promise<T>): Promise<T> {
using _lock = await this.lock.acquire();
return await cb();
}
async destroy() {
await this.adapter.destroy();
// when db is closed, we can safely remove it from ensure-db list
this.update$.complete();
}
private readonly toDBDocId = (docId: string) => {
return this.workspaceId === docId ? undefined : docId;
};
getWorkspaceName = async () => {
const ydoc = new YDoc();
const updates = await this.adapter.getUpdates();
updates.forEach(update => {
applyUpdate(ydoc, update.data);
});
return ydoc.getMap('meta').get('name') as string;
};
async init() {
const db = await this.adapter.connectIfNeeded();
await this.tryTrim();
return db;
}
async get(docId: string) {
return this.adapter.getUpdates(docId);
}
// getUpdates then encode
getDocAsUpdates = async (docId: string) => {
const dbID = this.toDBDocId(docId);
const update = await this.tryTrim(dbID);
if (update) {
return update;
} else {
const updates = await this.adapter.getUpdates(dbID);
return mergeUpdate(updates.map(row => row.data));
}
};
async addBlob(key: string, value: Uint8Array) {
this.update$.next();
const res = await this.adapter.addBlob(key, value);
return res;
}
async getBlob(key: string) {
return this.adapter.getBlob(key);
}
async getBlobKeys() {
return this.adapter.getBlobKeys();
}
async deleteBlob(key: string) {
this.update$.next();
await this.adapter.deleteBlob(key);
}
async addUpdateToSQLite(update: Uint8Array, subdocId: string) {
this.update$.next();
await this.transaction(async () => {
const dbID = this.toDBDocId(subdocId);
const oldUpdate = await this.adapter.getUpdates(dbID);
await this.adapter.replaceUpdates(dbID, [
{
data: mergeUpdate([...oldUpdate.map(u => u.data), update]),
docId: dbID,
},
]);
});
}
async deleteUpdate(subdocId: string) {
this.update$.next();
await this.adapter.deleteUpdates(this.toDBDocId(subdocId));
}
private readonly tryTrim = async (dbID?: string) => {
const count = (await this.adapter?.getUpdatesCount(dbID)) ?? 0;
if (count > TRIM_SIZE) {
return await this.transaction(async () => {
logger.debug(`trim ${this.workspaceId}:${dbID} ${count}`);
const updates = await this.adapter.getUpdates(dbID);
const update = mergeUpdate(updates.map(row => row.data));
const insertRows = [{ data: update, docId: dbID }];
await this.adapter?.replaceUpdates(dbID, insertRows);
logger.debug(`trim ${this.workspaceId}:${dbID} successfully`);
return update;
});
}
return null;
};
}
export async function openWorkspaceDatabase(
spaceType: SpaceType,
spaceId: string
) {
const meta = await getWorkspaceMeta(spaceType, spaceId);
const db = new WorkspaceSQLiteDB(meta.mainDBPath, spaceId);
await db.init();
logger.info(`openWorkspaceDatabase [${spaceId}]`);
return db;
}
@@ -0,0 +1,221 @@
import { ValidationResult } from '@affine/native';
import fs from 'fs-extra';
import { nanoid } from 'nanoid';
import { ensureSQLiteDB } from '../db/ensure-db';
import { logger } from '../logger';
import { mainRPC } from '../main-rpc';
import { storeWorkspaceMeta } from '../workspace';
import { getWorkspaceDBPath, getWorkspacesBasePath } from '../workspace/meta';
export type ErrorMessage =
| 'DB_FILE_ALREADY_LOADED'
| 'DB_FILE_PATH_INVALID'
| 'DB_FILE_INVALID'
| 'DB_FILE_MIGRATION_FAILED'
| 'FILE_ALREADY_EXISTS'
| 'UNKNOWN_ERROR';
export interface LoadDBFileResult {
workspaceId?: string;
error?: ErrorMessage;
canceled?: boolean;
}
export interface SaveDBFileResult {
filePath?: string;
canceled?: boolean;
error?: ErrorMessage;
}
export interface SelectDBFileLocationResult {
filePath?: string;
error?: ErrorMessage;
canceled?: boolean;
}
// provide a backdoor to set dialog path for testing in playwright
export interface FakeDialogResult {
canceled?: boolean;
filePath?: string;
filePaths?: string[];
}
// result will be used in the next call to showOpenDialog
// if it is being read once, it will be reset to undefined
let fakeDialogResult: FakeDialogResult | undefined = undefined;
function getFakedResult() {
const result = fakeDialogResult;
fakeDialogResult = undefined;
return result;
}
export function setFakeDialogResult(result: FakeDialogResult | undefined) {
fakeDialogResult = result;
// for convenience, we will fill filePaths with filePath if it is not set
if (result?.filePaths === undefined && result?.filePath !== undefined) {
result.filePaths = [result.filePath];
}
}
const extension = 'affine';
function getDefaultDBFileName(name: string, id: string) {
const fileName = `${name}_${id}.${extension}`;
// make sure fileName is a valid file name
return fileName.replace(/[/\\?%*:|"<>]/g, '-');
}
/**
* This function is called when the user clicks the "Save" button in the "Save Workspace" dialog.
*
* It will just copy the file to the given path
*/
export async function saveDBFileAs(
workspaceId: string
): Promise<SaveDBFileResult> {
try {
const db = await ensureSQLiteDB('workspace', workspaceId);
const fakedResult = getFakedResult();
const ret =
fakedResult ??
(await mainRPC.showSaveDialog({
properties: ['showOverwriteConfirmation'],
title: 'Save Workspace',
showsTagField: false,
buttonLabel: 'Save',
filters: [
{
extensions: [extension],
name: '',
},
],
defaultPath: getDefaultDBFileName(
await db.getWorkspaceName(),
workspaceId
),
message: 'Save Workspace as a SQLite Database file',
}));
const filePath = ret.filePath;
if (ret.canceled || !filePath) {
return {
canceled: true,
};
}
await fs.copyFile(db.path, filePath);
logger.log('saved', filePath);
if (!fakedResult) {
mainRPC.showItemInFolder(filePath).catch(err => {
console.error(err);
});
}
return { filePath };
} catch (err) {
logger.error('saveDBFileAs', err);
return {
error: 'UNKNOWN_ERROR',
};
}
}
export async function selectDBFileLocation(): Promise<SelectDBFileLocationResult> {
try {
const ret =
getFakedResult() ??
(await mainRPC.showOpenDialog({
properties: ['openDirectory'],
title: 'Set Workspace Storage Location',
buttonLabel: 'Select',
defaultPath: await mainRPC.getPath('documents'),
message: "Select a location to store the workspace's database file",
}));
const dir = ret.filePaths?.[0];
if (ret.canceled || !dir) {
return {
canceled: true,
};
}
return { filePath: dir };
} catch (err) {
logger.error('selectDBFileLocation', err);
return {
error: (err as any).message,
};
}
}
/**
* This function is called when the user clicks the "Load" button in the "Load Workspace" dialog.
*
* It will
* - symlink the source db file to a new workspace id to app-data
* - return the new workspace id
*
* eg, it will create a new folder in app-data:
* <app-data>/<app-name>/workspaces/<workspace-id>/storage.db
*
* On the renderer side, after the UI got a new workspace id, it will
* update the local workspace id list and then connect to it.
*
*/
export async function loadDBFile(): Promise<LoadDBFileResult> {
try {
const ret =
getFakedResult() ??
(await mainRPC.showOpenDialog({
properties: ['openFile'],
title: 'Load Workspace',
buttonLabel: 'Load',
filters: [
{
name: 'SQLite Database',
// do we want to support other file format?
extensions: ['db', 'affine'],
},
],
message: 'Load Workspace from a AFFiNE file',
}));
const originalPath = ret.filePaths?.[0];
if (ret.canceled || !originalPath) {
logger.info('loadDBFile canceled');
return { canceled: true };
}
// the imported file should not be in app data dir
if (originalPath.startsWith(await getWorkspacesBasePath())) {
logger.warn('loadDBFile: db file in app data dir');
return { error: 'DB_FILE_PATH_INVALID' };
}
const { SqliteConnection } = await import('@affine/native');
const validationResult = await SqliteConnection.validate(originalPath);
if (validationResult !== ValidationResult.Valid) {
return { error: 'DB_FILE_INVALID' }; // invalid db file
}
// copy the db file to a new workspace id
const workspaceId = nanoid(10);
const internalFilePath = await getWorkspaceDBPath('workspace', workspaceId);
await fs.ensureDir(await getWorkspacesBasePath());
await fs.copy(originalPath, internalFilePath);
logger.info(`loadDBFile, copy: ${originalPath} -> ${internalFilePath}`);
await storeWorkspaceMeta(workspaceId, {
id: workspaceId,
mainDBPath: internalFilePath,
});
return { workspaceId };
} catch (err) {
logger.error('loadDBFile', err);
return {
error: 'UNKNOWN_ERROR',
};
}
}
@@ -0,0 +1,23 @@
import {
loadDBFile,
saveDBFileAs,
selectDBFileLocation,
setFakeDialogResult,
} from './dialog';
export const dialogHandlers = {
loadDBFile: async () => {
return loadDBFile();
},
saveDBFileAs: async (workspaceId: string) => {
return saveDBFileAs(workspaceId);
},
selectDBFileLocation: async () => {
return selectDBFileLocation();
},
setFakeDialogResult: async (
result: Parameters<typeof setFakeDialogResult>[0]
) => {
return setFakeDialogResult(result);
},
};
@@ -0,0 +1,36 @@
import { dbEvents, dbHandlers } from './db';
import { dialogHandlers } from './dialog';
import { provideExposed } from './provide';
import { workspaceEvents, workspaceHandlers } from './workspace';
export const handlers = {
db: dbHandlers,
workspace: workspaceHandlers,
dialog: dialogHandlers,
};
export const events = {
db: dbEvents,
workspace: workspaceEvents,
};
const getExposedMeta = () => {
const handlersMeta = Object.entries(handlers).map(
([namespace, namespaceHandlers]) => {
return [namespace, Object.keys(namespaceHandlers)] as [string, string[]];
}
);
const eventsMeta = Object.entries(events).map(
([namespace, namespaceHandlers]) => {
return [namespace, Object.keys(namespaceHandlers)] as [string, string[]];
}
);
return {
handlers: handlersMeta,
events: eventsMeta,
};
};
provideExposed(getExposedMeta());
@@ -0,0 +1,82 @@
import { AsyncCall } from 'async-call-rpc';
import type { RendererToHelper } from '../shared/type';
import { events, handlers } from './exposed';
import { logger } from './logger';
function setupRendererConnection(rendererPort: Electron.MessagePortMain) {
const flattenedHandlers = Object.entries(handlers).flatMap(
([namespace, namespaceHandlers]) => {
return Object.entries(namespaceHandlers).map(([name, handler]) => {
const handlerWithLog = async (...args: any[]) => {
try {
const start = performance.now();
const result = await handler(...args);
logger.debug(
'[async-api]',
`${namespace}.${name}`,
args.filter(
arg => typeof arg !== 'function' && typeof arg !== 'object'
),
'-',
(performance.now() - start).toFixed(2),
'ms'
);
return result;
} catch (error) {
logger.error('[async-api]', `${namespace}.${name}`, error);
}
};
return [`${namespace}:${name}`, handlerWithLog];
});
}
);
const rpc = AsyncCall<RendererToHelper>(
Object.fromEntries(flattenedHandlers),
{
channel: {
on(listener) {
const f = (e: Electron.MessageEvent) => {
listener(e.data);
};
rendererPort.on('message', f);
// MUST start the connection to receive messages
rendererPort.start();
return () => {
rendererPort.off('message', f);
};
},
send(data) {
rendererPort.postMessage(data);
},
},
log: false,
}
);
for (const [namespace, namespaceEvents] of Object.entries(events)) {
for (const [key, eventRegister] of Object.entries(namespaceEvents)) {
const unsub = eventRegister((...args: any[]) => {
const chan = `${namespace}:${key}`;
rpc.postEvent(chan, ...args).catch(err => {
console.error(err);
});
});
process.on('exit', () => {
unsub();
});
}
}
}
function main() {
process.parentPort.on('message', e => {
if (e.data.channel === 'renderer-connect' && e.ports.length === 1) {
const rendererPort = e.ports[0];
setupRendererConnection(rendererPort);
logger.info('[helper] renderer connected');
}
});
}
main();
@@ -0,0 +1,5 @@
import log from 'electron-log/main';
export const logger = log.scope('helper');
log.transports.file.level = 'info';
@@ -0,0 +1,32 @@
import { assertExists } from '@blocksuite/global/utils';
import { AsyncCall } from 'async-call-rpc';
import type { HelperToMain, MainToHelper } from '../shared/type';
import { exposed } from './provide';
const helperToMainServer: HelperToMain = {
getMeta: () => {
assertExists(exposed);
return exposed;
},
};
export const mainRPC = AsyncCall<MainToHelper>(helperToMainServer, {
strict: {
unknownMessage: false,
},
channel: {
on(listener) {
const f = (e: Electron.MessageEvent) => {
listener(e.data);
};
process.parentPort.on('message', f);
return () => {
process.parentPort.off('message', f);
};
},
send(data) {
process.parentPort.postMessage(data);
},
},
});
@@ -0,0 +1,11 @@
import type { ExposedMeta } from '../shared/type';
/**
* A naive DI implementation to get rid of circular dependency.
*/
export let exposed: ExposedMeta | undefined;
export const provideExposed = (exposedMeta: ExposedMeta) => {
exposed = exposedMeta;
};
@@ -0,0 +1,8 @@
export interface WorkspaceMeta {
id: string;
mainDBPath: string;
}
export type YOrigin = 'self' | 'external' | 'upstream' | 'renderer';
export type MainEventRegister = (...args: any[]) => () => void;
@@ -0,0 +1,45 @@
import path from 'node:path';
import fs from 'fs-extra';
import { ensureSQLiteDB } from '../db/ensure-db';
import { logger } from '../logger';
import type { WorkspaceMeta } from '../type';
import {
getDeletedWorkspacesBasePath,
getWorkspaceBasePath,
getWorkspaceMeta,
} from './meta';
export async function deleteWorkspace(id: string) {
const basePath = await getWorkspaceBasePath('workspace', id);
const movedPath = path.join(await getDeletedWorkspacesBasePath(), `${id}`);
try {
const db = await ensureSQLiteDB('workspace', id);
await db.destroy();
return await fs.move(basePath, movedPath, {
overwrite: true,
});
} catch (error) {
logger.error('deleteWorkspace', error);
}
}
export async function storeWorkspaceMeta(
workspaceId: string,
meta: Partial<WorkspaceMeta>
) {
try {
const basePath = await getWorkspaceBasePath('workspace', workspaceId);
await fs.ensureDir(basePath);
const metaPath = path.join(basePath, 'meta.json');
const currentMeta = await getWorkspaceMeta('workspace', workspaceId);
const newMeta = {
...currentMeta,
...meta,
};
await fs.writeJSON(metaPath, newMeta);
} catch (err) {
logger.error('storeWorkspaceMeta failed', err);
}
}
@@ -0,0 +1,11 @@
import type { MainEventRegister } from '../type';
import { deleteWorkspace } from './handlers';
export * from './handlers';
export * from './subjects';
export const workspaceEvents = {} as Record<string, MainEventRegister>;
export const workspaceHandlers = {
delete: async (id: string) => deleteWorkspace(id),
};
@@ -0,0 +1,94 @@
import path from 'node:path';
import fs from 'fs-extra';
import type { SpaceType } from '../db/types';
import { logger } from '../logger';
import { mainRPC } from '../main-rpc';
import type { WorkspaceMeta } from '../type';
let _appDataPath = '';
export async function getAppDataPath() {
if (_appDataPath) {
return _appDataPath;
}
_appDataPath = await mainRPC.getPath('sessionData');
return _appDataPath;
}
export async function getWorkspacesBasePath() {
return path.join(await getAppDataPath(), 'workspaces');
}
export async function getWorkspaceBasePath(
spaceType: SpaceType,
workspaceId: string
) {
return path.join(
await getAppDataPath(),
spaceType === 'userspace' ? 'userspaces' : 'workspaces',
workspaceId
);
}
export async function getDeletedWorkspacesBasePath() {
return path.join(await getAppDataPath(), 'deleted-workspaces');
}
export async function getWorkspaceDBPath(
spaceType: SpaceType,
workspaceId: string
) {
return path.join(
await getWorkspaceBasePath(spaceType, workspaceId),
'storage.db'
);
}
export async function getWorkspaceMetaPath(
spaceType: SpaceType,
workspaceId: string
) {
return path.join(
await getWorkspaceBasePath(spaceType, workspaceId),
'meta.json'
);
}
/**
* Get workspace meta, create one if not exists
* This function will also migrate the workspace if needed
*/
export async function getWorkspaceMeta(
spaceType: SpaceType,
workspaceId: string
): Promise<WorkspaceMeta> {
try {
const basePath = await getWorkspaceBasePath(spaceType, workspaceId);
const metaPath = await getWorkspaceMetaPath(spaceType, workspaceId);
if (
!(await fs
.access(metaPath)
.then(() => true)
.catch(() => false))
) {
await fs.ensureDir(basePath);
const dbPath = await getWorkspaceDBPath(spaceType, workspaceId);
// create one if not exists
const meta = {
id: workspaceId,
mainDBPath: dbPath,
type: spaceType,
};
await fs.writeJSON(metaPath, meta);
return meta;
} else {
const meta = await fs.readJSON(metaPath);
return meta;
}
} catch (err) {
logger.error('getWorkspaceMeta failed', err);
throw err;
}
}
@@ -0,0 +1,7 @@
import { Subject } from 'rxjs';
import type { WorkspaceMeta } from '../type';
export const workspaceSubjects = {
meta$: new Subject<{ workspaceId: string; meta: WorkspaceMeta }>(),
};
@@ -0,0 +1,253 @@
import { app, Menu } from 'electron';
import { isMacOS, isWindows } from '../../shared/utils';
import { logger, revealLogFile } from '../logger';
import { checkForUpdates } from '../updater';
import {
addTab,
closeTab,
initAndShowMainWindow,
reloadView,
showDevTools,
showMainWindow,
switchTab,
switchToNextTab,
switchToPreviousTab,
undoCloseTab,
} from '../windows-manager';
import { applicationMenuSubjects } from './subject';
// Unique id for menuitems
const MENUITEM_NEW_PAGE = 'affine:new-page';
export function createApplicationMenu() {
const isMac = isMacOS();
// Electron menu cannot be modified
// You have to copy the complete default menu template event if you want to add a single custom item
// See https://www.electronjs.org/docs/latest/api/menu#examples
const template = [
// { role: 'appMenu' }
...(isMac
? [
{
label: app.name,
submenu: [
{
label: `About ${app.getName()}`,
click: async () => {
await showMainWindow();
applicationMenuSubjects.openAboutPageInSettingModal$.next();
},
},
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
},
]
: []),
// { role: 'fileMenu' }
{
label: 'File',
submenu: [
{
id: MENUITEM_NEW_PAGE,
label: 'New Doc',
accelerator: isMac ? 'Cmd+N' : 'Ctrl+N',
click: async () => {
await initAndShowMainWindow();
// fixme: if the window is just created, the new page action will not be triggered
applicationMenuSubjects.newPageAction$.next();
},
},
],
},
// { role: 'editMenu' }
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
...(isMac
? [
{ role: 'pasteAndMatchStyle' },
{ role: 'delete' },
{ role: 'selectAll' },
{ type: 'separator' },
{
label: 'Speech',
submenu: [{ role: 'startSpeaking' }, { role: 'stopSpeaking' }],
},
]
: [{ role: 'delete' }, { type: 'separator' }, { role: 'selectAll' }]),
],
},
// { role: 'viewMenu' }
{
label: 'View',
submenu: [
{
label: 'Reload',
accelerator: 'CommandOrControl+R',
click() {
reloadView().catch(console.error);
},
},
{
label: 'Open devtools',
accelerator: isMac ? 'Cmd+Option+I' : 'Ctrl+Shift+I',
click: () => {
showDevTools();
},
},
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
...(isWindows()
? [{ role: 'zoomIn', accelerator: 'Ctrl+=', visible: false }]
: []),
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
{ type: 'separator' },
{
label: 'New tab',
accelerator: 'CommandOrControl+T',
click() {
logger.info('New tab with shortcut');
addTab().catch(console.error);
},
},
{
label: 'Close tab',
accelerator: 'CommandOrControl+W',
click() {
logger.info('Close tab with shortcut');
closeTab().catch(console.error);
},
},
{
label: 'Undo close tab',
accelerator: 'CommandOrControl+Shift+T',
click() {
logger.info('Undo close tab with shortcut');
undoCloseTab().catch(console.error);
},
},
...[1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => {
const shortcut = `CommandOrControl+${n}`;
const listener = () => {
switchTab(n);
};
return {
acceleratorWorksWhenHidden: true,
label: `Switch to tab ${n}`,
accelerator: shortcut,
click: listener,
visible: false,
};
}),
{
label: 'Switch to next tab',
accelerator: 'CommandOrControl+Tab',
click: () => {
switchToNextTab();
},
},
{
label: 'Switch to previous tab',
accelerator: 'CommandOrControl+Shift+Tab',
click: () => {
switchToPreviousTab();
},
},
{
label: 'Switch to next tab (mac)',
accelerator: 'Command+]',
visible: false,
click: () => {
switchToNextTab();
},
},
{
label: 'Switch to previous tab (mac)',
accelerator: 'Command+[',
visible: false,
click: () => {
switchToPreviousTab();
},
},
{
label: 'Switch to next tab (mac 2)',
accelerator: 'Alt+Command+]',
visible: false,
click: () => {
switchToNextTab();
},
},
{
label: 'Switch to previous tab (mac 2)',
accelerator: 'Alt+Command+[',
visible: false,
click: () => {
switchToPreviousTab();
},
},
],
},
{
role: 'help',
submenu: [
{
label: 'Learn More',
click: async () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { shell } = require('electron');
await shell.openExternal('https://affine.pro/');
},
},
{
label: 'Open log file',
click: async () => {
await revealLogFile();
},
},
{
label: 'Check for Updates',
click: async () => {
await initAndShowMainWindow();
await checkForUpdates();
},
},
{
label: 'Documentation',
click: async () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { shell } = require('electron');
await shell.openExternal(
'https://docs.affine.pro/docs/hello-bonjour-aloha-你好'
);
},
},
],
},
];
// @ts-expect-error: The snippet is copied from Electron official docs.
// It's working as expected. No idea why it contains type errors.
// Just ignore for now.
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
return menu;
}
@@ -0,0 +1,27 @@
import type { MainEventRegister } from '../type';
import { applicationMenuSubjects } from './subject';
export * from './create';
export * from './subject';
/**
* Events triggered by application menu
*/
export const applicationMenuEvents = {
/**
* File -> New Doc
*/
onNewPageAction: (fn: () => void) => {
const sub = applicationMenuSubjects.newPageAction$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
openAboutPageInSettingModal: (fn: () => void) => {
const sub =
applicationMenuSubjects.openAboutPageInSettingModal$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
} satisfies Record<string, MainEventRegister>;
@@ -0,0 +1,6 @@
import { Subject } from 'rxjs';
export const applicationMenuSubjects = {
newPageAction$: new Subject<void>(),
openAboutPageInSettingModal$: new Subject<void>(),
};
@@ -0,0 +1,10 @@
import type { IpcMainInvokeEvent } from 'electron';
import { clipboard, nativeImage } from 'electron';
import type { NamespaceHandlers } from '../type';
export const clipboardHandlers = {
copyAsImageFromString: async (_: IpcMainInvokeEvent, dataURL: string) => {
clipboard.writeImage(nativeImage.createFromDataURL(dataURL));
},
} satisfies NamespaceHandlers;
@@ -0,0 +1,10 @@
import type { NamespaceHandlers } from '../type';
import { persistentConfig } from './persist';
/**
* @deprecated use shared storage
*/
export const configStorageHandlers = {
get: async () => persistentConfig.get(),
set: async (_, v) => persistentConfig.set(v),
} satisfies NamespaceHandlers;
@@ -0,0 +1 @@
export * from './handlers';
@@ -0,0 +1,20 @@
import fs from 'node:fs';
import path from 'node:path';
import {
AppConfigStorage,
defaultAppConfig,
} from '@toeverything/infra/app-config-storage';
import { app } from 'electron';
const FILENAME = 'config.json';
const FILEPATH = path.join(app.getPath('userData'), FILENAME);
/**
* @deprecated use shared storage
*/
export const persistentConfig = new AppConfigStorage({
config: defaultAppConfig,
get: () => JSON.parse(fs.readFileSync(FILEPATH, 'utf-8')),
set: data => fs.writeFileSync(FILEPATH, JSON.stringify(data, null, 2)),
});
@@ -0,0 +1,34 @@
import { z } from 'zod';
export const ReleaseTypeSchema = z.enum([
'stable',
'beta',
'canary',
'internal',
]);
declare global {
// THIS variable should be replaced during the build process
const REPLACE_ME_BUILD_ENV: string;
}
export const envBuildType = (process.env.BUILD_TYPE || REPLACE_ME_BUILD_ENV)
.trim()
.toLowerCase();
export const overrideSession = process.env.BUILD_TYPE === 'internal';
export const buildType = ReleaseTypeSchema.parse(envBuildType);
export const mode = process.env.NODE_ENV;
export const isDev = mode === 'development';
const API_URL_MAPPING = {
stable: `https://app.affine.pro`,
beta: `https://insider.affine.pro`,
canary: `https://affine.fail`,
internal: `https://insider.affine.pro`,
};
export const CLOUD_BASE_URL =
process.env.DEV_SERVER_URL || API_URL_MAPPING[buildType];
@@ -0,0 +1,4 @@
export const mainWindowOrigin = process.env.DEV_SERVER_URL || 'file://.';
export const onboardingViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}onboarding`;
export const shellViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}shell.html`;
export const customThemeViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}theme-editor`;
@@ -0,0 +1,97 @@
import path from 'node:path';
import type { App } from 'electron';
import { buildType, isDev } from './config';
import { logger } from './logger';
import { uiSubjects } from './ui';
import {
getMainWindow,
openUrlInHiddenWindow,
openUrlInMainWindow,
showMainWindow,
} from './windows-manager';
let protocol = buildType === 'stable' ? 'affine' : `affine-${buildType}`;
if (isDev) {
protocol = 'affine-dev';
}
export function setupDeepLink(app: App) {
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient(protocol, process.execPath, [
path.resolve(process.argv[1]),
]);
}
} else {
app.setAsDefaultProtocolClient(protocol);
}
app.on('open-url', (event, url) => {
if (url.startsWith(`${protocol}://`)) {
event.preventDefault();
handleAffineUrl(url).catch(e => {
logger.error('failed to handle affine url', e);
});
}
});
// on windows & linux, we need to listen for the second-instance event
app.on('second-instance', (event, commandLine) => {
getMainWindow()
.then(window => {
if (!window) {
logger.error('main window is not ready');
return;
}
window.show();
const url = commandLine.pop();
if (url?.startsWith(`${protocol}://`)) {
event.preventDefault();
handleAffineUrl(url).catch(e => {
logger.error('failed to handle affine url', e);
});
}
})
.catch(e => console.error('Failed to restore or create window:', e));
});
}
async function handleAffineUrl(url: string) {
await showMainWindow();
logger.info('open affine url', url);
const urlObj = new URL(url);
if (urlObj.hostname === 'authentication') {
const method = urlObj.searchParams.get('method');
const payload = JSON.parse(urlObj.searchParams.get('payload') ?? 'false');
if (
!method ||
(method !== 'magic-link' && method !== 'oauth') ||
!payload
) {
logger.error('Invalid authentication url', url);
return;
}
uiSubjects.authenticationRequest$.next({
method,
payload,
});
} else {
const hiddenWindow = urlObj.searchParams.get('hidden')
? await openUrlInHiddenWindow(urlObj)
: await openUrlInMainWindow(urlObj);
const main = await getMainWindow();
if (main && hiddenWindow) {
// when hidden window closed, the main window will be hidden somehow
hiddenWindow.on('close', () => {
main.show();
});
}
}
}
@@ -0,0 +1,67 @@
import { app, BrowserWindow, WebContentsView } from 'electron';
import { AFFINE_EVENT_CHANNEL_NAME } from '../shared/type';
import { applicationMenuEvents } from './application-menu';
import { logger } from './logger';
import { sharedStorageEvents } from './shared-storage';
import { uiEvents } from './ui/events';
import { updaterEvents } from './updater/event';
export const allEvents = {
applicationMenu: applicationMenuEvents,
updater: updaterEvents,
ui: uiEvents,
sharedStorage: sharedStorageEvents,
};
function getActiveWindows() {
return BrowserWindow.getAllWindows().filter(win => !win.isDestroyed());
}
export function registerEvents() {
const unsubs: (() => void)[] = [];
// register events
for (const [namespace, namespaceEvents] of Object.entries(allEvents)) {
for (const [key, eventRegister] of Object.entries(namespaceEvents)) {
const unsubscribe = eventRegister((...args: any[]) => {
const chan = `${namespace}:${key}`;
logger.debug(
'[ipc-event]',
chan,
args.filter(
a =>
a !== undefined &&
typeof a !== 'function' &&
typeof a !== 'object'
)
);
// is this efficient?
getActiveWindows().forEach(win => {
if (win.isDestroyed()) {
return;
}
// .webContents could be undefined if the window is destroyed
win.webContents?.send(AFFINE_EVENT_CHANNEL_NAME, chan, ...args);
win.contentView.children.forEach(child => {
if (
child instanceof WebContentsView &&
child.webContents &&
!child.webContents.isDestroyed()
) {
child.webContents?.send(AFFINE_EVENT_CHANNEL_NAME, chan, ...args);
}
});
});
});
unsubs.push(unsubscribe);
}
}
app.on('before-quit', () => {
// subscription on quit sometimes crashes the app
try {
unsubs.forEach(unsub => unsub());
} catch (err) {
logger.error('unsubscribe error', err);
}
});
}
@@ -0,0 +1,10 @@
import type { NamespaceHandlers } from '../type';
import { savePDFFileAs } from './pdf';
export const exportHandlers = {
savePDFFileAs: async (_, title: string) => {
return savePDFFileAs(title);
},
} satisfies NamespaceHandlers;
export * from './pdf';
@@ -0,0 +1,90 @@
import { BrowserWindow, dialog } from 'electron';
import fs from 'fs-extra';
import { logger } from '../logger';
import type { ErrorMessage } from './utils';
import { getFakedResult } from './utils';
export interface SavePDFFileResult {
filePath?: string;
canceled?: boolean;
error?: ErrorMessage;
}
/**
* This function is called when the user clicks the "Export to PDF" button in the electron.
*
* It will just copy the file to the given path
*/
export async function savePDFFileAs(
pageTitle: string
): Promise<SavePDFFileResult> {
try {
const ret =
getFakedResult() ??
(await dialog.showSaveDialog({
properties: ['showOverwriteConfirmation'],
title: 'Save PDF',
showsTagField: false,
buttonLabel: 'Save',
defaultPath: `${pageTitle}.pdf`,
message: 'Save Page as a PDF file',
filters: [{ name: 'PDF Files', extensions: ['pdf'] }],
}));
const filePath = ret.filePath;
if (ret.canceled || !filePath) {
return {
canceled: true,
};
}
await BrowserWindow.getFocusedWindow()
?.webContents.printToPDF({
pageSize: 'A4',
margins: {
bottom: 0.5,
},
printBackground: true,
landscape: false,
displayHeaderFooter: true,
headerTemplate: '<div></div>',
footerTemplate: getFootTemple(),
})
.then(data => {
fs.writeFile(filePath, data, error => {
if (error) throw error;
logger.log(`Wrote PDF successfully to ${filePath}`);
});
});
return { filePath };
} catch (err) {
logger.error('savePDFFileAs', err);
return {
error: 'UNKNOWN_ERROR',
};
}
}
function getFootTemple(): string {
const logo = `
<svg xmlns="http://www.w3.org/2000/svg" width="53" height="12" viewBox="0 0 53 12" fill="none">
<path d="M18.9256 0.709372C18.8749 0.504937 18.6912 0.361572 18.4807 0.361572H17.77C17.5595 0.361572 17.3758 0.504937 17.3252 0.709372L14.9153 10.4283C14.8438 10.7172 15.0621 10.9965 15.3601 10.9965H15.6052C15.8183 10.9965 16.0033 10.8497 16.0513 10.6423L16.5646 8.43721C16.6127 8.22974 16.7976 8.08291 17.0107 8.08291H19.2396C19.4527 8.08291 19.6376 8.22974 19.6857 8.43721L20.199 10.6423C20.247 10.8497 20.432 10.9965 20.6451 10.9965H20.8902C21.1878 10.9965 21.4065 10.7172 21.335 10.4283L18.9251 0.709372H18.9256ZM18.7891 7.0629H17.4616C17.1666 7.0629 16.9483 6.7883 17.0155 6.50113L17.9025 2.23181C17.9575 1.99576 18.2936 1.99576 18.3486 2.23181L19.2357 6.50113C19.3024 6.7883 19.0845 7.0629 18.7896 7.0629H18.7891Z" fill="black" fill-opacity="0.1"/>
<path d="M36.2654 5.00861H30.766C30.5131 5.00861 30.3078 4.8033 30.3078 4.55036V2.25132C30.3078 1.77055 30.6976 1.38074 31.1783 1.38074H33.8997C34.1526 1.38074 34.3579 1.17544 34.3579 0.922494V0.818977C34.3579 0.566031 34.1526 0.36073 33.8997 0.36073H30.8539C29.8924 0.36073 29.1132 1.14036 29.1132 2.10146V5.00774H24.2171C23.9642 5.00774 23.7589 4.80244 23.7589 4.54949V2.25046C23.7589 1.76969 24.1487 1.37988 24.6295 1.37988H27.3508C27.6038 1.37988 27.8091 1.17457 27.8091 0.921628V0.818111C27.8091 0.565165 27.6038 0.359863 27.3508 0.359863H24.3051C23.3435 0.359863 22.5643 1.13949 22.5643 2.1006V10.5366C22.5643 10.7895 22.7696 10.9948 23.0226 10.9948H23.3011C23.554 10.9948 23.7593 10.7895 23.7593 10.5366V6.48513C23.7593 6.23219 23.9646 6.02689 24.2176 6.02689H29.1136V10.5366C29.1136 10.7895 29.3189 10.9948 29.5719 10.9948H29.8504C30.1033 10.9948 30.3086 10.7895 30.3086 10.5366V6.48513C30.3086 6.23219 30.5139 6.02689 30.7669 6.02689H35.9713C36.4521 6.02689 36.8419 6.4167 36.8419 6.89747V10.5418C36.8419 10.7947 37.0472 11 37.3001 11H37.5492C37.8021 11 38.0074 10.7947 38.0074 10.5418V6.74804C38.0074 5.7865 37.2278 5.00731 36.2667 5.00731L36.2654 5.00861Z" fill="black" fill-opacity="0.1"/>
<path d="M45.2871 0.361517H45.0363C44.7838 0.361517 44.5789 0.565953 44.5781 0.818032L44.5504 9.53946L42.0512 0.695024C41.9954 0.497519 41.8156 0.361517 41.6103 0.361517H40.521C40.268 0.361517 40.0627 0.566819 40.0627 0.819765V10.5387C40.0627 10.7916 40.268 10.9969 40.521 10.9969H40.7718C41.0243 10.9969 41.2292 10.7925 41.23 10.5404L41.2577 1.81899L43.7569 10.6634C43.8128 10.8609 43.9925 10.9969 44.1978 10.9969H45.2871C45.5401 10.9969 45.7454 10.7916 45.7454 10.5387V0.819331C45.7454 0.566386 45.5401 0.361084 45.2871 0.361084V0.361517Z" fill="black" fill-opacity="0.1"/>
<path d="M49.2307 1.3811H51.8212C52.0741 1.3811 52.2794 1.17579 52.2794 0.922849V0.819331C52.2794 0.566386 52.0741 0.361084 51.8212 0.361084H48.9214C47.9599 0.361084 47.1807 1.14071 47.1807 2.10182V9.25489C47.1807 10.2164 47.9603 10.9956 48.9214 10.9956H51.8212C52.0741 10.9956 52.2794 10.7903 52.2794 10.5374V10.4339C52.2794 10.1809 52.0741 9.97562 51.8212 9.97562H49.2307C48.7499 9.97562 48.3601 9.5858 48.3601 9.10503V6.33996C48.3601 6.08701 48.5654 5.88171 48.8183 5.88171H51.6752C51.9282 5.88171 52.1335 5.67641 52.1335 5.42346V5.31994C52.1335 5.067 51.9282 4.8617 51.6752 4.8617H48.8183C48.5654 4.8617 48.3601 4.65639 48.3601 4.40345V2.24995C48.3601 1.76918 48.7499 1.37936 49.2307 1.37936V1.3811Z" fill="black" fill-opacity="0.1"/>
<path d="M37.3088 1.65787C37.1052 1.4543 36.7583 1.54742 36.6838 1.82549L36.3473 3.08199C36.2728 3.35962 36.527 3.61387 36.8051 3.5398L38.0616 3.20326C38.3396 3.12876 38.4323 2.7814 38.2292 2.57826L37.3097 1.65873L37.3088 1.65787Z" fill="black" fill-opacity="0.1"/>
<path d="M11.9043 9.92891C11.8624 9.85125 11.3139 8.91339 11.2674 8.82602C9.92288 6.49855 7.98407 3.13718 6.64195 0.814557C6.37775 0.29657 5.6448 0.286862 5.36882 0.795141C3.92685 3.2932 1.71414 7.12436 0.274242 9.6193C0.214607 9.72505 0.118915 9.88037 0.0617076 9.99687C-0.035025 10.2063 -0.0163025 10.4677 0.10574 10.6608C0.238877 10.8858 0.502032 11.0165 0.759985 11.0027H0.8269C2.06986 11.0009 9.86983 11.0048 11.2844 11.0027C11.8329 11.0041 12.1779 10.4022 11.904 9.92926L11.9043 9.92891ZM6.09068 1.66053C6.91793 3.09661 7.8967 4.79099 8.85535 6.45036C8.47016 6.21355 8.05792 6.02875 7.6169 5.91711C7.49763 5.89007 7.04136 5.83529 6.94289 5.83113C6.9207 5.82939 6.89851 5.82835 6.87598 5.82835H5.12474C4.97288 5.82835 4.82622 5.86753 4.69655 5.93757C4.53325 5.14845 4.68199 4.26468 4.8914 3.51683C4.90978 3.45269 4.92954 3.38889 4.9493 3.3251C5.29636 2.7239 5.62366 2.15737 5.91073 1.65984C5.95095 1.5905 6.0508 1.59084 6.09068 1.65984V1.66053ZM6.15412 8.25707C6.15412 8.25707 6.12327 8.30492 6.09692 8.34583C6.07161 8.37079 6.03728 8.38535 5.99984 8.38535C5.94956 8.38535 5.90484 8.35935 5.87988 8.31601L5.03841 6.85809C5.03841 6.85809 5.0124 6.80747 4.98987 6.76413C4.98085 6.72946 4.98571 6.69271 5.00408 6.66046C5.02905 6.61712 5.07412 6.59112 5.12404 6.59112H6.80733C6.80733 6.59112 6.86419 6.59389 6.91308 6.59632C6.9474 6.60568 6.97722 6.62822 6.99559 6.66046C7.02056 6.7038 7.02056 6.75581 6.99559 6.79915L6.15378 8.25707H6.15412ZM1.12681 9.94521C1.30502 9.63733 1.53766 9.23653 1.5914 9.14084C2.1971 8.09169 3.04585 6.62163 3.89252 5.15539C3.88004 5.60715 3.92616 6.05684 4.04993 6.49473C4.08599 6.61158 4.26697 7.03422 4.31274 7.12159C4.32591 7.14967 4.34256 7.17914 4.35851 7.20619L5.21939 8.69739C5.29532 8.8288 5.40245 8.93628 5.52796 9.01359C4.92607 9.54961 4.08634 9.86269 3.33397 10.0551C3.27191 10.0707 3.20916 10.0849 3.14675 10.0988C2.34827 10.0988 1.66837 10.0995 1.21695 10.1009C1.13651 10.1009 1.08659 10.0142 1.12681 9.94486V9.94521ZM10.7834 10.1016C9.65661 10.1026 7.37212 10.1005 5.25475 10.0995C5.65139 9.88453 6.01683 9.62034 6.33337 9.29478C6.41624 9.20498 6.69222 8.83712 6.74492 8.75391C6.7626 8.72825 6.77994 8.69913 6.79519 8.67208L7.65608 7.18088C7.73201 7.04947 7.77153 6.90281 7.77569 6.75546C8.54089 7.00856 9.23188 7.57925 9.77449 8.13468C9.82129 8.18322 9.86706 8.23245 9.91248 8.28203C10.2464 8.86035 10.5695 9.41994 10.8729 9.9459C10.9127 10.0152 10.8632 10.1019 10.7831 10.1019L10.7834 10.1016Z" fill="black" fill-opacity="0.1"/>
</svg>
`;
const footerTemp = `
<div style="font-size: 14px; width: 100%; display: flex; justify-content: flex-end; margin-right: 40px;">
<a href="https://affine.pro" style="display: flex; text-decoration: none; color: rgba(0, 0, 0, 0.1);">
<span>Created with</span>
<div style="display: flex; align-items: center;">${logo}</div>
</a>
</div>
`;
return footerTemp;
}
@@ -0,0 +1,24 @@
// provide a backdoor to set dialog path for testing in playwright
interface FakeDialogResult {
canceled?: boolean;
filePath?: string;
filePaths?: string[];
}
// result will be used in the next call to showOpenDialog
// if it is being read once, it will be reset to undefined
let fakeDialogResult: FakeDialogResult | undefined = undefined;
export function getFakedResult() {
const result = fakeDialogResult;
fakeDialogResult = undefined;
return result;
}
export function setFakeDialogResult(result: FakeDialogResult | undefined) {
fakeDialogResult = result;
// for convenience, we will fill filePaths with filePath if it is not set
if (result?.filePaths === undefined && result?.filePath !== undefined) {
result.filePaths = [result.filePath];
}
}
const ErrorMessages = ['FILE_ALREADY_EXISTS', 'UNKNOWN_ERROR'] as const;
export type ErrorMessage = (typeof ErrorMessages)[number];
@@ -0,0 +1,29 @@
import { allEvents as events } from './events';
import { allHandlers as handlers } from './handlers';
// this will be used by preload script to expose all handlers and events to the renderer process
// - register in exposeInMainWorld in preload
// - provide type hints
export { events, handlers };
export const getExposedMeta = () => {
const handlersMeta = Object.entries(handlers).map(
([namespace, namespaceHandlers]) => {
return [namespace, Object.keys(namespaceHandlers)];
}
);
const eventsMeta = Object.entries(events).map(
([namespace, namespaceHandlers]) => {
return [namespace, Object.keys(namespaceHandlers)];
}
);
return {
handlers: handlersMeta,
events: eventsMeta,
};
};
export type MainIPCHandlerMap = typeof handlers;
export type MainIPCEventMap = typeof events;
@@ -0,0 +1,19 @@
import type { NamespaceHandlers } from '../type';
export const findInPageHandlers = {
find: async (event, text: string, options?: Electron.FindInPageOptions) => {
const { promise, resolve } =
Promise.withResolvers<Electron.Result | null>();
const webContents = event.sender;
let requestId: number = -1;
webContents.once('found-in-page', (_, result) => {
resolve(result.requestId === requestId ? result : null);
});
requestId = webContents.findInPage(text, options);
return promise;
},
clear: async event => {
const webContents = event.sender;
webContents.stopFindInPage('keepSelection');
},
} satisfies NamespaceHandlers;
@@ -0,0 +1 @@
export * from './handlers';
@@ -0,0 +1,91 @@
import { ipcMain } from 'electron';
import { AFFINE_API_CHANNEL_NAME } from '../shared/type';
import { clipboardHandlers } from './clipboard';
import { configStorageHandlers } from './config-storage';
import { exportHandlers } from './export';
import { findInPageHandlers } from './find-in-page';
import { getLogFilePath, logger, revealLogFile } from './logger';
import { sharedStorageHandlers } from './shared-storage';
import { uiHandlers } from './ui/handlers';
import { updaterHandlers } from './updater';
export const debugHandlers = {
revealLogFile: async () => {
return revealLogFile();
},
logFilePath: async () => {
return getLogFilePath();
},
};
// Note: all of these handlers will be the single-source-of-truth for the apis exposed to the renderer process
export const allHandlers = {
debug: debugHandlers,
ui: uiHandlers,
clipboard: clipboardHandlers,
export: exportHandlers,
updater: updaterHandlers,
configStorage: configStorageHandlers,
findInPage: findInPageHandlers,
sharedStorage: sharedStorageHandlers,
};
export const registerHandlers = () => {
const handleIpcMessage = async (
e: Electron.IpcMainInvokeEvent,
...args: any[]
) => {
// args[0] is the `{namespace:key}`
if (typeof args[0] !== 'string') {
logger.error('invalid ipc message', args);
return;
}
const channel = args[0] as string;
const [namespace, key] = channel.split(':');
if (!namespace || !key) {
logger.error('invalid ipc message', args);
return;
}
// @ts-expect-error - ignore here
const handler = allHandlers[namespace]?.[key];
if (!handler) {
logger.error('handler not found for ', args[0]);
return;
}
const start = Date.now();
const realArgs = args.slice(1);
const result = await handler(e, ...realArgs);
logger.debug(
'[ipc-api]',
channel,
realArgs.filter(
arg => typeof arg !== 'function' && typeof arg !== 'object'
),
'-',
Date.now() - start,
'ms'
);
return result;
};
ipcMain.handle(AFFINE_API_CHANNEL_NAME, async (e, ...args: any[]) => {
return handleIpcMessage(e, ...args);
});
ipcMain.on(AFFINE_API_CHANNEL_NAME, (e, ...args: any[]) => {
handleIpcMessage(e, ...args)
.then(ret => {
e.returnValue = ret;
})
.catch(() => {
// never throw
});
});
};
@@ -0,0 +1,120 @@
import path from 'node:path';
import type { _AsyncVersionOf } from 'async-call-rpc';
import { AsyncCall } from 'async-call-rpc';
import type { UtilityProcess, WebContents } from 'electron';
import {
app,
dialog,
MessageChannelMain,
shell,
utilityProcess,
} from 'electron';
import type { HelperToMain, MainToHelper } from '../shared/type';
import { MessageEventChannel } from '../shared/utils';
import { logger } from './logger';
const HELPER_PROCESS_PATH = path.join(__dirname, './helper.js');
const isDev = process.env.NODE_ENV === 'development';
function pickAndBind<T extends object, U extends keyof T>(
obj: T,
keys: U[]
): { [K in U]: T[K] } {
return keys.reduce((acc, key) => {
const prop = obj[key];
acc[key] = typeof prop === 'function' ? prop.bind(obj) : prop;
return acc;
}, {} as any);
}
class HelperProcessManager {
ready: Promise<void>;
readonly #process: UtilityProcess;
// a rpc server for the main process -> helper process
rpc?: _AsyncVersionOf<HelperToMain>;
static _instance: HelperProcessManager | null = null;
static get instance() {
if (!this._instance) {
this._instance = new HelperProcessManager();
}
return this._instance;
}
private constructor() {
const helperProcess = utilityProcess.fork(HELPER_PROCESS_PATH, [], {
// todo: port number should not being used
execArgv: isDev ? ['--inspect=40894'] : [],
});
this.#process = helperProcess;
this.ready = new Promise((resolve, reject) => {
helperProcess.once('spawn', () => {
try {
this.#connectMain();
logger.info('[helper] forked', helperProcess.pid);
resolve();
} catch (err) {
logger.error('[helper] connectMain error', err);
reject(err);
}
});
});
app.on('before-quit', () => {
this.#process.kill();
});
}
// bridge renderer <-> helper process
connectRenderer(renderer: WebContents) {
// connect to the helper process
const { port1: helperPort, port2: rendererPort } = new MessageChannelMain();
this.#process.postMessage({ channel: 'renderer-connect' }, [helperPort]);
renderer.postMessage('helper-connection', null, [rendererPort]);
return () => {
helperPort.close();
rendererPort.close();
};
}
// bridge main <-> helper process
// also set up the RPC to the helper process
#connectMain() {
const dialogMethods = pickAndBind(dialog, [
'showOpenDialog',
'showSaveDialog',
]);
const shellMethods = pickAndBind(shell, [
'openExternal',
'showItemInFolder',
]);
const appMethods = pickAndBind(app, ['getPath']);
const mainToHelperServer: MainToHelper = {
...dialogMethods,
...shellMethods,
...appMethods,
};
this.rpc = AsyncCall<HelperToMain>(mainToHelperServer, {
strict: {
// the channel is shared for other purposes as well so that we do not want to
// restrict to only JSONRPC messages
unknownMessage: false,
},
channel: new MessageEventChannel(this.#process),
});
}
}
export async function ensureHelperProcess() {
const helperProcessManager = HelperProcessManager.instance;
await helperProcessManager.ready;
return helperProcessManager;
}
@@ -0,0 +1,95 @@
import './security-restrictions';
import path from 'node:path';
import * as Sentry from '@sentry/electron/main';
import { IPCMode } from '@sentry/electron/main';
import { app } from 'electron';
import { createApplicationMenu } from './application-menu/create';
import { buildType, overrideSession } from './config';
import { persistentConfig } from './config-storage/persist';
import { setupDeepLink } from './deep-link';
import { registerEvents } from './events';
import { registerHandlers } from './handlers';
import { logger } from './logger';
import { registerProtocol } from './protocol';
import { isOnline } from './ui';
import { registerUpdater } from './updater';
import { launch } from './windows-manager/launcher';
import { launchStage } from './windows-manager/stage';
app.enableSandbox();
app.commandLine.appendSwitch('enable-features', 'CSSTextAutoSpace');
// use the same data for internal & beta for testing
if (overrideSession) {
const appName = buildType === 'stable' ? 'AFFiNE' : `AFFiNE-${buildType}`;
const userDataPath = path.join(app.getPath('appData'), appName);
app.setPath('userData', userDataPath);
app.setPath('sessionData', userDataPath);
}
if (require('electron-squirrel-startup')) app.quit();
if (process.env.SKIP_ONBOARDING) {
launchStage.value = 'main';
persistentConfig.set({
onBoarding: false,
});
}
/**
* Prevent multiple instances
*/
const isSingleInstance = app.requestSingleInstanceLock();
if (!isSingleInstance) {
logger.info('Another instance is running, exiting...');
app.quit();
process.exit(0);
}
/**
* Shout down background process if all windows was closed
*/
app.on('window-all-closed', () => {
app.quit();
});
/**
* @see https://www.electronjs.org/docs/latest/api/app#event-activate-macos Event: 'activate'
*/
app.on('activate', () => {
launch().catch(e => console.error('Failed launch:', e));
});
setupDeepLink(app);
/**
* Create app window when background process will be ready
*/
app
.whenReady()
.then(registerProtocol)
.then(registerHandlers)
.then(registerEvents)
.then(launch)
.then(createApplicationMenu)
.then(registerUpdater)
.catch(e => console.error('Failed create window:', e));
if (process.env.SENTRY_RELEASE) {
// https://docs.sentry.io/platforms/javascript/guides/electron/
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.BUILD_TYPE ?? 'development',
ipcMode: IPCMode.Protocol,
transportOptions: {
maxAgeDays: 30,
maxQueueSize: 100,
shouldStore: () => !isOnline,
shouldSend: () => isOnline,
},
});
}
@@ -0,0 +1,22 @@
import { app, shell } from 'electron';
import log from 'electron-log/main';
export const logger = log.scope('main');
log.initialize({
preload: false,
});
log.transports.file.level = 'info';
export function getLogFilePath() {
return log.transports.file.getFile().path;
}
export async function revealLogFile() {
const filePath = getLogFilePath();
return await shell.openPath(filePath);
}
app.on('before-quit', () => {
log.transports.console.level = false;
});
@@ -0,0 +1,161 @@
import { join } from 'node:path';
import { net, protocol, session } from 'electron';
import { CLOUD_BASE_URL } from './config';
import { logger } from './logger';
import { isOfflineModeEnabled } from './utils';
import { getCookies } from './windows-manager';
protocol.registerSchemesAsPrivileged([
{
scheme: 'assets',
privileges: {
secure: false,
corsEnabled: true,
supportFetchAPI: true,
standard: true,
bypassCSP: true,
},
},
]);
protocol.registerSchemesAsPrivileged([
{
scheme: 'file',
privileges: {
secure: false,
corsEnabled: true,
supportFetchAPI: true,
standard: true,
bypassCSP: true,
stream: true,
},
},
]);
const NETWORK_REQUESTS = ['/api', '/ws', '/socket.io', '/graphql'];
const webStaticDir = join(__dirname, '../resources/web-static');
function isNetworkResource(pathname: string) {
return NETWORK_REQUESTS.some(opt => pathname.startsWith(opt));
}
async function handleFileRequest(request: Request) {
const clonedRequest = Object.assign(request.clone(), {
bypassCustomProtocolHandlers: true,
});
const urlObject = new URL(request.url);
if (isNetworkResource(urlObject.pathname)) {
// just pass through (proxy)
return net.fetch(
CLOUD_BASE_URL + urlObject.pathname + urlObject.search,
clonedRequest
);
} else {
// this will be file types (in the web-static folder)
let filepath = '';
// if is a file type, load the file in resources
if (urlObject.pathname.split('/').at(-1)?.includes('.')) {
filepath = join(webStaticDir, decodeURIComponent(urlObject.pathname));
} else {
// else, fallback to load the index.html instead
filepath = join(webStaticDir, 'index.html');
}
return net.fetch('file://' + filepath, clonedRequest);
}
}
export function registerProtocol() {
protocol.handle('file', request => {
return handleFileRequest(request);
});
protocol.handle('assets', request => {
return handleFileRequest(request);
});
// hack for CORS
// todo: should use a whitelist
session.defaultSession.webRequest.onHeadersReceived(
(responseDetails, callback) => {
const { responseHeaders } = responseDetails;
if (responseHeaders) {
// replace SameSite=Lax with SameSite=None
const originalCookie =
responseHeaders['set-cookie'] || responseHeaders['Set-Cookie'];
if (originalCookie) {
delete responseHeaders['set-cookie'];
delete responseHeaders['Set-Cookie'];
responseHeaders['Set-Cookie'] = originalCookie.map(cookie => {
let newCookie = cookie.replace(/SameSite=Lax/gi, 'SameSite=None');
// if the cookie is not secure, set it to secure
if (!newCookie.includes('Secure')) {
newCookie = newCookie + '; Secure';
}
return newCookie;
});
}
}
callback({ responseHeaders });
}
);
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
const url = new URL(details.url);
const pathname = url.pathname;
const protocol = url.protocol;
const origin = url.origin;
const sameOrigin = origin === CLOUD_BASE_URL || protocol === 'file:';
// offline whitelist
// 1. do not block non-api request for http://localhost || file:// (local dev assets)
// 2. do not block devtools
// 3. block all other requests
const blocked = (() => {
if (!isOfflineModeEnabled()) {
return false;
}
if (
(protocol === 'file:' || origin.startsWith('http://localhost')) &&
!isNetworkResource(pathname)
) {
return false;
}
if ('devtools:' === protocol) {
return false;
}
return true;
})();
if (blocked) {
logger.debug('blocked request', details.url);
callback({
cancel: true,
});
return;
}
// session cookies are set to file:// on production
// if sending request to the cloud, attach the session cookie (to affine cloud server)
if (isNetworkResource(pathname) && sameOrigin) {
const cookie = getCookies();
if (cookie) {
const cookieString = cookie.map(c => `${c.name}=${c.value}`).join('; ');
details.requestHeaders['cookie'] = cookieString;
}
// add the referer and origin headers
details.requestHeaders['referer'] ??= CLOUD_BASE_URL;
details.requestHeaders['origin'] ??= CLOUD_BASE_URL;
}
callback({
cancel: false,
requestHeaders: details.requestHeaders,
});
});
}
@@ -0,0 +1,47 @@
import { app, shell } from 'electron';
app.on('web-contents-created', (_, contents) => {
const isInternalUrl = (url: string) => {
return (
(process.env.DEV_SERVER_URL &&
url.startsWith(process.env.DEV_SERVER_URL)) ||
url.startsWith('affine://') ||
url.startsWith('file://.')
);
};
/**
* Block navigation to origins not on the allowlist.
*
* Navigation is a common attack vector. If an attacker can convince the app to navigate away
* from its current page, they can possibly force the app to open web sites on the Internet.
*
* @see https://www.electronjs.org/docs/latest/tutorial/security#13-disable-or-limit-navigation
*/
contents.on('will-navigate', (event, url) => {
if (isInternalUrl(url)) {
return;
}
// Prevent navigation
event.preventDefault();
shell.openExternal(url).catch(console.error);
});
/**
* Hyperlinks to allowed sites open in the default browser.
*
* The creation of new `webContents` is a common attack vector. Attackers attempt to convince the app to create new windows,
* frames, or other renderer processes with more privileges than they had before; or with pages opened that they couldn't open before.
* You should deny any unexpected window creation.
*
* @see https://www.electronjs.org/docs/latest/tutorial/security#14-disable-or-limit-creation-of-new-windows
* @see https://www.electronjs.org/docs/latest/tutorial/security#15-do-not-use-openexternal-with-untrusted-content
*/
contents.setWindowOpenHandler(({ url }) => {
if (!isInternalUrl(url) || url.includes('/redirect-proxy')) {
// Open default browser
shell.openExternal(url).catch(console.error);
}
// Prevent creating new window in application
return { action: 'deny' };
});
});
@@ -0,0 +1,25 @@
import type { MainEventRegister } from '../type';
import { globalCacheStorage, globalStateStorage } from './storage';
export const sharedStorageEvents = {
onGlobalStateChanged: (
fn: (state: Record<string, unknown | undefined>) => void
) => {
const subscription = globalStateStorage.watchAll().subscribe(updates => {
fn(updates);
});
return () => {
subscription.unsubscribe();
};
},
onGlobalCacheChanged: (
fn: (state: Record<string, unknown | undefined>) => void
) => {
const subscription = globalCacheStorage.watchAll().subscribe(updates => {
fn(updates);
});
return () => {
subscription.unsubscribe();
};
},
} satisfies Record<string, MainEventRegister>;
@@ -0,0 +1,29 @@
import type { NamespaceHandlers } from '../type';
import { globalCacheStorage, globalStateStorage } from './storage';
export const sharedStorageHandlers = {
getAllGlobalState: async () => {
return globalStateStorage.all();
},
getAllGlobalCache: async () => {
return globalCacheStorage.all();
},
setGlobalState: async (_, key: string, value: any) => {
return globalStateStorage.set(key, value);
},
delGlobalState: async (_, key: string) => {
return globalStateStorage.del(key);
},
clearGlobalState: async () => {
return globalStateStorage.clear();
},
setGlobalCache: async (_, key: string, value: any) => {
return globalCacheStorage.set(key, value);
},
delGlobalCache: async (_, key: string) => {
return globalCacheStorage.del(key);
},
clearGlobalCache: async () => {
return globalCacheStorage.clear();
},
} satisfies NamespaceHandlers;
@@ -0,0 +1,2 @@
export { sharedStorageEvents } from './events';
export { sharedStorageHandlers } from './handlers';
@@ -0,0 +1,139 @@
import fs from 'node:fs';
import type { Memento } from '@toeverything/infra';
import {
backoffRetry,
effect,
exhaustMapWithTrailing,
fromPromise,
} from '@toeverything/infra';
import { debounceTime, EMPTY, mergeMap, Observable, timeout } from 'rxjs';
import { logger } from '../logger';
export class PersistentJSONFileStorage implements Memento {
data: Record<string, any> = {};
subscriptions: Map<string, Set<(p: any) => void>> = new Map();
subscriptionAll: Set<(p: Record<string, any>) => void> = new Set();
constructor(readonly filepath: string) {
try {
this.data = JSON.parse(fs.readFileSync(filepath, 'utf-8'));
} catch (err) {
// ignore ENOENT error
if (
!(
err &&
typeof err === 'object' &&
'code' in err &&
err.code === 'ENOENT'
)
) {
logger.error('failed to load file', err);
}
}
}
get<T>(key: string): T | undefined {
return this.data[key];
}
all(): Record<string, any> {
return this.data;
}
watch<T>(key: string): Observable<T | undefined> {
const subs = this.subscriptions.get(key) || new Set();
this.subscriptions.set(key, subs);
return new Observable<T | undefined>(subscriber => {
const sub = (p: any) => subscriber.next(p);
subs.add(sub);
return () => {
subs.delete(sub);
};
});
}
watchAll(): Observable<Record<string, unknown | undefined>> {
return new Observable<Record<string, unknown | undefined>>(subscriber => {
const sub = (p: Record<string, unknown | undefined>) =>
subscriber.next(p);
this.subscriptionAll.add(sub);
return () => {
this.subscriptionAll.delete(sub);
};
});
}
set<T>(key: string, value: T): void {
this.data[key] = value;
const subs = this.subscriptions.get(key) || new Set();
for (const sub of subs) {
sub(value);
}
for (const sub of this.subscriptionAll) {
sub({
[key]: this.data[key],
});
}
this.save();
}
del(key: string): void {
delete this.data[key];
const subs = this.subscriptions.get(key) || new Set();
for (const sub of subs) {
sub(undefined);
}
for (const sub of this.subscriptionAll) {
sub({
[key]: undefined,
});
}
this.save();
}
clear(): void {
const oldData = this.data;
this.data = {};
for (const [_, subs] of this.subscriptions) {
for (const sub of subs) {
sub(undefined);
}
}
for (const sub of this.subscriptionAll) {
sub(
Object.fromEntries(
Object.entries(oldData).map(([key]) => [key, undefined])
)
);
}
this.save();
}
keys(): string[] {
return Object.keys(this.data);
}
save = effect(
debounceTime(1000),
exhaustMapWithTrailing(() => {
return fromPromise(async () => {
try {
await fs.promises.writeFile(
this.filepath,
JSON.stringify(this.data, null, 2),
'utf-8'
);
} catch (err) {
logger.error(`failed to save file, ${this.filepath}`, err);
}
}).pipe(
timeout(5000),
backoffRetry({
count: Infinity,
}),
mergeMap(() => EMPTY)
);
})
);
dispose() {
this.save.unsubscribe();
}
}
@@ -0,0 +1,13 @@
import path from 'node:path';
import { app } from 'electron';
import { PersistentJSONFileStorage } from './json-file';
export const globalStateStorage = new PersistentJSONFileStorage(
path.join(app.getPath('userData'), 'global-state.json')
);
export const globalCacheStorage = new PersistentJSONFileStorage(
path.join(app.getPath('userData'), 'global-cache.json')
);
@@ -0,0 +1,10 @@
export type MainEventRegister = (...args: any[]) => () => void;
export type IsomorphicHandler = (
e: Electron.IpcMainInvokeEvent,
...args: any[]
) => Promise<any>;
export type NamespaceHandlers = {
[key: string]: IsomorphicHandler;
};
@@ -0,0 +1,7 @@
import { mintChallengeResponse } from '@affine/native';
export const getChallengeResponse = async (resource: string) => {
// 20 bits challenge is a balance between security and user experience
// 20 bits challenge cost time is about 1-3s on m2 macbook air
return mintChallengeResponse(resource, 20);
};
@@ -0,0 +1,45 @@
import type { MainEventRegister } from '../type';
import {
type AuthenticationRequest,
onActiveTabChanged,
onTabAction,
onTabShellViewActiveChange,
onTabsStatusChange,
onTabViewsMetaChanged,
} from '../windows-manager';
import { uiSubjects } from './subject';
/**
* Events triggered by application menu
*/
export const uiEvents = {
onMaximized: (fn: (maximized: boolean) => void) => {
const sub = uiSubjects.onMaximized$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
onFullScreen: (fn: (fullScreen: boolean) => void) => {
const sub = uiSubjects.onFullScreen$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
onTabViewsMetaChanged,
onTabAction,
onToggleRightSidebar: (fn: (tabId: string) => void) => {
const sub = uiSubjects.onToggleRightSidebar$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
onTabsStatusChange,
onActiveTabChanged,
onTabShellViewActiveChange,
onAuthenticationRequest: (fn: (state: AuthenticationRequest) => void) => {
const sub = uiSubjects.authenticationRequest$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
} satisfies Record<string, MainEventRegister>;
@@ -0,0 +1,214 @@
import { app, nativeTheme, shell } from 'electron';
import { getLinkPreview } from 'link-preview-js';
import { persistentConfig } from '../config-storage/persist';
import { logger } from '../logger';
import type { NamespaceHandlers } from '../type';
import {
activateView,
addTab,
closeTab,
getMainWindow,
getOnboardingWindow,
getTabsStatus,
getTabViewsMeta,
getWorkbenchMeta,
handleWebContentsResize,
initAndShowMainWindow,
isActiveTab,
launchStage,
moveTab,
pingAppLayoutReady,
showDevTools,
showTab,
updateWorkbenchMeta,
updateWorkbenchViewMeta,
} from '../windows-manager';
import { showTabContextMenu } from '../windows-manager/context-menu';
import { getOrCreateCustomThemeWindow } from '../windows-manager/custom-theme-window';
import { getChallengeResponse } from './challenge';
import { uiSubjects } from './subject';
export let isOnline = true;
export const uiHandlers = {
isMaximized: async () => {
const window = await getMainWindow();
return window?.isMaximized();
},
isFullScreen: async () => {
const window = await getMainWindow();
return window?.isFullScreen();
},
handleThemeChange: async (_, theme: (typeof nativeTheme)['themeSource']) => {
nativeTheme.themeSource = theme;
},
handleMinimizeApp: async () => {
const window = await getMainWindow();
window?.minimize();
},
handleMaximizeApp: async () => {
const window = await getMainWindow();
if (!window) {
return;
}
// allow unmaximize when in full screen mode
if (window.isFullScreen()) {
window.setFullScreen(false);
window.unmaximize();
} else if (window.isMaximized()) {
window.unmaximize();
} else {
window.maximize();
}
},
handleWindowResize: async e => {
await handleWebContentsResize(e.sender);
},
handleCloseApp: async () => {
app.quit();
},
handleNetworkChange: async (_, _isOnline: boolean) => {
isOnline = _isOnline;
},
getChallengeResponse: async (_, challenge: string) => {
return getChallengeResponse(challenge);
},
handleOpenMainApp: async () => {
if (launchStage.value === 'onboarding') {
launchStage.value = 'main';
persistentConfig.patch('onBoarding', false);
}
try {
const onboarding = await getOnboardingWindow();
onboarding?.hide();
await initAndShowMainWindow();
// need to destroy onboarding window after main window is ready
// otherwise the main window will be closed as well
onboarding?.destroy();
} catch (err) {
logger.error('handleOpenMainApp', err);
}
},
getBookmarkDataByLink: async (_, link: string) => {
if (
(link.startsWith('https://x.com/') ||
link.startsWith('https://www.x.com/') ||
link.startsWith('https://www.twitter.com/') ||
link.startsWith('https://twitter.com/')) &&
link.includes('/status/')
) {
// use api.fxtwitter.com
link =
'https://api.fxtwitter.com/status/' + /\/status\/(.*)/.exec(link)?.[1];
try {
const { tweet } = await fetch(link).then(res => res.json());
return {
title: tweet.author.name,
icon: tweet.author.avatar_url,
description: tweet.text,
image: tweet.media?.photos[0].url || tweet.author.banner_url,
};
} catch (err) {
logger.error('getBookmarkDataByLink', err);
return {
title: undefined,
description: undefined,
icon: undefined,
image: undefined,
};
}
} else {
const previewData = (await getLinkPreview(link, {
timeout: 6000,
headers: {
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36',
},
followRedirects: 'follow',
}).catch(() => {
return {
title: '',
siteName: '',
description: '',
images: [],
videos: [],
contentType: `text/html`,
favicons: [],
};
})) as any;
return {
title: previewData.title,
description: previewData.description,
icon: previewData.favicons[0],
image: previewData.images[0],
};
}
},
openExternal(_, url: string) {
return shell.openExternal(url);
},
// tab handlers
isActiveTab: async e => {
return isActiveTab(e.sender);
},
getWorkbenchMeta: async (_, ...args: Parameters<typeof getWorkbenchMeta>) => {
return getWorkbenchMeta(...args);
},
updateWorkbenchMeta: async (
_,
...args: Parameters<typeof updateWorkbenchMeta>
) => {
return updateWorkbenchMeta(...args);
},
updateWorkbenchViewMeta: async (
_,
...args: Parameters<typeof updateWorkbenchViewMeta>
) => {
return updateWorkbenchViewMeta(...args);
},
getTabViewsMeta: async () => {
return getTabViewsMeta();
},
getTabsStatus: async () => {
return getTabsStatus();
},
addTab: async (_, ...args: Parameters<typeof addTab>) => {
await addTab(...args);
},
showTab: async (_, ...args: Parameters<typeof showTab>) => {
await showTab(...args);
},
closeTab: async (_, ...args: Parameters<typeof closeTab>) => {
await closeTab(...args);
},
activateView: async (_, ...args: Parameters<typeof activateView>) => {
await activateView(...args);
},
moveTab: async (_, ...args: Parameters<typeof moveTab>) => {
moveTab(...args);
},
toggleRightSidebar: async (_, tabId?: string) => {
tabId ??= getTabViewsMeta().activeWorkbenchId;
if (tabId) {
uiSubjects.onToggleRightSidebar$.next(tabId);
}
},
pingAppLayoutReady: async e => {
pingAppLayoutReady(e.sender);
},
showDevTools: async (_, ...args: Parameters<typeof showDevTools>) => {
return showDevTools(...args);
},
showTabContextMenu: async (_, tabKey: string, viewIndex: number) => {
return showTabContextMenu(tabKey, viewIndex);
},
openThemeEditor: async () => {
const win = await getOrCreateCustomThemeWindow();
win.show();
win.focus();
},
} satisfies NamespaceHandlers;
@@ -0,0 +1,3 @@
export * from './events';
export * from './handlers';
export * from './subject';
@@ -0,0 +1,10 @@
import { Subject } from 'rxjs';
import type { AuthenticationRequest } from '../windows-manager';
export const uiSubjects = {
onMaximized$: new Subject<boolean>(),
onFullScreen$: new Subject<boolean>(),
onToggleRightSidebar$: new Subject<string>(),
authenticationRequest$: new Subject<AuthenticationRequest>(),
};
@@ -0,0 +1,162 @@
// credits: migrated from https://github.com/electron-userland/electron-builder/blob/master/packages/electron-updater/src/providers/GitHubProvider.ts
import type { CustomPublishOptions } from 'builder-util-runtime';
import { newError } from 'builder-util-runtime';
import type {
AppUpdater,
ResolvedUpdateFileInfo,
UpdateFileInfo,
UpdateInfo,
} from 'electron-updater';
import { CancellationToken, Provider } from 'electron-updater';
import type { ProviderRuntimeOptions } from 'electron-updater/out/providers/Provider';
import {
getFileList,
parseUpdateInfo,
} from 'electron-updater/out/providers/Provider';
import type { buildType } from '../config';
import { isSquirrelBuild } from './utils';
interface GithubUpdateInfo extends UpdateInfo {
tag: string;
}
interface GithubRelease {
name: string;
tag_name: string;
published_at: string;
assets: Array<{
name: string;
url: string;
}>;
}
interface UpdateProviderOptions {
feedUrl?: string;
channel: typeof buildType;
}
export class AFFiNEUpdateProvider extends Provider<GithubUpdateInfo> {
static configFeed(options: UpdateProviderOptions): CustomPublishOptions {
return {
provider: 'custom',
feedUrl: 'https://affine.pro/api/worker/releases',
updateProvider: AFFiNEUpdateProvider,
...options,
};
}
constructor(
private readonly options: CustomPublishOptions,
_updater: AppUpdater,
runtimeOptions: ProviderRuntimeOptions
) {
super(runtimeOptions);
}
get feedUrl(): URL {
const url = new URL(this.options.feedUrl);
url.searchParams.set('channel', this.options.channel);
url.searchParams.set('minimal', 'true');
return url;
}
async getLatestVersion(): Promise<GithubUpdateInfo> {
const cancellationToken = new CancellationToken();
const releasesJsonStr = await this.httpRequest(
this.feedUrl,
{
accept: 'application/json',
'cache-control': 'no-cache',
},
cancellationToken
);
if (!releasesJsonStr) {
throw new Error(
`Failed to get releases from ${this.feedUrl.toString()}, response is empty`
);
}
const releases = JSON.parse(releasesJsonStr);
if (releases.length === 0) {
throw new Error(
`No published versions in channel ${this.options.channel}`
);
}
const latestRelease = releases[0] as GithubRelease;
const tag = latestRelease.tag_name;
const channelFileName = getChannelFilename(this.getDefaultChannelName());
const channelFileAsset = latestRelease.assets.find(({ url }) =>
url.endsWith(channelFileName)
);
if (!channelFileAsset) {
throw newError(
`Cannot find ${channelFileName} in the latest release artifacts.`,
'ERR_UPDATER_CHANNEL_FILE_NOT_FOUND'
);
}
const channelFileUrl = new URL(channelFileAsset.url);
const channelFileContent = await this.httpRequest(channelFileUrl);
const result = parseUpdateInfo(
channelFileContent,
channelFileName,
channelFileUrl
);
const files: UpdateFileInfo[] = [];
result.files.forEach(file => {
const asset = latestRelease.assets.find(({ name }) => name === file.url);
if (asset) {
file.url = asset.url;
}
// for windows, we need to determine its installer type (nsis or squirrel)
if (process.platform === 'win32') {
const isSquirrel = isSquirrelBuild();
if (isSquirrel && file.url.endsWith('.nsis.exe')) {
return;
}
}
files.push(file);
});
if (result.releaseName == null) {
result.releaseName = latestRelease.name;
}
if (result.releaseNotes == null) {
// TODO(@forehalo): add release notes
result.releaseNotes = '';
}
return {
tag: tag,
...result,
};
}
resolveFiles(updateInfo: GithubUpdateInfo): Array<ResolvedUpdateFileInfo> {
const files = getFileList(updateInfo);
return files.map(file => ({
url: new URL(file.url),
info: file,
}));
}
}
function getChannelFilename(channel: string): string {
return `${channel}.yml`;
}
@@ -0,0 +1,159 @@
import { app } from 'electron';
import { autoUpdater as defaultAutoUpdater } from 'electron-updater';
import { buildType } from '../config';
import { logger } from '../logger';
import { isOfflineModeEnabled } from '../utils';
import { AFFiNEUpdateProvider } from './affine-update-provider';
import { updaterSubjects } from './event';
import { WindowsUpdater } from './windows-updater';
const mode = process.env.NODE_ENV;
const isDev = mode === 'development';
// skip auto update in dev mode & internal
const disabled = buildType === 'internal' || isDev;
export const autoUpdater =
process.platform === 'win32' ? new WindowsUpdater() : defaultAutoUpdater;
export const quitAndInstall = async () => {
autoUpdater.quitAndInstall();
};
let downloading = false;
let configured = false;
let checkingUpdate = false;
export type UpdaterConfig = {
autoCheckUpdate: boolean;
autoDownloadUpdate: boolean;
};
const config: UpdaterConfig = {
autoCheckUpdate: true,
autoDownloadUpdate: true,
};
export const getConfig = (): UpdaterConfig => {
return { ...config };
};
export const setConfig = (newConfig: Partial<UpdaterConfig> = {}): void => {
configured = true;
Object.assign(config, newConfig);
logger.info('Updater configured!', config);
// if config.autoCheckUpdate is true, trigger a check
if (config.autoCheckUpdate) {
checkForUpdates().catch(err => {
logger.error('Error checking for updates', err);
});
}
};
export const checkForUpdates = async () => {
if (disabled || checkingUpdate || isOfflineModeEnabled()) {
return;
}
checkingUpdate = true;
try {
const info = await autoUpdater.checkForUpdates();
return info;
} finally {
checkingUpdate = false;
}
};
export const downloadUpdate = async () => {
if (disabled || downloading) {
return;
}
downloading = true;
updaterSubjects.downloadProgress$.next(0);
autoUpdater.downloadUpdate().catch(e => {
downloading = false;
logger.error('Failed to download update', e);
});
logger.info('Update available, downloading...');
return;
};
export const registerUpdater = async () => {
if (disabled) {
return;
}
const allowAutoUpdate = true;
autoUpdater.logger = logger;
autoUpdater.autoDownload = false;
autoUpdater.allowPrerelease = buildType !== 'stable';
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.autoRunAppAfterInstall = true;
const feedUrl = AFFiNEUpdateProvider.configFeed({
channel: buildType,
});
logger.debug('auto-updater feed config', feedUrl);
autoUpdater.setFeedURL(feedUrl);
// register events for checkForUpdates
autoUpdater.on('checking-for-update', () => {
logger.info('Checking for update');
});
autoUpdater.on('update-available', info => {
logger.info('Update available', info);
if (config.autoDownloadUpdate && allowAutoUpdate) {
downloadUpdate().catch(err => {
console.error(err);
});
}
updaterSubjects.updateAvailable$.next({
version: info.version,
allowAutoUpdate,
});
});
autoUpdater.on('update-not-available', info => {
logger.info('Update not available', info);
});
autoUpdater.on('download-progress', e => {
logger.info(`Download progress: ${e.percent}`);
updaterSubjects.downloadProgress$.next(e.percent);
});
autoUpdater.on('update-downloaded', e => {
downloading = false;
updaterSubjects.updateReady$.next({
version: e.version,
allowAutoUpdate,
});
// I guess we can skip it?
// updaterSubjects.clientDownloadProgress.next(100);
logger.info('Update downloaded, ready to install');
});
autoUpdater.on('error', e => {
logger.error('Error while updating client', e);
});
autoUpdater.forceDevUpdateConfig = isDev;
// check update whenever the window is activated
let lastCheckTime = 0;
app.on('browser-window-focus', () => {
(async () => {
if (
configured &&
config.autoCheckUpdate &&
lastCheckTime + 1000 * 1800 < Date.now()
) {
lastCheckTime = Date.now();
await checkForUpdates();
}
})().catch(err => {
logger.error('Error checking for updates', err);
});
});
};
@@ -0,0 +1,36 @@
import { BehaviorSubject, Subject } from 'rxjs';
import type { MainEventRegister } from '../type';
export interface UpdateMeta {
version: string;
allowAutoUpdate: boolean;
}
export const updaterSubjects = {
// means it is ready for restart and install the new version
updateAvailable$: new Subject<UpdateMeta>(),
updateReady$: new Subject<UpdateMeta>(),
downloadProgress$: new BehaviorSubject<number>(0),
};
export const updaterEvents = {
onUpdateAvailable: (fn: (versionMeta: UpdateMeta) => void) => {
const sub = updaterSubjects.updateAvailable$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
onUpdateReady: (fn: (versionMeta: UpdateMeta) => void) => {
const sub = updaterSubjects.updateReady$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
onDownloadProgress: (fn: (progress: number) => void) => {
const sub = updaterSubjects.downloadProgress$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
} satisfies Record<string, MainEventRegister>;
@@ -0,0 +1,45 @@
import type { IpcMainInvokeEvent } from 'electron';
import { app } from 'electron';
import type { NamespaceHandlers } from '../type';
import type { UpdaterConfig } from './electron-updater';
import {
checkForUpdates,
downloadUpdate,
getConfig,
quitAndInstall,
setConfig,
} from './electron-updater';
export const updaterHandlers = {
currentVersion: async () => {
return app.getVersion();
},
quitAndInstall: async () => {
return quitAndInstall();
},
downloadUpdate: async () => {
return downloadUpdate();
},
getConfig: async (): Promise<UpdaterConfig> => {
return getConfig();
},
setConfig: async (
_e: IpcMainInvokeEvent,
newConfig: Partial<UpdaterConfig>
): Promise<void> => {
return setConfig(newConfig);
},
checkForUpdates: async () => {
const res = await checkForUpdates();
if (res) {
const { updateInfo } = res;
return {
version: updateInfo.version,
};
}
return null;
},
} satisfies NamespaceHandlers;
export * from './electron-updater';
@@ -0,0 +1,18 @@
import fs from 'node:fs';
import path from 'node:path';
import { app } from 'electron';
let _isSquirrelBuild: boolean | null = null;
export function isSquirrelBuild() {
if (typeof _isSquirrelBuild === 'boolean') {
return _isSquirrelBuild;
}
// if it is squirrel build, there will be 'squirrel.exe'
// otherwise it is in nsis web mode
const files = fs.readdirSync(path.dirname(app.getPath('exe')));
_isSquirrelBuild = files.some(it => it.includes('squirrel.exe'));
return _isSquirrelBuild;
}

Some files were not shown because too many files have changed in this diff Show More