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

This commit is contained in:
upstream-mirror
2026-09-16 00:31:07 +05:00
commit 50f788e799
7907 changed files with 988785 additions and 0 deletions

View File

@@ -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

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 affine @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)

View File

@@ -0,0 +1,4 @@
owner: toeverything
repo: AFFiNE
provider: custom
private: false

View File

@@ -0,0 +1,434 @@
import cp from 'node:child_process';
import { readdir, rm, symlink } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { FuseV1Options, FuseVersion } from '@electron/fuses';
import { utils } from '@electron-forge/core';
import { FusesPlugin } from '@electron-forge/plugin-fuses';
import {
appIdMap,
arch,
buildType,
icnsPath,
iconUrl,
iconX64PngPath,
iconX512PngPath,
icoPath,
platform,
productName,
} from './scripts/make-env.js';
const fromBuildIdentifier = utils.fromBuildIdentifier;
const linuxMimeTypes = [`x-scheme-handler/${productName.toLowerCase()}`];
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const DEFAULT_ELECTRON_LOCALES_KEEP = new Set([
'en',
'en_US',
'en_GB',
'zh_CN',
'zh_TW',
'fr',
'es',
'es_419',
'pl',
'de',
'ru',
'ja',
'it',
'ca',
'da',
'hi',
'sv',
'ur',
'ar',
'uk',
'ko',
'pt_BR',
'fa',
'nb',
]);
const getElectronLocalesKeep = () => {
const raw = process.env.ELECTRON_LOCALES_KEEP?.trim();
if (!raw) return DEFAULT_ELECTRON_LOCALES_KEEP;
const normalized = raw.toLowerCase();
if (normalized === 'all' || normalized === '*') return null;
const keep = new Set(
raw
.split(',')
.map(s => s.trim())
.filter(Boolean)
);
// Always keep English as a safe fallback.
keep.add('en');
keep.add('en_US');
keep.add('en_GB');
return keep;
};
const getElectronPakLocalesKeep = keep => {
const pakKeep = new Set();
for (const locale of keep) {
if (locale === 'en') {
pakKeep.add('en-US');
continue;
}
pakKeep.add(locale.replaceAll('_', '-'));
}
// Always keep English (US) as a safe fallback for Chromium/Electron locales.
pakKeep.add('en');
pakKeep.add('en-US');
pakKeep.add('en-GB');
return pakKeep;
};
const trimElectronFrameworkLocales = async (
resourcesAppDir,
targetPlatform
) => {
if (process.env.TRIM_ELECTRON_LOCALES === '0') return;
if (targetPlatform !== 'darwin' && targetPlatform !== 'mas') return;
const keep = getElectronLocalesKeep();
if (!keep) return;
const contentsDir = path.resolve(resourcesAppDir, '..', '..');
const frameworkResourcesDir = path.join(
contentsDir,
'Frameworks',
'Electron Framework.framework',
'Versions',
'A',
'Resources'
);
let entries;
try {
entries = await readdir(frameworkResourcesDir, { withFileTypes: true });
} catch {
return;
}
const localeDirs = entries
.filter(entry => entry.isDirectory() && entry.name.endsWith('.lproj'))
.map(entry => entry.name);
await Promise.all(
localeDirs.map(async dirName => {
const locale = dirName.slice(0, -'.lproj'.length);
if (keep.has(locale)) return;
await rm(path.join(frameworkResourcesDir, dirName), {
recursive: true,
force: true,
});
})
);
};
const trimElectronPakLocales = async (resourcesAppDir, targetPlatform) => {
if (process.env.TRIM_ELECTRON_LOCALES === '0') return;
if (targetPlatform !== 'win32' && targetPlatform !== 'linux') return;
const keep = getElectronLocalesKeep();
if (!keep) return;
const rootDir = path.resolve(resourcesAppDir, '..', '..');
const localesDir = path.join(rootDir, 'locales');
let entries;
try {
entries = await readdir(localesDir, { withFileTypes: true });
} catch {
return;
}
const pakKeep = getElectronPakLocalesKeep(keep);
await Promise.all(
entries
.filter(entry => entry.isFile() && entry.name.endsWith('.pak'))
.map(async entry => {
const locale = entry.name.slice(0, -'.pak'.length);
if (pakKeep.has(locale)) return;
await rm(path.join(localesDir, entry.name), { force: true });
})
);
};
const makers = [
!process.env.SKIP_BUNDLE &&
platform === 'darwin' && {
name: '@electron-forge/maker-dmg',
config: {
format: 'ULMO',
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: '@reforged/maker-appimage',
platforms: ['linux'],
/** @type {import('@reforged/maker-appimage').MakerAppImageConfig} */
config: {
options: {
bin: productName,
mimeType: linuxMimeTypes,
productName,
genericName: productName,
categories: [
'Office',
'WordProcessor',
'Presentation',
'ContactManagement',
'ProjectManagement',
'VectorGraphics',
'Chat',
],
compressor: 'zstd',
icon: { '64x64': iconX64PngPath, '512x512': iconX512PngPath },
},
},
},
!process.env.SKIP_BUNDLE && {
name: '@electron-forge/maker-deb',
config: {
bin: productName,
options: {
name: productName,
productName,
icon: iconX512PngPath,
mimeType: linuxMimeTypes,
scripts: {
// maker-deb does not have a way to include arbitrary files in package root
// instead, put files in extraResource, and then install with a script
postinst: './resources/deb/postinst',
prerm: './resources/deb/prerm',
},
},
},
},
!process.env.SKIP_BUNDLE && {
name: '@electron-forge/maker-flatpak',
platforms: ['linux'],
/** @type {import('@electron-forge/maker-flatpak').MakerFlatpakConfig} */
config: {
options: {
mimeType: linuxMimeTypes,
productName,
bin: productName,
id: fromBuildIdentifier(appIdMap),
icon: { '64x64': iconX64PngPath, '512x512': iconX512PngPath },
branch: buildType,
runtime: 'org.freedesktop.Platform',
runtimeVersion: '25.08',
sdk: 'org.freedesktop.Sdk',
base: 'org.electronjs.Electron2.BaseApp',
baseVersion: '25.08',
files: [
[
'./resources/affine.metainfo.xml',
'/usr/share/metainfo/affine.metainfo.xml',
],
],
modules: [
{
name: 'zypak',
sources: [
{
type: 'git',
url: 'https://github.com/refi64/zypak',
tag: 'v2025.09',
},
],
},
],
finishArgs: [
// Wayland/X11 Rendering
'--socket=wayland',
'--socket=x11',
'--share=ipc',
// Open GL
'--device=dri',
// Audio output
'--socket=pulseaudio',
// Read/write home directory access
'--filesystem=home',
// Allow communication with network
'--share=network',
// System notifications with libnotify
'--talk-name=org.freedesktop.Notifications',
],
},
},
},
].filter(Boolean);
console.log('makers', makers);
/**
* @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,
},
electronZipDir: process.env.ELECTRON_FORGE_ELECTRON_ZIP_DIR,
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',
...(platform === 'linux' ? ['./resources/affine.metainfo.xml'] : []),
],
protocols: [
{
name: productName,
schemes: [productName.toLowerCase()],
},
],
executableName: productName,
ignore: [
/\.map$/,
/\/test($|\/)/,
/\/scripts($|\/)/,
/\/examples($|\/)/,
/\/docs($|\/)/,
/\/README\.md$/,
/\/forge\.config\.mjs$/,
/\/dev-app-update\.yml$/,
/\/resources\/app-update\.yml$/,
],
afterCopy: [
(buildPath, _electronVersion, targetPlatform, _arch, done) => {
Promise.all([
trimElectronFrameworkLocales(buildPath, targetPlatform),
trimElectronPakLocales(buildPath, targetPlatform),
])
.then(() => done())
.catch(done);
},
],
asar: true,
extendInfo: {
NSAudioCaptureUsageDescription:
'Please allow access in order to capture audio from other apps by AFFiNE.',
},
},
makers,
plugins: [
{ name: '@electron-forge/plugin-auto-unpack-natives', config: {} },
new FusesPlugin({
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableCookieEncryption]: true,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
[FuseV1Options.OnlyLoadAppFromAsar]: true,
}),
],
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',
cwd: __dirname,
});
},
},
};

View File

@@ -0,0 +1,95 @@
{
"name": "@affine/electron",
"private": true,
"version": "0.27.5",
"main": "./dist/main.js",
"author": "toeverything",
"repository": {
"url": "https://github.com/toeverything/AFFiNE",
"type": "git"
},
"description": "AFFiNE Desktop App",
"homepage": "https://github.com/toeverything/AFFiNE",
"exports": {
"./helper/exposed": "./src/helper/exposed.ts",
"./main/exposed": "./src/main/exposed.ts",
"./preload/electron-api": "./src/preload/electron-api.ts",
"./preload/shared-storage": "./src/preload/shared-storage.ts",
"./shared/import": "./src/shared/import.ts",
"./main/shared-state-schema": "./src/main/shared-state-schema.ts",
"./main/updater/event": "./src/main/updater/event.ts",
"./main/windows-manager": "./src/main/windows-manager/index.ts"
},
"scripts": {
"start": "electron .",
"dev": "cross-env DEV_SERVER_URL=http://localhost:8080 node ./scripts/dev.ts",
"dev:prod": "node ./scripts/dev.ts",
"build": "cross-env NODE_ENV=production node ./scripts/build-layers.ts",
"build:dev": "node ./scripts/build-layers.ts",
"generate-assets": "node ./scripts/generate-assets.ts",
"package": "electron-forge package",
"make": "electron-forge make",
"make-squirrel": "node ./scripts/make-squirrel.ts",
"make-nsis": "node ./scripts/make-nsis.ts"
},
"devDependencies": {
"@affine-tools/utils": "workspace:*",
"@affine/auth": "workspace:*",
"@affine/i18n": "workspace:*",
"@affine/native": "workspace:*",
"@affine/nbstore": "workspace:*",
"@electron-forge/cli": "^7.11.1",
"@electron-forge/core": "^7.11.1",
"@electron-forge/maker-deb": "^7.11.1",
"@electron-forge/maker-dmg": "^7.11.1",
"@electron-forge/maker-flatpak": "^7.11.1",
"@electron-forge/maker-squirrel": "^7.11.1",
"@electron-forge/maker-zip": "^7.11.1",
"@electron-forge/plugin-auto-unpack-natives": "^7.11.1",
"@electron-forge/plugin-fuses": "^7.11.1",
"@electron-forge/shared-types": "^7.11.1",
"@reforged/maker-appimage": "^5.2.0",
"@sentry/electron": "^7.13.0",
"@sentry/esbuild-plugin": "^5.3.0",
"@toeverything/infra": "workspace:*",
"@types/set-cookie-parser": "^2.4.10",
"app-builder-lib": "^26.15.0",
"builder-util-runtime": "^9.7.0",
"cross-env": "^10.1.0",
"debug": "^4.4.0",
"electron": "^39.0.0",
"electron-log": "^5.4.3",
"electron-squirrel-startup": "1.0.1",
"electron-window-state": "^5.0.3",
"esbuild": "^0.28.0",
"fs-extra": "^11.2.0",
"glob": "^11.0.0",
"lodash-es": "^4.17.23",
"msw": "^2.13.2",
"nanoid": "^5.1.6",
"rxjs": "^7.8.2",
"semver": "^7.7.3",
"tree-kill": "^1.2.2",
"typescript": "npm:@typescript/typescript6@6.0.2",
"uuid": "^14.0.0",
"vitest": "^4.1.8",
"zod": "^3.25.76"
},
"dependencies": {
"async-call-rpc": "^6.4.2",
"electron-updater": "^6.8.9",
"link-preview-js": "^4.0.0",
"set-cookie-parser": "^2.7.1",
"yjs": "^13.6.27"
},
"build": {
"protocols": [
{
"name": "affine",
"schemes": [
"affine"
]
}
]
}
}

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<component type="desktop-application">
<id>affine</id>
<name>AFFiNE</name>
<summary>Productivity and note-taking app</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT AND LicenseRef-proprietary=https://github.com/toeverything/AFFiNE/blob/canary/LICENSE</project_license>
<url type="homepage">https://affine.pro</url>
<url type="bugtracker">https://github.com/toeverything/AFFiNE/issues</url>
<url type="help">https://docs.affine.pro/</url>
<url type="vcs-browser">https://github.com/toeverything/AFFiNE</url>
<url type="contribute">https://docs.affine.pro/contributing</url>
<developer id="pro.affine.app">
<name>affine</name>
</developer>
<content_rating type="oars-1.1"/>
<categories>
<category>Office</category>
</categories>
<keywords>
<keyword>notes</keyword>
<keyword>canvas</keyword>
</keywords>
<branding>
<color type="primary">#1e96eb</color>
</branding>
<recommends>
<display_length compare="ge">800</display_length>
<internet>always</internet>
</recommends>
<provides>
<mediatype>x-scheme-handler/affine</mediatype>
</provides>
<supports>
<control>pointing</control>
<control>keyboard</control>
<control>tablet</control>
<control>touch</control>
</supports>
<description>
<p>Write, Draw, Plan, All at Once. With AI.</p>
<p>AFFiNE is a workspace with fully merged docs, whiteboards and databases. Get more things done, your creativity isn’t monotone.</p>
<p>Features</p>
<ul>
<li>Write your way to better productivity. Build up your content like blocks and let your ideas run wild.</li>
<li>Draw and visualise with ease and creativity. Visualise your creativity with others. No constraints, limited only by your imagination.</li>
<li>Plan, track, and collaborate efficiently. Stay on top of your workload and achieve more in less time.</li>
<li>AI partner helps you better write, draw and plan. Let you think bigger, create faster, work smarter in anytime, anywhere</li>
</ul>
</description>
<launchable type="desktop-id">affine.desktop</launchable>
<screenshots>
<screenshot type="default">
<image>https://affine.pro/overview/Write.jpg</image>
<caption>Document writing interface with block-based editing</caption>
</screenshot>
<screenshot>
<image>https://affine.pro/overview/Draw.jpg</image>
<caption>Whiteboard drawing and visualization capabilities</caption>
</screenshot>
<screenshot>
<image>https://affine.pro/overview/Plan.jpg</image>
<caption>Project planning and task management view</caption>
</screenshot>
</screenshots>
<releases>
<release version="0.27.5" date="2026-09-09">
<url>https://github.com/toeverything/AFFiNE/releases/tag/v0.27.5</url>
</release>
</releases>
</component>

View File

@@ -0,0 +1,4 @@
owner: toeverything
repo: AFFiNE
provider: custom
private: false

View File

@@ -0,0 +1,5 @@
#!/bin/sh
set -e
BUILD_TYPE=$(ls /usr/lib | grep '^affine')
install -Dm0644 /usr/lib/${BUILD_TYPE}/resources/affine.metainfo.xml /usr/share/metainfo/affine.metainfo.xml

View File

@@ -0,0 +1,4 @@
#!/bin/sh
set -e
rm /usr/share/metainfo/affine.metainfo.xml

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: 938 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

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: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 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: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 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: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 805 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 996 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 939 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 801 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 B

View File

@@ -0,0 +1,57 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import * as esbuild from 'esbuild';
import { config, electronDir, mode, rootDir } from './common';
async function assertNoRuntimeWorkspaceDependencies() {
const packageJson = JSON.parse(
await fs.readFile(path.resolve(electronDir, 'package.json'), 'utf8')
) as { dependencies?: Record<string, string> };
const workspaceDependencies = Object.entries(
packageJson.dependencies ?? {}
).filter(([, version]) => version.startsWith('workspace:'));
if (workspaceDependencies.length) {
throw new Error(
`Electron workspace dependencies must be bundled and declared as devDependencies: ${workspaceDependencies
.map(([name]) => name)
.join(', ')}`
);
}
}
async function buildLayers() {
await assertNoRuntimeWorkspaceDependencies();
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');

View File

@@ -0,0 +1,111 @@
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { getBuildConfig } from '@affine-tools/utils/build-config';
import { Package } from '@affine-tools/utils/workspace';
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 = 22;
export const mode = (process.env.NODE_ENV =
process.env.NODE_ENV || 'development');
export const config = (): BuildOptions => {
const define = {
'process.env.GITHUB_SHA': process.env.GITHUB_SHA,
'process.env.SENTRY_RELEASE': process.env.SENTRY_RELEASE,
'process.env.SENTRY_DSN': process.env.SENTRY_DSN,
'process.env.DEV_SERVER_URL': process.env.DEV_SERVER_URL,
'process.env.NODE_ENV': process.env.NODE_ENV,
REPLACE_ME_BUILD_ENV: process.env.BUILD_TYPE ?? 'stable',
...Object.entries(
getBuildConfig(new Package('@affine/electron'), {
mode:
process.env.NODE_ENV === 'production' ? 'production' : 'development',
channel: (process.env.BUILD_TYPE as any) ?? 'canary',
})
).reduce(
(def, [key, val]) => {
def[`BUILD_CONFIG.${key}`] = val;
return def;
},
{} as Record<string, any>
),
};
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: Object.entries(define).reduce(
(def, [key, val]) => {
def[key] =
JSON.stringify(val) ??
String(
val
); /* JSON.stringify(undefined) == undefined, but we need 'undefined' */
return def;
},
{} as Record<string, string>
),
assetNames: '[name]',
treeShaking: true,
sourcemap: 'linked',
plugins,
};
};

View File

