Compare commits

...

2 Commits

Author SHA1 Message Date
Peng Xiao
8f3035d7a3
Merge remote-tracking branch 'origin/canary' into xp/04-24-refactor_electron_nestjsfy 2025-06-24 11:50:48 +08:00
Peng Xiao
68ab87f5c5 refactor(electron): nestjsfy 2025-06-18 15:34:09 +08:00
178 changed files with 7139 additions and 5617 deletions

View File

@ -198,7 +198,10 @@
}
},
{
"files": ["packages/backend/**/*.ts"],
"files": [
"packages/backend/**/*.ts",
"packages/frontend/apps/electron/**/*.ts"
],
"rules": {
"typescript/consistent-type-imports": "off"
}

View File

@ -1,92 +0,0 @@
import { z } from 'zod';
export const appConfigSchema = z.object({
/** whether to show onboarding first */
onBoarding: z.boolean().optional().default(true),
});
export type AppConfigSchema = z.infer<typeof appConfigSchema>;
export const defaultAppConfig = appConfigSchema.parse({});
const _storage: Record<number, any> = {};
let _inMemoryId = 0;
interface StorageOptions<T> {
/** default config */
config: T;
get?: () => T;
set?: (data: T) => void;
}
/**
* Storage for app configuration, stored in memory by default
*/
class Storage<T extends object> {
private _cfg: T;
private readonly _id = _inMemoryId++;
private readonly _options;
constructor(options: StorageOptions<T>) {
this._options = {
get: () => _storage[this._id],
set: (data: T) => (_storage[this._id] = data),
...options,
};
this._cfg = this.get() ?? options.config;
}
/**
* update entire config
* @param data
*/
set(data: T) {
try {
this._options.set(data);
} catch (err) {
console.error('failed to save config', err);
}
this._cfg = data;
}
get(): T;
get<K extends keyof T>(key: K): T[K];
/**
* get config, if key is provided, return the value of the key
* @param key
* @returns
*/
get(key?: keyof T): T | T[keyof T] {
if (!key) {
try {
const cfg = this._options.get();
if (!cfg) {
this.set(this._options.config);
return this._options.config;
}
return cfg;
} catch {
return this._cfg;
}
} else {
const fullConfig = this.get();
// TODO(@catsjuice): handle key not found, set default value
// if (!(key in fullConfig)) {}
return fullConfig[key];
}
}
/**
* update a key in config
* @param key
* @param value
*/
patch(key: keyof T, value: any) {
this.set({ ...this.get(), [key]: value });
}
get value(): T {
return this.get();
}
}
export class AppConfigStorage extends Storage<AppConfigSchema> {}

View File

@ -1,4 +1,3 @@
export * from './app-config-storage';
export * from './atom';
export * from './framework';
export * from './livedata';

View File

@ -24,7 +24,7 @@ export function setupEvents(frameworkProvider: FrameworkProvider) {
.catch(console.error);
});
events?.applicationMenu.openInSettingModal(({ activeTab, scrollAnchor }) => {
events?.menu.onOpenInSettingModal(({ activeTab, scrollAnchor }) => {
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
if (!currentWorkspace) {
return;
@ -39,7 +39,7 @@ export function setupEvents(frameworkProvider: FrameworkProvider) {
});
});
events?.applicationMenu.onNewPageAction(type => {
events?.menu.onNewPageAction(type => {
apis?.ui
.isActiveTab()
.then(isActive => {
@ -61,7 +61,7 @@ export function setupEvents(frameworkProvider: FrameworkProvider) {
});
});
events?.applicationMenu.onOpenJournal(() => {
events?.menu.onOpenJournal(() => {
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
if (!currentWorkspace) {
return;

View File

@ -1,6 +1,5 @@
import './setup';
import { appConfigProxy } from '@affine/core/components/hooks/use-app-config-storage';
import { Telemetry } from '@affine/core/components/telemetry';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
@ -8,12 +7,6 @@ import { createRoot } from 'react-dom/client';
import { App } from './app';
function main() {
// load persistent config for electron
// TODO(@Peng): should be sync, but it's not necessary for now
appConfigProxy
.getSync()
.catch(() => console.error('failed to load app config'));
mountApp();
}

View File

@ -11,25 +11,20 @@
"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",
"./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"
"./*": "./src/*.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": "cross-env NODE_ENV=production node ./scripts/generate-types.ts && 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"
"make-nsis": "node ./scripts/make-nsis.ts",
"generate-types": "node ./scripts/generate-types.ts"
},
"devDependencies": {
"@affine-tools/utils": "workspace:*",
@ -45,6 +40,9 @@
"@electron-forge/maker-zip": "^7.6.0",
"@electron-forge/plugin-auto-unpack-natives": "^7.6.0",
"@electron-forge/shared-types": "^7.6.0",
"@nestjs/common": "^11.1.0",
"@nestjs/core": "^11.1.0",
"@nestjs/event-emitter": "^3.0.1",
"@pengx17/electron-forge-maker-appimage": "^1.2.1",
"@sentry/electron": "^6.1.0",
"@sentry/esbuild-plugin": "^3.0.0",
@ -62,15 +60,19 @@
"electron-squirrel-startup": "1.0.1",
"electron-window-state": "^5.0.3",
"esbuild": "^0.25.0",
"esbuild-plugin-tsc": "^0.5.0",
"fs-extra": "^11.2.0",
"glob": "^11.0.0",
"lodash-es": "^4.17.21",
"msw": "^2.6.8",
"nanoid": "^5.0.9",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"semver": "^7.6.3",
"tree-kill": "^1.2.2",
"ts-morph": "^25.0.1",
"ts-node": "^10.9.2",
"typescript": "^5.8.3",
"uuid": "^11.0.3",
"vitest": "3.1.3",
"zod": "^3.24.1"

View File

@ -5,6 +5,7 @@ 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';
import esbuildPluginTsc from 'esbuild-plugin-tsc';
export const electronDir = fileURLToPath(new URL('..', import.meta.url));
@ -38,7 +39,12 @@ export const config = (): BuildOptions => {
),
};
const plugins: Plugin[] = [];
const plugins: Plugin[] = [
// ensures nestjs decorators are working (emitDecoratorMetadata not supported in esbuild)
esbuildPluginTsc({
tsconfigPath: resolve(electronDir, 'tsconfig.json'),
}),
];
if (
process.env.SENTRY_AUTH_TOKEN &&
@ -78,16 +84,30 @@ export const config = (): BuildOptions => {
return {
entryPoints: [
resolve(electronDir, './src/main/index.ts'),
resolve(electronDir, './src/preload/index.ts'),
resolve(electronDir, './src/helper/index.ts'),
resolve(electronDir, './src/entries/main/index.ts'),
resolve(electronDir, './src/entries/preload/index.ts'),
resolve(electronDir, './src/entries/helper/index.ts'),
],
entryNames: '[dir]',
outdir: resolve(electronDir, './dist'),
bundle: true,
target: `node${NODE_MAJOR_VERSION}`,
platform: 'node',
external: ['electron', 'electron-updater', 'yjs', 'semver'],
external: [
'electron',
'electron-updater',
'yjs',
'semver',
// nestjs related:
'@nestjs/platform-express',
'@nestjs/microservices',
'@nestjs/websockets/socket-module',
'@apollo/subgraph',
'@apollo/gateway',
'ts-morph',
'class-validator',
'class-transformer',
],
format: 'cjs',
loader: {
'.node': 'copy',

View File

@ -0,0 +1,119 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Project } from 'ts-morph';
import { parseIpcEvents } from './ipc-generator/events-parser';
import { parseIpcHandlers } from './ipc-generator/handlers-parser';
import {
type CollectedApisMap,
type CollectedEventsMap,
type OutputPaths,
} from './ipc-generator/types';
import {
generateApiTypesFile,
generateCombinedMetaFile,
generateEventTypesFile,
} from './ipc-generator/utils';
// ES module equivalent for __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const electronRoot = path.resolve(__dirname, '../');
const rootDir = path.resolve(electronRoot, '..', '..', '..', '..');
// Configure output paths
const paths: OutputPaths = {
// Generate api types under @affine/electron-api
apiTypes: path.resolve(
rootDir,
'packages/frontend/electron-api/src/ipc-api-types.gen.ts'
),
ipcMeta: path.resolve(electronRoot, 'src/entries/preload/ipc-meta.gen.ts'),
// Event type definitions
eventTypes: path.resolve(
rootDir,
'packages/frontend/electron-api/src/ipc-event-types.gen.ts'
),
};
function ensureDirectories(paths: OutputPaths): void {
Object.values(paths)
.filter(Boolean) // Skip empty paths
.forEach(filePath => {
try {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
} catch (e: any) {
// Only warn about errors other than "directory already exists"
if (e.code !== 'EEXIST') {
console.warn(`[WARN] Could not create directory: ${e.message}`);
throw new Error(
`Failed to create directory for ${filePath}: ${e.message}`
);
}
}
});
}
function writeGeneratedFiles(
collectedApiHandlers: CollectedApisMap,
collectedEvents: CollectedEventsMap,
paths: OutputPaths
): void {
// Write API handler types file
fs.writeFileSync(paths.apiTypes, generateApiTypesFile(collectedApiHandlers));
console.log(`IPC API type definitions generated at: ${paths.apiTypes}`);
// Write event types file if events were found
if (collectedEvents.size > 0) {
fs.writeFileSync(paths.eventTypes, generateEventTypesFile(collectedEvents));
console.log(`IPC Event type definitions generated at: ${paths.eventTypes}`);
} else {
console.log('No IPC Events found. Skipping event types generation.');
}
// Write combined metadata file
fs.writeFileSync(
paths.ipcMeta,
generateCombinedMetaFile(collectedApiHandlers, collectedEvents)
);
console.log(`IPC combined metadata generated at: ${paths.ipcMeta}`);
}
/**
* Main function to generate IPC definitions
*/
function generateIpcDefinitions() {
// Initialize ts-morph project
const project = new Project({
tsConfigFilePath: path.resolve(electronRoot, 'tsconfig.json'),
skipAddingFilesFromTsConfig: true,
});
// Add relevant source files
project.addSourceFilesAtPaths([
path.resolve(electronRoot, 'src/**/*.ts'),
// Add other paths where IPC handlers might be defined
]);
// Parse handlers and events from the source files
const { apis: collectedApiHandlers } = parseIpcHandlers(project);
const { events: collectedEvents } = parseIpcEvents(project);
if (collectedApiHandlers.size === 0) {
console.log(
'No IPC handlers found. Generated files will be empty or contain minimal structure.'
);
}
// Ensure directories exist
ensureDirectories(paths);
// Write generated files
writeGeneratedFiles(collectedApiHandlers, collectedEvents, paths);
}
// Run the generator
generateIpcDefinitions();

View File

@ -0,0 +1,277 @@
import { Node, Project, PropertyDeclaration } from 'ts-morph';
import { type CollectedEventsMap, type ParsedEventInfo } from './types';
import { determineEntry } from './utils';
export const IpcEventDecoratorName = 'IpcEvent';
/**
* Parses the @IpcEvent decorator and extracts relevant information
*/
export function parseIpcEventDecorator(
propertyDeclaration: PropertyDeclaration
): Omit<ParsedEventInfo, 'description'> | { error: string } {
const decorator = propertyDeclaration
.getDecorators()
.find(d => d.getName() === IpcEventDecoratorName);
if (!decorator) return { error: 'Decorator not found' };
const args = decorator.getArguments();
const sourceFile = propertyDeclaration.getSourceFile();
const propertyNameInCode = propertyDeclaration.getName();
if (args.length === 0) {
return {
error: `@${IpcEventDecoratorName} on ${propertyNameInCode} in ${sourceFile.getFilePath()} is missing arguments.`,
};
}
const optionsArg = args[0];
if (!Node.isObjectLiteralExpression(optionsArg)) {
return {
error: `@${IpcEventDecoratorName} on ${propertyNameInCode} in ${sourceFile.getFilePath()} requires an object argument.`,
};
}
let scopeValue: string | undefined;
const scopeProperty = optionsArg.getProperty('scope');
if (scopeProperty && Node.isPropertyAssignment(scopeProperty)) {
const initializer = scopeProperty.getInitializer();
if (initializer) {
if (Node.isStringLiteral(initializer))
scopeValue = initializer.getLiteralValue();
else if (Node.isPropertyAccessExpression(initializer)) {
const type = initializer.getType();
if (type.isStringLiteral())
scopeValue = type.getLiteralValue() as string;
else scopeValue = initializer.getNameNode().getText();
}
}
}
if (!scopeValue)
return {
error: `@${IpcEventDecoratorName} on ${propertyNameInCode}: missing valid 'scope'.`,
};
let declaredName: string | undefined;
const nameProperty = optionsArg.getProperty('name');
if (nameProperty && Node.isPropertyAssignment(nameProperty)) {
const initializer = nameProperty.getInitializer();
if (initializer && Node.isStringLiteral(initializer))
declaredName = initializer.getLiteralValue();
else if (initializer)
return {
error: `@${IpcEventDecoratorName} on ${propertyNameInCode}: 'name' must be a string literal.`,
};
}
const eventName = declaredName ?? propertyNameInCode.replace(/\$$/, '');
const ipcChannel = `${scopeValue}:${eventName}`;
let payloadType = 'any[]'; // Default
const propertyTypeNode = propertyDeclaration.getTypeNode();
const propertyType = propertyDeclaration.getType(); // Get the full Type object
// Attempt 1: Regex on TypeNode text (faster, good for common cases)
if (propertyTypeNode) {
const typeNodeText = propertyTypeNode.getText();
// Consolidated regex for known stream types including Observable
const knownStreamTypesRegex =
/(?:BehaviorSubject|ReplaySubject|Subject|Observable|EventEmitter)<([^>]+)>/;
const typeMatch = typeNodeText.match(knownStreamTypesRegex);
if (typeMatch && typeMatch[1]) {
payloadType = typeMatch[1].trim();
}
}
// Attempt 2: If regex failed or resulted in default, try more robust Type object inspection
if (payloadType === 'any[]') {
// Only if not found by Attempt 1 (explicit type annotation)
const typesToInspect: import('ts-morph').Type[] = [propertyType];
if (propertyType.isIntersection()) {
typesToInspect.push(...propertyType.getIntersectionTypes());
}
// Consider base types if the primary type itself is not a directly recognized Observable symbol
// This helps with classes extending Observable<T>
const primarySymbolName = propertyType.getSymbol()?.getName();
const isPrimaryRecognizedObservable = [
'Subject',
'BehaviorSubject',
'ReplaySubject',
'EventEmitter',
'Observable',
].includes(primarySymbolName || '');
if (!isPrimaryRecognizedObservable && propertyType.isClassOrInterface()) {
typesToInspect.push(...propertyType.getBaseTypes());
}
for (const typeToInspect of typesToInspect) {
// Ensure we are dealing with a type that can have generics and a symbol (class/interface)
// isAnonymous handles cases within intersections that might not be directly isClassOrInterface
if (
!typeToInspect.isClassOrInterface() &&
!typeToInspect.isAnonymous() &&
!typeToInspect.isObject()
)
continue;
const typeName = typeToInspect.getSymbol()?.getName();
if (
typeName === 'Subject' ||
typeName === 'BehaviorSubject' ||
typeName === 'ReplaySubject' ||
typeName === 'EventEmitter' ||
typeName === 'Observable'
) {
const typeArguments = typeToInspect.getTypeArguments();
if (typeArguments.length > 0) {
const argText = typeArguments[0].getText(sourceFile).trim();
// Prioritize more specific types over 'any' or 'unknown' if multiple paths yield a type.
if (
payloadType === 'any[]' ||
((payloadType === 'any' || payloadType === 'unknown') &&
argText !== 'any' &&
argText !== 'unknown')
) {
payloadType = argText;
}
// If we found a concrete type (not any/unknown/any[]), we can stop searching.
if (
payloadType !== 'any' &&
payloadType !== 'unknown' &&
payloadType !== 'any[]'
) {
break; // Found a good type from typesToInspect loop
}
}
// If typeArguments is empty here, it implies Observable (or Subject, etc.) without a generic.
// The later fallback `if (payloadType === 'any[]') payloadType = 'any'` will handle this.
}
}
// If, after checking all typesToInspect, payloadType is still 'any[]' or a non-specific type,
// and we found a specific type from the primary propertyType earlier (even if it was 'any'),
// we might need to ensure the most specific one found is kept.
// However, the current logic of only updating if more specific should handle this.
}
// Attempt 3: Look at initializer expression (e.g., new Subject<void>()) if still default
if (payloadType === 'any[]') {
const initializer = propertyDeclaration.getInitializer();
if (initializer && Node.isNewExpression(initializer)) {
const typeArgs = initializer.getType().getTypeArguments();
if (typeArgs.length > 0) {
payloadType = typeArgs[0].getText(sourceFile).trim();
}
}
}
// If still default any[] but we have detected Subject without generic, treat as any not array
if (payloadType === 'any[]') {
payloadType = 'any';
}
// Final cleanup for void
if (payloadType.toLowerCase() === 'void') {
payloadType = ''; // Represent void as empty params for callback
}
return {
scope: scopeValue,
eventName,
ipcChannel,
payloadType,
originalPropertyName: propertyNameInCode,
entry: determineEntry(sourceFile.getFilePath()),
filePath: sourceFile.getFilePath(),
};
}
/**
* Parses all IPC events in the project and collects their information
*/
export function parseIpcEvents(project: Project): {
events: CollectedEventsMap;
} {
const collectedEvents: CollectedEventsMap = new Map();
project.getSourceFiles().forEach(sourceFile => {
sourceFile.getClasses().forEach(classDeclaration => {
classDeclaration.getProperties().forEach(propertyDeclaration => {
const decorator = propertyDeclaration
.getDecorators()
.find(d => d.getName() === IpcEventDecoratorName);
if (!decorator) return;
const parsedEventInfo = parseIpcEventDecorator(propertyDeclaration);
if ('error' in parsedEventInfo) {
if (parsedEventInfo.error !== 'Decorator not found')
console.error(`[Event ERR] ${parsedEventInfo.error}`);
return;
}
const {
scope,
eventName,
payloadType,
entry,
filePath,
originalPropertyName,
} = parsedEventInfo;
// derive modulePath and className here
const absPath = filePath as string;
const idx = absPath.lastIndexOf('/src/');
let modulePath = absPath;
if (idx !== -1) {
modulePath =
'@affine/electron/' + absPath.substring(idx + '/src/'.length);
}
modulePath = modulePath.replace(/\.[tj]sx?$/, '');
const clsName =
propertyDeclaration.getParent()?.getName?.() || 'UnknownClass';
const propName = originalPropertyName;
const description = propertyDeclaration
.getJsDocs()
.map(doc => doc.getDescription().trim())
.filter(Boolean)
.join('\n');
if (!collectedEvents.has(scope)) collectedEvents.set(scope, []);
const eventScopeMethods = collectedEvents.get(scope);
if (eventScopeMethods) {
const existingEvent = eventScopeMethods.find(
event => event.eventName === eventName
);
if (existingEvent) {
throw new Error(
`[Event ERR] Duplicate event found for scope '${scope}' and eventName '${eventName}'.\n` +
` Original: ${existingEvent.filePath} (${existingEvent.className}.${existingEvent.propertyName})\n` +
` Duplicate: ${filePath as string} (${clsName}.${propName})`
);
}
eventScopeMethods.push({
eventName,
payloadType,
modulePath,
className: clsName,
propertyName: propName,
description,
entry,
filePath: filePath as string,
});
} else {
console.error(
`[CRITICAL] Failed to retrieve event methods array for scope: ${scope}`
);
}
});
});
});
return {
events: collectedEvents,
};
}

View File

@ -0,0 +1,201 @@
import {
MethodDeclaration,
Node,
Project,
PropertyDeclaration,
} from 'ts-morph';
import { type CollectedApisMap, type ParsedDecoratorInfo } from './types';
import { determineEntry } from './utils';
export const IpcHandleDecoratorName = 'IpcHandle';
type IpcDecoratedMember = MethodDeclaration | PropertyDeclaration;
/**
* Parses the @IpcHandle decorator and extracts relevant information
*/
function parseIpcHandleDecorator(
memberDeclaration: IpcDecoratedMember
): ParsedDecoratorInfo {
const ipcHandleDecorator = memberDeclaration
.getDecorators()
.find(d => d.getName() === IpcHandleDecoratorName);
if (!ipcHandleDecorator) {
return {}; // No decorator found
}
const decoratorArgs = ipcHandleDecorator.getArguments();
const sourceFile = memberDeclaration.getSourceFile();
const methodNameInCode = memberDeclaration.getName(); // For error messages and fallback
if (decoratorArgs.length === 0) {
return {
error: `@${IpcHandleDecoratorName} on ${methodNameInCode} in ${sourceFile.getFilePath()} is missing arguments.`,
};
}
const optionsArg = decoratorArgs[0];
if (Node.isObjectLiteralExpression(optionsArg)) {
const scopeProperty = optionsArg.getProperty('scope');
let scopeValue: string | undefined;
if (scopeProperty && Node.isPropertyAssignment(scopeProperty)) {
const scopeInitializer = scopeProperty.getInitializer();
if (scopeInitializer) {
if (Node.isStringLiteral(scopeInitializer)) {
scopeValue = scopeInitializer.getLiteralValue();
} else if (Node.isPropertyAccessExpression(scopeInitializer)) {
const checker = scopeInitializer.getProject().getTypeChecker();
const propertyAccessType = scopeInitializer.getType(); // Type of the e.g. IpcScope.MAIN expression
if (propertyAccessType.isStringLiteral()) {
const literalValue = propertyAccessType.getLiteralValue();
if (typeof literalValue === 'string') {
scopeValue = literalValue;
} else {
// This case should be rare if isStringLiteral() is true
return {
error: `Scope for ${methodNameInCode} in ${sourceFile.getFilePath()} resolved to a string literal type, but its value is not a string: ${literalValue}. Please use a string enum or string literal for scope.`,
};
}
} else {
// The type of the expression (e.g., IpcScope.MAIN) is not itself a string literal type.
// This might happen if IpcScope is a numeric enum, or a more complex type.
// Attempt to get the constant value of the expression.
const constant = checker.compilerObject.getConstantValue(
scopeInitializer.compilerNode
);
if (typeof constant === 'string') {
scopeValue = constant;
} else {
let errorMessage = `Unable to resolve 'scope' for ${methodNameInCode} in ${sourceFile.getFilePath()} to a string constant. `;
if (typeof constant === 'number') {
errorMessage += `Resolved to a number (${constant}). Please use a string enum or string literal.`;
} else if (constant === undefined) {
errorMessage += `The expression does not resolve to a compile-time constant string. Ensure it's a direct string enum member (e.g., MyEnum.Value) or a string literal.`;
} else {
errorMessage += `Resolved to an unexpected type '${typeof constant}' with value '${constant}'. Please use a string enum or string literal.`;
}
return { error: errorMessage };
}
}
}
}
}
if (!scopeValue) {
return {
error: `@${IpcHandleDecoratorName} in ${methodNameInCode} in ${sourceFile.getFilePath()} is missing a valid 'scope'.`,
};
}
let nameValue: string | undefined;
const nameProperty = optionsArg.getProperty('name');
if (nameProperty && Node.isPropertyAssignment(nameProperty)) {
const nameInitializer = nameProperty.getInitializer();
if (nameInitializer && Node.isStringLiteral(nameInitializer)) {
nameValue = nameInitializer.getLiteralValue();
} else if (nameInitializer) {
return {
error: `@${IpcHandleDecoratorName} in ${methodNameInCode} in ${sourceFile.getFilePath()} has an invalid 'name' property. It must be a string literal.`,
};
}
}
return {
scope: scopeValue,
apiMethodName: nameValue ?? methodNameInCode,
entry: determineEntry(sourceFile.getFilePath()),
filePath: sourceFile.getFilePath() as string,
};
} else if (Node.isStringLiteral(optionsArg)) {
return {
error: `@${IpcHandleDecoratorName} on ${methodNameInCode} in ${sourceFile.getFilePath()} uses legacy string literal. Please update to object format { scope: string, name?: string }.`,
};
} else {
return {
error: `@${IpcHandleDecoratorName} on ${methodNameInCode} in ${sourceFile.getFilePath()} has invalid arguments.`,
};
}
}
/**
* Parses all IPC handlers in the project and collects their information
*/
export function parseIpcHandlers(project: Project): {
apis: CollectedApisMap;
} {
const collectedApiHandlers: CollectedApisMap = new Map();
project.getSourceFiles().forEach(sourceFile => {
sourceFile.getClasses().forEach(classDeclaration => {
// Iterate over both traditional methods and property declarations (which can
// hold arrow-function handlers) so that handlers like
// `handleWebContentsResize = () => {}` are also detected.
const members: IpcDecoratedMember[] = [
...classDeclaration.getMethods(),
...classDeclaration.getProperties(),
];
members.forEach(memberDeclaration => {
const parsedHandlerInfo = parseIpcHandleDecorator(memberDeclaration);
if (parsedHandlerInfo.error) {
console.error(`[API Handler ERR] ${parsedHandlerInfo.error}`);
return;
}
if (
!parsedHandlerInfo.scope ||
!parsedHandlerInfo.apiMethodName ||
!parsedHandlerInfo.entry ||
!parsedHandlerInfo.filePath
)
return;
const { scope, apiMethodName, entry, filePath } = parsedHandlerInfo;
const classDecl = memberDeclaration.getParent();
const className = (classDecl as any)?.getName?.() || 'UnknownClass';
// Build module path for import specifier (strip up to /src/ and .ts extension)
const absPath = sourceFile.getFilePath() as unknown as string;
const srcIdx = absPath.lastIndexOf('/src/');
let modulePath = absPath;
if (srcIdx !== -1) {
modulePath =
'@affine/electron/' + absPath.substring(srcIdx + '/src/'.length);
}
modulePath = modulePath.replace(/\.[tj]sx?$/, '');
const description = memberDeclaration
.getJsDocs()
.map(doc => doc.getDescription().trim())
.filter(Boolean)
.join('\n');
if (!collectedApiHandlers.has(scope))
collectedApiHandlers.set(scope, []);
const handlerScopeMethods = collectedApiHandlers.get(scope);
if (handlerScopeMethods) {
handlerScopeMethods.push({
apiMethodName,
modulePath,
className,
methodName: memberDeclaration.getName(),
description,
entry,
filePath: filePath as string,
});
} else {
console.error(
`[CRITICAL] Failed to retrieve handler methods array for scope: ${scope}`
);
}
});
});
});
return {
apis: collectedApiHandlers,
};
}

View File

@ -0,0 +1,67 @@
/**
* Types for IPC generator
*/
export interface ParsedDecoratorInfo {
/** ipc scope */
scope?: string;
/** method name that will show up in the generated API */
apiMethodName?: string;
/** optional error message when decorator is invalid */
error?: string;
/** entry: main/helper */
entry?: Entry;
/** absolute path to the source file */
filePath?: string;
}
export interface ParsedEventInfo {
scope: string;
/** event name without the scope prefix */
eventName: string;
/** full ipc channel (e.g. ui:maximized) */
ipcChannel: string;
/** payload type extracted from rxjs observable kept for compatibility */
payloadType: string;
/** original property name in the class */
originalPropertyName: string;
/** entry of the source file */
entry: Entry;
description?: string;
error?: string;
/** absolute path to the source file */
filePath?: string;
}
export type Entry = 'main' | 'helper';
export interface CollectedApiMethodInfo {
apiMethodName: string;
modulePath: string;
className: string;
methodName: string;
description?: string;
entry: Entry;
filePath: string;
}
export type CollectedApisMap = Map<string, CollectedApiMethodInfo[]>;
export interface CollectedEventInfoForMeta {
eventName: string;
}
export interface CollectedEventInfoForTypes extends CollectedEventInfoForMeta {
payloadType: string;
description?: string;
entry: Entry;
filePath: string;
className: string; // Class containing the event property
propertyName: string; // Original property name
modulePath: string;
}
export type CollectedEventsMap = Map<string, CollectedEventInfoForTypes[]>; // Keyed by scope
export interface OutputPaths {
apiTypes: string; // Type definitions for API handlers
eventTypes: string; // Type definitions for events
ipcMeta: string; // Combined metadata for both handlers and events
}

View File

@ -0,0 +1,111 @@
import {
type CollectedApisMap,
type CollectedEventsMap,
type Entry,
} from './types';
export const determineEntry = (path: string): Entry => {
if (path.includes('src/entries/helper')) return 'helper';
return 'main';
};
/**
* Generates the API type definitions file content
*/
export function generateApiTypesFile(collectedApis: CollectedApisMap): string {
let content = `// AUTO-GENERATED FILE. DO NOT EDIT MANUALLY.\n// Generated by: packages/frontend/apps/electron/scripts/generate-types.ts\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\n`;
content += `// Utility type: remove trailing IpcMainInvokeEvent param and ensure Promise return\n`;
content += `type EnsurePromise<T> = T extends Promise<any> ? T : Promise<T>;\n`;
content += `export type ApiMethod<T> = T extends (...args: infer P) => infer R ? (...args: P) => EnsurePromise<R> : never;\n\n`;
content += `export interface ElectronApis {\n`;
for (const [scope, methods] of collectedApis) {
content += ` ${scope}: {\n`;
for (const method of methods) {
if (method.description && method.description.trim().length > 0) {
const lines = method.description.split('\n').map(l => ` * ${l}`);
content += ' /**\n';
content += lines.join('\n') + '\n';
content += ' */\n';
}
content += ` ${method.apiMethodName}: ApiMethod<import('${method.modulePath}').${method.className}['${method.methodName}']>;\n`;
}
content += ` };\n`;
}
content += `}\n`;
return content;
}
/**
* Generates the event type definitions file content
*/
export function generateEventTypesFile(
collectedEvents: CollectedEventsMap
): string {
let out = `// AUTO-GENERATED FILE. DO NOT EDIT MANUALLY.\n// Generated by: packages/frontend/apps/electron/scripts/generate-types.ts\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\nimport type { Observable } from 'rxjs';\n\n`;
out += `type ToSubscribe<T extends Observable<unknown>> = T extends Observable<infer P> ? (callback: (payload: P) => void) => () => void : never;\n\n`;
out += `export interface ElectronEvents {\n`;
for (const [scope, events] of collectedEvents) {
out += ` ${scope}: {\n`;
for (const evt of events) {
const capName =
evt.eventName.charAt(0).toUpperCase() + evt.eventName.slice(1);
if (evt.description && evt.description.trim().length > 0) {
const lines = evt.description.split('\n').map(l => ` * ${l}`);
out += ' /**\n';
out += lines.join('\n') + '\n';
out += ' */\n';
}
out += ` on${capName}: ToSubscribe<import('${evt.modulePath}').${evt.className}['${evt.propertyName}']>;\n`;
}
out += ` };\n`;
}
out += `}\n`;
return out;
}
/**
* Generates a combined metadata file for both IPC handlers and events
*/
export function generateCombinedMetaFile(
apiHandlers: CollectedApisMap,
events: CollectedEventsMap
): string {
let content = `// AUTO-GENERATED FILE. DO NOT EDIT MANUALLY.\n// Generated by: packages/frontend/apps/electron/scripts/generate-types.ts\n/* eslint-disable @typescript-eslint/no-explicit-any */\n`;
// Build nested structure: entry -> scope -> names[]
const handlersMeta: Record<string, Record<string, string[]>> = {};
apiHandlers.forEach((methods, scope) => {
methods.forEach(m => {
const ent = m.entry;
if (!handlersMeta[ent]) handlersMeta[ent] = {};
if (!handlersMeta[ent][scope]) handlersMeta[ent][scope] = [];
handlersMeta[ent][scope].push(m.apiMethodName);
});
});
const eventsMeta: Record<string, Record<string, string[]>> = {};
events.forEach((evtList, scope) => {
evtList.forEach(e => {
const ent = e.entry;
if (!eventsMeta[ent]) eventsMeta[ent] = {};
if (!eventsMeta[ent][scope]) eventsMeta[ent][scope] = [];
eventsMeta[ent][scope].push(e.eventName);
});
});
// Serialize handlersMeta & eventsMeta
content += `export const handlersMeta = ${JSON.stringify(
handlersMeta,
null,
2
)} as const;\n\n`;
content += `export const eventsMeta = ${JSON.stringify(eventsMeta, null, 2)} as const;\n`;
return content;
}

View File

@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { ElectronIpcModule } from '../../ipc';
import { DialogModule } from './dialog';
import { HelperBootstrapService } from './helper-bootstrap.service';
import { LoggerModule } from './logger';
import { MainRpcModule } from './main-rpc';
import { NBStoreModule } from './nbstore';
/**
* Main module for the helper process
*/
@Module({
imports: [
LoggerModule,
ElectronIpcModule.forHelper(),
MainRpcModule,
// Feature modules
DialogModule,
NBStoreModule,
],
providers: [HelperBootstrapService],
})
export class AppModule {}

View File

@ -0,0 +1,21 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { logger } from './logger';
export async function bootstrap() {
// Process setup for parentPort message handling is done inside HelperBootstrapService
// which is automatically instantiated when the module initializes
const app = await NestFactory.createApplicationContext(AppModule, {
logger,
});
// Handle shutdown
process.on('exit', () => {
app.close().catch(err => {
logger.error('Failed to close Nest application context', err);
});
});
return app;
}

View File

@ -0,0 +1,328 @@
import path from 'node:path';
import { DocStorage, ValidationResult } from '@affine/native';
import { parseUniversalId } from '@affine/nbstore';
import { Injectable, Logger } from '@nestjs/common';
import fs from 'fs-extra';
import { nanoid } from 'nanoid';
import { IpcHandle, IpcScope } from '../../../ipc';
import { MainRpcService } from '../main-rpc';
import { NBStoreService } from '../nbstore/nbstore.service';
import { WorkspacePathService } from '../nbstore/workspace-path.service';
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;
}
// provide a backdoor to set dialog path for testing in playwright
export interface FakeDialogResult {
canceled?: boolean;
filePath?: string;
filePaths?: string[];
}
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, '-');
}
/**
* Service that handles dialog-related operations
*/
@Injectable()
export class DialogHandlerService {
context = 'DialogHandlerService';
constructor(
private readonly rpcService: MainRpcService,
private readonly workspacePathService: WorkspacePathService,
private readonly nbstore: NBStoreService,
private readonly logger: Logger
) {}
// result will be used in the next call to showOpenDialog
// if it is being read once, it will be reset to undefined
fakeDialogResult: FakeDialogResult | undefined = undefined;
getFakedResult() {
const result = this.fakeDialogResult;
this.fakeDialogResult = undefined;
return result;
}
/**
* Sets a fake dialog result that will be used by subsequent dialog-showing methods.
* This is primarily used for testing purposes (e.g., with Playwright) to simulate
* user interaction with file dialogs without actually displaying them.
* The fake result is consumed after one use.
* @param result - The fake dialog result to set, or undefined to clear any existing fake result.
*/
@IpcHandle({ scope: IpcScope.DIALOG })
setFakeDialogResult(result: FakeDialogResult | undefined) {
this.fakeDialogResult = result;
// for convenience, we will fill filePaths with filePath if it is not set
if (result?.filePaths === undefined && result?.filePath !== undefined) {
result.filePaths = [result.filePath];
}
}
/**
* This function is called when the user clicks the "Save" button in the "Save Workspace" dialog.
*
* It will just copy the file to the given path
*/
@IpcHandle({ scope: IpcScope.DIALOG })
async saveDBFileAs(
universalId: string,
name: string
): Promise<SaveDBFileResult> {
try {
const { peer, type, id } = parseUniversalId(universalId);
const dbPath = await this.workspacePathService.getSpaceDBPath(
peer,
type,
id
);
// connect to the pool and make sure all changes (WAL) are written to db
const pool = this.nbstore.pool;
await pool.connect(universalId, dbPath);
await pool.checkpoint(universalId); // make sure all changes (WAL) are written to db
const fakedResult = this.getFakedResult();
if (!dbPath) {
return {
error: 'DB_FILE_PATH_INVALID',
};
}
const ret =
fakedResult ??
(await this.rpcService.rpc?.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',
}));
if (!ret) {
return {
error: 'UNKNOWN_ERROR',
};
}
const filePath = ret.filePath;
if (ret.canceled || !filePath) {
return {
canceled: true,
};
}
await fs.copyFile(dbPath, filePath);
this.logger.log('saved', filePath, this.context);
if (!fakedResult) {
this.rpcService.rpc?.showItemInFolder(filePath).catch(err => {
this.logger.error(err, this.context);
});
}
return { filePath };
} catch (err) {
this.logger.error('saveDBFileAs', err, this.context);
return {
error: 'UNKNOWN_ERROR',
};
}
}
/**
* Show an open dialog
*/
@IpcHandle({ scope: IpcScope.DIALOG })
async loadDBFile(dbFilePath?: string): Promise<LoadDBFileResult> {
try {
const provided =
this.getFakedResult() ??
(dbFilePath
? {
filePath: dbFilePath,
filePaths: [dbFilePath],
canceled: false,
}
: undefined);
const ret =
provided ??
(await this.rpcService.rpc?.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',
}));
if (!ret) {
return {
error: 'UNKNOWN_ERROR',
};
}
const originalPath = ret.filePaths?.[0];
if (ret.canceled || !originalPath) {
this.logger.log('loadDBFile canceled', this.context);
return { canceled: true };
}
// the imported file should not be in app data dir
if (
originalPath.startsWith(
await this.workspacePathService.getWorkspacesBasePath()
)
) {
this.logger.warn('loadDBFile: db file in app data dir', this.context);
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 this.cpV1DBFile(originalPath, workspaceId);
}
// v2 import logic
const internalFilePath = await this.workspacePathService.getSpaceDBPath(
'local',
'workspace',
workspaceId
);
await fs.ensureDir(path.parse(internalFilePath).dir);
await fs.copy(originalPath, internalFilePath);
this.logger.log(
`loadDBFile, copy: ${originalPath} -> ${internalFilePath}`,
this.context
);
storage = new DocStorage(internalFilePath);
await storage.setSpaceId(workspaceId);
return {
workspaceId,
};
} catch (err) {
this.logger.error('loadDBFile', err, this.context);
return {
error: 'UNKNOWN_ERROR',
};
}
}
@IpcHandle({ scope: IpcScope.DIALOG })
async selectDBFileLocation(): Promise<SelectDBFileLocationResult> {
try {
const ret =
this.getFakedResult() ??
(await this.rpcService.rpc?.showOpenDialog({
properties: ['openDirectory'],
title: 'Set Workspace Storage Location',
buttonLabel: 'Select',
defaultPath: await this.rpcService.rpc?.getPath('documents'),
message: "Select a location to store the workspace's database file",
}));
if (!ret) {
return {
error: 'UNKNOWN_ERROR',
};
}
const dir = ret.filePaths?.[0];
if (ret.canceled || !dir) {
return {
canceled: true,
};
}
return { filePath: dir };
} catch (err) {
this.logger.error('selectDBFileLocation', err, this.context);
return {
error: (err as any).message,
};
}
}
async 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
}
// checkout to make sure wal is flushed
const connection = new SqliteConnection(originalPath);
await connection.connect();
await connection.checkpoint();
await connection.close();
const internalFilePath = await this.workspacePathService.getWorkspaceDBPath(
'workspace',
workspaceId
);
await fs.ensureDir(await this.workspacePathService.getWorkspacesBasePath());
await fs.copy(originalPath, internalFilePath);
this.logger.log(
`loadDBFile, copy: ${originalPath} -> ${internalFilePath}`,
this.context
);
await this.workspacePathService.storeWorkspaceMeta(workspaceId, {
id: workspaceId,
mainDBPath: internalFilePath,
});
return {
workspaceId,
};
}
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { NBStoreModule } from '../nbstore';
import { DialogHandlerService } from './dialog-handler.service';
/**
* Module that provides dialog functionality for the helper process
*/
@Module({
providers: [DialogHandlerService],
exports: [DialogHandlerService],
imports: [NBStoreModule],
})
export class DialogModule {}

View File

@ -0,0 +1,96 @@
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { AsyncCall } from 'async-call-rpc';
import type { MessagePortMain } from 'electron';
import { AFFINE_RENDERER_CONNECT_CHANNEL_NAME, IpcScanner } from '../../ipc';
import type { RendererToHelper } from './types';
/**
* Service that handles the initial bootstrap of the helper process
* and sets up the connection to the renderer process
*/
@Injectable()
export class HelperBootstrapService implements OnModuleInit {
private readonly context = 'HelperBootstrap';
constructor(
private readonly logger: Logger,
private readonly ipcScanner: IpcScanner
) {}
/**
* Initialize the helper process, setting up message listeners for renderer connection
*/
onModuleInit(): void {
this.logger.log(`Helper bootstrap started`, this.context);
// Check if we're in a worker environment with a parent port
if (!process.parentPort) {
this.logger.error(
'Helper process was not started in a worker environment'
);
return;
}
// Listen for 'renderer-connect' messages from the main process
process.parentPort.on('message', e => {
if (
e.data.channel === AFFINE_RENDERER_CONNECT_CHANNEL_NAME &&
e.ports.length === 1
) {
this.connectToRenderer(e.ports[0]);
this.logger.debug('Renderer connected', this.context);
}
});
this.logger.log(
'Helper bootstrap complete, waiting for renderer connection',
this.context
);
}
connectToRenderer(rendererPort: MessagePortMain) {
const handlers = this.ipcScanner.scanHandlers();
const flattenedHandlers = Array.from(handlers.entries()).map(
([channel, handler]) => {
const handlerWithLog = async (...args: any[]) => {
try {
const start = performance.now();
const result = await handler(...args);
this.logger.debug(
`${channel}`,
'async-api',
`${args.filter(
arg => typeof arg !== 'function' && typeof arg !== 'object'
)} - ${(performance.now() - start).toFixed(2)} ms`
);
return result;
} catch (error) {
this.logger.error(`${channel}`, String(error), 'async-api');
throw error; // Re-throw to ensure error is communicated back
}
};
return [channel, handlerWithLog];
}
);
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,
});
}
}

View File

@ -0,0 +1,6 @@
import { bootstrap } from './bootstrap';
bootstrap().catch(err => {
console.error(err);
process.exit(1);
});

View File

@ -0,0 +1,18 @@
import { Global, Logger, Module, Scope } from '@nestjs/common';
import { createLoggerService } from '../../../logger';
export const logger = createLoggerService('helper');
@Global()
@Module({
providers: [
{
scope: Scope.TRANSIENT,
provide: Logger,
useValue: logger,
},
],
exports: [Logger],
})
export class LoggerModule {}

View File

@ -0,0 +1,43 @@
import { Global, Injectable, Module, type OnModuleInit } from '@nestjs/common';
import { AsyncCall, type AsyncVersionOf } from 'async-call-rpc';
import type { MainToHelper } from '../../shared/type';
@Injectable()
export class MainRpcService implements OnModuleInit {
rpc: AsyncVersionOf<MainToHelper> | null = null;
onModuleInit(): void {
if (!process.parentPort) {
console.error('[MainRpcService] parentPort is not available');
return;
}
this.rpc = AsyncCall<MainToHelper>(null, {
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,
});
}
}
@Global()
@Module({
providers: [MainRpcService],
exports: [MainRpcService],
})
export class MainRpcModule {}

View File

@ -0,0 +1,26 @@
export { universalId } from '@affine/nbstore';
import { Module } from '@nestjs/common';
import { NBStoreService } from './nbstore.service';
import { LegacyDBHandlers } from './v1';
import { LegacyDBManager } from './v1/db-manager.service';
import { WorkspaceHandlersService } from './workspace-handlers.service';
import { WorkspacePathService } from './workspace-path.service';
@Module({
providers: [
LegacyDBHandlers,
LegacyDBManager,
NBStoreService,
WorkspacePathService,
WorkspaceHandlersService,
],
exports: [
LegacyDBManager,
NBStoreService,
WorkspacePathService,
WorkspaceHandlersService,
],
})
export class NBStoreModule {}

View File

@ -0,0 +1,113 @@
import path from 'node:path';
import { DocStoragePool } from '@affine/native';
import { parseUniversalId } from '@affine/nbstore';
import { Injectable, Logger } from '@nestjs/common';
import fs from 'fs-extra';
import { IpcHandle, IpcScope } from '../../../ipc';
import { WorkspacePathService } from './workspace-path.service';
@Injectable()
export class NBStoreService {
constructor(
private readonly workspacePathService: WorkspacePathService,
private readonly logger: Logger
) {}
pool = new DocStoragePool();
@IpcHandle({ scope: IpcScope.NBSTORE })
connect = async (universalId: string) => {
this.logger.log('connect', universalId, 'nbstore');
const { peer, type, id } = parseUniversalId(universalId);
const dbPath = await this.workspacePathService.getSpaceDBPath(
peer,
type,
id
);
await fs.ensureDir(path.dirname(dbPath));
await this.pool.connect(universalId, dbPath);
await this.pool.setSpaceId(universalId, id);
};
@IpcHandle({ scope: IpcScope.NBSTORE })
disconnect = this.pool.disconnect.bind(this.pool);
@IpcHandle({ scope: IpcScope.NBSTORE })
pushUpdate = this.pool.pushUpdate.bind(this.pool);
@IpcHandle({ scope: IpcScope.NBSTORE })
getDocSnapshot = this.pool.getDocSnapshot.bind(this.pool);
@IpcHandle({ scope: IpcScope.NBSTORE })
setDocSnapshot = this.pool.setDocSnapshot.bind(this.pool);
@IpcHandle({ scope: IpcScope.NBSTORE })
getDocUpdates = this.pool.getDocUpdates.bind(this.pool);
@IpcHandle({ scope: IpcScope.NBSTORE })
markUpdatesMerged = this.pool.markUpdatesMerged.bind(this.pool);
@IpcHandle({ scope: IpcScope.NBSTORE })
deleteDoc = this.pool.deleteDoc.bind(this.pool);
@IpcHandle({ scope: IpcScope.NBSTORE })
getDocClocks = this.pool.getDocClocks.bind(this.pool);
@IpcHandle({ scope: IpcScope.NBSTORE })
getDocClock = this.pool.getDocClock.bind(this.pool);
@IpcHandle({ scope: IpcScope.NBSTORE })
getBlob = this.pool.getBlob.bind(this.pool);
@IpcHandle({ scope: IpcScope.NBSTORE })
setBlob = this.pool.setBlob.bind(this.pool);
@IpcHandle({ scope: IpcScope.NBSTORE })
deleteBlob = this.pool.deleteBlob.bind(this.pool);
@IpcHandle({ scope: IpcScope.NBSTORE })
releaseBlobs = this.pool.releaseBlobs.bind(this.pool);
@IpcHandle({ scope: IpcScope.NBSTORE })
listBlobs = this.pool.listBlobs.bind(this.pool);
@IpcHandle({ scope: IpcScope.NBSTORE })
getPeerRemoteClocks = this.pool.getPeerRemoteClocks.bind(this.pool);
@IpcHandle({ scope: IpcScope.NBSTORE })
getPeerRemoteClock = this.pool.getPeerRemoteClock.bind(this.pool);
@IpcHandle({ scope: IpcScope.NBSTORE })
setPeerRemoteClock = this.pool.setPeerRemoteClock.bind(this.pool);
@IpcHandle({ scope: IpcScope.NBSTORE })
getPeerPulledRemoteClocks = this.pool.getPeerPulledRemoteClocks.bind(
this.pool
);
@IpcHandle({ scope: IpcScope.NBSTORE })
getPeerPulledRemoteClock = this.pool.getPeerPulledRemoteClock.bind(this.pool);
@IpcHandle({ scope: IpcScope.NBSTORE })
setPeerPulledRemoteClock = this.pool.setPeerPulledRemoteClock.bind(this.pool);
@IpcHandle({ scope: IpcScope.NBSTORE })
getPeerPushedClocks = this.pool.getPeerPushedClocks.bind(this.pool);
@IpcHandle({ scope: IpcScope.NBSTORE })
getPeerPushedClock = this.pool.getPeerPushedClock.bind(this.pool);
@IpcHandle({ scope: IpcScope.NBSTORE })
setPeerPushedClock = this.pool.setPeerPushedClock.bind(this.pool);
@IpcHandle({ scope: IpcScope.NBSTORE })
clearClocks = this.pool.clearClocks.bind(this.pool);
@IpcHandle({ scope: IpcScope.NBSTORE })
setBlobUploadedAt = this.pool.setBlobUploadedAt.bind(this.pool);
@IpcHandle({ scope: IpcScope.NBSTORE })
getBlobUploadedAt = this.pool.getBlobUploadedAt.bind(this.pool);
}

View File

@ -15,7 +15,7 @@ export class SQLiteAdapter {
if (!this.db) {
this.db = new SqliteConnection(this.path);
await this.db.connect();
logger.info(`[SQLiteAdapter]`, 'connected:', this.path);
logger.log(`[SQLiteAdapter]`, 'connected:', this.path);
}
return this.db;
}
@ -24,7 +24,7 @@ export class SQLiteAdapter {
const { db } = this;
this.db = null;
// log after close will sometimes crash the app when quitting
logger.info(`[SQLiteAdapter]`, 'destroyed:', this.path);
logger.log(`[SQLiteAdapter]`, 'destroyed:', this.path);
await db?.close();
}
@ -115,7 +115,7 @@ export class SQLiteAdapter {
}
const start = performance.now();
await this.db.insertUpdates(updates);
logger.debug(
logger.log(
`[SQLiteAdapter] addUpdateToSQLite`,
'length:',
updates.length,

View File

@ -0,0 +1,87 @@
import { existsSync } from 'node:fs';
import type { SpaceType } from '@affine/nbstore';
import { Injectable, Logger } from '@nestjs/common';
import { WorkspacePathService } from '../workspace-path.service';
import { WorkspaceSQLiteDB } from './workspace-db-adapter';
@Injectable()
export class LegacyDBManager {
constructor(
private readonly workspacePathService: WorkspacePathService,
private readonly logger: Logger
) {}
db$Map = new Map<`${SpaceType}:${string}`, Promise<WorkspaceSQLiteDB>>();
async openWorkspaceDatabase(spaceType: SpaceType, spaceId: string) {
const meta = await this.workspacePathService.getWorkspaceMeta(
spaceType,
spaceId
);
const db = new WorkspaceSQLiteDB(meta.mainDBPath, spaceId);
await db.init();
this.logger.log(`openWorkspaceDatabase [${spaceId}]`);
return db;
}
async getWorkspaceDB(spaceType: SpaceType, id: string) {
const cacheId = `${spaceType}:${id}` as const;
let db = await this.db$Map.get(cacheId);
if (!db) {
const promise = this.openWorkspaceDatabase(spaceType, id);
this.db$Map.set(cacheId, promise);
const _db = (db = await promise);
const cleanup = () => {
this.db$Map.delete(cacheId);
_db
.destroy()
.then(() => {
this.logger.log(
'[ensureSQLiteDB] db connection closed',
_db.workspaceId
);
})
.catch((err: any) => {
this.logger.error('[ensureSQLiteDB] destroy db failed', err);
});
};
db?.update$.subscribe({
complete: cleanup,
});
process.on('beforeExit', cleanup);
}
// oxlint-disable-next-line @typescript-eslint/no-non-null-assertion
return db!;
}
async ensureSQLiteDB(
spaceType: SpaceType,
id: string
): Promise<WorkspaceSQLiteDB | null> {
const meta = await this.workspacePathService.getWorkspaceMeta(
spaceType,
id
);
// do not auto create v1 db anymore
if (!existsSync(meta.mainDBPath)) {
return null;
}
return this.getWorkspaceDB(spaceType, id);
}
async ensureSQLiteDisconnected(spaceType: SpaceType, id: string) {
const db = await this.ensureSQLiteDB(spaceType, id);
if (db) {
await db.checkpoint();
await db.destroy();
}
}
}

View File

@ -0,0 +1,59 @@
import type { SpaceType } from '@affine/nbstore';
import { Injectable } from '@nestjs/common';
import { IpcHandle, IpcScope } from '../../../../ipc';
import { LegacyDBManager } from './db-manager.service';
@Injectable()
export class LegacyDBHandlers {
constructor(private readonly dbManager: LegacyDBManager) {}
@IpcHandle({ scope: IpcScope.DB })
async getDocAsUpdates(
spaceType: SpaceType,
workspaceId: string,
subdocId: string
) {
const spaceDB = await this.dbManager.ensureSQLiteDB(spaceType, workspaceId);
if (!spaceDB) {
// means empty update in yjs
return new Uint8Array([0, 0]);
}
return spaceDB.getDocAsUpdates(subdocId);
}
@IpcHandle({ scope: IpcScope.DB })
async getDocTimestamps(spaceType: SpaceType, workspaceId: string) {
const spaceDB = await this.dbManager.ensureSQLiteDB(spaceType, workspaceId);
if (!spaceDB) {
return [];
}
return spaceDB.getDocTimestamps();
}
@IpcHandle({ scope: IpcScope.DB })
async getBlob(spaceType: SpaceType, workspaceId: string, key: string) {
const spaceDB = await this.dbManager.ensureSQLiteDB(spaceType, workspaceId);
if (!spaceDB) {
return null;
}
return spaceDB.getBlob(key);
}
@IpcHandle({ scope: IpcScope.DB })
async getBlobKeys(spaceType: SpaceType, workspaceId: string) {
const spaceDB = await this.dbManager.ensureSQLiteDB(spaceType, workspaceId);
if (!spaceDB) {
return [];
}
return spaceDB.getBlobKeys();
}
}

View File

@ -1,25 +1,23 @@
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
) {}
lock = new AsyncLock();
update$ = new Subject<void>();
adapter = new SQLiteAdapter(this.path);
async transaction<T>(cb: () => Promise<T>): Promise<T> {
using _lock = await this.lock.acquire();
return await cb();
@ -128,12 +126,12 @@ export class WorkspaceSQLiteDB implements AsyncDisposable {
const count = (await this.adapter?.getUpdatesCount(dbID)) ?? 0;
if (count > TRIM_SIZE) {
return await this.transaction(async () => {
logger.debug(`trim ${this.workspaceId}:${dbID} ${count}`);
logger.log(`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`);
logger.log(`trim ${this.workspaceId}:${dbID} successfully`);
return update;
});
}
@ -144,14 +142,3 @@ export class WorkspaceSQLiteDB implements AsyncDisposable {
await this.adapter.checkpoint();
}
}
export async function openWorkspaceDatabase(
spaceType: SpaceType,
spaceId: string
) {
const meta = await getWorkspaceMeta(spaceType, spaceId);
const db = new WorkspaceSQLiteDB(meta.mainDBPath, spaceId);
await db.init();
logger.info(`openWorkspaceDatabase [${spaceId}]`);
return db;
}

View File

@ -0,0 +1,247 @@
import path from 'node:path';
import { DocStorage } from '@affine/native';
import {
parseUniversalId,
universalId as generateUniversalId,
} from '@affine/nbstore';
import { Injectable, Logger } from '@nestjs/common';
import fs from 'fs-extra';
import { applyUpdate, Doc as YDoc } from 'yjs';
import { IpcHandle, IpcScope } from '../../../ipc';
import { NBStoreService } from './nbstore.service';
import { LegacyDBManager } from './v1/db-manager.service';
import { WorkspaceSQLiteDB } from './v1/workspace-db-adapter';
import { WorkspacePathService } from './workspace-path.service';
type WorkspaceDocMeta = {
id: string;
name: string;
avatar: Uint8Array | null;
fileSize: number;
updatedAt: Date;
createdAt: Date;
docCount: number;
dbPath: string;
};
@Injectable()
export class WorkspaceHandlersService {
context = 'WorkspaceHandlersService';
constructor(
private readonly workspacePathService: WorkspacePathService,
private readonly nbstoreService: NBStoreService,
private readonly legacyDBManager: LegacyDBManager,
private readonly logger: Logger
) {}
async deleteWorkspaceV1(workspaceId: string) {
try {
await this.legacyDBManager.ensureSQLiteDisconnected(
'workspace',
workspaceId
);
const basePath = await this.workspacePathService.getWorkspaceBasePathV1(
'workspace',
workspaceId
);
await fs.rmdir(basePath, { recursive: true });
} catch (error) {
this.logger.error('deleteWorkspaceV1', error, this.context);
}
}
/**
* Permanently delete the workspace data
*/
@IpcHandle({ scope: IpcScope.WORKSPACE })
async deleteWorkspace(universalId: string) {
const { peer, type, id } = parseUniversalId(universalId);
await this.deleteWorkspaceV1(id);
const dbPath = await this.workspacePathService.getSpaceDBPath(
peer,
type,
id
);
try {
await this.nbstoreService.pool.disconnect(universalId);
await fs.rmdir(path.dirname(dbPath), { recursive: true });
} catch (e) {
this.logger.error('deleteWorkspace', e, this.context);
}
}
/**
* 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.
*/
@IpcHandle({ scope: IpcScope.WORKSPACE })
async moveToTrash(universalId: string) {
const { peer, type, id } = parseUniversalId(universalId);
await this.deleteWorkspaceV1(id);
const dbPath = await this.workspacePathService.getSpaceDBPath(
peer,
type,
id
);
const basePath =
await this.workspacePathService.getDeletedWorkspacesBasePath();
const movedPath = path.join(basePath, `${id}`);
try {
const storage = new DocStorage(dbPath);
if (await storage.validate()) {
await this.nbstoreService.pool.checkpoint(universalId);
await this.nbstoreService.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.rmdir(path.dirname(dbPath), { recursive: true });
} catch (error) {
this.logger.error('trashWorkspace', error, this.context);
}
}
@IpcHandle({ scope: IpcScope.WORKSPACE })
async getBackupWorkspaces() {
const basePath =
await this.workspacePathService.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 this.getWorkspaceDocMeta(
id,
path.join(basePath, id, 'storage.db')
);
if (meta) {
items.push(meta);
} else {
this.logger.warn(
'getDeletedWorkspaces',
`No meta found for ${id}`,
this.context
);
}
}
return {
items: items,
};
}
@IpcHandle({ scope: IpcScope.WORKSPACE })
async deleteBackupWorkspace(id: string) {
const basePath =
await this.workspacePathService.getDeletedWorkspacesBasePath();
const workspacePath = path.join(basePath, id);
await fs.rmdir(workspacePath, { recursive: true });
this.logger.log(
'deleteBackupWorkspace',
`Deleted backup workspace: ${workspacePath}`,
this.context
);
}
async 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 getWorkspaceDocMeta(
workspaceId: string,
dbPath: string
): Promise<WorkspaceDocMeta | null> {
const pool = this.nbstoreService.pool;
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 this.getWorkspaceDocMetaV1(workspaceId, dbPath);
} finally {
await pool.disconnect(universalId);
}
return null;
}
}

View File

@ -0,0 +1,126 @@
import path from 'node:path';
import { type SpaceType } from '@affine/nbstore';
import { Injectable } from '@nestjs/common';
import fs from 'fs-extra';
import { logger } from '../../main/logger';
import { isWindows } from '../../main/utils';
import { MainRpcService } from '../main-rpc';
@Injectable()
export class WorkspacePathService {
constructor(private readonly mainRpcService: MainRpcService) {}
_appDataPath: string | undefined;
async getAppDataPath() {
if (this._appDataPath) {
return this._appDataPath;
}
this._appDataPath = await this.mainRpcService.rpc?.getPath('sessionData');
if (!this._appDataPath) {
throw new Error('App data path not found');
}
return this._appDataPath;
}
async getWorkspacesBasePath() {
const appDataPath = await this.getAppDataPath();
return path.join(appDataPath, 'workspaces');
}
async getWorkspaceBasePathV1(spaceType: SpaceType, workspaceId: string) {
const appDataPath = await this.getAppDataPath();
return path.join(
appDataPath,
spaceType === 'userspace' ? 'userspaces' : 'workspaces',
isWindows() ? workspaceId.replace(':', '_') : workspaceId
);
}
async getSpaceBasePath(spaceType: SpaceType) {
return path.join(
await this.getAppDataPath(),
spaceType === 'userspace' ? 'userspaces' : 'workspaces'
);
}
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('_');
}
async getSpaceDBPath(peer: string, spaceType: SpaceType, id: string) {
const spaceBasePath = await this.getSpaceBasePath(spaceType);
return path.join(
spaceBasePath,
this.escapeFilename(peer),
id,
'storage.db'
);
}
async getDeletedWorkspacesBasePath() {
const appDataPath = await this.getAppDataPath();
return path.join(appDataPath, 'deleted-workspaces');
}
async getWorkspaceDBPath(spaceType: SpaceType, workspaceId: string) {
const workspaceBasePath = await this.getWorkspaceBasePathV1(
spaceType,
workspaceId
);
return path.join(workspaceBasePath, 'storage.db');
}
async getWorkspaceMetaPath(spaceType: SpaceType, workspaceId: string) {
const workspaceBasePath = await this.getWorkspaceBasePathV1(
spaceType,
workspaceId
);
return path.join(workspaceBasePath, 'meta.json');
}
/**
* Get workspace meta, create one if not exists
* This function will also migrate the workspace if needed
*/
async getWorkspaceMeta(spaceType: SpaceType, workspaceId: string) {
const dbPath = await this.getWorkspaceDBPath(spaceType, workspaceId);
return {
mainDBPath: dbPath,
id: workspaceId,
};
}
async storeWorkspaceMeta(
workspaceId: string,
meta: Partial<{
mainDBPath: string;
id: string;
}>
) {
try {
const basePath = await this.getWorkspaceBasePathV1(
'workspace',
workspaceId
);
await fs.ensureDir(basePath);
const metaPath = path.join(basePath, 'meta.json');
const currentMeta = await this.getWorkspaceMeta('workspace', workspaceId);
const newMeta = {
...currentMeta,
...meta,
};
await fs.writeJSON(metaPath, newMeta);
} catch (err) {
logger.error('storeWorkspaceMeta failed', err);
}
}
}

View File

@ -1,8 +1,12 @@
/**
* Workspace metadata type
*/
export interface WorkspaceMeta {
id: string;
mainDBPath: string;
name: string;
avatar?: string;
createDate: string;
[key: string]: any;
}
export type YOrigin = 'self' | 'external' | 'upstream' | 'renderer';
export type MainEventRegister = (...args: any[]) => () => void;

View File

@ -0,0 +1,45 @@
import type { MessagePortMain } from 'electron';
/**
* Type for namespaced handlers, used for RPC registration
*/
export type NamespacedHandlers = Record<
string,
Record<string, (...args: any[]) => any>
>;
/**
* Type for event registrations
* These are functions that take a callback and return an unsubscribe function
*/
export type EventRegistration = (cb: (...args: any[]) => void) => () => void;
/**
* Type for namespaced events
*/
export type NamespacedEvents = Record<
string,
Record<string, EventRegistration>
>;
/**
* Interface for the RPC service that communicates with the renderer
*/
export interface RendererRpcInterface {
connect(port: MessagePortMain): void;
postEvent(channel: string, ...args: any[]): Promise<void>;
}
/**
* Interface for renderer to helper communication
*/
export interface RendererToHelper {
postEvent: (channel: string, ...args: any[]) => void;
}
/**
* Interface for helper to renderer communication
*/
export interface HelperToRenderer {
[key: string]: (...args: any[]) => Promise<any>;
}

View File

@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { ElectronIpcModule } from '../../ipc';
import { HelperProcessModule } from './helper-process';
import { LoggerModule } from './logger';
import { MiscModule } from './misc';
import { StorageModule } from './storage';
import { UpdaterModule } from './updater';
import { WindowsModule } from './windows';
@Module({
imports: [
WindowsModule,
LoggerModule,
ElectronIpcModule.forMain(),
HelperProcessModule,
StorageModule,
UpdaterModule,
MiscModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,84 @@
import path from 'node:path';
import { NestFactory } from '@nestjs/core';
import { app as electronApp } from 'electron';
import { buildType, isDev, overrideSession } from '../../shared/constants';
import { AppModule } from './app.module';
import { logger } from './logger';
import { registerSchemes, setAsDefaultProtocolClient } from './misc';
function enableSandbox() {
electronApp.enableSandbox();
}
function setupSquirrel() {
// oxlint-disable-next-line @typescript-eslint/no-var-requires
if (require('electron-squirrel-startup')) electronApp.quit();
}
function setupCommandLine() {
electronApp.commandLine.appendSwitch('enable-features', 'CSSTextAutoSpace');
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
electronApp.commandLine.appendSwitch('host-rules', 'MAP 0.0.0.0 127.0.0.1');
}
// https://github.com/electron/electron/issues/43556
electronApp.commandLine.appendSwitch(
'disable-features',
'PlzDedicatedWorker'
);
}
function ensureSingleInstance() {
/**
* Prevent multiple instances
*/
const isSingleInstance = electronApp.requestSingleInstanceLock();
if (!isSingleInstance) {
logger.log(
'Another instance is running or responding deep link, exiting...'
);
electronApp.quit();
process.exit(0);
}
}
function configurePaths() {
// use the same data for internal & beta for testing
if (overrideSession) {
const appName = buildType === 'stable' ? 'AFFiNE' : `AFFiNE-${buildType}`;
const userDataPath = path.join(electronApp.getPath('appData'), appName);
electronApp.setPath('userData', userDataPath);
electronApp.setPath('sessionData', userDataPath);
}
}
// some settings must be called before ready
function beforeReady() {
enableSandbox();
setupSquirrel();
setupCommandLine();
ensureSingleInstance();
configurePaths();
registerSchemes();
setAsDefaultProtocolClient();
}
export async function bootstrap() {
beforeReady();
const context = await NestFactory.createApplicationContext(AppModule, {
logger, // use our own logger
});
// Close on Electron quit
electronApp.on('before-quit', () => {
context.close().catch(err => {
logger.error(err);
});
});
}

View File

@ -1,8 +1,6 @@
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)[] = [];
@ -35,22 +33,3 @@ export function onTabClose(tabId: string) {
}
});
}
app.on('ready', () => {
globalStateStorage.set('media:playback-state', null);
globalStateStorage.set('media:stats', null);
});
beforeAppQuit(() => {
globalStateStorage.set('media:playback-state', null);
globalStateStorage.set('media:stats', null);
});
// set audio play state
beforeTabClose(tabId => {
const stats = globalStateStorage.get<MediaStats | null>('media:stats');
if (stats && stats.tabId === tabId) {
globalStateStorage.set('media:playback-state', null);
globalStateStorage.set('media:stats', null);
}
});

