Compare commits
2 Commits
canary
...
xp/04-24-r
Author | SHA1 | Date | |
---|---|---|---|
|
8f3035d7a3 | ||
|
68ab87f5c5 |
@ -198,7 +198,10 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["packages/backend/**/*.ts"],
|
||||
"files": [
|
||||
"packages/backend/**/*.ts",
|
||||
"packages/frontend/apps/electron/**/*.ts"
|
||||
],
|
||||
"rules": {
|
||||
"typescript/consistent-type-imports": "off"
|
||||
}
|
||||
|
@ -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> {}
|
@ -1,4 +1,3 @@
|
||||
export * from './app-config-storage';
|
||||
export * from './atom';
|
||||
export * from './framework';
|
||||
export * from './livedata';
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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',
|
||||
|
119
packages/frontend/apps/electron/scripts/generate-types.ts
Normal file
119
packages/frontend/apps/electron/scripts/generate-types.ts
Normal 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();
|
@ -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,
|
||||
};
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
@ -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
|
||||
}
|
111
packages/frontend/apps/electron/scripts/ipc-generator/utils.ts
Normal file
111
packages/frontend/apps/electron/scripts/ipc-generator/utils.ts
Normal 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;
|
||||
}
|
@ -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 {}
|
@ -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;
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
@ -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 {}
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
import { bootstrap } from './bootstrap';
|
||||
|
||||
bootstrap().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
@ -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 {}
|
@ -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 {}
|
@ -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 {}
|
@ -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);
|
||||
}
|
@ -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,
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
45
packages/frontend/apps/electron/src/entries/helper/types.ts
Normal file
45
packages/frontend/apps/electron/src/entries/helper/types.ts
Normal 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>;
|
||||
}
|
@ -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 {}
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
@ -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);
|
||||
}
|
||||
});
|
@ -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`);
|
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { HelperProcessManager } from './helper-process.service';
|
||||
|
||||
@Module({
|
||||
providers: [HelperProcessManager],
|
||||
exports: [HelperProcessManager],
|
||||
})
|
||||
export class HelperProcessModule {}
|
@ -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.');
|
||||
}
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
export * from './helper-process.module';
|
||||
export * from './helper-process.service';
|
@ -0,0 +1,7 @@
|
||||
import { bootstrap } from './bootstrap';
|
||||
import { logger } from './logger';
|
||||
|
||||
bootstrap().catch(err => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
@ -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 {}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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');
|
||||
}
|
||||
}
|
@ -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'),
|
@ -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';
|
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
@ -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' };
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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 {}
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -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();
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
export * from './storage.module';
|
||||
export * from './storage.service';
|
@ -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),
|
@ -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 {}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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 {
|
@ -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 {}
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
12
packages/frontend/apps/electron/src/entries/main/utils.ts
Normal file
12
packages/frontend/apps/electron/src/entries/main/utils.ts
Normal 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';
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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';
|
@ -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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
};
|
||||
}
|
@ -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();
|
@ -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 {}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
@ -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,
|
||||
});
|
@ -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();
|
@ -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,
|
||||
};
|
@ -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,
|
||||
};
|
@ -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;
|
@ -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) {
|
@ -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
|
@ -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,
|
||||
};
|
||||
}
|
@ -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);
|
||||
},
|
||||
};
|
@ -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());
|
@ -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();
|
@ -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';
|
@ -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,
|
||||
});
|
@ -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),
|
||||
};
|
@ -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';
|
@ -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();
|
||||
}
|
||||
}
|
@ -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>;
|
@ -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;
|
||||
};
|
@ -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}`
|
||||
);
|
||||
}
|
@ -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),
|
||||
};
|
@ -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,
|
||||
};
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import type { WorkspaceMeta } from '../type';
|
||||
|
||||
export const workspaceSubjects = {
|
||||
meta$: new Subject<{ workspaceId: string; meta: WorkspaceMeta }>(),
|
||||
};
|
21
packages/frontend/apps/electron/src/ipc/constant.ts
Normal file
21
packages/frontend/apps/electron/src/ipc/constant.ts
Normal 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
Loading…
x
Reference in New Issue
Block a user