@@ -0,0 +1,117 @@
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}`);
// remove import loader option
const NODE_OPTIONS = process.env.NODE_OPTIONS;
if (NODE_OPTIONS) {
process.env.NODE_OPTIONS = NODE_OPTIONS.replace(/--import=[^\s]*/, '');
}
spawnProcess = spawn(exe, ['.', '--inspect'], {
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).catch(e => {
console.error(e);
});
initialBuild = true;
}
});
},
},
],
});
buildContextPromise
.then(buildContext => {
return buildContext.watch();
})
.catch(e => {
console.error(e);
});
});
}
await watchLayers();
if (watchMode) {
console.log(`Watching for changes...`);
} else {
console.log('Starting electron...');
spawnOrReloadElectron();
console.log(`Electron is started, watching for changes...`);
}

View File

@@ -0,0 +1,74 @@
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';
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',
'apps',
'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
const cwd = repoRootDir;
// step 1: build web dist
if (!process.env.SKIP_WEB_BUILD) {
spawnSync('yarn', ['affine', '@affine/electron-renderer', 'build'], {
stdio: 'inherit',
env: process.env,
cwd,
shell: true,
});
spawnSync('yarn', ['affine', '@affine/electron', 'build'], {
stdio: 'inherit',
env: process.env,
cwd,
shell: true,
});
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);
}

View File

@@ -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');

View File

@@ -0,0 +1,94 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs } from 'node:util';
import debug from 'debug';
import { z } from 'zod';
const log = debug('affine:make-env');
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 iconX512PngPath = path.join(
ROOT,
`./resources/icons/icon_${buildType}_512x512.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`;
log(`buildType=${buildType}, productName=${productName}, icoPath=${icoPath}`);
const {
values: { arch, platform },
} = parseArgs({
options: {
arch: {
type: 'string',
description: 'The architecture to build for',
default: process.arch,
},
platform: {
type: 'string',
description: 'The platform to build for',
default: process.platform,
},
},
allowPositionals: true,
strict: false,
});
log(`parsed args: arch=${arch}, platform=${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,
iconX512PngPath,
icoPath,
platform,
productName,
REPO_ROOT,
ROOT,
stableBuild,
};

View File

@@ -0,0 +1,94 @@
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('affine: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().catch(e => {
console.error(e);
});

View File

@@ -0,0 +1,84 @@
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('affine: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}`
);
log('making squirrel.windows: appDirectory', appDirectory);
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,
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().catch(e => {
console.error(e);
});

View File

@@ -0,0 +1 @@
ManifestDPIAware true

View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

View File

@@ -0,0 +1,304 @@
import { parse, resolve } from 'node:path';
import { DocStorage, ValidationResult } from '@affine/native';
import { parseUniversalId } from '@affine/nbstore';
import fs from 'fs-extra';
import { nanoid } from 'nanoid';
import { isPathInsideBase } from '../../shared/utils';
import { logger } from '../logger';
import { mainRPC } from '../main-rpc';
import { getDocStoragePool } from '../nbstore';
import { storeWorkspaceMeta } from '../workspace';
import {
getSpaceDBPath,
getWorkspaceDBPath,
getWorkspacesBasePath,
} from '../workspace/meta';
export type ErrorMessage =
| 'DB_FILE_PATH_INVALID'
| 'DB_FILE_INVALID'
| '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;
}
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, '-');
}
async function resolveExistingPath(path: string) {
if (!(await fs.pathExists(path))) {
return null;
}
try {
return await fs.realpath(path);
} catch {
return resolve(path);
}
}
async function isSameFilePath(sourcePath: string, targetPath: string) {
if (resolve(sourcePath) === resolve(targetPath)) {
return true;
}
const [resolvedSourcePath, resolvedTargetPath] = await Promise.all([
resolveExistingPath(sourcePath),
resolveExistingPath(targetPath),
]);
return !!resolvedSourcePath && resolvedSourcePath === resolvedTargetPath;
}
async function normalizeImportDBPath(selectedPath: string) {
if (!(await fs.pathExists(selectedPath))) {
return null;
}
const [normalizedPath, workspacesBasePath] = await Promise.all([
resolveExistingPath(selectedPath),
resolveExistingPath(await getWorkspacesBasePath()),
]);
const resolvedSelectedPath = normalizedPath ?? resolve(selectedPath);
const resolvedWorkspacesBasePath =
workspacesBasePath ?? resolve(await getWorkspacesBasePath());
if (isPathInsideBase(resolvedWorkspacesBasePath, resolvedSelectedPath)) {
logger.warn('loadDBFile: db file in app data dir');
return null;
}
return resolvedSelectedPath;
}
/**
* This function is called when the user clicks the "Save" button in the "Save Workspace" dialog.
*
* It will export a compacted database file to the given path
*/
export async function saveDBFileAs(
universalId: string,
name: string
): Promise<SaveDBFileResult> {
try {
const { peer, type, id } = parseUniversalId(universalId);
const dbPath = await getSpaceDBPath(peer, type, id);
// connect to the pool and make sure all changes (WAL) are written to db
const pool = getDocStoragePool();
await pool.connect(universalId, dbPath);
await pool.checkpoint(universalId); // make sure all changes (WAL) are written to db
if (!dbPath) {
return {
error: 'DB_FILE_PATH_INVALID',
};
}
const ret = await mainRPC.showSaveDialog({
properties: ['showOverwriteConfirmation'],
title: 'Save Workspace',
showsTagField: false,
buttonLabel: 'Save',
filters: [
{
extensions: [extension],
name: '',
},
],
defaultPath: getDefaultDBFileName(name, id),
message: 'Save Workspace as a SQLite Database file',
});
const filePath = ret.filePath;
if (ret.canceled || !filePath) {
return { canceled: true };
}
if (await isSameFilePath(dbPath, filePath)) {
return { error: 'DB_FILE_PATH_INVALID' };
}
const tempFilePath = `${filePath}.${nanoid(6)}.tmp`;
if (await fs.pathExists(tempFilePath)) {
await fs.remove(tempFilePath);
}
try {
await pool.vacuumInto(universalId, tempFilePath);
await fs.move(tempFilePath, filePath, { overwrite: true });
} finally {
if (await fs.pathExists(tempFilePath)) {
await fs.remove(tempFilePath);
}
}
logger.log('saved', filePath);
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 = 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|userspaces>/<peer>/<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 = 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 selectedPath = ret.filePaths?.[0];
if (ret.canceled || !selectedPath) {
logger.info('loadDBFile canceled');
return { canceled: true };
}
const originalPath = await normalizeImportDBPath(selectedPath);
if (!originalPath) {
return { error: 'DB_FILE_PATH_INVALID' };
}
const workspaceId = nanoid(10);
let storage = new DocStorage(originalPath);
// if imported db is not a valid v2 db, we will treat it as a v1 db
if (!(await storage.validate())) {
return await cpV1DBFile(originalPath, workspaceId);
}
if (!(await storage.validateImportSchema())) {
return { error: 'DB_FILE_INVALID' };
}
// v2 import logic
const internalFilePath = await getSpaceDBPath(
'local',
'workspace',
workspaceId
);
await fs.ensureDir(parse(internalFilePath).dir);
await storage.vacuumInto(internalFilePath);
logger.info(`loadDBFile, vacuum: ${originalPath} -> ${internalFilePath}`);
storage = new DocStorage(internalFilePath);
await storage.setSpaceId(workspaceId);
return {
workspaceId,
};
} catch (err) {
logger.error('loadDBFile', err);
return {
error: 'UNKNOWN_ERROR',
};
}
}
async function cpV1DBFile(
originalPath: string,
workspaceId: string
): Promise<LoadDBFileResult> {
const { SqliteConnection } = await import('@affine/native');
const validationResult = await SqliteConnection.validate(originalPath);
if (validationResult !== ValidationResult.Valid) {
return { error: 'DB_FILE_INVALID' }; // invalid db file
}
const connection = new SqliteConnection(originalPath);
try {
if (!(await connection.validateImportSchema())) {
return { error: 'DB_FILE_INVALID' };
}
const internalFilePath = await getWorkspaceDBPath('workspace', workspaceId);
await fs.ensureDir(parse(internalFilePath).dir);
await connection.vacuumInto(internalFilePath);
logger.info(`loadDBFile, vacuum: ${originalPath} -> ${internalFilePath}`);
await storeWorkspaceMeta(workspaceId, {
id: workspaceId,
mainDBPath: internalFilePath,
});
return {
workspaceId,
};
} finally {
await connection.close();
}
}

View File

@@ -0,0 +1,13 @@
import { loadDBFile, saveDBFileAs, selectDBFileLocation } from './dialog';
export const dialogHandlers = {
loadDBFile: async () => {
return loadDBFile();
},
saveDBFileAs: async (universalId: string, name: string) => {
return saveDBFileAs(universalId, name);
},
selectDBFileLocation: async () => {
return selectDBFileLocation();
},
};

View File

@@ -0,0 +1,39 @@
import { dialogHandlers } from './dialog';
import { dbEventsV1, dbHandlersV1, nbstoreHandlers } from './nbstore';
import { previewHandlers } from './preview';
import { provideExposed } from './provide';
import { workspaceEvents, workspaceHandlers } from './workspace';
export const handlers = {
db: dbHandlersV1,
nbstore: nbstoreHandlers,
workspace: workspaceHandlers,
dialog: dialogHandlers,
preview: previewHandlers,
};
export const events = {
db: dbEventsV1,
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());

View File

@@ -0,0 +1,85 @@
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);
// Propagate errors to the renderer so callers don't receive `undefined`
// and fail with confusing TypeErrors.
throw error instanceof Error ? error : new Error(String(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.debug('[helper] renderer connected');
}
});
}
main();

View File

@@ -0,0 +1,7 @@
import log from 'electron-log/main';
export const logger = log.scope('helper');
log.transports.file.level = 'info';
log.transports.console.level =
process.env.NODE_ENV === 'development' ? 'info' : false;

View File

@@ -0,0 +1,34 @@
import { AsyncCall } from 'async-call-rpc';
import type { HelperToMain, MainToHelper } from '../shared/type';
import { exposed } from './provide';
const helperToMainServer: HelperToMain = {
getMeta: () => {
if (!exposed) {
throw new Error('Helper is not initialized correctly');
}
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);
},
},
log: false,
});

View File

@@ -0,0 +1,62 @@
import path from 'node:path';
import { DocStoragePool } from '@affine/native';
import { parseUniversalId } from '@affine/nbstore';
import type { NativeDBApis } from '@affine/nbstore/sqlite';
import fs from 'fs-extra';
import { getSpaceDBPath } from '../workspace/meta';
const POOL = new DocStoragePool();
export function getDocStoragePool() {
return POOL;
}
export const nbstoreHandlers: NativeDBApis = {
connect: async (universalId: string) => {
const { peer, type, id } = parseUniversalId(universalId);
const dbPath = await getSpaceDBPath(peer, type, id);
await fs.ensureDir(path.dirname(dbPath));
await POOL.connect(universalId, dbPath);
await POOL.setSpaceId(universalId, id);
},
disconnect: POOL.disconnect.bind(POOL),
pushUpdate: POOL.pushUpdate.bind(POOL),
getDocSnapshot: POOL.getDocSnapshot.bind(POOL),
setDocSnapshot: POOL.setDocSnapshot.bind(POOL),
getDocUpdates: POOL.getDocUpdates.bind(POOL),
markUpdatesMerged: POOL.markUpdatesMerged.bind(POOL),
deleteDoc: POOL.deleteDoc.bind(POOL),
getDocClocks: POOL.getDocClocks.bind(POOL),
getDocClock: POOL.getDocClock.bind(POOL),
getDocIndexedClock: POOL.getDocIndexedClock.bind(POOL),
setDocIndexedClock: POOL.setDocIndexedClock.bind(POOL),
clearDocIndexedClock: POOL.clearDocIndexedClock.bind(POOL),
getBlob: POOL.getBlob.bind(POOL),
setBlob: POOL.setBlob.bind(POOL),
deleteBlob: POOL.deleteBlob.bind(POOL),
releaseBlobs: POOL.releaseBlobs.bind(POOL),
listBlobs: POOL.listBlobs.bind(POOL),
getPeerRemoteClocks: POOL.getPeerRemoteClocks.bind(POOL),
getPeerRemoteClock: POOL.getPeerRemoteClock.bind(POOL),
setPeerRemoteClock: POOL.setPeerRemoteClock.bind(POOL),
getPeerPulledRemoteClocks: POOL.getPeerPulledRemoteClocks.bind(POOL),
getPeerPulledRemoteClock: POOL.getPeerPulledRemoteClock.bind(POOL),
setPeerPulledRemoteClock: POOL.setPeerPulledRemoteClock.bind(POOL),
getPeerPushedClocks: POOL.getPeerPushedClocks.bind(POOL),
getPeerPushedClock: POOL.getPeerPushedClock.bind(POOL),
setPeerPushedClock: POOL.setPeerPushedClock.bind(POOL),
clearClocks: POOL.clearClocks.bind(POOL),
setBlobUploadedAt: POOL.setBlobUploadedAt.bind(POOL),
getBlobUploadedAt: POOL.getBlobUploadedAt.bind(POOL),
crawlDocData: POOL.crawlDocData.bind(POOL),
setDocIndexedClocks: POOL.setDocIndexedClocks.bind(POOL),
indexUpsert: POOL.indexUpsert.bind(POOL),
indexDelete: POOL.indexDelete.bind(POOL),
indexSearch: POOL.indexSearch.bind(POOL),
indexAggregate: POOL.indexAggregate.bind(POOL),
indexDeleteByQuery: POOL.indexDeleteByQuery.bind(POOL),
indexFlush: POOL.indexFlush.bind(POOL),
indexVersion: POOL.indexVersion.bind(POOL),
};

View File

@@ -0,0 +1,4 @@
export { nbstoreHandlers } from './handlers';
export { getDocStoragePool } from './handlers';
export { dbEvents as dbEventsV1, dbHandlers as dbHandlersV1 } from './v1';
export { universalId } from '@affine/nbstore';

View File

@@ -0,0 +1,266 @@
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 checkpoint() {
try {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return;
}
await this.db.checkpoint();
} catch (error) {
logger.error('checkpoint', 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();
},
};
async getDocTimestamps() {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return [];
}
return await this.db.getDocTimestamps();
}
}

View File

@@ -0,0 +1,70 @@
import { existsSync } from 'node:fs';
import type { SpaceType } from '@affine/nbstore';
import { logger } from '../../logger';
import { getWorkspaceMeta } from '../../workspace/meta';
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);
}
// oxlint-disable-next-line typescript/no-non-null-assertion
return db!;
}
export async function ensureSQLiteDB(
spaceType: SpaceType,
id: string
): Promise<WorkspaceSQLiteDB | null> {
const meta = await getWorkspaceMeta(spaceType, id);
// do not auto create v1 db anymore
if (!existsSync(meta.mainDBPath)) {
return null;
}
return getWorkspaceDB(spaceType, id);
}
export async function ensureSQLiteDisconnected(
spaceType: SpaceType,
id: string
) {
const db = await ensureSQLiteDB(spaceType, id);
if (db) {
await db.checkpoint();
await db.destroy();
}
}

View File

@@ -0,0 +1,52 @@
import type { SpaceType } from '@affine/nbstore';
import type { MainEventRegister } from '../../type';
import { ensureSQLiteDB } from './ensure-db';
export * from './ensure-db';
export const dbHandlers = {
getDocAsUpdates: async (
spaceType: SpaceType,
workspaceId: string,
subdocId: string
) => {
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
if (!spaceDB) {
// means empty update in yjs
return new Uint8Array([0, 0]);
}
return spaceDB.getDocAsUpdates(subdocId);
},
getDocTimestamps: async (spaceType: SpaceType, workspaceId: string) => {
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
if (!spaceDB) {
return [];
}
return spaceDB.getDocTimestamps();
},
getBlob: async (spaceType: SpaceType, workspaceId: string, key: string) => {
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
if (!spaceDB) {
return null;
}
return spaceDB.getBlob(key);
},
getBlobKeys: async (spaceType: SpaceType, workspaceId: string) => {
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
if (!spaceDB) {
return [];
}
return spaceDB.getBlobKeys();
},
};
export const dbEvents = {} satisfies Record<string, MainEventRegister>;

View File

@@ -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);
}

View File

@@ -0,0 +1,157 @@
import type { SpaceType } from '@affine/nbstore';
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';
const TRIM_SIZE = 1;
export class WorkspaceSQLiteDB implements AsyncDisposable {
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();
}
[Symbol.asyncDispose] = async () => {
await this.destroy();
};
private readonly toDBDocId = (docId: string) => {
return this.workspaceId === docId ? undefined : docId;
};
getWorkspaceMeta = async () => {
const ydoc = new YDoc();
const updates = await this.adapter.getUpdates();
updates.forEach(update => {
applyUpdate(ydoc, update.data);
});
logger.log(
`ydoc.getMap('meta').get('name')`,
ydoc.getMap('meta').get('name'),
this.path,
updates.length
);
return ydoc.getMap('meta').toJSON();
};
getWorkspaceName = async () => {
const meta = await this.getWorkspaceMeta();
return meta.name;
};
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 getDocTimestamps() {
return this.adapter.getDocTimestamps();
}
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;
};
async checkpoint() {
await this.adapter.checkpoint();
}
}
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;
}

View File

