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

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

View File

@@ -0,0 +1,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: 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: 46 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: 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: 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)
);
};

View File

@@ -0,0 +1,157 @@
// oxlint-disable typescript/no-var-requires
// Should not load @affine/native for unsupported platforms
import { shell } from 'electron';
import { isMacOS, resolvePathInBase } from '../../shared/utils';
import { openExternalSafely } from '../security/open-external';
import type { NamespaceHandlers } from '../type';
import {
askForMeetingPermission,
checkMeetingPermissions,
checkRecordingAvailable,
claimRecordingImport,
completeRecordingImport,
disableRecordingFeature,
dismissRecordingStatus,
failRecordingImport,
getCurrentRecordingStatus,
getRecording,
getRecordingImportQueue,
readRecordingFile,
recordingImportQueue$,
recordingStatus$,
removeRecording,
SAVED_RECORDINGS_DIR,
type SerializedRecordingImportStatus,
type SerializedRecordingStatus,
serializeRecordingStatus,
setupRecordingFeature,
startRecording,
stopRecording,
} from './feature';
import type { AppGroupInfo } from './types';
export const recordingHandlers = {
getRecording: async (_, id: number) => {
return getRecording(id);
},
getCurrentRecording: async () => {
// not all properties are serializable, so we need to return a subset of the status
const status = getCurrentRecordingStatus();
return status ? serializeRecordingStatus(status) : null;
},
startRecording: async (_, appGroup?: AppGroupInfo | number) => {
return startRecording(appGroup);
},
stopRecording: async (_, id: number) => {
return stopRecording(id);
},
readRecordingFile: async (_, filepath: string) => {
return readRecordingFile(filepath);
},
getRecordingImportQueue: async () => {
return getRecordingImportQueue();
},
claimRecordingImport: async (_, id: number, workspaceId: string) => {
return claimRecordingImport(id, workspaceId);
},
completeRecordingImport: async (_, id: number) => {
return completeRecordingImport(id);
},
dismissRecordingStatus: async (_, id: number) => {
return dismissRecordingStatus(id);
},
failRecordingImport: async (_, id: number, errorMessage?: string) => {
return failRecordingImport(id, errorMessage);
},
removeRecording: async (_, id: number) => {
return removeRecording(id);
},
checkRecordingAvailable: async () => {
return checkRecordingAvailable();
},
setupRecordingFeature: async () => {
return setupRecordingFeature();
},
disableRecordingFeature: async () => {
return disableRecordingFeature();
},
checkMeetingPermissions: async () => {
return checkMeetingPermissions();
},
askForMeetingPermission: async (_, type: 'screen' | 'microphone') => {
return askForMeetingPermission(type);
},
showRecordingPermissionSetting: async (_, type: 'screen' | 'microphone') => {
if (isMacOS()) {
const urlMap = {
screen: 'Privacy_ScreenCapture',
microphone: 'Privacy_Microphone',
};
const url = `x-apple.systempreferences:com.apple.preference.security?${urlMap[type]}`;
return openExternalSafely(url, {
additionalProtocols: ['x-apple.systempreferences:'],
});
}
// this only available on MacOS
return false;
},
showSavedRecordings: async (_, subpath?: string) => {
const directory = resolvePathInBase(SAVED_RECORDINGS_DIR, subpath ?? '', {
label: 'directory',
});
return shell.showItemInFolder(directory);
},
} satisfies NamespaceHandlers;
export const recordingEvents = {
onRecordingStatusChanged: (
fn: (status: SerializedRecordingStatus | null) => void
) => {
const sub = recordingStatus$.subscribe(status => {
fn(status ? serializeRecordingStatus(status) : null);
});
return () => {
try {
sub.unsubscribe();
} catch {
// ignore unsubscribe error
}
};
},
onRecordingImportQueueChanged: (
fn: (queue: SerializedRecordingImportStatus[]) => void
) => {
const sub = recordingImportQueue$.subscribe(queue => {
fn(
queue.map(item => ({
id: item.id,
appName: item.appName,
workspaceId: item.workspaceId,
docId: item.docId,
startTime: item.startTime,
filepath: item.filepath,
sampleRate: item.sampleRate,
numberOfChannels: item.numberOfChannels,
durationMs: item.durationMs,
size: item.size,
degraded: item.degraded,
overflowCount: item.overflowCount,
importStatus: item.importStatus,
errorMessage: item.errorMessage,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
}))
);
});
return () => {
try {
sub.unsubscribe();
} catch {
// ignore unsubscribe error
}
};
},
};

