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

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

View File

@@ -0,0 +1,304 @@
import { parse, resolve } from 'node:path';
import { DocStorage, ValidationResult } from '@affine/native';
import { parseUniversalId } from '@affine/nbstore';
import fs from 'fs-extra';
import { nanoid } from 'nanoid';
import { isPathInsideBase } from '../../shared/utils';
import { logger } from '../logger';
import { mainRPC } from '../main-rpc';
import { getDocStoragePool } from '../nbstore';
import { storeWorkspaceMeta } from '../workspace';
import {
getSpaceDBPath,
getWorkspaceDBPath,
getWorkspacesBasePath,
} from '../workspace/meta';
export type ErrorMessage =
| 'DB_FILE_PATH_INVALID'
| 'DB_FILE_INVALID'
| 'UNKNOWN_ERROR';
export interface LoadDBFileResult {
workspaceId?: string;
error?: ErrorMessage;
canceled?: boolean;
}
export interface SaveDBFileResult {
filePath?: string;
canceled?: boolean;
error?: ErrorMessage;
}
export interface SelectDBFileLocationResult {
filePath?: string;
error?: ErrorMessage;
canceled?: boolean;
}
const extension = 'affine';
function getDefaultDBFileName(name: string, id: string) {
const fileName = `${name}_${id}.${extension}`;
// make sure fileName is a valid file name
return fileName.replace(/[/\\?%*:|"<>]/g, '-');
}
async function resolveExistingPath(path: string) {
if (!(await fs.pathExists(path))) {
return null;
}
try {
return await fs.realpath(path);
} catch {
return resolve(path);
}
}
async function isSameFilePath(sourcePath: string, targetPath: string) {
if (resolve(sourcePath) === resolve(targetPath)) {
return true;
}
const [resolvedSourcePath, resolvedTargetPath] = await Promise.all([
resolveExistingPath(sourcePath),
resolveExistingPath(targetPath),
]);
return !!resolvedSourcePath && resolvedSourcePath === resolvedTargetPath;
}
async function normalizeImportDBPath(selectedPath: string) {
if (!(await fs.pathExists(selectedPath))) {
return null;
}
const [normalizedPath, workspacesBasePath] = await Promise.all([
resolveExistingPath(selectedPath),
resolveExistingPath(await getWorkspacesBasePath()),
]);
const resolvedSelectedPath = normalizedPath ?? resolve(selectedPath);
const resolvedWorkspacesBasePath =
workspacesBasePath ?? resolve(await getWorkspacesBasePath());
if (isPathInsideBase(resolvedWorkspacesBasePath, resolvedSelectedPath)) {
logger.warn('loadDBFile: db file in app data dir');
return null;
}
return resolvedSelectedPath;
}
/**
* This function is called when the user clicks the "Save" button in the "Save Workspace" dialog.
*
* It will export a compacted database file to the given path
*/
export async function saveDBFileAs(
universalId: string,
name: string
): Promise<SaveDBFileResult> {
try {
const { peer, type, id } = parseUniversalId(universalId);
const dbPath = await getSpaceDBPath(peer, type, id);
// connect to the pool and make sure all changes (WAL) are written to db
const pool = getDocStoragePool();
await pool.connect(universalId, dbPath);
await pool.checkpoint(universalId); // make sure all changes (WAL) are written to db
if (!dbPath) {
return {
error: 'DB_FILE_PATH_INVALID',
};
}
const ret = await mainRPC.showSaveDialog({
properties: ['showOverwriteConfirmation'],
title: 'Save Workspace',
showsTagField: false,
buttonLabel: 'Save',
filters: [
{
extensions: [extension],
name: '',
},
],
defaultPath: getDefaultDBFileName(name, id),
message: 'Save Workspace as a SQLite Database file',
});
const filePath = ret.filePath;
if (ret.canceled || !filePath) {
return { canceled: true };
}
if (await isSameFilePath(dbPath, filePath)) {
return { error: 'DB_FILE_PATH_INVALID' };
}
const tempFilePath = `${filePath}.${nanoid(6)}.tmp`;
if (await fs.pathExists(tempFilePath)) {
await fs.remove(tempFilePath);
}
try {
await pool.vacuumInto(universalId, tempFilePath);
await fs.move(tempFilePath, filePath, { overwrite: true });
} finally {
if (await fs.pathExists(tempFilePath)) {
await fs.remove(tempFilePath);
}
}
logger.log('saved', filePath);
mainRPC.showItemInFolder(filePath).catch(err => {
console.error(err);
});
return { filePath };
} catch (err) {
logger.error('saveDBFileAs', err);
return {
error: 'UNKNOWN_ERROR',
};
}
}
export async function selectDBFileLocation(): Promise<SelectDBFileLocationResult> {
try {
const ret = await mainRPC.showOpenDialog({
properties: ['openDirectory'],
title: 'Set Workspace Storage Location',
buttonLabel: 'Select',
defaultPath: await mainRPC.getPath('documents'),
message: "Select a location to store the workspace's database file",
});
const dir = ret.filePaths?.[0];
if (ret.canceled || !dir) {
return {
canceled: true,
};
}
return { filePath: dir };
} catch (err) {
logger.error('selectDBFileLocation', err);
return {
error: (err as any).message,
};
}
}
/**
* This function is called when the user clicks the "Load" button in the "Load Workspace" dialog.
*
* It will
* - symlink the source db file to a new workspace id to app-data
* - return the new workspace id
*
* eg, it will create a new folder in app-data:
* <app-data>/<app-name>/<workspaces|userspaces>/<peer>/<workspace-id>/storage.db
*
* On the renderer side, after the UI got a new workspace id, it will
* update the local workspace id list and then connect to it.
*
*/
export async function loadDBFile(): Promise<LoadDBFileResult> {
try {
const ret = await mainRPC.showOpenDialog({
properties: ['openFile'],
title: 'Load Workspace',
buttonLabel: 'Load',
filters: [
{
name: 'SQLite Database',
// do we want to support other file format?
extensions: ['db', 'affine'],
},
],
message: 'Load Workspace from a AFFiNE file',
});
const selectedPath = ret.filePaths?.[0];
if (ret.canceled || !selectedPath) {
logger.info('loadDBFile canceled');
return { canceled: true };
}
const originalPath = await normalizeImportDBPath(selectedPath);
if (!originalPath) {
return { error: 'DB_FILE_PATH_INVALID' };
}
const workspaceId = nanoid(10);
let storage = new DocStorage(originalPath);
// if imported db is not a valid v2 db, we will treat it as a v1 db
if (!(await storage.validate())) {
return await cpV1DBFile(originalPath, workspaceId);
}
if (!(await storage.validateImportSchema())) {
return { error: 'DB_FILE_INVALID' };
}
// v2 import logic
const internalFilePath = await getSpaceDBPath(
'local',
'workspace',
workspaceId
);
await fs.ensureDir(parse(internalFilePath).dir);
await storage.vacuumInto(internalFilePath);
logger.info(`loadDBFile, vacuum: ${originalPath} -> ${internalFilePath}`);
storage = new DocStorage(internalFilePath);
await storage.setSpaceId(workspaceId);
return {
workspaceId,
};
} catch (err) {
logger.error('loadDBFile', err);
return {
error: 'UNKNOWN_ERROR',
};
}
}
async function cpV1DBFile(
originalPath: string,
workspaceId: string
): Promise<LoadDBFileResult> {
const { SqliteConnection } = await import('@affine/native');
const validationResult = await SqliteConnection.validate(originalPath);
if (validationResult !== ValidationResult.Valid) {
return { error: 'DB_FILE_INVALID' }; // invalid db file
}
const connection = new SqliteConnection(originalPath);
try {
if (!(await connection.validateImportSchema())) {
return { error: 'DB_FILE_INVALID' };
}
const internalFilePath = await getWorkspaceDBPath('workspace', workspaceId);
await fs.ensureDir(parse(internalFilePath).dir);
await connection.vacuumInto(internalFilePath);
logger.info(`loadDBFile, vacuum: ${originalPath} -> ${internalFilePath}`);
await storeWorkspaceMeta(workspaceId, {
id: workspaceId,
mainDBPath: internalFilePath,
});
return {
workspaceId,
};
} finally {
await connection.close();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,266 @@
import type { InsertRow } from '@affine/native';
import { SqliteConnection } from '@affine/native';
import type { ByteKVBehavior } from '@toeverything/infra/storage';
import { logger } from '../../logger';
/**
* A base class for SQLite DB adapter that provides basic methods around updates & blobs
*/
export class SQLiteAdapter {
db: SqliteConnection | null = null;
constructor(public readonly path: string) {}
async connectIfNeeded() {
if (!this.db) {
this.db = new SqliteConnection(this.path);
await this.db.connect();
logger.info(`[SQLiteAdapter]`, 'connected:', this.path);
}
return this.db;
}
async destroy() {
const { db } = this;
this.db = null;
// log after close will sometimes crash the app when quitting
logger.info(`[SQLiteAdapter]`, 'destroyed:', this.path);
await db?.close();
}
async addBlob(key: string, data: Uint8Array) {
try {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return;
}
await this.db.addBlob(key, data);
} catch (error) {
logger.error('addBlob', error);
}
}
async getBlob(key: string) {
try {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return null;
}
const blob = await this.db.getBlob(key);
return blob?.data ?? null;
} catch (error) {
logger.error('getBlob', error);
return null;
}
}
async deleteBlob(key: string) {
try {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return;
}
await this.db.deleteBlob(key);
} catch (error) {
logger.error(`${this.path} delete blob failed`, error);
}
}
async getBlobKeys() {
try {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return [];
}
return await this.db.getBlobKeys();
} catch (error) {
logger.error(`getBlobKeys failed`, error);
return [];
}
}
async getUpdates(docId?: string) {
try {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return [];
}
return await this.db.getUpdates(docId);
} catch (error) {
logger.error('getUpdates', error);
return [];
}
}
async getAllUpdates() {
try {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return [];
}
return await this.db.getAllUpdates();
} catch (error) {
logger.error('getAllUpdates', error);
return [];
}
}
// add a single update to SQLite
async addUpdateToSQLite(updates: InsertRow[]) {
// batch write instead write per key stroke?
try {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return;
}
const start = performance.now();
await this.db.insertUpdates(updates);
logger.debug(
`[SQLiteAdapter] addUpdateToSQLite`,
'length:',
updates.length,
'docids',
updates.map(u => u.docId),
performance.now() - start,
'ms'
);
} catch (error) {
logger.error('addUpdateToSQLite', this.path, error);
}
}
async deleteUpdates(docId?: string) {
try {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return;
}
await this.db.deleteUpdates(docId);
} catch (error) {
logger.error('deleteUpdates', error);
}
}
async checkpoint() {
try {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return;
}
await this.db.checkpoint();
} catch (error) {
logger.error('checkpoint', error);
}
}
async getUpdatesCount(docId?: string) {
try {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return 0;
}
return await this.db.getUpdatesCount(docId);
} catch (error) {
logger.error('getUpdatesCount', error);
return 0;
}
}
async replaceUpdates(docId: string | null | undefined, updates: InsertRow[]) {
try {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return;
}
await this.db.replaceUpdates(docId, updates);
} catch (error) {
logger.error('replaceUpdates', error);
}
}
serverClock: ByteKVBehavior = {
get: async key => {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return null;
}
const blob = await this.db.getServerClock(key);
return blob?.data ?? null;
},
set: async (key, data) => {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return;
}
await this.db.setServerClock(key, data);
},
keys: async () => {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return [];
}
return await this.db.getServerClockKeys();
},
del: async key => {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return;
}
await this.db.delServerClock(key);
},
clear: async () => {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return;
}
await this.db.clearServerClock();
},
};
syncMetadata: ByteKVBehavior = {
get: async key => {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return null;
}
const blob = await this.db.getSyncMetadata(key);
return blob?.data ?? null;
},
set: async (key, data) => {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return;
}
await this.db.setSyncMetadata(key, data);
},
keys: async () => {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return [];
}
return await this.db.getSyncMetadataKeys();
},
del: async key => {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return;
}
await this.db.delSyncMetadata(key);
},
clear: async () => {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return;
}
await this.db.clearSyncMetadata();
},
};
async getDocTimestamps() {
if (!this.db) {
logger.warn(`${this.path} is not connected`);
return [];
}
return await this.db.getDocTimestamps();
}
}

View File

@@ -0,0 +1,70 @@
import { existsSync } from 'node:fs';
import type { SpaceType } from '@affine/nbstore';
import { logger } from '../../logger';
import { getWorkspaceMeta } from '../../workspace/meta';
import type { WorkspaceSQLiteDB } from './workspace-db-adapter';
import { openWorkspaceDatabase } from './workspace-db-adapter';
// export for testing
export const db$Map = new Map<
`${SpaceType}:${string}`,
Promise<WorkspaceSQLiteDB>
>();
async function getWorkspaceDB(spaceType: SpaceType, id: string) {
const cacheId = `${spaceType}:${id}` as const;
let db = await db$Map.get(cacheId);
if (!db) {
const promise = openWorkspaceDatabase(spaceType, id);
db$Map.set(cacheId, promise);
const _db = (db = await promise);
const cleanup = () => {
db$Map.delete(cacheId);
_db
.destroy()
.then(() => {
logger.info('[ensureSQLiteDB] db connection closed', _db.workspaceId);
})
.catch(err => {
logger.error('[ensureSQLiteDB] destroy db failed', err);
});
};
db.update$.subscribe({
complete: cleanup,
});
process.on('beforeExit', cleanup);
}
// oxlint-disable-next-line typescript/no-non-null-assertion
return db!;
}
export async function ensureSQLiteDB(
spaceType: SpaceType,
id: string
): Promise<WorkspaceSQLiteDB | null> {
const meta = await getWorkspaceMeta(spaceType, id);
// do not auto create v1 db anymore
if (!existsSync(meta.mainDBPath)) {
return null;
}
return getWorkspaceDB(spaceType, id);
}
export async function ensureSQLiteDisconnected(
spaceType: SpaceType,
id: string
) {
const db = await ensureSQLiteDB(spaceType, id);
if (db) {
await db.checkpoint();
await db.destroy();
}
}

View File

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

View File

@@ -0,0 +1,17 @@
import { applyUpdate, Doc as YDoc, encodeStateAsUpdate, transact } from 'yjs';
export function mergeUpdate(updates: Uint8Array[]) {
if (updates.length === 0) {
return new Uint8Array();
}
if (updates.length === 1) {
return updates[0];
}
const yDoc = new YDoc();
transact(yDoc, () => {
for (const update of updates) {
applyUpdate(yDoc, update);
}
});
return encodeStateAsUpdate(yDoc);
}

View File