@@ -0,0 +1,69 @@
import fs from 'node:fs';
import path from 'node:path';
import {
type MermaidRenderRequest,
type MermaidRenderResult,
renderMermaidSvg,
renderTypstSvg,
type TypstRenderRequest,
type TypstRenderResult,
} from '@affine/native';
const TYPST_FONT_DIRS_ENV = 'AFFINE_TYPST_FONT_DIRS';
function parseTypstFontDirsFromEnv() {
const value = process.env[TYPST_FONT_DIRS_ENV];
if (!value) {
return [];
}
return value
.split(path.delimiter)
.map(dir => dir.trim())
.filter(Boolean);
}
function getTypstFontDirCandidates() {
const resourcesPath = process.resourcesPath ?? '';
return [
...parseTypstFontDirsFromEnv(),
path.join(resourcesPath, 'fonts'),
path.join(resourcesPath, 'js', 'fonts'),
path.join(resourcesPath, 'app.asar.unpacked', 'fonts'),
path.join(resourcesPath, 'app.asar.unpacked', 'js', 'fonts'),
];
}
function resolveTypstFontDirs() {
return Array.from(
new Set(getTypstFontDirCandidates().map(dir => path.resolve(dir)))
).filter(dir => fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory());
}
function withTypstFontDirs(
request: TypstRenderRequest,
fontDirs: string[]
): TypstRenderRequest {
const nextOptions = request.options ? { ...request.options } : {};
if (!nextOptions.fontDirs?.length) {
nextOptions.fontDirs = fontDirs;
}
return { ...request, options: nextOptions };
}
const typstFontDirs = resolveTypstFontDirs();
export const previewHandlers = {
renderMermaidSvg: async (
request: MermaidRenderRequest
): Promise<MermaidRenderResult> => {
return renderMermaidSvg(request);
},
renderTypstSvg: async (
request: TypstRenderRequest
): Promise<TypstRenderResult> => {
return renderTypstSvg(withTypstFontDirs(request, typstFontDirs));
},
};

View File

@@ -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;
};

View File

@@ -0,0 +1,8 @@
export interface WorkspaceMeta {
id: string;
mainDBPath: string;
}
export type YOrigin = 'self' | 'external' | 'upstream' | 'renderer';
export type MainEventRegister = (...args: any[]) => () => void;

View File