View File

@ -1,3 +1,5 @@
import { join } from 'node:path';
export const mainHost = '.';
export const anotherHost = 'another-host';
@ -13,3 +15,6 @@ export const customThemeViewUrl = `${mainWindowOrigin}/theme-editor.html`;
// Notes from electron official docs:
// "The zoom policy at the Chromium level is same-origin, meaning that the zoom level for a specific domain propagates across all instances of windows with the same domain. Differentiating the window URLs will make zoom work per-window."
export const popupViewUrl = `${anotherOrigin}/popup.html`;
// Resources path
export const resourcesPath = join(__dirname, `../resources`);

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { HelperProcessManager } from './helper-process.service';
@Module({
providers: [HelperProcessManager],
exports: [HelperProcessManager],
})
export class HelperProcessModule {}

View File

@ -0,0 +1,187 @@
import path from 'node:path';
import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
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 {
AFFINE_HELPER_CONNECT_CHANNEL_NAME,
AFFINE_RENDERER_CONNECT_CHANNEL_NAME,
} from '../../../ipc';
import type { HelperToMain, MainToHelper } from '../../../shared/type';
import { MessageEventChannel } from '../../../shared/utils';
const isDev = process.env.NODE_ENV === 'development';
const HELPER_PROCESS_PATH = path.join(__dirname, './helper.js');
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);
}
@Injectable()
export class HelperProcessManager
implements OnModuleInit, OnApplicationShutdown
{
private readonly logger = new Logger(HelperProcessManager.name);
private utilityProcess: UtilityProcess | null = null;
private _ready = Promise.withResolvers<void>();
// RPC client for Main -> Helper calls
rpcToHelper?: _AsyncVersionOf<HelperToMain>;
get ready() {
return this._ready.promise;
}
async ensureHelperProcess() {
await this.ready;
// oxlint-disable-next-line no-non-null-assertion
return this.utilityProcess!;
}
onModuleInit() {
app.on('ready', () => {
this.logger.log('Initializing helper process...');
const helperProcess = utilityProcess.fork(HELPER_PROCESS_PATH, [], {
execArgv: isDev ? ['--inspect=40895'] : [], // Adjusted port
serviceName: 'affine-helper-nestjs',
stdio: 'pipe', // Capture stdio for logging
});
this.utilityProcess = helperProcess;
if (isDev) {
helperProcess.stdout?.on('data', data => {
this.logger.log(data.toString().trim());
});
helperProcess.stderr?.on('data', data => {
this.logger.error(data.toString().trim());
});
}
helperProcess.once('spawn', () => {
this.logger.log(
`Helper process spawned successfully (PID: ${helperProcess.pid})`
);
// The RPC and other connections will be set up after spawn,
// possibly triggered by other services or parts of the app.
this._ready.resolve();
});
helperProcess.once('exit', code => {
this.logger.warn(`Helper process exited with code: ${code}`);
this.utilityProcess = null;
// Re-reject the promise if it hasn't resolved yet, or handle re-initialization
this._ready.reject(
new Error(`Helper process exited with code: ${code}`)
);
// Reset ready promise for potential restarts
this._ready = Promise.withResolvers<void>();
});
});
app.on('will-quit', () => this.onApplicationShutdown());
}
onApplicationShutdown(signal?: string) {
this.logger.log(`Shutting down helper process (signal: ${signal})...`);
if (this.utilityProcess && this.utilityProcess.kill()) {
this.logger.log('Helper process killed.');
} else {
this.logger.log('Helper process was not running or already killed.');
}
this.utilityProcess = null;
}
// Bridge renderer <-> helper process
connectRenderer(renderer: WebContents) {
if (!this.utilityProcess) {
this.logger.error('Helper process not started, cannot connect renderer.');
throw new Error('Helper process not started.');
}
const { port1: helperPort, port2: rendererPort } = new MessageChannelMain();
this.logger.log(
`Connecting renderer (ID: ${renderer.id}) to helper process.`
);
this.utilityProcess.postMessage(
{ channel: AFFINE_RENDERER_CONNECT_CHANNEL_NAME },
[helperPort]
);
renderer.postMessage(AFFINE_HELPER_CONNECT_CHANNEL_NAME, null, [
rendererPort,
]);
return () => {
try {
helperPort.close();
rendererPort.close();
this.logger.log(
`Disconnected renderer (ID: ${renderer.id}) from helper process.`
);
} catch (err) {
this.logger.error('Error closing renderer connection ports:', err);
}
};
}
// Bridge main <-> helper process
// also set up the RPC to the helper process
connectMain(window: BaseWindow) {
if (!this.utilityProcess) {
this.logger.error('Helper process not started, cannot connect main.');
throw new Error('Helper process not started.');
}
const dialogMethods = {
showOpenDialog: async (opts: OpenDialogOptions) => {
return dialog.showOpenDialog(window, opts);
},
showSaveDialog: async (opts: SaveDialogOptions) => {
return dialog.showSaveDialog(window, opts);
},
};
const shellMethods = pickAndBind(shell, [
'openExternal',
'showItemInFolder',
]);
const appMethods = pickAndBind(app, ['getPath']);
// some electron api is not available in the helper process
// so we need to proxy them to the helper process
const mainToHelperServer: MainToHelper = {
...dialogMethods,
...shellMethods,
...appMethods,
};
this.rpcToHelper = AsyncCall<HelperToMain>(mainToHelperServer, {
strict: {
unknownMessage: false,
},
channel: new MessageEventChannel(this.utilityProcess),
log: false,
});
this.logger.log('Main process connected to helper process for RPC.');
}
}