@@ -0,0 +1,157 @@
import type { SpaceType } from '@affine/nbstore';
import { AsyncLock } from '@toeverything/infra/utils';
import { Subject } from 'rxjs';
import { applyUpdate, Doc as YDoc } from 'yjs';
import { logger } from '../../logger';
import { getWorkspaceMeta } from '../../workspace/meta';
import { SQLiteAdapter } from './db-adapter';
import { mergeUpdate } from './merge-update';
const TRIM_SIZE = 1;
export class WorkspaceSQLiteDB implements AsyncDisposable {
lock = new AsyncLock();
update$ = new Subject<void>();
adapter = new SQLiteAdapter(this.path);
constructor(
public path: string,
public workspaceId: string
) {}
async transaction<T>(cb: () => Promise<T>): Promise<T> {
using _lock = await this.lock.acquire();
return await cb();
}
async destroy() {
await this.adapter.destroy();
// when db is closed, we can safely remove it from ensure-db list
this.update$.complete();
}
[Symbol.asyncDispose] = async () => {
await this.destroy();
};
private readonly toDBDocId = (docId: string) => {
return this.workspaceId === docId ? undefined : docId;
};
getWorkspaceMeta = async () => {
const ydoc = new YDoc();
const updates = await this.adapter.getUpdates();
updates.forEach(update => {
applyUpdate(ydoc, update.data);
});
logger.log(
`ydoc.getMap('meta').get('name')`,
ydoc.getMap('meta').get('name'),
this.path,
updates.length
);
return ydoc.getMap('meta').toJSON();
};
getWorkspaceName = async () => {
const meta = await this.getWorkspaceMeta();
return meta.name;
};
async init() {
const db = await this.adapter.connectIfNeeded();
await this.tryTrim();
return db;
}
async get(docId: string) {
return this.adapter.getUpdates(docId);
}
// getUpdates then encode
getDocAsUpdates = async (docId: string) => {
const dbID = this.toDBDocId(docId);
const update = await this.tryTrim(dbID);
if (update) {
return update;
} else {
const updates = await this.adapter.getUpdates(dbID);
return mergeUpdate(updates.map(row => row.data));
}
};
async getDocTimestamps() {
return this.adapter.getDocTimestamps();
}
async addBlob(key: string, value: Uint8Array) {
this.update$.next();
const res = await this.adapter.addBlob(key, value);
return res;
}
async getBlob(key: string) {
return this.adapter.getBlob(key);
}
async getBlobKeys() {
return this.adapter.getBlobKeys();
}
async deleteBlob(key: string) {
this.update$.next();
await this.adapter.deleteBlob(key);
}
async addUpdateToSQLite(update: Uint8Array, subdocId: string) {
this.update$.next();
await this.transaction(async () => {
const dbID = this.toDBDocId(subdocId);
const oldUpdate = await this.adapter.getUpdates(dbID);
await this.adapter.replaceUpdates(dbID, [
{
data: mergeUpdate([...oldUpdate.map(u => u.data), update]),
docId: dbID,
},
]);
});
}
async deleteUpdate(subdocId: string) {
this.update$.next();
await this.adapter.deleteUpdates(this.toDBDocId(subdocId));
}
private readonly tryTrim = async (dbID?: string) => {
const count = (await this.adapter?.getUpdatesCount(dbID)) ?? 0;
if (count > TRIM_SIZE) {
return await this.transaction(async () => {
logger.debug(`trim ${this.workspaceId}:${dbID} ${count}`);
const updates = await this.adapter.getUpdates(dbID);
const update = mergeUpdate(updates.map(row => row.data));
const insertRows = [{ data: update, docId: dbID }];
await this.adapter?.replaceUpdates(dbID, insertRows);
logger.debug(`trim ${this.workspaceId}:${dbID} successfully`);
return update;
});
}
return null;
};
async checkpoint() {
await this.adapter.checkpoint();
}
}
export async function openWorkspaceDatabase(
spaceType: SpaceType,
spaceId: string
) {
const meta = await getWorkspaceMeta(spaceType, spaceId);
const db = new WorkspaceSQLiteDB(meta.mainDBPath, spaceId);
await db.init();
logger.info(`openWorkspaceDatabase [${spaceId}]`);
return db;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,103 @@
import path from 'node:path';
import { type SpaceType } from '@affine/nbstore';
import { normalizeWorkspaceIdForPath } from '../../shared/utils';
import { mainRPC } from '../main-rpc';
import type { WorkspaceMeta } from '../type';
let _appDataPath = '';
export async function getAppDataPath() {
if (_appDataPath) {
return _appDataPath;
}
_appDataPath = await mainRPC.getPath('sessionData');
return _appDataPath;
}
export async function getWorkspacesBasePath() {
return path.join(await getAppDataPath(), 'workspaces');
}
export async function getWorkspaceBasePathV1(
spaceType: SpaceType,
workspaceId: string
) {
const safeWorkspaceId = normalizeWorkspaceIdForPath(workspaceId);
return path.join(
await getAppDataPath(),
spaceType === 'userspace' ? 'userspaces' : 'workspaces',
safeWorkspaceId
);
}
export async function getSpaceBasePath(spaceType: SpaceType) {
return path.join(
await getAppDataPath(),
spaceType === 'userspace' ? 'userspaces' : 'workspaces'
);
}
export function escapeFilename(name: string) {
// replace all special characters with '_' and replace repeated '_' with a single '_' and remove trailing '_'
return name
.replaceAll(/[\\/!@#$%^&*()+~`"':;,?<>|]/g, '_')
.split('_')
.filter(Boolean)
.join('_');
}
export async function getSpaceDBPath(
peer: string,
spaceType: SpaceType,
id: string
) {
const safeId = normalizeWorkspaceIdForPath(id);
return path.join(
await getSpaceBasePath(spaceType),
escapeFilename(peer),
safeId,
'storage.db'
);
}
export async function getDeletedWorkspacesBasePath() {
return path.join(await getAppDataPath(), 'deleted-workspaces');
}
export async function getWorkspaceDBPath(
spaceType: SpaceType,
workspaceId: string
) {
return path.join(
await getWorkspaceBasePathV1(spaceType, workspaceId),
'storage.db'
);
}
export async function getWorkspaceMetaPath(
spaceType: SpaceType,
workspaceId: string
) {
return path.join(
await getWorkspaceBasePathV1(spaceType, workspaceId),
'meta.json'
);
}
/**
* Get workspace meta, create one if not exists
* This function will also migrate the workspace if needed
*/
export async function getWorkspaceMeta(
spaceType: SpaceType,
workspaceId: string
): Promise<WorkspaceMeta> {
const dbPath = await getWorkspaceDBPath(spaceType, workspaceId);
return {
mainDBPath: dbPath,
id: workspaceId,
};
}

View File

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

View File

@@ -0,0 +1,294 @@
import { app, Menu } from 'electron';
import { isMacOS } from '../../shared/utils';
import { logger, revealLogFile } from '../logger';
import { uiSubjects } from '../ui/subject';
import { checkForUpdates } from '../updater';
import {
addTab,
initAndShowMainWindow,
reloadView,
showMainWindow,
switchTab,
switchToNextTab,
switchToPreviousTab,
undoCloseTab,
WebContentViewsManager,
} from '../windows-manager';
import { popupManager } from '../windows-manager/popup';
import { WorkerManager } from '../worker/pool';
import { applicationMenuSubjects } from './subject';
// Unique id for menuitems
const MENUITEM_NEW_PAGE = 'affine:new-page';
export function createApplicationMenu() {
const isMac = isMacOS();
// Electron menu cannot be modified
// You have to copy the complete default menu template event if you want to add a single custom item
// See https://www.electronjs.org/docs/latest/api/menu#examples
const template = [
// { role: 'appMenu' }
...(isMac
? [
{
label: app.name,
submenu: [
{
label: `About ${app.getName()}`,
click: async () => {
await showMainWindow();
applicationMenuSubjects.openInSettingModal$.next({
activeTab: 'about',
});
},
},
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
},
]
: []),
// { role: 'fileMenu' }
{
label: 'File',
submenu: [
{
id: MENUITEM_NEW_PAGE,
label: 'New Doc',
accelerator: isMac ? 'Cmd+N' : 'Ctrl+N',
click: async () => {
await initAndShowMainWindow();
// fixme: if the window is just created, the new page action will not be triggered
applicationMenuSubjects.newPageAction$.next('default');
},
},
],
},
// { role: 'editMenu' }
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
...(isMac
? [
{ role: 'pasteAndMatchStyle' },
{ role: 'delete' },
{ role: 'selectAll' },
{ type: 'separator' },
{
label: 'Speech',
submenu: [{ role: 'startSpeaking' }, { role: 'stopSpeaking' }],
},
]
: [{ role: 'delete' }, { type: 'separator' }, { role: 'selectAll' }]),
],
},
// { role: 'viewMenu' }
{
label: 'View',
submenu: [
{
label: 'Reload',
accelerator: 'CommandOrControl+R',
click() {
reloadView().catch(console.error);
},
},
{
role: 'windowMenu',
},
{
label: 'Open devtools',
accelerator: isMac ? 'Cmd+Option+I' : 'Ctrl+Shift+I',
click: () => {
const workerContents = Array.from(
WorkerManager.instance.workers.values()
).map(
worker => [worker.key, worker.browserWindow.webContents] as const
);
const tabs = Array.from(
WebContentViewsManager.instance.tabViewsMap
).map(view => {
const isActive = WebContentViewsManager.instance.isActiveTab(
view[0]
);
return [
view[0] + (isActive ? ' (active)' : ''),
view[1].webContents,
] as const;
});
const popups = Array.from(popupManager.popupWindows$.value.values())
.filter(popup => popup.browserWindow)
.map(popup => {
// oxlint-disable-next-line no-non-null-assertion
return [popup.type, popup.browserWindow!.webContents] as const;
});
const allWebContents = [
['tabs', tabs],
['workers', workerContents],
['popups', popups],
] as const;
Menu.buildFromTemplate(
allWebContents.flatMap(([type, contents]) => {
return [
{
label: type,
enabled: false,
},
...contents.map(([id, webContents]) => ({
label: id,
click: () => {
webContents.openDevTools({
mode: 'undocked',
});
},
})),
{ type: 'separator' },
];
})
).popup();
},
},
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
...(!isMacOS()
? [{ role: 'zoomIn', accelerator: 'Ctrl+=', visible: false }]
: []),
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
{ type: 'separator' },
{
label: 'New tab',
accelerator: 'CommandOrControl+T',
click() {
logger.info('New tab with shortcut');
addTab().catch(console.error);
},
},
{
label: 'Close view',
accelerator: 'CommandOrControl+W',
click() {
logger.info('Close view with shortcut');
// tell the active workbench to close the current view
uiSubjects.onCloseView$.next();
},
},
{
label: 'Undo close tab',
accelerator: 'CommandOrControl+Shift+T',
click() {
logger.info('Undo close tab with shortcut');
undoCloseTab().catch(console.error);
},
},
...[1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => {
const shortcut = `CommandOrControl+${n}`;
const listener = () => {
switchTab(n);
};
return {
acceleratorWorksWhenHidden: true,
label: `Switch to tab ${n}`,
accelerator: shortcut,
click: listener,
visible: false,
};
}),
{
label: 'Switch to next tab',
accelerator: 'Control+Tab',
click: () => {
switchToNextTab();
},
},
{
label: 'Switch to previous tab',
accelerator: 'Control+Shift+Tab',
click: () => {
switchToPreviousTab();
},
},
{
label: 'Switch to next tab (mac 2)',
accelerator: 'Alt+Command+]',
visible: false,
click: () => {
switchToNextTab();
},
},
{
label: 'Switch to previous tab (mac 2)',
accelerator: 'Alt+Command+[',
visible: false,
click: () => {
switchToPreviousTab();
},
},
],
},
{
role: 'help',
submenu: [
{
label: 'Learn More',
click: async () => {
// oxlint-disable-next-line no-var-requires
const { shell } = require('electron');
await shell.openExternal('https://affine.pro/');
},
},
{
label: 'Open log file',
click: async () => {
await revealLogFile();
},
},
{
label: 'Check for Updates',
click: async () => {
await initAndShowMainWindow();
await checkForUpdates();
},
},
{
label: 'Documentation',
click: async () => {
// oxlint-disable-next-line no-var-requires
const { shell } = require('electron');
await shell.openExternal(
'https://docs.affine.pro/docs/hello-bonjour-aloha-你好'
);
},
},
],
},
];
// @ts-expect-error: The snippet is copied from Electron official docs.
// It's working as expected. No idea why it contains type errors.
// Just ignore for now.
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
return menu;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
import type { NamespaceHandlers } from '../type';
import { persistentConfig } from './persist';
/**
* @deprecated use shared storage
*/
export const configStorageHandlers = {
get: async () => persistentConfig.get(),
set: async (_, v) => persistentConfig.set(v),
} satisfies NamespaceHandlers;

View File

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

View File

@@ -0,0 +1,20 @@
import fs from 'node:fs';
import path from 'node:path';
import {
AppConfigStorage,
defaultAppConfig,
} from '@toeverything/infra/app-config-storage';
import { app } from 'electron';
const FILENAME = 'config.json';
const FILEPATH = path.join(app.getPath('userData'), FILENAME);
/**
* @deprecated use shared storage
*/
export const persistentConfig = new AppConfigStorage({
config: defaultAppConfig,
get: () => JSON.parse(fs.readFileSync(FILEPATH, 'utf-8')),
set: data => fs.writeFileSync(FILEPATH, JSON.stringify(data, null, 2)),
});

View File

@@ -0,0 +1,24 @@
import { z } from 'zod';
export const ReleaseTypeSchema = z.enum([
'stable',
'beta',
'canary',
'internal',
]);
declare global {
// THIS variable should be replaced during the build process
const REPLACE_ME_BUILD_ENV: string;
}
export const envBuildType = (process.env.BUILD_TYPE || REPLACE_ME_BUILD_ENV)
.trim()
.toLowerCase();
export const overrideSession = process.env.BUILD_TYPE === 'internal';
export const buildType = ReleaseTypeSchema.parse(envBuildType);
export const mode = process.env.NODE_ENV;
export const isDev = mode === 'development';

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
import { allEvents as events } from './events';
import { allHandlers as handlers } from './handlers';
// this will be used by preload script to expose all handlers and events to the renderer process
// - register in exposeInMainWorld in preload
// - provide type hints
export { events, handlers };
export const getExposedMeta = () => {
const handlersMeta = Object.entries(handlers).map(
([namespace, namespaceHandlers]) => {
return [namespace, Object.keys(namespaceHandlers)];
}
);
const eventsMeta = Object.entries(events).map(
([namespace, namespaceHandlers]) => {
return [namespace, Object.keys(namespaceHandlers)];
}
);
return {
handlers: handlersMeta,
events: eventsMeta,
};
};
export type MainIPCHandlerMap = typeof handlers;
export type MainIPCEventMap = typeof events;

View File

@@ -0,0 +1,19 @@
import type { NamespaceHandlers } from '../type';
export const findInPageHandlers = {
find: async (event, text: string, options?: Electron.FindInPageOptions) => {
const { promise, resolve } =
Promise.withResolvers<Electron.Result | null>();
const webContents = event.sender;
let requestId: number = -1;
webContents.once('found-in-page', (_, result) => {
resolve(result.requestId === requestId ? result : null);
});
requestId = webContents.findInPage(text, options);
return promise;
},
clear: async event => {
const webContents = event.sender;
webContents.stopFindInPage('keepSelection');
},
} satisfies NamespaceHandlers;

View File

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

View File

@@ -0,0 +1,117 @@
import { I18n } from '@affine/i18n';
import { ipcMain } from 'electron';
import { AFFINE_API_CHANNEL_NAME } from '../shared/type';
import { authHandlers } from './auth/handlers';
import { byokStorageHandlers } from './byok-storage/handlers';
import { clipboardHandlers } from './clipboard';
import { configStorageHandlers } from './config-storage';
import { findInPageHandlers } from './find-in-page';
import { importHandlers } from './import';
import { getLogFilePath, logger, revealLogFile } from './logger';
import { recordingHandlers } from './recording';
import { checkSource } from './security-restrictions';
import { sharedStorageHandlers } from './shared-storage';
import { uiHandlers } from './ui/handlers';
import { updaterHandlers } from './updater';
import { popupHandlers } from './windows-manager/popup';
import { workerHandlers } from './worker/handlers';
export const debugHandlers = {
revealLogFile: async () => {
return revealLogFile();
},
logFilePath: async () => {
return getLogFilePath();
},
};
export const i18nHandlers = {
changeLanguage: async (_: Electron.IpcMainInvokeEvent, language: string) => {
return I18n.changeLanguage(language);
},
};
// Note: all of these handlers will be the single-source-of-truth for the apis exposed to the renderer process
export const allHandlers = {
debug: debugHandlers,
ui: uiHandlers,
clipboard: clipboardHandlers,
updater: updaterHandlers,
configStorage: configStorageHandlers,
findInPage: findInPageHandlers,
import: importHandlers,
sharedStorage: sharedStorageHandlers,
worker: workerHandlers,
recording: recordingHandlers,
popup: popupHandlers,
i18n: i18nHandlers,
byokStorage: byokStorageHandlers,
auth: authHandlers,
};
export const registerHandlers = () => {
const handleIpcMessage = async (
e: Electron.IpcMainInvokeEvent,
...args: any[]
) => {
// args[0] is the `{namespace:key}`
if (!checkSource(e) || typeof args[0] !== 'string') {
logger.error('invalid ipc message', args);
return;
}
const channel = args[0] as string;
const [namespace, key] = channel.split(':');
if (!namespace || !key) {
logger.error('invalid ipc message', args);
return;
}
// @ts-expect-error - ignore here
const handler = allHandlers[namespace]?.[key];
if (!handler) {
logger.error('handler not found for ', args[0]);
return;
}
const start = Date.now();
const realArgs = args.slice(1);
const result = await handler(e, ...realArgs);
logger.debug(
'[ipc-api]',
channel,
realArgs.filter(
arg => typeof arg !== 'function' && typeof arg !== 'object'
),
'-',
Date.now() - start,
'ms'
);
return result;
};
ipcMain.handle(AFFINE_API_CHANNEL_NAME, async (e, ...args: any[]) => {
try {
return await handleIpcMessage(e, ...args);
} catch (error) {
logger.error(`error in ipc handler when calling ${args[0]}`, error);
throw error;
}
});
ipcMain.on(AFFINE_API_CHANNEL_NAME, (e, ...args: any[]) => {
if (!checkSource(e)) return;
handleIpcMessage(e, ...args)
.then(ret => {
e.returnValue = ret;
})
.catch(() => {
// never throw
});
});
};

View File

@@ -0,0 +1,147 @@
import path from 'node:path';
import type { _AsyncVersionOf } from 'async-call-rpc';
import { AsyncCall } from 'async-call-rpc';
import type {
BaseWindow,
OpenDialogOptions,
SaveDialogOptions,
UtilityProcess,
WebContents,
} from 'electron';
import {
app,
dialog,
MessageChannelMain,
shell,
utilityProcess,
} from 'electron';
import type { HelperToMain, MainToHelper } from '../shared/type';
import { MessageEventChannel } from '../shared/utils';
import { beforeAppQuit } from './cleanup';
import { logger } from './logger';
import { openExternalSafely } from './security/open-external';
const HELPER_PROCESS_PATH = path.join(__dirname, './helper.js');
const isDev = process.env.NODE_ENV === 'development';
function pickAndBind<T extends object, U extends keyof T>(
obj: T,
keys: U[]
): { [K in U]: T[K] } {
return keys.reduce((acc, key) => {
const prop = obj[key];
acc[key] = typeof prop === 'function' ? prop.bind(obj) : prop;
return acc;
}, {} as any);
}
class HelperProcessManager {
ready: Promise<void>;
readonly #process: UtilityProcess;
// a rpc server for the main process -> helper process
rpc?: _AsyncVersionOf<HelperToMain>;
static _instance: HelperProcessManager | null = null;
static get instance() {
if (!this._instance) {
this._instance = new HelperProcessManager();
}
return this._instance;
}
private constructor() {
const helperProcess = utilityProcess.fork(HELPER_PROCESS_PATH, [], {
// todo: port number should not being used
execArgv: isDev ? ['--inspect=40894'] : [],
serviceName: 'affine-helper',
});
this.#process = helperProcess;
this.ready = new Promise((resolve, reject) => {
helperProcess.once('spawn', () => {
try {
logger.info('[helper] forked', helperProcess.pid);
resolve();
} catch (err) {
logger.error('[helper] connectMain error', err);
reject(err);
}
});
});
beforeAppQuit(() => {
this.#process.kill();
});
this.#process.on('exit', code => {
logger.error('[helper] process exited', { code });
HelperProcessManager._instance = null;
});
this.#process.on('error', err => {
logger.error('[helper] process error', err);
HelperProcessManager._instance = null;
});
}
// bridge renderer <-> helper process
connectRenderer(renderer: WebContents) {
// connect to the helper process
const { port1: helperPort, port2: rendererPort } = new MessageChannelMain();
this.#process.postMessage({ channel: 'renderer-connect' }, [helperPort]);
renderer.postMessage('helper-connection', null, [rendererPort]);
return () => {
try {
helperPort.close();
rendererPort.close();
} catch (err) {
logger.error('[helper] close port error', err);
}
};
}
// bridge main <-> helper process
// also set up the RPC to the helper process
connectMain(window: BaseWindow) {
const dialogMethods = {
showOpenDialog: async (opts: OpenDialogOptions) => {
return dialog.showOpenDialog(window, opts);
},
showSaveDialog: async (opts: SaveDialogOptions) => {
return dialog.showSaveDialog(window, opts);
},
};
const shellMethods = {
openExternal: openExternalSafely as typeof shell.openExternal,
showItemInFolder: shell.showItemInFolder.bind(shell),
};
const appMethods = pickAndBind(app, ['getPath']);
const mainToHelperServer: MainToHelper = {
...dialogMethods,
...shellMethods,
...appMethods,
};
this.rpc = AsyncCall<HelperToMain>(mainToHelperServer, {
strict: {
// the channel is shared for other purposes as well so that we do not want to
// restrict to only JSONRPC messages
unknownMessage: false,
},
channel: new MessageEventChannel(this.#process),
log: false,
});
}
}
export async function ensureHelperProcess() {
const helperProcessManager = HelperProcessManager.instance;
await helperProcessManager.ready;
return helperProcessManager;
}