@@ -0,0 +1,351 @@
import path from 'node:path';
import { DocStorage, ValidationResult } from '@affine/native';
import {
parseUniversalId,
universalId as generateUniversalId,
} from '@affine/nbstore';
import fs from 'fs-extra';
import { nanoid } from 'nanoid';
import { applyUpdate, Doc as YDoc } from 'yjs';
import {
normalizeWorkspaceIdForPath,
resolveExistingPathInBase,
} from '../../shared/utils';
import { logger } from '../logger';
import { getDocStoragePool } from '../nbstore';
import { ensureSQLiteDisconnected } from '../nbstore/v1/ensure-db';
import { WorkspaceSQLiteDB } from '../nbstore/v1/workspace-db-adapter';
import type { WorkspaceMeta } from '../type';
import {
getDeletedWorkspacesBasePath,
getSpaceBasePath,
getSpaceDBPath,
getWorkspaceBasePathV1,
getWorkspaceDBPath,
getWorkspaceMeta,
} from './meta';
async function deleteWorkspaceV1(workspaceId: string) {
try {
await ensureSQLiteDisconnected('workspace', workspaceId);
const basePath = await getWorkspaceBasePathV1('workspace', workspaceId);
await fs.remove(basePath);
} catch (error) {
logger.error('deleteWorkspaceV1', error);
}
}
/**
* Permanently delete the workspace data
*/
export async function deleteWorkspace(universalId: string) {
const { peer, type, id } = parseUniversalId(universalId);
await deleteWorkspaceV1(id);
const dbPath = await getSpaceDBPath(peer, type, id);
try {
await getDocStoragePool().disconnect(universalId);
await fs.remove(path.dirname(dbPath));
} catch (e) {
logger.error('deleteWorkspace', e);
}
}
/**
* Move the workspace folder to `deleted-workspaces`
* At the same time, permanently delete the v1 workspace folder if it's id exists in nbstore,
* because trashing always happens after full sync from v1 to nbstore.
*/
export async function trashWorkspace(universalId: string) {
const { peer, type, id } = parseUniversalId(universalId);
await deleteWorkspaceV1(id);
const dbPath = await getSpaceDBPath(peer, type, id);
const basePath = await getDeletedWorkspacesBasePath();
const movedPath = path.join(basePath, normalizeWorkspaceIdForPath(id));
try {
const storage = new DocStorage(dbPath);
if (await storage.validate()) {
const pool = getDocStoragePool();
await pool.checkpoint(universalId);
await pool.disconnect(universalId);
}
await fs.ensureDir(movedPath);
// todo(@pengx17): it seems the db file is still being used at the point
// on windows so that it cannot be moved. we will fallback to copy the dir instead.
await fs.copy(path.dirname(dbPath), movedPath, {
overwrite: true,
});
await fs.remove(path.dirname(dbPath));
} catch (error) {
logger.error('trashWorkspace', error);
}
}
export async function storeWorkspaceMeta(
workspaceId: string,
meta: Partial<WorkspaceMeta>
) {
try {
const basePath = await getWorkspaceBasePathV1('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);
}
}
export async function listLocalWorkspaceIds(): Promise<string[]> {
const localWorkspaceBasePath = path.join(
await getSpaceBasePath('workspace'),
'local'
);
if (!(await fs.pathExists(localWorkspaceBasePath))) {
return [];
}
const deletedWorkspaceBasePath = await getDeletedWorkspacesBasePath();
const deletedWorkspaceIds = new Set<string>(
(await fs.readdir(deletedWorkspaceBasePath).catch(() => [])).filter(Boolean)
);
const entries = await fs.readdir(localWorkspaceBasePath);
const ids = await Promise.all(
entries.map(async entry => {
if (deletedWorkspaceIds.has(entry)) {
return null;
}
const workspacePath = path.join(localWorkspaceBasePath, entry);
const stat = await fs.stat(workspacePath).catch(() => null);
if (!stat?.isDirectory()) {
return null;
}
if (!(await fs.pathExists(path.join(workspacePath, 'storage.db')))) {
return null;
}
return entry;
})
);
return ids.filter((id): id is string => typeof id === 'string');
}
type WorkspaceDocMeta = {
id: string;
name: string;
avatar: Uint8Array | null;
fileSize: number;
updatedAt: Date;
createdAt: Date;
docCount: number;
dbPath: string;
};
async function getWorkspaceDocMetaV1(
workspaceId: string,
dbPath: string
): Promise<WorkspaceDocMeta | null> {
try {
await using db = new WorkspaceSQLiteDB(dbPath, workspaceId);
await db.init();
await db.checkpoint();
const meta = await db.getWorkspaceMeta();
const dbFileSize = await fs.stat(dbPath);
return {
id: workspaceId,
name: meta.name,
avatar: await db.getBlob(meta.avatar),
fileSize: dbFileSize.size,
updatedAt: dbFileSize.mtime,
createdAt: dbFileSize.birthtime,
docCount: meta.pages.length,
dbPath,
};
} catch {
// ignore
}
return null;
}
async function getWorkspaceDocMeta(
workspaceId: string,
dbPath: string
): Promise<WorkspaceDocMeta | null> {
const pool = getDocStoragePool();
const universalId = generateUniversalId({
peer: 'deleted-local',
type: 'workspace',
id: workspaceId,
});
try {
await pool.connect(universalId, dbPath);
await pool.checkpoint(universalId);
const snapshot = await pool.getDocSnapshot(universalId, workspaceId);
const pendingUpdates = await pool.getDocUpdates(universalId, workspaceId);
if (snapshot) {
const updates = snapshot.bin;
const ydoc = new YDoc();
applyUpdate(ydoc, updates);
pendingUpdates.forEach(update => {
applyUpdate(ydoc, update.bin);
});
const meta = ydoc.getMap('meta').toJSON();
const dbFileStat = await fs.stat(dbPath);
const blob = meta.avatar
? await pool.getBlob(universalId, meta.avatar)
: null;
return {
id: workspaceId,
name: meta.name,
avatar: blob ? blob.data : null,
fileSize: dbFileStat.size,
updatedAt: dbFileStat.mtime,
createdAt: dbFileStat.birthtime,
docCount: meta.pages.length,
dbPath,
};
}
} catch {
// try using v1
return await getWorkspaceDocMetaV1(workspaceId, dbPath);
} finally {
await pool.disconnect(universalId);
}
return null;
}
export async function getDeletedWorkspaces() {
const basePath = await getDeletedWorkspacesBasePath();
const directories = await fs.readdir(basePath);
const workspaceEntries = await Promise.all(
directories.map(async dir => {
const stats = await fs.stat(path.join(basePath, dir));
if (!stats.isDirectory()) {
return null;
}
const dbfileStats = await fs.stat(path.join(basePath, dir, 'storage.db'));
return {
id: dir,
mtime: new Date(dbfileStats.mtime),
};
})
);
const workspaceIds = workspaceEntries
.filter(v => v !== null)
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
.map(entry => entry.id);
const items: WorkspaceDocMeta[] = [];
// todo(@pengx17): add cursor based pagination
for (const id of workspaceIds) {
const meta = await getWorkspaceDocMeta(
id,
path.join(basePath, id, 'storage.db')
);
if (meta) {
items.push(meta);
} else {
logger.warn('getDeletedWorkspaces', `No meta found for ${id}`);
}
}
return {
items: items,
};
}
async function importLegacyWorkspaceDb(
originalPath: string,
workspaceId: string
) {
const { SqliteConnection } = await import('@affine/native');
const validationResult = await SqliteConnection.validate(originalPath);
if (validationResult !== ValidationResult.Valid) {
return {};
}
const connection = new SqliteConnection(originalPath);
if (!(await connection.validateImportSchema())) {
return {};
}
const internalFilePath = await getWorkspaceDBPath('workspace', workspaceId);
await fs.ensureDir(path.parse(internalFilePath).dir);
await connection.vacuumInto(internalFilePath);
logger.info(
`recoverBackupWorkspace, vacuum: ${originalPath} -> ${internalFilePath}`
);
await storeWorkspaceMeta(workspaceId, {
id: workspaceId,
mainDBPath: internalFilePath,
});
return {
workspaceId,
};
}
async function importWorkspaceDb(originalPath: string) {
const workspaceId = nanoid(10);
let storage = new DocStorage(originalPath);
if (!(await storage.validate())) {
return await importLegacyWorkspaceDb(originalPath, workspaceId);
}
if (!(await storage.validateImportSchema())) {
return {};
}
const internalFilePath = await getSpaceDBPath(
'local',
'workspace',
workspaceId
);
await fs.ensureDir(path.parse(internalFilePath).dir);
await storage.vacuumInto(internalFilePath);
logger.info(
`recoverBackupWorkspace, vacuum: ${originalPath} -> ${internalFilePath}`
);
storage = new DocStorage(internalFilePath);
await storage.setSpaceId(workspaceId);
return {
workspaceId,
};
}
export async function deleteBackupWorkspace(id: string) {
const basePath = await getDeletedWorkspacesBasePath();
const workspacePath = path.join(basePath, normalizeWorkspaceIdForPath(id));
await fs.remove(workspacePath);
logger.info(
'deleteBackupWorkspace',
`Deleted backup workspace: ${workspacePath}`
);
}
export async function recoverBackupWorkspace(id: string) {
const basePath = await getDeletedWorkspacesBasePath();
const workspacePath = path.join(basePath, normalizeWorkspaceIdForPath(id));
const dbPath = await resolveExistingPathInBase(
basePath,
path.join(workspacePath, 'storage.db'),
{ label: 'backup workspace filepath' }
);
return await importWorkspaceDb(dbPath);
}

View File

@@ -0,0 +1,25 @@
import type { MainEventRegister } from '../type';
import {
deleteBackupWorkspace,
deleteWorkspace,
getDeletedWorkspaces,
listLocalWorkspaceIds,
recoverBackupWorkspace,
trashWorkspace,
} from './handlers';
export * from './handlers';
export * from './subjects';
export const workspaceEvents = {} as Record<string, MainEventRegister>;
export const workspaceHandlers = {
delete: deleteWorkspace,
moveToTrash: trashWorkspace,
getBackupWorkspaces: async () => {
return getDeletedWorkspaces();
},
deleteBackupWorkspace: async (id: string) => deleteBackupWorkspace(id),
recoverBackupWorkspace: async (id: string) => recoverBackupWorkspace(id),
listLocalWorkspaceIds: async () => listLocalWorkspaceIds(),
};

View File

@@ -0,0 +1,103 @@
import path from 'node:path';
import { type SpaceType } from '@affine/nbstore';
import { normalizeWorkspaceIdForPath } from '../../shared/utils';
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 getWorkspaceBasePathV1(
spaceType: SpaceType,
workspaceId: string
) {
const safeWorkspaceId = normalizeWorkspaceIdForPath(workspaceId);
return path.join(
await getAppDataPath(),
spaceType === 'userspace' ? 'userspaces' : 'workspaces',
safeWorkspaceId
);
}
export async function getSpaceBasePath(spaceType: SpaceType) {
return path.join(
await getAppDataPath(),
spaceType === 'userspace' ? 'userspaces' : 'workspaces'
);
}
export function escapeFilename(name: string) {
// replace all special characters with '_' and replace repeated '_' with a single '_' and remove trailing '_'
return name
.replaceAll(/[\\/!@#$%^&*()+~`"':;,?<>|]/g, '_')
.split('_')
.filter(Boolean)
.join('_');
}
export async function getSpaceDBPath(
peer: string,
spaceType: SpaceType,
id: string
) {
const safeId = normalizeWorkspaceIdForPath(id);
return path.join(
await getSpaceBasePath(spaceType),
escapeFilename(peer),
safeId,
'storage.db'
);
}
export async function getDeletedWorkspacesBasePath() {
return path.join(await getAppDataPath(), 'deleted-workspaces');
}
export async function getWorkspaceDBPath(
spaceType: SpaceType,
workspaceId: string
) {
return path.join(
await getWorkspaceBasePathV1(spaceType, workspaceId),
'storage.db'
);
}
export async function getWorkspaceMetaPath(
spaceType: SpaceType,
workspaceId: string
) {
return path.join(
await getWorkspaceBasePathV1(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> {
const dbPath = await getWorkspaceDBPath(spaceType, workspaceId);
return {
mainDBPath: dbPath,
id: workspaceId,
};
}

View File

@@ -0,0 +1,7 @@
import { Subject } from 'rxjs';
import type { WorkspaceMeta } from '../type';
export const workspaceSubjects = {
meta$: new Subject<{ workspaceId: string; meta: WorkspaceMeta }>(),
};

View File

@@ -0,0 +1,294 @@
import { app, Menu } from 'electron';
import { isMacOS } from '../../shared/utils';
import { logger, revealLogFile } from '../logger';
import { uiSubjects } from '../ui/subject';
import { checkForUpdates } from '../updater';
import {
addTab,
initAndShowMainWindow,
reloadView,
showMainWindow,
switchTab,
switchToNextTab,
switchToPreviousTab,
undoCloseTab,
WebContentViewsManager,
} from '../windows-manager';
import { popupManager } from '../windows-manager/popup';
import { WorkerManager } from '../worker/pool';
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.openInSettingModal$.next({
activeTab: 'about',
});
},
},
{ 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('default');
},
},
],
},
// { 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);
},
},
{
role: 'windowMenu',
},
{
label: 'Open devtools',
accelerator: isMac ? 'Cmd+Option+I' : 'Ctrl+Shift+I',
click: () => {
const workerContents = Array.from(
WorkerManager.instance.workers.values()
).map(
worker => [worker.key, worker.browserWindow.webContents] as const
);
const tabs = Array.from(
WebContentViewsManager.instance.tabViewsMap
).map(view => {
const isActive = WebContentViewsManager.instance.isActiveTab(
view[0]
);
return [
view[0] + (isActive ? ' (active)' : ''),
view[1].webContents,
] as const;
});
const popups = Array.from(popupManager.popupWindows$.value.values())
.filter(popup => popup.browserWindow)
.map(popup => {
// oxlint-disable-next-line no-non-null-assertion
return [popup.type, popup.browserWindow!.webContents] as const;
});
const allWebContents = [
['tabs', tabs],
['workers', workerContents],
['popups', popups],
] as const;
Menu.buildFromTemplate(
allWebContents.flatMap(([type, contents]) => {
return [
{
label: type,
enabled: false,
},
...contents.map(([id, webContents]) => ({
label: id,
click: () => {
webContents.openDevTools({
mode: 'undocked',
});
},
})),
{ type: 'separator' },
];
})
).popup();
},
},
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
...(!isMacOS()
? [{ 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 view',
accelerator: 'CommandOrControl+W',
click() {
logger.info('Close view with shortcut');
// tell the active workbench to close the current view
uiSubjects.onCloseView$.next();
},
},
{
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: 'Control+Tab',
click: () => {
switchToNextTab();
},
},
{
label: 'Switch to previous tab',
accelerator: 'Control+Shift+Tab',
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 () => {
// oxlint-disable-next-line 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 () => {
// oxlint-disable-next-line 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;
}

View File

@@ -0,0 +1,35 @@
import type { MainEventRegister } from '../type';
import { applicationMenuSubjects, type NewPageAction } from './subject';
export * from './create';
export * from './subject';
/**
* Events triggered by application menu
*/
export const applicationMenuEvents = {
/**
* File -> New Doc
*/
onNewPageAction: (fn: (type: NewPageAction) => void) => {
const sub = applicationMenuSubjects.newPageAction$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
// todo: properly define the active tab type
openInSettingModal: (
fn: (props: { activeTab: string; scrollAnchor?: string }) => void
) => {
const sub = applicationMenuSubjects.openInSettingModal$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
onOpenJournal: (fn: () => void) => {
const sub = applicationMenuSubjects.openJournal$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
} satisfies Record<string, MainEventRegister>;

View File

@@ -0,0 +1,12 @@
import { Subject } from 'rxjs';
export type NewPageAction = 'page' | 'edgeless' | 'default';
export const applicationMenuSubjects = {
newPageAction$: new Subject<NewPageAction>(),
openJournal$: new Subject<void>(),
openInSettingModal$: new Subject<{
activeTab: string;
scrollAnchor?: string;
}>(),
};

View File

@@ -0,0 +1,304 @@
import { randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import {
AuthTokenBroker,
type AuthTokenPair,
type AuthTokenResponse,
classifyAuthError,
} from '@affine/auth';
import { app, safeStorage } from 'electron';
import { logger } from '../logger';
import { authFetch } from './transport';
const FILEPATH = path.join(app.getPath('userData'), 'auth-sessions.json');
const TEMP_FILEPATH = `${FILEPATH}.tmp`;
const INSTALLATION_FILEPATH = path.join(
app.getPath('userData'),
'installation-id'
);
const brokers = new Map<string, AuthTokenBroker>();
const memoryStore = new Map<string, AuthTokenPair>();
let fileMutation = Promise.resolve();
let installationId: Promise<string> | undefined;
const AUTH_REQUEST_TIMEOUT = 10_000;
function secureStorageAvailable() {
return (
safeStorage.isEncryptionAvailable() &&
(process.platform !== 'linux' ||
safeStorage.getSelectedStorageBackend() !== 'basic_text')
);
}
export function normalizeEndpoint(endpoint: string) {
return new URL(endpoint).origin;
}
async function readStore(): Promise<Record<string, string>> {
try {
const value = JSON.parse(await fs.readFile(FILEPATH, 'utf8'));
return value && typeof value === 'object' && !Array.isArray(value)
? value
: {};
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error('failed to read auth session store', error);
}
return {};
}
}
async function mutateFile(mutator: (store: Record<string, string>) => void) {
const operation = fileMutation.then(async () => {
const store = await readStore();
mutator(store);
await fs.writeFile(TEMP_FILEPATH, JSON.stringify(store), { mode: 0o600 });
await fs.rename(TEMP_FILEPATH, FILEPATH);
});
fileMutation = operation.catch(() => {});
return await operation;
}
function encrypt(pair: AuthTokenPair) {
return safeStorage.encryptString(JSON.stringify(pair)).toString('base64');
}
function decrypt(value: string): AuthTokenPair | null {
try {
return JSON.parse(safeStorage.decryptString(Buffer.from(value, 'base64')));
} catch (error) {
logger.error('failed to decrypt auth session', error);
return null;
}
}
function storage(endpoint: string) {
return {
async load() {
if (!secureStorageAvailable()) {
return memoryStore.get(endpoint) ?? null;
}
const encrypted = (await readStore())[endpoint];
if (!encrypted) return null;
const pair = decrypt(encrypted);
if (!pair) await mutateFile(store => delete store[endpoint]);
return pair;
},
async save(pair: AuthTokenPair) {
if (!secureStorageAvailable()) {
await mutateFile(store => delete store[endpoint]);
memoryStore.set(endpoint, pair);
return;
}
await mutateFile(store => {
store[endpoint] = encrypt(pair);
});
memoryStore.delete(endpoint);
},
async clear() {
memoryStore.delete(endpoint);
await mutateFile(store => delete store[endpoint]);
},
};
}
async function refresh(endpoint: string, refreshToken: string) {
const response = await authFetch(
new URL('/api/auth/session/refresh', endpoint).toString(),
{
method: 'POST',
headers: {
'content-type': 'application/json',
'x-affine-client-kind': 'native',
'x-affine-version': BUILD_CONFIG.appVersion,
},
body: JSON.stringify({ refreshToken }),
signal: AbortSignal.timeout(AUTH_REQUEST_TIMEOUT),
}
);
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw classifyAuthError({
code:
typeof body === 'object' && body && 'code' in body
? String(body.code)
: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE',
});
}
return body as AuthTokenResponse;
}
export function getAuthSessionBroker(endpoint: string) {
const normalized = normalizeEndpoint(endpoint);
let broker = brokers.get(normalized);
if (!broker) {
broker = new AuthTokenBroker(storage(normalized), {
refresh: (token: string) => refresh(normalized, token),
});
brokers.set(normalized, broker);
}
return broker;
}
export async function initializeAuthSessions() {
for (const endpoint of Object.keys(await readStore())) {
try {
getAuthSessionBroker(endpoint);
} catch (error) {
logger.error('failed to initialize auth session', endpoint, error);
}
}
}
export function isManagedAuthEndpoint(url: string) {
try {
const parsed = new URL(url);
if (parsed.protocol === 'ws:') parsed.protocol = 'http:';
if (parsed.protocol === 'wss:') parsed.protocol = 'https:';
return brokers.has(parsed.origin);
} catch {
return false;
}
}
export async function setAuthSession(
endpoint: string,
response: AuthTokenResponse
) {
await getAuthSessionBroker(endpoint).set(response);
return { persistent: secureStorageAvailable() };
}
export function getInstallationId() {
if (installationId) return installationId;
const pending = fs
.readFile(INSTALLATION_FILEPATH, 'utf8')
.then(value => {
if (
!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
value
)
) {
throw new Error('Invalid installation id');
}
return value;
})
.catch(async () => {
const value = randomUUID();
await fs.writeFile(INSTALLATION_FILEPATH, value, { mode: 0o600 });
return value;
});
installationId = pending;
void pending.catch(() => {
if (installationId === pending) installationId = undefined;
});
return pending;
}
export async function revokeAuthSession(endpoint: string) {
const normalized = normalizeEndpoint(endpoint);
await getAuthSessionBroker(normalized).revoke(
'sign-out',
async (refreshToken: string) => {
const response = await authFetch(
new URL('/api/auth/session/revoke', normalized).toString(),
{
method: 'POST',
headers: {
'content-type': 'application/json',
'x-affine-client-kind': 'native',
'x-affine-version': BUILD_CONFIG.appVersion,
},
body: JSON.stringify({ refreshToken }),
signal: AbortSignal.timeout(AUTH_REQUEST_TIMEOUT),
}
);
if (!response.ok) throw new Error('Failed to revoke auth session');
}
);
}
export async function clearAuthSession(endpoint: string, reason: string) {
await getAuthSessionBroker(endpoint).clear(reason);
}
export async function getValidAccessToken(
endpoint: string,
minValidity = 60_000
) {
try {
return await getAuthSessionBroker(endpoint).getValidAccessToken(
minValidity
);
} catch (error) {
const classified = classifyAuthError(error);
if (classified.code === 'AUTH_SESSION_EMPTY' || !classified.transient) {
return null;
}
throw classified;
}
}
export async function getAccessTokenForUrl(url: string, minValidity = 60_000) {
try {
const parsed = new URL(url);
if (parsed.protocol === 'ws:') parsed.protocol = 'http:';
if (parsed.protocol === 'wss:') parsed.protocol = 'https:';
return await getValidAccessToken(parsed.origin, minValidity);
} catch (error) {
if (error instanceof TypeError) return null;
throw error;
}
}
export async function refreshAccessTokenForUrl(url: string) {
const parsed = new URL(url);
if (parsed.protocol === 'ws:') parsed.protocol = 'http:';
if (parsed.protocol === 'wss:') parsed.protocol = 'https:';
return (
await getAuthSessionBroker(parsed.origin).refresh('access-token-expired')
).accessToken;
}
async function authorizedRequest(
request: Request,
targetUrl: string,
accessToken?: string
) {
const cloned = request.clone();
const headers = new Headers(cloned.headers);
headers.delete('Authorization');
const token = accessToken ?? (await getAccessTokenForUrl(targetUrl));
if (token) headers.set('Authorization', `Bearer ${token}`);
return new Request(targetUrl, {
body:
cloned.method === 'GET' || cloned.method === 'HEAD'
? undefined
: cloned.body,
headers,
method: cloned.method,
redirect: cloned.redirect,
signal: cloned.signal,
duplex: 'half',
});
}
export async function executeAuthSessionRequest(
request: Request,
targetUrl: string,
execute: (request: Request) => Promise<Response>
) {
const retry = request.clone();
const response = await execute(await authorizedRequest(request, targetUrl));
if (response.status !== 401) return response;
const body = (await response
.clone()
.json()
.catch(() => null)) as { code?: string } | null;
if (body?.code !== 'ACCESS_TOKEN_EXPIRED') return response;
const token = await refreshAccessTokenForUrl(targetUrl);
return await execute(await authorizedRequest(retry, targetUrl, token));
}

View File

@@ -0,0 +1,218 @@
import os from 'node:os';
import type { AuthTokenResponse } from '@affine/auth';
import { session } from 'electron';
import { logger } from '../logger';
import type { NamespaceHandlers } from '../type';
import {
clearAuthSession,
getInstallationId,
getValidAccessToken,
revokeAuthSession,
setAuthSession,
} from './auth-session';
import { authFetch, getAuthTransportSession } from './transport';
export interface SignInResponse {
id?: string;
email?: string;
name?: string;
hasPassword?: boolean | null;
avatarUrl?: string | null;
emailVerified?: boolean;
exchangeCode?: string;
redirectUri?: string;
}
export interface PasswordSignInResponse extends SignInResponse {
id: string;
email: string;
name: string;
hasPassword: boolean | null;
avatarUrl: string | null;
emailVerified: boolean;
sessionOnly?: boolean;
}
const authCookieNames = [
'affine_session',
'affine_user_id',
'affine_csrf_token',
];
function authUrl(endpoint: string, path: string) {
return new URL(path, endpoint).toString();
}
async function readJson<T>(response: Response): Promise<T> {
const text = await response.text();
if (!response.ok) {
let message = text || response.statusText;
try {
const error = JSON.parse(text);
if (typeof error.message === 'string') {
message = error.message;
}
} catch {}
throw new Error(message);
}
return text ? JSON.parse(text) : ({} as T);
}
async function fetchAuth(endpoint: string, path: string, body?: unknown) {
return await authFetch(authUrl(endpoint, path), {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-affine-client-kind': 'native',
'x-affine-version': BUILD_CONFIG.appVersion,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
}
async function clearAuthCookies(endpoint: string) {
const sessions = [session.defaultSession, getAuthTransportSession()];
await Promise.all(
sessions.flatMap(authSession =>
authCookieNames.map(name =>
authSession.cookies
.remove(endpoint, name)
.catch(error =>
logger.debug(
'failed to clear native auth cookie',
endpoint,
name,
error
)
)
)
)
);
}
async function exchangeSession(endpoint: string, response: SignInResponse) {
if (!response.exchangeCode) {
throw new Error('Missing native auth exchange code.');
}
const exchangeResponse = await fetchAuth(
endpoint,
'/api/auth/session/exchange',
{
code: response.exchangeCode,
installationId: await getInstallationId(),
platform: 'electron',
deviceName: os.hostname(),
}
);
const body = await readJson<AuthTokenResponse>(exchangeResponse);
const { persistent } = await setAuthSession(endpoint, body);
await clearAuthCookies(endpoint);
return { persistent };
}
export const authHandlers = {
signInMagicLink: async (
_,
endpoint: string,
email: string,
token: string,
clientNonce?: string
) => {
const response = await fetchAuth(endpoint, '/api/auth/magic-link', {
email,
token,
client_nonce: clientNonce,
});
const body = await readJson<SignInResponse>(response);
const { persistent } = await exchangeSession(endpoint, body);
return { sessionOnly: !persistent };
},
signInOauth: async (
_,
endpoint: string,
code: string,
state: string,
clientNonce?: string
) => {
const response = await fetchAuth(endpoint, '/api/oauth/callback', {
code,
state,
client_nonce: clientNonce,
});
const body = await readJson<SignInResponse>(response);
const { persistent } = await exchangeSession(endpoint, body);
return { redirectUri: body.redirectUri, sessionOnly: !persistent };
},
signInPassword: async (
_,
endpoint: string,
credential: {
email: string;
password: string;
verifyToken?: string;
challenge?: string;
}
) => {
const response = await authFetch(authUrl(endpoint, '/api/auth/sign-in'), {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-affine-client-kind': 'native',
'x-affine-version': BUILD_CONFIG.appVersion,
...(credential.verifyToken
? { 'x-captcha-token': credential.verifyToken }
: {}),
...(credential.verifyToken
? {
'x-captcha-provider': credential.challenge
? 'hashcash'
: 'turnstile',
}
: {}),
...(credential.challenge
? { 'x-captcha-challenge': credential.challenge }
: {}),
},
body: JSON.stringify({
email: credential.email,
password: credential.password,
}),
});
const body = await readJson<PasswordSignInResponse>(response);
const { persistent } = await exchangeSession(endpoint, body);
return { ...body, sessionOnly: !persistent };
},
signInOpenAppSignInCode: async (_e, endpoint: string, code: string) => {
const response = await fetchAuth(endpoint, '/api/auth/open-app/sign-in', {
code,
});
const { persistent } = await exchangeSession(
endpoint,
await readJson(response)
);
return { sessionOnly: !persistent };
},
signOut: async (_e, endpoint: string) => {
try {
await revokeAuthSession(endpoint);
} finally {
await clearAuthCookies(endpoint);
}
},
clearSession: async (_e, endpoint: string) => {
await clearAuthSession(endpoint, 'local-clear');
},
getValidAccessToken: async (_e, endpoint: string) => {
return { token: await getValidAccessToken(endpoint, 120_000) };
},
} satisfies NamespaceHandlers;

View File

@@ -0,0 +1,11 @@
import { session } from 'electron';
const AUTH_TRANSPORT_PARTITION = 'affine-auth-transport';
export function getAuthTransportSession() {
return session.fromPartition(AUTH_TRANSPORT_PARTITION, { cache: false });
}
export function authFetch(input: string | Request, init?: RequestInit) {
return getAuthTransportSession().fetch(input, init);
}

View File

@@ -0,0 +1,296 @@
import path from 'node:path';
import { app, safeStorage } from 'electron';
import { PersistentJSONFileStorage } from '../shared-storage/json-file';
import type { NamespaceHandlers } from '../type';
const byokStorage = new PersistentJSONFileStorage(
path.join(app.getPath('userData'), 'workspace-byok-keys.json')
);
export function disposeWorkspaceByokStorage() {
byokStorage.dispose();
}
const allowedProviders = new Set(['openai', 'anthropic', 'gemini', 'fal']);
const allowedInputs = new Set(['text', 'image', 'audio', 'file']);
const allowedOutputs = new Set([
'text',
'object',
'structured',
'embedding',
'rerank',
'image',
]);
const allowedFeatures = new Set(['tool_calling', 'reasoning', 'web_search']);
const allowedAttachmentKinds = new Set(['image', 'audio', 'file']);
const allowedAttachmentSources = new Set([
'url',
'data',
'bytes',
'file_handle',
]);
type WorkspaceByokKey = {
id: string;
provider: 'openai' | 'anthropic' | 'gemini' | 'fal';
name: string;
description?: string | null;
credential: string;
definition: {
endpoint: {
kind: 'provider_default' | 'openai_compatible';
url?: string | null;
dialect?: 'responses' | 'chat_completions' | null;
};
models: Array<{
modelId: string;
enabled: boolean;
capabilities: Array<{
input: string[];
output: string[];
features: string[];
attachmentKinds: string[];
attachmentSources: string[];
}>;
}>;
};
sortOrder?: number | null;
enabled?: boolean | null;
};
type WorkspaceByokKeyInput = Omit<
WorkspaceByokKey,
'credential' | 'definition'
> & {
credential?: string | null;
definition?: WorkspaceByokKey['definition'];
};
function assertSupported() {
if (!safeStorage.isEncryptionAvailable()) {
throw new Error('Secure BYOK key storage is not available.');
}
}
function hasOwnField(
key: WorkspaceByokKeyInput,
field: keyof WorkspaceByokKey
) {
return Object.prototype.hasOwnProperty.call(key, field);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isAllowedStringArray(
value: unknown,
allowed: Set<string>
): value is string[] {
return (
Array.isArray(value) &&
value.every(item => typeof item === 'string' && allowed.has(item))
);
}
function isValidEndpoint(value: unknown) {
if (!isRecord(value) || typeof value.kind !== 'string') return false;
if (value.kind === 'provider_default')
return value.url == null && value.dialect == null;
if (
value.kind !== 'openai_compatible' ||
typeof value.url !== 'string' ||
!['responses', 'chat_completions'].includes(String(value.dialect))
)
return false;
try {
const endpoint = new URL(value.url);
return (
(endpoint.protocol === 'http:' || endpoint.protocol === 'https:') &&
!!endpoint.hostname &&
!endpoint.username &&
!endpoint.password
);
} catch {
return false;
}
}
function isValidCapability(value: unknown) {
return (
isRecord(value) &&
isAllowedStringArray(value.input, allowedInputs) &&
value.input.length > 0 &&
isAllowedStringArray(value.output, allowedOutputs) &&
value.output.length > 0 &&
isAllowedStringArray(value.features, allowedFeatures) &&
isAllowedStringArray(value.attachmentKinds, allowedAttachmentKinds) &&
isAllowedStringArray(value.attachmentSources, allowedAttachmentSources)
);
}
function isValidDefinition(
value: unknown
): value is WorkspaceByokKey['definition'] {
return (
isRecord(value) &&
isValidEndpoint(value.endpoint) &&
Array.isArray(value.models) &&
value.models.length > 0 &&
value.models.every(
model =>
isRecord(model) &&
typeof model.modelId === 'string' &&
model.modelId.trim().length > 0 &&
model.modelId.length <= 512 &&
typeof model.enabled === 'boolean' &&
Array.isArray(model.capabilities) &&
model.capabilities.length > 0 &&
model.capabilities.every(isValidCapability)
)
);
}
function normalizeKey(
key: WorkspaceByokKeyInput,
existing?: WorkspaceByokKey,
defaultSortOrder = 0
): WorkspaceByokKey {
if (!allowedProviders.has(key.provider)) {
throw new Error('Unsupported BYOK provider.');
}
const credential = key.credential ?? existing?.credential;
const definition = key.definition ?? existing?.definition;
if (
definition?.endpoint.kind === 'openai_compatible' &&
key.provider !== 'openai'
) {
throw new Error('OpenAI-compatible endpoints require OpenAI provider.');
}
if (!key.id || !key.name || !credential || !isValidDefinition(definition)) {
throw new Error('Invalid BYOK key.');
}
return {
id: key.id,
provider: key.provider,
name: key.name,
description: hasOwnField(key, 'description')
? (key.description ?? null)
: (existing?.description ?? null),
credential,
definition,
sortOrder: hasOwnField(key, 'sortOrder')
? (key.sortOrder ?? defaultSortOrder)
: (existing?.sortOrder ?? defaultSortOrder),
enabled: hasOwnField(key, 'enabled')
? (key.enabled ?? true)
: (existing?.enabled ?? true),
};
}
function encryptKey(key: WorkspaceByokKey) {
return safeStorage
.encryptString(JSON.stringify(normalizeKey(key)))
.toString('base64');
}
function decryptKey(value: string): WorkspaceByokKey | null {
try {
return normalizeKey(
JSON.parse(safeStorage.decryptString(Buffer.from(value, 'base64')))
);
} catch {
return null;
}
}
function sortWorkspaceKeys(keys: WorkspaceByokKey[]) {
return keys.toSorted((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
}
function readWorkspaceKeys(workspaceId: string): WorkspaceByokKey[] {
assertSupported();
const encryptedKeys = byokStorage.get<string[]>(workspaceId) ?? [];
return sortWorkspaceKeys(
encryptedKeys.flatMap(value => {
const key = decryptKey(value);
return key ? [key] : [];
})
);
}
function writeWorkspaceKeys(workspaceId: string, keys: WorkspaceByokKey[]) {
assertSupported();
byokStorage.set(workspaceId, keys.map(encryptKey));
}
function toPublicKey({ credential: _, ...key }: WorkspaceByokKey) {
return {
...key,
storage: 'local',
configured: true,
};
}
export const byokStorageHandlers = {
isSupported: async () => safeStorage.isEncryptionAvailable(),
listWorkspaceKeys: async (_e, workspaceId: string) => {
return readWorkspaceKeys(workspaceId).map(toPublicKey);
},
getWorkspaceLeaseProviders: async (_e, workspaceId: string) => {
return readWorkspaceKeys(workspaceId).filter(key => key.enabled !== false);
},
upsertWorkspaceKey: async (
_e,
workspaceId: string,
key: WorkspaceByokKeyInput
) => {
const keys = readWorkspaceKeys(workspaceId);
const index = keys.findIndex(storedKey => storedKey.id === key.id);
const nextKey = normalizeKey(
key,
index === -1 ? undefined : keys[index],
keys.length
);
if (index === -1) {
keys.push(nextKey);
} else {
keys[index] = nextKey;
}
writeWorkspaceKeys(workspaceId, keys);
return toPublicKey(nextKey);
},
deleteWorkspaceKey: async (_e, workspaceId: string, keyId: string) => {
writeWorkspaceKeys(
workspaceId,
readWorkspaceKeys(workspaceId).filter(key => key.id !== keyId)
);
return true;
},
reorderWorkspaceKeys: async (_e, workspaceId: string, ids: string[]) => {
const keys = readWorkspaceKeys(workspaceId);
const byId = new Map(keys.map(key => [key.id, key]));
const ordered = ids
.map((id, sortOrder) => {
const key = byId.get(id);
byId.delete(id);
return key ? ({ ...key, sortOrder } as WorkspaceByokKey) : null;
})
.filter((key): key is WorkspaceByokKey => !!key);
const nextKeys = sortWorkspaceKeys([
...ordered,
...Array.from(byId.values()).map((key, index) => ({
...key,
sortOrder: ordered.length + index,
})),
]);
writeWorkspaceKeys(workspaceId, nextKeys);
return nextKeys.map(toPublicKey);
},
clearWorkspaceKeys: async (_e, workspaceId: string) => {
byokStorage.del(workspaceId);
return true;
},
} satisfies NamespaceHandlers;

View File

@@ -0,0 +1,56 @@
import type { MediaStats } from '@toeverything/infra';
import { app } from 'electron';
import { logger } from './logger';
import { globalStateStorage } from './shared-storage/storage';
const beforeAppQuitRegistry: (() => void)[] = [];
const beforeTabCloseRegistry: ((tabId: string) => void)[] = [];
export function beforeAppQuit(fn: () => void) {
beforeAppQuitRegistry.push(fn);
}
export function beforeTabClose(fn: (tabId: string) => void) {
beforeTabCloseRegistry.push(fn);
}
app.on('before-quit', () => {
beforeAppQuitRegistry.forEach(fn => {
// some cleanup functions might throw on quit and crash the app
try {
fn();
} catch (err) {
logger.warn('cleanup error on quit', err);
}
});
});
export function onTabClose(tabId: string) {
beforeTabCloseRegistry.forEach(fn => {
try {
fn(tabId);
} catch (err) {
logger.warn('cleanup error on tab close', err);
}
});
}
app.on('ready', () => {
globalStateStorage.set('media:playback-state', null);
globalStateStorage.set('media:stats', null);
});
beforeAppQuit(() => {
globalStateStorage.set('media:playback-state', null);
globalStateStorage.set('media:stats', null);
});
// set audio play state
beforeTabClose(tabId => {
const stats = globalStateStorage.get<MediaStats | null>('media:stats');
if (stats && stats.tabId === tabId) {
globalStateStorage.set('media:playback-state', null);
globalStateStorage.set('media:stats', null);
}
});

View File

@@ -0,0 +1,12 @@
import { clipboard, nativeImage } from 'electron';
import type { NamespaceHandlers } from '../type';
export const clipboardHandlers = {
copyAsPNG: async (_, arrayBuffer: ArrayBuffer) => {
const image = nativeImage.createFromBuffer(Buffer.from(arrayBuffer));
if (image.isEmpty()) return false;
clipboard.writeImage(image);
return true;
},
} satisfies NamespaceHandlers;

View File

@@ -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;

View File

@@ -0,0 +1 @@
export * from './handlers';

View File

@@ -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)),
});

View File

@@ -0,0 +1,24 @@
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';

View File

@@ -0,0 +1,155 @@
import path from 'node:path';
import type { App } from 'electron';
import { buildType, isDev } from './config';
import { logger } from './logger';
import { uiSubjects } from './ui';
import {
addTabWithUrl,
loadUrlInActiveTab,
openUrlInHiddenWindow,
showMainWindow,
} from './windows-manager';
let protocol = buildType === 'stable' ? 'affine' : `affine-${buildType}`;
if (isDev) {
protocol = 'affine-dev';
}
const authMethods = new Set(['magic-link', 'oauth', 'open-app-signin']);
function summarizeDeepLink(rawUrl: string) {
try {
const url = new URL(rawUrl);
const method = url.searchParams.get('method');
const server = url.searchParams.get('server');
let serverOrigin: string | undefined;
try {
serverOrigin = server ? new URL(server).origin : undefined;
} catch {
serverOrigin = undefined;
}
return {
protocol: url.protocol,
action: url.hostname,
method: method && authMethods.has(method) ? method : undefined,
serverOrigin,
};
} catch {
return { valid: false };
}
}
function logDeepLinkFailure(rawUrl: string, error: unknown) {
logger.error('failed to handle affine url', summarizeDeepLink(rawUrl), {
error: error instanceof Error ? error.name : typeof error,
});
}
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) => {
logger.log('open-url', summarizeDeepLink(url));
if (url.startsWith(`${protocol}://`)) {
event.preventDefault();
app
.whenReady()
.then(() => handleAffineUrl(url))
.catch(e => {
logDeepLinkFailure(url, e);
});
}
});
// on windows & linux, we need to listen for the second-instance event
app.on('second-instance', (event, commandLine) => {
showMainWindow()
.then(() => {
const url = commandLine.pop();
if (url?.startsWith(`${protocol}://`)) {
event.preventDefault();
handleAffineUrl(url).catch(e => {
logDeepLinkFailure(url, e);
});
}
})
.catch(e => console.error('Failed to restore or create window:', e));
});
app.on('ready', () => {
// app may be brought up without having a running instance
// need to read the url from the command line
const url = process.argv.at(-1);
logger.log(
'url from argv',
url?.startsWith(`${protocol}://`)
? summarizeDeepLink(url)
: { deepLink: false, argumentCount: process.argv.length }
);
if (url?.startsWith(`${protocol}://`)) {
handleAffineUrl(url).catch(e => {
logDeepLinkFailure(url, e);
});
}
});
}
async function handleAffineUrl(url: string) {
await showMainWindow();
logger.info('open affine url', summarizeDeepLink(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');
const server = urlObj.searchParams.get('server') || undefined;
if (
!method ||
(method !== 'magic-link' &&
method !== 'oauth' &&
method !== 'open-app-signin') ||
!payload
) {
logger.error('Invalid authentication url', summarizeDeepLink(url));
return;
}
uiSubjects.authenticationRequest$.next({
method,
payload,
server,
});
} else if (
urlObj.searchParams.get('new-tab') &&
urlObj.pathname.startsWith('/workspace')
) {
// @todo(@forehalo): refactor router utilities
// basename of /workspace/xxx/yyy is /workspace/xxx
await addTabWithUrl(url);
} else {
const hiddenWindow = urlObj.searchParams.get('hidden')
? await openUrlInHiddenWindow(urlObj)
: await loadUrlInActiveTab(url);
if (hiddenWindow) {
// when hidden window closed, the main window will be hidden somehow
hiddenWindow.on('close', () => {
void showMainWindow().catch(e => {
logger.error('Failed to restore main window:', e);
});
});
}
}
}

View File

@@ -0,0 +1,123 @@
import { ipcMain, powerMonitor, webContents } from 'electron';
import {
AFFINE_EVENT_CHANNEL_NAME,
AFFINE_EVENT_SUBSCRIBE_CHANNEL_NAME,
} from '../shared/type';
import { applicationMenuEvents } from './application-menu';
import { beforeAppQuit } from './cleanup';
import { logger } from './logger';
import { powerEvents } from './power';
import { recordingEvents } from './recording';
import { checkSource } from './security-restrictions';
import { sharedStorageEvents } from './shared-storage';
import { uiEvents } from './ui/events';
import { updaterEvents } from './updater/event';
import { popupEvents } from './windows-manager/popup';
export const allEvents = {
applicationMenu: applicationMenuEvents,
updater: updaterEvents,
ui: uiEvents,
sharedStorage: sharedStorageEvents,
recording: recordingEvents,
popup: popupEvents,
power: powerEvents,
};
const subscriptions = new Map<number, Set<string>>();
function getTargetContents(channel: string) {
const targets: Electron.WebContents[] = [];
subscriptions.forEach((channels, id) => {
if (!channels.has(channel)) return;
const wc = webContents.fromId(id);
if (wc && !wc.isDestroyed()) {
targets.push(wc);
}
});
return targets;
}
function addSubscription(sender: Electron.WebContents, channel: string) {
const id = sender.id;
const set = subscriptions.get(id) ?? new Set<string>();
set.add(channel);
if (!subscriptions.has(id)) {
sender.once('destroyed', () => {
subscriptions.delete(id);
});
}
subscriptions.set(id, set);
}
function removeSubscription(sender: Electron.WebContents, channel: string) {
const id = sender.id;
const set = subscriptions.get(id);
if (!set) return;
set.delete(channel);
if (set.size === 0) {
subscriptions.delete(id);
} else {
subscriptions.set(id, set);
}
}
export function registerEvents() {
const unsubs: (() => void)[] = [];
const onSubscribe = (
event: Electron.IpcMainEvent,
action: 'subscribe' | 'unsubscribe',
channel: string
) => {
if (!checkSource(event) || typeof channel !== 'string') return;
if (action === 'subscribe') {
addSubscription(event.sender, channel);
if (channel === 'power:power-source') {
event.sender.send(
AFFINE_EVENT_CHANNEL_NAME,
channel,
powerMonitor.isOnBatteryPower()
);
}
} else {
removeSubscription(event.sender, channel);
}
};
ipcMain.on(AFFINE_EVENT_SUBSCRIBE_CHANNEL_NAME, onSubscribe);
unsubs.push(() =>
ipcMain.removeListener(AFFINE_EVENT_SUBSCRIBE_CHANNEL_NAME, onSubscribe)
);
// 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'
)
);
getTargetContents(chan).forEach(wc => {
if (!wc.isDestroyed()) {
wc.send(AFFINE_EVENT_CHANNEL_NAME, chan, ...args);
}
});
});
unsubs.push(unsubscribe);
}
}
unsubs.forEach(unsub => {
beforeAppQuit(() => {
unsub();
});
});
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1 @@
export * from './handlers';

