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
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:
304
packages/frontend/apps/electron/src/helper/dialog/dialog.ts
Normal file
304
packages/frontend/apps/electron/src/helper/dialog/dialog.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { parse, resolve } from 'node:path';
|
||||
|
||||
import { DocStorage, ValidationResult } from '@affine/native';
|
||||
import { parseUniversalId } from '@affine/nbstore';
|
||||
import fs from 'fs-extra';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { isPathInsideBase } from '../../shared/utils';
|
||||
import { logger } from '../logger';
|
||||
import { mainRPC } from '../main-rpc';
|
||||
import { getDocStoragePool } from '../nbstore';
|
||||
import { storeWorkspaceMeta } from '../workspace';
|
||||
import {
|
||||
getSpaceDBPath,
|
||||
getWorkspaceDBPath,
|
||||
getWorkspacesBasePath,
|
||||
} from '../workspace/meta';
|
||||
|
||||
export type ErrorMessage =
|
||||
| 'DB_FILE_PATH_INVALID'
|
||||
| 'DB_FILE_INVALID'
|
||||
| 'UNKNOWN_ERROR';
|
||||
|
||||
export interface LoadDBFileResult {
|
||||
workspaceId?: string;
|
||||
error?: ErrorMessage;
|
||||
canceled?: boolean;
|
||||
}
|
||||
|
||||
export interface SaveDBFileResult {
|
||||
filePath?: string;
|
||||
canceled?: boolean;
|
||||
error?: ErrorMessage;
|
||||
}
|
||||
|
||||
export interface SelectDBFileLocationResult {
|
||||
filePath?: string;
|
||||
error?: ErrorMessage;
|
||||
canceled?: boolean;
|
||||
}
|
||||
|
||||
const extension = 'affine';
|
||||
|
||||
function getDefaultDBFileName(name: string, id: string) {
|
||||
const fileName = `${name}_${id}.${extension}`;
|
||||
// make sure fileName is a valid file name
|
||||
return fileName.replace(/[/\\?%*:|"<>]/g, '-');
|
||||
}
|
||||
|
||||
async function resolveExistingPath(path: string) {
|
||||
if (!(await fs.pathExists(path))) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await fs.realpath(path);
|
||||
} catch {
|
||||
return resolve(path);
|
||||
}
|
||||
}
|
||||
|
||||
async function isSameFilePath(sourcePath: string, targetPath: string) {
|
||||
if (resolve(sourcePath) === resolve(targetPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [resolvedSourcePath, resolvedTargetPath] = await Promise.all([
|
||||
resolveExistingPath(sourcePath),
|
||||
resolveExistingPath(targetPath),
|
||||
]);
|
||||
|
||||
return !!resolvedSourcePath && resolvedSourcePath === resolvedTargetPath;
|
||||
}
|
||||
|
||||
async function normalizeImportDBPath(selectedPath: string) {
|
||||
if (!(await fs.pathExists(selectedPath))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [normalizedPath, workspacesBasePath] = await Promise.all([
|
||||
resolveExistingPath(selectedPath),
|
||||
resolveExistingPath(await getWorkspacesBasePath()),
|
||||
]);
|
||||
const resolvedSelectedPath = normalizedPath ?? resolve(selectedPath);
|
||||
const resolvedWorkspacesBasePath =
|
||||
workspacesBasePath ?? resolve(await getWorkspacesBasePath());
|
||||
|
||||
if (isPathInsideBase(resolvedWorkspacesBasePath, resolvedSelectedPath)) {
|
||||
logger.warn('loadDBFile: db file in app data dir');
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolvedSelectedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is called when the user clicks the "Save" button in the "Save Workspace" dialog.
|
||||
*
|
||||
* It will export a compacted database file to the given path
|
||||
*/
|
||||
export async function saveDBFileAs(
|
||||
universalId: string,
|
||||
name: string
|
||||
): Promise<SaveDBFileResult> {
|
||||
try {
|
||||
const { peer, type, id } = parseUniversalId(universalId);
|
||||
const dbPath = await getSpaceDBPath(peer, type, id);
|
||||
|
||||
// connect to the pool and make sure all changes (WAL) are written to db
|
||||
const pool = getDocStoragePool();
|
||||
await pool.connect(universalId, dbPath);
|
||||
await pool.checkpoint(universalId); // make sure all changes (WAL) are written to db
|
||||
|
||||
if (!dbPath) {
|
||||
return {
|
||||
error: 'DB_FILE_PATH_INVALID',
|
||||
};
|
||||
}
|
||||
|
||||
const ret = await mainRPC.showSaveDialog({
|
||||
properties: ['showOverwriteConfirmation'],
|
||||
title: 'Save Workspace',
|
||||
showsTagField: false,
|
||||
buttonLabel: 'Save',
|
||||
filters: [
|
||||
{
|
||||
extensions: [extension],
|
||||
name: '',
|
||||
},
|
||||
],
|
||||
defaultPath: getDefaultDBFileName(name, id),
|
||||
message: 'Save Workspace as a SQLite Database file',
|
||||
});
|
||||
|
||||
const filePath = ret.filePath;
|
||||
if (ret.canceled || !filePath) {
|
||||
return { canceled: true };
|
||||
}
|
||||
|
||||
if (await isSameFilePath(dbPath, filePath)) {
|
||||
return { error: 'DB_FILE_PATH_INVALID' };
|
||||
}
|
||||
|
||||
const tempFilePath = `${filePath}.${nanoid(6)}.tmp`;
|
||||
if (await fs.pathExists(tempFilePath)) {
|
||||
await fs.remove(tempFilePath);
|
||||
}
|
||||
|
||||
try {
|
||||
await pool.vacuumInto(universalId, tempFilePath);
|
||||
await fs.move(tempFilePath, filePath, { overwrite: true });
|
||||
} finally {
|
||||
if (await fs.pathExists(tempFilePath)) {
|
||||
await fs.remove(tempFilePath);
|
||||
}
|
||||
}
|
||||
logger.log('saved', filePath);
|
||||
mainRPC.showItemInFolder(filePath).catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
return { filePath };
|
||||
} catch (err) {
|
||||
logger.error('saveDBFileAs', err);
|
||||
return {
|
||||
error: 'UNKNOWN_ERROR',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function selectDBFileLocation(): Promise<SelectDBFileLocationResult> {
|
||||
try {
|
||||
const ret = await mainRPC.showOpenDialog({
|
||||
properties: ['openDirectory'],
|
||||
title: 'Set Workspace Storage Location',
|
||||
buttonLabel: 'Select',
|
||||
defaultPath: await mainRPC.getPath('documents'),
|
||||
message: "Select a location to store the workspace's database file",
|
||||
});
|
||||
const dir = ret.filePaths?.[0];
|
||||
if (ret.canceled || !dir) {
|
||||
return {
|
||||
canceled: true,
|
||||
};
|
||||
}
|
||||
return { filePath: dir };
|
||||
} catch (err) {
|
||||
logger.error('selectDBFileLocation', err);
|
||||
return {
|
||||
error: (err as any).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is called when the user clicks the "Load" button in the "Load Workspace" dialog.
|
||||
*
|
||||
* It will
|
||||
* - symlink the source db file to a new workspace id to app-data
|
||||
* - return the new workspace id
|
||||
*
|
||||
* eg, it will create a new folder in app-data:
|
||||
* <app-data>/<app-name>/<workspaces|userspaces>/<peer>/<workspace-id>/storage.db
|
||||
*
|
||||
* On the renderer side, after the UI got a new workspace id, it will
|
||||
* update the local workspace id list and then connect to it.
|
||||
*
|
||||
*/
|
||||
export async function loadDBFile(): Promise<LoadDBFileResult> {
|
||||
try {
|
||||
const ret = await mainRPC.showOpenDialog({
|
||||
properties: ['openFile'],
|
||||
title: 'Load Workspace',
|
||||
buttonLabel: 'Load',
|
||||
filters: [
|
||||
{
|
||||
name: 'SQLite Database',
|
||||
// do we want to support other file format?
|
||||
extensions: ['db', 'affine'],
|
||||
},
|
||||
],
|
||||
message: 'Load Workspace from a AFFiNE file',
|
||||
});
|
||||
const selectedPath = ret.filePaths?.[0];
|
||||
if (ret.canceled || !selectedPath) {
|
||||
logger.info('loadDBFile canceled');
|
||||
return { canceled: true };
|
||||
}
|
||||
|
||||
const originalPath = await normalizeImportDBPath(selectedPath);
|
||||
if (!originalPath) {
|
||||
return { error: 'DB_FILE_PATH_INVALID' };
|
||||
}
|
||||
|
||||
const workspaceId = nanoid(10);
|
||||
let storage = new DocStorage(originalPath);
|
||||
|
||||
// if imported db is not a valid v2 db, we will treat it as a v1 db
|
||||
if (!(await storage.validate())) {
|
||||
return await cpV1DBFile(originalPath, workspaceId);
|
||||
}
|
||||
|
||||
if (!(await storage.validateImportSchema())) {
|
||||
return { error: 'DB_FILE_INVALID' };
|
||||
}
|
||||
|
||||
// v2 import logic
|
||||
const internalFilePath = await getSpaceDBPath(
|
||||
'local',
|
||||
'workspace',
|
||||
workspaceId
|
||||
);
|
||||
await fs.ensureDir(parse(internalFilePath).dir);
|
||||
await storage.vacuumInto(internalFilePath);
|
||||
logger.info(`loadDBFile, vacuum: ${originalPath} -> ${internalFilePath}`);
|
||||
|
||||
storage = new DocStorage(internalFilePath);
|
||||
await storage.setSpaceId(workspaceId);
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error('loadDBFile', err);
|
||||
return {
|
||||
error: 'UNKNOWN_ERROR',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function cpV1DBFile(
|
||||
originalPath: string,
|
||||
workspaceId: string
|
||||
): Promise<LoadDBFileResult> {
|
||||
const { SqliteConnection } = await import('@affine/native');
|
||||
|
||||
const validationResult = await SqliteConnection.validate(originalPath);
|
||||
|
||||
if (validationResult !== ValidationResult.Valid) {
|
||||
return { error: 'DB_FILE_INVALID' }; // invalid db file
|
||||
}
|
||||
|
||||
const connection = new SqliteConnection(originalPath);
|
||||
try {
|
||||
if (!(await connection.validateImportSchema())) {
|
||||
return { error: 'DB_FILE_INVALID' };
|
||||
}
|
||||
|
||||
const internalFilePath = await getWorkspaceDBPath('workspace', workspaceId);
|
||||
|
||||
await fs.ensureDir(parse(internalFilePath).dir);
|
||||
await connection.vacuumInto(internalFilePath);
|
||||
logger.info(`loadDBFile, vacuum: ${originalPath} -> ${internalFilePath}`);
|
||||
|
||||
await storeWorkspaceMeta(workspaceId, {
|
||||
id: workspaceId,
|
||||
mainDBPath: internalFilePath,
|
||||
});
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
};
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
}
|
||||
13
packages/frontend/apps/electron/src/helper/dialog/index.ts
Normal file
13
packages/frontend/apps/electron/src/helper/dialog/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { loadDBFile, saveDBFileAs, selectDBFileLocation } from './dialog';
|
||||
|
||||
export const dialogHandlers = {
|
||||
loadDBFile: async () => {
|
||||
return loadDBFile();
|
||||
},
|
||||
saveDBFileAs: async (universalId: string, name: string) => {
|
||||
return saveDBFileAs(universalId, name);
|
||||
},
|
||||
selectDBFileLocation: async () => {
|
||||
return selectDBFileLocation();
|
||||
},
|
||||
};
|
||||
39
packages/frontend/apps/electron/src/helper/exposed.ts
Normal file
39
packages/frontend/apps/electron/src/helper/exposed.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { dialogHandlers } from './dialog';
|
||||
import { dbEventsV1, dbHandlersV1, nbstoreHandlers } from './nbstore';
|
||||
import { previewHandlers } from './preview';
|
||||
import { provideExposed } from './provide';
|
||||
import { workspaceEvents, workspaceHandlers } from './workspace';
|
||||
|
||||
export const handlers = {
|
||||
db: dbHandlersV1,
|
||||
nbstore: nbstoreHandlers,
|
||||
workspace: workspaceHandlers,
|
||||
dialog: dialogHandlers,
|
||||
preview: previewHandlers,
|
||||
};
|
||||
|
||||
export const events = {
|
||||
db: dbEventsV1,
|
||||
workspace: workspaceEvents,
|
||||
};
|
||||
|
||||
const getExposedMeta = () => {
|
||||
const handlersMeta = Object.entries(handlers).map(
|
||||
([namespace, namespaceHandlers]) => {
|
||||
return [namespace, Object.keys(namespaceHandlers)] as [string, string[]];
|
||||
}
|
||||
);
|
||||
|
||||
const eventsMeta = Object.entries(events).map(
|
||||
([namespace, namespaceHandlers]) => {
|
||||
return [namespace, Object.keys(namespaceHandlers)] as [string, string[]];
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
handlers: handlersMeta,
|
||||
events: eventsMeta,
|
||||
};
|
||||
};
|
||||
|
||||
provideExposed(getExposedMeta());
|
||||
85
packages/frontend/apps/electron/src/helper/index.ts
Normal file
85
packages/frontend/apps/electron/src/helper/index.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { AsyncCall } from 'async-call-rpc';
|
||||
|
||||
import type { RendererToHelper } from '../shared/type';
|
||||
import { events, handlers } from './exposed';
|
||||
import { logger } from './logger';
|
||||
|
||||
function setupRendererConnection(rendererPort: Electron.MessagePortMain) {
|
||||
const flattenedHandlers = Object.entries(handlers).flatMap(
|
||||
([namespace, namespaceHandlers]) => {
|
||||
return Object.entries(namespaceHandlers).map(([name, handler]) => {
|
||||
const handlerWithLog = async (...args: any[]) => {
|
||||
try {
|
||||
const start = performance.now();
|
||||
const result = await handler(...args);
|
||||
logger.debug(
|
||||
'[async-api]',
|
||||
`${namespace}.${name}`,
|
||||
args.filter(
|
||||
arg => typeof arg !== 'function' && typeof arg !== 'object'
|
||||
),
|
||||
'-',
|
||||
(performance.now() - start).toFixed(2),
|
||||
'ms'
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('[async-api]', `${namespace}.${name}`, error);
|
||||
// Propagate errors to the renderer so callers don't receive `undefined`
|
||||
// and fail with confusing TypeErrors.
|
||||
throw error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
};
|
||||
return [`${namespace}:${name}`, handlerWithLog];
|
||||
});
|
||||
}
|
||||
);
|
||||
const rpc = AsyncCall<RendererToHelper>(
|
||||
Object.fromEntries(flattenedHandlers),
|
||||
{
|
||||
channel: {
|
||||
on(listener) {
|
||||
const f = (e: Electron.MessageEvent) => {
|
||||
listener(e.data);
|
||||
};
|
||||
rendererPort.on('message', f);
|
||||
// MUST start the connection to receive messages
|
||||
rendererPort.start();
|
||||
return () => {
|
||||
rendererPort.off('message', f);
|
||||
};
|
||||
},
|
||||
send(data) {
|
||||
rendererPort.postMessage(data);
|
||||
},
|
||||
},
|
||||
log: false,
|
||||
}
|
||||
);
|
||||
|
||||
for (const [namespace, namespaceEvents] of Object.entries(events)) {
|
||||
for (const [key, eventRegister] of Object.entries(namespaceEvents)) {
|
||||
const unsub = eventRegister((...args: any[]) => {
|
||||
const chan = `${namespace}:${key}`;
|
||||
rpc.postEvent(chan, ...args).catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
});
|
||||
process.on('exit', () => {
|
||||
unsub();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
process.parentPort.on('message', e => {
|
||||
if (e.data.channel === 'renderer-connect' && e.ports.length === 1) {
|
||||
const rendererPort = e.ports[0];
|
||||
setupRendererConnection(rendererPort);
|
||||
logger.debug('[helper] renderer connected');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
7
packages/frontend/apps/electron/src/helper/logger.ts
Normal file
7
packages/frontend/apps/electron/src/helper/logger.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import log from 'electron-log/main';
|
||||
|
||||
export const logger = log.scope('helper');
|
||||
|
||||
log.transports.file.level = 'info';
|
||||
log.transports.console.level =
|
||||
process.env.NODE_ENV === 'development' ? 'info' : false;
|
||||
34
packages/frontend/apps/electron/src/helper/main-rpc.ts
Normal file
34
packages/frontend/apps/electron/src/helper/main-rpc.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { AsyncCall } from 'async-call-rpc';
|
||||
|
||||
import type { HelperToMain, MainToHelper } from '../shared/type';
|
||||
import { exposed } from './provide';
|
||||
|
||||
const helperToMainServer: HelperToMain = {
|
||||
getMeta: () => {
|
||||
if (!exposed) {
|
||||
throw new Error('Helper is not initialized correctly');
|
||||
}
|
||||
return exposed;
|
||||
},
|
||||
};
|
||||
|
||||
export const mainRPC = AsyncCall<MainToHelper>(helperToMainServer, {
|
||||
strict: {
|
||||
unknownMessage: false,
|
||||
},
|
||||
channel: {
|
||||
on(listener) {
|
||||
const f = (e: Electron.MessageEvent) => {
|
||||
listener(e.data);
|
||||
};
|
||||
process.parentPort.on('message', f);
|
||||
return () => {
|
||||
process.parentPort.off('message', f);
|
||||
};
|
||||
},
|
||||
send(data) {
|
||||
process.parentPort.postMessage(data);
|
||||
},
|
||||
},
|
||||
log: false,
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { DocStoragePool } from '@affine/native';
|
||||
import { parseUniversalId } from '@affine/nbstore';
|
||||
import type { NativeDBApis } from '@affine/nbstore/sqlite';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { getSpaceDBPath } from '../workspace/meta';
|
||||
|
||||
const POOL = new DocStoragePool();
|
||||
|
||||
export function getDocStoragePool() {
|
||||
return POOL;
|
||||
}
|
||||
|
||||
export const nbstoreHandlers: NativeDBApis = {
|
||||
connect: async (universalId: string) => {
|
||||
const { peer, type, id } = parseUniversalId(universalId);
|
||||
const dbPath = await getSpaceDBPath(peer, type, id);
|
||||
await fs.ensureDir(path.dirname(dbPath));
|
||||
await POOL.connect(universalId, dbPath);
|
||||
await POOL.setSpaceId(universalId, id);
|
||||
},
|
||||
disconnect: POOL.disconnect.bind(POOL),
|
||||
pushUpdate: POOL.pushUpdate.bind(POOL),
|
||||
getDocSnapshot: POOL.getDocSnapshot.bind(POOL),
|
||||
setDocSnapshot: POOL.setDocSnapshot.bind(POOL),
|
||||
getDocUpdates: POOL.getDocUpdates.bind(POOL),
|
||||
markUpdatesMerged: POOL.markUpdatesMerged.bind(POOL),
|
||||
deleteDoc: POOL.deleteDoc.bind(POOL),
|
||||
getDocClocks: POOL.getDocClocks.bind(POOL),
|
||||
getDocClock: POOL.getDocClock.bind(POOL),
|
||||
getDocIndexedClock: POOL.getDocIndexedClock.bind(POOL),
|
||||
setDocIndexedClock: POOL.setDocIndexedClock.bind(POOL),
|
||||
clearDocIndexedClock: POOL.clearDocIndexedClock.bind(POOL),
|
||||
getBlob: POOL.getBlob.bind(POOL),
|
||||
setBlob: POOL.setBlob.bind(POOL),
|
||||
deleteBlob: POOL.deleteBlob.bind(POOL),
|
||||
releaseBlobs: POOL.releaseBlobs.bind(POOL),
|
||||
listBlobs: POOL.listBlobs.bind(POOL),
|
||||
getPeerRemoteClocks: POOL.getPeerRemoteClocks.bind(POOL),
|
||||
getPeerRemoteClock: POOL.getPeerRemoteClock.bind(POOL),
|
||||
setPeerRemoteClock: POOL.setPeerRemoteClock.bind(POOL),
|
||||
getPeerPulledRemoteClocks: POOL.getPeerPulledRemoteClocks.bind(POOL),
|
||||
getPeerPulledRemoteClock: POOL.getPeerPulledRemoteClock.bind(POOL),
|
||||
setPeerPulledRemoteClock: POOL.setPeerPulledRemoteClock.bind(POOL),
|
||||
getPeerPushedClocks: POOL.getPeerPushedClocks.bind(POOL),
|
||||
getPeerPushedClock: POOL.getPeerPushedClock.bind(POOL),
|
||||
setPeerPushedClock: POOL.setPeerPushedClock.bind(POOL),
|
||||
clearClocks: POOL.clearClocks.bind(POOL),
|
||||
setBlobUploadedAt: POOL.setBlobUploadedAt.bind(POOL),
|
||||
getBlobUploadedAt: POOL.getBlobUploadedAt.bind(POOL),
|
||||
crawlDocData: POOL.crawlDocData.bind(POOL),
|
||||
setDocIndexedClocks: POOL.setDocIndexedClocks.bind(POOL),
|
||||
indexUpsert: POOL.indexUpsert.bind(POOL),
|
||||
indexDelete: POOL.indexDelete.bind(POOL),
|
||||
indexSearch: POOL.indexSearch.bind(POOL),
|
||||
indexAggregate: POOL.indexAggregate.bind(POOL),
|
||||
indexDeleteByQuery: POOL.indexDeleteByQuery.bind(POOL),
|
||||
indexFlush: POOL.indexFlush.bind(POOL),
|
||||
indexVersion: POOL.indexVersion.bind(POOL),
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { nbstoreHandlers } from './handlers';
|
||||
export { getDocStoragePool } from './handlers';
|
||||
export { dbEvents as dbEventsV1, dbHandlers as dbHandlersV1 } from './v1';
|
||||
export { universalId } from '@affine/nbstore';
|
||||
@@ -0,0 +1,266 @@
|
||||
import type { InsertRow } from '@affine/native';
|
||||
import { SqliteConnection } from '@affine/native';
|
||||
import type { ByteKVBehavior } from '@toeverything/infra/storage';
|
||||
|
||||
import { logger } from '../../logger';
|
||||
|
||||
/**
|
||||
* A base class for SQLite DB adapter that provides basic methods around updates & blobs
|
||||
*/
|
||||
export class SQLiteAdapter {
|
||||
db: SqliteConnection | null = null;
|
||||
constructor(public readonly path: string) {}
|
||||
|
||||
async connectIfNeeded() {
|
||||
if (!this.db) {
|
||||
this.db = new SqliteConnection(this.path);
|
||||
await this.db.connect();
|
||||
logger.info(`[SQLiteAdapter]`, 'connected:', this.path);
|
||||
}
|
||||
return this.db;
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
const { db } = this;
|
||||
this.db = null;
|
||||
// log after close will sometimes crash the app when quitting
|
||||
logger.info(`[SQLiteAdapter]`, 'destroyed:', this.path);
|
||||
await db?.close();
|
||||
}
|
||||
|
||||
async addBlob(key: string, data: Uint8Array) {
|
||||
try {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return;
|
||||
}
|
||||
await this.db.addBlob(key, data);
|
||||
} catch (error) {
|
||||
logger.error('addBlob', error);
|
||||
}
|
||||
}
|
||||
|
||||
async getBlob(key: string) {
|
||||
try {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return null;
|
||||
}
|
||||
const blob = await this.db.getBlob(key);
|
||||
return blob?.data ?? null;
|
||||
} catch (error) {
|
||||
logger.error('getBlob', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteBlob(key: string) {
|
||||
try {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return;
|
||||
}
|
||||
await this.db.deleteBlob(key);
|
||||
} catch (error) {
|
||||
logger.error(`${this.path} delete blob failed`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async getBlobKeys() {
|
||||
try {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return [];
|
||||
}
|
||||
return await this.db.getBlobKeys();
|
||||
} catch (error) {
|
||||
logger.error(`getBlobKeys failed`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getUpdates(docId?: string) {
|
||||
try {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return [];
|
||||
}
|
||||
return await this.db.getUpdates(docId);
|
||||
} catch (error) {
|
||||
logger.error('getUpdates', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getAllUpdates() {
|
||||
try {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return [];
|
||||
}
|
||||
return await this.db.getAllUpdates();
|
||||
} catch (error) {
|
||||
logger.error('getAllUpdates', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// add a single update to SQLite
|
||||
async addUpdateToSQLite(updates: InsertRow[]) {
|
||||
// batch write instead write per key stroke?
|
||||
try {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return;
|
||||
}
|
||||
const start = performance.now();
|
||||
await this.db.insertUpdates(updates);
|
||||
logger.debug(
|
||||
`[SQLiteAdapter] addUpdateToSQLite`,
|
||||
'length:',
|
||||
updates.length,
|
||||
'docids',
|
||||
updates.map(u => u.docId),
|
||||
performance.now() - start,
|
||||
'ms'
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('addUpdateToSQLite', this.path, error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteUpdates(docId?: string) {
|
||||
try {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return;
|
||||
}
|
||||
await this.db.deleteUpdates(docId);
|
||||
} catch (error) {
|
||||
logger.error('deleteUpdates', error);
|
||||
}
|
||||
}
|
||||
|
||||
async checkpoint() {
|
||||
try {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return;
|
||||
}
|
||||
await this.db.checkpoint();
|
||||
} catch (error) {
|
||||
logger.error('checkpoint', error);
|
||||
}
|
||||
}
|
||||
|
||||
async getUpdatesCount(docId?: string) {
|
||||
try {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return 0;
|
||||
}
|
||||
return await this.db.getUpdatesCount(docId);
|
||||
} catch (error) {
|
||||
logger.error('getUpdatesCount', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async replaceUpdates(docId: string | null | undefined, updates: InsertRow[]) {
|
||||
try {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return;
|
||||
}
|
||||
await this.db.replaceUpdates(docId, updates);
|
||||
} catch (error) {
|
||||
logger.error('replaceUpdates', error);
|
||||
}
|
||||
}
|
||||
|
||||
serverClock: ByteKVBehavior = {
|
||||
get: async key => {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return null;
|
||||
}
|
||||
const blob = await this.db.getServerClock(key);
|
||||
return blob?.data ?? null;
|
||||
},
|
||||
set: async (key, data) => {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return;
|
||||
}
|
||||
await this.db.setServerClock(key, data);
|
||||
},
|
||||
keys: async () => {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return [];
|
||||
}
|
||||
return await this.db.getServerClockKeys();
|
||||
},
|
||||
del: async key => {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return;
|
||||
}
|
||||
await this.db.delServerClock(key);
|
||||
},
|
||||
clear: async () => {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return;
|
||||
}
|
||||
await this.db.clearServerClock();
|
||||
},
|
||||
};
|
||||
|
||||
syncMetadata: ByteKVBehavior = {
|
||||
get: async key => {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return null;
|
||||
}
|
||||
const blob = await this.db.getSyncMetadata(key);
|
||||
return blob?.data ?? null;
|
||||
},
|
||||
set: async (key, data) => {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return;
|
||||
}
|
||||
await this.db.setSyncMetadata(key, data);
|
||||
},
|
||||
keys: async () => {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return [];
|
||||
}
|
||||
return await this.db.getSyncMetadataKeys();
|
||||
},
|
||||
del: async key => {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return;
|
||||
}
|
||||
await this.db.delSyncMetadata(key);
|
||||
},
|
||||
clear: async () => {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return;
|
||||
}
|
||||
await this.db.clearSyncMetadata();
|
||||
},
|
||||
};
|
||||
|
||||
async getDocTimestamps() {
|
||||
if (!this.db) {
|
||||
logger.warn(`${this.path} is not connected`);
|
||||
return [];
|
||||
}
|
||||
return await this.db.getDocTimestamps();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
import type { SpaceType } from '@affine/nbstore';
|
||||
|
||||
import { logger } from '../../logger';
|
||||
import { getWorkspaceMeta } from '../../workspace/meta';
|
||||
import type { WorkspaceSQLiteDB } from './workspace-db-adapter';
|
||||
import { openWorkspaceDatabase } from './workspace-db-adapter';
|
||||
|
||||
// export for testing
|
||||
export const db$Map = new Map<
|
||||
`${SpaceType}:${string}`,
|
||||
Promise<WorkspaceSQLiteDB>
|
||||
>();
|
||||
|
||||
async function getWorkspaceDB(spaceType: SpaceType, id: string) {
|
||||
const cacheId = `${spaceType}:${id}` as const;
|
||||
let db = await db$Map.get(cacheId);
|
||||
if (!db) {
|
||||
const promise = openWorkspaceDatabase(spaceType, id);
|
||||
db$Map.set(cacheId, promise);
|
||||
const _db = (db = await promise);
|
||||
const cleanup = () => {
|
||||
db$Map.delete(cacheId);
|
||||
_db
|
||||
.destroy()
|
||||
.then(() => {
|
||||
logger.info('[ensureSQLiteDB] db connection closed', _db.workspaceId);
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error('[ensureSQLiteDB] destroy db failed', err);
|
||||
});
|
||||
};
|
||||
|
||||
db.update$.subscribe({
|
||||
complete: cleanup,
|
||||
});
|
||||
|
||||
process.on('beforeExit', cleanup);
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
return db!;
|
||||
}
|
||||
|
||||
export async function ensureSQLiteDB(
|
||||
spaceType: SpaceType,
|
||||
id: string
|
||||
): Promise<WorkspaceSQLiteDB | null> {
|
||||
const meta = await getWorkspaceMeta(spaceType, id);
|
||||
|
||||
// do not auto create v1 db anymore
|
||||
if (!existsSync(meta.mainDBPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getWorkspaceDB(spaceType, id);
|
||||
}
|
||||
|
||||
export async function ensureSQLiteDisconnected(
|
||||
spaceType: SpaceType,
|
||||
id: string
|
||||
) {
|
||||
const db = await ensureSQLiteDB(spaceType, id);
|
||||
|
||||
if (db) {
|
||||
await db.checkpoint();
|
||||
await db.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { SpaceType } from '@affine/nbstore';
|
||||
|
||||
import type { MainEventRegister } from '../../type';
|
||||
import { ensureSQLiteDB } from './ensure-db';
|
||||
|
||||
export * from './ensure-db';
|
||||
|
||||
export const dbHandlers = {
|
||||
getDocAsUpdates: async (
|
||||
spaceType: SpaceType,
|
||||
workspaceId: string,
|
||||
subdocId: string
|
||||
) => {
|
||||
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
|
||||
|
||||
if (!spaceDB) {
|
||||
// means empty update in yjs
|
||||
return new Uint8Array([0, 0]);
|
||||
}
|
||||
|
||||
return spaceDB.getDocAsUpdates(subdocId);
|
||||
},
|
||||
getDocTimestamps: async (spaceType: SpaceType, workspaceId: string) => {
|
||||
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
|
||||
|
||||
if (!spaceDB) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return spaceDB.getDocTimestamps();
|
||||
},
|
||||
getBlob: async (spaceType: SpaceType, workspaceId: string, key: string) => {
|
||||
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
|
||||
|
||||
if (!spaceDB) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return spaceDB.getBlob(key);
|
||||
},
|
||||
getBlobKeys: async (spaceType: SpaceType, workspaceId: string) => {
|
||||
const spaceDB = await ensureSQLiteDB(spaceType, workspaceId);
|
||||
|
||||
if (!spaceDB) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return spaceDB.getBlobKeys();
|
||||
},
|
||||
};
|
||||
|
||||
export const dbEvents = {} satisfies Record<string, MainEventRegister>;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { applyUpdate, Doc as YDoc, encodeStateAsUpdate, transact } from 'yjs';
|
||||
|
||||
export function mergeUpdate(updates: Uint8Array[]) {
|
||||
if (updates.length === 0) {
|
||||
return new Uint8Array();
|
||||
}
|
||||
if (updates.length === 1) {
|
||||
return updates[0];
|
||||
}
|
||||
const yDoc = new YDoc();
|
||||
transact(yDoc, () => {
|
||||
for (const update of updates) {
|
||||
applyUpdate(yDoc, update);
|
||||
}
|
||||
});
|
||||
return encodeStateAsUpdate(yDoc);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { SpaceType } from '@affine/nbstore';
|
||||
import { AsyncLock } from '@toeverything/infra/utils';
|
||||
import { Subject } from 'rxjs';
|
||||
import { applyUpdate, Doc as YDoc } from 'yjs';
|
||||
|
||||
import { logger } from '../../logger';
|
||||
import { getWorkspaceMeta } from '../../workspace/meta';
|
||||
import { SQLiteAdapter } from './db-adapter';
|
||||
import { mergeUpdate } from './merge-update';
|
||||
|
||||
const TRIM_SIZE = 1;
|
||||
|
||||
export class WorkspaceSQLiteDB implements AsyncDisposable {
|
||||
lock = new AsyncLock();
|
||||
update$ = new Subject<void>();
|
||||
adapter = new SQLiteAdapter(this.path);
|
||||
|
||||
constructor(
|
||||
public path: string,
|
||||
public workspaceId: string
|
||||
) {}
|
||||
|
||||
async transaction<T>(cb: () => Promise<T>): Promise<T> {
|
||||
using _lock = await this.lock.acquire();
|
||||
return await cb();
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
await this.adapter.destroy();
|
||||
|
||||
// when db is closed, we can safely remove it from ensure-db list
|
||||
this.update$.complete();
|
||||
}
|
||||
|
||||
[Symbol.asyncDispose] = async () => {
|
||||
await this.destroy();
|
||||
};
|
||||
|
||||
private readonly toDBDocId = (docId: string) => {
|
||||
return this.workspaceId === docId ? undefined : docId;
|
||||
};
|
||||
|
||||
getWorkspaceMeta = async () => {
|
||||
const ydoc = new YDoc();
|
||||
const updates = await this.adapter.getUpdates();
|
||||
updates.forEach(update => {
|
||||
applyUpdate(ydoc, update.data);
|
||||
});
|
||||
logger.log(
|
||||
`ydoc.getMap('meta').get('name')`,
|
||||
ydoc.getMap('meta').get('name'),
|
||||
this.path,
|
||||
updates.length
|
||||
);
|
||||
return ydoc.getMap('meta').toJSON();
|
||||
};
|
||||
|
||||
getWorkspaceName = async () => {
|
||||
const meta = await this.getWorkspaceMeta();
|
||||
return meta.name;
|
||||
};
|
||||
|
||||
async init() {
|
||||
const db = await this.adapter.connectIfNeeded();
|
||||
await this.tryTrim();
|
||||
return db;
|
||||
}
|
||||
|
||||
async get(docId: string) {
|
||||
return this.adapter.getUpdates(docId);
|
||||
}
|
||||
|
||||
// getUpdates then encode
|
||||
getDocAsUpdates = async (docId: string) => {
|
||||
const dbID = this.toDBDocId(docId);
|
||||
const update = await this.tryTrim(dbID);
|
||||
if (update) {
|
||||
return update;
|
||||
} else {
|
||||
const updates = await this.adapter.getUpdates(dbID);
|
||||
return mergeUpdate(updates.map(row => row.data));
|
||||
}
|
||||
};
|
||||
|
||||
async getDocTimestamps() {
|
||||
return this.adapter.getDocTimestamps();
|
||||
}
|
||||
|
||||
async addBlob(key: string, value: Uint8Array) {
|
||||
this.update$.next();
|
||||
const res = await this.adapter.addBlob(key, value);
|
||||
return res;
|
||||
}
|
||||
|
||||
async getBlob(key: string) {
|
||||
return this.adapter.getBlob(key);
|
||||
}
|
||||
|
||||
async getBlobKeys() {
|
||||
return this.adapter.getBlobKeys();
|
||||
}
|
||||
|
||||
async deleteBlob(key: string) {
|
||||
this.update$.next();
|
||||
await this.adapter.deleteBlob(key);
|
||||
}
|
||||
|
||||
async addUpdateToSQLite(update: Uint8Array, subdocId: string) {
|
||||
this.update$.next();
|
||||
await this.transaction(async () => {
|
||||
const dbID = this.toDBDocId(subdocId);
|
||||
const oldUpdate = await this.adapter.getUpdates(dbID);
|
||||
await this.adapter.replaceUpdates(dbID, [
|
||||
{
|
||||
data: mergeUpdate([...oldUpdate.map(u => u.data), update]),
|
||||
docId: dbID,
|
||||
},
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUpdate(subdocId: string) {
|
||||
this.update$.next();
|
||||
await this.adapter.deleteUpdates(this.toDBDocId(subdocId));
|
||||
}
|
||||
|
||||
private readonly tryTrim = async (dbID?: string) => {
|
||||
const count = (await this.adapter?.getUpdatesCount(dbID)) ?? 0;
|
||||
if (count > TRIM_SIZE) {
|
||||
return await this.transaction(async () => {
|
||||
logger.debug(`trim ${this.workspaceId}:${dbID} ${count}`);
|
||||
const updates = await this.adapter.getUpdates(dbID);
|
||||
const update = mergeUpdate(updates.map(row => row.data));
|
||||
const insertRows = [{ data: update, docId: dbID }];
|
||||
await this.adapter?.replaceUpdates(dbID, insertRows);
|
||||
logger.debug(`trim ${this.workspaceId}:${dbID} successfully`);
|
||||
return update;
|
||||
});
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
async checkpoint() {
|
||||
await this.adapter.checkpoint();
|
||||
}
|
||||
}
|
||||
|
||||
export async function openWorkspaceDatabase(
|
||||
spaceType: SpaceType,
|
||||
spaceId: string
|
||||
) {
|
||||
const meta = await getWorkspaceMeta(spaceType, spaceId);
|
||||
const db = new WorkspaceSQLiteDB(meta.mainDBPath, spaceId);
|
||||
await db.init();
|
||||
logger.info(`openWorkspaceDatabase [${spaceId}]`);
|
||||
return db;
|
||||
}
|
||||
69
packages/frontend/apps/electron/src/helper/preview/index.ts
Normal file
69
packages/frontend/apps/electron/src/helper/preview/index.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
type MermaidRenderRequest,
|
||||
type MermaidRenderResult,
|
||||
renderMermaidSvg,
|
||||
renderTypstSvg,
|
||||
type TypstRenderRequest,
|
||||
type TypstRenderResult,
|
||||
} from '@affine/native';
|
||||
|
||||
const TYPST_FONT_DIRS_ENV = 'AFFINE_TYPST_FONT_DIRS';
|
||||
|
||||
function parseTypstFontDirsFromEnv() {
|
||||
const value = process.env[TYPST_FONT_DIRS_ENV];
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.split(path.delimiter)
|
||||
.map(dir => dir.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getTypstFontDirCandidates() {
|
||||
const resourcesPath = process.resourcesPath ?? '';
|
||||
|
||||
return [
|
||||
...parseTypstFontDirsFromEnv(),
|
||||
path.join(resourcesPath, 'fonts'),
|
||||
path.join(resourcesPath, 'js', 'fonts'),
|
||||
path.join(resourcesPath, 'app.asar.unpacked', 'fonts'),
|
||||
path.join(resourcesPath, 'app.asar.unpacked', 'js', 'fonts'),
|
||||
];
|
||||
}
|
||||
|
||||
function resolveTypstFontDirs() {
|
||||
return Array.from(
|
||||
new Set(getTypstFontDirCandidates().map(dir => path.resolve(dir)))
|
||||
).filter(dir => fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory());
|
||||
}
|
||||
|
||||
function withTypstFontDirs(
|
||||
request: TypstRenderRequest,
|
||||
fontDirs: string[]
|
||||
): TypstRenderRequest {
|
||||
const nextOptions = request.options ? { ...request.options } : {};
|
||||
if (!nextOptions.fontDirs?.length) {
|
||||
nextOptions.fontDirs = fontDirs;
|
||||
}
|
||||
return { ...request, options: nextOptions };
|
||||
}
|
||||
|
||||
const typstFontDirs = resolveTypstFontDirs();
|
||||
|
||||
export const previewHandlers = {
|
||||
renderMermaidSvg: async (
|
||||
request: MermaidRenderRequest
|
||||
): Promise<MermaidRenderResult> => {
|
||||
return renderMermaidSvg(request);
|
||||
},
|
||||
renderTypstSvg: async (
|
||||
request: TypstRenderRequest
|
||||
): Promise<TypstRenderResult> => {
|
||||
return renderTypstSvg(withTypstFontDirs(request, typstFontDirs));
|
||||
},
|
||||
};
|
||||
11
packages/frontend/apps/electron/src/helper/provide.ts
Normal file
11
packages/frontend/apps/electron/src/helper/provide.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { ExposedMeta } from '../shared/type';
|
||||
|
||||
/**
|
||||
* A naive DI implementation to get rid of circular dependency.
|
||||
*/
|
||||
|
||||
export let exposed: ExposedMeta | undefined;
|
||||
|
||||
export const provideExposed = (exposedMeta: ExposedMeta) => {
|
||||
exposed = exposedMeta;
|
||||
};
|
||||
8
packages/frontend/apps/electron/src/helper/type.ts
Normal file
8
packages/frontend/apps/electron/src/helper/type.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface WorkspaceMeta {
|
||||
id: string;
|
||||
mainDBPath: string;
|
||||
}
|
||||
|
||||
export type YOrigin = 'self' | 'external' | 'upstream' | 'renderer';
|
||||
|
||||
export type MainEventRegister = (...args: any[]) => () => void;
|
||||
351
packages/frontend/apps/electron/src/helper/workspace/handlers.ts
Normal file
351
packages/frontend/apps/electron/src/helper/workspace/handlers.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { DocStorage, ValidationResult } from '@affine/native';
|
||||
import {
|
||||
parseUniversalId,
|
||||
universalId as generateUniversalId,
|
||||
} from '@affine/nbstore';
|
||||
import fs from 'fs-extra';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { applyUpdate, Doc as YDoc } from 'yjs';
|
||||
|
||||
import {
|
||||
normalizeWorkspaceIdForPath,
|
||||
resolveExistingPathInBase,
|
||||
} from '../../shared/utils';
|
||||
import { logger } from '../logger';
|
||||
import { getDocStoragePool } from '../nbstore';
|
||||
import { ensureSQLiteDisconnected } from '../nbstore/v1/ensure-db';
|
||||
import { WorkspaceSQLiteDB } from '../nbstore/v1/workspace-db-adapter';
|
||||
import type { WorkspaceMeta } from '../type';
|
||||
import {
|
||||
getDeletedWorkspacesBasePath,
|
||||
getSpaceBasePath,
|
||||
getSpaceDBPath,
|
||||
getWorkspaceBasePathV1,
|
||||
getWorkspaceDBPath,
|
||||
getWorkspaceMeta,
|
||||
} from './meta';
|
||||
|
||||
async function deleteWorkspaceV1(workspaceId: string) {
|
||||
try {
|
||||
await ensureSQLiteDisconnected('workspace', workspaceId);
|
||||
const basePath = await getWorkspaceBasePathV1('workspace', workspaceId);
|
||||
await fs.remove(basePath);
|
||||
} catch (error) {
|
||||
logger.error('deleteWorkspaceV1', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete the workspace data
|
||||
*/
|
||||
export async function deleteWorkspace(universalId: string) {
|
||||
const { peer, type, id } = parseUniversalId(universalId);
|
||||
await deleteWorkspaceV1(id);
|
||||
|
||||
const dbPath = await getSpaceDBPath(peer, type, id);
|
||||
try {
|
||||
await getDocStoragePool().disconnect(universalId);
|
||||
await fs.remove(path.dirname(dbPath));
|
||||
} catch (e) {
|
||||
logger.error('deleteWorkspace', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the workspace folder to `deleted-workspaces`
|
||||
* At the same time, permanently delete the v1 workspace folder if it's id exists in nbstore,
|
||||
* because trashing always happens after full sync from v1 to nbstore.
|
||||
*/
|
||||
export async function trashWorkspace(universalId: string) {
|
||||
const { peer, type, id } = parseUniversalId(universalId);
|
||||
await deleteWorkspaceV1(id);
|
||||
|
||||
const dbPath = await getSpaceDBPath(peer, type, id);
|
||||
const basePath = await getDeletedWorkspacesBasePath();
|
||||
const movedPath = path.join(basePath, normalizeWorkspaceIdForPath(id));
|
||||
try {
|
||||
const storage = new DocStorage(dbPath);
|
||||
if (await storage.validate()) {
|
||||
const pool = getDocStoragePool();
|
||||
await pool.checkpoint(universalId);
|
||||
await pool.disconnect(universalId);
|
||||
}
|
||||
await fs.ensureDir(movedPath);
|
||||
// todo(@pengx17): it seems the db file is still being used at the point
|
||||
// on windows so that it cannot be moved. we will fallback to copy the dir instead.
|
||||
await fs.copy(path.dirname(dbPath), movedPath, {
|
||||
overwrite: true,
|
||||
});
|
||||
await fs.remove(path.dirname(dbPath));
|
||||
} catch (error) {
|
||||
logger.error('trashWorkspace', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function storeWorkspaceMeta(
|
||||
workspaceId: string,
|
||||
meta: Partial<WorkspaceMeta>
|
||||
) {
|
||||
try {
|
||||
const basePath = await getWorkspaceBasePathV1('workspace', workspaceId);
|
||||
await fs.ensureDir(basePath);
|
||||
const metaPath = path.join(basePath, 'meta.json');
|
||||
const currentMeta = await getWorkspaceMeta('workspace', workspaceId);
|
||||
const newMeta = {
|
||||
...currentMeta,
|
||||
...meta,
|
||||
};
|
||||
await fs.writeJSON(metaPath, newMeta);
|
||||
} catch (err) {
|
||||
logger.error('storeWorkspaceMeta failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listLocalWorkspaceIds(): Promise<string[]> {
|
||||
const localWorkspaceBasePath = path.join(
|
||||
await getSpaceBasePath('workspace'),
|
||||
'local'
|
||||
);
|
||||
if (!(await fs.pathExists(localWorkspaceBasePath))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const deletedWorkspaceBasePath = await getDeletedWorkspacesBasePath();
|
||||
const deletedWorkspaceIds = new Set<string>(
|
||||
(await fs.readdir(deletedWorkspaceBasePath).catch(() => [])).filter(Boolean)
|
||||
);
|
||||
|
||||
const entries = await fs.readdir(localWorkspaceBasePath);
|
||||
const ids = await Promise.all(
|
||||
entries.map(async entry => {
|
||||
if (deletedWorkspaceIds.has(entry)) {
|
||||
return null;
|
||||
}
|
||||
const workspacePath = path.join(localWorkspaceBasePath, entry);
|
||||
const stat = await fs.stat(workspacePath).catch(() => null);
|
||||
if (!stat?.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
if (!(await fs.pathExists(path.join(workspacePath, 'storage.db')))) {
|
||||
return null;
|
||||
}
|
||||
return entry;
|
||||
})
|
||||
);
|
||||
|
||||
return ids.filter((id): id is string => typeof id === 'string');
|
||||
}
|
||||
|
||||
type WorkspaceDocMeta = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: Uint8Array | null;
|
||||
fileSize: number;
|
||||
updatedAt: Date;
|
||||
createdAt: Date;
|
||||
docCount: number;
|
||||
dbPath: string;
|
||||
};
|
||||
|
||||
async function getWorkspaceDocMetaV1(
|
||||
workspaceId: string,
|
||||
dbPath: string
|
||||
): Promise<WorkspaceDocMeta | null> {
|
||||
try {
|
||||
await using db = new WorkspaceSQLiteDB(dbPath, workspaceId);
|
||||
await db.init();
|
||||
await db.checkpoint();
|
||||
const meta = await db.getWorkspaceMeta();
|
||||
const dbFileSize = await fs.stat(dbPath);
|
||||
return {
|
||||
id: workspaceId,
|
||||
name: meta.name,
|
||||
avatar: await db.getBlob(meta.avatar),
|
||||
fileSize: dbFileSize.size,
|
||||
updatedAt: dbFileSize.mtime,
|
||||
createdAt: dbFileSize.birthtime,
|
||||
docCount: meta.pages.length,
|
||||
dbPath,
|
||||
};
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getWorkspaceDocMeta(
|
||||
workspaceId: string,
|
||||
dbPath: string
|
||||
): Promise<WorkspaceDocMeta | null> {
|
||||
const pool = getDocStoragePool();
|
||||
const universalId = generateUniversalId({
|
||||
peer: 'deleted-local',
|
||||
type: 'workspace',
|
||||
id: workspaceId,
|
||||
});
|
||||
try {
|
||||
await pool.connect(universalId, dbPath);
|
||||
await pool.checkpoint(universalId);
|
||||
const snapshot = await pool.getDocSnapshot(universalId, workspaceId);
|
||||
const pendingUpdates = await pool.getDocUpdates(universalId, workspaceId);
|
||||
if (snapshot) {
|
||||
const updates = snapshot.bin;
|
||||
const ydoc = new YDoc();
|
||||
applyUpdate(ydoc, updates);
|
||||
pendingUpdates.forEach(update => {
|
||||
applyUpdate(ydoc, update.bin);
|
||||
});
|
||||
const meta = ydoc.getMap('meta').toJSON();
|
||||
const dbFileStat = await fs.stat(dbPath);
|
||||
const blob = meta.avatar
|
||||
? await pool.getBlob(universalId, meta.avatar)
|
||||
: null;
|
||||
return {
|
||||
id: workspaceId,
|
||||
name: meta.name,
|
||||
avatar: blob ? blob.data : null,
|
||||
fileSize: dbFileStat.size,
|
||||
updatedAt: dbFileStat.mtime,
|
||||
createdAt: dbFileStat.birthtime,
|
||||
docCount: meta.pages.length,
|
||||
dbPath,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// try using v1
|
||||
return await getWorkspaceDocMetaV1(workspaceId, dbPath);
|
||||
} finally {
|
||||
await pool.disconnect(universalId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getDeletedWorkspaces() {
|
||||
const basePath = await getDeletedWorkspacesBasePath();
|
||||
const directories = await fs.readdir(basePath);
|
||||
const workspaceEntries = await Promise.all(
|
||||
directories.map(async dir => {
|
||||
const stats = await fs.stat(path.join(basePath, dir));
|
||||
if (!stats.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
const dbfileStats = await fs.stat(path.join(basePath, dir, 'storage.db'));
|
||||
return {
|
||||
id: dir,
|
||||
mtime: new Date(dbfileStats.mtime),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const workspaceIds = workspaceEntries
|
||||
.filter(v => v !== null)
|
||||
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
|
||||
.map(entry => entry.id);
|
||||
|
||||
const items: WorkspaceDocMeta[] = [];
|
||||
|
||||
// todo(@pengx17): add cursor based pagination
|
||||
for (const id of workspaceIds) {
|
||||
const meta = await getWorkspaceDocMeta(
|
||||
id,
|
||||
path.join(basePath, id, 'storage.db')
|
||||
);
|
||||
if (meta) {
|
||||
items.push(meta);
|
||||
} else {
|
||||
logger.warn('getDeletedWorkspaces', `No meta found for ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items: items,
|
||||
};
|
||||
}
|
||||
|
||||
async function importLegacyWorkspaceDb(
|
||||
originalPath: string,
|
||||
workspaceId: string
|
||||
) {
|
||||
const { SqliteConnection } = await import('@affine/native');
|
||||
|
||||
const validationResult = await SqliteConnection.validate(originalPath);
|
||||
if (validationResult !== ValidationResult.Valid) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const connection = new SqliteConnection(originalPath);
|
||||
if (!(await connection.validateImportSchema())) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const internalFilePath = await getWorkspaceDBPath('workspace', workspaceId);
|
||||
await fs.ensureDir(path.parse(internalFilePath).dir);
|
||||
await connection.vacuumInto(internalFilePath);
|
||||
logger.info(
|
||||
`recoverBackupWorkspace, vacuum: ${originalPath} -> ${internalFilePath}`
|
||||
);
|
||||
|
||||
await storeWorkspaceMeta(workspaceId, {
|
||||
id: workspaceId,
|
||||
mainDBPath: internalFilePath,
|
||||
});
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
async function importWorkspaceDb(originalPath: string) {
|
||||
const workspaceId = nanoid(10);
|
||||
let storage = new DocStorage(originalPath);
|
||||
|
||||
if (!(await storage.validate())) {
|
||||
return await importLegacyWorkspaceDb(originalPath, workspaceId);
|
||||
}
|
||||
|
||||
if (!(await storage.validateImportSchema())) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const internalFilePath = await getSpaceDBPath(
|
||||
'local',
|
||||
'workspace',
|
||||
workspaceId
|
||||
);
|
||||
await fs.ensureDir(path.parse(internalFilePath).dir);
|
||||
await storage.vacuumInto(internalFilePath);
|
||||
logger.info(
|
||||
`recoverBackupWorkspace, vacuum: ${originalPath} -> ${internalFilePath}`
|
||||
);
|
||||
|
||||
storage = new DocStorage(internalFilePath);
|
||||
await storage.setSpaceId(workspaceId);
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteBackupWorkspace(id: string) {
|
||||
const basePath = await getDeletedWorkspacesBasePath();
|
||||
const workspacePath = path.join(basePath, normalizeWorkspaceIdForPath(id));
|
||||
await fs.remove(workspacePath);
|
||||
logger.info(
|
||||
'deleteBackupWorkspace',
|
||||
`Deleted backup workspace: ${workspacePath}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function recoverBackupWorkspace(id: string) {
|
||||
const basePath = await getDeletedWorkspacesBasePath();
|
||||
const workspacePath = path.join(basePath, normalizeWorkspaceIdForPath(id));
|
||||
const dbPath = await resolveExistingPathInBase(
|
||||
basePath,
|
||||
path.join(workspacePath, 'storage.db'),
|
||||
{ label: 'backup workspace filepath' }
|
||||
);
|
||||
|
||||
return await importWorkspaceDb(dbPath);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { MainEventRegister } from '../type';
|
||||
import {
|
||||
deleteBackupWorkspace,
|
||||
deleteWorkspace,
|
||||
getDeletedWorkspaces,
|
||||
listLocalWorkspaceIds,
|
||||
recoverBackupWorkspace,
|
||||
trashWorkspace,
|
||||
} from './handlers';
|
||||
|
||||
export * from './handlers';
|
||||
export * from './subjects';
|
||||
|
||||
export const workspaceEvents = {} as Record<string, MainEventRegister>;
|
||||
|
||||
export const workspaceHandlers = {
|
||||
delete: deleteWorkspace,
|
||||
moveToTrash: trashWorkspace,
|
||||
getBackupWorkspaces: async () => {
|
||||
return getDeletedWorkspaces();
|
||||
},
|
||||
deleteBackupWorkspace: async (id: string) => deleteBackupWorkspace(id),
|
||||
recoverBackupWorkspace: async (id: string) => recoverBackupWorkspace(id),
|
||||
listLocalWorkspaceIds: async () => listLocalWorkspaceIds(),
|
||||
};
|
||||
103
packages/frontend/apps/electron/src/helper/workspace/meta.ts
Normal file
103
packages/frontend/apps/electron/src/helper/workspace/meta.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { type SpaceType } from '@affine/nbstore';
|
||||
|
||||
import { normalizeWorkspaceIdForPath } from '../../shared/utils';
|
||||
import { mainRPC } from '../main-rpc';
|
||||
import type { WorkspaceMeta } from '../type';
|
||||
|
||||
let _appDataPath = '';
|
||||
|
||||
export async function getAppDataPath() {
|
||||
if (_appDataPath) {
|
||||
return _appDataPath;
|
||||
}
|
||||
_appDataPath = await mainRPC.getPath('sessionData');
|
||||
return _appDataPath;
|
||||
}
|
||||
|
||||
export async function getWorkspacesBasePath() {
|
||||
return path.join(await getAppDataPath(), 'workspaces');
|
||||
}
|
||||
|
||||
export async function getWorkspaceBasePathV1(
|
||||
spaceType: SpaceType,
|
||||
workspaceId: string
|
||||
) {
|
||||
const safeWorkspaceId = normalizeWorkspaceIdForPath(workspaceId);
|
||||
return path.join(
|
||||
await getAppDataPath(),
|
||||
spaceType === 'userspace' ? 'userspaces' : 'workspaces',
|
||||
safeWorkspaceId
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSpaceBasePath(spaceType: SpaceType) {
|
||||
return path.join(
|
||||
await getAppDataPath(),
|
||||
spaceType === 'userspace' ? 'userspaces' : 'workspaces'
|
||||
);
|
||||
}
|
||||
|
||||
export function escapeFilename(name: string) {
|
||||
// replace all special characters with '_' and replace repeated '_' with a single '_' and remove trailing '_'
|
||||
return name
|
||||
.replaceAll(/[\\/!@#$%^&*()+~`"':;,?<>|]/g, '_')
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.join('_');
|
||||
}
|
||||
|
||||
export async function getSpaceDBPath(
|
||||
peer: string,
|
||||
spaceType: SpaceType,
|
||||
id: string
|
||||
) {
|
||||
const safeId = normalizeWorkspaceIdForPath(id);
|
||||
return path.join(
|
||||
await getSpaceBasePath(spaceType),
|
||||
escapeFilename(peer),
|
||||
safeId,
|
||||
'storage.db'
|
||||
);
|
||||
}
|
||||
|
||||
export async function getDeletedWorkspacesBasePath() {
|
||||
return path.join(await getAppDataPath(), 'deleted-workspaces');
|
||||
}
|
||||
|
||||
export async function getWorkspaceDBPath(
|
||||
spaceType: SpaceType,
|
||||
workspaceId: string
|
||||
) {
|
||||
return path.join(
|
||||
await getWorkspaceBasePathV1(spaceType, workspaceId),
|
||||
'storage.db'
|
||||
);
|
||||
}
|
||||
|
||||
export async function getWorkspaceMetaPath(
|
||||
spaceType: SpaceType,
|
||||
workspaceId: string
|
||||
) {
|
||||
return path.join(
|
||||
await getWorkspaceBasePathV1(spaceType, workspaceId),
|
||||
'meta.json'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workspace meta, create one if not exists
|
||||
* This function will also migrate the workspace if needed
|
||||
*/
|
||||
export async function getWorkspaceMeta(
|
||||
spaceType: SpaceType,
|
||||
workspaceId: string
|
||||
): Promise<WorkspaceMeta> {
|
||||
const dbPath = await getWorkspaceDBPath(spaceType, workspaceId);
|
||||
|
||||
return {
|
||||
mainDBPath: dbPath,
|
||||
id: workspaceId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import type { WorkspaceMeta } from '../type';
|
||||
|
||||
export const workspaceSubjects = {
|
||||
meta$: new Subject<{ workspaceId: string; meta: WorkspaceMeta }>(),
|
||||
};
|
||||
@@ -0,0 +1,294 @@
|
||||
import { app, Menu } from 'electron';
|
||||
|
||||
import { isMacOS } from '../../shared/utils';
|
||||
import { logger, revealLogFile } from '../logger';
|
||||
import { uiSubjects } from '../ui/subject';
|
||||
import { checkForUpdates } from '../updater';
|
||||
import {
|
||||
addTab,
|
||||
initAndShowMainWindow,
|
||||
reloadView,
|
||||
showMainWindow,
|
||||
switchTab,
|
||||
switchToNextTab,
|
||||
switchToPreviousTab,
|
||||
undoCloseTab,
|
||||
WebContentViewsManager,
|
||||
} from '../windows-manager';
|
||||
import { popupManager } from '../windows-manager/popup';
|
||||
import { WorkerManager } from '../worker/pool';
|
||||
import { applicationMenuSubjects } from './subject';
|
||||
|
||||
// Unique id for menuitems
|
||||
const MENUITEM_NEW_PAGE = 'affine:new-page';
|
||||
|
||||
export function createApplicationMenu() {
|
||||
const isMac = isMacOS();
|
||||
|
||||
// Electron menu cannot be modified
|
||||
// You have to copy the complete default menu template event if you want to add a single custom item
|
||||
// See https://www.electronjs.org/docs/latest/api/menu#examples
|
||||
const template = [
|
||||
// { role: 'appMenu' }
|
||||
...(isMac
|
||||
? [
|
||||
{
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{
|
||||
label: `About ${app.getName()}`,
|
||||
click: async () => {
|
||||
await showMainWindow();
|
||||
applicationMenuSubjects.openInSettingModal$.next({
|
||||
activeTab: 'about',
|
||||
});
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'services' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'hide' },
|
||||
{ role: 'hideOthers' },
|
||||
{ role: 'unhide' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'quit' },
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// { role: 'fileMenu' }
|
||||
{
|
||||
label: 'File',
|
||||
submenu: [
|
||||
{
|
||||
id: MENUITEM_NEW_PAGE,
|
||||
label: 'New Doc',
|
||||
accelerator: isMac ? 'Cmd+N' : 'Ctrl+N',
|
||||
click: async () => {
|
||||
await initAndShowMainWindow();
|
||||
// fixme: if the window is just created, the new page action will not be triggered
|
||||
applicationMenuSubjects.newPageAction$.next('default');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// { role: 'editMenu' }
|
||||
{
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{ role: 'undo' },
|
||||
{ role: 'redo' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'cut' },
|
||||
{ role: 'copy' },
|
||||
{ role: 'paste' },
|
||||
...(isMac
|
||||
? [
|
||||
{ role: 'pasteAndMatchStyle' },
|
||||
{ role: 'delete' },
|
||||
{ role: 'selectAll' },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Speech',
|
||||
submenu: [{ role: 'startSpeaking' }, { role: 'stopSpeaking' }],
|
||||
},
|
||||
]
|
||||
: [{ role: 'delete' }, { type: 'separator' }, { role: 'selectAll' }]),
|
||||
],
|
||||
},
|
||||
// { role: 'viewMenu' }
|
||||
{
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Reload',
|
||||
accelerator: 'CommandOrControl+R',
|
||||
click() {
|
||||
reloadView().catch(console.error);
|
||||
},
|
||||
},
|
||||
{
|
||||
role: 'windowMenu',
|
||||
},
|
||||
{
|
||||
label: 'Open devtools',
|
||||
accelerator: isMac ? 'Cmd+Option+I' : 'Ctrl+Shift+I',
|
||||
click: () => {
|
||||
const workerContents = Array.from(
|
||||
WorkerManager.instance.workers.values()
|
||||
).map(
|
||||
worker => [worker.key, worker.browserWindow.webContents] as const
|
||||
);
|
||||
|
||||
const tabs = Array.from(
|
||||
WebContentViewsManager.instance.tabViewsMap
|
||||
).map(view => {
|
||||
const isActive = WebContentViewsManager.instance.isActiveTab(
|
||||
view[0]
|
||||
);
|
||||
return [
|
||||
view[0] + (isActive ? ' (active)' : ''),
|
||||
view[1].webContents,
|
||||
] as const;
|
||||
});
|
||||
|
||||
const popups = Array.from(popupManager.popupWindows$.value.values())
|
||||
.filter(popup => popup.browserWindow)
|
||||
.map(popup => {
|
||||
// oxlint-disable-next-line no-non-null-assertion
|
||||
return [popup.type, popup.browserWindow!.webContents] as const;
|
||||
});
|
||||
|
||||
const allWebContents = [
|
||||
['tabs', tabs],
|
||||
['workers', workerContents],
|
||||
['popups', popups],
|
||||
] as const;
|
||||
|
||||
Menu.buildFromTemplate(
|
||||
allWebContents.flatMap(([type, contents]) => {
|
||||
return [
|
||||
{
|
||||
label: type,
|
||||
enabled: false,
|
||||
},
|
||||
...contents.map(([id, webContents]) => ({
|
||||
label: id,
|
||||
click: () => {
|
||||
webContents.openDevTools({
|
||||
mode: 'undocked',
|
||||
});
|
||||
},
|
||||
})),
|
||||
{ type: 'separator' },
|
||||
];
|
||||
})
|
||||
).popup();
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'resetZoom' },
|
||||
{ role: 'zoomIn' },
|
||||
...(!isMacOS()
|
||||
? [{ role: 'zoomIn', accelerator: 'Ctrl+=', visible: false }]
|
||||
: []),
|
||||
{ role: 'zoomOut' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen' },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'New tab',
|
||||
accelerator: 'CommandOrControl+T',
|
||||
click() {
|
||||
logger.info('New tab with shortcut');
|
||||
addTab().catch(console.error);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Close view',
|
||||
accelerator: 'CommandOrControl+W',
|
||||
click() {
|
||||
logger.info('Close view with shortcut');
|
||||
// tell the active workbench to close the current view
|
||||
uiSubjects.onCloseView$.next();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Undo close tab',
|
||||
accelerator: 'CommandOrControl+Shift+T',
|
||||
click() {
|
||||
logger.info('Undo close tab with shortcut');
|
||||
undoCloseTab().catch(console.error);
|
||||
},
|
||||
},
|
||||
...[1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => {
|
||||
const shortcut = `CommandOrControl+${n}`;
|
||||
const listener = () => {
|
||||
switchTab(n);
|
||||
};
|
||||
return {
|
||||
acceleratorWorksWhenHidden: true,
|
||||
label: `Switch to tab ${n}`,
|
||||
accelerator: shortcut,
|
||||
click: listener,
|
||||
visible: false,
|
||||
};
|
||||
}),
|
||||
{
|
||||
label: 'Switch to next tab',
|
||||
accelerator: 'Control+Tab',
|
||||
click: () => {
|
||||
switchToNextTab();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Switch to previous tab',
|
||||
accelerator: 'Control+Shift+Tab',
|
||||
click: () => {
|
||||
switchToPreviousTab();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Switch to next tab (mac 2)',
|
||||
accelerator: 'Alt+Command+]',
|
||||
visible: false,
|
||||
click: () => {
|
||||
switchToNextTab();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Switch to previous tab (mac 2)',
|
||||
accelerator: 'Alt+Command+[',
|
||||
visible: false,
|
||||
click: () => {
|
||||
switchToPreviousTab();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'help',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Learn More',
|
||||
click: async () => {
|
||||
// oxlint-disable-next-line no-var-requires
|
||||
const { shell } = require('electron');
|
||||
await shell.openExternal('https://affine.pro/');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Open log file',
|
||||
click: async () => {
|
||||
await revealLogFile();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Check for Updates',
|
||||
click: async () => {
|
||||
await initAndShowMainWindow();
|
||||
await checkForUpdates();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Documentation',
|
||||
click: async () => {
|
||||
// oxlint-disable-next-line no-var-requires
|
||||
const { shell } = require('electron');
|
||||
await shell.openExternal(
|
||||
'https://docs.affine.pro/docs/hello-bonjour-aloha-你好'
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// @ts-expect-error: The snippet is copied from Electron official docs.
|
||||
// It's working as expected. No idea why it contains type errors.
|
||||
// Just ignore for now.
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
Menu.setApplicationMenu(menu);
|
||||
|
||||
return menu;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { MainEventRegister } from '../type';
|
||||
import { applicationMenuSubjects, type NewPageAction } from './subject';
|
||||
|
||||
export * from './create';
|
||||
export * from './subject';
|
||||
|
||||
/**
|
||||
* Events triggered by application menu
|
||||
*/
|
||||
export const applicationMenuEvents = {
|
||||
/**
|
||||
* File -> New Doc
|
||||
*/
|
||||
onNewPageAction: (fn: (type: NewPageAction) => void) => {
|
||||
const sub = applicationMenuSubjects.newPageAction$.subscribe(fn);
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
},
|
||||
// todo: properly define the active tab type
|
||||
openInSettingModal: (
|
||||
fn: (props: { activeTab: string; scrollAnchor?: string }) => void
|
||||
) => {
|
||||
const sub = applicationMenuSubjects.openInSettingModal$.subscribe(fn);
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
},
|
||||
onOpenJournal: (fn: () => void) => {
|
||||
const sub = applicationMenuSubjects.openJournal$.subscribe(fn);
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
},
|
||||
} satisfies Record<string, MainEventRegister>;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
export type NewPageAction = 'page' | 'edgeless' | 'default';
|
||||
|
||||
export const applicationMenuSubjects = {
|
||||
newPageAction$: new Subject<NewPageAction>(),
|
||||
openJournal$: new Subject<void>(),
|
||||
openInSettingModal$: new Subject<{
|
||||
activeTab: string;
|
||||
scrollAnchor?: string;
|
||||
}>(),
|
||||
};
|
||||
304
packages/frontend/apps/electron/src/main/auth/auth-session.ts
Normal file
304
packages/frontend/apps/electron/src/main/auth/auth-session.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
AuthTokenBroker,
|
||||
type AuthTokenPair,
|
||||
type AuthTokenResponse,
|
||||
classifyAuthError,
|
||||
} from '@affine/auth';
|
||||
import { app, safeStorage } from 'electron';
|
||||
|
||||
import { logger } from '../logger';
|
||||
import { authFetch } from './transport';
|
||||
|
||||
const FILEPATH = path.join(app.getPath('userData'), 'auth-sessions.json');
|
||||
const TEMP_FILEPATH = `${FILEPATH}.tmp`;
|
||||
const INSTALLATION_FILEPATH = path.join(
|
||||
app.getPath('userData'),
|
||||
'installation-id'
|
||||
);
|
||||
const brokers = new Map<string, AuthTokenBroker>();
|
||||
const memoryStore = new Map<string, AuthTokenPair>();
|
||||
let fileMutation = Promise.resolve();
|
||||
let installationId: Promise<string> | undefined;
|
||||
const AUTH_REQUEST_TIMEOUT = 10_000;
|
||||
|
||||
function secureStorageAvailable() {
|
||||
return (
|
||||
safeStorage.isEncryptionAvailable() &&
|
||||
(process.platform !== 'linux' ||
|
||||
safeStorage.getSelectedStorageBackend() !== 'basic_text')
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeEndpoint(endpoint: string) {
|
||||
return new URL(endpoint).origin;
|
||||
}
|
||||
|
||||
async function readStore(): Promise<Record<string, string>> {
|
||||
try {
|
||||
const value = JSON.parse(await fs.readFile(FILEPATH, 'utf8'));
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value
|
||||
: {};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.error('failed to read auth session store', error);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function mutateFile(mutator: (store: Record<string, string>) => void) {
|
||||
const operation = fileMutation.then(async () => {
|
||||
const store = await readStore();
|
||||
mutator(store);
|
||||
await fs.writeFile(TEMP_FILEPATH, JSON.stringify(store), { mode: 0o600 });
|
||||
await fs.rename(TEMP_FILEPATH, FILEPATH);
|
||||
});
|
||||
fileMutation = operation.catch(() => {});
|
||||
return await operation;
|
||||
}
|
||||
|
||||
function encrypt(pair: AuthTokenPair) {
|
||||
return safeStorage.encryptString(JSON.stringify(pair)).toString('base64');
|
||||
}
|
||||
|
||||
function decrypt(value: string): AuthTokenPair | null {
|
||||
try {
|
||||
return JSON.parse(safeStorage.decryptString(Buffer.from(value, 'base64')));
|
||||
} catch (error) {
|
||||
logger.error('failed to decrypt auth session', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function storage(endpoint: string) {
|
||||
return {
|
||||
async load() {
|
||||
if (!secureStorageAvailable()) {
|
||||
return memoryStore.get(endpoint) ?? null;
|
||||
}
|
||||
const encrypted = (await readStore())[endpoint];
|
||||
if (!encrypted) return null;
|
||||
const pair = decrypt(encrypted);
|
||||
if (!pair) await mutateFile(store => delete store[endpoint]);
|
||||
return pair;
|
||||
},
|
||||
async save(pair: AuthTokenPair) {
|
||||
if (!secureStorageAvailable()) {
|
||||
await mutateFile(store => delete store[endpoint]);
|
||||
memoryStore.set(endpoint, pair);
|
||||
return;
|
||||
}
|
||||
await mutateFile(store => {
|
||||
store[endpoint] = encrypt(pair);
|
||||
});
|
||||
memoryStore.delete(endpoint);
|
||||
},
|
||||
async clear() {
|
||||
memoryStore.delete(endpoint);
|
||||
await mutateFile(store => delete store[endpoint]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function refresh(endpoint: string, refreshToken: string) {
|
||||
const response = await authFetch(
|
||||
new URL('/api/auth/session/refresh', endpoint).toString(),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-affine-client-kind': 'native',
|
||||
'x-affine-version': BUILD_CONFIG.appVersion,
|
||||
},
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
signal: AbortSignal.timeout(AUTH_REQUEST_TIMEOUT),
|
||||
}
|
||||
);
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw classifyAuthError({
|
||||
code:
|
||||
typeof body === 'object' && body && 'code' in body
|
||||
? String(body.code)
|
||||
: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE',
|
||||
});
|
||||
}
|
||||
return body as AuthTokenResponse;
|
||||
}
|
||||
|
||||
export function getAuthSessionBroker(endpoint: string) {
|
||||
const normalized = normalizeEndpoint(endpoint);
|
||||
let broker = brokers.get(normalized);
|
||||
if (!broker) {
|
||||
broker = new AuthTokenBroker(storage(normalized), {
|
||||
refresh: (token: string) => refresh(normalized, token),
|
||||
});
|
||||
brokers.set(normalized, broker);
|
||||
}
|
||||
return broker;
|
||||
}
|
||||
|
||||
export async function initializeAuthSessions() {
|
||||
for (const endpoint of Object.keys(await readStore())) {
|
||||
try {
|
||||
getAuthSessionBroker(endpoint);
|
||||
} catch (error) {
|
||||
logger.error('failed to initialize auth session', endpoint, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isManagedAuthEndpoint(url: string) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol === 'ws:') parsed.protocol = 'http:';
|
||||
if (parsed.protocol === 'wss:') parsed.protocol = 'https:';
|
||||
return brokers.has(parsed.origin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setAuthSession(
|
||||
endpoint: string,
|
||||
response: AuthTokenResponse
|
||||
) {
|
||||
await getAuthSessionBroker(endpoint).set(response);
|
||||
return { persistent: secureStorageAvailable() };
|
||||
}
|
||||
|
||||
export function getInstallationId() {
|
||||
if (installationId) return installationId;
|
||||
const pending = fs
|
||||
.readFile(INSTALLATION_FILEPATH, 'utf8')
|
||||
.then(value => {
|
||||
if (
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||||
value
|
||||
)
|
||||
) {
|
||||
throw new Error('Invalid installation id');
|
||||
}
|
||||
return value;
|
||||
})
|
||||
.catch(async () => {
|
||||
const value = randomUUID();
|
||||
await fs.writeFile(INSTALLATION_FILEPATH, value, { mode: 0o600 });
|
||||
return value;
|
||||
});
|
||||
installationId = pending;
|
||||
void pending.catch(() => {
|
||||
if (installationId === pending) installationId = undefined;
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
|
||||
export async function revokeAuthSession(endpoint: string) {
|
||||
const normalized = normalizeEndpoint(endpoint);
|
||||
await getAuthSessionBroker(normalized).revoke(
|
||||
'sign-out',
|
||||
async (refreshToken: string) => {
|
||||
const response = await authFetch(
|
||||
new URL('/api/auth/session/revoke', normalized).toString(),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-affine-client-kind': 'native',
|
||||
'x-affine-version': BUILD_CONFIG.appVersion,
|
||||
},
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
signal: AbortSignal.timeout(AUTH_REQUEST_TIMEOUT),
|
||||
}
|
||||
);
|
||||
if (!response.ok) throw new Error('Failed to revoke auth session');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function clearAuthSession(endpoint: string, reason: string) {
|
||||
await getAuthSessionBroker(endpoint).clear(reason);
|
||||
}
|
||||
|
||||
export async function getValidAccessToken(
|
||||
endpoint: string,
|
||||
minValidity = 60_000
|
||||
) {
|
||||
try {
|
||||
return await getAuthSessionBroker(endpoint).getValidAccessToken(
|
||||
minValidity
|
||||
);
|
||||
} catch (error) {
|
||||
const classified = classifyAuthError(error);
|
||||
if (classified.code === 'AUTH_SESSION_EMPTY' || !classified.transient) {
|
||||
return null;
|
||||
}
|
||||
throw classified;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAccessTokenForUrl(url: string, minValidity = 60_000) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol === 'ws:') parsed.protocol = 'http:';
|
||||
if (parsed.protocol === 'wss:') parsed.protocol = 'https:';
|
||||
return await getValidAccessToken(parsed.origin, minValidity);
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshAccessTokenForUrl(url: string) {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol === 'ws:') parsed.protocol = 'http:';
|
||||
if (parsed.protocol === 'wss:') parsed.protocol = 'https:';
|
||||
return (
|
||||
await getAuthSessionBroker(parsed.origin).refresh('access-token-expired')
|
||||
).accessToken;
|
||||
}
|
||||
|
||||
async function authorizedRequest(
|
||||
request: Request,
|
||||
targetUrl: string,
|
||||
accessToken?: string
|
||||
) {
|
||||
const cloned = request.clone();
|
||||
const headers = new Headers(cloned.headers);
|
||||
headers.delete('Authorization');
|
||||
const token = accessToken ?? (await getAccessTokenForUrl(targetUrl));
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
return new Request(targetUrl, {
|
||||
body:
|
||||
cloned.method === 'GET' || cloned.method === 'HEAD'
|
||||
? undefined
|
||||
: cloned.body,
|
||||
headers,
|
||||
method: cloned.method,
|
||||
redirect: cloned.redirect,
|
||||
signal: cloned.signal,
|
||||
duplex: 'half',
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeAuthSessionRequest(
|
||||
request: Request,
|
||||
targetUrl: string,
|
||||
execute: (request: Request) => Promise<Response>
|
||||
) {
|
||||
const retry = request.clone();
|
||||
const response = await execute(await authorizedRequest(request, targetUrl));
|
||||
if (response.status !== 401) return response;
|
||||
const body = (await response
|
||||
.clone()
|
||||
.json()
|
||||
.catch(() => null)) as { code?: string } | null;
|
||||
if (body?.code !== 'ACCESS_TOKEN_EXPIRED') return response;
|
||||
const token = await refreshAccessTokenForUrl(targetUrl);
|
||||
return await execute(await authorizedRequest(retry, targetUrl, token));
|
||||
}
|
||||
218
packages/frontend/apps/electron/src/main/auth/handlers.ts
Normal file
218
packages/frontend/apps/electron/src/main/auth/handlers.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import os from 'node:os';
|
||||
|
||||
import type { AuthTokenResponse } from '@affine/auth';
|
||||
import { session } from 'electron';
|
||||
|
||||
import { logger } from '../logger';
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
import {
|
||||
clearAuthSession,
|
||||
getInstallationId,
|
||||
getValidAccessToken,
|
||||
revokeAuthSession,
|
||||
setAuthSession,
|
||||
} from './auth-session';
|
||||
import { authFetch, getAuthTransportSession } from './transport';
|
||||
|
||||
export interface SignInResponse {
|
||||
id?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
hasPassword?: boolean | null;
|
||||
avatarUrl?: string | null;
|
||||
emailVerified?: boolean;
|
||||
exchangeCode?: string;
|
||||
redirectUri?: string;
|
||||
}
|
||||
|
||||
export interface PasswordSignInResponse extends SignInResponse {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
hasPassword: boolean | null;
|
||||
avatarUrl: string | null;
|
||||
emailVerified: boolean;
|
||||
sessionOnly?: boolean;
|
||||
}
|
||||
|
||||
const authCookieNames = [
|
||||
'affine_session',
|
||||
'affine_user_id',
|
||||
'affine_csrf_token',
|
||||
];
|
||||
|
||||
function authUrl(endpoint: string, path: string) {
|
||||
return new URL(path, endpoint).toString();
|
||||
}
|
||||
|
||||
async function readJson<T>(response: Response): Promise<T> {
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
let message = text || response.statusText;
|
||||
try {
|
||||
const error = JSON.parse(text);
|
||||
if (typeof error.message === 'string') {
|
||||
message = error.message;
|
||||
}
|
||||
} catch {}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return text ? JSON.parse(text) : ({} as T);
|
||||
}
|
||||
|
||||
async function fetchAuth(endpoint: string, path: string, body?: unknown) {
|
||||
return await authFetch(authUrl(endpoint, path), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-affine-client-kind': 'native',
|
||||
'x-affine-version': BUILD_CONFIG.appVersion,
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function clearAuthCookies(endpoint: string) {
|
||||
const sessions = [session.defaultSession, getAuthTransportSession()];
|
||||
await Promise.all(
|
||||
sessions.flatMap(authSession =>
|
||||
authCookieNames.map(name =>
|
||||
authSession.cookies
|
||||
.remove(endpoint, name)
|
||||
.catch(error =>
|
||||
logger.debug(
|
||||
'failed to clear native auth cookie',
|
||||
endpoint,
|
||||
name,
|
||||
error
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function exchangeSession(endpoint: string, response: SignInResponse) {
|
||||
if (!response.exchangeCode) {
|
||||
throw new Error('Missing native auth exchange code.');
|
||||
}
|
||||
|
||||
const exchangeResponse = await fetchAuth(
|
||||
endpoint,
|
||||
'/api/auth/session/exchange',
|
||||
{
|
||||
code: response.exchangeCode,
|
||||
installationId: await getInstallationId(),
|
||||
platform: 'electron',
|
||||
deviceName: os.hostname(),
|
||||
}
|
||||
);
|
||||
const body = await readJson<AuthTokenResponse>(exchangeResponse);
|
||||
const { persistent } = await setAuthSession(endpoint, body);
|
||||
await clearAuthCookies(endpoint);
|
||||
return { persistent };
|
||||
}
|
||||
|
||||
export const authHandlers = {
|
||||
signInMagicLink: async (
|
||||
_,
|
||||
endpoint: string,
|
||||
email: string,
|
||||
token: string,
|
||||
clientNonce?: string
|
||||
) => {
|
||||
const response = await fetchAuth(endpoint, '/api/auth/magic-link', {
|
||||
email,
|
||||
token,
|
||||
client_nonce: clientNonce,
|
||||
});
|
||||
const body = await readJson<SignInResponse>(response);
|
||||
const { persistent } = await exchangeSession(endpoint, body);
|
||||
return { sessionOnly: !persistent };
|
||||
},
|
||||
|
||||
signInOauth: async (
|
||||
_,
|
||||
endpoint: string,
|
||||
code: string,
|
||||
state: string,
|
||||
clientNonce?: string
|
||||
) => {
|
||||
const response = await fetchAuth(endpoint, '/api/oauth/callback', {
|
||||
code,
|
||||
state,
|
||||
client_nonce: clientNonce,
|
||||
});
|
||||
const body = await readJson<SignInResponse>(response);
|
||||
const { persistent } = await exchangeSession(endpoint, body);
|
||||
return { redirectUri: body.redirectUri, sessionOnly: !persistent };
|
||||
},
|
||||
|
||||
signInPassword: async (
|
||||
_,
|
||||
endpoint: string,
|
||||
credential: {
|
||||
email: string;
|
||||
password: string;
|
||||
verifyToken?: string;
|
||||
challenge?: string;
|
||||
}
|
||||
) => {
|
||||
const response = await authFetch(authUrl(endpoint, '/api/auth/sign-in'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-affine-client-kind': 'native',
|
||||
'x-affine-version': BUILD_CONFIG.appVersion,
|
||||
...(credential.verifyToken
|
||||
? { 'x-captcha-token': credential.verifyToken }
|
||||
: {}),
|
||||
...(credential.verifyToken
|
||||
? {
|
||||
'x-captcha-provider': credential.challenge
|
||||
? 'hashcash'
|
||||
: 'turnstile',
|
||||
}
|
||||
: {}),
|
||||
...(credential.challenge
|
||||
? { 'x-captcha-challenge': credential.challenge }
|
||||
: {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: credential.email,
|
||||
password: credential.password,
|
||||
}),
|
||||
});
|
||||
const body = await readJson<PasswordSignInResponse>(response);
|
||||
const { persistent } = await exchangeSession(endpoint, body);
|
||||
return { ...body, sessionOnly: !persistent };
|
||||
},
|
||||
|
||||
signInOpenAppSignInCode: async (_e, endpoint: string, code: string) => {
|
||||
const response = await fetchAuth(endpoint, '/api/auth/open-app/sign-in', {
|
||||
code,
|
||||
});
|
||||
const { persistent } = await exchangeSession(
|
||||
endpoint,
|
||||
await readJson(response)
|
||||
);
|
||||
return { sessionOnly: !persistent };
|
||||
},
|
||||
|
||||
signOut: async (_e, endpoint: string) => {
|
||||
try {
|
||||
await revokeAuthSession(endpoint);
|
||||
} finally {
|
||||
await clearAuthCookies(endpoint);
|
||||
}
|
||||
},
|
||||
|
||||
clearSession: async (_e, endpoint: string) => {
|
||||
await clearAuthSession(endpoint, 'local-clear');
|
||||
},
|
||||
|
||||
getValidAccessToken: async (_e, endpoint: string) => {
|
||||
return { token: await getValidAccessToken(endpoint, 120_000) };
|
||||
},
|
||||
} satisfies NamespaceHandlers;
|
||||
11
packages/frontend/apps/electron/src/main/auth/transport.ts
Normal file
11
packages/frontend/apps/electron/src/main/auth/transport.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { session } from 'electron';
|
||||
|
||||
const AUTH_TRANSPORT_PARTITION = 'affine-auth-transport';
|
||||
|
||||
export function getAuthTransportSession() {
|
||||
return session.fromPartition(AUTH_TRANSPORT_PARTITION, { cache: false });
|
||||
}
|
||||
|
||||
export function authFetch(input: string | Request, init?: RequestInit) {
|
||||
return getAuthTransportSession().fetch(input, init);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { app, safeStorage } from 'electron';
|
||||
|
||||
import { PersistentJSONFileStorage } from '../shared-storage/json-file';
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
|
||||
const byokStorage = new PersistentJSONFileStorage(
|
||||
path.join(app.getPath('userData'), 'workspace-byok-keys.json')
|
||||
);
|
||||
|
||||
export function disposeWorkspaceByokStorage() {
|
||||
byokStorage.dispose();
|
||||
}
|
||||
|
||||
const allowedProviders = new Set(['openai', 'anthropic', 'gemini', 'fal']);
|
||||
const allowedInputs = new Set(['text', 'image', 'audio', 'file']);
|
||||
const allowedOutputs = new Set([
|
||||
'text',
|
||||
'object',
|
||||
'structured',
|
||||
'embedding',
|
||||
'rerank',
|
||||
'image',
|
||||
]);
|
||||
const allowedFeatures = new Set(['tool_calling', 'reasoning', 'web_search']);
|
||||
const allowedAttachmentKinds = new Set(['image', 'audio', 'file']);
|
||||
const allowedAttachmentSources = new Set([
|
||||
'url',
|
||||
'data',
|
||||
'bytes',
|
||||
'file_handle',
|
||||
]);
|
||||
|
||||
type WorkspaceByokKey = {
|
||||
id: string;
|
||||
provider: 'openai' | 'anthropic' | 'gemini' | 'fal';
|
||||
name: string;
|
||||
description?: string | null;
|
||||
credential: string;
|
||||
definition: {
|
||||
endpoint: {
|
||||
kind: 'provider_default' | 'openai_compatible';
|
||||
url?: string | null;
|
||||
dialect?: 'responses' | 'chat_completions' | null;
|
||||
};
|
||||
models: Array<{
|
||||
modelId: string;
|
||||
enabled: boolean;
|
||||
capabilities: Array<{
|
||||
input: string[];
|
||||
output: string[];
|
||||
features: string[];
|
||||
attachmentKinds: string[];
|
||||
attachmentSources: string[];
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
sortOrder?: number | null;
|
||||
enabled?: boolean | null;
|
||||
};
|
||||
|
||||
type WorkspaceByokKeyInput = Omit<
|
||||
WorkspaceByokKey,
|
||||
'credential' | 'definition'
|
||||
> & {
|
||||
credential?: string | null;
|
||||
definition?: WorkspaceByokKey['definition'];
|
||||
};
|
||||
|
||||
function assertSupported() {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
throw new Error('Secure BYOK key storage is not available.');
|
||||
}
|
||||
}
|
||||
|
||||
function hasOwnField(
|
||||
key: WorkspaceByokKeyInput,
|
||||
field: keyof WorkspaceByokKey
|
||||
) {
|
||||
return Object.prototype.hasOwnProperty.call(key, field);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isAllowedStringArray(
|
||||
value: unknown,
|
||||
allowed: Set<string>
|
||||
): value is string[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every(item => typeof item === 'string' && allowed.has(item))
|
||||
);
|
||||
}
|
||||
|
||||
function isValidEndpoint(value: unknown) {
|
||||
if (!isRecord(value) || typeof value.kind !== 'string') return false;
|
||||
if (value.kind === 'provider_default')
|
||||
return value.url == null && value.dialect == null;
|
||||
if (
|
||||
value.kind !== 'openai_compatible' ||
|
||||
typeof value.url !== 'string' ||
|
||||
!['responses', 'chat_completions'].includes(String(value.dialect))
|
||||
)
|
||||
return false;
|
||||
try {
|
||||
const endpoint = new URL(value.url);
|
||||
return (
|
||||
(endpoint.protocol === 'http:' || endpoint.protocol === 'https:') &&
|
||||
!!endpoint.hostname &&
|
||||
!endpoint.username &&
|
||||
!endpoint.password
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isValidCapability(value: unknown) {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
isAllowedStringArray(value.input, allowedInputs) &&
|
||||
value.input.length > 0 &&
|
||||
isAllowedStringArray(value.output, allowedOutputs) &&
|
||||
value.output.length > 0 &&
|
||||
isAllowedStringArray(value.features, allowedFeatures) &&
|
||||
isAllowedStringArray(value.attachmentKinds, allowedAttachmentKinds) &&
|
||||
isAllowedStringArray(value.attachmentSources, allowedAttachmentSources)
|
||||
);
|
||||
}
|
||||
|
||||
function isValidDefinition(
|
||||
value: unknown
|
||||
): value is WorkspaceByokKey['definition'] {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
isValidEndpoint(value.endpoint) &&
|
||||
Array.isArray(value.models) &&
|
||||
value.models.length > 0 &&
|
||||
value.models.every(
|
||||
model =>
|
||||
isRecord(model) &&
|
||||
typeof model.modelId === 'string' &&
|
||||
model.modelId.trim().length > 0 &&
|
||||
model.modelId.length <= 512 &&
|
||||
typeof model.enabled === 'boolean' &&
|
||||
Array.isArray(model.capabilities) &&
|
||||
model.capabilities.length > 0 &&
|
||||
model.capabilities.every(isValidCapability)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeKey(
|
||||
key: WorkspaceByokKeyInput,
|
||||
existing?: WorkspaceByokKey,
|
||||
defaultSortOrder = 0
|
||||
): WorkspaceByokKey {
|
||||
if (!allowedProviders.has(key.provider)) {
|
||||
throw new Error('Unsupported BYOK provider.');
|
||||
}
|
||||
const credential = key.credential ?? existing?.credential;
|
||||
const definition = key.definition ?? existing?.definition;
|
||||
if (
|
||||
definition?.endpoint.kind === 'openai_compatible' &&
|
||||
key.provider !== 'openai'
|
||||
) {
|
||||
throw new Error('OpenAI-compatible endpoints require OpenAI provider.');
|
||||
}
|
||||
if (!key.id || !key.name || !credential || !isValidDefinition(definition)) {
|
||||
throw new Error('Invalid BYOK key.');
|
||||
}
|
||||
return {
|
||||
id: key.id,
|
||||
provider: key.provider,
|
||||
name: key.name,
|
||||
description: hasOwnField(key, 'description')
|
||||
? (key.description ?? null)
|
||||
: (existing?.description ?? null),
|
||||
credential,
|
||||
definition,
|
||||
sortOrder: hasOwnField(key, 'sortOrder')
|
||||
? (key.sortOrder ?? defaultSortOrder)
|
||||
: (existing?.sortOrder ?? defaultSortOrder),
|
||||
enabled: hasOwnField(key, 'enabled')
|
||||
? (key.enabled ?? true)
|
||||
: (existing?.enabled ?? true),
|
||||
};
|
||||
}
|
||||
|
||||
function encryptKey(key: WorkspaceByokKey) {
|
||||
return safeStorage
|
||||
.encryptString(JSON.stringify(normalizeKey(key)))
|
||||
.toString('base64');
|
||||
}
|
||||
|
||||
function decryptKey(value: string): WorkspaceByokKey | null {
|
||||
try {
|
||||
return normalizeKey(
|
||||
JSON.parse(safeStorage.decryptString(Buffer.from(value, 'base64')))
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sortWorkspaceKeys(keys: WorkspaceByokKey[]) {
|
||||
return keys.toSorted((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
}
|
||||
|
||||
function readWorkspaceKeys(workspaceId: string): WorkspaceByokKey[] {
|
||||
assertSupported();
|
||||
const encryptedKeys = byokStorage.get<string[]>(workspaceId) ?? [];
|
||||
return sortWorkspaceKeys(
|
||||
encryptedKeys.flatMap(value => {
|
||||
const key = decryptKey(value);
|
||||
return key ? [key] : [];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function writeWorkspaceKeys(workspaceId: string, keys: WorkspaceByokKey[]) {
|
||||
assertSupported();
|
||||
byokStorage.set(workspaceId, keys.map(encryptKey));
|
||||
}
|
||||
|
||||
function toPublicKey({ credential: _, ...key }: WorkspaceByokKey) {
|
||||
return {
|
||||
...key,
|
||||
storage: 'local',
|
||||
configured: true,
|
||||
};
|
||||
}
|
||||
|
||||
export const byokStorageHandlers = {
|
||||
isSupported: async () => safeStorage.isEncryptionAvailable(),
|
||||
listWorkspaceKeys: async (_e, workspaceId: string) => {
|
||||
return readWorkspaceKeys(workspaceId).map(toPublicKey);
|
||||
},
|
||||
getWorkspaceLeaseProviders: async (_e, workspaceId: string) => {
|
||||
return readWorkspaceKeys(workspaceId).filter(key => key.enabled !== false);
|
||||
},
|
||||
upsertWorkspaceKey: async (
|
||||
_e,
|
||||
workspaceId: string,
|
||||
key: WorkspaceByokKeyInput
|
||||
) => {
|
||||
const keys = readWorkspaceKeys(workspaceId);
|
||||
const index = keys.findIndex(storedKey => storedKey.id === key.id);
|
||||
const nextKey = normalizeKey(
|
||||
key,
|
||||
index === -1 ? undefined : keys[index],
|
||||
keys.length
|
||||
);
|
||||
if (index === -1) {
|
||||
keys.push(nextKey);
|
||||
} else {
|
||||
keys[index] = nextKey;
|
||||
}
|
||||
writeWorkspaceKeys(workspaceId, keys);
|
||||
return toPublicKey(nextKey);
|
||||
},
|
||||
deleteWorkspaceKey: async (_e, workspaceId: string, keyId: string) => {
|
||||
writeWorkspaceKeys(
|
||||
workspaceId,
|
||||
readWorkspaceKeys(workspaceId).filter(key => key.id !== keyId)
|
||||
);
|
||||
return true;
|
||||
},
|
||||
reorderWorkspaceKeys: async (_e, workspaceId: string, ids: string[]) => {
|
||||
const keys = readWorkspaceKeys(workspaceId);
|
||||
const byId = new Map(keys.map(key => [key.id, key]));
|
||||
const ordered = ids
|
||||
.map((id, sortOrder) => {
|
||||
const key = byId.get(id);
|
||||
byId.delete(id);
|
||||
return key ? ({ ...key, sortOrder } as WorkspaceByokKey) : null;
|
||||
})
|
||||
.filter((key): key is WorkspaceByokKey => !!key);
|
||||
const nextKeys = sortWorkspaceKeys([
|
||||
...ordered,
|
||||
...Array.from(byId.values()).map((key, index) => ({
|
||||
...key,
|
||||
sortOrder: ordered.length + index,
|
||||
})),
|
||||
]);
|
||||
writeWorkspaceKeys(workspaceId, nextKeys);
|
||||
return nextKeys.map(toPublicKey);
|
||||
},
|
||||
clearWorkspaceKeys: async (_e, workspaceId: string) => {
|
||||
byokStorage.del(workspaceId);
|
||||
return true;
|
||||
},
|
||||
} satisfies NamespaceHandlers;
|
||||
56
packages/frontend/apps/electron/src/main/cleanup.ts
Normal file
56
packages/frontend/apps/electron/src/main/cleanup.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { MediaStats } from '@toeverything/infra';
|
||||
import { app } from 'electron';
|
||||
|
||||
import { logger } from './logger';
|
||||
import { globalStateStorage } from './shared-storage/storage';
|
||||
|
||||
const beforeAppQuitRegistry: (() => void)[] = [];
|
||||
const beforeTabCloseRegistry: ((tabId: string) => void)[] = [];
|
||||
|
||||
export function beforeAppQuit(fn: () => void) {
|
||||
beforeAppQuitRegistry.push(fn);
|
||||
}
|
||||
|
||||
export function beforeTabClose(fn: (tabId: string) => void) {
|
||||
beforeTabCloseRegistry.push(fn);
|
||||
}
|
||||
|
||||
app.on('before-quit', () => {
|
||||
beforeAppQuitRegistry.forEach(fn => {
|
||||
// some cleanup functions might throw on quit and crash the app
|
||||
try {
|
||||
fn();
|
||||
} catch (err) {
|
||||
logger.warn('cleanup error on quit', err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export function onTabClose(tabId: string) {
|
||||
beforeTabCloseRegistry.forEach(fn => {
|
||||
try {
|
||||
fn(tabId);
|
||||
} catch (err) {
|
||||
logger.warn('cleanup error on tab close', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.on('ready', () => {
|
||||
globalStateStorage.set('media:playback-state', null);
|
||||
globalStateStorage.set('media:stats', null);
|
||||
});
|
||||
|
||||
beforeAppQuit(() => {
|
||||
globalStateStorage.set('media:playback-state', null);
|
||||
globalStateStorage.set('media:stats', null);
|
||||
});
|
||||
|
||||
// set audio play state
|
||||
beforeTabClose(tabId => {
|
||||
const stats = globalStateStorage.get<MediaStats | null>('media:stats');
|
||||
if (stats && stats.tabId === tabId) {
|
||||
globalStateStorage.set('media:playback-state', null);
|
||||
globalStateStorage.set('media:stats', null);
|
||||
}
|
||||
});
|
||||
12
packages/frontend/apps/electron/src/main/clipboard/index.ts
Normal file
12
packages/frontend/apps/electron/src/main/clipboard/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { clipboard, nativeImage } from 'electron';
|
||||
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
|
||||
export const clipboardHandlers = {
|
||||
copyAsPNG: async (_, arrayBuffer: ArrayBuffer) => {
|
||||
const image = nativeImage.createFromBuffer(Buffer.from(arrayBuffer));
|
||||
if (image.isEmpty()) return false;
|
||||
clipboard.writeImage(image);
|
||||
return true;
|
||||
},
|
||||
} satisfies NamespaceHandlers;
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
import { persistentConfig } from './persist';
|
||||
|
||||
/**
|
||||
* @deprecated use shared storage
|
||||
*/
|
||||
export const configStorageHandlers = {
|
||||
get: async () => persistentConfig.get(),
|
||||
set: async (_, v) => persistentConfig.set(v),
|
||||
} satisfies NamespaceHandlers;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './handlers';
|
||||
@@ -0,0 +1,20 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
AppConfigStorage,
|
||||
defaultAppConfig,
|
||||
} from '@toeverything/infra/app-config-storage';
|
||||
import { app } from 'electron';
|
||||
|
||||
const FILENAME = 'config.json';
|
||||
const FILEPATH = path.join(app.getPath('userData'), FILENAME);
|
||||
|
||||
/**
|
||||
* @deprecated use shared storage
|
||||
*/
|
||||
export const persistentConfig = new AppConfigStorage({
|
||||
config: defaultAppConfig,
|
||||
get: () => JSON.parse(fs.readFileSync(FILEPATH, 'utf-8')),
|
||||
set: data => fs.writeFileSync(FILEPATH, JSON.stringify(data, null, 2)),
|
||||
});
|
||||
24
packages/frontend/apps/electron/src/main/config.ts
Normal file
24
packages/frontend/apps/electron/src/main/config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ReleaseTypeSchema = z.enum([
|
||||
'stable',
|
||||
'beta',
|
||||
'canary',
|
||||
'internal',
|
||||
]);
|
||||
|
||||
declare global {
|
||||
// THIS variable should be replaced during the build process
|
||||
const REPLACE_ME_BUILD_ENV: string;
|
||||
}
|
||||
|
||||
export const envBuildType = (process.env.BUILD_TYPE || REPLACE_ME_BUILD_ENV)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
export const overrideSession = process.env.BUILD_TYPE === 'internal';
|
||||
|
||||
export const buildType = ReleaseTypeSchema.parse(envBuildType);
|
||||
|
||||
export const mode = process.env.NODE_ENV;
|
||||
export const isDev = mode === 'development';
|
||||
155
packages/frontend/apps/electron/src/main/deep-link.ts
Normal file
155
packages/frontend/apps/electron/src/main/deep-link.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import type { App } from 'electron';
|
||||
|
||||
import { buildType, isDev } from './config';
|
||||
import { logger } from './logger';
|
||||
import { uiSubjects } from './ui';
|
||||
import {
|
||||
addTabWithUrl,
|
||||
loadUrlInActiveTab,
|
||||
openUrlInHiddenWindow,
|
||||
showMainWindow,
|
||||
} from './windows-manager';
|
||||
|
||||
let protocol = buildType === 'stable' ? 'affine' : `affine-${buildType}`;
|
||||
if (isDev) {
|
||||
protocol = 'affine-dev';
|
||||
}
|
||||
|
||||
const authMethods = new Set(['magic-link', 'oauth', 'open-app-signin']);
|
||||
|
||||
function summarizeDeepLink(rawUrl: string) {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
const method = url.searchParams.get('method');
|
||||
const server = url.searchParams.get('server');
|
||||
let serverOrigin: string | undefined;
|
||||
try {
|
||||
serverOrigin = server ? new URL(server).origin : undefined;
|
||||
} catch {
|
||||
serverOrigin = undefined;
|
||||
}
|
||||
return {
|
||||
protocol: url.protocol,
|
||||
action: url.hostname,
|
||||
method: method && authMethods.has(method) ? method : undefined,
|
||||
serverOrigin,
|
||||
};
|
||||
} catch {
|
||||
return { valid: false };
|
||||
}
|
||||
}
|
||||
|
||||
function logDeepLinkFailure(rawUrl: string, error: unknown) {
|
||||
logger.error('failed to handle affine url', summarizeDeepLink(rawUrl), {
|
||||
error: error instanceof Error ? error.name : typeof error,
|
||||
});
|
||||
}
|
||||
|
||||
export function setupDeepLink(app: App) {
|
||||
if (process.defaultApp) {
|
||||
if (process.argv.length >= 2) {
|
||||
app.setAsDefaultProtocolClient(protocol, process.execPath, [
|
||||
path.resolve(process.argv[1]),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient(protocol);
|
||||
}
|
||||
|
||||
app.on('open-url', (event, url) => {
|
||||
logger.log('open-url', summarizeDeepLink(url));
|
||||
if (url.startsWith(`${protocol}://`)) {
|
||||
event.preventDefault();
|
||||
app
|
||||
.whenReady()
|
||||
.then(() => handleAffineUrl(url))
|
||||
.catch(e => {
|
||||
logDeepLinkFailure(url, e);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// on windows & linux, we need to listen for the second-instance event
|
||||
app.on('second-instance', (event, commandLine) => {
|
||||
showMainWindow()
|
||||
.then(() => {
|
||||
const url = commandLine.pop();
|
||||
if (url?.startsWith(`${protocol}://`)) {
|
||||
event.preventDefault();
|
||||
handleAffineUrl(url).catch(e => {
|
||||
logDeepLinkFailure(url, e);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(e => console.error('Failed to restore or create window:', e));
|
||||
});
|
||||
|
||||
app.on('ready', () => {
|
||||
// app may be brought up without having a running instance
|
||||
// need to read the url from the command line
|
||||
const url = process.argv.at(-1);
|
||||
logger.log(
|
||||
'url from argv',
|
||||
url?.startsWith(`${protocol}://`)
|
||||
? summarizeDeepLink(url)
|
||||
: { deepLink: false, argumentCount: process.argv.length }
|
||||
);
|
||||
if (url?.startsWith(`${protocol}://`)) {
|
||||
handleAffineUrl(url).catch(e => {
|
||||
logDeepLinkFailure(url, e);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAffineUrl(url: string) {
|
||||
await showMainWindow();
|
||||
|
||||
logger.info('open affine url', summarizeDeepLink(url));
|
||||
const urlObj = new URL(url);
|
||||
|
||||
if (urlObj.hostname === 'authentication') {
|
||||
const method = urlObj.searchParams.get('method');
|
||||
const payload = JSON.parse(urlObj.searchParams.get('payload') ?? 'false');
|
||||
const server = urlObj.searchParams.get('server') || undefined;
|
||||
|
||||
if (
|
||||
!method ||
|
||||
(method !== 'magic-link' &&
|
||||
method !== 'oauth' &&
|
||||
method !== 'open-app-signin') ||
|
||||
!payload
|
||||
) {
|
||||
logger.error('Invalid authentication url', summarizeDeepLink(url));
|
||||
return;
|
||||
}
|
||||
|
||||
uiSubjects.authenticationRequest$.next({
|
||||
method,
|
||||
payload,
|
||||
server,
|
||||
});
|
||||
} else if (
|
||||
urlObj.searchParams.get('new-tab') &&
|
||||
urlObj.pathname.startsWith('/workspace')
|
||||
) {
|
||||
// @todo(@forehalo): refactor router utilities
|
||||
// basename of /workspace/xxx/yyy is /workspace/xxx
|
||||
await addTabWithUrl(url);
|
||||
} else {
|
||||
const hiddenWindow = urlObj.searchParams.get('hidden')
|
||||
? await openUrlInHiddenWindow(urlObj)
|
||||
: await loadUrlInActiveTab(url);
|
||||
|
||||
if (hiddenWindow) {
|
||||
// when hidden window closed, the main window will be hidden somehow
|
||||
hiddenWindow.on('close', () => {
|
||||
void showMainWindow().catch(e => {
|
||||
logger.error('Failed to restore main window:', e);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
123
packages/frontend/apps/electron/src/main/events.ts
Normal file
123
packages/frontend/apps/electron/src/main/events.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { ipcMain, powerMonitor, webContents } from 'electron';
|
||||
|
||||
import {
|
||||
AFFINE_EVENT_CHANNEL_NAME,
|
||||
AFFINE_EVENT_SUBSCRIBE_CHANNEL_NAME,
|
||||
} from '../shared/type';
|
||||
import { applicationMenuEvents } from './application-menu';
|
||||
import { beforeAppQuit } from './cleanup';
|
||||
import { logger } from './logger';
|
||||
import { powerEvents } from './power';
|
||||
import { recordingEvents } from './recording';
|
||||
import { checkSource } from './security-restrictions';
|
||||
import { sharedStorageEvents } from './shared-storage';
|
||||
import { uiEvents } from './ui/events';
|
||||
import { updaterEvents } from './updater/event';
|
||||
import { popupEvents } from './windows-manager/popup';
|
||||
|
||||
export const allEvents = {
|
||||
applicationMenu: applicationMenuEvents,
|
||||
updater: updaterEvents,
|
||||
ui: uiEvents,
|
||||
sharedStorage: sharedStorageEvents,
|
||||
recording: recordingEvents,
|
||||
popup: popupEvents,
|
||||
power: powerEvents,
|
||||
};
|
||||
|
||||
const subscriptions = new Map<number, Set<string>>();
|
||||
|
||||
function getTargetContents(channel: string) {
|
||||
const targets: Electron.WebContents[] = [];
|
||||
subscriptions.forEach((channels, id) => {
|
||||
if (!channels.has(channel)) return;
|
||||
const wc = webContents.fromId(id);
|
||||
if (wc && !wc.isDestroyed()) {
|
||||
targets.push(wc);
|
||||
}
|
||||
});
|
||||
return targets;
|
||||
}
|
||||
|
||||
function addSubscription(sender: Electron.WebContents, channel: string) {
|
||||
const id = sender.id;
|
||||
const set = subscriptions.get(id) ?? new Set<string>();
|
||||
set.add(channel);
|
||||
if (!subscriptions.has(id)) {
|
||||
sender.once('destroyed', () => {
|
||||
subscriptions.delete(id);
|
||||
});
|
||||
}
|
||||
subscriptions.set(id, set);
|
||||
}
|
||||
|
||||
function removeSubscription(sender: Electron.WebContents, channel: string) {
|
||||
const id = sender.id;
|
||||
const set = subscriptions.get(id);
|
||||
if (!set) return;
|
||||
set.delete(channel);
|
||||
if (set.size === 0) {
|
||||
subscriptions.delete(id);
|
||||
} else {
|
||||
subscriptions.set(id, set);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerEvents() {
|
||||
const unsubs: (() => void)[] = [];
|
||||
|
||||
const onSubscribe = (
|
||||
event: Electron.IpcMainEvent,
|
||||
action: 'subscribe' | 'unsubscribe',
|
||||
channel: string
|
||||
) => {
|
||||
if (!checkSource(event) || typeof channel !== 'string') return;
|
||||
if (action === 'subscribe') {
|
||||
addSubscription(event.sender, channel);
|
||||
if (channel === 'power:power-source') {
|
||||
event.sender.send(
|
||||
AFFINE_EVENT_CHANNEL_NAME,
|
||||
channel,
|
||||
powerMonitor.isOnBatteryPower()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
removeSubscription(event.sender, channel);
|
||||
}
|
||||
};
|
||||
|
||||
ipcMain.on(AFFINE_EVENT_SUBSCRIBE_CHANNEL_NAME, onSubscribe);
|
||||
unsubs.push(() =>
|
||||
ipcMain.removeListener(AFFINE_EVENT_SUBSCRIBE_CHANNEL_NAME, onSubscribe)
|
||||
);
|
||||
// register events
|
||||
for (const [namespace, namespaceEvents] of Object.entries(allEvents)) {
|
||||
for (const [key, eventRegister] of Object.entries(namespaceEvents)) {
|
||||
const unsubscribe = eventRegister((...args: any[]) => {
|
||||
const chan = `${namespace}:${key}`;
|
||||
logger.debug(
|
||||
'[ipc-event]',
|
||||
chan,
|
||||
args.filter(
|
||||
a =>
|
||||
a !== undefined &&
|
||||
typeof a !== 'function' &&
|
||||
typeof a !== 'object'
|
||||
)
|
||||
);
|
||||
getTargetContents(chan).forEach(wc => {
|
||||
if (!wc.isDestroyed()) {
|
||||
wc.send(AFFINE_EVENT_CHANNEL_NAME, chan, ...args);
|
||||
}
|
||||
});
|
||||
});
|
||||
unsubs.push(unsubscribe);
|
||||
}
|
||||
}
|
||||
|
||||
unsubs.forEach(unsub => {
|
||||
beforeAppQuit(() => {
|
||||
unsub();
|
||||
});
|
||||
});
|
||||
}
|
||||
29
packages/frontend/apps/electron/src/main/exposed.ts
Normal file
29
packages/frontend/apps/electron/src/main/exposed.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { allEvents as events } from './events';
|
||||
import { allHandlers as handlers } from './handlers';
|
||||
|
||||
// this will be used by preload script to expose all handlers and events to the renderer process
|
||||
// - register in exposeInMainWorld in preload
|
||||
// - provide type hints
|
||||
export { events, handlers };
|
||||
|
||||
export const getExposedMeta = () => {
|
||||
const handlersMeta = Object.entries(handlers).map(
|
||||
([namespace, namespaceHandlers]) => {
|
||||
return [namespace, Object.keys(namespaceHandlers)];
|
||||
}
|
||||
);
|
||||
|
||||
const eventsMeta = Object.entries(events).map(
|
||||
([namespace, namespaceHandlers]) => {
|
||||
return [namespace, Object.keys(namespaceHandlers)];
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
handlers: handlersMeta,
|
||||
events: eventsMeta,
|
||||
};
|
||||
};
|
||||
|
||||
export type MainIPCHandlerMap = typeof handlers;
|
||||
export type MainIPCEventMap = typeof events;
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
|
||||
export const findInPageHandlers = {
|
||||
find: async (event, text: string, options?: Electron.FindInPageOptions) => {
|
||||
const { promise, resolve } =
|
||||
Promise.withResolvers<Electron.Result | null>();
|
||||
const webContents = event.sender;
|
||||
let requestId: number = -1;
|
||||
webContents.once('found-in-page', (_, result) => {
|
||||
resolve(result.requestId === requestId ? result : null);
|
||||
});
|
||||
requestId = webContents.findInPage(text, options);
|
||||
return promise;
|
||||
},
|
||||
clear: async event => {
|
||||
const webContents = event.sender;
|
||||
webContents.stopFindInPage('keepSelection');
|
||||
},
|
||||
} satisfies NamespaceHandlers;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './handlers';
|
||||
117
packages/frontend/apps/electron/src/main/handlers.ts
Normal file
117
packages/frontend/apps/electron/src/main/handlers.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { I18n } from '@affine/i18n';
|
||||
import { ipcMain } from 'electron';
|
||||
|
||||
import { AFFINE_API_CHANNEL_NAME } from '../shared/type';
|
||||
import { authHandlers } from './auth/handlers';
|
||||
import { byokStorageHandlers } from './byok-storage/handlers';
|
||||
import { clipboardHandlers } from './clipboard';
|
||||
import { configStorageHandlers } from './config-storage';
|
||||
import { findInPageHandlers } from './find-in-page';
|
||||
import { importHandlers } from './import';
|
||||
import { getLogFilePath, logger, revealLogFile } from './logger';
|
||||
import { recordingHandlers } from './recording';
|
||||
import { checkSource } from './security-restrictions';
|
||||
import { sharedStorageHandlers } from './shared-storage';
|
||||
import { uiHandlers } from './ui/handlers';
|
||||
import { updaterHandlers } from './updater';
|
||||
import { popupHandlers } from './windows-manager/popup';
|
||||
import { workerHandlers } from './worker/handlers';
|
||||
|
||||
export const debugHandlers = {
|
||||
revealLogFile: async () => {
|
||||
return revealLogFile();
|
||||
},
|
||||
logFilePath: async () => {
|
||||
return getLogFilePath();
|
||||
},
|
||||
};
|
||||
|
||||
export const i18nHandlers = {
|
||||
changeLanguage: async (_: Electron.IpcMainInvokeEvent, language: string) => {
|
||||
return I18n.changeLanguage(language);
|
||||
},
|
||||
};
|
||||
|
||||
// Note: all of these handlers will be the single-source-of-truth for the apis exposed to the renderer process
|
||||
export const allHandlers = {
|
||||
debug: debugHandlers,
|
||||
ui: uiHandlers,
|
||||
clipboard: clipboardHandlers,
|
||||
updater: updaterHandlers,
|
||||
configStorage: configStorageHandlers,
|
||||
findInPage: findInPageHandlers,
|
||||
import: importHandlers,
|
||||
sharedStorage: sharedStorageHandlers,
|
||||
worker: workerHandlers,
|
||||
recording: recordingHandlers,
|
||||
popup: popupHandlers,
|
||||
i18n: i18nHandlers,
|
||||
byokStorage: byokStorageHandlers,
|
||||
auth: authHandlers,
|
||||
};
|
||||
|
||||
export const registerHandlers = () => {
|
||||
const handleIpcMessage = async (
|
||||
e: Electron.IpcMainInvokeEvent,
|
||||
...args: any[]
|
||||
) => {
|
||||
// args[0] is the `{namespace:key}`
|
||||
if (!checkSource(e) || typeof args[0] !== 'string') {
|
||||
logger.error('invalid ipc message', args);
|
||||
return;
|
||||
}
|
||||
const channel = args[0] as string;
|
||||
const [namespace, key] = channel.split(':');
|
||||
|
||||
if (!namespace || !key) {
|
||||
logger.error('invalid ipc message', args);
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-expect-error - ignore here
|
||||
const handler = allHandlers[namespace]?.[key];
|
||||
|
||||
if (!handler) {
|
||||
logger.error('handler not found for ', args[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const realArgs = args.slice(1);
|
||||
const result = await handler(e, ...realArgs);
|
||||
|
||||
logger.debug(
|
||||
'[ipc-api]',
|
||||
channel,
|
||||
realArgs.filter(
|
||||
arg => typeof arg !== 'function' && typeof arg !== 'object'
|
||||
),
|
||||
'-',
|
||||
Date.now() - start,
|
||||
'ms'
|
||||
);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
ipcMain.handle(AFFINE_API_CHANNEL_NAME, async (e, ...args: any[]) => {
|
||||
try {
|
||||
return await handleIpcMessage(e, ...args);
|
||||
} catch (error) {
|
||||
logger.error(`error in ipc handler when calling ${args[0]}`, error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on(AFFINE_API_CHANNEL_NAME, (e, ...args: any[]) => {
|
||||
if (!checkSource(e)) return;
|
||||
|
||||
handleIpcMessage(e, ...args)
|
||||
.then(ret => {
|
||||
e.returnValue = ret;
|
||||
})
|
||||
.catch(() => {
|
||||
// never throw
|
||||
});
|
||||
});
|
||||
};
|
||||
147
packages/frontend/apps/electron/src/main/helper-process.ts
Normal file
147
packages/frontend/apps/electron/src/main/helper-process.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import type { _AsyncVersionOf } from 'async-call-rpc';
|
||||
import { AsyncCall } from 'async-call-rpc';
|
||||
import type {
|
||||
BaseWindow,
|
||||
OpenDialogOptions,
|
||||
SaveDialogOptions,
|
||||
UtilityProcess,
|
||||
WebContents,
|
||||
} from 'electron';
|
||||
import {
|
||||
app,
|
||||
dialog,
|
||||
MessageChannelMain,
|
||||
shell,
|
||||
utilityProcess,
|
||||
} from 'electron';
|
||||
|
||||
import type { HelperToMain, MainToHelper } from '../shared/type';
|
||||
import { MessageEventChannel } from '../shared/utils';
|
||||
import { beforeAppQuit } from './cleanup';
|
||||
import { logger } from './logger';
|
||||
import { openExternalSafely } from './security/open-external';
|
||||
|
||||
const HELPER_PROCESS_PATH = path.join(__dirname, './helper.js');
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
function pickAndBind<T extends object, U extends keyof T>(
|
||||
obj: T,
|
||||
keys: U[]
|
||||
): { [K in U]: T[K] } {
|
||||
return keys.reduce((acc, key) => {
|
||||
const prop = obj[key];
|
||||
acc[key] = typeof prop === 'function' ? prop.bind(obj) : prop;
|
||||
return acc;
|
||||
}, {} as any);
|
||||
}
|
||||
|
||||
class HelperProcessManager {
|
||||
ready: Promise<void>;
|
||||
readonly #process: UtilityProcess;
|
||||
|
||||
// a rpc server for the main process -> helper process
|
||||
rpc?: _AsyncVersionOf<HelperToMain>;
|
||||
|
||||
static _instance: HelperProcessManager | null = null;
|
||||
|
||||
static get instance() {
|
||||
if (!this._instance) {
|
||||
this._instance = new HelperProcessManager();
|
||||
}
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
const helperProcess = utilityProcess.fork(HELPER_PROCESS_PATH, [], {
|
||||
// todo: port number should not being used
|
||||
execArgv: isDev ? ['--inspect=40894'] : [],
|
||||
serviceName: 'affine-helper',
|
||||
});
|
||||
this.#process = helperProcess;
|
||||
this.ready = new Promise((resolve, reject) => {
|
||||
helperProcess.once('spawn', () => {
|
||||
try {
|
||||
logger.info('[helper] forked', helperProcess.pid);
|
||||
resolve();
|
||||
} catch (err) {
|
||||
logger.error('[helper] connectMain error', err);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
beforeAppQuit(() => {
|
||||
this.#process.kill();
|
||||
});
|
||||
|
||||
this.#process.on('exit', code => {
|
||||
logger.error('[helper] process exited', { code });
|
||||
HelperProcessManager._instance = null;
|
||||
});
|
||||
|
||||
this.#process.on('error', err => {
|
||||
logger.error('[helper] process error', err);
|
||||
HelperProcessManager._instance = null;
|
||||
});
|
||||
}
|
||||
|
||||
// bridge renderer <-> helper process
|
||||
connectRenderer(renderer: WebContents) {
|
||||
// connect to the helper process
|
||||
const { port1: helperPort, port2: rendererPort } = new MessageChannelMain();
|
||||
this.#process.postMessage({ channel: 'renderer-connect' }, [helperPort]);
|
||||
renderer.postMessage('helper-connection', null, [rendererPort]);
|
||||
|
||||
return () => {
|
||||
try {
|
||||
helperPort.close();
|
||||
rendererPort.close();
|
||||
} catch (err) {
|
||||
logger.error('[helper] close port error', err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// bridge main <-> helper process
|
||||
// also set up the RPC to the helper process
|
||||
connectMain(window: BaseWindow) {
|
||||
const dialogMethods = {
|
||||
showOpenDialog: async (opts: OpenDialogOptions) => {
|
||||
return dialog.showOpenDialog(window, opts);
|
||||
},
|
||||
showSaveDialog: async (opts: SaveDialogOptions) => {
|
||||
return dialog.showSaveDialog(window, opts);
|
||||
},
|
||||
};
|
||||
const shellMethods = {
|
||||
openExternal: openExternalSafely as typeof shell.openExternal,
|
||||
showItemInFolder: shell.showItemInFolder.bind(shell),
|
||||
};
|
||||
const appMethods = pickAndBind(app, ['getPath']);
|
||||
|
||||
const mainToHelperServer: MainToHelper = {
|
||||
...dialogMethods,
|
||||
...shellMethods,
|
||||
...appMethods,
|
||||
};
|
||||
|
||||
this.rpc = AsyncCall<HelperToMain>(mainToHelperServer, {
|
||||
strict: {
|
||||
// the channel is shared for other purposes as well so that we do not want to
|
||||
// restrict to only JSONRPC messages
|
||||
unknownMessage: false,
|
||||
},
|
||||
channel: new MessageEventChannel(this.#process),
|
||||
log: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureHelperProcess() {
|
||||
const helperProcessManager = HelperProcessManager.instance;
|
||||
await helperProcessManager.ready;
|
||||
return helperProcessManager;
|
||||
}
|
||||
39
packages/frontend/apps/electron/src/main/import/handlers.ts
Normal file
39
packages/frontend/apps/electron/src/main/import/handlers.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { CreateImportSessionOptions } from '@affine/native';
|
||||
import {
|
||||
cancelImportSession,
|
||||
createImportSession,
|
||||
disposeImportSession,
|
||||
nextImportBatch,
|
||||
} from '@affine/native';
|
||||
|
||||
export const importHandlers = {
|
||||
createImportSession: (
|
||||
event: Electron.IpcMainInvokeEvent,
|
||||
options: CreateImportSessionOptions
|
||||
) => {
|
||||
void event;
|
||||
return createImportSession({
|
||||
format: options.format,
|
||||
source: options.source,
|
||||
batchLimits: options.batchLimits,
|
||||
});
|
||||
},
|
||||
nextImportBatch: (event: Electron.IpcMainInvokeEvent, sessionId: string) => {
|
||||
void event;
|
||||
return nextImportBatch(sessionId);
|
||||
},
|
||||
cancelImportSession: (
|
||||
event: Electron.IpcMainInvokeEvent,
|
||||
sessionId: string
|
||||
) => {
|
||||
void event;
|
||||
return cancelImportSession(sessionId);
|
||||
},
|
||||
disposeImportSession: (
|
||||
event: Electron.IpcMainInvokeEvent,
|
||||
sessionId: string
|
||||
) => {
|
||||
void event;
|
||||
return disposeImportSession(sessionId);
|
||||
},
|
||||
};
|
||||
1
packages/frontend/apps/electron/src/main/import/index.ts
Normal file
1
packages/frontend/apps/electron/src/main/import/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { importHandlers } from './handlers';
|
||||
152
packages/frontend/apps/electron/src/main/index.ts
Normal file
152
packages/frontend/apps/electron/src/main/index.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import * as Sentry from '@sentry/electron/main';
|
||||
import { IPCMode } from '@sentry/electron/main';
|
||||
import { app, protocol } from 'electron';
|
||||
|
||||
import { createApplicationMenu } from './application-menu/create';
|
||||
import { buildType, isDev, overrideSession } from './config';
|
||||
import { persistentConfig } from './config-storage/persist';
|
||||
import { setupDeepLink } from './deep-link';
|
||||
import { registerEvents } from './events';
|
||||
import { registerHandlers } from './handlers';
|
||||
import { logger } from './logger';
|
||||
import { registerProtocol } from './protocol';
|
||||
import { setupRecordingFeature } from './recording/feature';
|
||||
import { registerSecurityRestrictions } from './security-restrictions';
|
||||
import { setupTrayState } from './tray';
|
||||
import { registerUpdater } from './updater';
|
||||
import { launch } from './windows-manager/launcher';
|
||||
import { launchStage } from './windows-manager/stage';
|
||||
|
||||
app.enableSandbox();
|
||||
|
||||
if (isDev) {
|
||||
// In electron the dev server will be resolved to 0.0.0.0, but it
|
||||
// might be blocked by electron.
|
||||
// See https://github.com/webpack/webpack-dev-server/pull/384
|
||||
app.commandLine.appendSwitch('host-resolver-rules', 'MAP 0.0.0.0 127.0.0.1');
|
||||
}
|
||||
// https://github.com/electron/electron/issues/43556
|
||||
// // `CalculateNativeWinOcclusion` - Disable native window occlusion tracker (https://groups.google.com/a/chromium.org/g/embedder-dev/c/ZF3uHHyWLKw/m/VDN2hDXMAAAJ)
|
||||
const disabledFeatures = [
|
||||
'PlzDedicatedWorker',
|
||||
'CalculateNativeWinOcclusion',
|
||||
// Disable Chrome autofill and password save prompts
|
||||
'AutofillServerCommunication',
|
||||
'AutofillProfileCleanup',
|
||||
'AutofillAddressProfileSavePrompt',
|
||||
'AutofillPaymentCards',
|
||||
'AutofillEnableAccountWalletStorage',
|
||||
'SavePasswordBubble',
|
||||
].join(',');
|
||||
app.commandLine.appendSwitch('disable-features', disabledFeatures);
|
||||
app.commandLine.appendSwitch('disable-blink-features', 'Autofill');
|
||||
|
||||
// Following features are enabled from the runtime:
|
||||
// `DocumentPolicyIncludeJSCallStacksInCrashReports` - https://www.electronjs.org/docs/latest/api/web-frame-main#framecollectjavascriptcallstack-experimental
|
||||
// `EarlyEstablishGpuChannel` - Refs https://issues.chromium.org/issues/40208065
|
||||
// `EstablishGpuChannelAsync` - Refs https://issues.chromium.org/issues/40208065
|
||||
const enabledFeatures = [
|
||||
'DocumentPolicyIncludeJSCallStacksInCrashReports',
|
||||
'EarlyEstablishGpuChannel',
|
||||
'EstablishGpuChannelAsync',
|
||||
].join(',');
|
||||
app.commandLine.appendSwitch('enable-features', enabledFeatures);
|
||||
const enabledBlinkFeatures = ['CSSTextAutoSpace', 'WebCodecs'].join(',');
|
||||
app.commandLine.appendSwitch('enable-blink-features', enabledBlinkFeatures);
|
||||
app.commandLine.appendSwitch('force-color-profile', 'srgb');
|
||||
|
||||
// use the same data for internal & beta for testing
|
||||
if (overrideSession) {
|
||||
const appName = buildType === 'stable' ? 'AFFiNE' : `AFFiNE-${buildType}`;
|
||||
const userDataPath = path.join(app.getPath('appData'), appName);
|
||||
app.setPath('userData', userDataPath);
|
||||
app.setPath('sessionData', userDataPath);
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line typescript/no-var-requires
|
||||
if (require('electron-squirrel-startup')) app.quit();
|
||||
|
||||
if (process.env.SKIP_ONBOARDING) {
|
||||
launchStage.value = 'main';
|
||||
persistentConfig.set({
|
||||
onBoarding: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent multiple instances
|
||||
*/
|
||||
const isSingleInstance = app.requestSingleInstanceLock();
|
||||
if (!isSingleInstance) {
|
||||
logger.info(
|
||||
'Another instance is running or responding deep link, exiting...'
|
||||
);
|
||||
app.quit();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shout down background process if all windows was closed
|
||||
*/
|
||||
app.on('window-all-closed', () => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
/**
|
||||
* @see https://www.electronjs.org/docs/latest/api/app#event-activate-macos Event: 'activate'
|
||||
*/
|
||||
app.on('activate', () => {
|
||||
if (app.isReady()) {
|
||||
launch().catch(e => console.error('Failed launch:', e));
|
||||
}
|
||||
});
|
||||
|
||||
setupDeepLink(app);
|
||||
registerSecurityRestrictions();
|
||||
|
||||
/**
|
||||
* Create app window when background process will be ready
|
||||
*/
|
||||
app
|
||||
.whenReady()
|
||||
.then(registerProtocol)
|
||||
.then(registerHandlers)
|
||||
.then(registerEvents)
|
||||
.then(launch)
|
||||
.then(createApplicationMenu)
|
||||
.then(registerUpdater)
|
||||
.then(setupRecordingFeature)
|
||||
.then(setupTrayState)
|
||||
.catch(e => console.error('Failed create window:', e));
|
||||
|
||||
if (process.env.SENTRY_RELEASE) {
|
||||
// https://docs.sentry.io/platforms/javascript/guides/electron/
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
environment: process.env.BUILD_TYPE ?? 'development',
|
||||
ipcMode: IPCMode.Protocol,
|
||||
transportOptions: {
|
||||
maxAgeDays: 30,
|
||||
maxQueueSize: 100,
|
||||
},
|
||||
});
|
||||
Sentry.setTags({
|
||||
distribution: 'electron',
|
||||
appVersion: app.getVersion(),
|
||||
});
|
||||
}
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: 'assets',
|
||||
privileges: {
|
||||
secure: true,
|
||||
corsEnabled: true,
|
||||
supportFetchAPI: true,
|
||||
standard: true,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
25
packages/frontend/apps/electron/src/main/logger.ts
Normal file
25
packages/frontend/apps/electron/src/main/logger.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { app, shell } from 'electron';
|
||||
import log from 'electron-log/main';
|
||||
|
||||
import { isDev } from './config';
|
||||
|
||||
export const logger = log.scope('main');
|
||||
log.initialize({
|
||||
preload: false,
|
||||
});
|
||||
|
||||
log.transports.file.level = 'info';
|
||||
log.transports.console.level = isDev ? 'info' : false;
|
||||
|
||||
export function getLogFilePath() {
|
||||
return log.transports.file.getFile().path;
|
||||
}
|
||||
|
||||
export async function revealLogFile() {
|
||||
const filePath = getLogFilePath();
|
||||
return await shell.openPath(filePath);
|
||||
}
|
||||
|
||||
app.on('before-quit', () => {
|
||||
log.transports.console.level = false;
|
||||
});
|
||||
29
packages/frontend/apps/electron/src/main/power/index.ts
Normal file
29
packages/frontend/apps/electron/src/main/power/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { powerMonitor } from 'electron';
|
||||
|
||||
/**
|
||||
* Power-related event handlers for the Electron main process.
|
||||
*/
|
||||
export const powerEvents = {
|
||||
/**
|
||||
* Subscribes to system power source changes.
|
||||
* Emits the initial state immediately upon subscription.
|
||||
* @param emit - Callback function to send power state to the renderer.
|
||||
* @returns A cleanup function to remove listeners from powerMonitor.
|
||||
*/
|
||||
'power-source': (emit: (isOnBattery: boolean) => void) => {
|
||||
// emit initial state
|
||||
emit(powerMonitor.isOnBatteryPower());
|
||||
|
||||
const onBattery = () => emit(true);
|
||||
const onAC = () => emit(false);
|
||||
|
||||
powerMonitor.on('on-battery', onBattery);
|
||||
powerMonitor.on('on-ac', onAC);
|
||||
|
||||
// cleanup
|
||||
return () => {
|
||||
powerMonitor.off('on-battery', onBattery);
|
||||
powerMonitor.off('on-ac', onAC);
|
||||
};
|
||||
},
|
||||
};
|
||||
295
packages/frontend/apps/electron/src/main/protocol.ts
Normal file
295
packages/frontend/apps/electron/src/main/protocol.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import path, { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import { app, net, protocol, session } from 'electron';
|
||||
|
||||
import { anotherHost, mainHost } from '../shared/internal-origin';
|
||||
import {
|
||||
isPathInsideBase,
|
||||
isWindows,
|
||||
resolveExistingPathInBase,
|
||||
resolvePathInBase,
|
||||
resourcesPath,
|
||||
} from '../shared/utils';
|
||||
import {
|
||||
executeAuthSessionRequest,
|
||||
getAccessTokenForUrl,
|
||||
initializeAuthSessions,
|
||||
isManagedAuthEndpoint,
|
||||
} from './auth/auth-session';
|
||||
import { buildType, isDev } from './config';
|
||||
import { logger } from './logger';
|
||||
|
||||
const webStaticDir = join(resourcesPath, 'web-static');
|
||||
const devServerBase = process.env.DEV_SERVER_URL;
|
||||
const localWhiteListDirs = [
|
||||
path.resolve(app.getPath('sessionData')),
|
||||
path.resolve(app.getPath('temp')),
|
||||
];
|
||||
|
||||
function isPathInWhiteList(filepath: string) {
|
||||
return localWhiteListDirs.some(whitelistDir =>
|
||||
isPathInsideBase(whitelistDir, filepath, {
|
||||
caseInsensitive: isWindows(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveWhitelistedLocalPath(filepath: string) {
|
||||
for (const whitelistDir of localWhiteListDirs) {
|
||||
try {
|
||||
return await resolveExistingPathInBase(whitelistDir, filepath, {
|
||||
caseInsensitive: isWindows(),
|
||||
label: 'filepath',
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invalid filepath');
|
||||
}
|
||||
|
||||
const apiBaseByBuildType: Record<typeof buildType, string> = {
|
||||
stable: 'https://app.affine.pro',
|
||||
beta: 'https://insider.affine.pro',
|
||||
internal: 'https://insider.affine.pro',
|
||||
canary: 'https://affine.fail',
|
||||
};
|
||||
|
||||
function resolveApiBaseUrl() {
|
||||
if (isDev && devServerBase) {
|
||||
return devServerBase;
|
||||
}
|
||||
|
||||
return apiBaseByBuildType[buildType] ?? apiBaseByBuildType.stable;
|
||||
}
|
||||
|
||||
function buildTargetUrl(base: string, urlObject: URL) {
|
||||
return new URL(`${urlObject.pathname}${urlObject.search}`, base).toString();
|
||||
}
|
||||
|
||||
async function proxyRequest(
|
||||
request: Request,
|
||||
urlObject: URL,
|
||||
base: string,
|
||||
options: { bypassCustomProtocolHandlers?: boolean } = {}
|
||||
) {
|
||||
const { bypassCustomProtocolHandlers = true } = options;
|
||||
const targetUrl = buildTargetUrl(base, urlObject);
|
||||
return await executeAuthSessionRequest(request, targetUrl, request =>
|
||||
net.fetch(
|
||||
bypassCustomProtocolHandlers
|
||||
? Object.assign(request, { bypassCustomProtocolHandlers: true })
|
||||
: request
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleFileRequest(request: Request) {
|
||||
const urlObject = new URL(request.url);
|
||||
|
||||
if (urlObject.host === anotherHost) {
|
||||
urlObject.host = mainHost;
|
||||
}
|
||||
|
||||
const isAbsolutePath = urlObject.host !== '.';
|
||||
const isApiRequest =
|
||||
!isAbsolutePath &&
|
||||
(urlObject.pathname.startsWith('/api/') ||
|
||||
urlObject.pathname === '/graphql');
|
||||
|
||||
if (isApiRequest) {
|
||||
return proxyRequest(request, urlObject, resolveApiBaseUrl());
|
||||
}
|
||||
|
||||
const isFontRequest =
|
||||
urlObject.pathname &&
|
||||
/\.(woff2?|ttf|otf)$/i.test(urlObject.pathname.split('?')[0] ?? '');
|
||||
|
||||
// Redirect to webpack dev server if available
|
||||
if (isDev && devServerBase && !isAbsolutePath && !isFontRequest) {
|
||||
return proxyRequest(request, urlObject, devServerBase, {
|
||||
bypassCustomProtocolHandlers: false,
|
||||
});
|
||||
}
|
||||
const clonedRequest = Object.assign(request.clone(), {
|
||||
bypassCustomProtocolHandlers: true,
|
||||
});
|
||||
// this will be file types (in the web-static folder)
|
||||
let filepath = '';
|
||||
|
||||
// for relative path, load the file in resources
|
||||
if (!isAbsolutePath) {
|
||||
if (urlObject.pathname.split('/').at(-1)?.includes('.')) {
|
||||
const decodedPath = decodeURIComponent(urlObject.pathname).replace(
|
||||
/^\/+/,
|
||||
''
|
||||
);
|
||||
filepath = resolvePathInBase(webStaticDir, decodedPath, {
|
||||
caseInsensitive: isWindows(),
|
||||
label: 'filepath',
|
||||
});
|
||||
} else {
|
||||
// else, fallback to load the index.html instead
|
||||
filepath = join(webStaticDir, 'index.html');
|
||||
}
|
||||
} else {
|
||||
filepath = decodeURIComponent(urlObject.pathname);
|
||||
// on windows, the path could be start with '/'
|
||||
if (isWindows()) {
|
||||
filepath = path.resolve(filepath.replace(/^\//, ''));
|
||||
}
|
||||
if (urlObject.host !== 'local-file' || !isPathInWhiteList(filepath)) {
|
||||
throw new Error('Invalid filepath');
|
||||
}
|
||||
filepath = await resolveWhitelistedLocalPath(filepath);
|
||||
}
|
||||
return net.fetch(pathToFileURL(filepath).toString(), clonedRequest);
|
||||
}
|
||||
|
||||
const needRefererDomains = [
|
||||
/^(?:[a-zA-Z0-9-]+\.)*youtube\.com$/,
|
||||
/^(?:[a-zA-Z0-9-]+\.)*youtube-nocookie\.com$/,
|
||||
/^(?:[a-zA-Z0-9-]+\.)*googlevideo\.com$/,
|
||||
];
|
||||
const defaultReferer = 'https://client.affine.local/';
|
||||
const affineDomains = [
|
||||
/^(?:[a-z0-9-]+\.)*usercontent\.affine\.pro$/i,
|
||||
/^(?:[a-z0-9-]+\.)*affine\.pro$/i,
|
||||
/^(?:[a-z0-9-]+\.)*affine\.fail$/i,
|
||||
/^(?:[a-z0-9-]+\.)*affine\.run$/i,
|
||||
];
|
||||
|
||||
function setHeader(
|
||||
headers: Record<string, string[]>,
|
||||
name: string,
|
||||
value: string
|
||||
) {
|
||||
Object.keys(headers).forEach(key => {
|
||||
if (key.toLowerCase() === name.toLowerCase()) {
|
||||
delete headers[key];
|
||||
}
|
||||
});
|
||||
headers[name] = [value];
|
||||
}
|
||||
|
||||
function ensureFrameAncestors(
|
||||
headers: Record<string, string[]>,
|
||||
directive: string
|
||||
) {
|
||||
const cspHeaderKey = Object.keys(headers).find(
|
||||
key => key.toLowerCase() === 'content-security-policy'
|
||||
);
|
||||
if (!cspHeaderKey) {
|
||||
headers['Content-Security-Policy'] = [`frame-ancestors ${directive}`];
|
||||
return;
|
||||
}
|
||||
|
||||
const values = headers[cspHeaderKey];
|
||||
headers[cspHeaderKey] = values.map(val => {
|
||||
if (typeof val !== 'string') return val as any;
|
||||
const directives = val
|
||||
.split(';')
|
||||
.map(v => v.trim())
|
||||
.filter(Boolean)
|
||||
.filter(d => !d.toLowerCase().startsWith('frame-ancestors'));
|
||||
directives.push(`frame-ancestors ${directive}`);
|
||||
return directives.join('; ');
|
||||
});
|
||||
}
|
||||
|
||||
function allowCors(
|
||||
headers: Record<string, string[]>,
|
||||
origin: string = 'assets://.'
|
||||
) {
|
||||
// Object-storage upload and Copilot URLs do not include application CORS headers.
|
||||
setHeader(headers, 'Access-Control-Allow-Origin', origin);
|
||||
setHeader(headers, 'Access-Control-Allow-Credentials', 'true');
|
||||
setHeader(headers, 'Access-Control-Allow-Methods', 'GET, HEAD, PUT, OPTIONS');
|
||||
setHeader(
|
||||
headers,
|
||||
'Access-Control-Allow-Headers',
|
||||
'*, Authorization, Content-Type, Range'
|
||||
);
|
||||
}
|
||||
|
||||
export async function registerProtocol() {
|
||||
await initializeAuthSessions();
|
||||
|
||||
protocol.handle('assets', request => {
|
||||
return handleFileRequest(request);
|
||||
});
|
||||
|
||||
session.defaultSession.webRequest.onHeadersReceived(
|
||||
(responseDetails, callback) => {
|
||||
const { responseHeaders, url } = responseDetails;
|
||||
(async () => {
|
||||
if (responseHeaders) {
|
||||
const { protocol, hostname } = new URL(url);
|
||||
|
||||
// Adjust CORS for local assets and application-owned object storage.
|
||||
if (protocol === 'assets:') {
|
||||
delete responseHeaders['access-control-allow-origin'];
|
||||
delete responseHeaders['access-control-allow-headers'];
|
||||
delete responseHeaders['Access-Control-Allow-Origin'];
|
||||
delete responseHeaders['Access-Control-Allow-Headers'];
|
||||
setHeader(responseHeaders, 'X-Frame-Options', 'SAMEORIGIN');
|
||||
ensureFrameAncestors(responseHeaders, "'self'");
|
||||
} else if (
|
||||
(protocol === 'http:' || protocol === 'https:') &&
|
||||
affineDomains.some(regex => regex.test(hostname))
|
||||
) {
|
||||
allowCors(responseHeaders);
|
||||
}
|
||||
}
|
||||
})()
|
||||
.catch(err => {
|
||||
logger.error('error handling headers received', err);
|
||||
})
|
||||
.finally(() => {
|
||||
callback({ responseHeaders });
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
const url = new URL(details.url);
|
||||
const managedAuthRequest =
|
||||
(url.protocol === 'http:' ||
|
||||
url.protocol === 'https:' ||
|
||||
url.protocol === 'ws:' ||
|
||||
url.protocol === 'wss:') &&
|
||||
isManagedAuthEndpoint(details.url);
|
||||
let cancel = false;
|
||||
|
||||
(async () => {
|
||||
if (managedAuthRequest) {
|
||||
delete details.requestHeaders.authorization;
|
||||
delete details.requestHeaders.Authorization;
|
||||
const token = await getAccessTokenForUrl(details.url, 120_000);
|
||||
if (token) {
|
||||
details.requestHeaders.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
const hostname = url.hostname;
|
||||
const needReferer = needRefererDomains.some(regex =>
|
||||
regex.test(hostname)
|
||||
);
|
||||
if (needReferer && !details.requestHeaders['Referer']) {
|
||||
details.requestHeaders['Referer'] = defaultReferer;
|
||||
}
|
||||
})()
|
||||
.catch(err => {
|
||||
cancel = managedAuthRequest;
|
||||
logger.error('error handling before send headers', err);
|
||||
})
|
||||
.finally(() => {
|
||||
callback({
|
||||
cancel,
|
||||
requestHeaders: details.requestHeaders,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// the following apps are sorted by the name in alphabetical order
|
||||
const appNameKeywords = new Set([
|
||||
'arc',
|
||||
'boardmix',
|
||||
'brave',
|
||||
'chrome',
|
||||
'chromium',
|
||||
'classin',
|
||||
'dia',
|
||||
'dingtalk',
|
||||
'discord',
|
||||
'edge',
|
||||
'facetime',
|
||||
'feishu',
|
||||
'figma',
|
||||
'firefox',
|
||||
'lark',
|
||||
'meet',
|
||||
'messenger',
|
||||
'opera',
|
||||
'qq',
|
||||
'safari',
|
||||
'skype',
|
||||
'slack',
|
||||
'telegram',
|
||||
'tim',
|
||||
'vivaldi',
|
||||
'webex',
|
||||
'wechat',
|
||||
'whatsapp',
|
||||
'wire',
|
||||
'zen',
|
||||
'zoom',
|
||||
]);
|
||||
|
||||
export function isAppNameAllowed(name: string) {
|
||||
for (const keyword of appNameKeywords) {
|
||||
if (name.toLowerCase().includes(keyword.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,864 @@
|
||||
import { BehaviorSubject, type Observable } from 'rxjs';
|
||||
|
||||
import { logger } from '../logger';
|
||||
import { globalStateStorage } from '../shared-storage/storage';
|
||||
import type {
|
||||
AppGroupInfo,
|
||||
RecordingArtifactInfo,
|
||||
RecordingDisplayState,
|
||||
RecordingImportStatus,
|
||||
RecordingJobStatus,
|
||||
RecordingStatus,
|
||||
} from './types';
|
||||
|
||||
const RECORDING_JOBS_KEY = 'recordingJobs:v2';
|
||||
const IMPORT_LEASE_MS = 30_000;
|
||||
|
||||
interface NativeRecordingMeta {
|
||||
id: string;
|
||||
filepath: string;
|
||||
sampleRate: number;
|
||||
channels: number;
|
||||
startedAt?: number;
|
||||
}
|
||||
|
||||
interface NativeRecordingArtifact {
|
||||
id: string;
|
||||
filepath: string;
|
||||
sampleRate: number;
|
||||
channels: number;
|
||||
durationMs: number;
|
||||
size: number;
|
||||
degraded?: boolean;
|
||||
overflowCount?: number;
|
||||
}
|
||||
|
||||
export interface NativeRecordingController {
|
||||
startRecording(options: {
|
||||
appProcessId?: number;
|
||||
outputDir: string;
|
||||
format: 'opus';
|
||||
id: string;
|
||||
}): Promise<NativeRecordingMeta>;
|
||||
stopRecording(nativeId: string): Promise<NativeRecordingArtifact>;
|
||||
abortRecording(nativeId: string): Promise<void>;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function isArtifactInfo(value: unknown): value is RecordingArtifactInfo {
|
||||
if (!isObject(value) || typeof value.filepath !== 'string') {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(value.sampleRate === undefined || typeof value.sampleRate === 'number') &&
|
||||
(value.numberOfChannels === undefined ||
|
||||
typeof value.numberOfChannels === 'number') &&
|
||||
(value.durationMs === undefined || typeof value.durationMs === 'number') &&
|
||||
(value.size === undefined || typeof value.size === 'number') &&
|
||||
(value.degraded === undefined || typeof value.degraded === 'boolean') &&
|
||||
(value.overflowCount === undefined ||
|
||||
typeof value.overflowCount === 'number')
|
||||
);
|
||||
}
|
||||
|
||||
function isRecordingJob(value: unknown): value is RecordingJobStatus {
|
||||
if (!isObject(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof value.id !== 'number' ||
|
||||
typeof value.phase !== 'string' ||
|
||||
typeof value.startTime !== 'number' ||
|
||||
typeof value.createdAt !== 'number' ||
|
||||
typeof value.updatedAt !== 'number'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.appName !== undefined && typeof value.appName !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (value.appGroupId !== undefined && typeof value.appGroupId !== 'number') {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
value.bundleIdentifier !== undefined &&
|
||||
typeof value.bundleIdentifier !== 'string'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
value.appProcessId !== undefined &&
|
||||
typeof value.appProcessId !== 'number'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (value.nativeId !== undefined && typeof value.nativeId !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (value.artifact !== undefined && !isArtifactInfo(value.artifact)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.import !== undefined) {
|
||||
if (!isObject(value.import)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
value.import.workspaceId !== undefined &&
|
||||
typeof value.import.workspaceId !== 'string'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
value.import.docId !== undefined &&
|
||||
typeof value.import.docId !== 'string'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
value.import.errorMessage !== undefined &&
|
||||
typeof value.import.errorMessage !== 'string'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
value.import.leaseExpiresAt !== undefined &&
|
||||
typeof value.import.leaseExpiresAt !== 'number'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
value.import.startedAt !== undefined &&
|
||||
typeof value.import.startedAt !== 'number'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
value.import.finishedAt !== undefined &&
|
||||
typeof value.import.finishedAt !== 'number'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
value.error !== undefined &&
|
||||
(!isObject(value.error) ||
|
||||
typeof value.error.stage !== 'string' ||
|
||||
typeof value.error.message !== 'string')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
value.dismissedAt !== undefined &&
|
||||
typeof value.dismissedAt !== 'number'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function loadPersistedJobs() {
|
||||
const persisted = globalStateStorage.get(RECORDING_JOBS_KEY);
|
||||
if (!Array.isArray(persisted)) {
|
||||
return [] as RecordingJobStatus[];
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
return persisted.flatMap(value => {
|
||||
if (!isRecordingJob(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (
|
||||
value.phase === 'new' ||
|
||||
value.phase === 'starting' ||
|
||||
value.phase === 'recording' ||
|
||||
value.phase === 'finalizing' ||
|
||||
value.phase === 'aborted'
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (value.phase === 'recorded' || value.phase === 'importing') {
|
||||
return [
|
||||
{
|
||||
...value,
|
||||
phase: 'recorded' as const,
|
||||
import: {
|
||||
...value.import,
|
||||
errorMessage: undefined,
|
||||
leaseExpiresAt: undefined,
|
||||
},
|
||||
updatedAt: now,
|
||||
dismissedAt: value.dismissedAt ?? now,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (value.phase === 'imported' || value.phase === 'failed') {
|
||||
return [{ ...value, dismissedAt: value.dismissedAt ?? now }];
|
||||
}
|
||||
|
||||
return [value];
|
||||
});
|
||||
}
|
||||
|
||||
function toImportStatus(job: RecordingJobStatus): RecordingImportStatus | null {
|
||||
if (!job.artifact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let importStatus: RecordingImportStatus['importStatus'];
|
||||
switch (job.phase) {
|
||||
case 'recorded':
|
||||
importStatus = 'pending_import';
|
||||
break;
|
||||
case 'importing':
|
||||
importStatus = 'importing';
|
||||
break;
|
||||
case 'imported':
|
||||
importStatus = 'imported';
|
||||
break;
|
||||
case 'failed':
|
||||
if (job.error?.stage !== 'import') {
|
||||
return null;
|
||||
}
|
||||
importStatus = 'import_failed';
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: job.id,
|
||||
appName: job.appName,
|
||||
workspaceId: job.import?.workspaceId,
|
||||
docId: job.import?.docId,
|
||||
startTime: job.startTime,
|
||||
filepath: job.artifact.filepath,
|
||||
sampleRate: job.artifact.sampleRate,
|
||||
numberOfChannels: job.artifact.numberOfChannels,
|
||||
durationMs: job.artifact.durationMs,
|
||||
size: job.artifact.size,
|
||||
degraded: job.artifact.degraded,
|
||||
overflowCount: job.artifact.overflowCount,
|
||||
importStatus,
|
||||
errorMessage: job.error?.stage === 'import' ? job.error.message : undefined,
|
||||
createdAt: job.createdAt,
|
||||
updatedAt: job.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function toDisplayStatus(
|
||||
job: RecordingJobStatus | undefined
|
||||
): RecordingStatus | null {
|
||||
if (!job || job.dismissedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let status: RecordingDisplayState | null = null;
|
||||
switch (job.phase) {
|
||||
case 'new':
|
||||
case 'starting':
|
||||
case 'recording':
|
||||
case 'finalizing':
|
||||
status = job.phase;
|
||||
break;
|
||||
case 'recorded':
|
||||
status = 'pending_import';
|
||||
break;
|
||||
case 'importing':
|
||||
status = 'importing';
|
||||
break;
|
||||
case 'imported':
|
||||
status = 'imported';
|
||||
break;
|
||||
case 'failed':
|
||||
if (job.error?.stage === 'start') {
|
||||
status = 'start_failed';
|
||||
} else if (job.error?.stage === 'finalize') {
|
||||
status = 'finalize_failed';
|
||||
} else {
|
||||
status = 'import_failed';
|
||||
}
|
||||
break;
|
||||
case 'aborted':
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: job.id,
|
||||
status,
|
||||
appName: job.appName,
|
||||
appGroupId: job.appGroupId,
|
||||
startTime: job.startTime,
|
||||
filepath: job.artifact?.filepath,
|
||||
sampleRate: job.artifact?.sampleRate,
|
||||
numberOfChannels: job.artifact?.numberOfChannels,
|
||||
durationMs: job.artifact?.durationMs,
|
||||
size: job.artifact?.size,
|
||||
degraded: job.artifact?.degraded,
|
||||
overflowCount: job.artifact?.overflowCount,
|
||||
errorMessage: job.error?.message,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDocId(jobId: number) {
|
||||
return `recording-${jobId}`;
|
||||
}
|
||||
|
||||
export class RecordingCoordinator {
|
||||
private readonly jobsSubject$ = new BehaviorSubject<RecordingJobStatus[]>(
|
||||
loadPersistedJobs()
|
||||
);
|
||||
private readonly statusSubject$ = new BehaviorSubject<RecordingStatus | null>(
|
||||
null
|
||||
);
|
||||
private readonly importQueueSubject$ = new BehaviorSubject<
|
||||
RecordingImportStatus[]
|
||||
>([]);
|
||||
private nextId =
|
||||
this.jobsSubject$.value.reduce((max, job) => Math.max(max, job.id), -1) + 1;
|
||||
|
||||
constructor(
|
||||
private readonly outputDir: string,
|
||||
private readonly resolveFilepath: (filepath: string) => Promise<string>,
|
||||
private readonly getNativeController: () => Promise<NativeRecordingController>
|
||||
) {
|
||||
this.emit();
|
||||
}
|
||||
|
||||
get jobs$(): Observable<RecordingJobStatus[]> {
|
||||
return this.jobsSubject$.asObservable();
|
||||
}
|
||||
|
||||
get status$(): Observable<RecordingStatus | null> {
|
||||
return this.statusSubject$.asObservable();
|
||||
}
|
||||
|
||||
get importQueue$(): Observable<RecordingImportStatus[]> {
|
||||
return this.importQueueSubject$.asObservable();
|
||||
}
|
||||
|
||||
get jobs() {
|
||||
return this.jobsSubject$.value;
|
||||
}
|
||||
|
||||
currentStatus() {
|
||||
return this.statusSubject$.value;
|
||||
}
|
||||
|
||||
importQueue() {
|
||||
return this.importQueueSubject$.value;
|
||||
}
|
||||
|
||||
activeJob() {
|
||||
return this.jobs.find(
|
||||
job =>
|
||||
job.phase === 'starting' ||
|
||||
job.phase === 'recording' ||
|
||||
job.phase === 'finalizing'
|
||||
);
|
||||
}
|
||||
|
||||
createPrompt(appGroup?: AppGroupInfo) {
|
||||
const matchingPrompt = this.jobs.find(
|
||||
job =>
|
||||
job.phase === 'new' &&
|
||||
job.dismissedAt === undefined &&
|
||||
job.appGroupId === appGroup?.processGroupId
|
||||
);
|
||||
if (matchingPrompt) {
|
||||
return matchingPrompt;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const runningApp = appGroup?.apps.find(app => app.isRunning);
|
||||
const job: RecordingJobStatus = {
|
||||
id: this.nextId++,
|
||||
phase: 'new',
|
||||
appName: appGroup?.name,
|
||||
appGroupId: appGroup?.processGroupId,
|
||||
bundleIdentifier: appGroup?.bundleIdentifier,
|
||||
appProcessId: runningApp?.processId,
|
||||
startTime: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
this.setJobs([...this.jobs, job]);
|
||||
return job;
|
||||
}
|
||||
|
||||
async start(appGroup?: AppGroupInfo) {
|
||||
const currentActive = this.activeJob();
|
||||
if (currentActive) {
|
||||
logger.error(
|
||||
'Cannot start a new recording while another session is active'
|
||||
);
|
||||
return currentActive;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const runningApp = appGroup?.apps.find(app => app.isRunning);
|
||||
const matchingPrompt = this.jobs.find(
|
||||
job =>
|
||||
job.phase === 'new' &&
|
||||
job.dismissedAt === undefined &&
|
||||
job.appGroupId === appGroup?.processGroupId
|
||||
);
|
||||
|
||||
const startingJob: RecordingJobStatus = matchingPrompt
|
||||
? {
|
||||
...matchingPrompt,
|
||||
phase: 'starting',
|
||||
appName: appGroup?.name ?? matchingPrompt.appName,
|
||||
appGroupId: appGroup?.processGroupId ?? matchingPrompt.appGroupId,
|
||||
bundleIdentifier:
|
||||
appGroup?.bundleIdentifier ?? matchingPrompt.bundleIdentifier,
|
||||
appProcessId: runningApp?.processId,
|
||||
updatedAt: now,
|
||||
dismissedAt: undefined,
|
||||
error: undefined,
|
||||
}
|
||||
: {
|
||||
id: this.nextId++,
|
||||
phase: 'starting',
|
||||
appName: appGroup?.name,
|
||||
appGroupId: appGroup?.processGroupId,
|
||||
bundleIdentifier: appGroup?.bundleIdentifier,
|
||||
appProcessId: runningApp?.processId,
|
||||
startTime: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
this.upsertJob(startingJob);
|
||||
|
||||
let nativeId: string | undefined;
|
||||
try {
|
||||
logger.info(`recording ${startingJob.id} starting`);
|
||||
const nativeController = await this.getNativeController();
|
||||
const meta = await nativeController.startRecording({
|
||||
appProcessId: startingJob.appProcessId,
|
||||
outputDir: this.outputDir,
|
||||
format: 'opus',
|
||||
id: String(startingJob.id),
|
||||
});
|
||||
nativeId = meta.id;
|
||||
|
||||
const filepath = await this.resolveFilepath(meta.filepath);
|
||||
const currentJob = this.findJob(startingJob.id);
|
||||
if (!currentJob || currentJob.phase !== 'starting') {
|
||||
if (nativeId) {
|
||||
await nativeController.abortRecording(nativeId).catch(error => {
|
||||
logger.error('failed to cleanup abandoned native recording', error);
|
||||
});
|
||||
}
|
||||
return this.findJob(startingJob.id) ?? currentJob ?? null;
|
||||
}
|
||||
|
||||
const nextJob: RecordingJobStatus = {
|
||||
...currentJob,
|
||||
phase: 'recording',
|
||||
nativeId: meta.id,
|
||||
startTime: meta.startedAt ?? Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
artifact: {
|
||||
filepath,
|
||||
sampleRate: meta.sampleRate,
|
||||
numberOfChannels: meta.channels,
|
||||
},
|
||||
};
|
||||
this.upsertJob(nextJob);
|
||||
logger.info(`recording ${startingJob.id} started`, {
|
||||
nativeId: meta.id,
|
||||
sampleRate: meta.sampleRate,
|
||||
channels: meta.channels,
|
||||
});
|
||||
return nextJob;
|
||||
} catch (error) {
|
||||
if (nativeId) {
|
||||
const nativeController = await this.getNativeController();
|
||||
await nativeController.abortRecording(nativeId).catch(cleanupError => {
|
||||
logger.error(
|
||||
'failed to cleanup abandoned native recording',
|
||||
cleanupError
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const currentJob = this.findJob(startingJob.id);
|
||||
if (currentJob && currentJob.phase === 'starting') {
|
||||
this.upsertJob({
|
||||
...currentJob,
|
||||
phase: 'failed',
|
||||
updatedAt: Date.now(),
|
||||
error: {
|
||||
stage: 'start',
|
||||
message: error instanceof Error ? error.message : 'failed to start',
|
||||
},
|
||||
});
|
||||
}
|
||||
logger.error('failed to start recording', error);
|
||||
return this.findJob(startingJob.id) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(id: number) {
|
||||
const job = this.findJob(id);
|
||||
if (!job || job.phase !== 'recording' || !job.nativeId) {
|
||||
logger.error(`stopRecording: Recording ${id} not found`);
|
||||
return job ?? null;
|
||||
}
|
||||
|
||||
const finalizingJob: RecordingJobStatus = {
|
||||
...job,
|
||||
phase: 'finalizing',
|
||||
updatedAt: Date.now(),
|
||||
error: undefined,
|
||||
};
|
||||
this.upsertJob(finalizingJob);
|
||||
|
||||
try {
|
||||
logger.info(`recording ${id} finalizing`, {
|
||||
nativeId: job.nativeId,
|
||||
});
|
||||
const nativeController = await this.getNativeController();
|
||||
const artifact = await nativeController.stopRecording(job.nativeId);
|
||||
const filepath = await this.resolveFilepath(artifact.filepath);
|
||||
|
||||
const currentJob = this.findJob(id);
|
||||
if (!currentJob || currentJob.phase !== 'finalizing') {
|
||||
return currentJob ?? null;
|
||||
}
|
||||
|
||||
const nextJob: RecordingJobStatus = {
|
||||
...currentJob,
|
||||
phase: 'recorded',
|
||||
nativeId: undefined,
|
||||
updatedAt: Date.now(),
|
||||
artifact: {
|
||||
filepath,
|
||||
sampleRate: artifact.sampleRate,
|
||||
numberOfChannels: artifact.channels,
|
||||
durationMs: artifact.durationMs,
|
||||
size: artifact.size,
|
||||
degraded: artifact.degraded,
|
||||
overflowCount: artifact.overflowCount,
|
||||
},
|
||||
import: {
|
||||
...currentJob.import,
|
||||
errorMessage: undefined,
|
||||
leaseExpiresAt: undefined,
|
||||
},
|
||||
};
|
||||
this.upsertJob(nextJob);
|
||||
logger.info(`recording ${id} finalized`, {
|
||||
filepath,
|
||||
degraded: artifact.degraded,
|
||||
overflowCount: artifact.overflowCount,
|
||||
});
|
||||
return nextJob;
|
||||
} catch (error) {
|
||||
logger.error('Failed to stop recording', error);
|
||||
const currentJob = this.findJob(id);
|
||||
if (currentJob && currentJob.phase === 'finalizing') {
|
||||
this.upsertJob({
|
||||
...currentJob,
|
||||
phase: 'failed',
|
||||
updatedAt: Date.now(),
|
||||
error: {
|
||||
stage: 'finalize',
|
||||
message:
|
||||
error instanceof Error ? error.message : 'failed to finalize',
|
||||
},
|
||||
});
|
||||
}
|
||||
return this.findJob(id) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
async abortActive() {
|
||||
const job =
|
||||
this.activeJob() ??
|
||||
[...this.jobs]
|
||||
.sort((left, right) => right.updatedAt - left.updatedAt)
|
||||
.find(
|
||||
entry =>
|
||||
!!entry.nativeId &&
|
||||
(entry.phase === 'starting' ||
|
||||
entry.phase === 'recording' ||
|
||||
entry.phase === 'finalizing')
|
||||
);
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!job.nativeId) {
|
||||
this.upsertJob({
|
||||
...job,
|
||||
phase: 'aborted',
|
||||
updatedAt: Date.now(),
|
||||
dismissedAt: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nativeController = await this.getNativeController();
|
||||
try {
|
||||
await nativeController.abortRecording(job.nativeId);
|
||||
} finally {
|
||||
this.removeJob(job.id);
|
||||
}
|
||||
}
|
||||
|
||||
getRecording(id: number) {
|
||||
const job = this.findJob(id);
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
startTime: job.startTime,
|
||||
filepath: job.artifact?.filepath,
|
||||
sampleRate: job.artifact?.sampleRate,
|
||||
numberOfChannels: job.artifact?.numberOfChannels,
|
||||
appGroup: job.appGroupId
|
||||
? {
|
||||
processGroupId: job.appGroupId,
|
||||
apps: [],
|
||||
name: job.appName ?? '',
|
||||
bundleIdentifier: job.bundleIdentifier ?? '',
|
||||
icon: undefined,
|
||||
isRunning: false,
|
||||
}
|
||||
: undefined,
|
||||
app:
|
||||
job.appProcessId &&
|
||||
job.appName &&
|
||||
job.appGroupId &&
|
||||
job.bundleIdentifier
|
||||
? {
|
||||
info: {} as never,
|
||||
isRunning: false,
|
||||
processId: job.appProcessId,
|
||||
processGroupId: job.appGroupId,
|
||||
bundleIdentifier: job.bundleIdentifier,
|
||||
name: job.appName,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
claimImport(id: number, workspaceId: string) {
|
||||
const now = Date.now();
|
||||
let claimed: RecordingJobStatus | null = null;
|
||||
this.setJobs(
|
||||
this.jobs.map(job => {
|
||||
if (job.id !== id || !job.artifact) {
|
||||
return job;
|
||||
}
|
||||
|
||||
if (job.import?.workspaceId && job.import.workspaceId !== workspaceId) {
|
||||
return job;
|
||||
}
|
||||
|
||||
if (job.phase === 'recorded') {
|
||||
claimed = {
|
||||
...job,
|
||||
phase: 'importing',
|
||||
updatedAt: now,
|
||||
import: {
|
||||
...job.import,
|
||||
workspaceId,
|
||||
docId: job.import?.docId ?? buildDocId(job.id),
|
||||
errorMessage: undefined,
|
||||
startedAt: job.import?.startedAt ?? now,
|
||||
leaseExpiresAt: now + IMPORT_LEASE_MS,
|
||||
},
|
||||
dismissedAt: undefined,
|
||||
};
|
||||
return claimed;
|
||||
}
|
||||
|
||||
if (
|
||||
job.phase === 'importing' &&
|
||||
(!job.import?.leaseExpiresAt || job.import.leaseExpiresAt <= now)
|
||||
) {
|
||||
claimed = {
|
||||
...job,
|
||||
updatedAt: now,
|
||||
import: {
|
||||
...job.import,
|
||||
workspaceId,
|
||||
docId: job.import?.docId ?? buildDocId(job.id),
|
||||
errorMessage: undefined,
|
||||
startedAt: job.import?.startedAt ?? now,
|
||||
leaseExpiresAt: now + IMPORT_LEASE_MS,
|
||||
},
|
||||
dismissedAt: undefined,
|
||||
};
|
||||
return claimed;
|
||||
}
|
||||
|
||||
return job;
|
||||
})
|
||||
);
|
||||
return claimed ? toImportStatus(claimed) : null;
|
||||
}
|
||||
|
||||
completeImport(id: number) {
|
||||
const job = this.findJob(id);
|
||||
if (!job || (job.phase !== 'recorded' && job.phase !== 'importing')) {
|
||||
logger.error(`Recording import ${id} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextJob: RecordingJobStatus = {
|
||||
...job,
|
||||
phase: 'imported',
|
||||
updatedAt: Date.now(),
|
||||
import: {
|
||||
...job.import,
|
||||
errorMessage: undefined,
|
||||
leaseExpiresAt: undefined,
|
||||
finishedAt: Date.now(),
|
||||
},
|
||||
error: undefined,
|
||||
dismissedAt: undefined,
|
||||
};
|
||||
this.upsertJob(nextJob);
|
||||
return toImportStatus(nextJob);
|
||||
}
|
||||
|
||||
failImport(id: number, errorMessage?: string) {
|
||||
const job = this.findJob(id);
|
||||
if (!job || (job.phase !== 'recorded' && job.phase !== 'importing')) {
|
||||
logger.error(`Recording import ${id} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextJob: RecordingJobStatus = {
|
||||
...job,
|
||||
phase: 'failed',
|
||||
updatedAt: Date.now(),
|
||||
import: {
|
||||
...job.import,
|
||||
errorMessage,
|
||||
leaseExpiresAt: undefined,
|
||||
},
|
||||
error: {
|
||||
stage: 'import',
|
||||
message: errorMessage ?? 'failed to import recording',
|
||||
},
|
||||
dismissedAt: undefined,
|
||||
};
|
||||
this.upsertJob(nextJob);
|
||||
return toImportStatus(nextJob);
|
||||
}
|
||||
|
||||
dismiss(id: number) {
|
||||
const job = this.findJob(id);
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (job.phase === 'imported') {
|
||||
this.removeJob(id);
|
||||
return this.currentStatus();
|
||||
}
|
||||
|
||||
if (
|
||||
job.phase === 'new' ||
|
||||
(job.phase === 'failed' && job.error?.stage !== 'import')
|
||||
) {
|
||||
this.removeJob(id);
|
||||
return this.currentStatus();
|
||||
}
|
||||
|
||||
this.upsertJob({
|
||||
...job,
|
||||
updatedAt: Date.now(),
|
||||
dismissedAt: Date.now(),
|
||||
});
|
||||
return this.currentStatus();
|
||||
}
|
||||
|
||||
remove(id: number) {
|
||||
this.removeJob(id);
|
||||
}
|
||||
|
||||
private findJob(id: number) {
|
||||
return this.jobs.find(job => job.id === id) ?? null;
|
||||
}
|
||||
|
||||
private upsertJob(job: RecordingJobStatus) {
|
||||
const nextJobs = this.jobs.filter(entry => entry.id !== job.id);
|
||||
nextJobs.push(job);
|
||||
nextJobs.sort((left, right) => left.id - right.id);
|
||||
this.setJobs(nextJobs);
|
||||
}
|
||||
|
||||
private removeJob(id: number) {
|
||||
this.setJobs(this.jobs.filter(job => job.id !== id));
|
||||
}
|
||||
|
||||
private setJobs(jobs: RecordingJobStatus[]) {
|
||||
this.jobsSubject$.next(jobs);
|
||||
globalStateStorage.set(RECORDING_JOBS_KEY, jobs);
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private emit() {
|
||||
const visibleJobs = this.jobs.filter(job => !job.dismissedAt);
|
||||
const current =
|
||||
visibleJobs.find(
|
||||
job =>
|
||||
job.phase === 'new' ||
|
||||
job.phase === 'starting' ||
|
||||
job.phase === 'recording' ||
|
||||
job.phase === 'finalizing'
|
||||
) ??
|
||||
[...visibleJobs]
|
||||
.sort((left, right) => right.updatedAt - left.updatedAt)
|
||||
.find(
|
||||
job =>
|
||||
job.phase === 'recorded' ||
|
||||
job.phase === 'importing' ||
|
||||
job.phase === 'imported' ||
|
||||
job.phase === 'failed'
|
||||
);
|
||||
|
||||
this.statusSubject$.next(toDisplayStatus(current));
|
||||
this.importQueueSubject$.next(
|
||||
this.jobs
|
||||
.flatMap(job => {
|
||||
const status = toImportStatus(job);
|
||||
if (!status) {
|
||||
return [];
|
||||
}
|
||||
return status.importStatus === 'pending_import' ||
|
||||
status.importStatus === 'importing'
|
||||
? [status]
|
||||
: [];
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (left.updatedAt !== right.updatedAt) {
|
||||
return right.updatedAt - left.updatedAt;
|
||||
}
|
||||
return right.id - left.id;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
718
packages/frontend/apps/electron/src/main/recording/feature.ts
Normal file
718
packages/frontend/apps/electron/src/main/recording/feature.ts
Normal file
@@ -0,0 +1,718 @@
|
||||
/* oxlint-disable no-var-requires */
|
||||
import { execSync } from 'node:child_process';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type * as NativeModuleType from '@affine/native';
|
||||
import { app, systemPreferences } from 'electron';
|
||||
import fs from 'fs-extra';
|
||||
import { debounce } from 'lodash-es';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
distinctUntilChanged,
|
||||
groupBy,
|
||||
interval,
|
||||
mergeMap,
|
||||
type Observable,
|
||||
Subject,
|
||||
throttleTime,
|
||||
} from 'rxjs';
|
||||
import { filter, map, shareReplay } from 'rxjs/operators';
|
||||
|
||||
import {
|
||||
isMacOS,
|
||||
isWindows,
|
||||
resolveExistingPathInBase,
|
||||
shallowEqual,
|
||||
} from '../../shared/utils';
|
||||
import { beforeAppQuit } from '../cleanup';
|
||||
import { logger } from '../logger';
|
||||
import {
|
||||
MeetingSettingsKey,
|
||||
MeetingSettingsSchema,
|
||||
} from '../shared-state-schema';
|
||||
import { globalStateStorage } from '../shared-storage/storage';
|
||||
import { showMainWindow } from '../windows-manager';
|
||||
import { popupManager } from '../windows-manager/popup';
|
||||
import { isAppNameAllowed } from './allow-list';
|
||||
import { RecordingCoordinator } from './coordinator';
|
||||
import type {
|
||||
AppGroupInfo,
|
||||
RecordingImportStatus,
|
||||
RecordingJobStatus,
|
||||
RecordingStatus,
|
||||
TappableAppInfo,
|
||||
} from './types';
|
||||
|
||||
export const MeetingsSettingsState = {
|
||||
$: globalStateStorage.watch<MeetingSettingsSchema>(MeetingSettingsKey).pipe(
|
||||
map(v => MeetingSettingsSchema.parse(v ?? {})),
|
||||
shareReplay(1)
|
||||
),
|
||||
|
||||
get value() {
|
||||
return MeetingSettingsSchema.parse(
|
||||
globalStateStorage.get(MeetingSettingsKey) ?? {}
|
||||
);
|
||||
},
|
||||
|
||||
set value(value: MeetingSettingsSchema) {
|
||||
globalStateStorage.set(MeetingSettingsKey, value);
|
||||
},
|
||||
};
|
||||
|
||||
type Subscriber = {
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
|
||||
const subscribers: Subscriber[] = [];
|
||||
let appStateSubscribers: Subscriber[] = [];
|
||||
|
||||
export const SAVED_RECORDINGS_DIR = path.join(
|
||||
app.getPath('sessionData'),
|
||||
'recordings'
|
||||
);
|
||||
|
||||
type NativeModule = typeof NativeModuleType;
|
||||
type ShareableContentType = InstanceType<NativeModule['ShareableContent']>;
|
||||
type ShareableContentStatic = NativeModule['ShareableContent'];
|
||||
|
||||
let shareableContent: ShareableContentType | null = null;
|
||||
let nativeModuleOverride: NativeModule | null = null;
|
||||
|
||||
function getNativeModule(): NativeModule {
|
||||
return nativeModuleOverride ?? (require('@affine/native') as NativeModule);
|
||||
}
|
||||
|
||||
async function getNativeModuleAsync(): Promise<NativeModule> {
|
||||
if (nativeModuleOverride) {
|
||||
return nativeModuleOverride;
|
||||
}
|
||||
return (await import('@affine/native')) as NativeModule;
|
||||
}
|
||||
|
||||
async function assertRecordingFilepath(filepath: string) {
|
||||
return await resolveExistingPathInBase(SAVED_RECORDINGS_DIR, filepath, {
|
||||
caseInsensitive: isWindows(),
|
||||
label: 'recording filepath',
|
||||
});
|
||||
}
|
||||
|
||||
const recordingCoordinator = new RecordingCoordinator(
|
||||
SAVED_RECORDINGS_DIR,
|
||||
assertRecordingFilepath,
|
||||
async () => {
|
||||
const nativeModule = await getNativeModuleAsync();
|
||||
return {
|
||||
startRecording: nativeModule.startRecording,
|
||||
stopRecording: nativeModule.stopRecording,
|
||||
abortRecording: nativeModule.abortRecording,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export function setRecordingNativeModuleForTesting(
|
||||
nativeModule: NativeModule | null
|
||||
) {
|
||||
nativeModuleOverride = nativeModule;
|
||||
}
|
||||
|
||||
function resetFeatureSubscriptions() {
|
||||
appStateSubscribers.forEach(subscriber => {
|
||||
try {
|
||||
subscriber.unsubscribe();
|
||||
} catch {
|
||||
// ignore unsubscribe error
|
||||
}
|
||||
});
|
||||
appStateSubscribers = [];
|
||||
subscribers.forEach(subscriber => {
|
||||
try {
|
||||
subscriber.unsubscribe();
|
||||
} catch {
|
||||
// ignore unsubscribe error
|
||||
}
|
||||
});
|
||||
subscribers.length = 0;
|
||||
shareableContent = null;
|
||||
applications$.next([]);
|
||||
appGroups$.next([]);
|
||||
}
|
||||
|
||||
async function abortActiveRecording() {
|
||||
try {
|
||||
await recordingCoordinator.abortActive();
|
||||
} catch (error) {
|
||||
logger.error('failed to cleanup abandoned native recording', error);
|
||||
}
|
||||
}
|
||||
|
||||
beforeAppQuit(() => {
|
||||
void abortActiveRecording().catch(() => undefined);
|
||||
resetFeatureSubscriptions();
|
||||
});
|
||||
|
||||
export const applications$ = new BehaviorSubject<TappableAppInfo[]>([]);
|
||||
export const appGroups$ = new BehaviorSubject<AppGroupInfo[]>([]);
|
||||
export const updateApplicationsPing$ = new Subject<number>();
|
||||
|
||||
export const recordingStatus$: Observable<RecordingStatus | null> =
|
||||
recordingCoordinator.status$;
|
||||
export const recordingImportQueue$: Observable<RecordingImportStatus[]> =
|
||||
recordingCoordinator.importQueue$;
|
||||
|
||||
export function getCurrentRecordingStatus() {
|
||||
return recordingCoordinator.currentStatus();
|
||||
}
|
||||
|
||||
function hasActivePopupStatus(status: RecordingStatus | null | undefined) {
|
||||
return (
|
||||
status?.status === 'starting' ||
|
||||
status?.status === 'recording' ||
|
||||
status?.status === 'finalizing'
|
||||
);
|
||||
}
|
||||
|
||||
function isTerminalPopupStatus(
|
||||
status: RecordingStatus | null | undefined
|
||||
): status is RecordingStatus & {
|
||||
status: 'imported' | 'import_failed' | 'start_failed' | 'finalize_failed';
|
||||
} {
|
||||
return (
|
||||
status?.status === 'imported' ||
|
||||
status?.status === 'import_failed' ||
|
||||
status?.status === 'start_failed' ||
|
||||
status?.status === 'finalize_failed'
|
||||
);
|
||||
}
|
||||
|
||||
function createAppGroup(processGroupId: number): AppGroupInfo | undefined {
|
||||
const SC: ShareableContentStatic = getNativeModule().ShareableContent;
|
||||
const groupProcess = SC?.applicationWithProcessId(processGroupId);
|
||||
if (!groupProcess) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
processGroupId,
|
||||
apps: [],
|
||||
name: groupProcess.name,
|
||||
bundleIdentifier: groupProcess.bundleIdentifier,
|
||||
get icon() {
|
||||
try {
|
||||
return groupProcess.icon;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get icon for ${groupProcess.name}`, error);
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
isRunning: false,
|
||||
};
|
||||
}
|
||||
|
||||
function setupAppGroups() {
|
||||
subscribers.push(
|
||||
applications$.pipe(distinctUntilChanged()).subscribe(apps => {
|
||||
const groups: AppGroupInfo[] = [];
|
||||
apps.forEach(appInfo => {
|
||||
let group = groups.find(
|
||||
entry => entry.processGroupId === appInfo.processGroupId
|
||||
);
|
||||
if (!group) {
|
||||
group = createAppGroup(appInfo.processGroupId);
|
||||
if (group) {
|
||||
groups.push(group);
|
||||
}
|
||||
}
|
||||
if (group) {
|
||||
group.apps.push(appInfo);
|
||||
}
|
||||
});
|
||||
|
||||
groups.forEach(group => {
|
||||
group.isRunning = group.apps.some(app => app.isRunning);
|
||||
});
|
||||
|
||||
appGroups$.next(groups);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function setupNewRunningAppGroup() {
|
||||
const appGroupRunningChanged$ = appGroups$.pipe(
|
||||
mergeMap(groups => groups),
|
||||
groupBy(group => group.processGroupId),
|
||||
mergeMap(groupStream$ =>
|
||||
groupStream$.pipe(
|
||||
distinctUntilChanged((prev, curr) => prev.isRunning === curr.isRunning)
|
||||
)
|
||||
),
|
||||
filter(group => isAppNameAllowed(group.name))
|
||||
);
|
||||
|
||||
appGroups$.value.forEach(group => {
|
||||
const recordingStatus = getCurrentRecordingStatus();
|
||||
if (
|
||||
group.isRunning &&
|
||||
(!recordingStatus || recordingStatus.status === 'new')
|
||||
) {
|
||||
newRecording(group);
|
||||
}
|
||||
});
|
||||
|
||||
const debounceStartRecording = debounce((appGroup: AppGroupInfo) => {
|
||||
const currentGroup = appGroups$.value.find(
|
||||
group => group.processGroupId === appGroup.processGroupId
|
||||
);
|
||||
if (currentGroup?.isRunning) {
|
||||
void startRecording(currentGroup).catch(err => {
|
||||
logger.error('failed to start recording', err);
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
subscribers.push(
|
||||
appGroupRunningChanged$.subscribe(currentGroup => {
|
||||
logger.info(
|
||||
'appGroupRunningChanged',
|
||||
currentGroup.bundleIdentifier,
|
||||
currentGroup.isRunning
|
||||
);
|
||||
|
||||
if (MeetingsSettingsState.value.recordingMode === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
const recordingStatus = getCurrentRecordingStatus();
|
||||
|
||||
if (currentGroup.isRunning) {
|
||||
if (
|
||||
!recordingStatus ||
|
||||
recordingStatus.status === 'new' ||
|
||||
!hasActivePopupStatus(recordingStatus)
|
||||
) {
|
||||
if (MeetingsSettingsState.value.recordingMode === 'prompt') {
|
||||
newRecording(currentGroup);
|
||||
} else if (
|
||||
MeetingsSettingsState.value.recordingMode === 'auto-start'
|
||||
) {
|
||||
debounceStartRecording(currentGroup);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
recordingStatus?.status === 'new' &&
|
||||
recordingStatus.appGroupId === currentGroup.processGroupId
|
||||
) {
|
||||
removeRecording(recordingStatus.id);
|
||||
}
|
||||
|
||||
if (
|
||||
recordingStatus?.status === 'recording' &&
|
||||
recordingStatus.appGroupId === currentGroup.processGroupId
|
||||
) {
|
||||
void stopRecording(recordingStatus.id).catch(err => {
|
||||
logger.error('failed to stop recording', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function getRecording(id: number) {
|
||||
return recordingCoordinator.getRecording(id);
|
||||
}
|
||||
|
||||
function setupRecordingListeners() {
|
||||
subscribers.push(
|
||||
recordingStatus$
|
||||
.pipe(distinctUntilChanged(shallowEqual))
|
||||
.subscribe(status => {
|
||||
const popup = popupManager.get('recording');
|
||||
|
||||
if (status && !popup.showing) {
|
||||
void popup.show().catch(err => {
|
||||
logger.error('failed to show recording popup', err);
|
||||
});
|
||||
}
|
||||
|
||||
if (isTerminalPopupStatus(status)) {
|
||||
setTimeout(
|
||||
() => {
|
||||
const currentStatus = getCurrentRecordingStatus();
|
||||
if (
|
||||
isTerminalPopupStatus(currentStatus) &&
|
||||
currentStatus.id === status.id
|
||||
) {
|
||||
void popup.hide().catch(err => {
|
||||
logger.error('failed to hide recording popup', err);
|
||||
});
|
||||
dismissRecordingStatus(status.id);
|
||||
}
|
||||
},
|
||||
status.status === 'import_failed' ||
|
||||
status.status === 'start_failed' ||
|
||||
status.status === 'finalize_failed'
|
||||
? 30_000
|
||||
: 10_000
|
||||
);
|
||||
} else if (!status) {
|
||||
void popupManager
|
||||
.get('recording')
|
||||
.hide()
|
||||
.catch(err => {
|
||||
logger.error('failed to hide recording popup', err);
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function getAllApps(): TappableAppInfo[] {
|
||||
if (!shareableContent) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { ShareableContent } = getNativeModule();
|
||||
const apps = ShareableContent.applications().map(appInfo => {
|
||||
try {
|
||||
const isRunning = ShareableContent.isUsingMicrophone(appInfo.processId);
|
||||
return {
|
||||
info: appInfo,
|
||||
processId: appInfo.processId,
|
||||
processGroupId: appInfo.processGroupId,
|
||||
bundleIdentifier: appInfo.bundleIdentifier,
|
||||
name: appInfo.name,
|
||||
isRunning,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('failed to get app info', error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
return apps.filter(
|
||||
(value): value is TappableAppInfo =>
|
||||
value !== null &&
|
||||
!value.bundleIdentifier.startsWith('com.apple') &&
|
||||
!value.bundleIdentifier.startsWith('pro.affine') &&
|
||||
value.processId !== process.pid
|
||||
);
|
||||
}
|
||||
|
||||
function setupMediaListeners() {
|
||||
const ShareableContent = getNativeModule().ShareableContent;
|
||||
applications$.next(getAllApps());
|
||||
subscribers.push(
|
||||
interval(3000).subscribe(() => {
|
||||
updateApplicationsPing$.next(Date.now());
|
||||
}),
|
||||
ShareableContent.onApplicationListChanged(() => {
|
||||
updateApplicationsPing$.next(Date.now());
|
||||
}),
|
||||
updateApplicationsPing$
|
||||
.pipe(distinctUntilChanged(), throttleTime(3000))
|
||||
.subscribe(() => {
|
||||
applications$.next(getAllApps());
|
||||
})
|
||||
);
|
||||
|
||||
subscribers.push(
|
||||
applications$.subscribe(apps => {
|
||||
appStateSubscribers.forEach(subscriber => {
|
||||
try {
|
||||
subscriber.unsubscribe();
|
||||
} catch {
|
||||
// ignore unsubscribe error
|
||||
}
|
||||
});
|
||||
|
||||
appStateSubscribers = apps.flatMap(appInfo => {
|
||||
try {
|
||||
return [
|
||||
ShareableContent.onAppStateChanged(appInfo.info, () => {
|
||||
updateApplicationsPing$.next(Date.now());
|
||||
}),
|
||||
];
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to set up app state listener for ${appInfo.name}`,
|
||||
error
|
||||
);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function askForScreenRecordingPermission() {
|
||||
if (!isMacOS()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const ShareableContent = getNativeModule().ShareableContent;
|
||||
new ShareableContent();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('failed to ask for screen recording permission', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function setupRecordingFeature() {
|
||||
if (!MeetingsSettingsState.value.enabled || !checkCanRecordMeeting()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ShareableContent = getNativeModule().ShareableContent;
|
||||
if (!shareableContent) {
|
||||
shareableContent = new ShareableContent();
|
||||
setupMediaListeners();
|
||||
}
|
||||
setupAppGroups();
|
||||
setupNewRunningAppGroup();
|
||||
setupRecordingListeners();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('failed to setup recording feature', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function disableRecordingFeature() {
|
||||
await abortActiveRecording();
|
||||
resetFeatureSubscriptions();
|
||||
}
|
||||
|
||||
function normalizeAppGroupInfo(
|
||||
appGroup?: AppGroupInfo | number
|
||||
): AppGroupInfo | undefined {
|
||||
return typeof appGroup === 'number'
|
||||
? appGroups$.value.find(group => group.processGroupId === appGroup)
|
||||
: appGroup;
|
||||
}
|
||||
|
||||
export function newRecording(
|
||||
appGroup?: AppGroupInfo | number
|
||||
): RecordingStatus | null {
|
||||
recordingCoordinator.createPrompt(normalizeAppGroupInfo(appGroup));
|
||||
return serializeRecordingStatus(getCurrentRecordingStatus());
|
||||
}
|
||||
|
||||
export async function startRecording(
|
||||
appGroup?: AppGroupInfo | number
|
||||
): Promise<RecordingStatus | null> {
|
||||
fs.ensureDirSync(SAVED_RECORDINGS_DIR);
|
||||
const job = await recordingCoordinator.start(normalizeAppGroupInfo(appGroup));
|
||||
return serializeJob(job);
|
||||
}
|
||||
|
||||
export async function stopRecording(id: number) {
|
||||
const job = await recordingCoordinator.stop(id);
|
||||
if (job?.phase === 'recorded') {
|
||||
void showMainWindow().catch(err => {
|
||||
logger.error('failed to bring up the window', err);
|
||||
});
|
||||
}
|
||||
return serializeRecordingStatus(getCurrentRecordingStatus());
|
||||
}
|
||||
|
||||
export async function readRecordingFile(filepath: string) {
|
||||
const normalizedPath = await assertRecordingFilepath(filepath);
|
||||
return fsp.readFile(normalizedPath);
|
||||
}
|
||||
|
||||
export function getRecordingImportQueue() {
|
||||
return recordingCoordinator.importQueue().flatMap(status => {
|
||||
const serialized = serializeRecordingImportStatus(status);
|
||||
return serialized ? [serialized] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function claimRecordingImport(id: number, workspaceId: string) {
|
||||
return serializeRecordingImportStatus(
|
||||
recordingCoordinator.claimImport(id, workspaceId)
|
||||
);
|
||||
}
|
||||
|
||||
export function completeRecordingImport(id: number) {
|
||||
logger.info(`recording import ${id} completed`);
|
||||
return serializeRecordingImportStatus(
|
||||
recordingCoordinator.completeImport(id)
|
||||
);
|
||||
}
|
||||
|
||||
export function failRecordingImport(id: number, errorMessage?: string) {
|
||||
logger.error(`recording import ${id} failed`, errorMessage);
|
||||
return serializeRecordingImportStatus(
|
||||
recordingCoordinator.failImport(id, errorMessage)
|
||||
);
|
||||
}
|
||||
|
||||
export function dismissRecordingStatus(id: number) {
|
||||
return serializeRecordingStatus(recordingCoordinator.dismiss(id));
|
||||
}
|
||||
|
||||
export function removeRecording(id: number) {
|
||||
recordingCoordinator.remove(id);
|
||||
}
|
||||
|
||||
export interface SerializedRecordingStatus {
|
||||
id: number;
|
||||
status: RecordingStatus['status'];
|
||||
appName?: string;
|
||||
appGroupId?: number;
|
||||
icon?: Buffer;
|
||||
startTime: number;
|
||||
filepath?: string;
|
||||
sampleRate?: number;
|
||||
numberOfChannels?: number;
|
||||
durationMs?: number;
|
||||
size?: number;
|
||||
degraded?: boolean;
|
||||
overflowCount?: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
function serializeJob(job: RecordingJobStatus | null | undefined) {
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
return serializeRecordingStatus(recordingCoordinator.currentStatus());
|
||||
}
|
||||
|
||||
export function serializeRecordingStatus(
|
||||
status: RecordingStatus | null | undefined
|
||||
): SerializedRecordingStatus | null {
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: status.id,
|
||||
status: status.status,
|
||||
appName: status.appName,
|
||||
appGroupId: status.appGroupId,
|
||||
icon: status.icon,
|
||||
startTime: status.startTime,
|
||||
filepath: status.filepath,
|
||||
sampleRate: status.sampleRate,
|
||||
numberOfChannels: status.numberOfChannels,
|
||||
durationMs: status.durationMs,
|
||||
size: status.size,
|
||||
degraded: status.degraded,
|
||||
overflowCount: status.overflowCount,
|
||||
errorMessage: status.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SerializedRecordingImportStatus {
|
||||
id: number;
|
||||
appName?: string;
|
||||
workspaceId?: string;
|
||||
docId?: string;
|
||||
startTime: number;
|
||||
filepath: string;
|
||||
sampleRate?: number;
|
||||
numberOfChannels?: number;
|
||||
durationMs?: number;
|
||||
size?: number;
|
||||
degraded?: boolean;
|
||||
overflowCount?: number;
|
||||
importStatus: RecordingImportStatus['importStatus'];
|
||||
errorMessage?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export function serializeRecordingImportStatus(
|
||||
status: RecordingImportStatus | null | undefined
|
||||
): SerializedRecordingImportStatus | null {
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: status.id,
|
||||
appName: status.appName,
|
||||
workspaceId: status.workspaceId,
|
||||
docId: status.docId,
|
||||
startTime: status.startTime,
|
||||
filepath: status.filepath,
|
||||
sampleRate: status.sampleRate,
|
||||
numberOfChannels: status.numberOfChannels,
|
||||
durationMs: status.durationMs,
|
||||
size: status.size,
|
||||
degraded: status.degraded,
|
||||
overflowCount: status.overflowCount,
|
||||
importStatus: status.importStatus,
|
||||
errorMessage: status.errorMessage,
|
||||
createdAt: status.createdAt,
|
||||
updatedAt: status.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export const getMacOSVersion = () => {
|
||||
try {
|
||||
const stdout = execSync('sw_vers -productVersion').toString();
|
||||
const [major, minor, patch] = stdout.trim().split('.').map(Number);
|
||||
return { major, minor, patch };
|
||||
} catch (error) {
|
||||
logger.error('Failed to get MacOS version', error);
|
||||
return { major: 0, minor: 0, patch: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
export const checkRecordingAvailable = () => {
|
||||
if (isMacOS()) {
|
||||
const version = getMacOSVersion();
|
||||
return (version.major === 14 && version.minor >= 2) || version.major > 14;
|
||||
}
|
||||
if (isWindows()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const checkMeetingPermissions = () => {
|
||||
if (isWindows()) {
|
||||
return {
|
||||
screen: true,
|
||||
microphone: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isMacOS()) {
|
||||
return undefined;
|
||||
}
|
||||
const mediaTypes = ['screen', 'microphone'] as const;
|
||||
return Object.fromEntries(
|
||||
mediaTypes.map(mediaType => [
|
||||
mediaType,
|
||||
systemPreferences.getMediaAccessStatus(mediaType) === 'granted',
|
||||
])
|
||||
) as Record<(typeof mediaTypes)[number], boolean>;
|
||||
};
|
||||
|
||||
export const askForMeetingPermission = async (
|
||||
type: 'microphone' | 'screen'
|
||||
) => {
|
||||
if (!isMacOS()) {
|
||||
return false;
|
||||
}
|
||||
if (type === 'screen') {
|
||||
return askForScreenRecordingPermission();
|
||||
}
|
||||
return systemPreferences.askForMediaAccess(type);
|
||||
};
|
||||
|
||||
export const checkCanRecordMeeting = () => {
|
||||
const features = checkMeetingPermissions();
|
||||
return (
|
||||
checkRecordingAvailable() &&
|
||||
features &&
|
||||
Object.values(features).every(feature => feature)
|
||||
);
|
||||
};
|
||||
157
packages/frontend/apps/electron/src/main/recording/index.ts
Normal file
157
packages/frontend/apps/electron/src/main/recording/index.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
// oxlint-disable typescript/no-var-requires
|
||||
|
||||
// Should not load @affine/native for unsupported platforms
|
||||
|
||||
import { shell } from 'electron';
|
||||
|
||||
import { isMacOS, resolvePathInBase } from '../../shared/utils';
|
||||
import { openExternalSafely } from '../security/open-external';
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
import {
|
||||
askForMeetingPermission,
|
||||
checkMeetingPermissions,
|
||||
checkRecordingAvailable,
|
||||
claimRecordingImport,
|
||||
completeRecordingImport,
|
||||
disableRecordingFeature,
|
||||
dismissRecordingStatus,
|
||||
failRecordingImport,
|
||||
getCurrentRecordingStatus,
|
||||
getRecording,
|
||||
getRecordingImportQueue,
|
||||
readRecordingFile,
|
||||
recordingImportQueue$,
|
||||
recordingStatus$,
|
||||
removeRecording,
|
||||
SAVED_RECORDINGS_DIR,
|
||||
type SerializedRecordingImportStatus,
|
||||
type SerializedRecordingStatus,
|
||||
serializeRecordingStatus,
|
||||
setupRecordingFeature,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
} from './feature';
|
||||
import type { AppGroupInfo } from './types';
|
||||
|
||||
export const recordingHandlers = {
|
||||
getRecording: async (_, id: number) => {
|
||||
return getRecording(id);
|
||||
},
|
||||
getCurrentRecording: async () => {
|
||||
// not all properties are serializable, so we need to return a subset of the status
|
||||
const status = getCurrentRecordingStatus();
|
||||
return status ? serializeRecordingStatus(status) : null;
|
||||
},
|
||||
startRecording: async (_, appGroup?: AppGroupInfo | number) => {
|
||||
return startRecording(appGroup);
|
||||
},
|
||||
stopRecording: async (_, id: number) => {
|
||||
return stopRecording(id);
|
||||
},
|
||||
readRecordingFile: async (_, filepath: string) => {
|
||||
return readRecordingFile(filepath);
|
||||
},
|
||||
getRecordingImportQueue: async () => {
|
||||
return getRecordingImportQueue();
|
||||
},
|
||||
claimRecordingImport: async (_, id: number, workspaceId: string) => {
|
||||
return claimRecordingImport(id, workspaceId);
|
||||
},
|
||||
completeRecordingImport: async (_, id: number) => {
|
||||
return completeRecordingImport(id);
|
||||
},
|
||||
dismissRecordingStatus: async (_, id: number) => {
|
||||
return dismissRecordingStatus(id);
|
||||
},
|
||||
failRecordingImport: async (_, id: number, errorMessage?: string) => {
|
||||
return failRecordingImport(id, errorMessage);
|
||||
},
|
||||
removeRecording: async (_, id: number) => {
|
||||
return removeRecording(id);
|
||||
},
|
||||
checkRecordingAvailable: async () => {
|
||||
return checkRecordingAvailable();
|
||||
},
|
||||
setupRecordingFeature: async () => {
|
||||
return setupRecordingFeature();
|
||||
},
|
||||
disableRecordingFeature: async () => {
|
||||
return disableRecordingFeature();
|
||||
},
|
||||
checkMeetingPermissions: async () => {
|
||||
return checkMeetingPermissions();
|
||||
},
|
||||
askForMeetingPermission: async (_, type: 'screen' | 'microphone') => {
|
||||
return askForMeetingPermission(type);
|
||||
},
|
||||
showRecordingPermissionSetting: async (_, type: 'screen' | 'microphone') => {
|
||||
if (isMacOS()) {
|
||||
const urlMap = {
|
||||
screen: 'Privacy_ScreenCapture',
|
||||
microphone: 'Privacy_Microphone',
|
||||
};
|
||||
const url = `x-apple.systempreferences:com.apple.preference.security?${urlMap[type]}`;
|
||||
return openExternalSafely(url, {
|
||||
additionalProtocols: ['x-apple.systempreferences:'],
|
||||
});
|
||||
}
|
||||
// this only available on MacOS
|
||||
return false;
|
||||
},
|
||||
showSavedRecordings: async (_, subpath?: string) => {
|
||||
const directory = resolvePathInBase(SAVED_RECORDINGS_DIR, subpath ?? '', {
|
||||
label: 'directory',
|
||||
});
|
||||
return shell.showItemInFolder(directory);
|
||||
},
|
||||
} satisfies NamespaceHandlers;
|
||||
|
||||
export const recordingEvents = {
|
||||
onRecordingStatusChanged: (
|
||||
fn: (status: SerializedRecordingStatus | null) => void
|
||||
) => {
|
||||
const sub = recordingStatus$.subscribe(status => {
|
||||
fn(status ? serializeRecordingStatus(status) : null);
|
||||
});
|
||||
return () => {
|
||||
try {
|
||||
sub.unsubscribe();
|
||||
} catch {
|
||||
// ignore unsubscribe error
|
||||
}
|
||||
};
|
||||
},
|
||||
onRecordingImportQueueChanged: (
|
||||
fn: (queue: SerializedRecordingImportStatus[]) => void
|
||||
) => {
|
||||
const sub = recordingImportQueue$.subscribe(queue => {
|
||||
fn(
|
||||
queue.map(item => ({
|
||||
id: item.id,
|
||||
appName: item.appName,
|
||||
workspaceId: item.workspaceId,
|
||||
docId: item.docId,
|
||||
startTime: item.startTime,
|
||||
filepath: item.filepath,
|
||||
sampleRate: item.sampleRate,
|
||||
numberOfChannels: item.numberOfChannels,
|
||||
durationMs: item.durationMs,
|
||||
size: item.size,
|
||||
degraded: item.degraded,
|
||||
overflowCount: item.overflowCount,
|
||||
importStatus: item.importStatus,
|
||||
errorMessage: item.errorMessage,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
}))
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
try {
|
||||
sub.unsubscribe();
|
||||
} catch {
|
||||
// ignore unsubscribe error
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
# Recording State Transitions
|
||||
|
||||
The desktop recording flow now uses two independent lifecycle models:
|
||||
|
||||
1. recording session state in Electron main, which tracks native capture/finalize.
|
||||
2. artifact import state in Electron main, which tracks renderer-side doc import.
|
||||
|
||||
## Recording Session State
|
||||
|
||||
- `inactive`: no session has been created yet.
|
||||
- `new`: app detected, waiting for user confirmation.
|
||||
- `starting`: native session setup is in progress.
|
||||
- `start_failed`: native session setup failed.
|
||||
- `recording`: native capture is running.
|
||||
- `finalizing`: native stop/finalize is in progress.
|
||||
- `finalized`: native finalized an artifact successfully.
|
||||
- `finalize_failed`: native finalize failed.
|
||||
|
||||
Only `starting`, `recording`, and `finalizing` occupy the active native slot.
|
||||
`start_failed`, `finalized`, and `finalize_failed` no longer block the next recording.
|
||||
|
||||
## Recording Artifact Import State
|
||||
|
||||
- `pending_import`: artifact is finalized and durable in main, waiting for a renderer to consume it.
|
||||
- `importing`: a renderer has claimed the artifact and is importing it into a doc.
|
||||
- `imported`: doc import finished successfully.
|
||||
- `import_failed`: doc import failed after import work began; the saved artifact remains available, but automatic import stops to avoid duplicate docs.
|
||||
|
||||
Artifacts are persisted in main process storage so renderer reloads or missing workspace context do not drop them.
|
||||
|
||||
## Session Flow
|
||||
|
||||
```text
|
||||
inactive -> new -> starting -> recording -> finalizing -> finalized
|
||||
\ \
|
||||
\ -> finalize_failed
|
||||
-> start_failed
|
||||
```
|
||||
|
||||
- `START_RECORDING` creates or reuses a pending `new` recording and moves it to `starting`.
|
||||
- `ATTACH_NATIVE_RECORDING` attaches native session metadata and moves the session to `recording`.
|
||||
- `START_RECORDING_FAILED` keeps the session terminal with `start_failed`.
|
||||
- `STOP_RECORDING` moves the session to `finalizing`.
|
||||
- `ATTACH_RECORDING_ARTIFACT` marks the session `finalized` with the native artifact metadata.
|
||||
- `FINALIZE_RECORDING_FAILED` marks the session `finalize_failed`.
|
||||
- after enqueueing the artifact for renderer import, main clears the finalized session and lets the import registry become the sole source of truth.
|
||||
|
||||
## Import Flow
|
||||
|
||||
```text
|
||||
pending_import -> importing -> imported
|
||||
\
|
||||
-> import_failed
|
||||
```
|
||||
|
||||
- main enqueues `pending_import` after native finalize succeeds.
|
||||
- renderer claims the artifact, moving it to `importing`.
|
||||
- renderer marks the artifact `imported` or `import_failed`.
|
||||
- `imported` is not kept in the durable queue; it is projected as a transient popup status and then dropped.
|
||||
- automatic retry only covers missing UI preconditions before import work begins; once doc creation starts, or completion cannot be persisted afterward, the entry stays `import_failed` to avoid duplicate docs.
|
||||
|
||||
## Popup Projection
|
||||
|
||||
The popup still renders a single current status, but it is now a projection:
|
||||
|
||||
- active session states map to `new`, `starting`, `start_failed`, `recording`, `finalizing`, `finalize_failed`.
|
||||
- otherwise active import queue entries map to `pending_import` or `importing`.
|
||||
- terminal import results (`imported`, `import_failed`) are shown through a transient popup projection instead of the durable queue.
|
||||
|
||||
This keeps the UI simple without collapsing the underlying source-of-truth back into a single overloaded `processing` state.
|
||||
120
packages/frontend/apps/electron/src/main/recording/types.ts
Normal file
120
packages/frontend/apps/electron/src/main/recording/types.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { ApplicationInfo } from '@affine/native';
|
||||
|
||||
export interface TappableAppInfo {
|
||||
info: ApplicationInfo;
|
||||
isRunning: boolean;
|
||||
processId: number;
|
||||
processGroupId: number;
|
||||
bundleIdentifier: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AppGroupInfo {
|
||||
processGroupId: number;
|
||||
apps: TappableAppInfo[];
|
||||
name: string;
|
||||
bundleIdentifier: string;
|
||||
icon: Buffer | undefined;
|
||||
isRunning: boolean;
|
||||
}
|
||||
|
||||
export type RecordingJobPhase =
|
||||
| 'new'
|
||||
| 'starting'
|
||||
| 'recording'
|
||||
| 'finalizing'
|
||||
| 'recorded'
|
||||
| 'importing'
|
||||
| 'imported'
|
||||
| 'failed'
|
||||
| 'aborted';
|
||||
|
||||
export type RecordingFailureStage = 'start' | 'finalize' | 'import';
|
||||
|
||||
export interface RecordingFailureInfo {
|
||||
stage: RecordingFailureStage;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type RecordingImportState =
|
||||
| 'pending_import'
|
||||
| 'importing'
|
||||
| 'imported'
|
||||
| 'import_failed';
|
||||
|
||||
export interface RecordingArtifactInfo {
|
||||
filepath: string;
|
||||
sampleRate?: number;
|
||||
numberOfChannels?: number;
|
||||
durationMs?: number;
|
||||
size?: number;
|
||||
degraded?: boolean;
|
||||
overflowCount?: number;
|
||||
}
|
||||
|
||||
export interface RecordingImportProgress {
|
||||
workspaceId?: string;
|
||||
docId?: string;
|
||||
errorMessage?: string;
|
||||
leaseExpiresAt?: number;
|
||||
startedAt?: number;
|
||||
finishedAt?: number;
|
||||
}
|
||||
|
||||
export interface RecordingJobStatus {
|
||||
id: number;
|
||||
phase: RecordingJobPhase;
|
||||
appName?: string;
|
||||
appGroupId?: number;
|
||||
bundleIdentifier?: string;
|
||||
appProcessId?: number;
|
||||
nativeId?: string;
|
||||
startTime: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
artifact?: RecordingArtifactInfo;
|
||||
import?: RecordingImportProgress;
|
||||
error?: RecordingFailureInfo;
|
||||
dismissedAt?: number;
|
||||
}
|
||||
|
||||
export interface RecordingImportStatus extends RecordingArtifactInfo {
|
||||
id: number;
|
||||
appName?: string;
|
||||
workspaceId?: string;
|
||||
docId?: string;
|
||||
startTime: number;
|
||||
importStatus: RecordingImportState;
|
||||
errorMessage?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export type RecordingDisplayState =
|
||||
| 'new'
|
||||
| 'starting'
|
||||
| 'start_failed'
|
||||
| 'recording'
|
||||
| 'finalizing'
|
||||
| 'pending_import'
|
||||
| 'importing'
|
||||
| 'imported'
|
||||
| 'import_failed'
|
||||
| 'finalize_failed';
|
||||
|
||||
export interface RecordingStatus {
|
||||
id: number;
|
||||
status: RecordingDisplayState;
|
||||
appName?: string;
|
||||
appGroupId?: number;
|
||||
icon?: Buffer;
|
||||
startTime: number;
|
||||
filepath?: string;
|
||||
sampleRate?: number;
|
||||
numberOfChannels?: number;
|
||||
durationMs?: number;
|
||||
size?: number;
|
||||
degraded?: boolean;
|
||||
overflowCount?: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { app } from 'electron';
|
||||
|
||||
import { isInternalUrl } from '../shared/internal-origin';
|
||||
import { logger } from './logger';
|
||||
import { openExternalSafely } from './security/open-external';
|
||||
import { validateRedirectProxyUrl } from './security/redirect-proxy';
|
||||
|
||||
export const checkSource = (
|
||||
e: Electron.IpcMainInvokeEvent | Electron.IpcMainEvent
|
||||
) => {
|
||||
const url = e.senderFrame?.url || e.sender.getURL();
|
||||
const result = isInternalUrl(url);
|
||||
if (!result) logger.error('invalid source', url);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const registerSecurityRestrictions = () => {
|
||||
app.on('web-contents-created', (_, contents) => {
|
||||
/**
|
||||
* Block navigation to origins not on the allowlist.
|
||||
*
|
||||
* Navigation is a common attack vector. If an attacker can convince the app to navigate away
|
||||
* from its current page, they can possibly force the app to open web sites on the Internet.
|
||||
*
|
||||
* @see https://www.electronjs.org/docs/latest/tutorial/security#13-disable-or-limit-navigation
|
||||
*/
|
||||
contents.on('will-navigate', (event, url) => {
|
||||
if (isInternalUrl(url)) {
|
||||
return;
|
||||
}
|
||||
// Prevent navigation
|
||||
event.preventDefault();
|
||||
openExternalSafely(url).catch(error => {
|
||||
console.error('[security] Failed to open external URL:', error);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Hyperlinks to allowed sites open in the default browser.
|
||||
*
|
||||
* The creation of new `webContents` is a common attack vector. Attackers attempt to convince the app to create new windows,
|
||||
* frames, or other renderer processes with more privileges than they had before; or with pages opened that they couldn't open before.
|
||||
* You should deny any unexpected window creation.
|
||||
*
|
||||
* @see https://www.electronjs.org/docs/latest/tutorial/security#14-disable-or-limit-creation-of-new-windows
|
||||
* @see https://www.electronjs.org/docs/latest/tutorial/security#15-do-not-use-openexternal-with-untrusted-content
|
||||
*/
|
||||
contents.setWindowOpenHandler(({ url }) => {
|
||||
if (!isInternalUrl(url)) {
|
||||
openExternalSafely(url).catch(error => {
|
||||
console.error('[security] Failed to open external URL:', error);
|
||||
});
|
||||
} else if (url.includes('/redirect-proxy')) {
|
||||
const result = validateRedirectProxyUrl(url);
|
||||
if (!result.allow) {
|
||||
console.warn(
|
||||
`[security] Blocked redirect proxy: ${result.reason}`,
|
||||
result.redirectTarget ?? url
|
||||
);
|
||||
return { action: 'deny' };
|
||||
}
|
||||
|
||||
openExternalSafely(result.redirectTarget).catch(error => {
|
||||
console.error('[security] Failed to open external URL:', error);
|
||||
});
|
||||
}
|
||||
// Prevent creating new window in application
|
||||
return { action: 'deny' };
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { shell } from 'electron';
|
||||
|
||||
const DEFAULT_ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
|
||||
|
||||
export interface OpenExternalOptions {
|
||||
additionalProtocols?: string[];
|
||||
}
|
||||
|
||||
export const isAllowedExternalUrl = (
|
||||
rawUrl: string,
|
||||
additionalProtocols: Iterable<string> = []
|
||||
) => {
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
const protocol = parsed.protocol.toLowerCase();
|
||||
if (DEFAULT_ALLOWED_PROTOCOLS.has(protocol)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const extra of additionalProtocols) {
|
||||
if (protocol === extra.toLowerCase()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.warn('[security] Failed to parse external URL', rawUrl, error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const openExternalSafely = async (
|
||||
rawUrl: string,
|
||||
options: OpenExternalOptions = {}
|
||||
) => {
|
||||
const { additionalProtocols = [] } = options;
|
||||
|
||||
if (!isAllowedExternalUrl(rawUrl, additionalProtocols)) {
|
||||
console.warn('[security] Blocked attempt to open external URL:', rawUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await shell.openExternal(rawUrl);
|
||||
} catch (error) {
|
||||
console.error('[security] Failed to open external URL:', rawUrl, error);
|
||||
}
|
||||
};
|
||||
export const ALLOWED_EXTERNAL_PROTOCOLS: ReadonlySet<string> = new Set(
|
||||
DEFAULT_ALLOWED_PROTOCOLS
|
||||
);
|
||||
@@ -0,0 +1,84 @@
|
||||
import { isAllowedRedirectTarget } from '@toeverything/infra/utils';
|
||||
|
||||
import { buildType, isDev } from '../config';
|
||||
|
||||
const API_BASE_BY_BUILD_TYPE: Record<typeof buildType, string> = {
|
||||
stable: 'https://app.affine.pro',
|
||||
beta: 'https://insider.affine.pro',
|
||||
internal: 'https://insider.affine.pro',
|
||||
canary: 'https://affine.fail',
|
||||
};
|
||||
|
||||
function resolveCurrentHostnameForRedirectAllowlist() {
|
||||
const devServerBase = process.env.DEV_SERVER_URL;
|
||||
const base =
|
||||
isDev && devServerBase
|
||||
? devServerBase
|
||||
: (API_BASE_BY_BUILD_TYPE[buildType] ?? API_BASE_BY_BUILD_TYPE.stable);
|
||||
|
||||
try {
|
||||
return new URL(base).hostname;
|
||||
} catch {
|
||||
return 'app.affine.pro';
|
||||
}
|
||||
}
|
||||
|
||||
export function extractRedirectTarget(rawUrl: string) {
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
const redirectUri = parsed.searchParams.get('redirect_uri');
|
||||
if (redirectUri) {
|
||||
return redirectUri;
|
||||
}
|
||||
|
||||
if (parsed.hash) {
|
||||
const hash = parsed.hash.startsWith('#')
|
||||
? parsed.hash.slice(1)
|
||||
: parsed.hash;
|
||||
|
||||
const queryIndex = hash.indexOf('?');
|
||||
if (queryIndex !== -1) {
|
||||
const hashParams = new URLSearchParams(hash.slice(queryIndex + 1));
|
||||
const hashRedirect = hashParams.get('redirect_uri');
|
||||
if (hashRedirect) {
|
||||
return hashRedirect;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type RedirectProxyValidationResult =
|
||||
| {
|
||||
allow: true;
|
||||
redirectTarget: string;
|
||||
}
|
||||
| {
|
||||
allow: false;
|
||||
reason: 'missing_redirect_target' | 'untrusted_redirect_target';
|
||||
redirectTarget?: string;
|
||||
};
|
||||
|
||||
export function validateRedirectProxyUrl(
|
||||
rawUrl: string
|
||||
): RedirectProxyValidationResult {
|
||||
const redirectTarget = extractRedirectTarget(rawUrl);
|
||||
if (!redirectTarget) {
|
||||
return { allow: false, reason: 'missing_redirect_target' };
|
||||
}
|
||||
|
||||
const currentHostname = resolveCurrentHostnameForRedirectAllowlist();
|
||||
if (!isAllowedRedirectTarget(redirectTarget, { currentHostname })) {
|
||||
return {
|
||||
allow: false,
|
||||
reason: 'untrusted_redirect_target',
|
||||
redirectTarget,
|
||||
};
|
||||
}
|
||||
|
||||
return { allow: true, redirectTarget };
|
||||
}
|
||||
117
packages/frontend/apps/electron/src/main/security/url-safety.ts
Normal file
117
packages/frontend/apps/electron/src/main/security/url-safety.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import * as dns from 'node:dns/promises';
|
||||
import { BlockList, isIP } from 'node:net';
|
||||
|
||||
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
|
||||
const BLOCKED_IPS = new BlockList();
|
||||
const ALLOWED_IPV6 = new BlockList();
|
||||
|
||||
function stripZoneId(address: string) {
|
||||
const idx = address.indexOf('%');
|
||||
return idx === -1 ? address : address.slice(0, idx);
|
||||
}
|
||||
|
||||
// Use Node's built-in BlockList (Electron 39 ships with Node 22.x).
|
||||
for (const [network, prefix] of [
|
||||
['0.0.0.0', 8],
|
||||
['10.0.0.0', 8],
|
||||
['127.0.0.0', 8],
|
||||
['169.254.0.0', 16],
|
||||
['172.16.0.0', 12],
|
||||
['192.168.0.0', 16],
|
||||
['100.64.0.0', 10], // CGNAT
|
||||
['224.0.0.0', 4], // multicast
|
||||
['240.0.0.0', 4], // reserved (includes broadcast)
|
||||
] as const) {
|
||||
BLOCKED_IPS.addSubnet(network, prefix, 'ipv4');
|
||||
}
|
||||
|
||||
BLOCKED_IPS.addAddress('::', 'ipv6');
|
||||
BLOCKED_IPS.addAddress('::1', 'ipv6');
|
||||
BLOCKED_IPS.addSubnet('ff00::', 8, 'ipv6'); // multicast
|
||||
BLOCKED_IPS.addSubnet('fc00::', 7, 'ipv6'); // unique local
|
||||
BLOCKED_IPS.addSubnet('fe80::', 10, 'ipv6'); // link-local
|
||||
ALLOWED_IPV6.addSubnet('2000::', 3, 'ipv6'); // global unicast
|
||||
|
||||
function extractEmbeddedIPv4FromIPv6(address: string): string | null {
|
||||
if (!address.includes('.')) {
|
||||
return null;
|
||||
}
|
||||
const idx = address.lastIndexOf(':');
|
||||
if (idx === -1) {
|
||||
return null;
|
||||
}
|
||||
const tail = address.slice(idx + 1);
|
||||
return isIP(tail) === 4 ? tail : null;
|
||||
}
|
||||
|
||||
function isBlockedIpAddress(address: string): boolean {
|
||||
const ip = stripZoneId(address);
|
||||
const family = isIP(ip);
|
||||
if (family === 4) {
|
||||
return BLOCKED_IPS.check(ip, 'ipv4');
|
||||
}
|
||||
if (family === 6) {
|
||||
const embeddedV4 = extractEmbeddedIPv4FromIPv6(ip);
|
||||
if (embeddedV4) {
|
||||
return isBlockedIpAddress(embeddedV4);
|
||||
}
|
||||
if (!ALLOWED_IPV6.check(ip, 'ipv6')) {
|
||||
return true;
|
||||
}
|
||||
return BLOCKED_IPS.check(ip, 'ipv6');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function resolveHostAddresses(hostname: string): Promise<string[]> {
|
||||
const lowered = hostname.toLowerCase();
|
||||
if (lowered === 'localhost' || lowered.endsWith('.localhost')) {
|
||||
return ['127.0.0.1', '::1'];
|
||||
}
|
||||
|
||||
const results = await dns.lookup(hostname, { all: true, verbatim: true });
|
||||
return results.map(r => r.address);
|
||||
}
|
||||
|
||||
export async function resolveAndValidateUrlForPreview(
|
||||
rawUrl: string
|
||||
): Promise<{ url: URL; address: string }> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new Error('Invalid URL');
|
||||
}
|
||||
|
||||
if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
|
||||
throw new Error('Disallowed URL protocol');
|
||||
}
|
||||
|
||||
if (url.username || url.password) {
|
||||
throw new Error('URL must not include credentials');
|
||||
}
|
||||
|
||||
if (!url.hostname) {
|
||||
throw new Error('Missing hostname');
|
||||
}
|
||||
|
||||
if (isIP(url.hostname)) {
|
||||
if (isBlockedIpAddress(url.hostname)) {
|
||||
throw new Error('Blocked IP address');
|
||||
}
|
||||
return { url, address: url.hostname };
|
||||
}
|
||||
|
||||
const addresses = await resolveHostAddresses(url.hostname);
|
||||
if (!addresses.length) {
|
||||
throw new Error('Unresolvable hostname');
|
||||
}
|
||||
|
||||
for (const addr of addresses) {
|
||||
if (isBlockedIpAddress(addr)) {
|
||||
throw new Error('Blocked IP address');
|
||||
}
|
||||
}
|
||||
|
||||
return { url, address: addresses[0] };
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const workbenchViewIconNameSchema = z.enum([
|
||||
'trash',
|
||||
'allDocs',
|
||||
'collection',
|
||||
'tag',
|
||||
'doc', // refers to a doc whose mode is not yet being resolved
|
||||
'page',
|
||||
'edgeless',
|
||||
'journal',
|
||||
'attachment',
|
||||
'pdf',
|
||||
'ai',
|
||||
]);
|
||||
|
||||
export const workbenchViewMetaSchema = z.object({
|
||||
id: z.string(),
|
||||
path: z
|
||||
.object({
|
||||
pathname: z.string().optional(),
|
||||
hash: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
// todo: move title/module to cached stated
|
||||
title: z.string().optional(),
|
||||
iconName: workbenchViewIconNameSchema.optional(),
|
||||
});
|
||||
|
||||
export const workbenchMetaSchema = z.object({
|
||||
id: z.string(),
|
||||
activeViewIndex: z.number(),
|
||||
pinned: z.boolean().optional(),
|
||||
basename: z.string(),
|
||||
views: z.array(workbenchViewMetaSchema),
|
||||
});
|
||||
|
||||
export const tabViewsMetaSchema = z.object({
|
||||
activeWorkbenchId: z.string().optional(),
|
||||
workbenches: z.array(workbenchMetaSchema).default([]),
|
||||
});
|
||||
|
||||
export const TabViewsMetaKey = 'tabViewsMetaSchema';
|
||||
export type TabViewsMetaSchema = z.infer<typeof tabViewsMetaSchema>;
|
||||
export type WorkbenchMeta = z.infer<typeof workbenchMetaSchema>;
|
||||
export type WorkbenchViewMeta = z.infer<typeof workbenchViewMetaSchema>;
|
||||
export type WorkbenchViewModule = z.infer<typeof workbenchViewIconNameSchema>;
|
||||
|
||||
export const SpellCheckStateSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const SpellCheckStateKey = 'spellCheckState' as const;
|
||||
// oxlint-disable-next-line no-redeclare
|
||||
export type SpellCheckStateSchema = z.infer<typeof SpellCheckStateSchema>;
|
||||
|
||||
export const MenubarStateKey = 'menubarState' as const;
|
||||
export const MenubarStateSchema = z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
openOnLeftClick: z.boolean().default(false),
|
||||
minimizeToTray: z.boolean().default(false),
|
||||
closeToTray: z.boolean().default(false),
|
||||
startMinimized: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type MenubarStateSchema = z.infer<typeof MenubarStateSchema>;
|
||||
|
||||
export const MeetingSettingsKey = 'meetingSettings' as const;
|
||||
export const MeetingSettingsSchema = z.object({
|
||||
// global meeting feature control
|
||||
enabled: z.boolean().default(false),
|
||||
|
||||
// if false (and enabled = false), show a prompt page
|
||||
betaDisclaimerAccepted: z.boolean().default(false),
|
||||
|
||||
// when recording is saved, where to create the recording block
|
||||
recordingSavingMode: z.enum(['new-doc', 'journal-today']).default('new-doc'),
|
||||
|
||||
// whether to enable generation of summary for new meeting recordings
|
||||
autoTranscriptionSummary: z.boolean().default(true),
|
||||
|
||||
// whether to enable generation of todo list for new meeting recordings
|
||||
autoTranscriptionTodo: z.boolean().default(true),
|
||||
|
||||
// recording reactions to new meeting events
|
||||
recordingMode: z.enum(['none', 'prompt', 'auto-start']).default('prompt'),
|
||||
});
|
||||
|
||||
export type MeetingSettingsSchema = z.infer<typeof MeetingSettingsSchema>;
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { MainEventRegister } from '../type';
|
||||
import { globalCacheUpdates$, globalStateUpdates$ } from './handlers';
|
||||
|
||||
export const sharedStorageEvents = {
|
||||
onGlobalStateChanged: (
|
||||
fn: (state: Record<string, unknown | undefined>) => void
|
||||
) => {
|
||||
const subscription = globalStateUpdates$.subscribe(fn);
|
||||
return () => subscription.unsubscribe();
|
||||
},
|
||||
onGlobalCacheChanged: (
|
||||
fn: (state: Record<string, unknown | undefined>) => void
|
||||
) => {
|
||||
const subscription = globalCacheUpdates$.subscribe(fn);
|
||||
return () => subscription.unsubscribe();
|
||||
},
|
||||
} satisfies Record<string, MainEventRegister>;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
import { globalCacheStorage, globalStateStorage } from './storage';
|
||||
|
||||
// Subjects used by shared-storage/events.ts to broadcast updates to all renderer processes
|
||||
export const globalStateUpdates$ = new Subject<Record<string, any>>();
|
||||
export const globalCacheUpdates$ = new Subject<Record<string, any>>();
|
||||
|
||||
// Revision maps; main generates the next value each time
|
||||
const globalStateRevisions = new Map<string, number>();
|
||||
const globalCacheRevisions = new Map<string, number>();
|
||||
|
||||
function nextRev(revisions: Map<string, number>, key: string) {
|
||||
const r = (revisions.get(key) ?? 0) + 1;
|
||||
revisions.set(key, r);
|
||||
return r;
|
||||
}
|
||||
|
||||
export const sharedStorageHandlers = {
|
||||
getAllGlobalState: async () => {
|
||||
return globalStateStorage.all();
|
||||
},
|
||||
getAllGlobalCache: async () => {
|
||||
return globalCacheStorage.all();
|
||||
},
|
||||
|
||||
setGlobalState: async (_e, key: string, value: any, sourceId?: string) => {
|
||||
const rev = nextRev(globalStateRevisions, key);
|
||||
globalStateStorage.set(key, value);
|
||||
globalStateUpdates$.next({ [key]: { v: value, r: rev, s: sourceId } });
|
||||
},
|
||||
delGlobalState: async (_e, key: string, sourceId?: string) => {
|
||||
const rev = nextRev(globalStateRevisions, key);
|
||||
globalStateStorage.del(key);
|
||||
globalStateUpdates$.next({ [key]: { v: undefined, r: rev, s: sourceId } });
|
||||
},
|
||||
clearGlobalState: async (_e, sourceId?: string) => {
|
||||
globalStateRevisions.clear();
|
||||
globalStateStorage.clear();
|
||||
globalStateUpdates$.next({ '*': { v: undefined, r: 0, s: sourceId } });
|
||||
},
|
||||
|
||||
setGlobalCache: async (_e, key: string, value: any, sourceId?: string) => {
|
||||
const rev = nextRev(globalCacheRevisions, key);
|
||||
globalCacheStorage.set(key, value);
|
||||
globalCacheUpdates$.next({ [key]: { v: value, r: rev, s: sourceId } });
|
||||
},
|
||||
delGlobalCache: async (_e, key: string, sourceId?: string) => {
|
||||
const rev = nextRev(globalCacheRevisions, key);
|
||||
globalCacheStorage.del(key);
|
||||
globalCacheUpdates$.next({ [key]: { v: undefined, r: rev, s: sourceId } });
|
||||
},
|
||||
clearGlobalCache: async (_e, sourceId?: string) => {
|
||||
globalCacheRevisions.clear();
|
||||
globalCacheStorage.clear();
|
||||
globalCacheUpdates$.next({ '*': { v: undefined, r: 0, s: sourceId } });
|
||||
},
|
||||
} satisfies NamespaceHandlers;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { sharedStorageEvents } from './events';
|
||||
export { sharedStorageHandlers } from './handlers';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
);
|
||||
13
packages/frontend/apps/electron/src/main/tray/icons.ts
Normal file
13
packages/frontend/apps/electron/src/main/tray/icons.ts
Normal 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'),
|
||||
};
|
||||
395
packages/frontend/apps/electron/src/main/tray/index.ts
Normal file
395
packages/frontend/apps/electron/src/main/tray/index.ts
Normal 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();
|
||||
});
|
||||
};
|
||||
10
packages/frontend/apps/electron/src/main/type.ts
Normal file
10
packages/frontend/apps/electron/src/main/type.ts
Normal 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;
|
||||
};
|
||||
7
packages/frontend/apps/electron/src/main/ui/challenge.ts
Normal file
7
packages/frontend/apps/electron/src/main/ui/challenge.ts
Normal 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);
|
||||
};
|
||||
57
packages/frontend/apps/electron/src/main/ui/events.ts
Normal file
57
packages/frontend/apps/electron/src/main/ui/events.ts
Normal 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>;
|
||||
236
packages/frontend/apps/electron/src/main/ui/handlers.ts
Normal file
236
packages/frontend/apps/electron/src/main/ui/handlers.ts
Normal 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;
|
||||
3
packages/frontend/apps/electron/src/main/ui/index.ts
Normal file
3
packages/frontend/apps/electron/src/main/ui/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './events';
|
||||
export * from './handlers';
|
||||
export * from './subject';
|
||||
13
packages/frontend/apps/electron/src/main/ui/subject.ts
Normal file
13
packages/frontend/apps/electron/src/main/ui/subject.ts
Normal 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>(),
|
||||
};
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
};
|
||||
36
packages/frontend/apps/electron/src/main/updater/event.ts
Normal file
36
packages/frontend/apps/electron/src/main/updater/event.ts
Normal 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>;
|
||||
45
packages/frontend/apps/electron/src/main/updater/index.ts
Normal file
45
packages/frontend/apps/electron/src/main/updater/index.ts
Normal 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';
|
||||
18
packages/frontend/apps/electron/src/main/updater/utils.ts
Normal file
18
packages/frontend/apps/electron/src/main/updater/utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
54
packages/frontend/apps/electron/src/main/utils.ts
Normal file
54
packages/frontend/apps/electron/src/main/utils.ts
Normal 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;
|
||||
}
|
||||
19
packages/frontend/apps/electron/src/main/web-preferences.ts
Normal file
19
packages/frontend/apps/electron/src/main/web-preferences.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface AuthenticationRequest {
|
||||
method: 'magic-link' | 'oauth' | 'open-app-signin';
|
||||
payload: Record<string, any>;
|
||||
server?: string;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
export type LaunchStage = 'main' | 'onboarding';
|
||||
@@ -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();
|
||||
};
|
||||
19
packages/frontend/apps/electron/src/main/worker/handlers.ts
Normal file
19
packages/frontend/apps/electron/src/main/worker/handlers.ts
Normal 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;
|
||||
133
packages/frontend/apps/electron/src/main/worker/pool.ts
Normal file
133
packages/frontend/apps/electron/src/main/worker/pool.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
packages/frontend/apps/electron/src/preload/bootstrap.ts
Normal file
22
packages/frontend/apps/electron/src/preload/bootstrap.ts
Normal 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();
|
||||
}
|
||||
342
packages/frontend/apps/electron/src/preload/electron-api.ts
Normal file
342
packages/frontend/apps/electron/src/preload/electron-api.ts
Normal 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,
|
||||
};
|
||||
1
packages/frontend/apps/electron/src/preload/index.ts
Normal file
1
packages/frontend/apps/electron/src/preload/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
import './bootstrap';
|
||||
155
packages/frontend/apps/electron/src/preload/shared-storage.ts
Normal file
155
packages/frontend/apps/electron/src/preload/shared-storage.ts
Normal 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;
|
||||
33
packages/frontend/apps/electron/src/preload/worker.ts
Normal file
33
packages/frontend/apps/electron/src/preload/worker.ts
Normal 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]
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
29
packages/frontend/apps/electron/src/shared/import.ts
Normal file
29
packages/frontend/apps/electron/src/shared/import.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
33
packages/frontend/apps/electron/src/shared/type.ts
Normal file
33
packages/frontend/apps/electron/src/shared/type.ts
Normal 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
Reference in New Issue
Block a user