View File

@ -0,0 +1,2 @@
export * from './helper-process.module';
export * from './helper-process.service';

View File

@ -0,0 +1,7 @@
import { bootstrap } from './bootstrap';
import { logger } from './logger';
bootstrap().catch(err => {
logger.error(err);
process.exit(1);
});

View File

@ -0,0 +1,18 @@
import { Global, Logger, Module, Scope } from '@nestjs/common';
import { createLoggerService } from '../../../logger';
export const logger = createLoggerService('main');
@Global()
@Module({
providers: [
{
scope: Scope.TRANSIENT,
provide: Logger,
useValue: logger,
},
],
exports: [Logger],
})
export class LoggerModule {}

View File

@ -0,0 +1,141 @@
import path from 'node:path';
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { app } from 'electron';
import { Subject } from 'rxjs';
import { IpcEvent, IpcScope } from '../../../ipc';
import { buildType, isDev } from '../../../shared/constants';
import type { MainWindowManager, TabViewsManager } from '../windows';
let protocol = buildType === 'stable' ? 'affine' : `affine-${buildType}`;
if (isDev) {
protocol = 'affine-dev';
}
export function setAsDefaultProtocolClient() {
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient(protocol, process.execPath, [
path.resolve(process.argv[1]),
]);
}
} else {
app.setAsDefaultProtocolClient(protocol);
}
}
export interface AuthenticationRequest {
method: 'magic-link' | 'oauth';
payload: Record<string, any>;
server?: string;
}
@Injectable()
export class DeepLinkService implements OnModuleInit {
constructor(
private readonly mainWindow: MainWindowManager,
private readonly tabViews: TabViewsManager,
private readonly logger: Logger
) {}
context = 'deep-link';
/**
* Emits when an authentication request is received via deep link.
*/
@IpcEvent({ scope: IpcScope.UI })
authenticationRequest$ = new Subject<AuthenticationRequest>();
onModuleInit() {
app.on('open-url', (event, url) => {
this.logger.log('open-url', url, this.context);
if (url.startsWith(`${protocol}://`)) {
event.preventDefault();
app
.whenReady()
.then(() => this.handleAffineUrl(url))
.catch(e => {
this.logger.error('failed to handle affine url', e);
});
}
});
// on windows & linux, we need to listen for the second-instance event
app.on('second-instance', (event, commandLine) => {
this.mainWindow
.getMainWindow()
.then(window => {
if (!window) {
this.logger.error('main window is not ready');
return;
}
window.show();
const url = commandLine.pop();
if (url?.startsWith(`${protocol}://`)) {
event.preventDefault();
this.handleAffineUrl(url).catch(e => {
this.logger.error('failed to handle affine 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);
this.logger.log('url from argv', process.argv, url);
if (url?.startsWith(`${protocol}://`)) {
this.handleAffineUrl(url).catch(e => {
this.logger.error('failed to handle affine url', e);
});
}
});
}
async handleAffineUrl(url: string) {
this.logger.log('open affine url', url, this.context);
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') ||
!payload
) {
this.logger.error('Invalid authentication url', url, this.context);
return;
}
this.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 this.tabViews.addTabWithUrl(url);
} else if (urlObj.searchParams.get('hidden')) {
const hiddenWindow = await this.mainWindow.openUrlInHiddenWindow(urlObj);
const main = await this.mainWindow.getMainWindow();
if (main && hiddenWindow) {
// when hidden window closed, the main window will be hidden somehow
hiddenWindow.on('close', () => {
main.show();
});
}
} else {
this.logger.error('Unknown affine url', url, this.context);
}
}
}

View File

@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import { getIpcEvent, IpcHandle, IpcScope } from '../../../ipc';
@Injectable()
export class FindInPageService {
/**
* Initiates a find-in-page operation for the current WebContents.
* @param text The text to search for.
* @param options Options for the find-in-page operation.
* @returns A promise that resolves with the find result or null if the request was superseded.
*/
@IpcHandle({ scope: IpcScope.FIND_IN_PAGE })
async find(text: string, options: Electron.FindInPageOptions) {
const event = getIpcEvent();
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;
}
/**
* Stops the current find-in-page operation for the current WebContents.
*/
@IpcHandle({ scope: IpcScope.FIND_IN_PAGE })
clear() {
const event = getIpcEvent();
const webContents = event.sender;
webContents.stopFindInPage('keepSelection');
}
}

View File

@ -1,6 +1,6 @@
import { join } from 'node:path';
import { resourcesPath } from '../../shared/utils';
import { resourcesPath } from '../constants';
export const icons = {
record: join(resourcesPath, 'icons/waveform.png'),

View File

@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { RecordingModule } from '../recording';
import { WindowsModule } from '../windows';
import { FindInPageService } from './find-in-page.service';
import { ProtocolService } from './protocol.service';
import { SecurityService } from './security.service';
import { TrayManager } from './tray.service';
import { UtilsHandleService } from './utils-handle.service';
@Module({
providers: [
ProtocolService,
SecurityService,
UtilsHandleService,
FindInPageService,
TrayManager,
],
imports: [WindowsModule, RecordingModule],
})
export class MiscModule {}
export * from './deep-link.service';
export * from './protocol.service';
export * from './security.service';
export * from './utils-handle.service';

View File

@ -0,0 +1,211 @@
import { join } from 'node:path';
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { app, net, protocol, session } from 'electron';
import cookieParser from 'set-cookie-parser';
import { anotherHost, mainHost, resourcesPath } from '../constants';
export function registerSchemes() {
protocol.registerSchemesAsPrivileged([
{
scheme: 'assets',
privileges: {
secure: false,
corsEnabled: true,
supportFetchAPI: true,
standard: true,
bypassCSP: true,
},
},
]);
protocol.registerSchemesAsPrivileged([
{
scheme: 'file',
privileges: {
secure: false,
corsEnabled: true,
supportFetchAPI: true,
standard: true,
bypassCSP: true,
stream: true,
},
},
]);
}
const webStaticDir = join(resourcesPath, 'web-static');
@Injectable()
export class ProtocolService implements OnModuleInit {
constructor(private readonly logger: Logger) {}
onModuleInit() {
app.on('ready', () => {
this.setupInterceptors();
});
}
private readonly handleFileRequest = async (request: Request) => {
const urlObject = new URL(request.url);
if (urlObject.host === anotherHost) {
urlObject.host = mainHost;
}
const isAbsolutePath = urlObject.host !== '.';
// Redirect to webpack dev server if defined
if (process.env.DEV_SERVER_URL && !isAbsolutePath) {
const devServerUrl = new URL(
urlObject.pathname,
process.env.DEV_SERVER_URL
);
return net.fetch(devServerUrl.toString(), request);
}
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('.')) {
// Sanitize pathname to prevent path traversal attacks
const decodedPath = decodeURIComponent(urlObject.pathname);
const normalizedPath = join(webStaticDir, decodedPath).normalize();
if (!normalizedPath.startsWith(webStaticDir)) {
// Attempted path traversal - reject by using empty path
filepath = join(webStaticDir, '');
} else {
filepath = normalizedPath;
}
} 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(/^\//, ''));
}
// security check if the filepath is within app.getPath('sessionData')
const sessionDataPath = path
.resolve(app.getPath('sessionData'))
.toLowerCase();
const tempPath = path.resolve(app.getPath('temp')).toLowerCase();
if (
!filepath.toLowerCase().startsWith(sessionDataPath) &&
!filepath.toLowerCase().startsWith(tempPath)
) {
throw new Error('Invalid filepath');
}
}
return net.fetch(pathToFileURL(filepath).toString(), clonedRequest);
};
setupInterceptors = () => {
this.logger.log('setting up interceptors', 'ProtocolService');
protocol.handle('file', request => {
return this.handleFileRequest(request);
});
protocol.handle('assets', request => {
return this.handleFileRequest(request);
});
session.defaultSession.webRequest.onHeadersReceived(
(responseDetails, callback) => {
const { responseHeaders } = responseDetails;
(async () => {
if (responseHeaders) {
const originalCookie =
responseHeaders['set-cookie'] || responseHeaders['Set-Cookie'];
if (originalCookie) {
// save the cookies, to support third party cookies
for (const cookies of originalCookie) {
const parsedCookies = cookieParser.parse(cookies);
for (const parsedCookie of parsedCookies) {
if (!parsedCookie.value) {
await session.defaultSession.cookies.remove(
responseDetails.url,
parsedCookie.name
);
} else {
await session.defaultSession.cookies.set({
url: responseDetails.url,
domain: parsedCookie.domain,
expirationDate: parsedCookie.expires?.getTime(),
httpOnly: parsedCookie.httpOnly,
secure: parsedCookie.secure,
value: parsedCookie.value,
name: parsedCookie.name,
path: parsedCookie.path,
sameSite: parsedCookie.sameSite?.toLowerCase() as
| 'unspecified'
| 'no_restriction'
| 'lax'
| 'strict'
| undefined,
});
}
}
}
}
delete responseHeaders['access-control-allow-origin'];
delete responseHeaders['access-control-allow-headers'];
delete responseHeaders['Access-Control-Allow-Origin'];
delete responseHeaders['Access-Control-Allow-Headers'];
}
})()
.catch(err => {
this.logger.error('error handling headers received', err);
})
.finally(() => {
callback({ responseHeaders });
});
}
);
session.defaultSession.webRequest.onBeforeSendHeaders(
(details, callback) => {
const url = new URL(details.url);
(async () => {
// session cookies are set to file:// on production
// if sending request to the cloud, attach the session cookie (to affine cloud server)
if (
url.protocol === 'http:' ||
url.protocol === 'https:' ||
url.protocol === 'ws:' ||
url.protocol === 'wss:'
) {
const cookies = await session.defaultSession.cookies.get({
url: details.url,
});
const cookieString = cookies
.map(c => `${c.name}=${c.value}`)
.join('; ');
delete details.requestHeaders['cookie'];
details.requestHeaders['Cookie'] = cookieString;
}
})()
.catch(err => {
this.logger.error('error handling before send headers', err);
})
.finally(() => {
callback({
cancel: false,
requestHeaders: details.requestHeaders,
});
});
}
);
};
}

View File

@ -0,0 +1,48 @@
import { Injectable, type OnModuleInit } from '@nestjs/common';
import { app, shell } from 'electron';
@Injectable()
export class SecurityService implements OnModuleInit {
onModuleInit() {
app.on('web-contents-created', (_, contents) => {
const isInternalUrl = (url: string) => {
return url.startsWith('file://.');
};
/**
* Block navigation to origins not on the allowlist.
*
* Navigation is a common attack vector. If an attacker can convince the app to navigate away
* from its current page, they can possibly force the app to open web sites on the Internet.
*
* @see https://www.electronjs.org/docs/latest/tutorial/security#13-disable-or-limit-navigation
*/
contents.on('will-navigate', (event, url) => {
if (isInternalUrl(url)) {
return;
}
// Prevent navigation
event.preventDefault();
shell.openExternal(url).catch(console.error);
});
/**
* Hyperlinks to allowed sites open in the default browser.
*
* The creation of new `webContents` is a common attack vector. Attackers attempt to convince the app to create new windows,
* frames, or other renderer processes with more privileges than they had before; or with pages opened that they couldn't open before.
* You should deny any unexpected window creation.
*
* @see https://www.electronjs.org/docs/latest/tutorial/security#14-disable-or-limit-creation-of-new-windows
* @see https://www.electronjs.org/docs/latest/tutorial/security#15-do-not-use-openexternal-with-untrusted-content
*/
contents.setWindowOpenHandler(({ url }) => {
if (!isInternalUrl(url) || url.includes('/redirect-proxy')) {
// Open default browser
shell.openExternal(url).catch(console.error);
}
// Prevent creating new window in application
return { action: 'deny' };
});
});
}
}

View File

@ -1,3 +1,4 @@
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import {
app,
Menu,
@ -9,24 +10,18 @@ import {
} from 'electron';
import { map, shareReplay } from 'rxjs';
import { isMacOS } from '../../shared/utils';
import { applicationMenuSubjects } from '../application-menu';
import { beforeAppQuit } from '../cleanup';
import { logger } from '../logger';
import {
appGroups$,
checkCanRecordMeeting,
checkRecordingAvailable,
MeetingsSettingsState,
recordingStatus$,
startRecording,
stopRecording,
updateApplicationsPing$,
} from '../recording/feature';
import { MenubarStateKey, MenubarStateSchema } from '../shared-state-schema';
import { globalStateStorage } from '../shared-storage/storage';
import { getMainWindow } from '../windows-manager';
MenubarStateKey,
MenubarStateSchema,
} from '../../../shared/shared-state-schema';
import { beforeAppQuit } from '../cleanup';
import { MeetingsSettingsState } from '../recording/meetings-settings-state.service';
import { RecordingManager } from '../recording/recording.service';
import { GlobalStateStorage } from '../storage/storage.service';
import { isMacOS } from '../utils';
import { ApplicationMenuManager, MainWindowManager } from '../windows';
import { icons } from './icons';
export interface TrayMenuConfigItem {
label: string;
click?: () => void;
@ -43,14 +38,6 @@ interface TrayMenuProvider {
getConfig(): TrayMenuConfig;
}
function showMainWindow() {
getMainWindow()
.then(w => {
w.show();
})
.catch(err => logger.error('Failed to show main window:', err));
}
function buildMenuConfig(config: TrayMenuConfig): MenuItemConstructorOptions[] {
const menuConfig: MenuItemConstructorOptions[] = [];
config.forEach(item => {
@ -94,7 +81,15 @@ class TrayState implements Disposable {
// tray's tooltip
tooltip: string = 'AFFiNE';
constructor() {
context = 'tray';
constructor(
private readonly mainWindow: MainWindowManager,
private readonly applicationMenu: ApplicationMenuManager,
private readonly recordingService: RecordingManager,
private readonly meetingSettings: MeetingsSettingsState,
private readonly logger: Logger
) {
this.icon.setTemplateImage(true);
this.init();
}
@ -108,27 +103,57 @@ class TrayState implements Disposable {
label: 'Open Journal',
icon: icons.journal,
click: () => {
logger.info('User action: Open Journal');
showMainWindow();
applicationMenuSubjects.openJournal$.next();
this.logger.log('User action: Open Journal');
this.mainWindow
.show()
.then(() => {
this.applicationMenu.openJournal$.next();
})
.catch(err => {
this.logger.error(
'Failed to open main window:',
err,
this.context
);
});
},
},
{
label: 'New Page',
icon: icons.page,
click: () => {
logger.info('User action: New Page');
showMainWindow();
applicationMenuSubjects.newPageAction$.next('page');
this.logger.log('User action: New Page');
this.mainWindow
.show()
.then(() => {
this.applicationMenu.newPageAction$.next('page');
})
.catch(err => {
this.logger.error(
'Failed to open main window:',
err,
this.context
);
});
},
},
{
label: 'New Edgeless',
icon: icons.edgeless,
click: () => {
logger.info('User action: New Edgeless');
showMainWindow();
applicationMenuSubjects.newPageAction$.next('edgeless');
this.logger.log('User action: New Edgeless');
this.mainWindow
.show()
.then(() => {
this.applicationMenu.newPageAction$.next('edgeless');
})
.catch(err => {
this.logger.error(
'Failed to open main window:',
err,
this.context
);
});
},
},
],
@ -136,29 +161,29 @@ class TrayState implements Disposable {
}
getRecordingMenuProvider(): TrayMenuProvider | null {
if (!checkRecordingAvailable()) {
if (!this.recordingService.checkRecordingAvailable()) {
return null;
}
const getConfig = () => {
const items: TrayMenuConfig = [];
if (!MeetingsSettingsState.value.enabled) {
if (!this.meetingSettings.value.enabled) {
items.push({
label: 'Meetings are disabled',
disabled: true,
});
} else if (!checkCanRecordMeeting()) {
} else if (!this.recordingService.checkCanRecordMeeting()) {
items.push({
label: 'Required permissions not granted',
disabled: true,
});
} else {
const appGroups = appGroups$.value;
const appGroups = this.recordingService.appGroups$.value;
const runningAppGroups = appGroups.filter(
appGroup => appGroup.isRunning
);
const recordingStatus = recordingStatus$.value;
const recordingStatus = this.recordingService.recordingStatus$.value;
if (
!recordingStatus ||
@ -169,10 +194,11 @@ class TrayState implements Disposable {
label: appGroup.name,
icon: appGroup.icon || undefined,
click: () => {
logger.info(
`User action: Start Recording Meeting (${appGroup.name})`
this.logger.log(
`User action: Start Recording Meeting (${appGroup.name})`,
this.context
);
startRecording(appGroup);
this.recordingService.startRecording(appGroup);
},
}));
@ -185,10 +211,11 @@ class TrayState implements Disposable {
label: 'System audio (all audio will be recorded)',
icon: icons.monitor,
click: () => {
logger.info(
'User action: Start Recording Meeting (System audio)'
this.logger.log(
'User action: Start Recording Meeting (System audio)',
this.context
);
startRecording();
this.recordingService.startRecording();
},
},
...appMenuItems,
@ -211,23 +238,35 @@ class TrayState implements Disposable {
{
label: 'Stop',
click: () => {
logger.info('User action: Stop Recording');
stopRecording(recordingStatus.id).catch(err => {
logger.error('Failed to stop recording:', err);
});
this.logger.log('User action: Stop Recording', this.context);
this.recordingService
.stopRecording(recordingStatus.id)
.catch(err => {
this.logger.error('Failed to stop recording:', err);
});
},
}
);
}
}
if (checkRecordingAvailable()) {
if (this.recordingService.checkRecordingAvailable()) {
items.push({
label: `Meetings Settings...`,
click: () => {
showMainWindow();
applicationMenuSubjects.openInSettingModal$.next({
activeTab: 'meetings',
});
this.mainWindow
.show()
.then(() => {
this.applicationMenu.openInSettingModal$.next({
activeTab: 'meetings',
});
})
.catch(err => {
this.logger.error(
'Failed to open main window:',
err,
this.context
);
});
},
});
}
@ -248,40 +287,56 @@ class TrayState implements Disposable {
{
label: 'Open AFFiNE',
click: () => {
logger.info('User action: Open AFFiNE');
getMainWindow()
.then(w => {
w.show();
this.logger.log('User action: Open AFFiNE', this.context);
this.mainWindow
.show()
.then(() => {
this.applicationMenu.openJournal$.next();
})
.catch(err => {
logger.error('Failed to open AFFiNE:', err);
this.logger.error('Failed to open AFFiNE:', err, this.context);
});
},
},
{
label: 'Menubar settings...',
click: () => {
showMainWindow();
applicationMenuSubjects.openInSettingModal$.next({
activeTab: 'appearance',
scrollAnchor: 'menubar',
});
this.mainWindow
.show()
.then(() => {
this.applicationMenu.openInSettingModal$.next({
activeTab: 'appearance',
scrollAnchor: 'menubar',
});
})
.catch(err => {
this.logger.error('Failed to open AFFiNE:', err, this.context);
});
},
},
{
label: `About ${app.getName()}`,
click: () => {
showMainWindow();
applicationMenuSubjects.openInSettingModal$.next({
activeTab: 'about',
});
this.mainWindow
.show()
.then(() => {
this.applicationMenu.openInSettingModal$.next({
activeTab: 'about',
});
})
.catch(err => {
this.logger.error('Failed to open AFFiNE:', err, this.context);
});
},
},
'separator',
{
label: 'Quit AFFiNE Completely...',
click: () => {
logger.info('User action: Quit AFFiNE Completely');
this.logger.log(
'User action: Quit AFFiNE Completely',
this.context
);
app.quit();
},
},
@ -314,18 +369,19 @@ class TrayState implements Disposable {
this.tray = new Tray(this.icon);
this.tray.setToolTip(this.tooltip);
const clickHandler = () => {
logger.debug('User clicked on tray icon');
this.logger.debug('User clicked on tray icon', this.context);
this.update();
if (!isMacOS()) {
this.tray?.popUpContextMenu();
}
updateApplicationsPing$.next(Date.now());
this.recordingService.updateApplicationsPing$.next(Date.now());
};
this.tray.on('click', clickHandler);
const appGroupsSubscription = appGroups$.subscribe(() => {
logger.debug('App groups updated, refreshing tray menu');
this.update();
});
const appGroupsSubscription = this.recordingService.appGroups$.subscribe(
() => {
this.update();
}
);
this.disposables.push(() => {
this.tray?.off('click', clickHandler);
@ -345,44 +401,66 @@ class TrayState implements Disposable {
}
init() {
logger.info('Initializing tray');
this.logger.log('Initializing tray', this.context);
this.update();
}
}
const TraySettingsState = {
$: globalStateStorage.watch<MenubarStateSchema>(MenubarStateKey).pipe(
map(v => MenubarStateSchema.parse(v ?? {})),
shareReplay(1)
),
@Injectable()
export class TrayManager implements OnModuleInit {
constructor(
private readonly globalStateStorage: GlobalStateStorage,
private readonly mainWindow: MainWindowManager,
private readonly applicationMenu: ApplicationMenuManager,
private readonly recordingService: RecordingManager,
private readonly meetingSettings: MeetingsSettingsState,
private readonly logger: Logger
) {}
get value() {
return MenubarStateSchema.parse(
globalStateStorage.get(MenubarStateKey) ?? {}
);
},
};
_trayState: TrayState | undefined;
export const setupTrayState = () => {
let _trayState: TrayState | undefined;
if (TraySettingsState.value.enabled) {
_trayState = new TrayState();
}
settingsState = {
$: this.globalStateStorage.watch<MenubarStateSchema>(MenubarStateKey).pipe(
map(v => MenubarStateSchema.parse(v ?? {})),
shareReplay(1)
),
const updateTrayState = (state: MenubarStateSchema) => {
value: () => {
return MenubarStateSchema.parse(
this.globalStateStorage.get(MenubarStateKey) ?? {}
);
},
};
updateTrayState = (state: MenubarStateSchema) => {
if (state.enabled) {
if (!_trayState) {
_trayState = new TrayState();
if (!this._trayState) {
this._trayState = new TrayState(
this.mainWindow,
this.applicationMenu,
this.recordingService,
this.meetingSettings,
this.logger
);
}
} else {
_trayState?.[Symbol.dispose]();
_trayState = undefined;
this._trayState?.[Symbol.dispose]();
this._trayState = undefined;
}
};
const subscription = TraySettingsState.$.subscribe(updateTrayState);
onModuleInit() {
app.on('ready', () => {
this.logger.log('Initializing tray manager', 'TrayManager');
const subscription = this.settingsState.$.subscribe(state => {
this.updateTrayState(state);
});
beforeAppQuit(() => {
subscription.unsubscribe();
});
};
this.updateTrayState(this.settingsState.value());
beforeAppQuit(() => {
subscription.unsubscribe();
});
});
}
}

View File

@ -0,0 +1,84 @@
import { mintChallengeResponse } from '@affine/native';
import { Injectable } from '@nestjs/common';
import { app, clipboard, nativeImage, shell } from 'electron';
import { getIpcEvent, IpcHandle, IpcScope } from '../../../ipc';
import { isMacOS } from '../utils';
interface CaptureAreaArgs {
x: number;
y: number;
width: number;
height: number;
}
@Injectable()
export class UtilsHandleService {
@IpcHandle({ scope: IpcScope.UI })
handleCloseApp() {
app.quit();
}
@IpcHandle({ scope: IpcScope.UI })
restartApp() {
app.relaunch();
app.quit();
}
@IpcHandle({ scope: IpcScope.UI })
async onLanguageChange(language: string) {
const e = getIpcEvent();
// only works for win/linux
// see https://www.electronjs.org/docs/latest/tutorial/spellchecker#how-to-set-the-languages-the-spellchecker-uses
if (isMacOS()) {
return;
}
if (e.sender.session.availableSpellCheckerLanguages.includes(language)) {
e.sender.session.setSpellCheckerLanguages([language, 'en-US']);
}
}
@IpcHandle({ scope: IpcScope.UI })
async captureArea({ x, y, width, height }: CaptureAreaArgs) {
const e = getIpcEvent();
const image = await e.sender.capturePage({
x: Math.floor(x),
y: Math.floor(y),
width: Math.floor(width),
height: Math.floor(height),
});
if (image.isEmpty()) {
throw new Error('Image is empty or invalid');
}
const buffer = image.toPNG();
if (!buffer || !buffer.length) {
throw new Error('Failed to generate PNG buffer from image');
}
clipboard.writeImage(nativeImage.createFromBuffer(buffer));
}
@IpcHandle({ scope: IpcScope.UI })
writeImageToClipboard(buffer: ArrayBuffer) {
const image = nativeImage.createFromBuffer(Buffer.from(buffer));
if (image.isEmpty()) return false;
clipboard.writeImage(image);
return true;
}
@IpcHandle({ scope: IpcScope.UI })
getChallengeResponse(challenge: string) {
// 20 bits challenge is a balance between security and user experience
// 20 bits challenge cost time is about 1-3s on m2 macbook air
return mintChallengeResponse(challenge, 20);
}
@IpcHandle({ scope: IpcScope.UI })
openExternal(url: string) {
return shell.openExternal(url);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { WindowsModule } from '../windows';
import { MeetingsSettingsState } from './meetings-settings-state.service';
import { RecordingManager } from './recording.service';
import { RecordingStateMachine } from './recording-state.service';
@Module({
imports: [WindowsModule],
providers: [RecordingManager, MeetingsSettingsState, RecordingStateMachine],
exports: [RecordingManager, MeetingsSettingsState],
})
export class RecordingModule {}

View File

@ -0,0 +1,65 @@
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import type { MediaStats } from '@toeverything/infra';
import { app } from 'electron';
import { map, shareReplay } from 'rxjs';
import {
MeetingSettingsKey,
MeetingSettingsSchema,
} from '../../../shared/shared-state-schema';
import { beforeAppQuit, beforeTabClose } from '../cleanup';
import { GlobalStateStorage } from '../storage';
@Injectable()
export class MeetingsSettingsState implements OnModuleInit {
constructor(
private readonly globalStateStorage: GlobalStateStorage,
private readonly logger: Logger
) {}
$ = this.globalStateStorage
.watch<MeetingSettingsSchema>(MeetingSettingsKey)
.pipe(
map(v => MeetingSettingsSchema.parse(v ?? {})),
shareReplay(1)
);
get value() {
return MeetingSettingsSchema.parse(
this.globalStateStorage.get(MeetingSettingsKey) ?? {}
);
}
set value(value: MeetingSettingsSchema) {
this.globalStateStorage.set(MeetingSettingsKey, value);
}
onModuleInit() {
app
.whenReady()
.then(() => {
this.globalStateStorage.set('media:playback-state', null);
this.globalStateStorage.set('media:stats', null);
})
.catch(err => {
this.logger.error(
'Failed to set media:playback-state and media:stats to null',
err
);
});
beforeAppQuit(() => {
this.globalStateStorage.set('media:playback-state', null);
this.globalStateStorage.set('media:stats', null);
});
beforeTabClose(tabId => {
const stats = this.globalStateStorage.get<MediaStats | null>(
'media:stats'
);
if (stats && stats.tabId === tabId) {
this.globalStateStorage.set('media:playback-state', null);
this.globalStateStorage.set('media:stats', null);
}
});
}
}

View File

@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { BehaviorSubject } from 'rxjs';
import { shallowEqual } from '../../shared/utils';
import { shallowEqual } from '../../../shared/utils';
import { logger } from '../logger';
import type { AppGroupInfo, RecordingStatus } from './types';
@ -39,7 +40,12 @@ export type RecordingEvent =
* Recording State Machine
* Handles state transitions for the recording process
*/
@Injectable()
export class RecordingStateMachine {
constructor(private readonly logger: Logger) {}
context = 'RecordingStateMachine';
private recordingId = 0;
private readonly recordingStatus$ =
new BehaviorSubject<RecordingStatus | null>(null);
@ -296,9 +302,6 @@ export class RecordingStateMachine {
private handleRemoveRecording(id: number): void {
// Actual recording removal logic would be handled by the caller
// This just ensures the state is updated correctly
logger.info(`Recording ${id} removed from state machine`);
this.logger.log(`Recording ${id} removed from state machine`, this.context);
}
}
// Create and export a singleton instance
export const recordingStateMachine = new RecordingStateMachine();

View File

@ -0,0 +1,913 @@
// eslint-disable no-var-requires
import { execSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import fsp from 'node:fs/promises';
import path from 'node:path';
// Should not load @affine/native for unsupported platforms
import type { ShareableContent as ShareableContentType } from '@affine/native';
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { app, shell, systemPreferences } from 'electron';
import fs from 'fs-extra';
import { debounce } from 'lodash-es';
import {
BehaviorSubject,
distinctUntilChanged,
filter,
groupBy,
interval,
map,
mergeMap,
Subject,
throttleTime,
} from 'rxjs';
import { IpcEvent, IpcHandle, IpcScope } from '../../../ipc';
import { shallowEqual } from '../../../shared/utils';
import { beforeAppQuit } from '../cleanup';
import { isMacOS, isWindows } from '../utils';
import { MainWindowManager } from '../windows';
import { PopupManager } from '../windows/popup.service';
import { isAppNameAllowed } from './allow-list';
import { MeetingsSettingsState } from './meetings-settings-state.service';
import { RecordingStateMachine } from './recording-state.service';
import type {
AppGroupInfo,
Recording,
RecordingStatus,
SerializedRecordingStatus,
TappableAppInfo,
} from './types';
type Subscriber = {
unsubscribe: () => void;
};
// recordings are saved in the app data directory
// may need a way to clean up old recordings
export const SAVED_RECORDINGS_DIR = path.join(
app.getPath('sessionData'),
'recordings'
);
@Injectable()
export class RecordingManager implements OnModuleInit {
constructor(
private readonly meetingsSettingsState: MeetingsSettingsState,
private readonly state: RecordingStateMachine,
private readonly mainWindow: MainWindowManager,
private readonly popupManager: PopupManager,
private readonly logger: Logger
) {}
context = 'RecordingManager';
subscribers: Subscriber[] = [];
shareableContent: ShareableContentType | null = null;
// recording id -> recording
// recordings will be saved in memory before consumed and created as an audio block to user's doc
recordings = new Map<number, Recording>();
applications$ = new BehaviorSubject<TappableAppInfo[]>([]);
appGroups$ = new BehaviorSubject<AppGroupInfo[]>([]);
// there should be only one active recording at a time
// We'll now use recordingStateMachine.status$ instead of our own BehaviorSubject
recordingStatus$ = this.state.status$;
@IpcEvent({ scope: IpcScope.RECORDING })
recordingStatusChanged$ = this.recordingStatus$.pipe(
distinctUntilChanged(shallowEqual),
map(status => (status ? this.serializeRecordingStatus(status) : null))
);
updateApplicationsPing$ = new Subject<number>();
cleanup() {
this.shareableContent = null;
this.subscribers.forEach(subscriber => {
try {
subscriber.unsubscribe();
} catch {
// ignore unsubscribe error
}
});
}
onModuleInit() {
this.setupRecordingFeature();
beforeAppQuit(() => {
this.cleanup();
});
}
private readonly createAppGroup = (
processGroupId: number
): AppGroupInfo | undefined => {
// MUST require dynamically to avoid loading @affine/native for unsupported platforms
const SC: typeof ShareableContentType =
require('@affine/native').ShareableContent;
const groupProcess = SC?.applicationWithProcessId(processGroupId);
if (!groupProcess) {
return;
}
const logger = this.logger;
const context = this.context;
return {
processGroupId: processGroupId,
apps: [], // leave it empty for now.
name: groupProcess.name,
bundleIdentifier: groupProcess.bundleIdentifier,
// icon should be lazy loaded
get icon() {
try {
return groupProcess.icon;
} catch (error) {
logger.error(
`Failed to get icon for ${groupProcess.name}`,
error,
context
);
return undefined;
}
},
isRunning: false,
};
};
private readonly setupAppGroups = () => {
this.subscribers.push(
this.applications$.pipe(distinctUntilChanged()).subscribe(apps => {
const appGroups: AppGroupInfo[] = [];
apps.forEach(app => {
let appGroup = appGroups.find(
group => group.processGroupId === app.processGroupId
);
if (!appGroup) {
appGroup = this.createAppGroup(app.processGroupId);
if (appGroup) {
appGroups.push(appGroup);
}
}
if (appGroup) {
appGroup.apps.push(app);
}
});
appGroups.forEach(appGroup => {
appGroup.isRunning = appGroup.apps.some(app => app.isRunning);
});
this.appGroups$.next(appGroups);
})
);
};
private readonly setupNewRunningAppGroup = () => {
const appGroupRunningChanged$ = this.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))
);
this.appGroups$.value.forEach(group => {
const recordingStatus = this.recordingStatus$.value;
if (
group.isRunning &&
(!recordingStatus || recordingStatus.status === 'new')
) {
this.newRecording(group);
}
});
const debounceStartRecording = debounce((appGroup: AppGroupInfo) => {
// check if the app is running again
if (appGroup.isRunning) {
this.startRecording(appGroup);
}
}, 1000);
this.subscribers.push(
appGroupRunningChanged$.subscribe(currentGroup => {
this.logger.log(
'appGroupRunningChanged',
currentGroup.bundleIdentifier,
currentGroup.isRunning,
this.context
);
if (this.meetingsSettingsState.value.recordingMode === 'none') {
return;
}
const recordingStatus = this.recordingStatus$.value;
if (currentGroup.isRunning) {
// when the app is running and there is no active recording popup
// we should show a new recording popup
if (
!recordingStatus ||
recordingStatus.status === 'new' ||
recordingStatus.status === 'create-block-success' ||
recordingStatus.status === 'create-block-failed'
) {
if (this.meetingsSettingsState.value.recordingMode === 'prompt') {
this.newRecording(currentGroup);
} else if (
this.meetingsSettingsState.value.recordingMode === 'auto-start'
) {
// there is a case that the watched app's running state changed rapidly
// we will schedule the start recording to avoid that
debounceStartRecording(currentGroup);
} else {
// do nothing, skip
}
}
} else {
// when displaying in "new" state but the app is not running any more
// we should remove the recording
if (
recordingStatus?.status === 'new' &&
currentGroup.bundleIdentifier ===
recordingStatus.appGroup?.bundleIdentifier
) {
this.removeRecording(recordingStatus.id);
}
// if the recording is stopped and we are recording it,
// we should stop the recording
if (
recordingStatus?.status === 'recording' &&
recordingStatus.appGroup?.bundleIdentifier ===
currentGroup.bundleIdentifier
) {
this.stopRecording(recordingStatus.id).catch(err => {
this.logger.error('failed to stop recording', err, this.context);
});
}
}
})
);
};
// recording popup status
// new: recording is started, popup is shown
// recording: recording is started, popup is shown
// stopped: recording is stopped, popup showing processing status
// create-block-success: recording is ready, show "open app" button
// create-block-failed: recording is failed, show "failed to save" button
// null: hide popup
private readonly setupRecordingListeners = () => {
this.subscribers.push(
this.recordingStatus$
.pipe(distinctUntilChanged(shallowEqual))
.subscribe(status => {
const popup = this.popupManager.get('recording');
if (status && !popup.showing) {
popup.show().catch(err => {
this.logger.error('failed to show recording popup', err);
});
}
if (status?.status === 'recording') {
let recording = this.recordings.get(status.id);
// create a recording if not exists
if (!recording) {
recording = this.createRecording(status);
}
} else if (status?.status === 'stopped') {
const recording = this.recordings.get(status.id);
if (recording) {
recording.session.stop();
}
} else if (
status?.status === 'create-block-success' ||
status?.status === 'create-block-failed'
) {
// show the popup for 10s
setTimeout(
() => {
// check again if current status is still ready
if (
(this.recordingStatus$.value?.status ===
'create-block-success' ||
this.recordingStatus$.value?.status ===
'create-block-failed') &&
this.recordingStatus$.value.id === status.id
) {
popup.hide().catch(err => {
this.logger.error(
'failed to hide recording popup',
err,
this.context
);
});
}
},
status?.status === 'create-block-failed' ? 30_000 : 10_000
);
} else if (!status) {
// status is removed, we should hide the popup
this.popupManager
.get('recording')
.hide()
.catch(err => {
this.logger.error(
'failed to hide recording popup',
err,
this.context
);
});
}
})
);
};
private readonly setupMediaListeners = () => {
const ShareableContent = require('@affine/native').ShareableContent;
this.applications$.next(this.getAllApps());
this.subscribers.push(
interval(3000).subscribe(() => {
this.updateApplicationsPing$.next(Date.now());
}),
ShareableContent.onApplicationListChanged(() => {
this.updateApplicationsPing$.next(Date.now());
}),
this.updateApplicationsPing$
.pipe(distinctUntilChanged(), throttleTime(3000))
.subscribe(() => {
this.applications$.next(this.getAllApps());
})
);
let appStateSubscribers: Subscriber[] = [];
this.subscribers.push(
this.applications$.subscribe(apps => {
appStateSubscribers.forEach(subscriber => {
try {
subscriber.unsubscribe();
} catch {
// ignore unsubscribe error
}
});
const _appStateSubscribers: Subscriber[] = [];
apps.forEach(app => {
try {
const applicationInfo = app.info;
_appStateSubscribers.push(
ShareableContent.onAppStateChanged(applicationInfo, () => {
this.updateApplicationsPing$.next(Date.now());
})
);
} catch (error) {
this.logger.error(
`Failed to set up app state listener for ${app.name}`,
error,
this.context
);
}
});
appStateSubscribers = _appStateSubscribers;
return () => {
_appStateSubscribers.forEach(subscriber => {
try {
subscriber.unsubscribe();
} catch {
// ignore unsubscribe error
}
});
};
})
);
};
@IpcHandle({ scope: IpcScope.RECORDING })
setupRecordingFeature() {
if (
!this.meetingsSettingsState.value.enabled ||
!this.checkCanRecordMeeting()
) {
return;
}
try {
const ShareableContent = require('@affine/native').ShareableContent;
if (!this.shareableContent) {
this.shareableContent = new ShareableContent();
this.setupMediaListeners();
}
// reset all states
this.recordingStatus$.next(null);
this.setupAppGroups();
this.setupNewRunningAppGroup();
this.setupRecordingListeners();
return true;
} catch (error) {
this.logger.error(
'failed to setup recording feature',
error,
this.context
);
return false;
}
}
@IpcHandle({ scope: IpcScope.RECORDING })
askForScreenRecordingPermission() {
if (!isMacOS()) {
return false;
}
try {
const ShareableContent = require('@affine/native').ShareableContent;
// this will trigger the permission prompt
new ShareableContent();
return true;
} catch (error) {
this.logger.error(
'failed to ask for screen recording permission',
error,
this.context
);
}
return false;
}
@IpcHandle({ scope: IpcScope.RECORDING })
getRecording(id: number) {
return this.recordings.get(id);
}
@IpcHandle({ scope: IpcScope.RECORDING })
getCurrentRecording() {
const status = this.recordingStatus$.value;
return status ? this.serializeRecordingStatus(status) : null;
}
@IpcHandle({ scope: IpcScope.RECORDING })
startRecording(appGroup?: AppGroupInfo | number): RecordingStatus | null {
const state = this.state.dispatch(
{
type: 'START_RECORDING',
appGroup: this.normalizeAppGroupInfo(appGroup),
},
false
);
if (state?.status === 'recording') {
this.createRecording(state);
}
this.state.status$.next(state);
return state;
}
getSanitizedAppId(bundleIdentifier?: string) {
if (!bundleIdentifier) {
return 'unknown';
}
return isWindows()
? createHash('sha256')
.update(bundleIdentifier)
.digest('hex')
.substring(0, 8)
: bundleIdentifier;
}
createRecording(status: RecordingStatus) {
let recording = this.recordings.get(status.id);
if (recording) {
return recording;
}
const appId = this.getSanitizedAppId(status.appGroup?.bundleIdentifier);
const bufferedFilePath = path.join(
SAVED_RECORDINGS_DIR,
`${appId}-${status.id}-${status.startTime}.raw`
);
fs.ensureDirSync(SAVED_RECORDINGS_DIR);
const file = fs.createWriteStream(bufferedFilePath);
const tapAudioSamples = (err: Error | null, samples: Float32Array) => {
const recordingStatus = this.recordingStatus$.value;
if (
!recordingStatus ||
recordingStatus.id !== status.id ||
recordingStatus.status === 'paused'
) {
return;
}
if (err) {
this.logger.error('failed to get audio samples', err, this.context);
} else {
// Writing raw Float32Array samples directly to file
// For stereo audio, samples are interleaved [L,R,L,R,...]
file.write(Buffer.from(samples.buffer));
}
};
// MUST require dynamically to avoid loading @affine/native for unsupported platforms
const SC: typeof ShareableContentType =
require('@affine/native').ShareableContent;
const stream = status.app
? SC.tapAudio(status.app.processId, tapAudioSamples)
: SC.tapGlobalAudio(null, tapAudioSamples);
recording = {
id: status.id,
startTime: status.startTime,
app: status.app,
appGroup: status.appGroup,
file,
session: stream,
};
this.recordings.set(status.id, recording);
return recording;
}
@IpcHandle({ scope: IpcScope.RECORDING })
async stopRecording(id: number) {
const recording = this.recordings.get(id);
if (!recording) {
this.logger.error(
`stopRecording: Recording ${id} not found`,
this.context
);
return;
}
if (!recording.file.path) {
this.logger.error(`Recording ${id} has no file path`, this.context);
return;
}
const { file, session: stream } = recording;
// First stop the audio stream to prevent more data coming in
try {
stream.stop();
} catch (err) {
this.logger.error('Failed to stop audio stream', err, this.context);
}
// End the file with a timeout
file.end();
try {
await Promise.race([
new Promise<void>((resolve, reject) => {
file.on('finish', () => {
// check if the file is empty
const stats = fs.statSync(file.path);
if (stats.size === 0) {
reject(new Error('Recording is empty'));
return;
}
resolve();
});
file.on('error', err => {
reject(err);
});
}),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('File writing timeout')), 10000)
),
]);
const recordingStatus = this.state.dispatch({
type: 'STOP_RECORDING',
id,
});
if (!recordingStatus) {
this.logger.error('No recording status to stop', this.context);
return;
}
return this.serializeRecordingStatus(recordingStatus);
} catch (error: unknown) {
this.logger.error('Failed to stop recording', error, this.context);
const recordingStatus = this.state.dispatch({
type: 'CREATE_BLOCK_FAILED',
id,
error: error instanceof Error ? error : undefined,
});
if (!recordingStatus) {
this.logger.error('No recording status to stop', this.context);
return;
}
return this.serializeRecordingStatus(recordingStatus);
} finally {
// Clean up the file stream if it's still open
if (!file.closed) {
file.destroy();
}
}
}
@IpcHandle({ scope: IpcScope.RECORDING })
pauseRecording(id: number) {
return this.state.dispatch({
type: 'PAUSE_RECORDING',
id,
});
}
@IpcHandle({ scope: IpcScope.RECORDING })
resumeRecording(id: number) {
return this.state.dispatch({
type: 'RESUME_RECORDING',
id,
});
}
@IpcHandle({ scope: IpcScope.RECORDING })
removeRecording(id: number) {
this.recordings.delete(id);
this.state.dispatch({ type: 'REMOVE_RECORDING', id });
}
@IpcHandle({ scope: IpcScope.RECORDING })
async readyRecording(id: number, buffer: Uint8Array) {
this.logger.log('readyRecording', id);
const recordingStatus = this.recordingStatus$.value;
const recording = this.recordings.get(id);
if (!recordingStatus || recordingStatus.id !== id || !recording) {
this.logger.error(
`readyRecording: Recording ${id} not found`,
this.context
);
return;
}
const rawFilePath = String(recording.file.path);
const filepath = rawFilePath.replace('.raw', '.opus');
if (!filepath) {
this.logger.error(
`readyRecording: Recording ${id} has no filepath`,
this.context
);
return;
}
await fs.writeFile(filepath, Buffer.from(buffer));
// can safely remove the raw file now
this.logger.log('remove raw file', rawFilePath);
if (rawFilePath) {
try {
await fs.unlink(rawFilePath);
} catch (err) {
this.logger.error('failed to remove raw file', err, this.context);
}
}
// Update the status through the state machine
this.state.dispatch({
type: 'SAVE_RECORDING',
id,
filepath,
});
// bring up the window
await this.mainWindow.show();
}
@IpcHandle({ scope: IpcScope.RECORDING })
handleBlockCreationSuccess(id: number) {
this.state.dispatch({
type: 'CREATE_BLOCK_SUCCESS',
id,
});
}
@IpcHandle({ scope: IpcScope.RECORDING })
handleBlockCreationFailed(id: number, error?: Error) {
this.state.dispatch({
type: 'CREATE_BLOCK_FAILED',
id,
error,
});
}
@IpcHandle({ scope: IpcScope.RECORDING })
disableRecordingFeature() {
this.recordingStatus$.next(null);
this.cleanup();
}
@IpcHandle({ scope: IpcScope.RECORDING })
async getRawAudioBuffers(
id: number,
cursor?: number
): Promise<{
buffer: Buffer;
nextCursor: number;
}> {
const recording = this.recordings.get(id);
if (!recording) {
throw new Error(`getRawAudioBuffers: Recording ${id} not found`);
}
const start = cursor ?? 0;
const file = await fsp.open(recording.file.path, 'r');
const stats = await file.stat();
const buffer = Buffer.alloc(stats.size - start);
const result = await file.read(buffer, 0, buffer.length, start);
await file.close();
return {
buffer,
nextCursor: start + result.bytesRead,
};
}
normalizeAppGroupInfo(
appGroup?: AppGroupInfo | number
): AppGroupInfo | undefined {
return typeof appGroup === 'number'
? this.appGroups$.value.find(group => group.processGroupId === appGroup)
: appGroup;
}
newRecording(appGroup?: AppGroupInfo | number): RecordingStatus | null {
return this.state.dispatch({
type: 'NEW_RECORDING',
appGroup: this.normalizeAppGroupInfo(appGroup),
});
}
serializeRecordingStatus(status: RecordingStatus): SerializedRecordingStatus {
const recording = this.recordings.get(status.id);
return {
id: status.id,
status: status.status,
appName: status.appGroup?.name,
appGroupId: status.appGroup?.processGroupId,
icon: status.appGroup?.icon,
startTime: status.startTime,
filepath:
status.filepath ??
(recording ? String(recording.file.path) : undefined),
sampleRate: recording?.session.sampleRate,
numberOfChannels: recording?.session.channels,
};
}
getAllApps(): TappableAppInfo[] {
if (!this.shareableContent) {
return [];
}
// MUST require dynamically to avoid loading @affine/native for unsupported platforms
const { ShareableContent } = require('@affine/native') as {
ShareableContent: typeof ShareableContentType;
};
const apps = ShareableContent.applications().map(app => {
try {
// Check if this process is actively using microphone/audio
const isRunning = ShareableContent.isUsingMicrophone(app.processId);
return {
info: app,
processId: app.processId,
processGroupId: app.processGroupId,
bundleIdentifier: app.bundleIdentifier,
name: app.name,
isRunning,
};
} catch (error) {
this.logger.error('failed to get app info', error, this.context);
return null;
}
});
const filteredApps = apps.filter(
(v): v is TappableAppInfo =>
v !== null &&
!v.bundleIdentifier.startsWith('com.apple') &&
!v.bundleIdentifier.startsWith('pro.affine') &&
v.processId !== process.pid
);
return filteredApps;
}
getMacOSVersion() {
try {
const stdout = execSync('sw_vers -productVersion').toString();
const [major, minor, patch] = stdout.trim().split('.').map(Number);
return { major, minor, patch };
} catch (error) {
this.logger.error('Failed to get MacOS version', error, this.context);
return { major: 0, minor: 0, patch: 0 };
}
}
@IpcHandle({ scope: IpcScope.RECORDING })
checkRecordingAvailable() {
if (isMacOS()) {
const version = this.getMacOSVersion();
return (version.major === 14 && version.minor >= 2) || version.major > 14;
}
if (isWindows()) {
return true;
}
return false;
}
@IpcHandle({ scope: IpcScope.RECORDING })
checkMeetingPermissions() {
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>;
}
@IpcHandle({ scope: IpcScope.RECORDING })
checkCanRecordMeeting() {
const features = this.checkMeetingPermissions();
return (
this.checkRecordingAvailable() &&
features &&
Object.values(features).every(feature => feature)
);
}
@IpcHandle({ scope: IpcScope.RECORDING })
async askForMeetingPermission(type: 'microphone' | 'screen') {
if (isWindows()) {
return {
screen: true,
microphone: true,
};
}
if (!isMacOS()) {
return false;
}
if (type === 'screen') {
return this.askForScreenRecordingPermission();
}
return systemPreferences.askForMediaAccess(type);
}
@IpcHandle({ scope: IpcScope.RECORDING })
async showRecordingPermissionSetting({
type,
}: {
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 shell.openExternal(url);
}
// this only available on MacOS
return false;
}
@IpcHandle({ scope: IpcScope.RECORDING })
showSavedRecordings(subpath?: string) {
const normalizedDir = path.normalize(
path.join(SAVED_RECORDINGS_DIR, subpath ?? '')
);
const normalizedBase = path.normalize(SAVED_RECORDINGS_DIR);
if (!normalizedDir.startsWith(normalizedBase)) {
throw new Error('Invalid directory');
}
return shell.showItemInFolder(normalizedDir);
}
}

View File

@ -55,3 +55,16 @@ export interface RecordingStatus {
startTime: number; // 0 means not started yet
filepath?: string; // encoded file path
}
export interface SerializedRecordingStatus {
id: number;
status: RecordingStatus['status'];
appName?: string;
// if there is no app group, it means the recording is for system audio
appGroupId?: number;
icon?: Buffer;
startTime: number;
filepath?: string;
sampleRate?: number;
numberOfChannels?: number;
}

View File

@ -0,0 +1,2 @@
export * from './storage.module';
export * from './storage.service';

View File

@ -1,5 +1,6 @@
import fs from 'node:fs';
import { Logger } from '@nestjs/common';
import type { Memento } from '@toeverything/infra';
import {
backoffRetry,
@ -9,14 +10,15 @@ import {
} from '@toeverything/infra';
import { debounceTime, Observable, timeout } from 'rxjs';
import { logger } from '../logger';
export class PersistentJSONFileStorage implements Memento {
export class PersistentJsonFileStorageService implements Memento {
data: Record<string, any> = {};
subscriptions: Map<string, Set<(p: any) => void>> = new Map();
subscriptionAll: Set<(p: Record<string, any>) => void> = new Set();
constructor(readonly filepath: string) {
constructor(
readonly filepath: string,
private readonly logger: Logger
) {
try {
this.data = JSON.parse(fs.readFileSync(filepath, 'utf-8'));
} catch (err) {
@ -29,7 +31,7 @@ export class PersistentJSONFileStorage implements Memento {
err.code === 'ENOENT'
)
) {
logger.error('failed to load file', err);
this.logger.error('failed to load file', err);
}
}
}
@ -121,7 +123,7 @@ export class PersistentJSONFileStorage implements Memento {
'utf-8'
);
} catch (err) {
logger.error(`failed to save file, ${this.filepath}`, err);
this.logger.error(`failed to save file, ${this.filepath}`, err);
}
}).pipe(
timeout(5000),

View File

@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { GlobalCacheStorage, GlobalStateStorage } from './storage.service';
@Global()
@Module({
providers: [GlobalStateStorage, GlobalCacheStorage],
exports: [GlobalStateStorage, GlobalCacheStorage],
})
export class StorageModule {}

View File

@ -0,0 +1,87 @@
import path from 'node:path';
import { Injectable, Logger } from '@nestjs/common';
import { app } from 'electron';
import { IpcEvent, IpcHandle, IpcScope } from '../../../ipc';
import { PersistentJsonFileStorageService } from './persistent-json-file';
@Injectable()
export class GlobalStateStorage extends PersistentJsonFileStorageService {
constructor(logger: Logger) {
super(path.join(app.getPath('userData'), 'global-state.json'), logger);
}
@IpcEvent({
scope: IpcScope.SHARED_STORAGE,
})
globalStateChanged$ = this.watchAll();
@IpcHandle({
scope: IpcScope.SHARED_STORAGE,
})
getAllGlobalState() {
return this.all();
}
@IpcHandle({
scope: IpcScope.SHARED_STORAGE,
})
setGlobalState(key: string, value: any) {
return this.set(key, value);
}
@IpcHandle({
scope: IpcScope.SHARED_STORAGE,
})
delGlobalState(key: string) {
return this.del(key);
}
@IpcHandle({
scope: IpcScope.SHARED_STORAGE,
})
clearGlobalState() {
return this.clear();
}
}
@Injectable()
export class GlobalCacheStorage extends PersistentJsonFileStorageService {
constructor(logger: Logger) {
super(path.join(app.getPath('userData'), 'global-cache.json'), logger);
}
@IpcEvent({
scope: IpcScope.SHARED_STORAGE,
})
globalCacheChanged$ = this.watchAll();
@IpcHandle({
scope: IpcScope.SHARED_STORAGE,
})
getAllGlobalCache() {
return this.all();
}
@IpcHandle({
scope: IpcScope.SHARED_STORAGE,
})
setGlobalCache(key: string, value: any) {
return this.set(key, value);
}
@IpcHandle({
scope: IpcScope.SHARED_STORAGE,
})
delGlobalCache(key: string) {
return this.del(key);
}
@IpcHandle({
scope: IpcScope.SHARED_STORAGE,
})
clearGlobalCache() {
return this.clear();
}
}

View File

@ -14,7 +14,7 @@ import {
parseUpdateInfo,
} from 'electron-updater/out/providers/Provider';
import type { buildType } from '../config';
import type { buildType } from '../../../shared/constants';
import { isSquirrelBuild } from './utils';
interface GithubUpdateInfo extends UpdateInfo {

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { UpdaterManagerService } from './updater-manager.service';
export * from './updater-manager.service';
@Module({
providers: [UpdaterManagerService],
})
export class UpdaterModule {}

View File

@ -0,0 +1,176 @@
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { app } from 'electron';
import { autoUpdater as defaultAutoUpdater } from 'electron-updater';
import { BehaviorSubject, Subject } from 'rxjs';
import { IpcEvent, IpcHandle, IpcScope } from '../../../ipc';
import { buildType, isDev } from '../../../shared/constants';
import { logger } from '../logger';
import { isWindows } from '../utils';
import { AFFiNEUpdateProvider } from './affine-update-provider';
import { WindowsUpdater } from './windows-updater';
export interface UpdateMeta {
version: string;
allowAutoUpdate: boolean;
}
@Injectable()
export class UpdaterManagerService implements OnModuleInit {
constructor(private readonly logger: Logger) {}
onModuleInit() {
this.registerUpdater();
}
disabled = buildType === 'internal' || isDev;
autoUpdater = isWindows() ? new WindowsUpdater() : defaultAutoUpdater;
@IpcHandle({ scope: IpcScope.UPDATER })
currentVersion() {
return app.getVersion();
}
@IpcHandle({ scope: IpcScope.UPDATER })
quitAndInstall() {
return this.autoUpdater.quitAndInstall();
}
downloading = false;
checkingUpdate = false;
configured = false;
@IpcEvent({ scope: IpcScope.UPDATER })
updateAvailable$ = new Subject<UpdateMeta>();
@IpcEvent({ scope: IpcScope.UPDATER })
updateReady$ = new Subject<UpdateMeta>();
@IpcEvent({ scope: IpcScope.UPDATER })
downloadProgress$ = new BehaviorSubject<number>(0);
config = {
autoCheckUpdate: true,
autoDownloadUpdate: true,
};
@IpcHandle({ scope: IpcScope.UPDATER })
getConfig() {
return this.config;
}
@IpcHandle({ scope: IpcScope.UPDATER })
setConfig(newConfig: Partial<typeof this.config>) {
this.configured = true;
Object.assign(this.config, newConfig);
if (this.config.autoCheckUpdate) {
this.checkForUpdates().catch(err => {
this.logger.error('Error checking for updates', err);
});
}
}
@IpcHandle({ scope: IpcScope.UPDATER })
async checkForUpdates() {
const result = await this.autoUpdater.checkForUpdatesAndNotify();
if (!result) {
return null;
}
const { isUpdateAvailable, updateInfo } = result;
return {
isUpdateAvailable,
updateInfo,
};
}
@IpcHandle({ scope: IpcScope.UPDATER })
async downloadUpdate() {
if (this.disabled || this.downloading) {
return;
}
this.downloading = true;
this.downloadProgress$.next(0);
this.autoUpdater.downloadUpdate().catch(e => {
this.downloading = false;
this.logger.error('Failed to download update', e);
});
this.logger.log('Update available, downloading...');
return;
}
registerUpdater() {
if (this.disabled) {
return;
}
const allowAutoUpdate = true;
this.autoUpdater.logger = logger;
this.autoUpdater.autoDownload = false;
this.autoUpdater.allowPrerelease = buildType !== 'stable';
this.autoUpdater.autoInstallOnAppQuit = false;
this.autoUpdater.autoRunAppAfterInstall = true;
const feedUrl = AFFiNEUpdateProvider.configFeed({
channel: buildType,
});
this.autoUpdater.setFeedURL(feedUrl);
// register events for checkForUpdates
this.autoUpdater.on('checking-for-update', () => {
this.logger.log('Checking for update');
});
this.autoUpdater.on('update-available', info => {
this.logger.log(`Update available: ${JSON.stringify(info)}`);
if (this.config.autoDownloadUpdate && allowAutoUpdate) {
this.downloadUpdate().catch(err => {
console.error(err);
});
}
this.updateAvailable$.next({
version: info.version,
allowAutoUpdate,
});
});
this.autoUpdater.on('update-not-available', info => {
this.logger.log(`Update not available: ${JSON.stringify(info)}`);
});
this.autoUpdater.on('download-progress', e => {
this.logger.log(`Download progress: ${e.percent}`);
this.downloadProgress$.next(e.percent);
});
this.autoUpdater.on('update-downloaded', e => {
this.downloading = false;
this.updateReady$.next({
version: e.version,
allowAutoUpdate,
});
// I guess we can skip it?
// updaterSubjects.clientDownloadProgress.next(100);
this.logger.log('Update downloaded, ready to install');
});
this.autoUpdater.on('error', e => {
this.logger.error('Error while updating client', e);
});
this.autoUpdater.forceDevUpdateConfig = isDev;
// check update whenever the window is activated
let lastCheckTime = 0;
app.on('browser-window-focus', () => {
(async () => {
if (
this.configured &&
this.config.autoCheckUpdate &&
lastCheckTime + 1000 * 1800 < Date.now()
) {
lastCheckTime = Date.now();
await this.checkForUpdates();
}
})().catch(err => {
this.logger.error('Error checking for updates', err);
});
});
}
}

View File

@ -0,0 +1,12 @@
// Platform detection utilities
export const isMacOS = () => {
return process.platform === 'darwin';
};
export const isWindows = () => {
return process.platform === 'win32';
};
export const isLinux = () => {
return process.platform === 'linux';
};

View File

@ -0,0 +1,315 @@
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { app, Menu } from 'electron';
import { Subject } from 'rxjs';
import { IpcEvent, IpcScope } from '../../../ipc';
import { revealLogFile } from '../../../logger';
import { isMacOS } from '../utils';
import { PopupManager } from './popup.service';
import { TabViewsManager } from './tab-views.service';
import { WorkerManager } from './worker-manager.service';
const MENUITEM_NEW_PAGE = 'affine:new-page';
@Injectable()
export class ApplicationMenuManager implements OnModuleInit {
@IpcEvent({ scope: IpcScope.MENU })
readonly openInSettingModal$ = new Subject<{
activeTab: string;
scrollAnchor?: string;
}>();
@IpcEvent({ scope: IpcScope.MENU })
readonly newPageAction$ = new Subject<'page' | 'edgeless'>();
@IpcEvent({ scope: IpcScope.MENU })
readonly openJournal$ = new Subject<void>();
constructor(
private readonly tabViews: TabViewsManager,
private readonly workerManager: WorkerManager,
private readonly popupManager: PopupManager,
private readonly logger: Logger
) {}
onModuleInit() {
app.on('ready', () => {
this.init();
});
}
private init() {
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 () => {
this.tabViews.mainWindow?.show();
this.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: () => {
this.tabViews.mainWindow?.show();
// fixme: if the window is just created, the new page action will not be triggered
this.newPageAction$.next('page');
},
},
],
},
// { 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: async () => {
if (this.tabViews.activeWorkbenchId) {
await this.tabViews.loadTab(this.tabViews.activeWorkbenchId);
}
},
},
{
role: 'windowMenu',
},
{
label: 'Open devtools',
accelerator: isMac ? 'Cmd+Option+I' : 'Ctrl+Shift+I',
click: () => {
const workerContents = Array.from(
this.workerManager.workers.values()
).map(
worker =>
[worker.key, worker.browserWindow.webContents] as const
);
const tabs = Array.from(this.tabViews.tabViewsMap).map(view => {
const isActive = this.tabViews.isActiveTab(view[0]);
return [
view[0] + (isActive ? ' (active)' : ''),
view[1].webContents,
] as const;
});
const popups = Array.from(
this.popupManager.popupWindows$.value.values()
)
.filter(popup => popup.browserWindow)
.map(popup => {
return [
popup.type,
// oxlint-disable-next-line no-non-null-assertion
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: () => {
this.logger.log('New tab with shortcut');
this.tabViews.addTab().catch(console.error);
},
},
{
label: 'Close view',
accelerator: 'CommandOrControl+W',
click: () => {
this.logger.log('Close view with shortcut');
// tell the active workbench to close the current view
this.tabViews.closeView$.next();
},
},
{
label: 'Undo close tab',
accelerator: 'CommandOrControl+Shift+T',
click: () => {
this.logger.log('Undo close tab with shortcut');
this.tabViews.undoCloseTab().catch(console.error);
},
},
...[1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => {
const shortcut = `CommandOrControl+${n}`;
const listener = () => {
this.tabViews.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: () => {
this.tabViews.switchToNextTab();
},
},
{
label: 'Switch to previous tab',
accelerator: 'Control+Shift+Tab',
click: () => {
this.tabViews.switchToPreviousTab();
},
},
{
label: 'Switch to next tab (mac 2)',
accelerator: 'Alt+Command+]',
visible: false,
click: () => {
this.tabViews.switchToNextTab();
},
},
{
label: 'Switch to previous tab (mac 2)',
accelerator: 'Alt+Command+[',
visible: false,
click: () => {
this.tabViews.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: 'Documentation',
click: async () => {
// oxlint-disable-next-line no-var-requires
const { shell } = require('electron');
await shell.openExternal(
'https://docs.affine.pro/docs/hello-bonjour-aloha-你好'
);
},
},
],
},
];
// @ts-expect-error: The snippet is copied from Electron official docs.
// It's working as expected. No idea why it contains type errors.
// Just ignore for now.
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
return menu;
}
}

View File

@ -0,0 +1,60 @@
import { join } from 'node:path';
import { Injectable, Logger } from '@nestjs/common';
import { BrowserWindow } from 'electron';
import { screen } from 'electron/main';
import { BehaviorSubject } from 'rxjs';
import { IpcHandle, IpcScope } from '../../../ipc';
import { customThemeViewUrl } from '../constants';
import { getScreenSize } from './utils';
@Injectable()
export class CustomThemeWindowManager {
private readonly window$ = new BehaviorSubject<BrowserWindow | undefined>(
undefined
);
constructor(private readonly logger: Logger) {}
private async ensureWindow() {
if (this.window$.value) {
return this.window$.value;
}
this.logger.debug(
'Creating custom theme window',
'CustomThemeWindowManager'
);
const { width: maxWidth, height: maxHeight } = getScreenSize(
screen.getPrimaryDisplay()
);
const browserWindow = new BrowserWindow({
width: Math.min(maxWidth, 800),
height: Math.min(maxHeight, 600),
resizable: true,
maximizable: false,
fullscreenable: false,
webPreferences: {
webgl: true,
preload: join(__dirname, './preload.js'),
additionalArguments: [`--window-name=theme-editor`],
},
});
this.window$.next(browserWindow);
browserWindow.on('closed', () => {
this.window$.next(undefined);
});
await browserWindow.loadURL(customThemeViewUrl);
return browserWindow;
}
@IpcHandle({ scope: IpcScope.UI })
async openThemeEditor() {
const window = await this.ensureWindow();
window.show();
window.focus();
}
}

View File

@ -0,0 +1,6 @@
export * from './application-menu.service';
export * from './main-window.service';
export * from './states';
export * from './tab-views.service';
export * from './windows.module';
export * from './windows.service';

View File

@ -1,19 +1,15 @@
import { join } from 'node:path';
import { Injectable, Logger } from '@nestjs/common';
import { BrowserWindow, nativeTheme } from 'electron';
import electronWindowState from 'electron-window-state';
import { BehaviorSubject } from 'rxjs';
import { isLinux, isMacOS, isWindows, resourcesPath } from '../../shared/utils';
import { beforeAppQuit } from '../cleanup';
import { buildType } from '../config';
import { mainWindowOrigin } from '../constants';
import { ensureHelperProcess } from '../helper-process';
import { logger } from '../logger';
import { uiSubjects } from '../ui/subject';
const IS_DEV: boolean =
process.env.NODE_ENV === 'development' && !process.env.CI;
import { IpcEvent, IpcHandle, IpcScope } from '../../../ipc';
import { buildType, isDev } from '../../../shared/constants';
import { mainWindowOrigin, resourcesPath } from '../constants';
import { HelperProcessManager } from '../helper-process';
import { isLinux, isMacOS, isWindows } from '../utils';
function closeAllWindows() {
BrowserWindow.getAllWindows().forEach(w => {
@ -23,18 +19,29 @@ function closeAllWindows() {
});
}
@Injectable()
export class MainWindowManager {
static readonly instance = new MainWindowManager();
context = 'main-window';
@IpcEvent({ scope: IpcScope.UI })
maximized$ = new BehaviorSubject<boolean>(false);
@IpcEvent({ scope: IpcScope.UI })
fullScreen$ = new BehaviorSubject<boolean>(false);
mainWindowReady: Promise<BrowserWindow> | undefined;
mainWindow$ = new BehaviorSubject<BrowserWindow | undefined>(undefined);
private hiddenMacWindow: BrowserWindow | undefined;
constructor(
private readonly logger: Logger,
private readonly helperProcessService: HelperProcessManager
) {}
get mainWindow() {
return this.mainWindow$.value;
}
// #region private methods
private preventMacAppQuit() {
if (!this.hiddenMacWindow && isMacOS()) {
this.hiddenMacWindow = new BrowserWindow({
@ -57,7 +64,7 @@ export class MainWindowManager {
}
private async createMainWindow() {
logger.info('create window');
this.logger.log('create window', this.context);
const mainWindowState = electronWindowState({
defaultWidth: 1000,
defaultHeight: 800,
@ -77,7 +84,6 @@ export class MainWindowManager {
minHeight: 480,
visualEffectState: 'active',
vibrancy: 'under-window',
// backgroundMaterial: 'mica',
height: mainWindowState.height,
show: false, // Use 'ready-to-show' event to show window
webPreferences: {
@ -86,12 +92,9 @@ export class MainWindowManager {
sandbox: false,
},
});
const helper = await ensureHelperProcess();
helper.connectMain(browserWindow);
if (isLinux()) {
browserWindow.setIcon(
// __dirname is `packages/frontend/apps/electron/dist` (the bundled output directory)
join(resourcesPath, `icons/icon_${buildType}_64x64.png`)
);
}
@ -100,29 +103,21 @@ export class MainWindowManager {
mainWindowState.manage(browserWindow);
this.bindEvents(browserWindow);
await this.helperProcessService.ready;
this.helperProcessService.connectMain(browserWindow);
return browserWindow;
}
private bindEvents(mainWindow: BrowserWindow) {
/**
* If you install `show: true` then it can cause issues when trying to close the window.
* Use `show: false` and listener events `ready-to-show` to fix these issues.
*
* @see https://github.com/electron/electron/issues/25012
*/
mainWindow.on('ready-to-show', () => {
logger.info('main window is ready to show');
uiSubjects.onMaximized$.next(mainWindow.isMaximized());
uiSubjects.onFullScreen$.next(mainWindow.isFullScreen());
});
beforeAppQuit(() => {
this.cleanupWindows();
this.logger.log('main window is ready to show', this.context);
this.maximized$.next(mainWindow.isMaximized());
this.fullScreen$.next(mainWindow.isFullScreen());
});
mainWindow.on('close', e => {
// TODO(@pengx17): gracefully close the app, for example, ask user to save unsaved changes
e.preventDefault();
if (!isMacOS()) {
closeAllWindows();
@ -130,16 +125,6 @@ export class MainWindowManager {
this.mainWindow$.next(undefined);
} else {
// hide window on macOS
// application quit will be handled by closing the hidden window
//
// explanation:
// - closing the top window (by clicking close button or CMD-w)
// - will be captured in "close" event here
// - hiding the app to make the app open faster when user click the app icon
// - quit the app by "cmd+q" or right click on the dock icon and select "quit"
// - all browser windows will capture the "close" event
// - the hidden window will close all windows
// - "window-all-closed" event will be emitted and eventually quit the app
if (mainWindow.isFullScreen()) {
mainWindow.once('leave-full-screen', () => {
mainWindow.hide();
@ -154,7 +139,6 @@ export class MainWindowManager {
const refreshBound = (timeout = 0) => {
setTimeout(() => {
if (mainWindow.isDestroyed()) return;
// FIXME: workaround for theme bug in full screen mode
const size = mainWindow.getSize();
mainWindow.setSize(size[0] + 1, size[1] + 1);
mainWindow.setSize(size[0], size[1]);
@ -162,31 +146,29 @@ export class MainWindowManager {
};
mainWindow.on('leave-full-screen', () => {
// seems call this too soon may cause the app to crash
refreshBound();
refreshBound(1000);
uiSubjects.onMaximized$.next(false);
uiSubjects.onFullScreen$.next(false);
this.maximized$.next(false);
this.fullScreen$.next(false);
});
mainWindow.on('maximize', () => {
uiSubjects.onMaximized$.next(true);
this.maximized$.next(true);
});
mainWindow.on('unmaximize', () => {
uiSubjects.onMaximized$.next(false);
this.maximized$.next(false);
});
// full-screen == maximized in UI on windows
mainWindow.on('enter-full-screen', () => {
uiSubjects.onFullScreen$.next(true);
this.fullScreen$.next(true);
});
mainWindow.on('leave-full-screen', () => {
uiSubjects.onFullScreen$.next(false);
this.fullScreen$.next(false);
});
}
// #endregion
async ensureMainWindow(): Promise<BrowserWindow> {
if (
@ -200,13 +182,10 @@ export class MainWindowManager {
return this.mainWindowReady;
}
/**
* Init main BrowserWindow. Will create a new window if it's not created yet.
*/
async initAndShowMainWindow() {
const mainWindow = await this.ensureMainWindow();
if (IS_DEV) {
if (isDev) {
// do not gain focus in dev mode
mainWindow.showInactive();
} else {
@ -217,61 +196,89 @@ export class MainWindowManager {
return mainWindow;
}
}
export async function initAndShowMainWindow() {
return MainWindowManager.instance.initAndShowMainWindow();
}
export async function getMainWindow() {
return MainWindowManager.instance.ensureMainWindow();
}
export async function showMainWindow() {
const window = await getMainWindow();
if (!window) return;
if (window.isMinimized()) {
window.restore();
}
window.focus();
}
const getWindowAdditionalArguments = async () => {
const { getExposedMeta } = await import('../exposed');
const mainExposedMeta = getExposedMeta();
return [
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
`--window-name=hidden-window`,
];
};
function transformToAppUrl(url: URL) {
const params = url.searchParams;
return mainWindowOrigin + url.pathname + '?' + params.toString();
}
/**
* Open a URL in a hidden window.
*/
export async function openUrlInHiddenWindow(urlObj: URL) {
const url = transformToAppUrl(urlObj);
const win = new BrowserWindow({
width: 1200,
height: 600,
webPreferences: {
preload: join(__dirname, './preload.js'),
additionalArguments: await getWindowAdditionalArguments(),
},
show: BUILD_CONFIG.debug,
});
if (BUILD_CONFIG.debug) {
win.webContents.openDevTools();
async getMainWindow() {
return this.ensureMainWindow();
}
logger.info('loading page at', url);
win.loadURL(url).catch(e => {
logger.error('failed to load url', e);
});
return win;
@IpcHandle({ scope: IpcScope.UI, name: 'showMainWindow' })
async show() {
const window = await this.getMainWindow();
if (!window) return;
if (window.isMinimized()) {
window.restore();
}
window.focus();
}
@IpcHandle({ scope: IpcScope.UI })
handleThemeChange(theme: (typeof nativeTheme)['themeSource']) {
nativeTheme.themeSource = theme;
}
@IpcHandle({ scope: IpcScope.UI })
isFullScreen() {
return this.mainWindow?.isFullScreen() ?? false;
}
@IpcHandle({ scope: IpcScope.UI })
isMaximized() {
return this.mainWindow?.isMaximized() ?? false;
}
@IpcHandle({ scope: IpcScope.UI })
handleMinimizeApp() {
this.mainWindow?.minimize();
}
@IpcHandle({ scope: IpcScope.UI })
handleHideApp() {
this.mainWindow?.hide();
}
@IpcHandle({ scope: IpcScope.UI })
handleMaximizeApp() {
const window = this.mainWindow;
if (!window) return;
// allow unmaximize when in full screen mode
if (window.isFullScreen()) {
window.setFullScreen(false);
window.unmaximize();
} else if (window.isMaximized()) {
window.unmaximize();
} else {
window.maximize();
}
}
transformToAppUrl(url: URL) {
const params = url.searchParams;
return mainWindowOrigin + url.pathname + '?' + params.toString();
}
/**
* Open a URL in a hidden window.
*/
async openUrlInHiddenWindow(urlObj: URL) {
const url = this.transformToAppUrl(urlObj);
const win = new BrowserWindow({
width: 1200,
height: 600,
webPreferences: {
preload: join(__dirname, './preload.js'),
additionalArguments: [`--window-name=hidden-window`],
},
show: BUILD_CONFIG.debug,
});
if (BUILD_CONFIG.debug) {
win.webContents.openDevTools();
}
this.logger.log('loading page at', url, this.context);
win.loadURL(url).catch(e => {
this.logger.error('failed to load url', e, this.context);
});
return win;
}
}

View File

@ -1,6 +1,7 @@
import { join } from 'node:path';
import { setTimeout } from 'node:timers/promises';
import { Injectable, Logger } from '@nestjs/common';
import {
app,
BrowserWindow,
@ -8,20 +9,15 @@ import {
} from 'electron';
import { BehaviorSubject } from 'rxjs';
import { IpcHandle } from '../../../ipc';
import { IpcScope } from '../../../ipc/constant';
import { popupViewUrl } from '../constants';
import { logger } from '../logger';
import type { MainEventRegister, NamespaceHandlers } from '../type';
import { getCurrentDisplay } from './utils';
type PopupWindowType = 'notification' | 'recording';
async function getAdditionalArguments(name: string) {
const { getExposedMeta } = await import('../exposed');
const mainExposedMeta = getExposedMeta();
return [
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
`--window-name=${name}`,
];
return [`--window-name=${name}`];
}
const POPUP_PADDING = 20; // padding between the popup and the edge of the screen
@ -65,6 +61,8 @@ abstract class PopupWindow {
private readonly showing$ = new BehaviorSubject<boolean>(false);
constructor(private readonly logger: Logger) {}
get showing() {
return this.showing$.value;
}
@ -107,12 +105,12 @@ abstract class PopupWindow {
visibleOnFullScreen: true,
});
logger.info('loading popup', this.name, popupViewUrl);
this.logger.log('loading popup', this.name, popupViewUrl);
browserWindow.webContents.on('did-finish-load', () => {
this.ready.resolve();
logger.info('popup ready', this.name);
this.logger.log('popup ready', this.name);
});
browserWindow.loadURL(popupViewUrl).catch(err => logger.error(err));
browserWindow.loadURL(popupViewUrl).catch(err => this.logger.error(err));
return browserWindow;
}
@ -139,7 +137,7 @@ abstract class PopupWindow {
// Set initial position
browserWindow.setPosition(startX, y);
logger.info('showing popup', this.name);
this.logger.log('showing popup', this.name);
// First fade in, then slide
await Promise.all([
@ -169,7 +167,7 @@ abstract class PopupWindow {
if (!this.browserWindow) {
return;
}
logger.info('hiding popup', this.name);
this.logger.log('hiding popup', this.name);
this.showing$.next(false);
await animate(this.browserWindow.getOpacity(), 0, opacity => {
this.browserWindow?.setOpacity(opacity);
@ -219,8 +217,10 @@ type PopupWindowTypeMap = {
recording: RecordingPopupWindow;
};
@Injectable()
export class PopupManager {
static readonly instance = new PopupManager();
constructor(private readonly logger: Logger) {}
// there could be a single instance of each type of popup window
readonly popupWindows$ = new BehaviorSubject<Map<string, PopupWindow>>(
new Map()
@ -241,11 +241,13 @@ export class PopupManager {
const popupWindow = (() => {
switch (type) {
case 'notification':
return new NotificationPopupWindow() as PopupWindowTypeMap[T];
return new NotificationPopupWindow(
this.logger
) as PopupWindowTypeMap[T];
case 'recording':
return new RecordingPopupWindow() as PopupWindowTypeMap[T];
return new RecordingPopupWindow(this.logger) as PopupWindowTypeMap[T];
default:
throw new Error(`Unknown popup type: ${type}`);
throw new Error(`Unknown popup window type: ${type}`);
}
})();
@ -254,37 +256,27 @@ export class PopupManager {
);
return popupWindow;
}
}
export const popupManager = PopupManager.instance;
// recording popup window events/handlers are in ../recording/index.ts
export const popupHandlers = {
getCurrentNotification: async () => {
const notification = popupManager.get('notification').notification$.value;
@IpcHandle({ scope: IpcScope.POPUP })
getCurrentNotification() {
const notification = this.get('notification').notification$.value;
if (!notification) {
return null;
}
return notification;
},
dismissCurrentNotification: async () => {
return popupManager.get('notification').hide();
},
dismissCurrentRecording: async () => {
return popupManager.get('recording').hide();
},
} satisfies NamespaceHandlers;
}
export const popupEvents = {
onNotificationChanged: (
callback: (notification: ElectronNotification | null) => void
) => {
const notification = popupManager.get('notification');
const sub = notification.notification$.subscribe(notification => {
callback(notification);
});
return () => {
sub.unsubscribe();
};
},
} satisfies Record<string, MainEventRegister>;
@IpcHandle({ scope: IpcScope.POPUP })
dismissCurrentNotification() {
const notification = this.get('notification').notification$.value;
if (!notification) {
return;
}
this.get('notification').notification$.next(null);
}
@IpcHandle({ scope: IpcScope.POPUP })
dismissCurrentRecording() {
return this.get('recording').hide();
}
}

View File

@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import { map, shareReplay } from 'rxjs';
import {
TabViewsMetaKey,
type TabViewsMetaSchema,
tabViewsMetaSchema,
} from '../../../shared/shared-state-schema';
import { GlobalStateStorage } from '../storage';
@Injectable()
export class TabViewsState {
constructor(private readonly globalStateStorage: GlobalStateStorage) {}
$ = this.globalStateStorage.watch<TabViewsMetaSchema>(TabViewsMetaKey).pipe(
map(v => tabViewsMetaSchema.parse(v ?? {})),
shareReplay(1)
);
set value(value: TabViewsMetaSchema) {
this.globalStateStorage.set(TabViewsMetaKey, value);
}
get value() {
return tabViewsMetaSchema.parse(
this.globalStateStorage.get(TabViewsMetaKey) ?? {}
);
}
// shallow merge
patch(patch: Partial<TabViewsMetaSchema>) {
this.value = {
...this.value,
...patch,
};
}
}

View File

@ -1,8 +1,8 @@
import { join } from 'node:path';
import { Injectable, Logger } from '@nestjs/common';
import {
app,
BrowserWindow,
Menu,
MenuItem,
type View,
@ -23,90 +23,48 @@ import {
type Unsubscribable,
} from 'rxjs';
import { isMacOS } from '../../shared/utils';
import { beforeAppQuit, onTabClose } from '../cleanup';
import { mainWindowOrigin, shellViewUrl } from '../constants';
import { ensureHelperProcess } from '../helper-process';
import { logger } from '../logger';
import { getIpcEvent, IpcEvent, IpcHandle, IpcScope } from '../../../ipc';
import {
SpellCheckStateKey,
SpellCheckStateSchema,
TabViewsMetaKey,
type TabViewsMetaSchema,
tabViewsMetaSchema,
type WorkbenchMeta,
type WorkbenchViewMeta,
} from '../shared-state-schema';
import { globalStateStorage } from '../shared-storage/storage';
import { getMainWindow, MainWindowManager } from './main-window';
} from '../../../shared/shared-state-schema';
import { beforeAppQuit, onTabClose } from '../cleanup';
import { mainWindowOrigin, shellViewUrl } from '../constants';
import { HelperProcessManager } from '../helper-process';
import { GlobalStateStorage } from '../storage';
import { isMacOS } from '../utils';
import { MainWindowManager } from './main-window.service';
import { TabViewsState } from './states';
async function getAdditionalArguments() {
const { getExposedMeta } = await import('../exposed');
const mainExposedMeta = getExposedMeta();
const helperProcessManager = await ensureHelperProcess();
const helperExposedMeta = await helperProcessManager.rpc?.getMeta();
return [
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
`--helper-exposed-meta=` + JSON.stringify(helperExposedMeta),
`--window-name=main`,
];
}
const TabViewsMetaState = {
$: globalStateStorage.watch<TabViewsMetaSchema>(TabViewsMetaKey).pipe(
map(v => tabViewsMetaSchema.parse(v ?? {})),
shareReplay(1)
),
set value(value: TabViewsMetaSchema) {
globalStateStorage.set(TabViewsMetaKey, value);
},
get value() {
return tabViewsMetaSchema.parse(
globalStateStorage.get(TabViewsMetaKey) ?? {}
);
},
// shallow merge
patch(patch: Partial<TabViewsMetaSchema>) {
this.value = {
...this.value,
...patch,
};
},
};
const spellCheckSettings = SpellCheckStateSchema.parse(
globalStateStorage.get(SpellCheckStateKey) ?? {}
);
type AddTabAction = {
export type AddTabAction = {
type: 'add-tab';
payload: WorkbenchMeta;
};
type CloseTabAction = {
export type CloseTabAction = {
type: 'close-tab';
payload?: string;
};
type PinTabAction = {
export type PinTabAction = {
type: 'pin-tab';
payload: { key: string; shouldPin: boolean };
};
type ActivateViewAction = {
export type ActivateViewAction = {
type: 'activate-view';
payload: { tabId: string; viewIndex: number };
};
type SeparateViewAction = {
export type SeparateViewAction = {
type: 'separate-view';
payload: { tabId: string; viewIndex: number };
};
type OpenInSplitViewAction = {
export type OpenInSplitViewAction = {
type: 'open-in-split-view';
payload: {
tabId: string;
@ -134,16 +92,21 @@ export type AddTabOption = {
pinned?: boolean;
};
export class WebContentViewsManager {
static readonly instance = new WebContentViewsManager(
MainWindowManager.instance
);
private constructor(public mainWindowManager: MainWindowManager) {
@Injectable()
export class TabViewsManager {
constructor(
private readonly mainWindowManager: MainWindowManager,
public readonly tabViewsState: TabViewsState,
private readonly helperProcessManager: HelperProcessManager,
private readonly globalStateStorage: GlobalStateStorage,
private readonly logger: Logger
) {
this.setup();
}
readonly tabViewsMeta$ = TabViewsMetaState.$;
@IpcEvent({ scope: IpcScope.UI, name: 'tabViewsMetaChange' })
private readonly tabViewsMeta$ = this.tabViewsState.$;
readonly appTabsUIReady$ = new BehaviorSubject(new Set<string>());
get appTabsUIReady() {
@ -155,18 +118,19 @@ export class WebContentViewsManager {
new Map<string, WebContentsView>()
);
@IpcEvent({ scope: IpcScope.UI, name: 'tabsStatusChange' })
readonly tabsStatus$ = combineLatest([
this.tabViewsMeta$.pipe(startWith(TabViewsMetaState.value)),
this.tabViewsMeta$.pipe(startWith(this.tabViewsState.value)),
this.webViewsMap$,
this.appTabsUIReady$,
]).pipe(
map(([viewsMeta, views, ready]) => {
map(([viewsMeta, webContents, ready]) => {
return viewsMeta.workbenches.map(w => {
return {
id: w.id,
pinned: !!w.pinned,
active: viewsMeta.activeWorkbenchId === w.id,
loaded: views.has(w.id),
loaded: webContents.has(w.id),
ready: ready.has(w.id),
activeViewIndex: w.activeViewIndex,
views: w.views,
@ -177,6 +141,9 @@ export class WebContentViewsManager {
shareReplay(1)
);
@IpcEvent({ scope: IpcScope.UI })
closeView$ = new Subject<void>();
// all app views (excluding shell view)
readonly workbenchViewsMap$ = this.webViewsMap$.pipe(
map(
@ -190,13 +157,16 @@ export class WebContentViewsManager {
/**
* Emits whenever a tab action is triggered.
*/
@IpcEvent({ scope: IpcScope.UI })
readonly tabAction$ = new Subject<TabAction>();
cookies: Electron.Cookie[] = [];
@IpcEvent({ scope: IpcScope.UI, name: 'activeTabChanged' })
readonly activeWorkbenchId$ = this.tabViewsMeta$.pipe(
map(m => m?.activeWorkbenchId ?? m?.workbenches[0].id)
);
readonly activeWorkbench$ = combineLatest([
this.activeWorkbenchId$,
this.workbenchViewsMap$,
@ -211,15 +181,15 @@ export class WebContentViewsManager {
);
get tabViewsMeta() {
return TabViewsMetaState.value;
return this.tabViewsState.value;
}
private set tabViewsMeta(meta: TabViewsMetaSchema) {
TabViewsMetaState.value = meta;
this.tabViewsState.value = meta;
}
readonly patchTabViewsMeta = (patch: Partial<TabViewsMetaSchema>) => {
TabViewsMetaState.patch(patch);
this.tabViewsState.patch(patch);
};
get shellView() {
@ -375,7 +345,7 @@ export class WebContentViewsManager {
return;
}
this.showTab(activeWorkbenchKey).catch(error => logger.error(error));
this.showTab(activeWorkbenchKey).catch(error => this.logger.error(error));
this.patchTabViewsMeta({
workbenches,
@ -471,6 +441,30 @@ export class WebContentViewsManager {
return workbench;
};
// parse the full pathname to basename and pathname
// eg: /workspace/xxx/yyy => { basename: '/workspace/xxx', pathname: '/yyy' }
parseFullPathname = (url: string) => {
const urlObj = new URL(url);
const basename = urlObj.pathname.match(/\/workspace\/[^/]+/g)?.[0] ?? '/';
return {
basename,
pathname: urlObj.pathname.slice(basename.length),
search: urlObj.search,
hash: urlObj.hash,
};
};
addTabWithUrl = async (url: string) => {
const { basename, pathname, search, hash } = this.parseFullPathname(url);
return this.addTab({
basename,
view: {
path: { pathname, search, hash },
},
});
};
loadTab = async (id: string): Promise<WebContentsView | undefined> => {
if (!this.tabViewsMeta.workbenches.some(w => w.id === id)) {
return;
@ -489,8 +483,16 @@ export class WebContentViewsManager {
);
url.hash = viewMeta.path?.hash ?? '';
url.search = viewMeta.path?.search ?? '';
logger.info(`loading tab ${id} at ${url.href}`);
view.webContents.loadURL(url.href).catch(err => logger.error(err));
this.logger.log(`loading tab ${id} at ${url.href}`);
const start = performance.now();
view.webContents
.loadURL(url.href)
.then(() => {
this.logger.log(
`loading tab ${id} at ${url.href} took ${performance.now() - start}ms`
);
})
.catch(err => this.logger.error(err));
}
return view;
};
@ -613,7 +615,7 @@ export class WebContentViewsManager {
this.updateWorkbenchMeta(tabId, {
views: tabMeta.views.toSpliced(viewIndex, 1),
});
addTab(newTabMeta).catch(err => logger.error(err));
this.addTab(newTabMeta).catch(err => this.logger.error(err));
};
openInSplitView = (payload: OpenInSplitViewAction['payload']) => {
@ -688,12 +690,27 @@ export class WebContentViewsManager {
this.mainWindow?.contentView.addChildView(view, idx);
});
handleWebContentsResize(activeView?.webContents).catch(err =>
logger.error(err)
this.handleWebContentsResize(activeView?.webContents).catch(err =>
this.logger.error(err)
);
}
};
handleWebContentsResize = async (webContents?: WebContents) => {
// right now when window is resized, we will relocate the traffic light positions
if (isMacOS()) {
const window = await this.mainWindowManager.ensureMainWindow();
const factor = webContents?.getZoomFactor() || 1;
window?.setWindowButtonPosition({ x: 14 * factor, y: 14 * factor - 2 });
}
};
@IpcHandle({ scope: IpcScope.UI })
handleWindowResize = async () => {
const e = getIpcEvent();
await this.handleWebContentsResize(e.sender);
};
setup = () => {
const windowReadyToShow$ = this.mainWindowManager.mainWindow$.pipe(
filter(w => !!w)
@ -702,7 +719,7 @@ export class WebContentViewsManager {
const disposables: Unsubscribable[] = [];
disposables.push(
windowReadyToShow$.subscribe(w => {
handleWebContentsResize().catch(err => logger.error(err));
this.handleWebContentsResize().catch(err => this.logger.error(err));
const screenSizeChangeEvents = ['resize', 'maximize', 'unmaximize'];
const onResize = () => {
if (this.activeWorkbenchView) {
@ -723,7 +740,7 @@ export class WebContentViewsManager {
});
// add shell view
this.createAndAddView('shell').catch(err => logger.error(err));
this.createAndAddView('shell').catch(err => this.logger.error(err));
(async () => {
if (this.tabViewsMeta.workbenches.length === 0) {
// create a default view (e.g., on first launch)
@ -732,7 +749,7 @@ export class WebContentViewsManager {
const defaultTabId = this.activeWorkbenchId;
if (defaultTabId) await this.showTab(defaultTabId);
}
})().catch(err => logger.error(err));
})().catch(err => this.logger.error(err));
})
);
@ -798,15 +815,16 @@ export class WebContentViewsManager {
viewId = this.generateViewId(type)
) => {
if (this.shellView && type === 'shell') {
logger.error('shell view is already created');
this.logger.error('shell view is already created');
}
const start = performance.now();
const additionalArguments = [`--window-name=main`, `--view-id=${viewId}`];
await this.helperProcessManager.ensureHelperProcess();
const additionalArguments = await getAdditionalArguments();
const helperProcessManager = await ensureHelperProcess();
// will be added to appInfo
additionalArguments.push(`--view-id=${viewId}`);
const spellCheckSettings = SpellCheckStateSchema.parse(
this.globalStateStorage.get(SpellCheckStateKey) ?? {}
);
const view = new WebContentsView({
webPreferences: {
@ -818,7 +836,6 @@ export class WebContentViewsManager {
preload: join(__dirname, './preload.js'), // this points to the bundled preload module
// serialize exposed meta that to be used in preload
additionalArguments: additionalArguments,
backgroundThrottling: false,
},
});
@ -865,8 +882,7 @@ export class WebContentViewsManager {
// shell process do not need to connect to helper process
if (type !== 'shell') {
view.webContents.on('did-finish-load', () => {
unsub();
unsub = helperProcessManager.connectRenderer(view.webContents);
unsub = this.helperProcessManager.connectRenderer(view.webContents);
});
} else {
view.webContents.on('focus', () => {
@ -876,10 +892,13 @@ export class WebContentViewsManager {
});
});
view.webContents.loadURL(shellViewUrl).catch(err => logger.error(err));
view.webContents
.loadURL(shellViewUrl)
.catch(err => this.logger.error(err));
}
view.webContents.on('destroyed', () => {
unsub();
this.webViewsMap$.next(
new Map(
[...this.tabViewsMap.entries()].filter(([key]) => key !== viewId)
@ -897,17 +916,153 @@ export class WebContentViewsManager {
view.webContents.on('did-finish-load', () => {
this.resizeView(view);
if (process.env.SKIP_ONBOARDING) {
this.skipOnboarding(view).catch(err => logger.error(err));
this.skipOnboarding(view).catch(err => this.logger.error(err));
}
});
// reorder will add to main window when loaded
this.reorderViews();
logger.info(`view ${viewId} created in ${performance.now() - start}ms`);
this.logger.log(`view ${viewId} created in ${performance.now() - start}ms`);
return view;
};
switchTab = (n: number) => {
const item = this.tabViewsMeta.workbenches.at(n === 9 ? -1 : n - 1);
if (item) {
this.showTab(item.id).catch(err => this.logger.error(err));
}
};
switchToNextTab = () => {
const length = this.tabViewsMeta.workbenches.length;
const activeIndex = this.activeWorkbenchIndex;
const item = this.tabViewsMeta.workbenches.at(
activeIndex === length - 1 ? 0 : activeIndex + 1
);
if (item) {
this.showTab(item.id).catch(err => this.logger.error(err));
}
};
switchToPreviousTab = () => {
const length = this.tabViewsMeta.workbenches.length;
const activeIndex = this.activeWorkbenchIndex;
const item = this.tabViewsMeta.workbenches.at(
activeIndex === 0 ? length - 1 : activeIndex - 1
);
if (item) {
this.showTab(item.id).catch(err => this.logger.error(err));
}
};
showTabContextMenu = async (
tabId: string,
viewIndex: number
): Promise<TabAction | null> => {
const workbenches = this.tabViewsMeta.workbenches;
const tabMeta = workbenches.find(w => w.id === tabId);
if (!tabMeta) {
return null;
}
const { resolve, promise } = Promise.withResolvers<TabAction | null>();
const template: Parameters<typeof Menu.buildFromTemplate>[0] = [
tabMeta.pinned
? {
label: 'Unpin tab',
click: () => {
this.pinTab(tabId, false);
},
}
: {
label: 'Pin tab',
click: () => {
this.pinTab(tabId, true);
},
},
{
label: 'Refresh tab',
click: () => {
if (this.activeWorkbenchId) {
this.loadTab(this.activeWorkbenchId).catch(err =>
this.logger.error(err)
);
}
},
},
{
label: 'Duplicate tab',
click: () => {
this.addTab({
basename: tabMeta.basename,
view: tabMeta.views,
show: false,
}).catch(err => this.logger.error(err));
},
},
{ type: 'separator' },
tabMeta.views.length > 1
? {
label: 'Separate tabs',
click: () => {
this.separateView(tabId, viewIndex);
},
}
: {
label: 'Open in split view',
click: () => {
this.openInSplitView({ tabId });
},
},
...(workbenches.length > 1
? ([
{ type: 'separator' },
{
label: 'Close tab',
click: () => {
this.closeTab(tabId).catch(err => this.logger.error(err));
},
},
{
label: 'Close other tabs',
click: () => {
const tabsToRetain = this.tabViewsMeta.workbenches.filter(
w => w.id === tabId || w.pinned
);
this.patchTabViewsMeta({
workbenches: tabsToRetain,
activeWorkbenchId: tabId,
});
},
},
] as const)
: []),
];
const menu = Menu.buildFromTemplate(template);
menu.popup();
let unsub: (() => void) | undefined;
const subscription = this.tabAction$.subscribe(action => {
resolve(action);
unsub?.();
});
menu.on('menu-will-close', () => {
setTimeout(() => {
resolve(null);
unsub?.();
});
});
unsub = () => {
subscription.unsubscribe();
};
return promise;
};
private async skipOnboarding(view: WebContentsView) {
await view.webContents.executeJavaScript(`
window.localStorage.setItem('app_config', '{"onBoarding":false}');
@ -918,243 +1073,146 @@ export class WebContentViewsManager {
}
}
// there is no proper way to listen to webContents resize event
// we will rely on window.resize event in renderer instead
export async function handleWebContentsResize(webContents?: WebContents) {
// right now when window is resized, we will relocate the traffic light positions
if (isMacOS()) {
const window = await getMainWindow();
const factor = webContents?.getZoomFactor() || 1;
window?.setWindowButtonPosition({ x: 14 * factor, y: 14 * factor - 2 });
}
}
/**
* For separation of concerns, we put the ipc communication related logic here.
*/
@Injectable()
export class TabViewsIpcRegistry {
@IpcEvent({ scope: IpcScope.UI })
toggleRightSidebar$ = new Subject<string>();
export function onTabViewsMetaChanged(
fn: (appViewMeta: TabViewsMetaSchema) => void
) {
const sub = WebContentViewsManager.instance.tabViewsMeta$.subscribe(meta => {
fn(meta);
});
return () => {
sub.unsubscribe();
};
}
export const onTabShellViewActiveChange = (fn: (active: boolean) => void) => {
const sub = combineLatest([
WebContentViewsManager.instance.appTabsUIReady$,
WebContentViewsManager.instance.activeWorkbenchId$,
]).subscribe(([ready, active]) => {
fn(!ready.has(active));
});
return () => {
sub.unsubscribe();
};
};
export const getTabsStatus = () => {
return firstValueFrom(WebContentViewsManager.instance.tabsStatus$);
};
export const onTabsStatusChange = (
fn: (
tabs: {
id: string;
active: boolean;
loaded: boolean;
ready: boolean;
pinned: boolean;
activeViewIndex: number;
views: WorkbenchViewMeta[];
basename: string;
}[]
) => void
) => {
const sub = WebContentViewsManager.instance.tabsStatus$.subscribe(tabs => {
fn(tabs);
});
return () => {
sub.unsubscribe();
};
};
export const updateWorkbenchMeta = (
id: string,
meta: Partial<Omit<WorkbenchMeta, 'id'>>
) => {
WebContentViewsManager.instance.updateWorkbenchMeta(id, meta);
};
export const updateWorkbenchViewMeta = (
workbenchId: string,
viewId: string | number,
meta: Partial<WorkbenchViewMeta>
) => {
WebContentViewsManager.instance.updateWorkbenchViewMeta(
workbenchId,
viewId,
meta
@IpcEvent({ scope: IpcScope.UI })
tabShellViewActiveChange$ = combineLatest([
this.tabViewsService.appTabsUIReady$,
this.tabViewsService.activeWorkbenchId$,
]).pipe(
map(([ready, active]) => {
return !ready.has(active);
})
);
};
export const getWorkbenchMeta = (id: string) => {
return TabViewsMetaState.value.workbenches.find(w => w.id === id);
};
@IpcEvent({ scope: IpcScope.UI })
tabGoToRequest$ = new Subject<{ tabId: string; to: string }>();
export const updateActiveViewMeta = (
wc: WebContents,
meta: Partial<WorkbenchViewMeta>
) => {
const workbenchId =
WebContentViewsManager.instance.getWorkbenchIdFromWebContentsId(wc.id);
const workbench = workbenchId ? getWorkbenchMeta(workbenchId) : undefined;
constructor(private readonly tabViewsService: TabViewsManager) {}
if (workbench && workbenchId) {
return WebContentViewsManager.instance.updateWorkbenchViewMeta(
workbenchId,
workbench.activeViewIndex,
meta
@IpcHandle({ scope: IpcScope.UI })
isActiveTab() {
const e = getIpcEvent();
return (
this.tabViewsService.activeWorkbenchView?.webContents.id === e.sender.id
);
}
};
export const getTabViewsMeta = () => TabViewsMetaState.value;
export const isActiveTab = (wc: WebContents) => {
return (
wc.id ===
WebContentViewsManager.instance.activeWorkbenchView?.webContents.id
);
};
// parse the full pathname to basename and pathname
// eg: /workspace/xxx/yyy => { basename: '/workspace/xxx', pathname: '/yyy' }
export const parseFullPathname = (url: string) => {
const urlObj = new URL(url);
const basename = urlObj.pathname.match(/\/workspace\/[^/]+/g)?.[0] ?? '/';
return {
basename,
pathname: urlObj.pathname.slice(basename.length),
search: urlObj.search,
hash: urlObj.hash,
};
};
export const addTab = WebContentViewsManager.instance.addTab;
export const addTabWithUrl = (url: string) => {
const { basename, pathname, search, hash } = parseFullPathname(url);
return addTab({
basename,
view: {
path: { pathname, search, hash },
},
});
};
export const loadUrlInActiveTab = async (_url: string) => {
// todo: implement
throw new Error('loadUrlInActiveTab not implemented');
};
export const ensureTabLoaded = async (tabId: string) => {
const tab = WebContentViewsManager.instance.tabViewsMap.get(tabId);
if (tab) {
return tab;
@IpcHandle({ scope: IpcScope.UI })
getWorkbenchMeta(id: string) {
return this.tabViewsService.tabViewsState.value.workbenches.find(
w => w.id === id
);
}
return WebContentViewsManager.instance.loadTab(tabId);
};
export const showTab = WebContentViewsManager.instance.showTab;
export const closeTab = WebContentViewsManager.instance.closeTab;
export const undoCloseTab = WebContentViewsManager.instance.undoCloseTab;
export const activateView = WebContentViewsManager.instance.activateView;
export const moveTab = WebContentViewsManager.instance.moveTab;
export const openInSplitView = WebContentViewsManager.instance.openInSplitView;
export const reloadView = async () => {
const id = WebContentViewsManager.instance.activeWorkbenchId;
if (id) {
await WebContentViewsManager.instance.loadTab(id);
}
};
export const onTabAction = (fn: (event: TabAction) => void) => {
const { unsubscribe } =
WebContentViewsManager.instance.tabAction$.subscribe(fn);
return unsubscribe;
};
export const onActiveTabChanged = (fn: (tabId: string) => void) => {
const sub = WebContentViewsManager.instance.activeWorkbenchId$.subscribe(fn);
return () => {
sub.unsubscribe();
};
};
export const showDevTools = (id?: string) => {
// use focusedWindow?
const focusedWindow = BrowserWindow.getFocusedWindow();
// check if focused window is main window
const mainWindow = WebContentViewsManager.instance.mainWindow;
if (focusedWindow && focusedWindow.id !== mainWindow?.id) {
focusedWindow.webContents.openDevTools();
} else {
const view = id
? WebContentViewsManager.instance.getViewById(id)
: WebContentViewsManager.instance.activeWorkbenchView;
if (view) {
view.webContents.openDevTools();
@IpcHandle({ scope: IpcScope.UI })
updateWorkbenchMeta(id: string, patch: Partial<WorkbenchMeta>) {
const workbench = this.getWorkbenchMeta(id);
if (!workbench) {
return;
}
this.tabViewsService.updateWorkbenchMeta(workbench.id, patch);
}
};
export const pingAppLayoutReady = (wc: WebContents, ready: boolean) => {
const viewId =
WebContentViewsManager.instance.getWorkbenchIdFromWebContentsId(wc.id);
if (viewId) {
@IpcHandle({ scope: IpcScope.UI })
updateActiveViewMeta = (meta: Partial<WorkbenchViewMeta>) => {
const wc = getIpcEvent().sender;
const workbenchId = this.tabViewsService.getWorkbenchIdFromWebContentsId(
wc.id
);
const workbench = workbenchId
? this.getWorkbenchMeta(workbenchId)
: undefined;
if (workbench && workbenchId) {
return this.tabViewsService.updateWorkbenchViewMeta(
workbenchId,
workbench.activeViewIndex,
meta
);
}
};
@IpcHandle({ scope: IpcScope.UI })
getTabViewsMeta() {
// Return the full tab-views meta of current window
return this.tabViewsService.tabViewsState.value;
}
@IpcHandle({ scope: IpcScope.UI })
async getTabsStatus() {
return firstValueFrom(this.tabViewsService.tabsStatus$);
}
@IpcHandle({ scope: IpcScope.UI })
async addTab(option?: AddTabOption) {
return this.tabViewsService.addTab(option);
}
@IpcHandle({ scope: IpcScope.UI })
async showTab(tabId: string) {
await this.tabViewsService.showTab(tabId);
}
@IpcHandle({ scope: IpcScope.UI })
async tabGoTo(tabId: string, to: string) {
this.tabGoToRequest$.next({ tabId, to });
}
@IpcHandle({ scope: IpcScope.UI })
async closeTab(tabId?: string) {
return this.tabViewsService.closeTab(tabId);
}
@IpcHandle({ scope: IpcScope.UI })
async activateView(tabId: string, viewIndex: number) {
return this.tabViewsService.activateView(tabId, viewIndex);
}
@IpcHandle({ scope: IpcScope.UI })
moveTab(from: string, to: string, edge?: 'left' | 'right') {
return this.tabViewsService.moveTab(from, to, edge);
}
/**
* Toggle right sidebar visibility for a given tab.
*/
@IpcHandle({ scope: IpcScope.UI })
async toggleRightSidebar(tabId?: string) {
// If no tab id supplied, default to current active workbench
tabId ??= this.tabViewsService.activeWorkbenchId;
if (!tabId) return;
this.toggleRightSidebar$.next(tabId);
}
/**
* Inform main process that the renderer layout has become (un)ready so
* the view ordering / resizing can be updated.
*/
@IpcHandle({ scope: IpcScope.UI })
pingAppLayoutReady(ready = true) {
const e = getIpcEvent();
const workbenchId = this.tabViewsService.getWorkbenchIdFromWebContentsId(
e.sender.id
);
if (!workbenchId) {
return;
}
if (ready) {
WebContentViewsManager.instance.setTabUIReady(viewId);
this.tabViewsService.setTabUIReady(workbenchId);
} else {
WebContentViewsManager.instance.setTabUIUnready(viewId);
this.tabViewsService.setTabUIUnready(workbenchId);
}
}
};
export const switchTab = (n: number) => {
const item = WebContentViewsManager.instance.tabViewsMeta.workbenches.at(
n === 9 ? -1 : n - 1
);
if (item) {
WebContentViewsManager.instance
.showTab(item.id)
.catch(err => logger.error(err));
@IpcHandle({ scope: IpcScope.UI })
async showTabContextMenu(tabId: string, viewIndex: number) {
return this.tabViewsService.showTabContextMenu(tabId, viewIndex);
}
};
export const switchToNextTab = () => {
const length =
WebContentViewsManager.instance.tabViewsMeta.workbenches.length;
const activeIndex = WebContentViewsManager.instance.activeWorkbenchIndex;
const item = WebContentViewsManager.instance.tabViewsMeta.workbenches.at(
activeIndex === length - 1 ? 0 : activeIndex + 1
);
if (item) {
WebContentViewsManager.instance
.showTab(item.id)
.catch(err => logger.error(err));
}
};
export const switchToPreviousTab = () => {
const length =
WebContentViewsManager.instance.tabViewsMeta.workbenches.length;
const activeIndex = WebContentViewsManager.instance.activeWorkbenchIndex;
const item = WebContentViewsManager.instance.tabViewsMeta.workbenches.at(
activeIndex === 0 ? length - 1 : activeIndex - 1
);
if (item) {
WebContentViewsManager.instance
.showTab(item.id)
.catch(err => logger.error(err));
}
};
}

View File

@ -1,6 +1,6 @@
import { BrowserWindow, type Display, type Rectangle, screen } from 'electron';
import { isMacOS } from '../../shared/utils';
import { isMacOS } from '../utils';
export const getCurrentDisplay = (browserWindow: BrowserWindow) => {
const position = browserWindow.getPosition();

View File

@ -0,0 +1,35 @@
import { Module } from '@nestjs/common';
import { HelperProcessModule } from '../helper-process';
import { ApplicationMenuManager } from './application-menu.service';
import { CustomThemeWindowManager } from './custom-theme-window.service';
import { MainWindowManager } from './main-window.service';
import { PopupManager } from './popup.service';
import { TabViewsState } from './states';
import { TabViewsIpcRegistry, TabViewsManager } from './tab-views.service';
import { WindowsService } from './windows.service';
import { WorkerManager } from './worker-manager.service';
@Module({
providers: [
WindowsService,
MainWindowManager,
TabViewsManager,
TabViewsIpcRegistry,
TabViewsState,
ApplicationMenuManager,
PopupManager,
CustomThemeWindowManager,
WorkerManager,
],
exports: [
WindowsService,
MainWindowManager,
TabViewsManager,
TabViewsState,
ApplicationMenuManager,
PopupManager,
],
imports: [HelperProcessModule],
})
export class WindowsModule {}

View File

@ -0,0 +1,33 @@
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { app } from 'electron';
import { MainWindowManager } from './main-window.service';
/**
* This service is responsible for managing the windows of the application.
* AKA the "launcher".
*/
@Injectable()
export class WindowsService implements OnModuleInit {
constructor(
private readonly mainWindowService: MainWindowManager,
private readonly logger: Logger
) {}
onModuleInit() {
app.on('ready', () => {
this.logger.log('app is ready', 'WindowsService');
this.initializeMainWindow().catch(err => {
this.logger.error('Failed to initialize main window', err);
});
});
}
async initializeMainWindow() {
return this.mainWindowService.initAndShowMainWindow();
}
async getMainWindow() {
return this.mainWindowService.getMainWindow();
}
}

View File

@ -1,25 +1,23 @@
import { join } from 'node:path';
import { Injectable, Logger } from '@nestjs/common';
import { BrowserWindow, MessageChannelMain, type WebContents } from 'electron';
import {
AFFINE_WORKER_CONNECT_CHANNEL_NAME,
getIpcEvent,
IpcHandle,
IpcScope,
} from '../../../ipc';
import { backgroundWorkerViewUrl } from '../constants';
import { ensureHelperProcess } from '../helper-process';
import { logger } from '../logger';
async function getAdditionalArguments() {
const { getExposedMeta } = await import('../exposed');
const mainExposedMeta = getExposedMeta();
const helperProcessManager = await ensureHelperProcess();
const helperExposedMeta = await helperProcessManager.rpc?.getMeta();
return [
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
`--helper-exposed-meta=` + JSON.stringify(helperExposedMeta),
`--window-name=worker`,
];
}
import { HelperProcessManager } from '../helper-process';
@Injectable()
export class WorkerManager {
static readonly instance = new WorkerManager();
constructor(
private readonly helperProcessManager: HelperProcessManager,
private readonly logger: Logger
) {}
workers = new Map<
string,
@ -32,8 +30,8 @@ export class WorkerManager {
>();
private async getOrCreateWorker(key: string) {
const additionalArguments = await getAdditionalArguments();
const helperProcessManager = await ensureHelperProcess();
const additionalArguments = [`--window-name=worker`];
await this.helperProcessManager.ensureHelperProcess();
const exists = this.workers.get(key);
if (exists) {
return exists;
@ -61,10 +59,10 @@ export class WorkerManager {
disconnectHelperProcess?.();
});
worker.loadURL(backgroundWorkerViewUrl).catch(e => {
logger.error('failed to load url', e);
this.logger.error('failed to load url', e);
});
worker.webContents.addListener('did-finish-load', () => {
disconnectHelperProcess = helperProcessManager.connectRenderer(
disconnectHelperProcess = this.helperProcessManager.connectRenderer(
worker.webContents
);
record.loaded.resolve();
@ -90,10 +88,12 @@ export class WorkerManager {
await worker.loaded.promise;
worker.browserWindow.webContents.postMessage('worker-connect', { portId }, [
portForWorker,
]);
return { portForRenderer, portId };
worker.browserWindow.webContents.postMessage(
AFFINE_WORKER_CONNECT_CHANNEL_NAME,
{ portId },
[portForWorker]
);
return { portId, portForRenderer };
}
disconnectWorker(key: string, portId: string) {
@ -106,4 +106,19 @@ export class WorkerManager {
}
}
}
@IpcHandle({ scope: IpcScope.WORKER, name: 'connectWorker' })
async connectWorkerIpc(key: string, portId: string) {
const e = getIpcEvent();
const { portForRenderer } = await this.connectWorker(key, portId, e.sender);
e.sender.postMessage(AFFINE_WORKER_CONNECT_CHANNEL_NAME, { portId }, [
portForRenderer,
]);
return { portId };
}
@IpcHandle({ scope: IpcScope.WORKER, name: 'disconnectWorker' })
async disconnectWorkerIpc(key: string, portId: string) {
this.disconnectWorker(key, portId);
}
}

View File

@ -0,0 +1,30 @@
import { z } from 'zod';
type Schema =
| 'affine'
| 'affine-canary'
| 'affine-beta'
| 'affine-internal'
| 'affine-dev';
// todo: remove duplicated codes
const ReleaseTypeSchema = z.enum(['stable', 'beta', 'canary', 'internal']);
const envBuildType = (process.env.BUILD_TYPE || 'canary').trim().toLowerCase();
const buildType = ReleaseTypeSchema.parse(envBuildType);
const isDev = process.env.NODE_ENV === 'development';
let scheme =
buildType === 'stable' ? 'affine' : (`affine-${envBuildType}` as Schema);
scheme = isDev ? 'affine-dev' : scheme;
export const appInfo = {
electron: true,
windowName:
process.argv.find(arg => arg.startsWith('--window-name='))?.split('=')[1] ??
'unknown',
viewId:
process.argv.find(arg => arg.startsWith('--view-id='))?.split('=')[1] ??
'unknown',
scheme,
};
export type AppInfo = typeof appInfo;

View File

@ -0,0 +1,62 @@
import type { MessagePort } from 'node:worker_threads';
import { AsyncCall, type EventBasedChannel } from 'async-call-rpc';
import { ipcRenderer } from 'electron';
import { Subject } from 'rxjs';
import { AFFINE_HELPER_CONNECT_CHANNEL_NAME } from '../../ipc/constant';
import type { HelperToRenderer, RendererToHelper } from '../helper/types';
// Create a channel for MessagePort communication
const createMessagePortChannel = (port: MessagePort): EventBasedChannel => {
return {
on(listener) {
const listen = (e: MessageEvent) => {
listener(e.data);
};
port.addEventListener('message', listen as any);
port.start();
return () => {
port.removeEventListener('message', listen as any);
try {
port.close();
} catch (err) {
console.error('[helper] close port error', err);
}
};
},
send(data) {
port.postMessage(data);
},
};
};
export const helperEvents$ = new Subject<{ channel: string; args: any[] }>();
const rendererToHelperServer: RendererToHelper = {
postEvent: (channel, ...args) => {
helperEvents$.next({ channel, args });
},
};
const helperPortPromise = Promise.withResolvers<MessagePort>();
let connected = false;
// Setup for helper process APIs using MessagePort and AsyncCall RPC
ipcRenderer.on(AFFINE_HELPER_CONNECT_CHANNEL_NAME, event => {
if (connected) {
return;
}
console.info('[preload] helper-connection', event);
connected = true;
helperPortPromise.resolve(event.ports[0]);
});
// Helper process RPC setup
export const helperRpc = AsyncCall<HelperToRenderer>(rendererToHelperServer, {
channel: helperPortPromise.promise.then(port =>
createMessagePortChannel(port)
),
log: false,
});

View File

@ -1,14 +1,14 @@
import '@sentry/electron/preload';
import { contextBridge } from 'electron';
import { apis, appInfo, events } from './electron-api';
import { appInfo } from './api-info';
import { exposedEvents } from './ipc-events';
import { exposedApis } from './ipc-handlers';
import { sharedStorage } from './shared-storage';
import { listenWorkerApis } from './worker';
contextBridge.exposeInMainWorld('__appInfo', appInfo);
contextBridge.exposeInMainWorld('__apis', apis);
contextBridge.exposeInMainWorld('__events', events);
contextBridge.exposeInMainWorld('__apis', exposedApis);
contextBridge.exposeInMainWorld('__events', exposedEvents);
contextBridge.exposeInMainWorld('__sharedStorage', sharedStorage);
listenWorkerApis();

View File

@ -0,0 +1,101 @@
import { ipcRenderer } from 'electron';
import { AFFINE_IPC_EVENT_CHANNEL_NAME } from '../../ipc/constant';
import { eventsMeta } from './ipc-meta.gen';
// --- Main Process Event Handling ---
// Map to store all listeners by their full channel name
const mainListenersMap = new Map<string, Set<(...args: any[]) => void>>();
// Set up a single listener for all main process events
ipcRenderer.on(
AFFINE_IPC_EVENT_CHANNEL_NAME,
(_event: Electron.IpcRendererEvent, channel: string, ...args: any[]) => {
// Get all callbacks registered for this specific channel
const callbacks = mainListenersMap.get(channel);
if (callbacks) {
callbacks.forEach(callback => callback(...args));
}
}
);
const createMainEventHandler = (scope: string, eventName: string) => {
const channel = `${scope}:${eventName}`;
return (callback: (...args: any[]) => void): (() => void) => {
// Get or create the set of callbacks for this channel
if (!mainListenersMap.has(channel)) {
mainListenersMap.set(channel, new Set());
}
const callbacks = mainListenersMap.get(channel);
if (callbacks) {
callbacks.add(callback);
}
// Return an unsubscribe function
return () => {
const callbacks = mainListenersMap.get(channel);
if (callbacks) {
callbacks.delete(callback);
if (callbacks.size === 0) {
mainListenersMap.delete(channel);
}
}
};
};
};
// const createHelperEventHandler = (scope: string, eventName: string) => {
// const channel = `${scope}:${eventName}`;
// return (callback: (...args: any[]) => void): (() => void) => {
// // Subscribe to the helper events subject
// const subscription = helperEvents$.subscribe(
// ({ channel: eventChannel, args }) => {
// if (eventChannel === channel) {
// callback(...args);
// }
// }
// );
// // Return an unsubscribe function
// return () => {
// subscription.unsubscribe();
// };
// };
// };
// --- Process main events ---
const mainEvents = Object.fromEntries(
Object.entries(eventsMeta.main).map(([scope, eventNames]) => [
scope,
Object.fromEntries(
(eventNames as readonly string[]).map(eventName => {
// Construct the public method name, e.g., onMaximized
const onMethodName = `on${eventName.charAt(0).toUpperCase() + eventName.slice(1)}`;
return [onMethodName, createMainEventHandler(scope, eventName)];
})
),
])
);
// TODO: Implement helper events?
// const helperEvents = Object.fromEntries(
// Object.entries(eventsMeta.helper).map(([scope, eventNames]) => [
// scope,
// Object.fromEntries(
// (eventNames as readonly string[]).map(eventName => {
// // Construct the public method name, e.g., onMaximized
// const onMethodName = `on${eventName.charAt(0).toUpperCase() + eventName.slice(1)}`;
// return [onMethodName, createHelperEventHandler(scope, eventName)];
// })
// ),
// ])
// );
// --- Combine all events ---
export const exposedEvents = {
...mainEvents,
// ...helperEvents,
};

View File

@ -0,0 +1,55 @@
import { ipcRenderer } from 'electron';
import { AFFINE_IPC_API_CHANNEL_NAME } from '../../ipc/constant';
import { helperRpc } from './helper-rpc';
import { handlersMeta } from './ipc-meta.gen';
// Handler for main process APIs using ipcRenderer.invoke
const createMainApiHandler = (channel: string) => {
return (...args: any[]) => {
return ipcRenderer.invoke(AFFINE_IPC_API_CHANNEL_NAME, channel, ...args);
};
};
// Create helper API handler
const createHelperApiHandler = (channel: string) => {
return async (...args: any[]) => {
return (
helperRpc[channel]?.(...args) ??
Promise.reject(new Error(`Method ${channel} not found`))
);
};
};
// --- Construct the API object to be exposed ---
// Process main handlers
const mainApis = Object.fromEntries(
Object.entries(handlersMeta.main).map(([scope, methodNames]) => [
scope,
Object.fromEntries(
(methodNames as readonly string[]).map(methodName => [
methodName,
createMainApiHandler(`${scope}:${methodName}`),
])
),
])
);
// Process helper handlers
const helperApis = Object.fromEntries(
Object.entries(handlersMeta.helper).map(([scope, methodNames]) => [
scope,
Object.fromEntries(
(methodNames as readonly string[]).map(methodName => [
methodName,
createHelperApiHandler(`${scope}:${methodName}`),
])
),
])
);
// Combine all APIs
export const exposedApis = {
...mainApis,
...helperApis,
};

View File

@ -0,0 +1,177 @@
// AUTO-GENERATED FILE. DO NOT EDIT MANUALLY.
// Generated by: packages/frontend/apps/electron/scripts/generate-types.ts
/* eslint-disable @typescript-eslint/no-explicit-any */
export const handlersMeta = {
"helper": {
"dialog": [
"setFakeDialogResult",
"saveDBFileAs",
"loadDBFile",
"selectDBFileLocation"
],
"nbstore": [
"connect",
"disconnect",
"pushUpdate",
"getDocSnapshot",
"setDocSnapshot",
"getDocUpdates",
"markUpdatesMerged",
"deleteDoc",
"getDocClocks",
"getDocClock",
"getBlob",
"setBlob",
"deleteBlob",
"releaseBlobs",
"listBlobs",
"getPeerRemoteClocks",
"getPeerRemoteClock",
"setPeerRemoteClock",
"getPeerPulledRemoteClocks",
"getPeerPulledRemoteClock",
"setPeerPulledRemoteClock",
"getPeerPushedClocks",
"getPeerPushedClock",
"setPeerPushedClock",
"clearClocks",
"setBlobUploadedAt",
"getBlobUploadedAt"
],
"workspace": [
"deleteWorkspace",
"moveToTrash",
"getBackupWorkspaces",
"deleteBackupWorkspace"
],
"db": [
"getDocAsUpdates",
"getDocTimestamps",
"getBlob",
"getBlobKeys"
]
},
"main": {
"findInPage": [
"find",
"clear"
],
"ui": [
"handleCloseApp",
"restartApp",
"onLanguageChange",
"captureArea",
"writeImageToClipboard",
"getChallengeResponse",
"openExternal",
"openThemeEditor",
"showMainWindow",
"handleThemeChange",
"isFullScreen",
"isMaximized",
"handleMinimizeApp",
"handleHideApp",
"handleMaximizeApp",
"handleWindowResize",
"isActiveTab",
"getWorkbenchMeta",
"updateWorkbenchMeta",
"getTabViewsMeta",
"getTabsStatus",
"addTab",
"showTab",
"tabGoTo",
"closeTab",
"activateView",
"moveTab",
"toggleRightSidebar",
"pingAppLayoutReady",
"showTabContextMenu",
"updateActiveViewMeta"
],
"recording": [
"setupRecordingFeature",
"askForScreenRecordingPermission",
"getRecording",
"getCurrentRecording",
"startRecording",
"stopRecording",
"pauseRecording",
"resumeRecording",
"removeRecording",
"readyRecording",
"handleBlockCreationSuccess",
"handleBlockCreationFailed",
"disableRecordingFeature",
"getRawAudioBuffers",
"checkRecordingAvailable",
"checkMeetingPermissions",
"checkCanRecordMeeting",
"askForMeetingPermission",
"showRecordingPermissionSetting",
"showSavedRecordings"
],
"sharedStorage": [
"getAllGlobalState",
"setGlobalState",
"delGlobalState",
"clearGlobalState",
"getAllGlobalCache",
"setGlobalCache",
"delGlobalCache",
"clearGlobalCache"
],
"updater": [
"currentVersion",
"quitAndInstall",
"getConfig",
"setConfig",
"checkForUpdates",
"downloadUpdate"
],
"popup": [
"getCurrentNotification",
"dismissCurrentNotification",
"dismissCurrentRecording"
],
"worker": [
"connectWorker",
"disconnectWorker"
]
}
} as const;
export const eventsMeta = {
"main": {
"ui": [
"authenticationRequest",
"maximized",
"fullScreen",
"tabViewsMetaChange",
"tabsStatusChange",
"closeView",
"tabAction",
"activeTabChanged",
"toggleRightSidebar",
"tabShellViewActiveChange",
"tabGoToRequest"
],
"recording": [
"recordingStatusChanged"
],
"sharedStorage": [
"globalStateChanged",
"globalCacheChanged"
],
"updater": [
"updateAvailable",
"updateReady",
"downloadProgress"
],
"menu": [
"openInSettingModal",
"newPageAction",
"openJournal"
]
}
} as const;

View File

@ -1,22 +1,22 @@
import { MemoryMemento } from '@toeverything/infra';
import { MemoryMemento } from '@toeverything/infra/storage';
import { ipcRenderer } from 'electron';
import {
AFFINE_API_CHANNEL_NAME,
AFFINE_EVENT_CHANNEL_NAME,
} from '../shared/type';
AFFINE_IPC_API_CHANNEL_NAME,
AFFINE_IPC_EVENT_CHANNEL_NAME,
} from '../../ipc/constant';
const initialGlobalState = ipcRenderer.sendSync(
AFFINE_API_CHANNEL_NAME,
AFFINE_IPC_API_CHANNEL_NAME,
'sharedStorage:getAllGlobalState'
);
const initialGlobalCache = ipcRenderer.sendSync(
AFFINE_API_CHANNEL_NAME,
AFFINE_IPC_API_CHANNEL_NAME,
'sharedStorage:getAllGlobalCache'
);
function invokeWithCatch(key: string, ...args: any[]) {
ipcRenderer.invoke(AFFINE_API_CHANNEL_NAME, key, ...args).catch(err => {
ipcRenderer.invoke(AFFINE_IPC_API_CHANNEL_NAME, key, ...args).catch(err => {
console.error(`Failed to invoke ${key}`, err);
});
}
@ -32,7 +32,7 @@ function createSharedStorageApi(
) {
const memory = new MemoryMemento();
memory.setAll(init);
ipcRenderer.on(AFFINE_EVENT_CHANNEL_NAME, (_event, channel, updates) => {
ipcRenderer.on(AFFINE_IPC_EVENT_CHANNEL_NAME, (_event, channel, updates) => {
if (channel === `sharedStorage:${event}`) {
for (const [key, value] of Object.entries(updates)) {
if (value === undefined) {

View File

@ -1,7 +1,9 @@
import { ipcRenderer } from 'electron';
import { AFFINE_WORKER_CONNECT_CHANNEL_NAME } from '../../ipc/constant';
export function listenWorkerApis() {
ipcRenderer.on('worker-connect', (ev, data) => {
ipcRenderer.on(AFFINE_WORKER_CONNECT_CHANNEL_NAME, (ev, data) => {
const portForRenderer = ev.ports[0];
// @ts-expect-error this function should only be evaluated in the renderer process

View File

@ -1,283 +0,0 @@
import { parse } from 'node:path';
import { DocStorage, ValidationResult } from '@affine/native';
import { parseUniversalId } from '@affine/nbstore';
import fs from 'fs-extra';
import { nanoid } from 'nanoid';
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;
}
// provide a backdoor to set dialog path for testing in playwright
export interface FakeDialogResult {
canceled?: boolean;
filePath?: string;
filePaths?: string[];
}
// result will be used in the next call to showOpenDialog
// if it is being read once, it will be reset to undefined
let fakeDialogResult: FakeDialogResult | undefined = undefined;
function getFakedResult() {
const result = fakeDialogResult;
fakeDialogResult = undefined;
return result;
}
export function setFakeDialogResult(result: FakeDialogResult | undefined) {
fakeDialogResult = result;
// for convenience, we will fill filePaths with filePath if it is not set
if (result?.filePaths === undefined && result?.filePath !== undefined) {
result.filePaths = [result.filePath];
}
}
const extension = 'affine';
function getDefaultDBFileName(name: string, id: string) {
const fileName = `${name}_${id}.${extension}`;
// make sure fileName is a valid file name
return fileName.replace(/[/\\?%*:|"<>]/g, '-');
}
/**
* This function is called when the user clicks the "Save" button in the "Save Workspace" dialog.
*
* It will just copy the file to the given path
*/
export async function saveDBFileAs(
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
const fakedResult = getFakedResult();
if (!dbPath) {
return {
error: 'DB_FILE_PATH_INVALID',
};
}
const ret =
fakedResult ??
(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,
};
}
await fs.copyFile(dbPath, filePath);
logger.log('saved', filePath);
if (!fakedResult) {
mainRPC.showItemInFolder(filePath).catch(err => {
console.error(err);
});
}
return { filePath };
} catch (err) {
logger.error('saveDBFileAs', err);
return {
error: 'UNKNOWN_ERROR',
};
}
}
export async function selectDBFileLocation(): Promise<SelectDBFileLocationResult> {
try {
const ret =
getFakedResult() ??
(await mainRPC.showOpenDialog({
properties: ['openDirectory'],
title: 'Set Workspace Storage Location',
buttonLabel: 'Select',
defaultPath: await mainRPC.getPath('documents'),
message: "Select a location to store the workspace's database file",
}));
const dir = ret.filePaths?.[0];
if (ret.canceled || !dir) {
return {
canceled: true,
};
}
return { filePath: dir };
} catch (err) {
logger.error('selectDBFileLocation', err);
return {
error: (err as any).message,
};
}
}
/**
* This function is called when the user clicks the "Load" button in the "Load Workspace" dialog.
*
* It will
* - symlink the source db file to a new workspace id to app-data
* - return the new workspace id
*
* eg, it will create a new folder in app-data:
* <app-data>/<app-name>/<workspaces|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(
dbFilePath?: string
): Promise<LoadDBFileResult> {
try {
const provided =
getFakedResult() ??
(dbFilePath
? {
filePath: dbFilePath,
filePaths: [dbFilePath],
canceled: false,
}
: undefined);
const ret =
provided ??
(await mainRPC.showOpenDialog({
properties: ['openFile'],
title: 'Load Workspace',
buttonLabel: 'Load',
filters: [
{
name: 'SQLite Database',
// do we want to support other file format?
extensions: ['db', 'affine'],
},
],
message: 'Load Workspace from a AFFiNE file',
}));
const originalPath = ret.filePaths?.[0];
if (ret.canceled || !originalPath) {
logger.info('loadDBFile canceled');
return { canceled: true };
}
// the imported file should not be in app data dir
if (originalPath.startsWith(await getWorkspacesBasePath())) {
logger.warn('loadDBFile: db file in app data dir');
return { error: 'DB_FILE_PATH_INVALID' };
}
const 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);
}
// v2 import logic
const internalFilePath = await getSpaceDBPath(
'local',
'workspace',
workspaceId
);
await fs.ensureDir(parse(internalFilePath).dir);
await fs.copy(originalPath, internalFilePath);
logger.info(`loadDBFile, copy: ${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
}
// checkout to make sure wal is flushed
const connection = new SqliteConnection(originalPath);
await connection.connect();
await connection.checkpoint();
await connection.close();
const internalFilePath = await getWorkspaceDBPath('workspace', workspaceId);
await fs.ensureDir(await getWorkspacesBasePath());
await fs.copy(originalPath, internalFilePath);
logger.info(`loadDBFile, copy: ${originalPath} -> ${internalFilePath}`);
await storeWorkspaceMeta(workspaceId, {
id: workspaceId,
mainDBPath: internalFilePath,
});
return {
workspaceId,
};
}

View File

@ -1,23 +0,0 @@
import {
loadDBFile,
saveDBFileAs,
selectDBFileLocation,
setFakeDialogResult,
} from './dialog';
export const dialogHandlers = {
loadDBFile: async (dbFilePath?: string) => {
return loadDBFile(dbFilePath);
},
saveDBFileAs: async (universalId: string, name: string) => {
return saveDBFileAs(universalId, name);
},
selectDBFileLocation: async () => {
return selectDBFileLocation();
},
setFakeDialogResult: async (
result: Parameters<typeof setFakeDialogResult>[0]
) => {
return setFakeDialogResult(result);
},
};

View File

@ -1,37 +0,0 @@
import { dialogHandlers } from './dialog';
import { dbEventsV1, dbHandlersV1, nbstoreHandlers } from './nbstore';
import { provideExposed } from './provide';
import { workspaceEvents, workspaceHandlers } from './workspace';
export const handlers = {
db: dbHandlersV1,
nbstore: nbstoreHandlers,
workspace: workspaceHandlers,
dialog: dialogHandlers,
};
export const events = {
db: dbEventsV1,
workspace: workspaceEvents,
};
const getExposedMeta = () => {
const handlersMeta = Object.entries(handlers).map(
([namespace, namespaceHandlers]) => {
return [namespace, Object.keys(namespaceHandlers)] as [string, string[]];
}
);
const eventsMeta = Object.entries(events).map(
([namespace, namespaceHandlers]) => {
return [namespace, Object.keys(namespaceHandlers)] as [string, string[]];
}
);
return {
handlers: handlersMeta,
events: eventsMeta,
};
};
provideExposed(getExposedMeta());

View File

@ -1,82 +0,0 @@
import { AsyncCall } from 'async-call-rpc';
import type { RendererToHelper } from '../shared/type';
import { events, handlers } from './exposed';
import { logger } from './logger';
function setupRendererConnection(rendererPort: Electron.MessagePortMain) {
const flattenedHandlers = Object.entries(handlers).flatMap(
([namespace, namespaceHandlers]) => {
return Object.entries(namespaceHandlers).map(([name, handler]) => {
const handlerWithLog = async (...args: any[]) => {
try {
const start = performance.now();
const result = await handler(...args);
logger.debug(
'[async-api]',
`${namespace}.${name}`,
args.filter(
arg => typeof arg !== 'function' && typeof arg !== 'object'
),
'-',
(performance.now() - start).toFixed(2),
'ms'
);
return result;
} catch (error) {
logger.error('[async-api]', `${namespace}.${name}`, error);
}
};
return [`${namespace}:${name}`, handlerWithLog];
});
}
);
const rpc = AsyncCall<RendererToHelper>(
Object.fromEntries(flattenedHandlers),
{
channel: {
on(listener) {
const f = (e: Electron.MessageEvent) => {
listener(e.data);
};
rendererPort.on('message', f);
// MUST start the connection to receive messages
rendererPort.start();
return () => {
rendererPort.off('message', f);
};
},
send(data) {
rendererPort.postMessage(data);
},
},
log: false,
}
);
for (const [namespace, namespaceEvents] of Object.entries(events)) {
for (const [key, eventRegister] of Object.entries(namespaceEvents)) {
const unsub = eventRegister((...args: any[]) => {
const chan = `${namespace}:${key}`;
rpc.postEvent(chan, ...args).catch(err => {
console.error(err);
});
});
process.on('exit', () => {
unsub();
});
}
}
}
function main() {
process.parentPort.on('message', e => {
if (e.data.channel === 'renderer-connect' && e.ports.length === 1) {
const rendererPort = e.ports[0];
setupRendererConnection(rendererPort);
logger.debug('[helper] renderer connected');
}
});
}
main();

View File

@ -1,6 +0,0 @@
import log from 'electron-log/main';
export const logger = log.scope('helper');
log.transports.file.level = 'info';
log.transports.console.level = 'info';

View File

@ -1,34 +0,0 @@
import { AsyncCall } from 'async-call-rpc';
import type { HelperToMain, MainToHelper } from '../shared/type';
import { exposed } from './provide';
const helperToMainServer: HelperToMain = {
getMeta: () => {
if (!exposed) {
throw new Error('Helper is not initialized correctly');
}
return exposed;
},
};
export const mainRPC = AsyncCall<MainToHelper>(helperToMainServer, {
strict: {
unknownMessage: false,
},
channel: {
on(listener) {
const f = (e: Electron.MessageEvent) => {
listener(e.data);
};
process.parentPort.on('message', f);
return () => {
process.parentPort.off('message', f);
};
},
send(data) {
process.parentPort.postMessage(data);
},
},
log: false,
});

View File

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

View File

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

View File

@ -1,70 +0,0 @@
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-eslint/no-non-null-assertion
return db!;
}
export async function ensureSQLiteDB(
spaceType: SpaceType,
id: string
): Promise<WorkspaceSQLiteDB | null> {
const meta = await getWorkspaceMeta(spaceType, id);
// do not auto create v1 db anymore
if (!existsSync(meta.mainDBPath)) {
return null;
}
return getWorkspaceDB(spaceType, id);
}
export async function ensureSQLiteDisconnected(
spaceType: SpaceType,
id: string
) {
const db = await ensureSQLiteDB(spaceType, id);
if (db) {
await db.checkpoint();
await db.destroy();
}
}

View File

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

View File

@ -1,11 +0,0 @@
import type { ExposedMeta } from '../shared/type';
/**
* A naive DI implementation to get rid of circular dependency.
*/
export let exposed: ExposedMeta | undefined;
export const provideExposed = (exposedMeta: ExposedMeta) => {
exposed = exposedMeta;
};

View File

@ -1,233 +0,0 @@
import path from 'node:path';
import { DocStorage } from '@affine/native';
import {
parseUniversalId,
universalId as generateUniversalId,
} from '@affine/nbstore';
import fs from 'fs-extra';
import { applyUpdate, Doc as YDoc } from 'yjs';
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,
getSpaceDBPath,
getWorkspaceBasePathV1,
getWorkspaceMeta,
} from './meta';
async function deleteWorkspaceV1(workspaceId: string) {
try {
await ensureSQLiteDisconnected('workspace', workspaceId);
const basePath = await getWorkspaceBasePathV1('workspace', workspaceId);
await fs.rmdir(basePath, { recursive: true });
} 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.rmdir(path.dirname(dbPath), { recursive: true });
} 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, `${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.rmdir(path.dirname(dbPath), { recursive: true });
} 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);
}
}
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,
};
}
export async function deleteBackupWorkspace(id: string) {
const basePath = await getDeletedWorkspacesBasePath();
const workspacePath = path.join(basePath, id);
await fs.rmdir(workspacePath, { recursive: true });
logger.info(
'deleteBackupWorkspace',
`Deleted backup workspace: ${workspacePath}`
);
}

View File

@ -1,21 +0,0 @@
import type { MainEventRegister } from '../type';
import {
deleteBackupWorkspace,
deleteWorkspace,
getDeletedWorkspaces,
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),
};

View File

@ -1,101 +0,0 @@
import path from 'node:path';
import { type SpaceType } from '@affine/nbstore';
import { isWindows } 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
) {
return path.join(
await getAppDataPath(),
spaceType === 'userspace' ? 'userspaces' : 'workspaces',
isWindows() ? workspaceId.replace(':', '_') : workspaceId
);
}
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
) {
return path.join(
await getSpaceBasePath(spaceType),
escapeFilename(peer),
id,
'storage.db'
);
}
export async function getDeletedWorkspacesBasePath() {
return path.join(await getAppDataPath(), 'deleted-workspaces');
}
export async function getWorkspaceDBPath(
spaceType: SpaceType,
workspaceId: string
) {
return path.join(
await getWorkspaceBasePathV1(spaceType, workspaceId),
'storage.db'
);
}
export async function getWorkspaceMetaPath(
spaceType: SpaceType,
workspaceId: string
) {
return path.join(
await getWorkspaceBasePathV1(spaceType, workspaceId),
'meta.json'
);
}
/**
* Get workspace meta, create one if not exists
* This function will also migrate the workspace if needed
*/
export async function getWorkspaceMeta(
spaceType: SpaceType,
workspaceId: string
): Promise<WorkspaceMeta> {
const dbPath = await getWorkspaceDBPath(spaceType, workspaceId);
return {
mainDBPath: dbPath,
id: workspaceId,
};
}

View File

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

View File

@ -0,0 +1,21 @@
export enum IpcScope {
UI = 'ui',
MENU = 'menu',
UPDATER = 'updater',
POPUP = 'popup',
FIND_IN_PAGE = 'findInPage',
RECORDING = 'recording',
WORKER = 'worker',
WORKSPACE = 'workspace',
DIALOG = 'dialog',
NBSTORE = 'nbstore',
DB = 'db',
SHARED_STORAGE = 'sharedStorage',
}
export const AFFINE_IPC_API_CHANNEL_NAME = 'affine-ipc-api';
export const AFFINE_IPC_EVENT_CHANNEL_NAME = 'affine-ipc-event';
export const AFFINE_RENDERER_CONNECT_CHANNEL_NAME = 'renderer-connect';
export const AFFINE_HELPER_CONNECT_CHANNEL_NAME = 'helper-connect';
export const AFFINE_WORKER_CONNECT_CHANNEL_NAME = 'worker-connect';

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