View File

@@ -0,0 +1,117 @@
import { I18n } from '@affine/i18n';
import { ipcMain } from 'electron';
import { AFFINE_API_CHANNEL_NAME } from '../shared/type';
import { authHandlers } from './auth/handlers';
import { byokStorageHandlers } from './byok-storage/handlers';
import { clipboardHandlers } from './clipboard';
import { configStorageHandlers } from './config-storage';
import { findInPageHandlers } from './find-in-page';
import { importHandlers } from './import';
import { getLogFilePath, logger, revealLogFile } from './logger';
import { recordingHandlers } from './recording';
import { checkSource } from './security-restrictions';
import { sharedStorageHandlers } from './shared-storage';
import { uiHandlers } from './ui/handlers';
import { updaterHandlers } from './updater';
import { popupHandlers } from './windows-manager/popup';
import { workerHandlers } from './worker/handlers';
export const debugHandlers = {
revealLogFile: async () => {
return revealLogFile();
},
logFilePath: async () => {
return getLogFilePath();
},
};
export const i18nHandlers = {
changeLanguage: async (_: Electron.IpcMainInvokeEvent, language: string) => {
return I18n.changeLanguage(language);
},
};
// 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,
updater: updaterHandlers,
configStorage: configStorageHandlers,
findInPage: findInPageHandlers,
import: importHandlers,
sharedStorage: sharedStorageHandlers,
worker: workerHandlers,
recording: recordingHandlers,
popup: popupHandlers,
i18n: i18nHandlers,
byokStorage: byokStorageHandlers,
auth: authHandlers,
};
export const registerHandlers = () => {
const handleIpcMessage = async (
e: Electron.IpcMainInvokeEvent,
...args: any[]
) => {
// args[0] is the `{namespace:key}`
if (!checkSource(e) || 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[]) => {
try {
return await handleIpcMessage(e, ...args);
} catch (error) {
logger.error(`error in ipc handler when calling ${args[0]}`, error);
throw error;
}
});
ipcMain.on(AFFINE_API_CHANNEL_NAME, (e, ...args: any[]) => {
if (!checkSource(e)) return;
handleIpcMessage(e, ...args)
.then(ret => {
e.returnValue = ret;
})
.catch(() => {
// never throw
});
});
};

View File

@@ -0,0 +1,147 @@
import path from 'node:path';
import type { _AsyncVersionOf } from 'async-call-rpc';
import { AsyncCall } from 'async-call-rpc';
import type {
BaseWindow,
OpenDialogOptions,
SaveDialogOptions,
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 { beforeAppQuit } from './cleanup';
import { logger } from './logger';
import { openExternalSafely } from './security/open-external';
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'] : [],
serviceName: 'affine-helper',
});
this.#process = helperProcess;
this.ready = new Promise((resolve, reject) => {
helperProcess.once('spawn', () => {
try {
logger.info('[helper] forked', helperProcess.pid);
resolve();
} catch (err) {
logger.error('[helper] connectMain error', err);
reject(err);
}
});
});
beforeAppQuit(() => {
this.#process.kill();
});
this.#process.on('exit', code => {
logger.error('[helper] process exited', { code });
HelperProcessManager._instance = null;
});
this.#process.on('error', err => {
logger.error('[helper] process error', err);
HelperProcessManager._instance = null;
});
}
// 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 () => {
try {
helperPort.close();
rendererPort.close();
} catch (err) {
logger.error('[helper] close port error', err);
}
};
}
// bridge main <-> helper process
// also set up the RPC to the helper process
connectMain(window: BaseWindow) {
const dialogMethods = {
showOpenDialog: async (opts: OpenDialogOptions) => {
return dialog.showOpenDialog(window, opts);
},
showSaveDialog: async (opts: SaveDialogOptions) => {
return dialog.showSaveDialog(window, opts);
},
};
const shellMethods = {
openExternal: openExternalSafely as typeof shell.openExternal,
showItemInFolder: shell.showItemInFolder.bind(shell),
};
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),
log: false,
});
}
}
export async function ensureHelperProcess() {
const helperProcessManager = HelperProcessManager.instance;
await helperProcessManager.ready;
return helperProcessManager;
}