View File

@@ -0,0 +1,70 @@
# Recording State Transitions
The desktop recording flow now uses two independent lifecycle models:
1. recording session state in Electron main, which tracks native capture/finalize.
2. artifact import state in Electron main, which tracks renderer-side doc import.
## Recording Session State
- `inactive`: no session has been created yet.
- `new`: app detected, waiting for user confirmation.
- `starting`: native session setup is in progress.
- `start_failed`: native session setup failed.
- `recording`: native capture is running.
- `finalizing`: native stop/finalize is in progress.
- `finalized`: native finalized an artifact successfully.
- `finalize_failed`: native finalize failed.
Only `starting`, `recording`, and `finalizing` occupy the active native slot.
`start_failed`, `finalized`, and `finalize_failed` no longer block the next recording.
## Recording Artifact Import State
- `pending_import`: artifact is finalized and durable in main, waiting for a renderer to consume it.
- `importing`: a renderer has claimed the artifact and is importing it into a doc.
- `imported`: doc import finished successfully.
- `import_failed`: doc import failed after import work began; the saved artifact remains available, but automatic import stops to avoid duplicate docs.
Artifacts are persisted in main process storage so renderer reloads or missing workspace context do not drop them.
## Session Flow
```text
inactive -> new -> starting -> recording -> finalizing -> finalized
\ \
\ -> finalize_failed
-> start_failed
```
- `START_RECORDING` creates or reuses a pending `new` recording and moves it to `starting`.
- `ATTACH_NATIVE_RECORDING` attaches native session metadata and moves the session to `recording`.
- `START_RECORDING_FAILED` keeps the session terminal with `start_failed`.
- `STOP_RECORDING` moves the session to `finalizing`.
- `ATTACH_RECORDING_ARTIFACT` marks the session `finalized` with the native artifact metadata.
- `FINALIZE_RECORDING_FAILED` marks the session `finalize_failed`.
- after enqueueing the artifact for renderer import, main clears the finalized session and lets the import registry become the sole source of truth.
## Import Flow
```text
pending_import -> importing -> imported
\
-> import_failed
```
- main enqueues `pending_import` after native finalize succeeds.
- renderer claims the artifact, moving it to `importing`.
- renderer marks the artifact `imported` or `import_failed`.
- `imported` is not kept in the durable queue; it is projected as a transient popup status and then dropped.
- automatic retry only covers missing UI preconditions before import work begins; once doc creation starts, or completion cannot be persisted afterward, the entry stays `import_failed` to avoid duplicate docs.
## Popup Projection
The popup still renders a single current status, but it is now a projection:
- active session states map to `new`, `starting`, `start_failed`, `recording`, `finalizing`, `finalize_failed`.
- otherwise active import queue entries map to `pending_import` or `importing`.
- terminal import results (`imported`, `import_failed`) are shown through a transient popup projection instead of the durable queue.
This keeps the UI simple without collapsing the underlying source-of-truth back into a single overloaded `processing` state.

View File

