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
17
packages/frontend/apps/electron/.gitignore
vendored
Normal 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
|
||||
32
packages/frontend/apps/electron/README.md
Normal 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)
|
||||
4
packages/frontend/apps/electron/dev-app-update.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
owner: toeverything
|
||||
repo: AFFiNE
|
||||
provider: custom
|
||||
private: false
|
||||
434
packages/frontend/apps/electron/forge.config.mjs
Normal 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,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
95
packages/frontend/apps/electron/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
4
packages/frontend/apps/electron/resources/app-update.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
owner: toeverything
|
||||
repo: AFFiNE
|
||||
provider: custom
|
||||
private: false
|
||||
5
packages/frontend/apps/electron/resources/deb/postinst
Normal 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
|
||||
4
packages/frontend/apps/electron/resources/deb/prerm
Normal file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
rm /usr/share/metainfo/affine.metainfo.xml
|
||||
BIN
packages/frontend/apps/electron/resources/icons/doc-edgeless.png
Normal file
|
After Width: | Height: | Size: 938 B |
BIN
packages/frontend/apps/electron/resources/icons/doc-page.png
Normal file
|
After Width: | Height: | Size: 636 B |
BIN
packages/frontend/apps/electron/resources/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
packages/frontend/apps/electron/resources/icons/icon_beta.ico
Normal file
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
BIN
packages/frontend/apps/electron/resources/icons/icon_canary.ico
Normal file
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 748 B |
BIN
packages/frontend/apps/electron/resources/icons/monitor.png
Normal file
|
After Width: | Height: | Size: 640 B |
BIN
packages/frontend/apps/electron/resources/icons/pause.png
Normal file
|
After Width: | Height: | Size: 996 B |
BIN
packages/frontend/apps/electron/resources/icons/stop.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
packages/frontend/apps/electron/resources/icons/tray-icon.png
Normal file
|
After Width: | Height: | Size: 939 B |
|
After Width: | Height: | Size: 801 B |
BIN
packages/frontend/apps/electron/resources/icons/waveform.png
Normal file
|
After Width: | Height: | Size: 481 B |
57
packages/frontend/apps/electron/scripts/build-layers.ts
Normal 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');
|
||||
111
packages/frontend/apps/electron/scripts/common.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
117
packages/frontend/apps/electron/scripts/dev.ts
Normal 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...`);
|
||||
}
|
||||
74
packages/frontend/apps/electron/scripts/generate-assets.ts
Executable 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);
|
||||
}
|
||||
@@ -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');
|
||||
94
packages/frontend/apps/electron/scripts/make-env.ts
Normal 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,
|
||||
};
|
||||
94
packages/frontend/apps/electron/scripts/make-nsis.ts
Normal 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);
|
||||
});
|
||||
84
packages/frontend/apps/electron/scripts/make-squirrel.ts
Normal 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);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
ManifestDPIAware true
|
||||
3
packages/frontend/apps/electron/scripts/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
304
packages/frontend/apps/electron/src/helper/dialog/dialog.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
13
packages/frontend/apps/electron/src/helper/dialog/index.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
39
packages/frontend/apps/electron/src/helper/exposed.ts
Normal 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());
|
||||
85
packages/frontend/apps/electron/src/helper/index.ts
Normal 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();
|
||||
7
packages/frontend/apps/electron/src/helper/logger.ts
Normal 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;
|
||||
34
packages/frontend/apps/electron/src/helper/main-rpc.ts
Normal 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,
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { applyUpdate, Doc as YDoc, encodeStateAsUpdate, transact } from 'yjs';
|
||||
|
||||
export function mergeUpdate(updates: Uint8Array[]) {
|
||||
if (updates.length === 0) {
|
||||
return new Uint8Array();
|
||||
}
|
||||
if (updates.length === 1) {
|
||||
return updates[0];
|
||||
}
|
||||
const yDoc = new YDoc();
|
||||
transact(yDoc, () => {
|
||||
for (const update of updates) {
|
||||
applyUpdate(yDoc, update);
|
||||
}
|
||||
});
|
||||
return encodeStateAsUpdate(yDoc);
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
69
packages/frontend/apps/electron/src/helper/preview/index.ts
Normal 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));
|
||||
},
|
||||
};
|
||||
11
packages/frontend/apps/electron/src/helper/provide.ts
Normal 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;
|
||||
};
|
||||
8
packages/frontend/apps/electron/src/helper/type.ts
Normal 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;
|
||||
351
packages/frontend/apps/electron/src/helper/workspace/handlers.ts
Normal 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);
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
103
packages/frontend/apps/electron/src/helper/workspace/meta.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import type { WorkspaceMeta } from '../type';
|
||||
|
||||
export const workspaceSubjects = {
|
||||
meta$: new Subject<{ workspaceId: string; meta: WorkspaceMeta }>(),
|
||||
};
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}>(),
|
||||
};
|
||||
304
packages/frontend/apps/electron/src/main/auth/auth-session.ts
Normal 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));
|
||||
}
|
||||
218
packages/frontend/apps/electron/src/main/auth/handlers.ts
Normal 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;
|
||||
11
packages/frontend/apps/electron/src/main/auth/transport.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
56
packages/frontend/apps/electron/src/main/cleanup.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
12
packages/frontend/apps/electron/src/main/clipboard/index.ts
Normal 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;
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
import { persistentConfig } from './persist';
|
||||
|
||||
/**
|
||||
* @deprecated use shared storage
|
||||
*/
|
||||
export const configStorageHandlers = {
|
||||
get: async () => persistentConfig.get(),
|
||||
set: async (_, v) => persistentConfig.set(v),
|
||||
} satisfies NamespaceHandlers;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './handlers';
|
||||
@@ -0,0 +1,20 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
AppConfigStorage,
|
||||
defaultAppConfig,
|
||||
} from '@toeverything/infra/app-config-storage';
|
||||
import { app } from 'electron';
|
||||
|
||||
const FILENAME = 'config.json';
|
||||
const FILEPATH = path.join(app.getPath('userData'), FILENAME);
|
||||
|
||||
/**
|
||||
* @deprecated use shared storage
|
||||
*/
|
||||
export const persistentConfig = new AppConfigStorage({
|
||||
config: defaultAppConfig,
|
||||
get: () => JSON.parse(fs.readFileSync(FILEPATH, 'utf-8')),
|
||||
set: data => fs.writeFileSync(FILEPATH, JSON.stringify(data, null, 2)),
|
||||
});
|
||||
24
packages/frontend/apps/electron/src/main/config.ts
Normal 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';
|
||||
155
packages/frontend/apps/electron/src/main/deep-link.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
123
packages/frontend/apps/electron/src/main/events.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
29
packages/frontend/apps/electron/src/main/exposed.ts
Normal 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;
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
|
||||
export const findInPageHandlers = {
|
||||
find: async (event, text: string, options?: Electron.FindInPageOptions) => {
|
||||
const { promise, resolve } =
|
||||
Promise.withResolvers<Electron.Result | null>();
|
||||
const webContents = event.sender;
|
||||
let requestId: number = -1;
|
||||
webContents.once('found-in-page', (_, result) => {
|
||||
resolve(result.requestId === requestId ? result : null);
|
||||
});
|
||||
requestId = webContents.findInPage(text, options);
|
||||
return promise;
|
||||
},
|
||||
clear: async event => {
|
||||
const webContents = event.sender;
|
||||
webContents.stopFindInPage('keepSelection');
|
||||
},
|
||||
} satisfies NamespaceHandlers;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './handlers';
|
||||
117
packages/frontend/apps/electron/src/main/handlers.ts
Normal 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
|
||||
});
|
||||
});
|
||||
};
|
||||
147
packages/frontend/apps/electron/src/main/helper-process.ts
Normal 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;
|
||||
}
|
||||
39
packages/frontend/apps/electron/src/main/import/handlers.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
1
packages/frontend/apps/electron/src/main/import/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { importHandlers } from './handlers';
|
||||
152
packages/frontend/apps/electron/src/main/index.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
]);
|
||||
25
packages/frontend/apps/electron/src/main/logger.ts
Normal 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;
|
||||
});
|
||||
29
packages/frontend/apps/electron/src/main/power/index.ts
Normal 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);
|
||||
};
|
||||
},
|
||||
};
|
||||
295
packages/frontend/apps/electron/src/main/protocol.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
718
packages/frontend/apps/electron/src/main/recording/feature.ts
Normal 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)
|
||||
);
|
||||
};
|
||||
157
packages/frontend/apps/electron/src/main/recording/index.ts
Normal 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
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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.
|
||||
120
packages/frontend/apps/electron/src/main/recording/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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' };
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
@@ -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 };
|
||||
}
|
||||
117
packages/frontend/apps/electron/src/main/security/url-safety.ts
Normal 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] };
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { sharedStorageEvents } from './events';
|
||||
export { sharedStorageHandlers } from './handlers';
|
||||