View File

@@ -0,0 +1,39 @@
import type { CreateImportSessionOptions } from '@affine/native';
import {
cancelImportSession,
createImportSession,
disposeImportSession,
nextImportBatch,
} from '@affine/native';
export const importHandlers = {
createImportSession: (
event: Electron.IpcMainInvokeEvent,
options: CreateImportSessionOptions
) => {
void event;
return createImportSession({
format: options.format,
source: options.source,
batchLimits: options.batchLimits,
});
},
nextImportBatch: (event: Electron.IpcMainInvokeEvent, sessionId: string) => {
void event;
return nextImportBatch(sessionId);
},
cancelImportSession: (
event: Electron.IpcMainInvokeEvent,
sessionId: string
) => {
void event;
return cancelImportSession(sessionId);
},
disposeImportSession: (
event: Electron.IpcMainInvokeEvent,
sessionId: string
) => {
void event;
return disposeImportSession(sessionId);
},
};

View File

@@ -0,0 +1 @@
export { importHandlers } from './handlers';

View File

@@ -0,0 +1,152 @@
import path from 'node:path';
import * as Sentry from '@sentry/electron/main';
import { IPCMode } from '@sentry/electron/main';
import { app, protocol } from 'electron';
import { createApplicationMenu } from './application-menu/create';
import { buildType, isDev, 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 { setupRecordingFeature } from './recording/feature';
import { registerSecurityRestrictions } from './security-restrictions';
import { setupTrayState } from './tray';
import { registerUpdater } from './updater';
import { launch } from './windows-manager/launcher';
import { launchStage } from './windows-manager/stage';
app.enableSandbox();
if (isDev) {
// In electron the dev server will be resolved to 0.0.0.0, but it
// might be blocked by electron.
// See https://github.com/webpack/webpack-dev-server/pull/384
app.commandLine.appendSwitch('host-resolver-rules', 'MAP 0.0.0.0 127.0.0.1');
}
// https://github.com/electron/electron/issues/43556
// // `CalculateNativeWinOcclusion` - Disable native window occlusion tracker (https://groups.google.com/a/chromium.org/g/embedder-dev/c/ZF3uHHyWLKw/m/VDN2hDXMAAAJ)
const disabledFeatures = [
'PlzDedicatedWorker',
'CalculateNativeWinOcclusion',
// Disable Chrome autofill and password save prompts
'AutofillServerCommunication',
'AutofillProfileCleanup',
'AutofillAddressProfileSavePrompt',
'AutofillPaymentCards',
'AutofillEnableAccountWalletStorage',
'SavePasswordBubble',
].join(',');
app.commandLine.appendSwitch('disable-features', disabledFeatures);
app.commandLine.appendSwitch('disable-blink-features', 'Autofill');
// Following features are enabled from the runtime:
// `DocumentPolicyIncludeJSCallStacksInCrashReports` - https://www.electronjs.org/docs/latest/api/web-frame-main#framecollectjavascriptcallstack-experimental
// `EarlyEstablishGpuChannel` - Refs https://issues.chromium.org/issues/40208065
// `EstablishGpuChannelAsync` - Refs https://issues.chromium.org/issues/40208065
const enabledFeatures = [
'DocumentPolicyIncludeJSCallStacksInCrashReports',
'EarlyEstablishGpuChannel',
'EstablishGpuChannelAsync',
].join(',');
app.commandLine.appendSwitch('enable-features', enabledFeatures);
const enabledBlinkFeatures = ['CSSTextAutoSpace', 'WebCodecs'].join(',');
app.commandLine.appendSwitch('enable-blink-features', enabledBlinkFeatures);
app.commandLine.appendSwitch('force-color-profile', 'srgb');
// 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);
}
// oxlint-disable-next-line typescript/no-var-requires
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 or responding deep link, 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', () => {
if (app.isReady()) {
launch().catch(e => console.error('Failed launch:', e));
}
});
setupDeepLink(app);
registerSecurityRestrictions();
/**
* Create app window when background process will be ready
*/
app
.whenReady()
.then(registerProtocol)
.then(registerHandlers)
.then(registerEvents)
.then(launch)
.then(createApplicationMenu)
.then(registerUpdater)
.then(setupRecordingFeature)
.then(setupTrayState)
.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,
},
});
Sentry.setTags({
distribution: 'electron',
appVersion: app.getVersion(),
});
}
protocol.registerSchemesAsPrivileged([
{
scheme: 'assets',
privileges: {
secure: true,
corsEnabled: true,
supportFetchAPI: true,
standard: true,
stream: true,
},
},
]);

View File

@@ -0,0 +1,25 @@
import { app, shell } from 'electron';
import log from 'electron-log/main';
import { isDev } from './config';
export const logger = log.scope('main');
log.initialize({
preload: false,
});
log.transports.file.level = 'info';
log.transports.console.level = isDev ? 'info' : false;
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;
});

View File

@@ -0,0 +1,29 @@
import { powerMonitor } from 'electron';
/**
* Power-related event handlers for the Electron main process.
*/
export const powerEvents = {
/**
* Subscribes to system power source changes.
* Emits the initial state immediately upon subscription.
* @param emit - Callback function to send power state to the renderer.
* @returns A cleanup function to remove listeners from powerMonitor.
*/
'power-source': (emit: (isOnBattery: boolean) => void) => {
// emit initial state
emit(powerMonitor.isOnBatteryPower());
const onBattery = () => emit(true);
const onAC = () => emit(false);
powerMonitor.on('on-battery', onBattery);
powerMonitor.on('on-ac', onAC);
// cleanup
return () => {
powerMonitor.off('on-battery', onBattery);
powerMonitor.off('on-ac', onAC);
};
},
};

View File