@@ -0,0 +1,120 @@
import type { ApplicationInfo } from '@affine/native';
export interface TappableAppInfo {
info: ApplicationInfo;
isRunning: boolean;
processId: number;
processGroupId: number;
bundleIdentifier: string;
name: string;
}
export interface AppGroupInfo {
processGroupId: number;
apps: TappableAppInfo[];
name: string;
bundleIdentifier: string;
icon: Buffer | undefined;
isRunning: boolean;
}
export type RecordingJobPhase =
| 'new'
| 'starting'
| 'recording'
| 'finalizing'
| 'recorded'
| 'importing'
| 'imported'
| 'failed'
| 'aborted';
export type RecordingFailureStage = 'start' | 'finalize' | 'import';
export interface RecordingFailureInfo {
stage: RecordingFailureStage;
message: string;
}
export type RecordingImportState =
| 'pending_import'
| 'importing'
| 'imported'
| 'import_failed';
export interface RecordingArtifactInfo {
filepath: string;
sampleRate?: number;
numberOfChannels?: number;
durationMs?: number;
size?: number;
degraded?: boolean;
overflowCount?: number;
}
export interface RecordingImportProgress {
workspaceId?: string;
docId?: string;
errorMessage?: string;
leaseExpiresAt?: number;
startedAt?: number;
finishedAt?: number;
}
export interface RecordingJobStatus {
id: number;
phase: RecordingJobPhase;
appName?: string;
appGroupId?: number;
bundleIdentifier?: string;
appProcessId?: number;
nativeId?: string;
startTime: number;
createdAt: number;
updatedAt: number;
artifact?: RecordingArtifactInfo;
import?: RecordingImportProgress;
error?: RecordingFailureInfo;
dismissedAt?: number;
}
export interface RecordingImportStatus extends RecordingArtifactInfo {
id: number;
appName?: string;
workspaceId?: string;
docId?: string;
startTime: number;
importStatus: RecordingImportState;
errorMessage?: string;
createdAt: number;
updatedAt: number;
}
export type RecordingDisplayState =
| 'new'
| 'starting'
| 'start_failed'
| 'recording'
| 'finalizing'
| 'pending_import'
| 'importing'
| 'imported'
| 'import_failed'
| 'finalize_failed';
export interface RecordingStatus {
id: number;
status: RecordingDisplayState;
appName?: string;
appGroupId?: number;
icon?: Buffer;
startTime: number;
filepath?: string;
sampleRate?: number;
numberOfChannels?: number;
durationMs?: number;
size?: number;
degraded?: boolean;
overflowCount?: number;
errorMessage?: string;
}

View File

@@ -0,0 +1,71 @@
import { app } from 'electron';
import { isInternalUrl } from '../shared/internal-origin';
import { logger } from './logger';
import { openExternalSafely } from './security/open-external';
import { validateRedirectProxyUrl } from './security/redirect-proxy';
export const checkSource = (
e: Electron.IpcMainInvokeEvent | Electron.IpcMainEvent
) => {
const url = e.senderFrame?.url || e.sender.getURL();
const result = isInternalUrl(url);
if (!result) logger.error('invalid source', url);
return result;
};
export const registerSecurityRestrictions = () => {
app.on('web-contents-created', (_, contents) => {
/**
* Block navigation to origins not on the allowlist.
*
* Navigation is a common attack vector. If an attacker can convince the app to navigate away
* from its current page, they can possibly force the app to open web sites on the Internet.
*
* @see https://www.electronjs.org/docs/latest/tutorial/security#13-disable-or-limit-navigation
*/
contents.on('will-navigate', (event, url) => {
if (isInternalUrl(url)) {
return;
}
// Prevent navigation
event.preventDefault();
openExternalSafely(url).catch(error => {
console.error('[security] Failed to open external URL:', error);
});
});
/**
* Hyperlinks to allowed sites open in the default browser.
*
* The creation of new `webContents` is a common attack vector. Attackers attempt to convince the app to create new windows,
* frames, or other renderer processes with more privileges than they had before; or with pages opened that they couldn't open before.
* You should deny any unexpected window creation.
*
* @see https://www.electronjs.org/docs/latest/tutorial/security#14-disable-or-limit-creation-of-new-windows
* @see https://www.electronjs.org/docs/latest/tutorial/security#15-do-not-use-openexternal-with-untrusted-content
*/
contents.setWindowOpenHandler(({ url }) => {
if (!isInternalUrl(url)) {
openExternalSafely(url).catch(error => {
console.error('[security] Failed to open external URL:', error);
});
} else if (url.includes('/redirect-proxy')) {
const result = validateRedirectProxyUrl(url);
if (!result.allow) {
console.warn(
`[security] Blocked redirect proxy: ${result.reason}`,
result.redirectTarget ?? url
);
return { action: 'deny' };
}
openExternalSafely(result.redirectTarget).catch(error => {
console.error('[security] Failed to open external URL:', error);
});
}
// Prevent creating new window in application
return { action: 'deny' };
});
});
};

View File