View File

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

View File

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

View File

@@ -0,0 +1,152 @@
import path from 'node:path';
import * as Sentry from '@sentry/electron/main';
import { IPCMode } from '@sentry/electron/main';
import { app, protocol } from 'electron';
import { createApplicationMenu } from './application-menu/create';
import { buildType, isDev, overrideSession } from './config';
import { persistentConfig } from './config-storage/persist';
import { setupDeepLink } from './deep-link';
import { registerEvents } from './events';
import { registerHandlers } from './handlers';
import { logger } from './logger';
import { registerProtocol } from './protocol';
import { setupRecordingFeature } from './recording/feature';
import { registerSecurityRestrictions } from './security-restrictions';
import { setupTrayState } from './tray';
import { registerUpdater } from './updater';
import { launch } from './windows-manager/launcher';
import { launchStage } from './windows-manager/stage';
app.enableSandbox();
if (isDev) {
// In electron the dev server will be resolved to 0.0.0.0, but it
// might be blocked by electron.
// See https://github.com/webpack/webpack-dev-server/pull/384
app.commandLine.appendSwitch('host-resolver-rules', 'MAP 0.0.0.0 127.0.0.1');
}
// https://github.com/electron/electron/issues/43556
// // `CalculateNativeWinOcclusion` - Disable native window occlusion tracker (https://groups.google.com/a/chromium.org/g/embedder-dev/c/ZF3uHHyWLKw/m/VDN2hDXMAAAJ)
const disabledFeatures = [
'PlzDedicatedWorker',
'CalculateNativeWinOcclusion',
// Disable Chrome autofill and password save prompts
'AutofillServerCommunication',
'AutofillProfileCleanup',
'AutofillAddressProfileSavePrompt',
'AutofillPaymentCards',
'AutofillEnableAccountWalletStorage',
'SavePasswordBubble',
].join(',');
app.commandLine.appendSwitch('disable-features', disabledFeatures);
app.commandLine.appendSwitch('disable-blink-features', 'Autofill');
// Following features are enabled from the runtime:
// `DocumentPolicyIncludeJSCallStacksInCrashReports` - https://www.electronjs.org/docs/latest/api/web-frame-main#framecollectjavascriptcallstack-experimental
// `EarlyEstablishGpuChannel` - Refs https://issues.chromium.org/issues/40208065
// `EstablishGpuChannelAsync` - Refs https://issues.chromium.org/issues/40208065
const enabledFeatures = [
'DocumentPolicyIncludeJSCallStacksInCrashReports',
'EarlyEstablishGpuChannel',
'EstablishGpuChannelAsync',
].join(',');
app.commandLine.appendSwitch('enable-features', enabledFeatures);
const enabledBlinkFeatures = ['CSSTextAutoSpace', 'WebCodecs'].join(',');
app.commandLine.appendSwitch('enable-blink-features', enabledBlinkFeatures);
app.commandLine.appendSwitch('force-color-profile', 'srgb');
// use the same data for internal & beta for testing
if (overrideSession) {
const appName = buildType === 'stable' ? 'AFFiNE' : `AFFiNE-${buildType}`;
const userDataPath = path.join(app.getPath('appData'), appName);
app.setPath('userData', userDataPath);
app.setPath('sessionData', userDataPath);
}
// oxlint-disable-next-line typescript/no-var-requires
if (require('electron-squirrel-startup')) app.quit();
if (process.env.SKIP_ONBOARDING) {
launchStage.value = 'main';
persistentConfig.set({
onBoarding: false,
});
}
/**
* Prevent multiple instances
*/
const isSingleInstance = app.requestSingleInstanceLock();
if (!isSingleInstance) {
logger.info(
'Another instance is running or responding deep link, exiting...'
);
app.quit();
process.exit(0);
}
/**
* Shout down background process if all windows was closed
*/
app.on('window-all-closed', () => {
app.quit();
});
/**
* @see https://www.electronjs.org/docs/latest/api/app#event-activate-macos Event: 'activate'
*/
app.on('activate', () => {
if (app.isReady()) {
launch().catch(e => console.error('Failed launch:', e));
}
});
setupDeepLink(app);
registerSecurityRestrictions();
/**
* Create app window when background process will be ready
*/
app
.whenReady()
.then(registerProtocol)
.then(registerHandlers)
.then(registerEvents)
.then(launch)
.then(createApplicationMenu)
.then(registerUpdater)
.then(setupRecordingFeature)
.then(setupTrayState)
.catch(e => console.error('Failed create window:', e));
if (process.env.SENTRY_RELEASE) {
// https://docs.sentry.io/platforms/javascript/guides/electron/
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.BUILD_TYPE ?? 'development',
ipcMode: IPCMode.Protocol,
transportOptions: {
maxAgeDays: 30,
maxQueueSize: 100,
},
});
Sentry.setTags({
distribution: 'electron',
appVersion: app.getVersion(),
});
}
protocol.registerSchemesAsPrivileged([
{
scheme: 'assets',
privileges: {
secure: true,
corsEnabled: true,
supportFetchAPI: true,
standard: true,
stream: true,
},
},
]);

View File

@@ -0,0 +1,25 @@
import { app, shell } from 'electron';
import log from 'electron-log/main';
import { isDev } from './config';
export const logger = log.scope('main');
log.initialize({
preload: false,
});
log.transports.file.level = 'info';
log.transports.console.level = isDev ? 'info' : false;
export function getLogFilePath() {
return log.transports.file.getFile().path;
}
export async function revealLogFile() {
const filePath = getLogFilePath();
return await shell.openPath(filePath);
}
app.on('before-quit', () => {
log.transports.console.level = false;
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,142 @@
import fs from 'node:fs';
import path from 'node:path';
import type { Memento } from '@toeverything/infra';
import {
backoffRetry,
effect,
exhaustMapWithTrailing,
fromPromise,
} from '@toeverything/infra';
import { debounceTime, Observable, timeout } from 'rxjs';
import { logger } from '../logger';
export class PersistentJSONFileStorage 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) {
try {
this.data = JSON.parse(fs.readFileSync(filepath, 'utf-8'));
} catch (err) {
// ignore ENOENT error
if (
!(
err &&
typeof err === 'object' &&
'code' in err &&
err.code === 'ENOENT'
)
) {
logger.error('failed to load file', err);
}
}
}
get<T>(key: string): T | undefined {
return this.data[key];
}
all(): Record<string, any> {
return this.data;
}
watch<T>(key: string): Observable<T | undefined> {
const subs = this.subscriptions.get(key) || new Set();
this.subscriptions.set(key, subs);
return new Observable<T | undefined>(subscriber => {
const sub = (p: any) => subscriber.next(p);
subs.add(sub);
return () => {
subs.delete(sub);
};
});
}
watchAll(): Observable<Record<string, unknown | undefined>> {
return new Observable<Record<string, unknown | undefined>>(subscriber => {
const sub = (p: Record<string, unknown | undefined>) =>
subscriber.next(p);
this.subscriptionAll.add(sub);
return () => {
this.subscriptionAll.delete(sub);
};
});
}
set<T>(key: string, value: T): void {
this.data[key] = value;
const subs = this.subscriptions.get(key) || new Set();
for (const sub of subs) {
sub(value);
}
for (const sub of this.subscriptionAll) {
sub({
[key]: this.data[key],
});
}
this.save();
}
del(key: string): void {
delete this.data[key];
const subs = this.subscriptions.get(key) || new Set();
for (const sub of subs) {
sub(undefined);
}
for (const sub of this.subscriptionAll) {
sub({
[key]: undefined,
});
}
this.save();
}
clear(): void {
const oldData = this.data;
this.data = {};
for (const [_, subs] of this.subscriptions) {
for (const sub of subs) {
sub(undefined);
}
}
for (const sub of this.subscriptionAll) {
sub(
Object.fromEntries(
Object.entries(oldData).map(([key]) => [key, undefined])
)
);
}
this.save();
}
keys(): string[] {
return Object.keys(this.data);
}
save = effect(
debounceTime(1000),
exhaustMapWithTrailing(() => {
return fromPromise(async () => {
try {
await fs.promises.mkdir(path.dirname(this.filepath), {
recursive: true,
});
await fs.promises.writeFile(
this.filepath,
JSON.stringify(this.data, null, 2),
'utf-8'
);
} catch (err) {
logger.error(`failed to save file, ${this.filepath}`, err);
}
}).pipe(
timeout(5000),
backoffRetry({
count: Infinity,
})
);
})
);
dispose() {
this.save.unsubscribe();
}
}