@@ -0,0 +1,295 @@
import path, { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { app, net, protocol, session } from 'electron';
import { anotherHost, mainHost } from '../shared/internal-origin';
import {
isPathInsideBase,
isWindows,
resolveExistingPathInBase,
resolvePathInBase,
resourcesPath,
} from '../shared/utils';
import {
executeAuthSessionRequest,
getAccessTokenForUrl,
initializeAuthSessions,
isManagedAuthEndpoint,
} from './auth/auth-session';
import { buildType, isDev } from './config';
import { logger } from './logger';
const webStaticDir = join(resourcesPath, 'web-static');
const devServerBase = process.env.DEV_SERVER_URL;
const localWhiteListDirs = [
path.resolve(app.getPath('sessionData')),
path.resolve(app.getPath('temp')),
];
function isPathInWhiteList(filepath: string) {
return localWhiteListDirs.some(whitelistDir =>
isPathInsideBase(whitelistDir, filepath, {
caseInsensitive: isWindows(),
})
);
}
async function resolveWhitelistedLocalPath(filepath: string) {
for (const whitelistDir of localWhiteListDirs) {
try {
return await resolveExistingPathInBase(whitelistDir, filepath, {
caseInsensitive: isWindows(),
label: 'filepath',
});
} catch {
continue;
}
}
throw new Error('Invalid filepath');
}
const apiBaseByBuildType: Record<typeof buildType, string> = {
stable: 'https://app.affine.pro',
beta: 'https://insider.affine.pro',
internal: 'https://insider.affine.pro',
canary: 'https://affine.fail',
};
function resolveApiBaseUrl() {
if (isDev && devServerBase) {
return devServerBase;
}
return apiBaseByBuildType[buildType] ?? apiBaseByBuildType.stable;
}
function buildTargetUrl(base: string, urlObject: URL) {
return new URL(`${urlObject.pathname}${urlObject.search}`, base).toString();
}
async function proxyRequest(
request: Request,
urlObject: URL,
base: string,
options: { bypassCustomProtocolHandlers?: boolean } = {}
) {
const { bypassCustomProtocolHandlers = true } = options;
const targetUrl = buildTargetUrl(base, urlObject);
return await executeAuthSessionRequest(request, targetUrl, request =>
net.fetch(
bypassCustomProtocolHandlers
? Object.assign(request, { bypassCustomProtocolHandlers: true })
: request
)
);
}
async function handleFileRequest(request: Request) {
const urlObject = new URL(request.url);
if (urlObject.host === anotherHost) {
urlObject.host = mainHost;
}
const isAbsolutePath = urlObject.host !== '.';
const isApiRequest =
!isAbsolutePath &&
(urlObject.pathname.startsWith('/api/') ||
urlObject.pathname === '/graphql');
if (isApiRequest) {
return proxyRequest(request, urlObject, resolveApiBaseUrl());
}
const isFontRequest =
urlObject.pathname &&
/\.(woff2?|ttf|otf)$/i.test(urlObject.pathname.split('?')[0] ?? '');
// Redirect to webpack dev server if available
if (isDev && devServerBase && !isAbsolutePath && !isFontRequest) {
return proxyRequest(request, urlObject, devServerBase, {
bypassCustomProtocolHandlers: false,
});
}
const clonedRequest = Object.assign(request.clone(), {
bypassCustomProtocolHandlers: true,
});
// this will be file types (in the web-static folder)
let filepath = '';
// for relative path, load the file in resources
if (!isAbsolutePath) {
if (urlObject.pathname.split('/').at(-1)?.includes('.')) {
const decodedPath = decodeURIComponent(urlObject.pathname).replace(
/^\/+/,
''
);
filepath = resolvePathInBase(webStaticDir, decodedPath, {
caseInsensitive: isWindows(),
label: 'filepath',
});
} else {
// else, fallback to load the index.html instead
filepath = join(webStaticDir, 'index.html');
}
} else {
filepath = decodeURIComponent(urlObject.pathname);
// on windows, the path could be start with '/'
if (isWindows()) {
filepath = path.resolve(filepath.replace(/^\//, ''));
}
if (urlObject.host !== 'local-file' || !isPathInWhiteList(filepath)) {
throw new Error('Invalid filepath');
}
filepath = await resolveWhitelistedLocalPath(filepath);
}
return net.fetch(pathToFileURL(filepath).toString(), clonedRequest);
}
const needRefererDomains = [
/^(?:[a-zA-Z0-9-]+\.)*youtube\.com$/,
/^(?:[a-zA-Z0-9-]+\.)*youtube-nocookie\.com$/,
/^(?:[a-zA-Z0-9-]+\.)*googlevideo\.com$/,
];
const defaultReferer = 'https://client.affine.local/';
const affineDomains = [
/^(?:[a-z0-9-]+\.)*usercontent\.affine\.pro$/i,
/^(?:[a-z0-9-]+\.)*affine\.pro$/i,
/^(?:[a-z0-9-]+\.)*affine\.fail$/i,
/^(?:[a-z0-9-]+\.)*affine\.run$/i,
];
function setHeader(
headers: Record<string, string[]>,
name: string,
value: string
) {
Object.keys(headers).forEach(key => {
if (key.toLowerCase() === name.toLowerCase()) {
delete headers[key];
}
});
headers[name] = [value];
}
function ensureFrameAncestors(
headers: Record<string, string[]>,
directive: string
) {
const cspHeaderKey = Object.keys(headers).find(
key => key.toLowerCase() === 'content-security-policy'
);
if (!cspHeaderKey) {
headers['Content-Security-Policy'] = [`frame-ancestors ${directive}`];
return;
}
const values = headers[cspHeaderKey];
headers[cspHeaderKey] = values.map(val => {
if (typeof val !== 'string') return val as any;
const directives = val
.split(';')
.map(v => v.trim())
.filter(Boolean)
.filter(d => !d.toLowerCase().startsWith('frame-ancestors'));
directives.push(`frame-ancestors ${directive}`);
return directives.join('; ');
});
}
function allowCors(
headers: Record<string, string[]>,
origin: string = 'assets://.'
) {
// Object-storage upload and Copilot URLs do not include application CORS headers.
setHeader(headers, 'Access-Control-Allow-Origin', origin);
setHeader(headers, 'Access-Control-Allow-Credentials', 'true');
setHeader(headers, 'Access-Control-Allow-Methods', 'GET, HEAD, PUT, OPTIONS');
setHeader(
headers,
'Access-Control-Allow-Headers',
'*, Authorization, Content-Type, Range'
);
}
export async function registerProtocol() {
await initializeAuthSessions();
protocol.handle('assets', request => {
return handleFileRequest(request);
});
session.defaultSession.webRequest.onHeadersReceived(
(responseDetails, callback) => {
const { responseHeaders, url } = responseDetails;
(async () => {
if (responseHeaders) {
const { protocol, hostname } = new URL(url);
// Adjust CORS for local assets and application-owned object storage.
if (protocol === 'assets:') {
delete responseHeaders['access-control-allow-origin'];
delete responseHeaders['access-control-allow-headers'];
delete responseHeaders['Access-Control-Allow-Origin'];
delete responseHeaders['Access-Control-Allow-Headers'];
setHeader(responseHeaders, 'X-Frame-Options', 'SAMEORIGIN');
ensureFrameAncestors(responseHeaders, "'self'");
} else if (
(protocol === 'http:' || protocol === 'https:') &&
affineDomains.some(regex => regex.test(hostname))
) {
allowCors(responseHeaders);
}
}
})()
.catch(err => {
logger.error('error handling headers received', err);
})
.finally(() => {
callback({ responseHeaders });
});
}
);
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
const url = new URL(details.url);
const managedAuthRequest =
(url.protocol === 'http:' ||
url.protocol === 'https:' ||
url.protocol === 'ws:' ||
url.protocol === 'wss:') &&
isManagedAuthEndpoint(details.url);
let cancel = false;
(async () => {
if (managedAuthRequest) {
delete details.requestHeaders.authorization;
delete details.requestHeaders.Authorization;
const token = await getAccessTokenForUrl(details.url, 120_000);
if (token) {
details.requestHeaders.Authorization = `Bearer ${token}`;
}
}
const hostname = url.hostname;
const needReferer = needRefererDomains.some(regex =>
regex.test(hostname)
);
if (needReferer && !details.requestHeaders['Referer']) {
details.requestHeaders['Referer'] = defaultReferer;
}
})()
.catch(err => {
cancel = managedAuthRequest;
logger.error('error handling before send headers', err);
})
.finally(() => {
callback({
cancel,
requestHeaders: details.requestHeaders,
});
});
});
}

View File

@@ -0,0 +1,43 @@
// the following apps are sorted by the name in alphabetical order
const appNameKeywords = new Set([
'arc',
'boardmix',
'brave',
'chrome',
'chromium',
'classin',
'dia',
'dingtalk',
'discord',
'edge',
'facetime',
'feishu',
'figma',
'firefox',
'lark',
'meet',
'messenger',
'opera',
'qq',
'safari',
'skype',
'slack',
'telegram',
'tim',
'vivaldi',
'webex',
'wechat',
'whatsapp',
'wire',
'zen',
'zoom',
]);
export function isAppNameAllowed(name: string) {
for (const keyword of appNameKeywords) {
if (name.toLowerCase().includes(keyword.toLowerCase())) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,864 @@
import { BehaviorSubject, type Observable } from 'rxjs';
import { logger } from '../logger';
import { globalStateStorage } from '../shared-storage/storage';
import type {
AppGroupInfo,
RecordingArtifactInfo,
RecordingDisplayState,
RecordingImportStatus,
RecordingJobStatus,
RecordingStatus,
} from './types';
const RECORDING_JOBS_KEY = 'recordingJobs:v2';
const IMPORT_LEASE_MS = 30_000;
interface NativeRecordingMeta {
id: string;
filepath: string;
sampleRate: number;
channels: number;
startedAt?: number;
}
interface NativeRecordingArtifact {
id: string;
filepath: string;
sampleRate: number;
channels: number;
durationMs: number;
size: number;
degraded?: boolean;
overflowCount?: number;
}
export interface NativeRecordingController {
startRecording(options: {
appProcessId?: number;
outputDir: string;
format: 'opus';
id: string;
}): Promise<NativeRecordingMeta>;
stopRecording(nativeId: string): Promise<NativeRecordingArtifact>;
abortRecording(nativeId: string): Promise<void>;
}
function isObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
function isArtifactInfo(value: unknown): value is RecordingArtifactInfo {
if (!isObject(value) || typeof value.filepath !== 'string') {
return false;
}
return (
(value.sampleRate === undefined || typeof value.sampleRate === 'number') &&
(value.numberOfChannels === undefined ||
typeof value.numberOfChannels === 'number') &&
(value.durationMs === undefined || typeof value.durationMs === 'number') &&
(value.size === undefined || typeof value.size === 'number') &&
(value.degraded === undefined || typeof value.degraded === 'boolean') &&
(value.overflowCount === undefined ||
typeof value.overflowCount === 'number')
);
}
function isRecordingJob(value: unknown): value is RecordingJobStatus {
if (!isObject(value)) {
return false;
}
if (
typeof value.id !== 'number' ||
typeof value.phase !== 'string' ||
typeof value.startTime !== 'number' ||
typeof value.createdAt !== 'number' ||
typeof value.updatedAt !== 'number'
) {
return false;
}
if (value.appName !== undefined && typeof value.appName !== 'string') {
return false;
}
if (value.appGroupId !== undefined && typeof value.appGroupId !== 'number') {
return false;
}
if (
value.bundleIdentifier !== undefined &&
typeof value.bundleIdentifier !== 'string'
) {
return false;
}
if (
value.appProcessId !== undefined &&
typeof value.appProcessId !== 'number'
) {
return false;
}
if (value.nativeId !== undefined && typeof value.nativeId !== 'string') {
return false;
}
if (value.artifact !== undefined && !isArtifactInfo(value.artifact)) {
return false;
}
if (value.import !== undefined) {
if (!isObject(value.import)) {
return false;
}
if (
value.import.workspaceId !== undefined &&
typeof value.import.workspaceId !== 'string'
) {
return false;
}
if (
value.import.docId !== undefined &&
typeof value.import.docId !== 'string'
) {
return false;
}
if (
value.import.errorMessage !== undefined &&
typeof value.import.errorMessage !== 'string'
) {
return false;
}
if (
value.import.leaseExpiresAt !== undefined &&
typeof value.import.leaseExpiresAt !== 'number'
) {
return false;
}
if (
value.import.startedAt !== undefined &&
typeof value.import.startedAt !== 'number'
) {
return false;
}
if (
value.import.finishedAt !== undefined &&
typeof value.import.finishedAt !== 'number'
) {
return false;
}
}
if (
value.error !== undefined &&
(!isObject(value.error) ||
typeof value.error.stage !== 'string' ||
typeof value.error.message !== 'string')
) {
return false;
}
if (
value.dismissedAt !== undefined &&
typeof value.dismissedAt !== 'number'
) {
return false;
}
return true;
}
function loadPersistedJobs() {
const persisted = globalStateStorage.get(RECORDING_JOBS_KEY);
if (!Array.isArray(persisted)) {
return [] as RecordingJobStatus[];
}
const now = Date.now();
return persisted.flatMap(value => {
if (!isRecordingJob(value)) {
return [];
}
if (
value.phase === 'new' ||
value.phase === 'starting' ||
value.phase === 'recording' ||
value.phase === 'finalizing' ||
value.phase === 'aborted'
) {
return [];
}
if (value.phase === 'recorded' || value.phase === 'importing') {
return [
{
...value,
phase: 'recorded' as const,
import: {
...value.import,
errorMessage: undefined,
leaseExpiresAt: undefined,
},
updatedAt: now,
dismissedAt: value.dismissedAt ?? now,
},
];
}
if (value.phase === 'imported' || value.phase === 'failed') {
return [{ ...value, dismissedAt: value.dismissedAt ?? now }];
}
return [value];
});
}
function toImportStatus(job: RecordingJobStatus): RecordingImportStatus | null {
if (!job.artifact) {
return null;
}
let importStatus: RecordingImportStatus['importStatus'];
switch (job.phase) {
case 'recorded':
importStatus = 'pending_import';
break;
case 'importing':
importStatus = 'importing';
break;
case 'imported':
importStatus = 'imported';
break;
case 'failed':
if (job.error?.stage !== 'import') {
return null;
}
importStatus = 'import_failed';
break;
default:
return null;
}
return {
id: job.id,
appName: job.appName,
workspaceId: job.import?.workspaceId,
docId: job.import?.docId,
startTime: job.startTime,
filepath: job.artifact.filepath,
sampleRate: job.artifact.sampleRate,
numberOfChannels: job.artifact.numberOfChannels,
durationMs: job.artifact.durationMs,
size: job.artifact.size,
degraded: job.artifact.degraded,
overflowCount: job.artifact.overflowCount,
importStatus,
errorMessage: job.error?.stage === 'import' ? job.error.message : undefined,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
};
}
function toDisplayStatus(
job: RecordingJobStatus | undefined
): RecordingStatus | null {
if (!job || job.dismissedAt) {
return null;
}
let status: RecordingDisplayState | null = null;
switch (job.phase) {
case 'new':
case 'starting':
case 'recording':
case 'finalizing':
status = job.phase;
break;
case 'recorded':
status = 'pending_import';
break;
case 'importing':
status = 'importing';
break;
case 'imported':
status = 'imported';
break;
case 'failed':
if (job.error?.stage === 'start') {
status = 'start_failed';
} else if (job.error?.stage === 'finalize') {
status = 'finalize_failed';
} else {
status = 'import_failed';
}
break;
case 'aborted':
return null;
default:
return null;
}
return {
id: job.id,
status,
appName: job.appName,
appGroupId: job.appGroupId,
startTime: job.startTime,
filepath: job.artifact?.filepath,
sampleRate: job.artifact?.sampleRate,
numberOfChannels: job.artifact?.numberOfChannels,
durationMs: job.artifact?.durationMs,
size: job.artifact?.size,
degraded: job.artifact?.degraded,
overflowCount: job.artifact?.overflowCount,
errorMessage: job.error?.message,
};
}
function buildDocId(jobId: number) {
return `recording-${jobId}`;
}
export class RecordingCoordinator {
private readonly jobsSubject$ = new BehaviorSubject<RecordingJobStatus[]>(
loadPersistedJobs()
);
private readonly statusSubject$ = new BehaviorSubject<RecordingStatus | null>(
null
);
private readonly importQueueSubject$ = new BehaviorSubject<
RecordingImportStatus[]
>([]);
private nextId =
this.jobsSubject$.value.reduce((max, job) => Math.max(max, job.id), -1) + 1;
constructor(
private readonly outputDir: string,
private readonly resolveFilepath: (filepath: string) => Promise<string>,
private readonly getNativeController: () => Promise<NativeRecordingController>
) {
this.emit();
}
get jobs$(): Observable<RecordingJobStatus[]> {
return this.jobsSubject$.asObservable();
}
get status$(): Observable<RecordingStatus | null> {
return this.statusSubject$.asObservable();
}
get importQueue$(): Observable<RecordingImportStatus[]> {
return this.importQueueSubject$.asObservable();
}
get jobs() {
return this.jobsSubject$.value;
}
currentStatus() {
return this.statusSubject$.value;
}
importQueue() {
return this.importQueueSubject$.value;
}
activeJob() {
return this.jobs.find(
job =>
job.phase === 'starting' ||
job.phase === 'recording' ||
job.phase === 'finalizing'
);
}
createPrompt(appGroup?: AppGroupInfo) {
const matchingPrompt = this.jobs.find(
job =>
job.phase === 'new' &&
job.dismissedAt === undefined &&
job.appGroupId === appGroup?.processGroupId
);
if (matchingPrompt) {
return matchingPrompt;
}
const now = Date.now();
const runningApp = appGroup?.apps.find(app => app.isRunning);
const job: RecordingJobStatus = {
id: this.nextId++,
phase: 'new',
appName: appGroup?.name,
appGroupId: appGroup?.processGroupId,
bundleIdentifier: appGroup?.bundleIdentifier,
appProcessId: runningApp?.processId,
startTime: 0,
createdAt: now,
updatedAt: now,
};
this.setJobs([...this.jobs, job]);
return job;
}
async start(appGroup?: AppGroupInfo) {
const currentActive = this.activeJob();
if (currentActive) {
logger.error(
'Cannot start a new recording while another session is active'
);
return currentActive;
}
const now = Date.now();
const runningApp = appGroup?.apps.find(app => app.isRunning);
const matchingPrompt = this.jobs.find(
job =>
job.phase === 'new' &&
job.dismissedAt === undefined &&
job.appGroupId === appGroup?.processGroupId
);
const startingJob: RecordingJobStatus = matchingPrompt
? {
...matchingPrompt,
phase: 'starting',
appName: appGroup?.name ?? matchingPrompt.appName,
appGroupId: appGroup?.processGroupId ?? matchingPrompt.appGroupId,
bundleIdentifier:
appGroup?.bundleIdentifier ?? matchingPrompt.bundleIdentifier,
appProcessId: runningApp?.processId,
updatedAt: now,
dismissedAt: undefined,
error: undefined,
}
: {
id: this.nextId++,
phase: 'starting',
appName: appGroup?.name,
appGroupId: appGroup?.processGroupId,
bundleIdentifier: appGroup?.bundleIdentifier,
appProcessId: runningApp?.processId,
startTime: 0,
createdAt: now,
updatedAt: now,
};
this.upsertJob(startingJob);
let nativeId: string | undefined;
try {
logger.info(`recording ${startingJob.id} starting`);
const nativeController = await this.getNativeController();
const meta = await nativeController.startRecording({
appProcessId: startingJob.appProcessId,
outputDir: this.outputDir,
format: 'opus',
id: String(startingJob.id),
});
nativeId = meta.id;
const filepath = await this.resolveFilepath(meta.filepath);
const currentJob = this.findJob(startingJob.id);
if (!currentJob || currentJob.phase !== 'starting') {
if (nativeId) {
await nativeController.abortRecording(nativeId).catch(error => {
logger.error('failed to cleanup abandoned native recording', error);
});
}
return this.findJob(startingJob.id) ?? currentJob ?? null;
}
const nextJob: RecordingJobStatus = {
...currentJob,
phase: 'recording',
nativeId: meta.id,
startTime: meta.startedAt ?? Date.now(),
updatedAt: Date.now(),
artifact: {
filepath,
sampleRate: meta.sampleRate,
numberOfChannels: meta.channels,
},
};
this.upsertJob(nextJob);
logger.info(`recording ${startingJob.id} started`, {
nativeId: meta.id,
sampleRate: meta.sampleRate,
channels: meta.channels,
});
return nextJob;
} catch (error) {
if (nativeId) {
const nativeController = await this.getNativeController();
await nativeController.abortRecording(nativeId).catch(cleanupError => {
logger.error(
'failed to cleanup abandoned native recording',
cleanupError
);
});
}
const currentJob = this.findJob(startingJob.id);
if (currentJob && currentJob.phase === 'starting') {
this.upsertJob({
...currentJob,
phase: 'failed',
updatedAt: Date.now(),
error: {
stage: 'start',
message: error instanceof Error ? error.message : 'failed to start',
},
});
}
logger.error('failed to start recording', error);
return this.findJob(startingJob.id) ?? null;
}
}
async stop(id: number) {
const job = this.findJob(id);
if (!job || job.phase !== 'recording' || !job.nativeId) {
logger.error(`stopRecording: Recording ${id} not found`);
return job ?? null;
}
const finalizingJob: RecordingJobStatus = {
...job,
phase: 'finalizing',
updatedAt: Date.now(),
error: undefined,
};
this.upsertJob(finalizingJob);
try {
logger.info(`recording ${id} finalizing`, {
nativeId: job.nativeId,
});
const nativeController = await this.getNativeController();
const artifact = await nativeController.stopRecording(job.nativeId);
const filepath = await this.resolveFilepath(artifact.filepath);
const currentJob = this.findJob(id);
if (!currentJob || currentJob.phase !== 'finalizing') {
return currentJob ?? null;
}
const nextJob: RecordingJobStatus = {
...currentJob,
phase: 'recorded',
nativeId: undefined,
updatedAt: Date.now(),
artifact: {
filepath,
sampleRate: artifact.sampleRate,
numberOfChannels: artifact.channels,
durationMs: artifact.durationMs,
size: artifact.size,
degraded: artifact.degraded,
overflowCount: artifact.overflowCount,
},
import: {
...currentJob.import,
errorMessage: undefined,
leaseExpiresAt: undefined,
},
};
this.upsertJob(nextJob);
logger.info(`recording ${id} finalized`, {
filepath,
degraded: artifact.degraded,
overflowCount: artifact.overflowCount,
});
return nextJob;
} catch (error) {
logger.error('Failed to stop recording', error);
const currentJob = this.findJob(id);
if (currentJob && currentJob.phase === 'finalizing') {
this.upsertJob({
...currentJob,
phase: 'failed',
updatedAt: Date.now(),
error: {
stage: 'finalize',
message:
error instanceof Error ? error.message : 'failed to finalize',
},
});
}
return this.findJob(id) ?? null;
}
}
async abortActive() {
const job =
this.activeJob() ??
[...this.jobs]
.sort((left, right) => right.updatedAt - left.updatedAt)
.find(
entry =>
!!entry.nativeId &&
(entry.phase === 'starting' ||
entry.phase === 'recording' ||
entry.phase === 'finalizing')
);
if (!job) {
return;
}
if (!job.nativeId) {
this.upsertJob({
...job,
phase: 'aborted',
updatedAt: Date.now(),
dismissedAt: Date.now(),
});
return;
}
const nativeController = await this.getNativeController();
try {
await nativeController.abortRecording(job.nativeId);
} finally {
this.removeJob(job.id);
}
}
getRecording(id: number) {
const job = this.findJob(id);
if (!job) {
return;
}
return {
id,
startTime: job.startTime,
filepath: job.artifact?.filepath,
sampleRate: job.artifact?.sampleRate,
numberOfChannels: job.artifact?.numberOfChannels,
appGroup: job.appGroupId
? {
processGroupId: job.appGroupId,
apps: [],
name: job.appName ?? '',
bundleIdentifier: job.bundleIdentifier ?? '',
icon: undefined,
isRunning: false,
}
: undefined,
app:
job.appProcessId &&
job.appName &&
job.appGroupId &&
job.bundleIdentifier
? {
info: {} as never,
isRunning: false,
processId: job.appProcessId,
processGroupId: job.appGroupId,
bundleIdentifier: job.bundleIdentifier,
name: job.appName,
}
: undefined,
};
}
claimImport(id: number, workspaceId: string) {
const now = Date.now();
let claimed: RecordingJobStatus | null = null;
this.setJobs(
this.jobs.map(job => {
if (job.id !== id || !job.artifact) {
return job;
}
if (job.import?.workspaceId && job.import.workspaceId !== workspaceId) {
return job;
}
if (job.phase === 'recorded') {
claimed = {
...job,
phase: 'importing',
updatedAt: now,
import: {
...job.import,
workspaceId,
docId: job.import?.docId ?? buildDocId(job.id),
errorMessage: undefined,
startedAt: job.import?.startedAt ?? now,
leaseExpiresAt: now + IMPORT_LEASE_MS,
},
dismissedAt: undefined,
};
return claimed;
}
if (
job.phase === 'importing' &&
(!job.import?.leaseExpiresAt || job.import.leaseExpiresAt <= now)
) {
claimed = {
...job,
updatedAt: now,
import: {
...job.import,
workspaceId,
docId: job.import?.docId ?? buildDocId(job.id),
errorMessage: undefined,
startedAt: job.import?.startedAt ?? now,
leaseExpiresAt: now + IMPORT_LEASE_MS,
},
dismissedAt: undefined,
};
return claimed;
}
return job;
})
);
return claimed ? toImportStatus(claimed) : null;
}
completeImport(id: number) {
const job = this.findJob(id);
if (!job || (job.phase !== 'recorded' && job.phase !== 'importing')) {
logger.error(`Recording import ${id} not found`);
return null;
}
const nextJob: RecordingJobStatus = {
...job,
phase: 'imported',
updatedAt: Date.now(),
import: {
...job.import,
errorMessage: undefined,
leaseExpiresAt: undefined,
finishedAt: Date.now(),
},
error: undefined,
dismissedAt: undefined,
};
this.upsertJob(nextJob);
return toImportStatus(nextJob);
}
failImport(id: number, errorMessage?: string) {
const job = this.findJob(id);
if (!job || (job.phase !== 'recorded' && job.phase !== 'importing')) {
logger.error(`Recording import ${id} not found`);
return null;
}
const nextJob: RecordingJobStatus = {
...job,
phase: 'failed',
updatedAt: Date.now(),
import: {
...job.import,
errorMessage,
leaseExpiresAt: undefined,
},
error: {
stage: 'import',
message: errorMessage ?? 'failed to import recording',
},
dismissedAt: undefined,
};
this.upsertJob(nextJob);
return toImportStatus(nextJob);
}
dismiss(id: number) {
const job = this.findJob(id);
if (!job) {
return null;
}
if (job.phase === 'imported') {
this.removeJob(id);
return this.currentStatus();
}
if (
job.phase === 'new' ||
(job.phase === 'failed' && job.error?.stage !== 'import')
) {
this.removeJob(id);
return this.currentStatus();
}
this.upsertJob({
...job,
updatedAt: Date.now(),
dismissedAt: Date.now(),
});
return this.currentStatus();
}
remove(id: number) {
this.removeJob(id);
}
private findJob(id: number) {
return this.jobs.find(job => job.id === id) ?? null;
}
private upsertJob(job: RecordingJobStatus) {
const nextJobs = this.jobs.filter(entry => entry.id !== job.id);
nextJobs.push(job);
nextJobs.sort((left, right) => left.id - right.id);
this.setJobs(nextJobs);
}
private removeJob(id: number) {
this.setJobs(this.jobs.filter(job => job.id !== id));
}
private setJobs(jobs: RecordingJobStatus[]) {
this.jobsSubject$.next(jobs);
globalStateStorage.set(RECORDING_JOBS_KEY, jobs);
this.emit();
}
private emit() {
const visibleJobs = this.jobs.filter(job => !job.dismissedAt);
const current =
visibleJobs.find(
job =>
job.phase === 'new' ||
job.phase === 'starting' ||
job.phase === 'recording' ||
job.phase === 'finalizing'
) ??
[...visibleJobs]
.sort((left, right) => right.updatedAt - left.updatedAt)
.find(
job =>
job.phase === 'recorded' ||
job.phase === 'importing' ||
job.phase === 'imported' ||
job.phase === 'failed'
);
this.statusSubject$.next(toDisplayStatus(current));
this.importQueueSubject$.next(
this.jobs
.flatMap(job => {
const status = toImportStatus(job);
if (!status) {
return [];
}
return status.importStatus === 'pending_import' ||
status.importStatus === 'importing'
? [status]
: [];
})
.sort((left, right) => {
if (left.updatedAt !== right.updatedAt) {
return right.updatedAt - left.updatedAt;
}
return right.id - left.id;
})
);
}
}

View File

@@ -0,0 +1,718 @@
/* oxlint-disable no-var-requires */
import { execSync } from 'node:child_process';
import fsp from 'node:fs/promises';
import path from 'node:path';
import type * as NativeModuleType from '@affine/native';
import { app, systemPreferences } from 'electron';
import fs from 'fs-extra';
import { debounce } from 'lodash-es';
import {
BehaviorSubject,
distinctUntilChanged,
groupBy,
interval,
mergeMap,
type Observable,
Subject,
throttleTime,
} from 'rxjs';
import { filter, map, shareReplay } from 'rxjs/operators';
import {
isMacOS,
isWindows,
resolveExistingPathInBase,
shallowEqual,
} from '../../shared/utils';
import { beforeAppQuit } from '../cleanup';
import { logger } from '../logger';
import {
MeetingSettingsKey,
MeetingSettingsSchema,
} from '../shared-state-schema';
import { globalStateStorage } from '../shared-storage/storage';
import { showMainWindow } from '../windows-manager';
import { popupManager } from '../windows-manager/popup';
import { isAppNameAllowed } from './allow-list';
import { RecordingCoordinator } from './coordinator';
import type {
AppGroupInfo,
RecordingImportStatus,
RecordingJobStatus,
RecordingStatus,
TappableAppInfo,
} from './types';
export const MeetingsSettingsState = {
$: globalStateStorage.watch<MeetingSettingsSchema>(MeetingSettingsKey).pipe(
map(v => MeetingSettingsSchema.parse(v ?? {})),
shareReplay(1)
),
get value() {
return MeetingSettingsSchema.parse(
globalStateStorage.get(MeetingSettingsKey) ?? {}
);
},
set value(value: MeetingSettingsSchema) {
globalStateStorage.set(MeetingSettingsKey, value);
},
};
type Subscriber = {
unsubscribe: () => void;
};
const subscribers: Subscriber[] = [];
let appStateSubscribers: Subscriber[] = [];
export const SAVED_RECORDINGS_DIR = path.join(
app.getPath('sessionData'),
'recordings'
);
type NativeModule = typeof NativeModuleType;
type ShareableContentType = InstanceType<NativeModule['ShareableContent']>;
type ShareableContentStatic = NativeModule['ShareableContent'];
let shareableContent: ShareableContentType | null = null;
let nativeModuleOverride: NativeModule | null = null;
function getNativeModule(): NativeModule {
return nativeModuleOverride ?? (require('@affine/native') as NativeModule);
}
async function getNativeModuleAsync(): Promise<NativeModule> {
if (nativeModuleOverride) {
return nativeModuleOverride;
}
return (await import('@affine/native')) as NativeModule;
}
async function assertRecordingFilepath(filepath: string) {
return await resolveExistingPathInBase(SAVED_RECORDINGS_DIR, filepath, {
caseInsensitive: isWindows(),
label: 'recording filepath',
});
}
const recordingCoordinator = new RecordingCoordinator(
SAVED_RECORDINGS_DIR,
assertRecordingFilepath,
async () => {
const nativeModule = await getNativeModuleAsync();
return {
startRecording: nativeModule.startRecording,
stopRecording: nativeModule.stopRecording,
abortRecording: nativeModule.abortRecording,
};
}
);
export function setRecordingNativeModuleForTesting(
nativeModule: NativeModule | null
) {
nativeModuleOverride = nativeModule;
}
function resetFeatureSubscriptions() {
appStateSubscribers.forEach(subscriber => {
try {
subscriber.unsubscribe();
} catch {
// ignore unsubscribe error
}
});
appStateSubscribers = [];
subscribers.forEach(subscriber => {
try {
subscriber.unsubscribe();
} catch {
// ignore unsubscribe error
}
});
subscribers.length = 0;
shareableContent = null;
applications$.next([]);
appGroups$.next([]);
}
async function abortActiveRecording() {
try {
await recordingCoordinator.abortActive();
} catch (error) {
logger.error('failed to cleanup abandoned native recording', error);
}
}
beforeAppQuit(() => {
void abortActiveRecording().catch(() => undefined);
resetFeatureSubscriptions();
});
export const applications$ = new BehaviorSubject<TappableAppInfo[]>([]);
export const appGroups$ = new BehaviorSubject<AppGroupInfo[]>([]);
export const updateApplicationsPing$ = new Subject<number>();
export const recordingStatus$: Observable<RecordingStatus | null> =
recordingCoordinator.status$;
export const recordingImportQueue$: Observable<RecordingImportStatus[]> =
recordingCoordinator.importQueue$;
export function getCurrentRecordingStatus() {
return recordingCoordinator.currentStatus();
}
function hasActivePopupStatus(status: RecordingStatus | null | undefined) {
return (
status?.status === 'starting' ||
status?.status === 'recording' ||
status?.status === 'finalizing'
);
}
function isTerminalPopupStatus(
status: RecordingStatus | null | undefined
): status is RecordingStatus & {
status: 'imported' | 'import_failed' | 'start_failed' | 'finalize_failed';
} {
return (
status?.status === 'imported' ||
status?.status === 'import_failed' ||
status?.status === 'start_failed' ||
status?.status === 'finalize_failed'
);
}
function createAppGroup(processGroupId: number): AppGroupInfo | undefined {
const SC: ShareableContentStatic = getNativeModule().ShareableContent;
const groupProcess = SC?.applicationWithProcessId(processGroupId);
if (!groupProcess) {
return;
}
return {
processGroupId,
apps: [],
name: groupProcess.name,
bundleIdentifier: groupProcess.bundleIdentifier,
get icon() {
try {
return groupProcess.icon;
} catch (error) {
logger.error(`Failed to get icon for ${groupProcess.name}`, error);
return undefined;
}
},
isRunning: false,
};
}
function setupAppGroups() {
subscribers.push(
applications$.pipe(distinctUntilChanged()).subscribe(apps => {
const groups: AppGroupInfo[] = [];
apps.forEach(appInfo => {
let group = groups.find(
entry => entry.processGroupId === appInfo.processGroupId
);
if (!group) {
group = createAppGroup(appInfo.processGroupId);
if (group) {
groups.push(group);
}
}
if (group) {
group.apps.push(appInfo);
}
});
groups.forEach(group => {
group.isRunning = group.apps.some(app => app.isRunning);
});
appGroups$.next(groups);
})
);
}
function setupNewRunningAppGroup() {
const appGroupRunningChanged$ = appGroups$.pipe(
mergeMap(groups => groups),
groupBy(group => group.processGroupId),
mergeMap(groupStream$ =>
groupStream$.pipe(
distinctUntilChanged((prev, curr) => prev.isRunning === curr.isRunning)
)
),
filter(group => isAppNameAllowed(group.name))
);
appGroups$.value.forEach(group => {
const recordingStatus = getCurrentRecordingStatus();
if (
group.isRunning &&
(!recordingStatus || recordingStatus.status === 'new')
) {
newRecording(group);
}
});
const debounceStartRecording = debounce((appGroup: AppGroupInfo) => {
const currentGroup = appGroups$.value.find(
group => group.processGroupId === appGroup.processGroupId
);
if (currentGroup?.isRunning) {
void startRecording(currentGroup).catch(err => {
logger.error('failed to start recording', err);
});
}
}, 1000);
subscribers.push(
appGroupRunningChanged$.subscribe(currentGroup => {
logger.info(
'appGroupRunningChanged',
currentGroup.bundleIdentifier,
currentGroup.isRunning
);
if (MeetingsSettingsState.value.recordingMode === 'none') {
return;
}
const recordingStatus = getCurrentRecordingStatus();
if (currentGroup.isRunning) {
if (
!recordingStatus ||
recordingStatus.status === 'new' ||
!hasActivePopupStatus(recordingStatus)
) {
if (MeetingsSettingsState.value.recordingMode === 'prompt') {
newRecording(currentGroup);
} else if (
MeetingsSettingsState.value.recordingMode === 'auto-start'
) {
debounceStartRecording(currentGroup);
}
}
} else {
if (
recordingStatus?.status === 'new' &&
recordingStatus.appGroupId === currentGroup.processGroupId
) {
removeRecording(recordingStatus.id);
}
if (
recordingStatus?.status === 'recording' &&
recordingStatus.appGroupId === currentGroup.processGroupId
) {
void stopRecording(recordingStatus.id).catch(err => {
logger.error('failed to stop recording', err);
});
}
}
})
);
}
export async function getRecording(id: number) {
return recordingCoordinator.getRecording(id);
}
function setupRecordingListeners() {
subscribers.push(
recordingStatus$
.pipe(distinctUntilChanged(shallowEqual))
.subscribe(status => {
const popup = popupManager.get('recording');
if (status && !popup.showing) {
void popup.show().catch(err => {
logger.error('failed to show recording popup', err);
});
}
if (isTerminalPopupStatus(status)) {
setTimeout(
() => {
const currentStatus = getCurrentRecordingStatus();
if (
isTerminalPopupStatus(currentStatus) &&
currentStatus.id === status.id
) {
void popup.hide().catch(err => {
logger.error('failed to hide recording popup', err);
});
dismissRecordingStatus(status.id);
}
},
status.status === 'import_failed' ||
status.status === 'start_failed' ||
status.status === 'finalize_failed'
? 30_000
: 10_000
);
} else if (!status) {
void popupManager
.get('recording')
.hide()
.catch(err => {
logger.error('failed to hide recording popup', err);
});
}
})
);
}
function getAllApps(): TappableAppInfo[] {
if (!shareableContent) {
return [];
}
const { ShareableContent } = getNativeModule();
const apps = ShareableContent.applications().map(appInfo => {
try {
const isRunning = ShareableContent.isUsingMicrophone(appInfo.processId);
return {
info: appInfo,
processId: appInfo.processId,
processGroupId: appInfo.processGroupId,
bundleIdentifier: appInfo.bundleIdentifier,
name: appInfo.name,
isRunning,
};
} catch (error) {
logger.error('failed to get app info', error);
return null;
}
});
return apps.filter(
(value): value is TappableAppInfo =>
value !== null &&
!value.bundleIdentifier.startsWith('com.apple') &&
!value.bundleIdentifier.startsWith('pro.affine') &&
value.processId !== process.pid
);
}
function setupMediaListeners() {
const ShareableContent = getNativeModule().ShareableContent;
applications$.next(getAllApps());
subscribers.push(
interval(3000).subscribe(() => {
updateApplicationsPing$.next(Date.now());
}),
ShareableContent.onApplicationListChanged(() => {
updateApplicationsPing$.next(Date.now());
}),
updateApplicationsPing$
.pipe(distinctUntilChanged(), throttleTime(3000))
.subscribe(() => {
applications$.next(getAllApps());
})
);
subscribers.push(
applications$.subscribe(apps => {
appStateSubscribers.forEach(subscriber => {
try {
subscriber.unsubscribe();
} catch {
// ignore unsubscribe error
}
});
appStateSubscribers = apps.flatMap(appInfo => {
try {
return [
ShareableContent.onAppStateChanged(appInfo.info, () => {
updateApplicationsPing$.next(Date.now());
}),
];
} catch (error) {
logger.error(
`Failed to set up app state listener for ${appInfo.name}`,
error
);
return [];
}
});
})
);
}
function askForScreenRecordingPermission() {
if (!isMacOS()) {
return false;
}
try {
const ShareableContent = getNativeModule().ShareableContent;
new ShareableContent();
return true;
} catch (error) {
logger.error('failed to ask for screen recording permission', error);
}
return false;
}
export function setupRecordingFeature() {
if (!MeetingsSettingsState.value.enabled || !checkCanRecordMeeting()) {
return;
}
try {
const ShareableContent = getNativeModule().ShareableContent;
if (!shareableContent) {
shareableContent = new ShareableContent();
setupMediaListeners();
}
setupAppGroups();
setupNewRunningAppGroup();
setupRecordingListeners();
return true;
} catch (error) {
logger.error('failed to setup recording feature', error);
return false;
}
}
export async function disableRecordingFeature() {
await abortActiveRecording();
resetFeatureSubscriptions();
}
function normalizeAppGroupInfo(
appGroup?: AppGroupInfo | number
): AppGroupInfo | undefined {
return typeof appGroup === 'number'
? appGroups$.value.find(group => group.processGroupId === appGroup)
: appGroup;
}
export function newRecording(
appGroup?: AppGroupInfo | number
): RecordingStatus | null {
recordingCoordinator.createPrompt(normalizeAppGroupInfo(appGroup));
return serializeRecordingStatus(getCurrentRecordingStatus());
}
export async function startRecording(
appGroup?: AppGroupInfo | number
): Promise<RecordingStatus | null> {
fs.ensureDirSync(SAVED_RECORDINGS_DIR);
const job = await recordingCoordinator.start(normalizeAppGroupInfo(appGroup));
return serializeJob(job);
}
export async function stopRecording(id: number) {
const job = await recordingCoordinator.stop(id);
if (job?.phase === 'recorded') {
void showMainWindow().catch(err => {
logger.error('failed to bring up the window', err);
});
}
return serializeRecordingStatus(getCurrentRecordingStatus());
}
export async function readRecordingFile(filepath: string) {
const normalizedPath = await assertRecordingFilepath(filepath);
return fsp.readFile(normalizedPath);
}
export function getRecordingImportQueue() {
return recordingCoordinator.importQueue().flatMap(status => {
const serialized = serializeRecordingImportStatus(status);
return serialized ? [serialized] : [];
});
}
export function claimRecordingImport(id: number, workspaceId: string) {
return serializeRecordingImportStatus(
recordingCoordinator.claimImport(id, workspaceId)
);
}
export function completeRecordingImport(id: number) {
logger.info(`recording import ${id} completed`);
return serializeRecordingImportStatus(
recordingCoordinator.completeImport(id)
);
}
export function failRecordingImport(id: number, errorMessage?: string) {
logger.error(`recording import ${id} failed`, errorMessage);
return serializeRecordingImportStatus(
recordingCoordinator.failImport(id, errorMessage)
);
}
export function dismissRecordingStatus(id: number) {
return serializeRecordingStatus(recordingCoordinator.dismiss(id));
}
export function removeRecording(id: number) {
recordingCoordinator.remove(id);
}
export interface SerializedRecordingStatus {
id: number;
status: RecordingStatus['status'];
appName?: string;
appGroupId?: number;
icon?: Buffer;
startTime: number;
filepath?: string;
sampleRate?: number;
numberOfChannels?: number;
durationMs?: number;
size?: number;
degraded?: boolean;
overflowCount?: number;
errorMessage?: string;
}
function serializeJob(job: RecordingJobStatus | null | undefined) {
if (!job) {
return null;
}
return serializeRecordingStatus(recordingCoordinator.currentStatus());
}
export function serializeRecordingStatus(
status: RecordingStatus | null | undefined
): SerializedRecordingStatus | null {
if (!status) {
return null;
}
return {
id: status.id,
status: status.status,
appName: status.appName,
appGroupId: status.appGroupId,
icon: status.icon,
startTime: status.startTime,
filepath: status.filepath,
sampleRate: status.sampleRate,
numberOfChannels: status.numberOfChannels,
durationMs: status.durationMs,
size: status.size,
degraded: status.degraded,
overflowCount: status.overflowCount,
errorMessage: status.errorMessage,
};
}
export interface SerializedRecordingImportStatus {
id: number;
appName?: string;
workspaceId?: string;
docId?: string;
startTime: number;
filepath: string;
sampleRate?: number;
numberOfChannels?: number;
durationMs?: number;
size?: number;
degraded?: boolean;
overflowCount?: number;
importStatus: RecordingImportStatus['importStatus'];
errorMessage?: string;
createdAt: number;
updatedAt: number;
}
export function serializeRecordingImportStatus(
status: RecordingImportStatus | null | undefined
): SerializedRecordingImportStatus | null {
if (!status) {
return null;
}
return {
id: status.id,
appName: status.appName,
workspaceId: status.workspaceId,
docId: status.docId,
startTime: status.startTime,
filepath: status.filepath,
sampleRate: status.sampleRate,
numberOfChannels: status.numberOfChannels,
durationMs: status.durationMs,
size: status.size,
degraded: status.degraded,
overflowCount: status.overflowCount,
importStatus: status.importStatus,
errorMessage: status.errorMessage,
createdAt: status.createdAt,
updatedAt: status.updatedAt,
};
}
export const getMacOSVersion = () => {
try {
const stdout = execSync('sw_vers -productVersion').toString();
const [major, minor, patch] = stdout.trim().split('.').map(Number);
return { major, minor, patch };
} catch (error) {
logger.error('Failed to get MacOS version', error);
return { major: 0, minor: 0, patch: 0 };
}
};
export const checkRecordingAvailable = () => {
if (isMacOS()) {
const version = getMacOSVersion();
return (version.major === 14 && version.minor >= 2) || version.major > 14;
}
if (isWindows()) {
return true;
}
return false;
};
export const checkMeetingPermissions = () => {
if (isWindows()) {
return {
screen: true,
microphone: true,
};
}
if (!isMacOS()) {
return undefined;
}
const mediaTypes = ['screen', 'microphone'] as const;
return Object.fromEntries(
mediaTypes.map(mediaType => [
mediaType,
systemPreferences.getMediaAccessStatus(mediaType) === 'granted',
])
) as Record<(typeof mediaTypes)[number], boolean>;
};
export const askForMeetingPermission = async (
type: 'microphone' | 'screen'
) => {
if (!isMacOS()) {
return false;
}
if (type === 'screen') {
return askForScreenRecordingPermission();
}
return systemPreferences.askForMediaAccess(type);
};
export const checkCanRecordMeeting = () => {
const features = checkMeetingPermissions();
return (
checkRecordingAvailable() &&
features &&
Object.values(features).every(feature => feature)
);
};

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