@@ -0,0 +1,52 @@
import { shell } from 'electron';
const DEFAULT_ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
export interface OpenExternalOptions {
additionalProtocols?: string[];
}
export const isAllowedExternalUrl = (
rawUrl: string,
additionalProtocols: Iterable<string> = []
) => {
try {
const parsed = new URL(rawUrl);
const protocol = parsed.protocol.toLowerCase();
if (DEFAULT_ALLOWED_PROTOCOLS.has(protocol)) {
return true;
}
for (const extra of additionalProtocols) {
if (protocol === extra.toLowerCase()) {
return true;
}
}
return false;
} catch (error) {
console.warn('[security] Failed to parse external URL', rawUrl, error);
return false;
}
};
export const openExternalSafely = async (
rawUrl: string,
options: OpenExternalOptions = {}
) => {
const { additionalProtocols = [] } = options;
if (!isAllowedExternalUrl(rawUrl, additionalProtocols)) {
console.warn('[security] Blocked attempt to open external URL:', rawUrl);
return;
}
try {
await shell.openExternal(rawUrl);
} catch (error) {
console.error('[security] Failed to open external URL:', rawUrl, error);
}
};
export const ALLOWED_EXTERNAL_PROTOCOLS: ReadonlySet<string> = new Set(
DEFAULT_ALLOWED_PROTOCOLS
);

View File

@@ -0,0 +1,84 @@
import { isAllowedRedirectTarget } from '@toeverything/infra/utils';
import { buildType, isDev } from '../config';
const API_BASE_BY_BUILD_TYPE: Record<typeof buildType, string> = {
stable: 'https://app.affine.pro',
beta: 'https://insider.affine.pro',
internal: 'https://insider.affine.pro',
canary: 'https://affine.fail',
};
function resolveCurrentHostnameForRedirectAllowlist() {
const devServerBase = process.env.DEV_SERVER_URL;
const base =
isDev && devServerBase
? devServerBase
: (API_BASE_BY_BUILD_TYPE[buildType] ?? API_BASE_BY_BUILD_TYPE.stable);
try {
return new URL(base).hostname;
} catch {
return 'app.affine.pro';
}
}
export function extractRedirectTarget(rawUrl: string) {
try {
const parsed = new URL(rawUrl);
const redirectUri = parsed.searchParams.get('redirect_uri');
if (redirectUri) {
return redirectUri;
}
if (parsed.hash) {
const hash = parsed.hash.startsWith('#')
? parsed.hash.slice(1)
: parsed.hash;
const queryIndex = hash.indexOf('?');
if (queryIndex !== -1) {
const hashParams = new URLSearchParams(hash.slice(queryIndex + 1));
const hashRedirect = hashParams.get('redirect_uri');
if (hashRedirect) {
return hashRedirect;
}
}
}
return null;
} catch {
return null;
}
}
export type RedirectProxyValidationResult =
| {
allow: true;
redirectTarget: string;
}
| {
allow: false;
reason: 'missing_redirect_target' | 'untrusted_redirect_target';
redirectTarget?: string;
};
export function validateRedirectProxyUrl(
rawUrl: string
): RedirectProxyValidationResult {
const redirectTarget = extractRedirectTarget(rawUrl);
if (!redirectTarget) {
return { allow: false, reason: 'missing_redirect_target' };
}
const currentHostname = resolveCurrentHostnameForRedirectAllowlist();
if (!isAllowedRedirectTarget(redirectTarget, { currentHostname })) {
return {
allow: false,
reason: 'untrusted_redirect_target',
redirectTarget,
};
}
return { allow: true, redirectTarget };
}

View File