View File

@@ -0,0 +1,13 @@
import path from 'node:path';
import { app } from 'electron';
import { PersistentJSONFileStorage } from './json-file';
export const globalStateStorage = new PersistentJSONFileStorage(
path.join(app.getPath('userData'), 'global-state.json')
);
export const globalCacheStorage = new PersistentJSONFileStorage(
path.join(app.getPath('userData'), 'global-cache.json')
);

View File

@@ -0,0 +1,13 @@
import { join } from 'node:path';
import { resourcesPath } from '../../shared/utils';
export const icons = {
record: join(resourcesPath, 'icons/waveform.png'),
recording: join(resourcesPath, 'icons/waveform-recording.png'),
tray: join(resourcesPath, 'icons/tray-icon.png'),
journal: join(resourcesPath, 'icons/journal-today.png'),
page: join(resourcesPath, 'icons/doc-page.png'),
edgeless: join(resourcesPath, 'icons/doc-edgeless.png'),
monitor: join(resourcesPath, 'icons/monitor.png'),
};

View File

@@ -0,0 +1,395 @@
import {
app,
Menu,
MenuItem,
type MenuItemConstructorOptions,
type NativeImage,
nativeImage,
Tray,
} 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,
getCurrentRecordingStatus,
MeetingsSettingsState,
startRecording,
stopRecording,
updateApplicationsPing$,
} from '../recording/feature';
import { MenubarStateKey, MenubarStateSchema } from '../shared-state-schema';
import { globalStateStorage } from '../shared-storage/storage';
import { showMainWindow } from '../windows-manager';
import { icons } from './icons';
export interface TrayMenuConfigItem {
label: string;
click?: () => void;
icon?: NativeImage | string | Buffer;
disabled?: boolean;
submenu?: TrayMenuConfig;
}
export type TrayMenuConfig = Array<TrayMenuConfigItem | 'separator'>;
// each provider has a unique key and provides a menu config (a group of menu items)
interface TrayMenuProvider {
key: string;
getConfig(): TrayMenuConfig;
}
function activateMainWindow() {
void showMainWindow().catch(err => {
logger.error('Failed to show main window:', err);
});
}
function buildMenuConfig(config: TrayMenuConfig): MenuItemConstructorOptions[] {
const menuConfig: MenuItemConstructorOptions[] = [];
config.forEach(item => {
if (item === 'separator') {
menuConfig.push({ type: 'separator' });
} else {
const { icon, disabled, submenu, ...rest } = item;
let nativeIcon: NativeImage | undefined;
if (typeof icon === 'string') {
nativeIcon = nativeImage.createFromPath(icon);
} else if (Buffer.isBuffer(icon)) {
nativeIcon = nativeImage.createFromBuffer(icon);
}
if (nativeIcon) {
nativeIcon = nativeIcon.resize({ width: 20, height: 20 });
// string icon should be template image
if (typeof icon === 'string') {
nativeIcon.setTemplateImage(true);
}
}
const submenuConfig = submenu ? buildMenuConfig(submenu) : undefined;
menuConfig.push({
...rest,
enabled: !disabled,
icon: nativeIcon,
submenu: submenuConfig,
});
}
});
return menuConfig;
}
class TrayState implements Disposable {
tray: Tray | null = null;
// tray's icon
icon: NativeImage = nativeImage
.createFromPath(icons.tray)
.resize({ width: 18, height: 18 });
// tray's tooltip
tooltip: string = 'AFFiNE';
constructor() {
this.icon.setTemplateImage(true);
this.init();
}
// sorry, no idea on better naming
getPrimaryMenuProvider(): TrayMenuProvider {
return {
key: 'primary',
getConfig: () => [
{
label: 'Open Journal',
icon: icons.journal,
click: () => {
logger.info('User action: Open Journal');
activateMainWindow();
applicationMenuSubjects.openJournal$.next();
},
},
{
label: 'New Page',
icon: icons.page,
click: () => {
logger.info('User action: New Page');
activateMainWindow();
applicationMenuSubjects.newPageAction$.next('page');
},
},
{
label: 'New Edgeless',
icon: icons.edgeless,
click: () => {
logger.info('User action: New Edgeless');
activateMainWindow();
applicationMenuSubjects.newPageAction$.next('edgeless');
},
},
],
};
}
getRecordingMenuProvider(): TrayMenuProvider | null {
if (!checkRecordingAvailable()) {
return null;
}
const getConfig = () => {
const items: TrayMenuConfig = [];
if (!MeetingsSettingsState.value.enabled) {
items.push({
label: 'Meetings are disabled',
disabled: true,
});
} else if (!checkCanRecordMeeting()) {
items.push({
label: 'Required permissions not granted',
disabled: true,
});
} else {
const appGroups = appGroups$.value;
const runningAppGroups = appGroups.filter(
appGroup => appGroup.isRunning
);
const recordingStatus = getCurrentRecordingStatus();
if (
!recordingStatus ||
(recordingStatus.status !== 'starting' &&
recordingStatus.status !== 'recording' &&
recordingStatus.status !== 'finalizing')
) {
const appMenuItems = runningAppGroups.map(appGroup => ({
label: appGroup.name,
icon: appGroup.icon || undefined,
click: () => {
logger.info(
`User action: Start Recording Meeting (${appGroup.name})`
);
startRecording(appGroup).catch(err => {
logger.error('Failed to start recording:', err);
});
},
}));
items.push(
{
label: 'Start Recording Meeting',
icon: icons.record,
submenu: [
{
label: 'System audio (all audio will be recorded)',
icon: icons.monitor,
click: () => {
logger.info(
'User action: Start Recording Meeting (System audio)'
);
startRecording().catch(err => {
logger.error('Failed to start recording:', err);
});
},
},
...appMenuItems,
],
},
...appMenuItems
);
} else {
const recordingLabel = recordingStatus.appName
? `Recording (${recordingStatus.appName})`
: 'Recording';
// recording is active
items.push(
{
label: recordingLabel,
icon: icons.recording,
disabled: true,
},
{
label: 'Stop',
disabled: recordingStatus.status !== 'recording',
click: () => {
logger.info('User action: Stop Recording');
if (recordingStatus.status === 'recording') {
stopRecording(recordingStatus.id).catch(err => {
logger.error('Failed to stop recording:', err);
});
}
},
}
);
}
}
if (checkRecordingAvailable()) {
items.push({
label: `Meetings Settings...`,
click: () => {
activateMainWindow();
applicationMenuSubjects.openInSettingModal$.next({
activeTab: 'meetings',
});
},
});
}
return items;
};
return {
key: 'recording',
getConfig,
};
}
getSecondaryMenuProvider(): TrayMenuProvider {
return {
key: 'secondary',
getConfig: () => [
{
label: 'Open AFFiNE',
click: () => {
logger.info('User action: Open AFFiNE');
activateMainWindow();
},
},
{
label: 'Menubar settings...',
click: () => {
activateMainWindow();
applicationMenuSubjects.openInSettingModal$.next({
activeTab: 'appearance',
scrollAnchor: 'menubar',
});
},
},
{
label: `About ${app.getName()}`,
click: () => {
activateMainWindow();
applicationMenuSubjects.openInSettingModal$.next({
activeTab: 'about',
});
},
},
'separator',
{
label: 'Quit AFFiNE Completely...',
click: () => {
logger.info('User action: Quit AFFiNE Completely');
app.quit();
},
},
],
};
}
buildMenu(providers: TrayMenuProvider[]) {
const menu = new Menu();
providers.forEach((provider, index) => {
const config = provider.getConfig();
buildMenuConfig(config).forEach(item => {
menu.append(new MenuItem(item));
});
if (index !== providers.length - 1) {
menu.append(new MenuItem({ type: 'separator' }));
}
});
return menu;
}
disposables: (() => void)[] = [];
[Symbol.dispose]() {
this.disposables.forEach(d => d());
}
update() {
if (!this.tray) {
this.tray = new Tray(this.icon);
this.tray.setToolTip(this.tooltip);
const clickHandler = () => {
logger.debug('User clicked on tray icon');
this.update();
if (!isMacOS()) {
if (
TraySettingsState.value.enabled &&
TraySettingsState.value.openOnLeftClick
) {
activateMainWindow();
} else {
this.tray?.popUpContextMenu();
}
}
updateApplicationsPing$.next(Date.now());
};
this.tray.on('click', clickHandler);
const appGroupsSubscription = appGroups$.subscribe(() => {
logger.debug('App groups updated, refreshing tray menu');
this.update();
});
this.disposables.push(() => {
this.tray?.off('click', clickHandler);
this.tray?.destroy();
appGroupsSubscription.unsubscribe();
});
}
const providers = [
this.getPrimaryMenuProvider(),
this.getRecordingMenuProvider(),
this.getSecondaryMenuProvider(),
].filter(p => p !== null);
const menu = this.buildMenu(providers);
this.tray.setContextMenu(menu);
}
init() {
logger.info('Initializing tray');
this.update();
}
}
const TraySettingsState = {
$: globalStateStorage.watch<MenubarStateSchema>(MenubarStateKey).pipe(
map(v => MenubarStateSchema.parse(v ?? {})),
shareReplay(1)
),
get value() {
return MenubarStateSchema.parse(
globalStateStorage.get(MenubarStateKey) ?? {}
);
},
};
export const setupTrayState = () => {
let _trayState: TrayState | undefined;
if (TraySettingsState.value.enabled) {
_trayState = new TrayState();
}
const updateTrayState = (state: MenubarStateSchema) => {
if (state.enabled) {
if (!_trayState) {
_trayState = new TrayState();
}
} else {
_trayState?.[Symbol.dispose]();
_trayState = undefined;
}
};
const subscription = TraySettingsState.$.subscribe(updateTrayState);
beforeAppQuit(() => {
subscription.unsubscribe();
});
};

View File

@@ -0,0 +1,10 @@
export type MainEventRegister = (...args: any[]) => () => void;
export type IsomorphicHandler = (
e: Electron.IpcMainInvokeEvent,
...args: any[]
) => Promise<any>;
export type NamespaceHandlers = {
[key: string]: IsomorphicHandler;
};

View File

@@ -0,0 +1,7 @@
import { mintChallengeResponse } from '@affine/native';
export const getChallengeResponse = async (resource: 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(resource, 20);
};

View File

@@ -0,0 +1,57 @@
import type { MainEventRegister } from '../type';
import {
type AuthenticationRequest,
onActiveTabChanged,
onTabAction,
onTabShellViewActiveChange,
onTabsStatusChange,
onTabViewsMetaChanged,
} from '../windows-manager';
import { uiSubjects } from './subject';
/**
* Events triggered by application menu
*/
export const uiEvents = {
onMaximized: (fn: (maximized: boolean) => void) => {
const sub = uiSubjects.onMaximized$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
onFullScreen: (fn: (fullScreen: boolean) => void) => {
const sub = uiSubjects.onFullScreen$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
onTabViewsMetaChanged,
onTabAction,
onToggleRightSidebar: (fn: (tabId: string) => void) => {
const sub = uiSubjects.onToggleRightSidebar$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
onTabsStatusChange,
onActiveTabChanged,
onTabGoToRequest: (fn: (opts: { tabId: string; to: string }) => void) => {
const sub = uiSubjects.tabGoToRequest$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
onTabShellViewActiveChange,
onAuthenticationRequest: (fn: (state: AuthenticationRequest) => void) => {
const sub = uiSubjects.authenticationRequest$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
onCloseView: (fn: () => void) => {
const sub = uiSubjects.onCloseView$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
} satisfies Record<string, MainEventRegister>;

View File

@@ -0,0 +1,236 @@
import { app, clipboard, nativeImage, nativeTheme } from 'electron';
import { map, shareReplay } from 'rxjs';
import { isMacOS } from '../../shared/utils';
import { persistentConfig } from '../config-storage/persist';
import { logger } from '../logger';
import { openExternalSafely } from '../security/open-external';
import type { WorkbenchViewMeta } from '../shared-state-schema';
import { MenubarStateKey, MenubarStateSchema } from '../shared-state-schema';
import { globalStateStorage } from '../shared-storage/storage';
import type { NamespaceHandlers } from '../type';
import {
activateView,
addTab,
closeMainWindowToBackground,
closeTab,
ensureTabLoaded,
getMainWindow,
getOnboardingWindow,
getTabsStatus,
getTabViewsMeta,
getWorkbenchMeta,
handleWebContentsResize,
initAndShowMainWindow,
isActiveTab,
launchStage,
moveTab,
pingAppLayoutReady,
showDevTools,
showTab,
updateActiveViewMeta,
updateWorkbenchMeta,
updateWorkbenchViewMeta,
} from '../windows-manager';
import { showTabContextMenu } from '../windows-manager/context-menu';
import { getOrCreateCustomThemeWindow } from '../windows-manager/custom-theme-window';
import { getChallengeResponse } from './challenge';
import { uiSubjects } from './subject';
const TraySettingsState = {
$: globalStateStorage.watch<MenubarStateSchema>(MenubarStateKey).pipe(
map(v => MenubarStateSchema.parse(v ?? {})),
shareReplay(1)
),
get value() {
return MenubarStateSchema.parse(
globalStateStorage.get(MenubarStateKey) ?? {}
);
},
};
export const uiHandlers = {
isMaximized: async () => {
const window = await getMainWindow();
return window?.isMaximized();
},
isFullScreen: async () => {
const window = await getMainWindow();
return window?.isFullScreen();
},
handleThemeChange: async (_, theme: (typeof nativeTheme)['themeSource']) => {
nativeTheme.themeSource = theme;
},
handleMinimizeApp: async () => {
const window = await getMainWindow();
if (
TraySettingsState.value.enabled &&
TraySettingsState.value.minimizeToTray
) {
window?.hide();
} else {
window?.minimize();
}
},
handleMaximizeApp: async () => {
const window = await getMainWindow();
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();
}
},
handleWindowResize: async e => {
await handleWebContentsResize(e.sender);
},
handleCloseApp: async () => {
if (
TraySettingsState.value.enabled &&
TraySettingsState.value.closeToTray
) {
await closeMainWindowToBackground();
} else {
app.quit();
}
},
handleHideApp: async () => {
const window = await getMainWindow();
window?.hide();
},
getChallengeResponse: async (_, challenge: string) => {
return getChallengeResponse(challenge);
},
handleOpenMainApp: async () => {
if (launchStage.value === 'onboarding') {
launchStage.value = 'main';
persistentConfig.patch('onBoarding', false);
}
try {
const onboarding = await getOnboardingWindow();
onboarding?.hide();
await initAndShowMainWindow();
// need to destroy onboarding window after main window is ready
// otherwise the main window will be closed as well
onboarding?.destroy();
} catch (err) {
logger.error('handleOpenMainApp', err);
}
},
openExternal(_, url: string) {
return openExternalSafely(url);
},
// tab handlers
isActiveTab: async e => {
return isActiveTab(e.sender);
},
getWorkbenchMeta: async (_, ...args: Parameters<typeof getWorkbenchMeta>) => {
return getWorkbenchMeta(...args);
},
updateWorkbenchMeta: async (
_,
...args: Parameters<typeof updateWorkbenchMeta>
) => {
return updateWorkbenchMeta(...args);
},
updateWorkbenchViewMeta: async (
_,
...args: Parameters<typeof updateWorkbenchViewMeta>
) => {
return updateWorkbenchViewMeta(...args);
},
getTabViewsMeta: async () => {
return getTabViewsMeta();
},
updateActiveViewMeta: async (e, meta: Partial<WorkbenchViewMeta>) => {
return updateActiveViewMeta(e.sender, meta);
},
getTabsStatus: async () => {
return getTabsStatus();
},
addTab: async (_, ...args: Parameters<typeof addTab>) => {
await addTab(...args);
},
showTab: async (_, ...args: Parameters<typeof showTab>) => {
await showTab(...args);
},
tabGoTo: async (_, tabId: string, to: string) => {
uiSubjects.tabGoToRequest$.next({ tabId, to });
},
ensureTabLoaded: async (_, ...args: Parameters<typeof ensureTabLoaded>) => {
await ensureTabLoaded(...args);
},
closeTab: async (_, ...args: Parameters<typeof closeTab>) => {
await closeTab(...args);
},
activateView: async (_, ...args: Parameters<typeof activateView>) => {
await activateView(...args);
},
moveTab: async (_, ...args: Parameters<typeof moveTab>) => {
moveTab(...args);
},
toggleRightSidebar: async (_, tabId?: string) => {
tabId ??= getTabViewsMeta().activeWorkbenchId;
if (tabId) {
uiSubjects.onToggleRightSidebar$.next(tabId);
}
},
pingAppLayoutReady: async (e, ready = true) => {
pingAppLayoutReady(e.sender, ready);
},
showDevTools: async (_, ...args: Parameters<typeof showDevTools>) => {
return showDevTools(...args);
},
showTabContextMenu: async (_, tabKey: string, viewIndex: number) => {
return showTabContextMenu(tabKey, viewIndex);
},
openThemeEditor: async () => {
const win = await getOrCreateCustomThemeWindow();
win.show();
win.focus();
},
restartApp: async () => {
app.relaunch();
app.quit();
},
onLanguageChange: async (e, language: string) => {
// 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']);
}
},
captureArea: async (e, { x, y, width, height }: Electron.Rectangle) => {
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));
},
} satisfies NamespaceHandlers;

View File

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

View File

@@ -0,0 +1,13 @@
import { Subject } from 'rxjs';
import type { AuthenticationRequest } from '../windows-manager';
export const uiSubjects = {
onMaximized$: new Subject<boolean>(),
onFullScreen$: new Subject<boolean>(),
onToggleRightSidebar$: new Subject<string>(),
tabGoToRequest$: new Subject<{ tabId: string; to: string }>(),
authenticationRequest$: new Subject<AuthenticationRequest>(),
// via menu -> close view (CMD+W)
onCloseView$: new Subject<void>(),
};

View File

@@ -0,0 +1,286 @@
// credits: migrated from https://github.com/electron-userland/electron-builder/blob/master/packages/electron-updater/src/providers/GitHubProvider.ts
import {
CancellationToken,
type CustomPublishOptions,
newError,
type UpdateInfo,
} from 'builder-util-runtime';
import type { AppUpdater, ResolvedUpdateFileInfo } from 'electron-updater';
import { Provider } from 'electron-updater';
import type { ProviderRuntimeOptions } from 'electron-updater/out/providers/Provider';
import {
getFileList,
parseUpdateInfo,
} from 'electron-updater/out/providers/Provider';
import type { buildType } from '../config';
import { isSquirrelBuild } from './utils';
interface GithubUpdateInfo extends UpdateInfo {
tag: string;
}
interface GithubRelease {
url: string;
name: string;
tag_name: string;
body: string;
draft: boolean;
prerelease: boolean;
created_at: string;
published_at: string;
assets: Array<{
name: string;
url: string;
size: number;
}>;
}
interface UpdateProviderOptions {
feedUrl?: string;
channel: typeof buildType;
}
export class AFFiNEUpdateProvider extends Provider<GithubUpdateInfo> {
static configFeed(options: UpdateProviderOptions): CustomPublishOptions {
return {
provider: 'custom',
feedUrl: 'https://affine.pro/api/worker/releases',
updateProvider: AFFiNEUpdateProvider,
...options,
};
}
constructor(
private readonly options: CustomPublishOptions,
_updater: AppUpdater,
runtimeOptions: ProviderRuntimeOptions
) {
super(runtimeOptions);
}
get feedUrl(): URL {
const url = new URL(this.options.feedUrl);
url.searchParams.set('channel', this.options.channel);
url.searchParams.set('minimal', 'true');
return url;
}
async getLatestVersion(): Promise<GithubUpdateInfo> {
const cancellationToken = new CancellationToken();
const releasesJsonStr = await this.httpRequest(
this.feedUrl,
{
accept: 'application/json',
'cache-control': 'no-cache',
},
cancellationToken
);
if (!releasesJsonStr) {
throw new Error(
`Failed to get releases from ${this.feedUrl.toString()}, response is empty`
);
}
const releases = JSON.parse(releasesJsonStr);
if (releases.length === 0) {
throw new Error(
`No published versions in channel ${this.options.channel}`
);
}
const latestRelease = releases[0] as GithubRelease;
const tag = latestRelease.tag_name;
const channelFileName = 'latest.yml';
const channelFileAsset = latestRelease.assets.find(
({ name }) => name === channelFileName
);
// TODO(@forehalo):
// we need a way to let UI thread prompt user to manually install the latest version,
// if we introduce breaking changes on auto updater in the future.
// for example we rename the release file from `latest.yml` to `release.yml`
if (!channelFileAsset) {
throw newError(
`Cannot find ${channelFileName} in the latest release artifacts.`,
'ERR_UPDATER_CHANNEL_FILE_NOT_FOUND'
);
}
const channelFileUrl = new URL(channelFileAsset.url);
const channelFileContent = await this.httpRequest(channelFileUrl);
const result = parseUpdateInfo(
channelFileContent,
channelFileName,
channelFileUrl
);
result.files
.filter(({ url }) =>
availableForMyPlatformAndInstaller(
url,
process.platform,
process.arch,
isSquirrelBuild()
)
)
.forEach(file => {
const asset = latestRelease.assets.find(
({ name }) => name === file.url
);
if (asset) {
file.url = asset.url;
}
});
if (result.releaseName == null) {
result.releaseName = latestRelease.name;
}
if (result.releaseNotes == null) {
result.releaseNotes = latestRelease.body;
}
return {
tag: tag,
...result,
};
}
resolveFiles(updateInfo: GithubUpdateInfo): Array<ResolvedUpdateFileInfo> {
const files = getFileList(updateInfo);
return files
.filter(({ url }) =>
availableForMyPlatformAndInstaller(
url,
process.platform,
process.arch,
isSquirrelBuild()
)
)
.map(file => ({
url: new URL(file.url),
info: file,
}));
}
}
type VersionDistribution = 'canary' | 'beta' | 'stable';
type VersionPlatform = 'windows' | 'macos' | 'linux';
type VersionArch = 'x64' | 'arm64';
type FileParts =
| ['affine', string, VersionDistribution, VersionPlatform, VersionArch]
| [
'affine',
string,
`${'canary' | 'beta'}.${number}`,
VersionDistribution,
VersionPlatform,
VersionArch,
];
export function availableForMyPlatformAndInstaller(
file: string,
platform: NodeJS.Platform,
arch: NodeJS.Architecture,
// moved to parameter to make test coverage easier
imWindowsSquirrelPkg: boolean
): boolean {
const imArm64 = arch === 'arm64';
const imX64 = arch === 'x64';
const imMacos = platform === 'darwin';
const imWindows = platform === 'win32';
const imLinux = platform === 'linux';
// in form of:
// affine-${build}-${buildSuffix}-${distribution}-${platform}-${arch}.${installer}
// ^ 1.0.0 ^canary.1 ^ canary ^windows ^ x64 ^.nsis.exe
const filename = file.split('/').pop();
if (!filename) {
return false;
}
const parts = filename.split('-') as FileParts;
// fix -${arch}.${installer}
const archDotInstaller = parts[parts.length - 1];
const installerIdx = archDotInstaller.indexOf('.');
if (installerIdx === -1) {
return false;
}
const installer = archDotInstaller.substring(installerIdx + 1);
parts[parts.length - 1] = archDotInstaller.substring(0, installerIdx);
let version: {
build: string;
suffix?: string;
distribution: VersionDistribution;
platform: VersionPlatform;
arch: VersionArch;
installer: string;
};
if (parts.length === 5) {
version = {
build: parts[1],
distribution: parts[2],
platform: parts[3],
arch: parts[4],
installer,
};
} else if (parts.length === 6) {
version = {
build: parts[1],
suffix: parts[2],
distribution: parts[3],
platform: parts[4],
arch: parts[5],
installer,
};
} else {
return false;
}
function matchPlatform(platform: VersionPlatform) {
return (
(platform === 'windows' && imWindows) ||
(platform === 'macos' && imMacos) ||
(platform === 'linux' && imLinux)
);
}
function matchArch(arch: VersionArch) {
return (
// off course we can install x64 on x64
(imX64 && arch === 'x64') ||
// arm64 macos can install arm64 or x64 in rosetta2
(imArm64 && (arch === 'arm64' || imMacos))
);
}
function matchInstaller(installer: string) {
// do not allow squirrel or nsis installer to cross download each other on windows
if (!imWindows) {
return true;
}
return imWindowsSquirrelPkg
? installer === 'exe'
: installer === 'nsis.exe';
}
return (
matchPlatform(version.platform) &&
matchArch(version.arch) &&
matchInstaller(version.installer)
);
}

View File

@@ -0,0 +1,156 @@
import { app } from 'electron';
import { autoUpdater as defaultAutoUpdater } from 'electron-updater';
import { buildType } from '../config';
import { logger } from '../logger';
import { AFFiNEUpdateProvider } from './affine-update-provider';
import { updaterSubjects } from './event';
import { WindowsUpdater } from './windows-updater';
const mode = process.env.NODE_ENV;
const isDev = mode === 'development';
// skip auto update in dev mode & internal
const disabled = buildType === 'internal' || isDev;
export const autoUpdater =
process.platform === 'win32' ? new WindowsUpdater() : defaultAutoUpdater;
export const quitAndInstall = async () => {
autoUpdater.quitAndInstall();
};
let downloading = false;
let configured = false;
let checkingUpdate = false;
export type UpdaterConfig = {
autoCheckUpdate: boolean;
autoDownloadUpdate: boolean;
};
const config: UpdaterConfig = {
autoCheckUpdate: true,
autoDownloadUpdate: true,
};
export const getConfig = (): UpdaterConfig => {
return { ...config };
};
export const setConfig = (newConfig: Partial<UpdaterConfig> = {}): void => {
configured = true;
Object.assign(config, newConfig);
logger.info('Updater configured!', config);
// if config.autoCheckUpdate is true, trigger a check
if (config.autoCheckUpdate) {
checkForUpdates().catch(err => {
logger.error('Error checking for updates', err);
});
}
};
export const checkForUpdates = async () => {
if (disabled || checkingUpdate) {
return;
}
checkingUpdate = true;
try {
const info = await autoUpdater.checkForUpdates();
return info;
} finally {
checkingUpdate = false;
}
};
export const downloadUpdate = async () => {
if (disabled || downloading) {
return;
}
downloading = true;
updaterSubjects.downloadProgress$.next(0);
autoUpdater.downloadUpdate().catch(e => {
downloading = false;
logger.error('Failed to download update', e);
});
logger.info('Update available, downloading...');
return;
};
export const registerUpdater = async () => {
if (disabled) {
return;
}
const allowAutoUpdate = true;
autoUpdater.logger = logger;
autoUpdater.autoDownload = false;
autoUpdater.allowPrerelease = buildType !== 'stable';
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.autoRunAppAfterInstall = true;
const feedUrl = AFFiNEUpdateProvider.configFeed({
channel: buildType,
});
autoUpdater.setFeedURL(feedUrl);
// register events for checkForUpdates
autoUpdater.on('checking-for-update', () => {
logger.info('Checking for update');
});
autoUpdater.on('update-available', info => {
logger.info('Update available', info);
if (config.autoDownloadUpdate && allowAutoUpdate) {
downloadUpdate().catch(err => {
console.error(err);
});
}
updaterSubjects.updateAvailable$.next({
version: info.version,
allowAutoUpdate,
});
});
autoUpdater.on('update-not-available', info => {
logger.info('Update not available', info);
});
autoUpdater.on('download-progress', e => {
logger.info(`Download progress: ${e.percent}`);
updaterSubjects.downloadProgress$.next(e.percent);
});
autoUpdater.on('update-downloaded', e => {
downloading = false;
updaterSubjects.updateReady$.next({
version: e.version,
allowAutoUpdate,
});
// I guess we can skip it?
// updaterSubjects.clientDownloadProgress.next(100);
logger.info('Update downloaded, ready to install');
});
autoUpdater.on('error', e => {
logger.error('Error while updating client', e);
});
autoUpdater.forceDevUpdateConfig = isDev;
// check update whenever the window is activated
let lastCheckTime = 0;
app.on('browser-window-focus', () => {
(async () => {
if (
configured &&
config.autoCheckUpdate &&
lastCheckTime + 1000 * 1800 < Date.now()
) {
lastCheckTime = Date.now();
await checkForUpdates();
}
})().catch(err => {
logger.error('Error checking for updates', err);
});
});
};

View File

@@ -0,0 +1,36 @@
import { BehaviorSubject, Subject } from 'rxjs';
import type { MainEventRegister } from '../type';
export interface UpdateMeta {
version: string;
allowAutoUpdate: boolean;
}
export const updaterSubjects = {
// means it is ready for restart and install the new version
updateAvailable$: new Subject<UpdateMeta>(),
updateReady$: new Subject<UpdateMeta>(),
downloadProgress$: new BehaviorSubject<number>(0),
};
export const updaterEvents = {
onUpdateAvailable: (fn: (versionMeta: UpdateMeta) => void) => {
const sub = updaterSubjects.updateAvailable$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
onUpdateReady: (fn: (versionMeta: UpdateMeta) => void) => {
const sub = updaterSubjects.updateReady$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
onDownloadProgress: (fn: (progress: number) => void) => {
const sub = updaterSubjects.downloadProgress$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
} satisfies Record<string, MainEventRegister>;

View File

@@ -0,0 +1,45 @@
import type { IpcMainInvokeEvent } from 'electron';
import { app } from 'electron';
import type { NamespaceHandlers } from '../type';
import type { UpdaterConfig } from './electron-updater';
import {
checkForUpdates,
downloadUpdate,
getConfig,
quitAndInstall,
setConfig,
} from './electron-updater';
export const updaterHandlers = {
currentVersion: async () => {
return app.getVersion();
},
quitAndInstall: async () => {
return quitAndInstall();
},
downloadUpdate: async () => {
return downloadUpdate();
},
getConfig: async (): Promise<UpdaterConfig> => {
return getConfig();
},
setConfig: async (
_e: IpcMainInvokeEvent,
newConfig: Partial<UpdaterConfig>
): Promise<void> => {
return setConfig(newConfig);
},
checkForUpdates: async () => {
const res = await checkForUpdates();
if (res) {
const { updateInfo } = res;
return {
version: updateInfo.version,
};
}
return null;
},
} satisfies NamespaceHandlers;
export * from './electron-updater';

View File

@@ -0,0 +1,18 @@
import fs from 'node:fs';
import path from 'node:path';
import { app } from 'electron';
let _isSquirrelBuild: boolean | null = null;
export function isSquirrelBuild() {
if (typeof _isSquirrelBuild === 'boolean') {
return _isSquirrelBuild;
}
// if it is squirrel build, there will be 'squirrel.exe'
// otherwise it is in nsis web mode
const files = fs.readdirSync(path.dirname(app.getPath('exe')));
_isSquirrelBuild = files.some(it => it.includes('squirrel.exe'));
return _isSquirrelBuild;
}

View File

@@ -0,0 +1,8 @@
import { app } from 'electron';
import { NsisUpdater } from 'electron-updater';
import { DownloadedUpdateHelper } from 'electron-updater/out/DownloadedUpdateHelper';
export class WindowsUpdater extends NsisUpdater {
protected override downloadedUpdateHelper: DownloadedUpdateHelper =
new DownloadedUpdateHelper(app.getPath('sessionData'));
}

View File

@@ -0,0 +1,54 @@
import type { CookiesSetDetails } from 'electron';
export function parseCookie(
cookieString: string,
url: string
): CookiesSetDetails {
const [nameValuePair, ...attributes] = cookieString
.split('; ')
.map(part => part.trim());
const [name, value] = nameValuePair.split('=');
const details: CookiesSetDetails = { url, name, value };
attributes.forEach(attribute => {
const [key, val] = attribute.split('=');
switch (key.toLowerCase()) {
case 'domain':
details.domain = val;
break;
case 'path':
details.path = val;
break;
case 'secure':
details.secure = true;
break;
case 'httponly':
details.httpOnly = true;
break;
case 'expires':
details.expirationDate = new Date(val).getTime() / 1000; // Convert to seconds
break;
case 'samesite':
if (
['unspecified', 'no_restriction', 'lax', 'strict'].includes(
val.toLowerCase()
)
) {
details.sameSite = val.toLowerCase() as
| 'unspecified'
| 'no_restriction'
| 'lax'
| 'strict';
}
break;
default:
// Handle other cookie attributes if needed
break;
}
});
return details;
}

View File

@@ -0,0 +1,19 @@
import type { WebPreferences } from 'electron';
const DEFAULT_WEB_PREFERENCES: Pick<
WebPreferences,
'contextIsolation' | 'nodeIntegration' | 'sandbox'
> = {
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
};
export function buildWebPreferences(
overrides: Partial<WebPreferences> = {}
): WebPreferences {
return {
...DEFAULT_WEB_PREFERENCES,
...overrides,
};
}

View File

@@ -0,0 +1,5 @@
export interface AuthenticationRequest {
method: 'magic-link' | 'oauth' | 'open-app-signin';
payload: Record<string, any>;
server?: string;
}

View File

@@ -0,0 +1,116 @@
import { Menu } from 'electron';
import { logger } from '../logger';
import {
addTab,
closeTab,
reloadView,
type TabAction,
WebContentViewsManager,
} from './tab-views';
export const showTabContextMenu = async (
tabId: string,
viewIndex: number
): Promise<TabAction | null> => {
const workbenches = WebContentViewsManager.instance.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: () => {
WebContentViewsManager.instance.pinTab(tabId, false);
},
}
: {
label: 'Pin tab',
click: () => {
WebContentViewsManager.instance.pinTab(tabId, true);
},
},
{
label: 'Refresh tab',
click: () => {
reloadView().catch(err => logger.error(err));
},
},
{
label: 'Duplicate tab',
click: () => {
addTab({
basename: tabMeta.basename,
view: tabMeta.views,
show: false,
}).catch(err => logger.error(err));
},
},
{ type: 'separator' },
tabMeta.views.length > 1
? {
label: 'Separate tabs',
click: () => {
WebContentViewsManager.instance.separateView(tabId, viewIndex);
},
}
: {
label: 'Open in split view',
click: () => {
WebContentViewsManager.instance.openInSplitView({ tabId });
},
},
...(workbenches.length > 1
? ([
{ type: 'separator' },
{
label: 'Close tab',
click: () => {
closeTab(tabId).catch(err => logger.error(err));
},
},
{
label: 'Close other tabs',
click: () => {
const tabsToRetain =
WebContentViewsManager.instance.tabViewsMeta.workbenches.filter(
w => w.id === tabId || w.pinned
);
WebContentViewsManager.instance.patchTabViewsMeta({
workbenches: tabsToRetain,
activeWorkbenchId: tabId,
});
},
},
] as const)
: []),
];
const menu = Menu.buildFromTemplate(template);
menu.popup();
let unsub: (() => void) | undefined;
const subscription = WebContentViewsManager.instance.tabAction$.subscribe(
action => {
resolve(action);
unsub?.();
}
);
menu.on('menu-will-close', () => {
setTimeout(() => {
resolve(null);
unsub?.();
});
});
unsub = () => {
subscription.unsubscribe();
};
return promise;
};

View File

@@ -0,0 +1,72 @@
import { join } from 'node:path';
import { BrowserWindow, type Display, screen } from 'electron';
import { customThemeViewUrl } from '../../shared/internal-origin';
import { isMacOS } from '../../shared/utils';
import { logger } from '../logger';
import { buildWebPreferences } from '../web-preferences';
let customThemeWindow: Promise<BrowserWindow> | undefined;
const getScreenSize = (display: Display) => {
const { width, height } = isMacOS() ? display.bounds : display.workArea;
return { width, height };
};
async function createCustomThemeWindow(additionalArguments: string[]) {
logger.info('creating custom theme window');
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: buildWebPreferences({
webgl: true,
preload: join(__dirname, './preload.js'),
additionalArguments: additionalArguments,
}),
});
await browserWindow.loadURL(customThemeViewUrl);
browserWindow.on('closed', () => {
customThemeWindow = undefined;
});
return browserWindow;
}
const getWindowAdditionalArguments = async () => {
const { getExposedMeta } = await import('../exposed');
const mainExposedMeta = getExposedMeta();
return [
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
`--window-name=theme-editor`,
];
};
export async function getOrCreateCustomThemeWindow() {
const additionalArguments = await getWindowAdditionalArguments();
if (
!customThemeWindow ||
(await customThemeWindow.then(w => w.isDestroyed()))
) {
customThemeWindow = createCustomThemeWindow(additionalArguments);
}
return customThemeWindow;
}
export async function getCustomThemeWindow() {
if (!customThemeWindow) return;
const window = await customThemeWindow;
if (window.isDestroyed()) return;
return window;
}

View File

@@ -0,0 +1,7 @@
export * from './authentication';
export * from './launcher';
export * from './main-window';
export * from './onboarding';
export * from './stage';
export * from './tab-views';
export * from './types';

View File

@@ -0,0 +1,11 @@
import { logger } from '../logger';
import { initAndShowMainWindow } from './main-window';
/**
* Launch app depending on launch stage
*/
export async function launch() {
initAndShowMainWindow().catch(e => {
logger.error('Failed to restore or create window:', e);
});
}

View File

@@ -0,0 +1,406 @@
import { join } from 'node:path';
import { app, BrowserWindow, nativeTheme } from 'electron';
import electronWindowState from 'electron-window-state';
import { BehaviorSubject, map, shareReplay } from 'rxjs';
import { mainWindowOrigin } from '../../shared/internal-origin';
import { isLinux, isMacOS, isWindows, resourcesPath } from '../../shared/utils';
import { beforeAppQuit } from '../cleanup';
import { buildType } from '../config';
import { ensureHelperProcess } from '../helper-process';
import { logger } from '../logger';
import { MenubarStateKey, MenubarStateSchema } from '../shared-state-schema';
import { globalStateStorage } from '../shared-storage/storage';
import { uiSubjects } from '../ui/subject';
import { buildWebPreferences } from '../web-preferences';
const IS_DEV: boolean =
process.env.NODE_ENV === 'development' && !process.env.CI;
const DOCK_VISIBILITY_THROTTLE_MS = 1100;
const FULL_SCREEN_EXIT_TIMEOUT_MS = 2000;
const TraySettingsState = {
$: globalStateStorage.watch<MenubarStateSchema>(MenubarStateKey).pipe(
map(v => MenubarStateSchema.parse(v ?? {})),
shareReplay(1)
),
get value() {
return MenubarStateSchema.parse(
globalStateStorage.get(MenubarStateKey) ?? {}
);
},
};
function closeAllWindows() {
BrowserWindow.getAllWindows().forEach(w => {
if (!w.isDestroyed()) {
w.destroy();
}
});
}
export class MainWindowManager {
static readonly instance = new MainWindowManager();
mainWindowReady: Promise<BrowserWindow> | undefined;
mainWindow$ = new BehaviorSubject<BrowserWindow | undefined>(undefined);
private backgroundRequested = false;
private hiddenMacWindow: BrowserWindow | undefined;
private lastDockShowAt = 0;
private pendingDockHide: ReturnType<typeof setTimeout> | undefined;
private constructor() {
const traySettingsSubscription = TraySettingsState.$.subscribe(state => {
if (!state.enabled || !state.closeToTray) {
void this.ensureDockVisible().catch(err => {
logger.error('Failed to restore Dock visibility:', err);
});
}
});
beforeAppQuit(() => {
traySettingsSubscription.unsubscribe();
this.cancelPendingDockHide();
this.cleanupWindows();
});
}
get mainWindow() {
return this.mainWindow$.value;
}
// #region private methods
private preventMacAppQuit() {
if (!this.hiddenMacWindow && isMacOS()) {
this.hiddenMacWindow = new BrowserWindow({
show: false,
width: 100,
height: 100,
webPreferences: buildWebPreferences(),
});
this.hiddenMacWindow.on('close', () => {
this.cleanupWindows();
});
}
}
private cleanupWindows() {
this.cancelPendingDockHide();
closeAllWindows();
this.mainWindowReady = undefined;
this.mainWindow$.next(undefined);
this.hiddenMacWindow?.destroy();
this.hiddenMacWindow = undefined;
}
private cancelPendingDockHide() {
if (this.pendingDockHide) {
clearTimeout(this.pendingDockHide);
this.pendingDockHide = undefined;
}
}
private shouldHideDock() {
const settings = TraySettingsState.value;
return (
isMacOS() &&
this.backgroundRequested &&
settings.enabled &&
settings.closeToTray
);
}
private async showDock() {
this.cancelPendingDockHide();
if (app.isReady() && app.dock && !app.dock.isVisible()) {
await app.dock.show();
this.lastDockShowAt = Date.now();
}
}
private scheduleDockHide(mainWindow: BrowserWindow) {
this.cancelPendingDockHide();
if (!app.dock || !this.shouldHideDock()) {
return;
}
const hideDock = () => {
this.pendingDockHide = undefined;
if (
this.shouldHideDock() &&
!mainWindow.isDestroyed() &&
!mainWindow.isVisible()
) {
app.dock?.hide();
}
};
const delay =
DOCK_VISIBILITY_THROTTLE_MS - (Date.now() - this.lastDockShowAt);
if (delay > 0) {
this.pendingDockHide = setTimeout(hideDock, delay);
} else {
hideDock();
}
}
private async hideMainWindow(mainWindow: BrowserWindow) {
this.backgroundRequested = true;
this.cancelPendingDockHide();
if (mainWindow.isFullScreen()) {
await new Promise<void>(resolve => {
const done = () => {
clearTimeout(timeout);
mainWindow.removeListener('leave-full-screen', done);
mainWindow.removeListener('closed', done);
resolve();
};
const timeout = setTimeout(done, FULL_SCREEN_EXIT_TIMEOUT_MS);
mainWindow.once('leave-full-screen', done);
mainWindow.once('closed', done);
mainWindow.setFullScreen(false);
});
}
if (this.backgroundRequested && !mainWindow.isDestroyed()) {
mainWindow.hide();
this.scheduleDockHide(mainWindow);
}
}
private async createMainWindow() {
logger.info('create window');
const mainWindowState = electronWindowState({
defaultWidth: 1000,
defaultHeight: 800,
});
const browserWindow = new BrowserWindow({
titleBarStyle: isMacOS()
? 'hiddenInset'
: isWindows()
? 'hidden'
: 'default',
x: mainWindowState.x,
y: mainWindowState.y,
width: mainWindowState.width,
autoHideMenuBar: isLinux(),
minWidth: 640,
minHeight: 480,
visualEffectState: 'active',
vibrancy: 'under-window',
// backgroundMaterial: 'mica',
height: mainWindowState.height,
show: false, // Use 'ready-to-show' event to show window
webPreferences: buildWebPreferences({
webgl: true,
}),
});
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`)
);
}
nativeTheme.themeSource = 'light';
mainWindowState.manage(browserWindow);
this.bindEvents(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());
});
mainWindow.on('close', e => {
// TODO(@pengx17): gracefully close the app, for example, ask user to save unsaved changes
e.preventDefault();
if (!isMacOS()) {
if (
TraySettingsState.value.enabled &&
TraySettingsState.value.closeToTray
) {
mainWindow.hide();
} else {
closeAllWindows();
this.mainWindowReady = undefined;
this.mainWindow$.next(undefined);
}
} else {
void this.hideMainWindow(mainWindow).catch(err => {
logger.error('Failed to hide main window:', err);
});
}
});
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]);
}, timeout);
};
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);
});
mainWindow.on('maximize', () => {
uiSubjects.onMaximized$.next(true);
});
mainWindow.on('unmaximize', () => {
uiSubjects.onMaximized$.next(false);
});
// full-screen == maximized in UI on windows
mainWindow.on('enter-full-screen', () => {
uiSubjects.onFullScreen$.next(true);
});
mainWindow.on('leave-full-screen', () => {
uiSubjects.onFullScreen$.next(false);
});
}
// #endregion
async ensureMainWindow(): Promise<BrowserWindow> {
if (
!this.mainWindowReady ||
(await this.mainWindowReady.then(w => w.isDestroyed()))
) {
this.mainWindowReady = this.createMainWindow();
this.mainWindow$.next(await this.mainWindowReady);
this.preventMacAppQuit();
}
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) {
// do not gain focus in dev mode
this.backgroundRequested = false;
await this.showDock();
mainWindow.showInactive();
} else if (
!TraySettingsState.value.enabled ||
!TraySettingsState.value.startMinimized
) {
await this.showMainWindow();
}
this.preventMacAppQuit();
return mainWindow;
}
async showMainWindow() {
this.backgroundRequested = false;
const mainWindow = await this.ensureMainWindow();
await this.showDock();
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.show();
mainWindow.focus();
return mainWindow;
}
async closeMainWindowToBackground() {
await this.hideMainWindow(await this.ensureMainWindow());
}
async ensureDockVisible() {
if (!this.shouldHideDock()) {
await this.showDock();
}
}
}
export async function initAndShowMainWindow() {
return MainWindowManager.instance.initAndShowMainWindow();
}
export async function getMainWindow() {
return MainWindowManager.instance.ensureMainWindow();
}
export async function showMainWindow() {
return MainWindowManager.instance.showMainWindow();
}
export async function closeMainWindowToBackground() {
return MainWindowManager.instance.closeMainWindowToBackground();
}
export async function ensureDockVisible() {
return MainWindowManager.instance.ensureDockVisible();
}
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: buildWebPreferences({
preload: join(__dirname, './preload.js'),
additionalArguments: await getWindowAdditionalArguments(),
}),
show: BUILD_CONFIG.debug,
});
if (BUILD_CONFIG.debug) {
win.webContents.openDevTools();
}
logger.info('loading page at', url);
win.loadURL(url).catch(e => {
logger.error('failed to load url', e);
});
return win;
}

View File

@@ -0,0 +1,104 @@
import { join } from 'node:path';
import { BrowserWindow, screen } from 'electron';
import { onboardingViewUrl } from '../../shared/internal-origin';
import { isDev } from '../config';
// import { getExposedMeta } from './exposed';
import { logger } from '../logger';
import { buildWebPreferences } from '../web-preferences';
import { fullscreenAndCenter, getScreenSize } from './utils';
// todo: not all window need all of the exposed meta
const getWindowAdditionalArguments = async () => {
const { getExposedMeta } = await import('../exposed');
const mainExposedMeta = getExposedMeta();
return [
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
`--window-name=onboarding`,
];
};
async function createOnboardingWindow(additionalArguments: string[]) {
logger.info('creating onboarding window');
// get user's screen size
const { width, height } = getScreenSize(screen.getPrimaryDisplay());
const browserWindow = new BrowserWindow({
width,
height,
frame: false,
show: false,
resizable: false,
closable: false,
minimizable: false,
movable: false,
titleBarStyle: 'hidden',
maximizable: false,
fullscreenable: false,
// skipTaskbar: true,
transparent: true,
hasShadow: false,
roundedCorners: false,
webPreferences: buildWebPreferences({
webgl: true,
preload: join(__dirname, './preload.js'),
additionalArguments: additionalArguments,
}),
});
// workaround for the phantom title bar on windows when losing focus
// see https://github.com/electron/electron/issues/39959#issuecomment-1758736966
browserWindow.on('focus', () => {
browserWindow.setBackgroundColor('#00000000');
});
browserWindow.on('blur', () => {
browserWindow.setBackgroundColor('#00000000');
});
browserWindow.on('ready-to-show', () => {
// forcing zoom factor to 1 to avoid onboarding display issues
browserWindow.webContents.setZoomFactor(1);
fullscreenAndCenter(browserWindow);
// TODO(@catsjuice): add a timeout to avoid flickering, window is ready, but dom is not ready
setTimeout(() => {
browserWindow.show();
}, 300);
});
// When moved to another screen, resize to fit the screen
browserWindow.on('moved', () => {
fullscreenAndCenter(browserWindow);
});
if (isDev) {
browserWindow.webContents.openDevTools();
}
await browserWindow.loadURL(onboardingViewUrl);
return browserWindow;
}
let onBoardingWindow: Promise<BrowserWindow> | undefined;
export async function getOrCreateOnboardingWindow() {
const additionalArguments = await getWindowAdditionalArguments();
if (
!onBoardingWindow ||
(await onBoardingWindow.then(w => w.isDestroyed()))
) {
onBoardingWindow = createOnboardingWindow(additionalArguments);
}
return onBoardingWindow;
}
export async function getOnboardingWindow() {
if (!onBoardingWindow) return;
const window = await onBoardingWindow;
if (window.isDestroyed()) return;
return window;
}

View File

@@ -0,0 +1,285 @@
import { join } from 'node:path';
import { setTimeout } from 'node:timers/promises';
import { BrowserWindow, type BrowserWindowConstructorOptions } from 'electron';
import { BehaviorSubject } from 'rxjs';
import { popupViewUrl } from '../../shared/internal-origin';
import { logger } from '../logger';
import type { MainEventRegister, NamespaceHandlers } from '../type';
import { buildWebPreferences } from '../web-preferences';
import { ensureDockVisible } from './main-window';
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}`,
];
}
const POPUP_PADDING = 20; // padding between the popup and the edge of the screen
const NOTIFICATION_SIZE = [300, 128];
const RECORDING_SIZE = [300, 36];
async function animate(
current: number,
target: number,
setter: (val: number) => void,
duration = 200,
delay = 0
): Promise<void> {
const fps = 60;
const steps = duration / (1000 / fps);
const delta = target - current;
const easing = (t: number) => -(Math.cos(Math.PI * t) - 1) / 2;
if (delay > 0) {
await setTimeout(delay);
}
for (let i = 0; i < steps; i++) {
const progress = easing(i / steps);
setter(current + delta * progress);
await setTimeout(1000 / fps);
}
// Ensure we hit the target exactly
setter(target);
}
abstract class PopupWindow {
abstract readonly type: PopupWindowType;
abstract readonly name: string;
browserWindow: BrowserWindow | undefined;
abstract windowOptions: Partial<BrowserWindowConstructorOptions>;
ready = Promise.withResolvers<void>();
private readonly showing$ = new BehaviorSubject<boolean>(false);
get showing() {
return this.showing$.value;
}
async build(): Promise<BrowserWindow> {
const browserWindow = new BrowserWindow({
resizable: false,
minimizable: false,
maximizable: false,
closable: false,
alwaysOnTop: true,
hiddenInMissionControl: true,
skipTaskbar: true,
movable: false,
titleBarStyle: 'hidden',
show: false, // hide by default,
backgroundColor: 'transparent',
visualEffectState: 'active',
vibrancy: 'under-window',
...this.windowOptions,
webPreferences: buildWebPreferences({
webgl: true,
transparent: true,
spellcheck: false,
preload: join(__dirname, './preload.js'), // this points to the bundled preload module
...this.windowOptions.webPreferences,
// serialize exposed meta that to be used in preload
additionalArguments: await getAdditionalArguments(this.name),
}),
});
await ensureDockVisible();
// required to make the window transparent
browserWindow.setBackgroundColor('#00000000');
browserWindow.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true,
});
logger.info('loading popup', this.name, popupViewUrl);
browserWindow.webContents.on('did-finish-load', () => {
this.ready.resolve();
logger.info('popup ready', this.name);
});
browserWindow.loadURL(popupViewUrl).catch(err => logger.error(err));
return browserWindow;
}
async show() {
if (!this.browserWindow) {
this.browserWindow = await this.build();
}
const browserWindow = this.browserWindow;
const workArea = getCurrentDisplay(browserWindow).workArea;
const popupSize = browserWindow.getSize();
await this.ready.promise;
this.showing$.next(true);
browserWindow.showInactive(); // focus the notification is too distracting right?
browserWindow.setOpacity(0);
// Calculate start and end positions for x coordinate
const startX = workArea.x + workArea.width + popupSize[0] + POPUP_PADDING;
const endX = workArea.x + workArea.width - popupSize[0] - POPUP_PADDING;
const y = workArea.y + POPUP_PADDING;
// Set initial position
browserWindow.setPosition(startX, y);
logger.info('showing popup', this.name);
// First fade in, then slide
await Promise.all([
// Slide in animation
animate(
startX,
endX,
x => {
browserWindow.setPosition(Math.round(x), y);
},
300
),
// Fade in animation
animate(
0,
1,
opacity => {
this.browserWindow?.setOpacity(opacity);
},
100,
100
),
]);
}
async hide() {
if (!this.browserWindow) {
return;
}
logger.info('hiding popup', this.name);
this.showing$.next(false);
await animate(this.browserWindow.getOpacity(), 0, opacity => {
this.browserWindow?.setOpacity(opacity);
});
this.browserWindow?.hide();
}
destroy() {
this.browserWindow?.destroy();
}
}
// leave for future use
type ElectronNotification = null;
class NotificationPopupWindow extends PopupWindow {
readonly type = 'notification' as const;
readonly name = `${this.type}`;
notification$ = new BehaviorSubject<ElectronNotification | null>(null);
windowOptions: Partial<BrowserWindowConstructorOptions> = {
width: NOTIFICATION_SIZE[0],
height: NOTIFICATION_SIZE[1],
};
async notify(notification: ElectronNotification) {
this.notification$.next(notification);
await super.show();
}
}
// recording popup window is singleton across the app
class RecordingPopupWindow extends PopupWindow {
readonly type = 'recording' as const;
readonly name = `${this.type}`;
windowOptions: Partial<BrowserWindowConstructorOptions> = {
width: RECORDING_SIZE[0],
height: RECORDING_SIZE[1],
movable: true,
};
}
// Type mapping from PopupWindowType to specific window class
type PopupWindowTypeMap = {
notification: NotificationPopupWindow;
recording: RecordingPopupWindow;
};
export class PopupManager {
static readonly instance = new PopupManager();
// there could be a single instance of each type of popup window
readonly popupWindows$ = new BehaviorSubject<Map<string, PopupWindow>>(
new Map()
);
get<T extends PopupWindowType>(type: T): PopupWindowTypeMap[T] {
// Check if popup of this type already exists
const existingPopup = Array.from(this.popupWindows$.value.values()).find(
popup => popup.type === type
) as PopupWindowTypeMap[T] | undefined;
// If exists, return it
if (existingPopup) {
return existingPopup;
}
// Otherwise create a new one
const popupWindow = (() => {
switch (type) {
case 'notification':
return new NotificationPopupWindow() as PopupWindowTypeMap[T];
case 'recording':
return new RecordingPopupWindow() as PopupWindowTypeMap[T];
default:
throw new Error(`Unknown popup type: ${type}`);
}
})();
this.popupWindows$.next(
new Map(this.popupWindows$.value).set(popupWindow.type, popupWindow)
);
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;
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>;

View File

@@ -0,0 +1,6 @@
import { persistentConfig } from '../config-storage/persist';
import type { LaunchStage } from './types';
export const launchStage: { value: LaunchStage } = {
value: persistentConfig.get('onBoarding') ? 'onboarding' : 'main',
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export type LaunchStage = 'main' | 'onboarding';

View File

@@ -0,0 +1,27 @@
import { BrowserWindow, type Display, type Rectangle, screen } from 'electron';
import { isMacOS } from '../../shared/utils';
export const getCurrentDisplay = (browserWindow: BrowserWindow) => {
const position = browserWindow.getPosition();
const size = browserWindow.getSize();
const currentDisplay = screen.getDisplayNearestPoint({
x: position[0] + size[0] / 2,
y: position[1] + size[1] / 2,
});
return currentDisplay;
};
export const getScreenSize = (display: Display | BrowserWindow): Rectangle => {
if (display instanceof BrowserWindow) {
return getScreenSize(getCurrentDisplay(display));
}
return isMacOS() ? display.bounds : display.workArea;
};
export const fullscreenAndCenter = (browserWindow: BrowserWindow) => {
const currentDisplay = getCurrentDisplay(browserWindow);
const { width, height } = getScreenSize(currentDisplay);
browserWindow.setSize(width, height);
browserWindow.center();
};

View File

@@ -0,0 +1,19 @@
import type { NamespaceHandlers } from '../type';
import { WorkerManager } from './pool';
export const workerHandlers = {
connectWorker: async (e, key: string, portId: string) => {
const { portForRenderer } = await WorkerManager.instance.connectWorker(
key,
portId,
e.sender
);
e.sender.postMessage('worker-connect', { portId }, [portForRenderer]);
return {
portId: portId,
};
},
disconnectWorker: async (_, key: string, portId: string) => {
WorkerManager.instance.disconnectWorker(key, portId);
},
} satisfies NamespaceHandlers;

View File

@@ -0,0 +1,133 @@
import { join } from 'node:path';
import { BrowserWindow, MessageChannelMain, type WebContents } from 'electron';
import { backgroundWorkerViewUrl } from '../../shared/internal-origin';
import { ensureHelperProcess } from '../helper-process';
import { logger } from '../logger';
import { buildWebPreferences } from '../web-preferences';
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`,
];
}
export class WorkerManager {
static readonly instance = new WorkerManager();
workers = new Map<
string,
{
browserWindow: BrowserWindow;
ports: Set<string>;
key: string;
loaded: PromiseWithResolvers<void>;
}
>();
private async getOrCreateWorker(key: string) {
const additionalArguments = await getAdditionalArguments();
const helperProcessManager = await ensureHelperProcess();
const exists = this.workers.get(key);
if (exists) {
return exists;
} else {
const worker = new BrowserWindow({
width: 1200,
height: 600,
webPreferences: buildWebPreferences({
preload: join(__dirname, './preload.js'),
additionalArguments: additionalArguments,
}),
show: false,
});
const record = {
browserWindow: worker,
ports: new Set<string>(),
key,
loaded: Promise.withResolvers<void>(),
};
let disconnectHelperProcess: (() => void) | null = null;
let cleanedUp = false;
const cleanup = () => {
if (cleanedUp) {
return;
}
cleanedUp = true;
this.workers.delete(key);
disconnectHelperProcess?.();
disconnectHelperProcess = null;
};
const handleWorkerFailure = (reason: string) => {
logger.error('[worker] renderer process gone', { key, reason });
record.loaded.reject(new Error(`worker ${key} failed: ${reason}`));
cleanup();
try {
worker.destroy();
} catch (err) {
logger.warn('failed to destroy worker window', err);
}
};
worker.on('closed', cleanup);
worker.webContents.on('render-process-gone', (_event, details) => {
handleWorkerFailure(details.reason ?? 'unknown');
});
worker.webContents.on('unresponsive', () => {
handleWorkerFailure('unresponsive');
});
worker.loadURL(backgroundWorkerViewUrl).catch(e => {
logger.error('failed to load url', e);
});
worker.webContents.addListener('did-finish-load', () => {
disconnectHelperProcess = helperProcessManager.connectRenderer(
worker.webContents
);
record.loaded.resolve();
});
this.workers.set(key, record);
return record;
}
}
async connectWorker(
key: string,
portId: string,
bindWebContent: WebContents
) {
bindWebContent.addListener('destroyed', () => {
this.disconnectWorker(key, portId);
});
const worker = await this.getOrCreateWorker(key);
worker.ports.add(portId);
const { port1: portForWorker, port2: portForRenderer } =
new MessageChannelMain();
await worker.loaded.promise;
worker.browserWindow.webContents.postMessage('worker-connect', { portId }, [
portForWorker,
]);
return { portForRenderer, portId };
}
disconnectWorker(key: string, portId: string) {
const worker = this.workers.get(key);
if (worker) {
worker.ports.delete(portId);
if (worker.ports.size === 0) {
worker.browserWindow.destroy();
this.workers.delete(key);
}
}
}
}

View File

@@ -0,0 +1,22 @@
import '@sentry/electron/preload';
import { contextBridge } from 'electron';
import { isInternalUrl } from '../shared/internal-origin';
import { apis, appInfo, events } from './electron-api';
import { sharedStorage } from './shared-storage';
import { listenWorkerApis } from './worker';
const locationLike = (globalThis as { location?: { href?: unknown } }).location;
const currentUrl =
typeof locationLike?.href === 'string' ? locationLike.href : null;
if (currentUrl && isInternalUrl(currentUrl)) {
contextBridge.exposeInMainWorld('__appInfo', appInfo);
contextBridge.exposeInMainWorld('__apis', apis);
contextBridge.exposeInMainWorld('__events', events);
contextBridge.exposeInMainWorld('__sharedStorage', sharedStorage);
listenWorkerApis();
}

View File

@@ -0,0 +1,342 @@
// Please add modules to `external` in `rollupOptions` to avoid wrong bundling.
import type { MessagePort } from 'node:worker_threads';
import type { EventBasedChannel } from 'async-call-rpc';
import { AsyncCall } from 'async-call-rpc';
import { ipcRenderer, webUtils } from 'electron';
import { Subject } from 'rxjs';
import { z } from 'zod';
import type {
CreateImportSessionFromSourceOptions,
NativeImportBrowserSource,
} from '../shared/import';
import {
AFFINE_API_CHANNEL_NAME,
AFFINE_EVENT_CHANNEL_NAME,
AFFINE_EVENT_SUBSCRIBE_CHANNEL_NAME,
type ExposedMeta,
type HelperToRenderer,
type RendererToHelper,
} from '../shared/type';
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;
function getMainAPIs() {
const meta: ExposedMeta = (() => {
const val = process.argv
.find(arg => arg.startsWith('--main-exposed-meta='))
?.split('=')[1];
return val ? JSON.parse(val) : null;
})();
// main handlers that can be invoked from the renderer process
const apis: any = (() => {
const { handlers: handlersMeta } = meta;
const all = handlersMeta.map(([namespace, functionNames]) => {
const namespaceApis = functionNames.map(name => {
const channel = `${namespace}:${name}`;
return [
name,
(...args: any[]) => {
return ipcRenderer.invoke(
AFFINE_API_CHANNEL_NAME,
channel,
...args
);
},
];
});
return [namespace, Object.fromEntries(namespaceApis)];
});
return Object.fromEntries(all);
})();
// main events that can be listened to from the renderer process
const events: any = (() => {
const { events: eventsMeta } = meta;
// channel -> callback[]
const listenersMap = new Map<string, ((...args: any[]) => void)[]>();
const subscribeCounts = new Map<string, number>();
const subscribe = (channel: string) => {
const count = (subscribeCounts.get(channel) ?? 0) + 1;
subscribeCounts.set(channel, count);
if (count === 1) {
ipcRenderer.send(
AFFINE_EVENT_SUBSCRIBE_CHANNEL_NAME,
'subscribe',
channel
);
}
};
const unsubscribe = (channel: string) => {
const count = (subscribeCounts.get(channel) ?? 0) - 1;
if (count <= 0) {
subscribeCounts.delete(channel);
ipcRenderer.send(
AFFINE_EVENT_SUBSCRIBE_CHANNEL_NAME,
'unsubscribe',
channel
);
} else {
subscribeCounts.set(channel, count);
}
};
ipcRenderer.on(AFFINE_EVENT_CHANNEL_NAME, (_event, channel, ...args) => {
if (typeof channel !== 'string') {
console.error('invalid ipc event', channel);
return;
}
const [namespace, name] = channel.split(':');
if (!namespace || !name) {
console.error('invalid ipc event', channel);
return;
}
const listeners = listenersMap.get(channel) ?? [];
listeners.forEach(listener => listener(...args));
});
const all = eventsMeta.map(([namespace, eventNames]) => {
const namespaceEvents = eventNames.map(name => {
const channel = `${namespace}:${name}`;
return [
name,
(callback: (...args: any[]) => void) => {
listenersMap.set(channel, [
...(listenersMap.get(channel) ?? []),
callback,
]);
subscribe(channel);
return () => {
const listeners = listenersMap.get(channel) ?? [];
const index = listeners.indexOf(callback);
if (index === -1) {
return;
}
listeners.splice(index, 1);
unsubscribe(channel);
if (listeners.length === 0) {
listenersMap.delete(channel);
} else {
listenersMap.set(channel, listeners);
}
};
},
];
});
return [namespace, Object.fromEntries(namespaceEvents)];
});
return Object.fromEntries(all);
})();
return { apis, events };
}
const helperPort = new Promise<MessagePort>(resolve =>
ipcRenderer.on('helper-connection', e => {
console.info('[preload] helper-connection', e);
resolve(e.ports[0]);
})
);
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);
},
};
};
function getHelperAPIs() {
const events$ = new Subject<{ channel: string; args: any[] }>();
const meta: ExposedMeta | null = (() => {
const val = process.argv
.find(arg => arg.startsWith('--helper-exposed-meta='))
?.split('=')[1];
return val ? JSON.parse(val) : null;
})();
const rendererToHelperServer: RendererToHelper = {
postEvent: (channel, ...args) => {
events$.next({ channel, args });
},
};
const rpc = AsyncCall<HelperToRenderer>(rendererToHelperServer, {
channel: helperPort.then(helperPort =>
createMessagePortChannel(helperPort)
),
log: false,
});
const toHelperHandler = (namespace: string, name: string) => {
return rpc[`${namespace}:${name}`];
};
const toHelperEventSubscriber = (namespace: string, name: string) => {
return (callback: (...args: any[]) => void) => {
const subscription = events$.subscribe(({ channel, args }) => {
if (channel === `${namespace}:${name}`) {
callback(...args);
}
});
return () => {
subscription.unsubscribe();
};
};
};
const setup = (meta: ExposedMeta) => {
const { handlers, events } = meta;
const helperHandlers = Object.fromEntries(
handlers.map(([namespace, functionNames]) => {
return [
namespace,
Object.fromEntries(
functionNames.map(name => {
return [name, toHelperHandler(namespace, name)];
})
),
];
})
);
const helperEvents = Object.fromEntries(
events.map(([namespace, eventNames]) => {
return [
namespace,
Object.fromEntries(
eventNames.map(name => {
return [name, toHelperEventSubscriber(namespace, name)];
})
),
];
})
);
return [helperHandlers, helperEvents];
};
if (meta) {
const [apis, events] = setup(meta);
return { apis, events };
} else {
return { apis: {}, events: {} };
}
}
const mainAPIs = getMainAPIs();
const helperAPIs = getHelperAPIs();
type DirectoryImportFile = File & { webkitRelativePath?: string };
function filePathFromFile(file: File) {
return webUtils.getPathForFile(file);
}
function directoryPathFromFiles(files: File[]) {
const first = files.find(
(file): file is DirectoryImportFile =>
!!(file as DirectoryImportFile).webkitRelativePath
);
if (!first) return null;
const filePath = filePathFromFile(first);
if (!filePath) return null;
const relativePath = first.webkitRelativePath;
if (!relativePath) return null;
const relativeParts = relativePath.split('/');
let rootPath = filePath.replaceAll('\\', '/');
for (let i = relativeParts.length - 1; i > 0; i--) {
const part = relativeParts[i];
if (part && rootPath.endsWith(`/${part}`)) {
rootPath = rootPath.slice(0, -part.length - 1);
}
}
return rootPath || null;
}
function resolveNativeImportSource(source: NativeImportBrowserSource) {
if (source.kind === 'file') {
const path = filePathFromFile(source.file);
return path ? { kind: 'filePath', path } : null;
}
const path = directoryPathFromFiles(source.files);
return path ? { kind: 'directoryPath', path } : null;
}
export const apis = {
...mainAPIs.apis,
...helperAPIs.apis,
import: {
...mainAPIs.apis.import,
createImportSessionFromSource(
options: CreateImportSessionFromSourceOptions
) {
const source = resolveNativeImportSource(options.source);
if (!source) {
throw new Error('Native import requires a local file source');
}
return mainAPIs.apis.import.createImportSession({
format: options.format,
source,
batchLimits: options.batchLimits,
});
},
},
};
export const events = {
...mainAPIs.events,
...helperAPIs.events,
};

View File

@@ -0,0 +1 @@
import './bootstrap';

View File

@@ -0,0 +1,155 @@
import { MemoryMemento } from '@toeverything/infra';
import { ipcRenderer } from 'electron';
import {
AFFINE_API_CHANNEL_NAME,
AFFINE_EVENT_CHANNEL_NAME,
} from '../shared/type';
// Unique id for this renderer instance, used to ignore self-originated broadcasts
const CLIENT_ID: string = Math.random().toString(36).slice(2);
function invokeWithCatch(key: string, ...args: any[]) {
ipcRenderer.invoke(AFFINE_API_CHANNEL_NAME, key, ...args).catch(err => {
console.error(`Failed to invoke ${key}`, err);
});
}
function createSharedStorageApi(
init: Record<string, any>,
event: string,
api: {
del: string;
clear: string;
set: string;
}
) {
const memory = new MemoryMemento();
const revisions = new Map<string, number>();
const updateQueue: Record<string, any>[] = [];
let loaded = false;
const applyUpdates = (updates: Record<string, any>) => {
for (const [key, raw] of Object.entries(updates)) {
// '*' means "reset everything" coming from a clear operation
if (key === '*') {
memory.clear();
revisions.clear();
continue;
}
// support both legacy plain value and new { v, r, s } structure
let value: any;
let source: string | undefined;
let rev: number | undefined;
if (raw && typeof raw === 'object' && 'v' in raw) {
value = raw.v;
source = raw.s;
rev = typeof raw.r === 'number' ? raw.r : undefined;
} else {
value = raw;
}
// Ignore our own broadcasts
if (source && source === CLIENT_ID) {
continue;
}
if (rev !== undefined) {
const current = revisions.get(key) ?? -1;
if (rev <= current) {
continue;
}
revisions.set(key, rev);
}
if (value === undefined) {
memory.del(key);
} else {
memory.set(key, value);
}
}
};
ipcRenderer.on(AFFINE_EVENT_CHANNEL_NAME, (_event, channel, updates) => {
if (channel === `sharedStorage:${event}`) {
if (loaded) {
applyUpdates(updates);
} else {
updateQueue.push(updates);
}
}
});
const initPromise = (async () => {
try {
memory.setAll(init);
const latest = await ipcRenderer.invoke(
AFFINE_API_CHANNEL_NAME,
event === 'onGlobalStateChanged'
? 'sharedStorage:getAllGlobalState'
: 'sharedStorage:getAllGlobalCache'
);
if (latest && typeof latest === 'object') {
memory.setAll(latest);
}
} catch (err) {
console.error('Failed to load initial shared storage', err);
} finally {
loaded = true;
while (updateQueue.length) {
const updates = updateQueue.shift();
if (updates) {
applyUpdates(updates);
}
}
}
})();
return {
ready: initPromise,
del(key: string) {
memory.del(key);
invokeWithCatch(`sharedStorage:${api.del}`, key, CLIENT_ID);
},
clear() {
memory.clear();
revisions.clear();
invokeWithCatch(`sharedStorage:${api.clear}`, CLIENT_ID);
},
get<T>(key: string): T | undefined {
return memory.get(key);
},
keys() {
return memory.keys();
},
set(key: string, value: unknown) {
memory.set(key, value);
invokeWithCatch(`sharedStorage:${api.set}`, key, value, CLIENT_ID);
},
watch<T>(key: string, cb: (i: T | undefined) => void): () => void {
const subscription = memory.watch(key).subscribe(i => cb(i as T));
return () => subscription.unsubscribe();
},
};
}
export const globalState = createSharedStorageApi({}, 'onGlobalStateChanged', {
clear: 'clearGlobalState',
del: 'delGlobalState',
set: 'setGlobalState',
});
export const globalCache = createSharedStorageApi({}, 'onGlobalCacheChanged', {
clear: 'clearGlobalCache',
del: 'delGlobalCache',
set: 'setGlobalCache',
});
export const sharedStorage = {
globalState,
globalCache,
};
export type SharedStorage = typeof sharedStorage;

View File

@@ -0,0 +1,33 @@
import { ipcRenderer } from 'electron';
export function listenWorkerApis() {
ipcRenderer.on('worker-connect', (ev, data) => {
const portForRenderer = ev.ports[0];
// @ts-expect-error this function should only be evaluated in the renderer process
if (document.readyState === 'complete') {
// @ts-expect-error this function should only be evaluated in the renderer process
window.postMessage(
{
type: 'electron:worker-connect',
portId: data.portId,
},
'*',
[portForRenderer]
);
} else {
// @ts-expect-error this function should only be evaluated in the renderer process
window.addEventListener('load', () => {
// @ts-expect-error this function should only be evaluated in the renderer process
window.postMessage(
{
type: 'electron:worker-connect',
portId: data.portId,
},
'*',
[portForRenderer]
);
});
}
});
}

View File

@@ -0,0 +1,29 @@
export type NativeImportFormat =
| 'markdownZip'
| 'notionZip'
| 'obsidian'
| 'bearZip'
| 'oneNote';
export type NativeImportBrowserSource =
| { kind: 'file'; file: File }
| { kind: 'directory'; files: File[] };
export type CreateImportSessionFromSourceOptions = {
format: NativeImportFormat;
source: NativeImportBrowserSource;
batchLimits?: {
maxDocs?: number;
maxBlobs?: number;
maxBlobBytes?: number;
};
};
export type NativeImportSessionHandlers = {
createImportSession(
options: CreateImportSessionFromSourceOptions
): Promise<string> | string;
nextImportBatch(sessionId: string): Promise<string | null> | string | null;
cancelImportSession(sessionId: string): Promise<void> | void;
disposeImportSession(sessionId: string): Promise<void> | void;
};

View File

@@ -0,0 +1,25 @@
export const mainHost = '.';
export const anotherHost = 'another-host';
export const internalHosts = new Set([mainHost, anotherHost]);
export const mainWindowOrigin = `assets://${mainHost}`;
export const anotherOrigin = `assets://${anotherHost}`;
export const onboardingViewUrl = `${mainWindowOrigin}/onboarding`;
export const shellViewUrl = `${mainWindowOrigin}/shell.html`;
export const backgroundWorkerViewUrl = `${mainWindowOrigin}/background-worker.html`;
export const customThemeViewUrl = `${mainWindowOrigin}/theme-editor`;
// mitigate the issue that popup window share the same zoom level of the main window
// 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`;
export const isInternalUrl = (url: string) => {
try {
const parsed = new URL(url);
return parsed.protocol === 'assets:' && internalHosts.has(parsed.hostname);
} catch {
return false;
}
};

View File

@@ -0,0 +1,33 @@
import type { app, dialog, shell } from 'electron';
export interface ExposedMeta {
handlers: [string, string[]][];
events: [string, string[]][];
}
// render <-> helper
export interface RendererToHelper {
postEvent: (channel: string, ...args: any[]) => void;
}
export interface HelperToRenderer {
[key: string]: (...args: any[]) => Promise<any>;
}
// helper <-> main
export interface HelperToMain {
getMeta: () => ExposedMeta;
}
export type MainToHelper = Pick<
typeof dialog & typeof shell & typeof app,
| 'showOpenDialog'
| 'showSaveDialog'
| 'openExternal'
| 'showItemInFolder'
| 'getPath'
>;
export const AFFINE_API_CHANNEL_NAME = 'affine-ipc-api';
export const AFFINE_EVENT_CHANNEL_NAME = 'affine-ipc-event';
export const AFFINE_EVENT_SUBSCRIBE_CHANNEL_NAME = 'affine-ipc-event-subscribe';

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