@@ -0,0 +1,117 @@
import * as dns from 'node:dns/promises';
import { BlockList, isIP } from 'node:net';
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
const BLOCKED_IPS = new BlockList();
const ALLOWED_IPV6 = new BlockList();
function stripZoneId(address: string) {
const idx = address.indexOf('%');
return idx === -1 ? address : address.slice(0, idx);
}
// Use Node's built-in BlockList (Electron 39 ships with Node 22.x).
for (const [network, prefix] of [
['0.0.0.0', 8],
['10.0.0.0', 8],
['127.0.0.0', 8],
['169.254.0.0', 16],
['172.16.0.0', 12],
['192.168.0.0', 16],
['100.64.0.0', 10], // CGNAT
['224.0.0.0', 4], // multicast
['240.0.0.0', 4], // reserved (includes broadcast)
] as const) {
BLOCKED_IPS.addSubnet(network, prefix, 'ipv4');
}
BLOCKED_IPS.addAddress('::', 'ipv6');
BLOCKED_IPS.addAddress('::1', 'ipv6');
BLOCKED_IPS.addSubnet('ff00::', 8, 'ipv6'); // multicast
BLOCKED_IPS.addSubnet('fc00::', 7, 'ipv6'); // unique local
BLOCKED_IPS.addSubnet('fe80::', 10, 'ipv6'); // link-local
ALLOWED_IPV6.addSubnet('2000::', 3, 'ipv6'); // global unicast
function extractEmbeddedIPv4FromIPv6(address: string): string | null {
if (!address.includes('.')) {
return null;
}
const idx = address.lastIndexOf(':');
if (idx === -1) {
return null;
}
const tail = address.slice(idx + 1);
return isIP(tail) === 4 ? tail : null;
}
function isBlockedIpAddress(address: string): boolean {
const ip = stripZoneId(address);
const family = isIP(ip);
if (family === 4) {
return BLOCKED_IPS.check(ip, 'ipv4');
}
if (family === 6) {
const embeddedV4 = extractEmbeddedIPv4FromIPv6(ip);
if (embeddedV4) {
return isBlockedIpAddress(embeddedV4);
}
if (!ALLOWED_IPV6.check(ip, 'ipv6')) {
return true;
}
return BLOCKED_IPS.check(ip, 'ipv6');
}
return true;
}
async function resolveHostAddresses(hostname: string): Promise<string[]> {
const lowered = hostname.toLowerCase();
if (lowered === 'localhost' || lowered.endsWith('.localhost')) {
return ['127.0.0.1', '::1'];
}
const results = await dns.lookup(hostname, { all: true, verbatim: true });
return results.map(r => r.address);
}
export async function resolveAndValidateUrlForPreview(
rawUrl: string
): Promise<{ url: URL; address: string }> {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw new Error('Invalid URL');
}
if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
throw new Error('Disallowed URL protocol');
}
if (url.username || url.password) {
throw new Error('URL must not include credentials');
}
if (!url.hostname) {
throw new Error('Missing hostname');
}
if (isIP(url.hostname)) {
if (isBlockedIpAddress(url.hostname)) {
throw new Error('Blocked IP address');
}
return { url, address: url.hostname };
}
const addresses = await resolveHostAddresses(url.hostname);
if (!addresses.length) {
throw new Error('Unresolvable hostname');
}
for (const addr of addresses) {
if (isBlockedIpAddress(addr)) {
throw new Error('Blocked IP address');
}
}
return { url, address: addresses[0] };
}

View File

@@ -0,0 +1,90 @@
import { z } from 'zod';
export const workbenchViewIconNameSchema = z.enum([
'trash',
'allDocs',
'collection',
'tag',
'doc', // refers to a doc whose mode is not yet being resolved
'page',
'edgeless',
'journal',
'attachment',
'pdf',
'ai',
]);
export const workbenchViewMetaSchema = z.object({
id: z.string(),
path: z
.object({
pathname: z.string().optional(),
hash: z.string().optional(),
search: z.string().optional(),
})
.optional(),
// todo: move title/module to cached stated
title: z.string().optional(),
iconName: workbenchViewIconNameSchema.optional(),
});
export const workbenchMetaSchema = z.object({
id: z.string(),
activeViewIndex: z.number(),
pinned: z.boolean().optional(),
basename: z.string(),
views: z.array(workbenchViewMetaSchema),
});
export const tabViewsMetaSchema = z.object({
activeWorkbenchId: z.string().optional(),
workbenches: z.array(workbenchMetaSchema).default([]),
});
export const TabViewsMetaKey = 'tabViewsMetaSchema';
export type TabViewsMetaSchema = z.infer<typeof tabViewsMetaSchema>;
export type WorkbenchMeta = z.infer<typeof workbenchMetaSchema>;
export type WorkbenchViewMeta = z.infer<typeof workbenchViewMetaSchema>;
export type WorkbenchViewModule = z.infer<typeof workbenchViewIconNameSchema>;
export const SpellCheckStateSchema = z.object({
enabled: z.boolean().optional(),
});
export const SpellCheckStateKey = 'spellCheckState' as const;
// oxlint-disable-next-line no-redeclare
export type SpellCheckStateSchema = z.infer<typeof SpellCheckStateSchema>;
export const MenubarStateKey = 'menubarState' as const;
export const MenubarStateSchema = z.object({
enabled: z.boolean().default(true),
openOnLeftClick: z.boolean().default(false),
minimizeToTray: z.boolean().default(false),
closeToTray: z.boolean().default(false),
startMinimized: z.boolean().default(false),
});
export type MenubarStateSchema = z.infer<typeof MenubarStateSchema>;
export const MeetingSettingsKey = 'meetingSettings' as const;
export const MeetingSettingsSchema = z.object({
// global meeting feature control
enabled: z.boolean().default(false),
// if false (and enabled = false), show a prompt page
betaDisclaimerAccepted: z.boolean().default(false),
// when recording is saved, where to create the recording block
recordingSavingMode: z.enum(['new-doc', 'journal-today']).default('new-doc'),
// whether to enable generation of summary for new meeting recordings
autoTranscriptionSummary: z.boolean().default(true),
// whether to enable generation of todo list for new meeting recordings
autoTranscriptionTodo: z.boolean().default(true),
// recording reactions to new meeting events
recordingMode: z.enum(['none', 'prompt', 'auto-start']).default('prompt'),
});
export type MeetingSettingsSchema = z.infer<typeof MeetingSettingsSchema>;

View File

@@ -0,0 +1,17 @@
import type { MainEventRegister } from '../type';
import { globalCacheUpdates$, globalStateUpdates$ } from './handlers';
export const sharedStorageEvents = {
onGlobalStateChanged: (
fn: (state: Record<string, unknown | undefined>) => void
) => {
const subscription = globalStateUpdates$.subscribe(fn);
return () => subscription.unsubscribe();
},
onGlobalCacheChanged: (
fn: (state: Record<string, unknown | undefined>) => void
) => {
const subscription = globalCacheUpdates$.subscribe(fn);
return () => subscription.unsubscribe();
},
} satisfies Record<string, MainEventRegister>;

View File

@@ -0,0 +1,59 @@
import { Subject } from 'rxjs';
import type { NamespaceHandlers } from '../type';
import { globalCacheStorage, globalStateStorage } from './storage';
// Subjects used by shared-storage/events.ts to broadcast updates to all renderer processes
export const globalStateUpdates$ = new Subject<Record<string, any>>();
export const globalCacheUpdates$ = new Subject<Record<string, any>>();
// Revision maps; main generates the next value each time
const globalStateRevisions = new Map<string, number>();
const globalCacheRevisions = new Map<string, number>();
function nextRev(revisions: Map<string, number>, key: string) {
const r = (revisions.get(key) ?? 0) + 1;
revisions.set(key, r);
return r;
}
export const sharedStorageHandlers = {
getAllGlobalState: async () => {
return globalStateStorage.all();
},
getAllGlobalCache: async () => {
return globalCacheStorage.all();
},
setGlobalState: async (_e, key: string, value: any, sourceId?: string) => {
const rev = nextRev(globalStateRevisions, key);
globalStateStorage.set(key, value);
globalStateUpdates$.next({ [key]: { v: value, r: rev, s: sourceId } });
},
delGlobalState: async (_e, key: string, sourceId?: string) => {
const rev = nextRev(globalStateRevisions, key);
globalStateStorage.del(key);
globalStateUpdates$.next({ [key]: { v: undefined, r: rev, s: sourceId } });
},
clearGlobalState: async (_e, sourceId?: string) => {
globalStateRevisions.clear();
globalStateStorage.clear();
globalStateUpdates$.next({ '*': { v: undefined, r: 0, s: sourceId } });
},
setGlobalCache: async (_e, key: string, value: any, sourceId?: string) => {
const rev = nextRev(globalCacheRevisions, key);
globalCacheStorage.set(key, value);
globalCacheUpdates$.next({ [key]: { v: value, r: rev, s: sourceId } });
},
delGlobalCache: async (_e, key: string, sourceId?: string) => {
const rev = nextRev(globalCacheRevisions, key);
globalCacheStorage.del(key);
globalCacheUpdates$.next({ [key]: { v: undefined, r: rev, s: sourceId } });
},
clearGlobalCache: async (_e, sourceId?: string) => {
globalCacheRevisions.clear();
globalCacheStorage.clear();
globalCacheUpdates$.next({ '*': { v: undefined, r: 0, s: sourceId } });
},
} satisfies NamespaceHandlers;

View File

@@ -0,0 +1,2 @@
export { sharedStorageEvents } from './events';
export { sharedStorageHandlers } from './handlers';

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