feat(native): record encoding (#14188)

fix #13784 

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Start/stop system or meeting recordings with Ogg/Opus artifacts and
native start/stop APIs; workspace backup recovery.

* **Refactor**
* Simplified recording lifecycle and UI flows; native runtime now
orchestrates recording/processing and reporting.

* **Bug Fixes**
* Stronger path validation, safer import/export dialogs, consistent
error handling/logging, and retry-safe recording processing.

* **Chores**
* Added cross-platform native audio capture and Ogg/Opus encoding
support.

* **Tests**
* New unit, integration, and e2e tests for recording, path guards,
dialogs, and workspace recovery.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-03-22 02:50:14 +08:00
committed by GitHub
parent 6a93566422
commit bcf2a51d41
44 changed files with 2921 additions and 1143 deletions
@@ -5,6 +5,7 @@ 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';
@@ -38,31 +39,6 @@ export interface SelectDBFileLocationResult {
canceled?: boolean;
}
// provide a backdoor to set dialog path for testing in playwright
export interface FakeDialogResult {
canceled?: boolean;
filePath?: string;
filePaths?: string[];
}
// result will be used in the next call to showOpenDialog
// if it is being read once, it will be reset to undefined
let fakeDialogResult: FakeDialogResult | undefined = undefined;
function getFakedResult() {
const result = fakeDialogResult;
fakeDialogResult = undefined;
return result;
}
export function setFakeDialogResult(result: FakeDialogResult | undefined) {
fakeDialogResult = result;
// for convenience, we will fill filePaths with filePath if it is not set
if (result?.filePaths === undefined && result?.filePath !== undefined) {
result.filePaths = [result.filePath];
}
}
const extension = 'affine';
function getDefaultDBFileName(name: string, id: string) {
@@ -87,12 +63,33 @@ async function isSameFilePath(sourcePath: string, targetPath: string) {
return true;
}
const [sourceRealPath, targetRealPath] = await Promise.all([
const [resolvedSourcePath, resolvedTargetPath] = await Promise.all([
resolveExistingPath(sourcePath),
resolveExistingPath(targetPath),
]);
return !!sourceRealPath && sourceRealPath === targetRealPath;
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;
}
/**
@@ -113,29 +110,26 @@ export async function saveDBFileAs(
await pool.connect(universalId, dbPath);
await pool.checkpoint(universalId); // make sure all changes (WAL) are written to db
const fakedResult = getFakedResult();
if (!dbPath) {
return {
error: 'DB_FILE_PATH_INVALID',
};
}
const ret =
fakedResult ??
(await mainRPC.showSaveDialog({
properties: ['showOverwriteConfirmation'],
title: 'Save Workspace',
showsTagField: false,
buttonLabel: 'Save',
filters: [
{
extensions: [extension],
name: '',
},
],
defaultPath: getDefaultDBFileName(name, id),
message: 'Save Workspace as a SQLite Database file',
}));
const 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) {
@@ -160,11 +154,9 @@ export async function saveDBFileAs(
}
}
logger.log('saved', filePath);
if (!fakedResult) {
mainRPC.showItemInFolder(filePath).catch(err => {
console.error(err);
});
}
mainRPC.showItemInFolder(filePath).catch(err => {
console.error(err);
});
return { filePath };
} catch (err) {
logger.error('saveDBFileAs', err);
@@ -176,15 +168,13 @@ export async function saveDBFileAs(
export async function selectDBFileLocation(): Promise<SelectDBFileLocationResult> {
try {
const ret =
getFakedResult() ??
(await mainRPC.showOpenDialog({
properties: ['openDirectory'],
title: 'Set Workspace Storage Location',
buttonLabel: 'Select',
defaultPath: await mainRPC.getPath('documents'),
message: "Select a location to store the workspace's database file",
}));
const 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 {
@@ -214,39 +204,29 @@ export async function selectDBFileLocation(): Promise<SelectDBFileLocationResult
* update the local workspace id list and then connect to it.
*
*/
export async function loadDBFile(
dbFilePath?: string
): Promise<LoadDBFileResult> {
export async function loadDBFile(): Promise<LoadDBFileResult> {
try {
const provided =
getFakedResult() ??
(dbFilePath
? { filePath: dbFilePath, filePaths: [dbFilePath], canceled: false }
: undefined);
const ret =
provided ??
(await mainRPC.showOpenDialog({
properties: ['openFile'],
title: 'Load Workspace',
buttonLabel: 'Load',
filters: [
{
name: 'SQLite Database',
// do we want to support other file format?
extensions: ['db', 'affine'],
},
],
message: 'Load Workspace from a AFFiNE file',
}));
const originalPath = ret.filePaths?.[0];
if (ret.canceled || !originalPath) {
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 };
}
// the imported file should not be in app data dir
if (originalPath.startsWith(await getWorkspacesBasePath())) {
logger.warn('loadDBFile: db file in app data dir');
const originalPath = await normalizeImportDBPath(selectedPath);
if (!originalPath) {
return { error: 'DB_FILE_PATH_INVALID' };
}
@@ -299,22 +279,26 @@ async function cpV1DBFile(
}
const connection = new SqliteConnection(originalPath);
if (!(await connection.validateImportSchema())) {
return { error: 'DB_FILE_INVALID' };
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();
}
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,
};
}
@@ -1,13 +1,8 @@
import {
loadDBFile,
saveDBFileAs,
selectDBFileLocation,
setFakeDialogResult,
} from './dialog';
import { loadDBFile, saveDBFileAs, selectDBFileLocation } from './dialog';
export const dialogHandlers = {
loadDBFile: async (dbFilePath?: string) => {
return loadDBFile(dbFilePath);
loadDBFile: async () => {
return loadDBFile();
},
saveDBFileAs: async (universalId: string, name: string) => {
return saveDBFileAs(universalId, name);
@@ -15,9 +10,4 @@ export const dialogHandlers = {
selectDBFileLocation: async () => {
return selectDBFileLocation();
},
setFakeDialogResult: async (
result: Parameters<typeof setFakeDialogResult>[0]
) => {
return setFakeDialogResult(result);
},
};
@@ -1,13 +1,18 @@
import path from 'node:path';
import { DocStorage } from '@affine/native';
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';
@@ -18,6 +23,7 @@ import {
getSpaceBasePath,
getSpaceDBPath,
getWorkspaceBasePathV1,
getWorkspaceDBPath,
getWorkspaceMeta,
} from './meta';
@@ -58,7 +64,7 @@ export async function trashWorkspace(universalId: string) {
const dbPath = await getSpaceDBPath(peer, type, id);
const basePath = await getDeletedWorkspacesBasePath();
const movedPath = path.join(basePath, `${id}`);
const movedPath = path.join(basePath, normalizeWorkspaceIdForPath(id));
try {
const storage = new DocStorage(dbPath);
if (await storage.validate()) {
@@ -258,12 +264,88 @@ export async function getDeletedWorkspaces() {
};
}
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, id);
const workspacePath = path.join(basePath, normalizeWorkspaceIdForPath(id));
await fs.rmdir(workspacePath, { recursive: true });
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);
}
@@ -4,6 +4,7 @@ import {
deleteWorkspace,
getDeletedWorkspaces,
listLocalWorkspaceIds,
recoverBackupWorkspace,
trashWorkspace,
} from './handlers';
@@ -19,5 +20,6 @@ export const workspaceHandlers = {
return getDeletedWorkspaces();
},
deleteBackupWorkspace: async (id: string) => deleteBackupWorkspace(id),
recoverBackupWorkspace: async (id: string) => recoverBackupWorkspace(id),
listLocalWorkspaceIds: async () => listLocalWorkspaceIds(),
};
@@ -2,7 +2,7 @@ import path from 'node:path';
import { type SpaceType } from '@affine/nbstore';
import { isWindows } from '../../shared/utils';
import { normalizeWorkspaceIdForPath } from '../../shared/utils';
import { mainRPC } from '../main-rpc';
import type { WorkspaceMeta } from '../type';
@@ -24,10 +24,11 @@ export async function getWorkspaceBasePathV1(
spaceType: SpaceType,
workspaceId: string
) {
const safeWorkspaceId = normalizeWorkspaceIdForPath(workspaceId);
return path.join(
await getAppDataPath(),
spaceType === 'userspace' ? 'userspaces' : 'workspaces',
isWindows() ? workspaceId.replace(':', '_') : workspaceId
safeWorkspaceId
);
}
@@ -52,10 +53,11 @@ export async function getSpaceDBPath(
spaceType: SpaceType,
id: string
) {
const safeId = normalizeWorkspaceIdForPath(id);
return path.join(
await getSpaceBasePath(spaceType),
escapeFilename(peer),
id,
safeId,
'storage.db'
);
}
@@ -5,24 +5,46 @@ import { app, net, protocol, session } from 'electron';
import cookieParser from 'set-cookie-parser';
import { anotherHost, mainHost } from '../shared/internal-origin';
import { isWindows, resourcesPath } from '../shared/utils';
import {
isPathInsideBase,
isWindows,
resolveExistingPathInBase,
resolvePathInBase,
resourcesPath,
} from '../shared/utils';
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')).toLowerCase(),
path.resolve(app.getPath('temp')).toLowerCase(),
path.resolve(app.getPath('sessionData')),
path.resolve(app.getPath('temp')),
];
function isPathInWhiteList(filepath: string) {
const lowerFilePath = filepath.toLowerCase();
return localWhiteListDirs.some(whitelistDir =>
lowerFilePath.startsWith(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',
@@ -94,15 +116,14 @@ async function handleFileRequest(request: Request) {
// for relative path, load the file in resources
if (!isAbsolutePath) {
if (urlObject.pathname.split('/').at(-1)?.includes('.')) {
// Sanitize pathname to prevent path traversal attacks
const decodedPath = decodeURIComponent(urlObject.pathname);
const normalizedPath = join(webStaticDir, decodedPath).normalize();
if (!normalizedPath.startsWith(webStaticDir)) {
// Attempted path traversal - reject by using empty path
filepath = join(webStaticDir, '');
} else {
filepath = normalizedPath;
}
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');
@@ -113,10 +134,10 @@ async function handleFileRequest(request: Request) {
if (isWindows()) {
filepath = path.resolve(filepath.replace(/^\//, ''));
}
// security check if the filepath is within app.getPath('sessionData')
if (urlObject.host !== 'local-file' || !isPathInWhiteList(filepath)) {
throw new Error('Invalid filepath');
}
filepath = await resolveWhitelistedLocalPath(filepath);
}
return net.fetch(pathToFileURL(filepath).toString(), clonedRequest);
}
@@ -1,11 +1,10 @@
/* oxlint-disable no-var-requires */
import { execSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import fsp from 'node:fs/promises';
import path from 'node:path';
// Should not load @affine/native for unsupported platforms
import type { ShareableContent as ShareableContentType } from '@affine/native';
import type * as NativeModuleType from '@affine/native';
import { app, systemPreferences } from 'electron';
import fs from 'fs-extra';
import { debounce } from 'lodash-es';
@@ -20,7 +19,12 @@ import {
} from 'rxjs';
import { filter, map, shareReplay } from 'rxjs/operators';
import { isMacOS, isWindows, shallowEqual } from '../../shared/utils';
import {
isMacOS,
isWindows,
resolveExistingPathInBase,
shallowEqual,
} from '../../shared/utils';
import { beforeAppQuit } from '../cleanup';
import { logger } from '../logger';
import {
@@ -32,12 +36,7 @@ import { getMainWindow } from '../windows-manager';
import { popupManager } from '../windows-manager/popup';
import { isAppNameAllowed } from './allow-list';
import { recordingStateMachine } from './state-machine';
import type {
AppGroupInfo,
Recording,
RecordingStatus,
TappableAppInfo,
} from './types';
import type { AppGroupInfo, RecordingStatus, TappableAppInfo } from './types';
export const MeetingsSettingsState = {
$: globalStateStorage.watch<MeetingSettingsSchema>(MeetingSettingsKey).pipe(
@@ -56,7 +55,12 @@ export const MeetingsSettingsState = {
},
};
type Subscriber = {
unsubscribe: () => void;
};
const subscribers: Subscriber[] = [];
let appStateSubscribers: Subscriber[] = [];
// recordings are saved in the app data directory
// may need a way to clean up old recordings
@@ -65,10 +69,29 @@ export const SAVED_RECORDINGS_DIR = path.join(
'recordings'
);
type NativeModule = typeof NativeModuleType;
type ShareableContentType = InstanceType<NativeModule['ShareableContent']>;
type ShareableContentStatic = NativeModule['ShareableContent'];
let shareableContent: ShareableContentType | null = null;
function getNativeModule(): NativeModule {
return require('@affine/native') as NativeModule;
}
function cleanup() {
const nativeId = recordingStateMachine.status?.nativeId;
if (nativeId) cleanupAbandonedNativeRecording(nativeId);
recordingStatus$.next(null);
shareableContent = null;
appStateSubscribers.forEach(subscriber => {
try {
subscriber.unsubscribe();
} catch {
// ignore unsubscribe error
}
});
appStateSubscribers = [];
subscribers.forEach(subscriber => {
try {
subscriber.unsubscribe();
@@ -76,6 +99,9 @@ function cleanup() {
// ignore unsubscribe error
}
});
subscribers.length = 0;
applications$.next([]);
appGroups$.next([]);
}
beforeAppQuit(() => {
@@ -87,18 +113,21 @@ export const appGroups$ = new BehaviorSubject<AppGroupInfo[]>([]);
export const updateApplicationsPing$ = new Subject<number>();
// recording id -> recording
// recordings will be saved in memory before consumed and created as an audio block to user's doc
const recordings = new Map<number, Recording>();
// there should be only one active recording at a time
// We'll now use recordingStateMachine.status$ instead of our own BehaviorSubject
// There should be only one active recording at a time; state is managed by the state machine
export const recordingStatus$ = recordingStateMachine.status$;
function isRecordingSettled(
status: RecordingStatus | null | undefined
): status is RecordingStatus & {
status: 'ready';
blockCreationStatus: 'success' | 'failed';
} {
return status?.status === 'ready' && status.blockCreationStatus !== undefined;
}
function createAppGroup(processGroupId: number): AppGroupInfo | undefined {
// MUST require dynamically to avoid loading @affine/native for unsupported platforms
const SC: typeof ShareableContentType =
require('@affine/native').ShareableContent;
const SC: ShareableContentStatic = getNativeModule().ShareableContent;
const groupProcess = SC?.applicationWithProcessId(processGroupId);
if (!groupProcess) {
return;
@@ -174,9 +203,13 @@ function setupNewRunningAppGroup() {
});
const debounceStartRecording = debounce((appGroup: AppGroupInfo) => {
// check if the app is running again
if (appGroup.isRunning) {
startRecording(appGroup);
const currentGroup = appGroups$.value.find(
group => group.processGroupId === appGroup.processGroupId
);
if (currentGroup?.isRunning) {
startRecording(currentGroup).catch(err => {
logger.error('failed to start recording', err);
});
}
}, 1000);
@@ -200,8 +233,7 @@ function setupNewRunningAppGroup() {
if (
!recordingStatus ||
recordingStatus.status === 'new' ||
recordingStatus.status === 'create-block-success' ||
recordingStatus.status === 'create-block-failed'
isRecordingSettled(recordingStatus)
) {
if (MeetingsSettingsState.value.recordingMode === 'prompt') {
newRecording(currentGroup);
@@ -226,7 +258,7 @@ function setupNewRunningAppGroup() {
removeRecording(recordingStatus.id);
}
// if the recording is stopped and we are recording it,
// if the watched app stops while we are recording it,
// we should stop the recording
if (
recordingStatus?.status === 'recording' &&
@@ -242,100 +274,28 @@ function setupNewRunningAppGroup() {
);
}
function getSanitizedAppId(bundleIdentifier?: string) {
if (!bundleIdentifier) {
return 'unknown';
}
return isWindows()
? createHash('sha256')
.update(bundleIdentifier)
.digest('hex')
.substring(0, 8)
: bundleIdentifier;
}
export function createRecording(status: RecordingStatus) {
let recording = recordings.get(status.id);
if (recording) {
return recording;
}
const appId = getSanitizedAppId(status.appGroup?.bundleIdentifier);
const bufferedFilePath = path.join(
SAVED_RECORDINGS_DIR,
`${appId}-${status.id}-${status.startTime}.raw`
);
fs.ensureDirSync(SAVED_RECORDINGS_DIR);
const file = fs.createWriteStream(bufferedFilePath);
function tapAudioSamples(err: Error | null, samples: Float32Array) {
const recordingStatus = recordingStatus$.getValue();
if (
!recordingStatus ||
recordingStatus.id !== status.id ||
recordingStatus.status === 'paused'
) {
return;
}
if (err) {
logger.error('failed to get audio samples', err);
} else {
// Writing raw Float32Array samples directly to file
// For stereo audio, samples are interleaved [L,R,L,R,...]
file.write(Buffer.from(samples.buffer));
}
}
// MUST require dynamically to avoid loading @affine/native for unsupported platforms
const SC: typeof ShareableContentType =
require('@affine/native').ShareableContent;
const stream = status.app
? SC.tapAudio(status.app.processId, tapAudioSamples)
: SC.tapGlobalAudio(null, tapAudioSamples);
recording = {
id: status.id,
startTime: status.startTime,
app: status.app,
appGroup: status.appGroup,
file,
session: stream,
};
recordings.set(status.id, recording);
return recording;
}
export async function getRecording(id: number) {
const recording = recordings.get(id);
if (!recording) {
const recording = recordingStateMachine.status;
if (!recording || recording.id !== id) {
logger.error(`Recording ${id} not found`);
return;
}
const rawFilePath = String(recording.file.path);
return {
id,
appGroup: recording.appGroup,
app: recording.app,
startTime: recording.startTime,
filepath: rawFilePath,
sampleRate: recording.session.sampleRate,
numberOfChannels: recording.session.channels,
filepath: recording.filepath,
sampleRate: recording.sampleRate,
numberOfChannels: recording.numberOfChannels,
};
}
// recording popup status
// new: recording is started, popup is shown
// recording: recording is started, popup is shown
// stopped: recording is stopped, popup showing processing status
// create-block-success: recording is ready, show "open app" button
// create-block-failed: recording is failed, show "failed to save" button
// new: waiting for user confirmation
// recording: native recording is ongoing
// processing: native stop or renderer import/transcription is ongoing
// ready + blockCreationStatus: post-processing finished
// null: hide popup
function setupRecordingListeners() {
subscribers.push(
@@ -350,36 +310,21 @@ function setupRecordingListeners() {
});
}
if (status?.status === 'recording') {
let recording = recordings.get(status.id);
// create a recording if not exists
if (!recording) {
recording = createRecording(status);
}
} else if (status?.status === 'stopped') {
const recording = recordings.get(status.id);
if (recording) {
recording.session.stop();
}
} else if (
status?.status === 'create-block-success' ||
status?.status === 'create-block-failed'
) {
if (isRecordingSettled(status)) {
// show the popup for 10s
setTimeout(
() => {
// check again if current status is still ready
const currentStatus = recordingStatus$.value;
if (
(recordingStatus$.value?.status === 'create-block-success' ||
recordingStatus$.value?.status === 'create-block-failed') &&
recordingStatus$.value.id === status.id
isRecordingSettled(currentStatus) &&
currentStatus.id === status.id
) {
popup.hide().catch(err => {
logger.error('failed to hide recording popup', err);
});
}
},
status?.status === 'create-block-failed' ? 30_000 : 10_000
status.blockCreationStatus === 'failed' ? 30_000 : 10_000
);
} else if (!status) {
// status is removed, we should hide the popup
@@ -400,9 +345,7 @@ function getAllApps(): TappableAppInfo[] {
}
// MUST require dynamically to avoid loading @affine/native for unsupported platforms
const { ShareableContent } = require('@affine/native') as {
ShareableContent: typeof ShareableContentType;
};
const { ShareableContent } = getNativeModule();
const apps = ShareableContent.applications().map(app => {
try {
@@ -433,12 +376,8 @@ function getAllApps(): TappableAppInfo[] {
return filteredApps;
}
type Subscriber = {
unsubscribe: () => void;
};
function setupMediaListeners() {
const ShareableContent = require('@affine/native').ShareableContent;
const ShareableContent = getNativeModule().ShareableContent;
applications$.next(getAllApps());
subscribers.push(
interval(3000).subscribe(() => {
@@ -454,8 +393,6 @@ function setupMediaListeners() {
})
);
let appStateSubscribers: Subscriber[] = [];
subscribers.push(
applications$.subscribe(apps => {
appStateSubscribers.forEach(subscriber => {
@@ -484,15 +421,6 @@ function setupMediaListeners() {
});
appStateSubscribers = _appStateSubscribers;
return () => {
_appStateSubscribers.forEach(subscriber => {
try {
subscriber.unsubscribe();
} catch {
// ignore unsubscribe error
}
});
};
})
);
}
@@ -502,7 +430,7 @@ function askForScreenRecordingPermission() {
return false;
}
try {
const ShareableContent = require('@affine/native').ShareableContent;
const ShareableContent = getNativeModule().ShareableContent;
// this will trigger the permission prompt
new ShareableContent();
return true;
@@ -519,7 +447,7 @@ export function setupRecordingFeature() {
}
try {
const ShareableContent = require('@affine/native').ShareableContent;
const ShareableContent = getNativeModule().ShareableContent;
if (!shareableContent) {
shareableContent = new ShareableContent();
setupMediaListeners();
@@ -537,7 +465,6 @@ export function setupRecordingFeature() {
}
export function disableRecordingFeature() {
recordingStatus$.next(null);
cleanup();
}
@@ -558,222 +485,175 @@ export function newRecording(
});
}
export function startRecording(
export async function startRecording(
appGroup?: AppGroupInfo | number
): RecordingStatus | null {
const state = recordingStateMachine.dispatch(
{
type: 'START_RECORDING',
appGroup: normalizeAppGroupInfo(appGroup),
},
false
);
): Promise<RecordingStatus | null> {
const previousState = recordingStateMachine.status;
const state = recordingStateMachine.dispatch({
type: 'START_RECORDING',
appGroup: normalizeAppGroupInfo(appGroup),
});
if (state?.status === 'recording') {
createRecording(state);
if (!state || state.status !== 'recording' || state === previousState) {
return state;
}
recordingStateMachine.status$.next(state);
let nativeId: string | undefined;
return state;
}
try {
fs.ensureDirSync(SAVED_RECORDINGS_DIR);
export function pauseRecording(id: number) {
return recordingStateMachine.dispatch({ type: 'PAUSE_RECORDING', id });
}
const meta = getNativeModule().startRecording({
appProcessId: state.app?.processId,
outputDir: SAVED_RECORDINGS_DIR,
format: 'opus',
id: String(state.id),
});
nativeId = meta.id;
export function resumeRecording(id: number) {
return recordingStateMachine.dispatch({ type: 'RESUME_RECORDING', id });
const filepath = await assertRecordingFilepath(meta.filepath);
const nextState = recordingStateMachine.dispatch({
type: 'ATTACH_NATIVE_RECORDING',
id: state.id,
nativeId: meta.id,
startTime: meta.startedAt ?? state.startTime,
filepath,
sampleRate: meta.sampleRate,
numberOfChannels: meta.channels,
});
if (!nextState || nextState.nativeId !== meta.id) {
throw new Error('Failed to attach native recording metadata');
}
return nextState;
} catch (error) {
if (nativeId) {
cleanupAbandonedNativeRecording(nativeId);
}
logger.error('failed to start recording', error);
return setRecordingBlockCreationStatus(
state.id,
'failed',
error instanceof Error ? error.message : undefined
);
}
}
export async function stopRecording(id: number) {
const recording = recordings.get(id);
if (!recording) {
const recording = recordingStateMachine.status;
if (!recording || recording.id !== id) {
logger.error(`stopRecording: Recording ${id} not found`);
return;
}
if (!recording.file.path) {
logger.error(`Recording ${id} has no file path`);
if (!recording.nativeId) {
logger.error(`stopRecording: Recording ${id} missing native id`);
return;
}
const { file, session: stream } = recording;
// First stop the audio stream to prevent more data coming in
try {
stream.stop();
} catch (err) {
logger.error('Failed to stop audio stream', err);
const processingState = recordingStateMachine.dispatch({
type: 'STOP_RECORDING',
id,
});
if (
!processingState ||
processingState.id !== id ||
processingState.status !== 'processing'
) {
return serializeRecordingStatus(processingState ?? recording);
}
// End the file with a timeout
file.end();
try {
await Promise.race([
new Promise<void>((resolve, reject) => {
file.on('finish', () => {
// check if the file is empty
const stats = fs.statSync(file.path);
if (stats.size === 0) {
reject(new Error('Recording is empty'));
return;
}
resolve();
});
file.on('error', err => {
reject(err);
});
}),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('File writing timeout')), 10000)
),
]);
const recordingStatus = recordingStateMachine.dispatch({
type: 'STOP_RECORDING',
const artifact = getNativeModule().stopRecording(recording.nativeId);
const filepath = await assertRecordingFilepath(artifact.filepath);
const readyStatus = recordingStateMachine.dispatch({
type: 'ATTACH_RECORDING_ARTIFACT',
id,
filepath,
sampleRate: artifact.sampleRate,
numberOfChannels: artifact.channels,
});
if (!recordingStatus) {
logger.error('No recording status to stop');
if (!readyStatus) {
logger.error('No recording status to save');
return;
}
return serializeRecordingStatus(recordingStatus);
getMainWindow()
.then(mainWindow => {
if (mainWindow) {
mainWindow.show();
}
})
.catch(err => {
logger.error('failed to bring up the window', err);
});
return serializeRecordingStatus(readyStatus);
} catch (error: unknown) {
logger.error('Failed to stop recording', error);
const recordingStatus = recordingStateMachine.dispatch({
type: 'CREATE_BLOCK_FAILED',
const recordingStatus = await setRecordingBlockCreationStatus(
id,
error: error instanceof Error ? error : undefined,
});
'failed',
error instanceof Error ? error.message : undefined
);
if (!recordingStatus) {
logger.error('No recording status to stop');
return;
}
return serializeRecordingStatus(recordingStatus);
} finally {
// Clean up the file stream if it's still open
if (!file.closed) {
file.destroy();
}
}
}
export async function getRawAudioBuffers(
id: number,
cursor?: number
): Promise<{
buffer: Buffer;
nextCursor: number;
}> {
const recording = recordings.get(id);
if (!recording) {
throw new Error(`getRawAudioBuffers: Recording ${id} not found`);
}
const start = cursor ?? 0;
const file = await fsp.open(recording.file.path, 'r');
const stats = await file.stat();
const buffer = Buffer.alloc(stats.size - start);
const result = await file.read(buffer, 0, buffer.length, start);
await file.close();
return {
buffer,
nextCursor: start + result.bytesRead,
};
}
function assertRecordingFilepath(filepath: string) {
const normalizedPath = path.normalize(filepath);
const normalizedBase = path.normalize(SAVED_RECORDINGS_DIR + path.sep);
if (!normalizedPath.toLowerCase().startsWith(normalizedBase.toLowerCase())) {
throw new Error('Invalid recording filepath');
}
return normalizedPath;
async function assertRecordingFilepath(filepath: string) {
return await resolveExistingPathInBase(SAVED_RECORDINGS_DIR, filepath, {
caseInsensitive: isWindows(),
label: 'recording filepath',
});
}
export async function readRecordingFile(filepath: string) {
const normalizedPath = assertRecordingFilepath(filepath);
const normalizedPath = await assertRecordingFilepath(filepath);
return fsp.readFile(normalizedPath);
}
export async function readyRecording(id: number, buffer: Buffer) {
logger.info('readyRecording', id);
const recordingStatus = recordingStatus$.value;
const recording = recordings.get(id);
if (!recordingStatus || recordingStatus.id !== id || !recording) {
logger.error(`readyRecording: Recording ${id} not found`);
return;
function cleanupAbandonedNativeRecording(nativeId: string) {
try {
const artifact = getNativeModule().stopRecording(nativeId);
void assertRecordingFilepath(artifact.filepath)
.then(filepath => {
fs.removeSync(filepath);
})
.catch(error => {
logger.error('failed to validate abandoned recording filepath', error);
});
} catch (error) {
logger.error('failed to cleanup abandoned native recording', error);
}
const rawFilePath = String(recording.file.path);
const filepath = rawFilePath.replace('.raw', '.opus');
if (!filepath) {
logger.error(`readyRecording: Recording ${id} has no filepath`);
return;
}
await fs.writeFile(filepath, buffer);
// can safely remove the raw file now
logger.info('remove raw file', rawFilePath);
if (rawFilePath) {
try {
await fs.unlink(rawFilePath);
} catch (err) {
logger.error('failed to remove raw file', err);
}
}
// Update the status through the state machine
recordingStateMachine.dispatch({
type: 'SAVE_RECORDING',
id,
filepath,
});
// bring up the window
getMainWindow()
.then(mainWindow => {
if (mainWindow) {
mainWindow.show();
}
})
.catch(err => {
logger.error('failed to bring up the window', err);
});
}
export async function handleBlockCreationSuccess(id: number) {
recordingStateMachine.dispatch({
type: 'CREATE_BLOCK_SUCCESS',
export async function setRecordingBlockCreationStatus(
id: number,
status: 'success' | 'failed',
errorMessage?: string
) {
return recordingStateMachine.dispatch({
type: 'SET_BLOCK_CREATION_STATUS',
id,
});
}
export async function handleBlockCreationFailed(id: number, error?: Error) {
recordingStateMachine.dispatch({
type: 'CREATE_BLOCK_FAILED',
id,
error,
status,
errorMessage,
});
}
export function removeRecording(id: number) {
recordings.delete(id);
recordingStateMachine.dispatch({ type: 'REMOVE_RECORDING', id });
}
export interface SerializedRecordingStatus {
id: number;
status: RecordingStatus['status'];
blockCreationStatus?: RecordingStatus['blockCreationStatus'];
appName?: string;
// if there is no app group, it means the recording is for system audio
appGroupId?: number;
@@ -787,18 +667,17 @@ export interface SerializedRecordingStatus {
export function serializeRecordingStatus(
status: RecordingStatus
): SerializedRecordingStatus | null {
const recording = recordings.get(status.id);
return {
id: status.id,
status: status.status,
blockCreationStatus: status.blockCreationStatus,
appName: status.appGroup?.name,
appGroupId: status.appGroup?.processGroupId,
icon: status.appGroup?.icon,
startTime: status.startTime,
filepath:
status.filepath ?? (recording ? String(recording.file.path) : undefined),
sampleRate: recording?.session.sampleRate,
numberOfChannels: recording?.session.channels,
filepath: status.filepath,
sampleRate: status.sampleRate,
numberOfChannels: status.numberOfChannels,
};
}
@@ -2,11 +2,9 @@
// Should not load @affine/native for unsupported platforms
import path from 'node:path';
import { shell } from 'electron';
import { isMacOS } from '../../shared/utils';
import { isMacOS, resolvePathInBase } from '../../shared/utils';
import { openExternalSafely } from '../security/open-external';
import type { NamespaceHandlers } from '../type';
import {
@@ -14,18 +12,14 @@ import {
checkMeetingPermissions,
checkRecordingAvailable,
disableRecordingFeature,
getRawAudioBuffers,
getRecording,
handleBlockCreationFailed,
handleBlockCreationSuccess,
pauseRecording,
readRecordingFile,
readyRecording,
recordingStatus$,
removeRecording,
SAVED_RECORDINGS_DIR,
type SerializedRecordingStatus,
serializeRecordingStatus,
setRecordingBlockCreationStatus,
setupRecordingFeature,
startRecording,
stopRecording,
@@ -45,27 +39,19 @@ export const recordingHandlers = {
startRecording: async (_, appGroup?: AppGroupInfo | number) => {
return startRecording(appGroup);
},
pauseRecording: async (_, id: number) => {
return pauseRecording(id);
},
stopRecording: async (_, id: number) => {
return stopRecording(id);
},
getRawAudioBuffers: async (_, id: number, cursor?: number) => {
return getRawAudioBuffers(id, cursor);
},
readRecordingFile: async (_, filepath: string) => {
return readRecordingFile(filepath);
},
// save the encoded recording buffer to the file system
readyRecording: async (_, id: number, buffer: Uint8Array) => {
return readyRecording(id, Buffer.from(buffer));
},
handleBlockCreationSuccess: async (_, id: number) => {
return handleBlockCreationSuccess(id);
},
handleBlockCreationFailed: async (_, id: number, error?: Error) => {
return handleBlockCreationFailed(id, error);
setRecordingBlockCreationStatus: async (
_,
id: number,
status: 'success' | 'failed',
errorMessage?: string
) => {
return setRecordingBlockCreationStatus(id, status, errorMessage);
},
removeRecording: async (_, id: number) => {
return removeRecording(id);
@@ -100,15 +86,10 @@ export const recordingHandlers = {
return false;
},
showSavedRecordings: async (_, subpath?: string) => {
const normalizedDir = path.normalize(
path.join(SAVED_RECORDINGS_DIR, subpath ?? '')
);
const normalizedBase = path.normalize(SAVED_RECORDINGS_DIR);
if (!normalizedDir.startsWith(normalizedBase)) {
throw new Error('Invalid directory');
}
return shell.showItemInFolder(normalizedDir);
const directory = resolvePathInBase(SAVED_RECORDINGS_DIR, subpath ?? '', {
label: 'directory',
});
return shell.showItemInFolder(directory);
},
} satisfies NamespaceHandlers;
@@ -13,25 +13,31 @@ export type RecordingEvent =
type: 'START_RECORDING';
appGroup?: AppGroupInfo;
}
| { type: 'PAUSE_RECORDING'; id: number }
| { type: 'RESUME_RECORDING'; id: number }
| {
type: 'ATTACH_NATIVE_RECORDING';
id: number;
nativeId: string;
startTime: number;
filepath: string;
sampleRate: number;
numberOfChannels: number;
}
| {
type: 'STOP_RECORDING';
id: number;
}
| {
type: 'SAVE_RECORDING';
type: 'ATTACH_RECORDING_ARTIFACT';
id: number;
filepath: string;
sampleRate?: number;
numberOfChannels?: number;
}
| {
type: 'CREATE_BLOCK_FAILED';
id: number;
error?: Error;
}
| {
type: 'CREATE_BLOCK_SUCCESS';
type: 'SET_BLOCK_CREATION_STATUS';
id: number;
status: 'success' | 'failed';
errorMessage?: string;
}
| { type: 'REMOVE_RECORDING'; id: number };
@@ -74,23 +80,26 @@ export class RecordingStateMachine {
case 'START_RECORDING':
newStatus = this.handleStartRecording(event.appGroup);
break;
case 'PAUSE_RECORDING':
newStatus = this.handlePauseRecording();
break;
case 'RESUME_RECORDING':
newStatus = this.handleResumeRecording();
case 'ATTACH_NATIVE_RECORDING':
newStatus = this.handleAttachNativeRecording(event);
break;
case 'STOP_RECORDING':
newStatus = this.handleStopRecording(event.id);
break;
case 'SAVE_RECORDING':
newStatus = this.handleSaveRecording(event.id, event.filepath);
case 'ATTACH_RECORDING_ARTIFACT':
newStatus = this.handleAttachRecordingArtifact(
event.id,
event.filepath,
event.sampleRate,
event.numberOfChannels
);
break;
case 'CREATE_BLOCK_SUCCESS':
newStatus = this.handleCreateBlockSuccess(event.id);
break;
case 'CREATE_BLOCK_FAILED':
newStatus = this.handleCreateBlockFailed(event.id, event.error);
case 'SET_BLOCK_CREATION_STATUS':
newStatus = this.handleSetBlockCreationStatus(
event.id,
event.status,
event.errorMessage
);
break;
case 'REMOVE_RECORDING':
this.handleRemoveRecording(event.id);
@@ -133,7 +142,7 @@ export class RecordingStateMachine {
const currentStatus = this.recordingStatus$.value;
if (
currentStatus?.status === 'recording' ||
currentStatus?.status === 'stopped'
currentStatus?.status === 'processing'
) {
logger.error(
'Cannot start a new recording if there is already a recording'
@@ -160,46 +169,31 @@ export class RecordingStateMachine {
}
/**
* Handle the PAUSE_RECORDING event
* Attach native recording metadata to the current recording
*/
private handlePauseRecording(): RecordingStatus | null {
private handleAttachNativeRecording(
event: Extract<RecordingEvent, { type: 'ATTACH_NATIVE_RECORDING' }>
): RecordingStatus | null {
const currentStatus = this.recordingStatus$.value;
if (!currentStatus) {
logger.error('No active recording to pause');
return null;
if (!currentStatus || currentStatus.id !== event.id) {
logger.error(`Recording ${event.id} not found for native attachment`);
return currentStatus;
}
if (currentStatus.status !== 'recording') {
logger.error(`Cannot pause recording in ${currentStatus.status} state`);
logger.error(
`Cannot attach native metadata when recording is in ${currentStatus.status} state`
);
return currentStatus;
}
return {
...currentStatus,
status: 'paused',
};
}
/**
* Handle the RESUME_RECORDING event
*/
private handleResumeRecording(): RecordingStatus | null {
const currentStatus = this.recordingStatus$.value;
if (!currentStatus) {
logger.error('No active recording to resume');
return null;
}
if (currentStatus.status !== 'paused') {
logger.error(`Cannot resume recording in ${currentStatus.status} state`);
return currentStatus;
}
return {
...currentStatus,
status: 'recording',
nativeId: event.nativeId,
startTime: event.startTime,
filepath: event.filepath,
sampleRate: event.sampleRate,
numberOfChannels: event.numberOfChannels,
};
}
@@ -214,26 +208,25 @@ export class RecordingStateMachine {
return currentStatus;
}
if (
currentStatus.status !== 'recording' &&
currentStatus.status !== 'paused'
) {
if (currentStatus.status !== 'recording') {
logger.error(`Cannot stop recording in ${currentStatus.status} state`);
return currentStatus;
}
return {
...currentStatus,
status: 'stopped',
status: 'processing',
};
}
/**
* Handle the SAVE_RECORDING event
* Attach the encoded artifact once native stop completes
*/
private handleSaveRecording(
private handleAttachRecordingArtifact(
id: number,
filepath: string
filepath: string,
sampleRate?: number,
numberOfChannels?: number
): RecordingStatus | null {
const currentStatus = this.recordingStatus$.value;
@@ -242,51 +235,54 @@ export class RecordingStateMachine {
return currentStatus;
}
return {
...currentStatus,
status: 'ready',
filepath,
};
}
/**
* Handle the CREATE_BLOCK_SUCCESS event
*/
private handleCreateBlockSuccess(id: number): RecordingStatus | null {
const currentStatus = this.recordingStatus$.value;
if (!currentStatus || currentStatus.id !== id) {
logger.error(`Recording ${id} not found for create-block-success`);
if (currentStatus.status !== 'processing') {
logger.error(`Cannot attach artifact in ${currentStatus.status} state`);
return currentStatus;
}
return {
...currentStatus,
status: 'create-block-success',
filepath,
sampleRate: sampleRate ?? currentStatus.sampleRate,
numberOfChannels: numberOfChannels ?? currentStatus.numberOfChannels,
};
}
/**
* Handle the CREATE_BLOCK_FAILED event
* Set the renderer-side block creation result
*/
private handleCreateBlockFailed(
private handleSetBlockCreationStatus(
id: number,
error?: Error
status: 'success' | 'failed',
errorMessage?: string
): RecordingStatus | null {
const currentStatus = this.recordingStatus$.value;
if (!currentStatus || currentStatus.id !== id) {
logger.error(`Recording ${id} not found for create-block-failed`);
logger.error(`Recording ${id} not found for block creation status`);
return currentStatus;
}
if (error) {
logger.error(`Recording ${id} create block failed:`, error);
if (currentStatus.status === 'new') {
logger.error(`Cannot settle recording ${id} before it starts`);
return currentStatus;
}
if (
currentStatus.status === 'ready' &&
currentStatus.blockCreationStatus !== undefined
) {
return currentStatus;
}
if (errorMessage) {
logger.error(`Recording ${id} create block failed: ${errorMessage}`);
}
return {
...currentStatus,
status: 'create-block-failed',
status: 'ready',
blockCreationStatus: status,
};
}
@@ -1,88 +1,35 @@
# Recording State Transitions
This document visualizes the possible state transitions in the recording system.
The desktop recording flow now has a single linear engine state and a separate post-process result.
## States
## Engine states
The recording system has the following states:
- `inactive`: no active recording
- `new`: app detected, waiting for user confirmation
- `recording`: native capture is running
- `processing`: native capture has stopped and the artifact is being imported
- `ready`: post-processing has finished
- **inactive**: No active recording (null state)
- **new**: A new recording has been detected but not yet started
- **recording**: Audio is being recorded
- **paused**: Recording is temporarily paused
- **stopped**: Recording has been stopped and is processing
- **ready**: Recording is processed and ready for use
## Post-process result
## Transitions
`ready` recordings may carry `blockCreationStatus`:
```
┌───────────┐ ┌───────┐
│ │ │ │
│ inactive │◀───────────────│ ready │
│ │ │ │
└─────┬─────┘ └───┬───┘
│ │
│ NEW_RECORDING │
▼ │
┌───────────┐ │
│ │ │
│ new │ │
│ │ │
└─────┬─────┘ │
│ │
│ START_RECORDING │
▼ │
┌───────────┐ │
│ │ STOP_RECORDING│
│ recording │─────────────────┐ │
│ │◀────────────┐ │ │
└─────┬─────┘ │ │ │
│ │ │ │
│ PAUSE_RECORDING │ │ │
▼ │ │ │
┌───────────┐ │ │ │
│ │ │ │ │
│ paused │ │ │ │
│ │ │ │ │
└─────┬─────┘ │ │ │
│ │ │ │
│ RESUME_RECORDING │ │ │
└───────────────────┘ │ │
│ │
▼ │
┌───────────┐
│ │
│ stopped │
│ │
└─────┬─────┘
│ SAVE_RECORDING
┌───────────┐
│ │
│ ready │
│ │
└───────────┘
- `success`: the recording block was created successfully
- `failed`: the artifact was saved, but block creation/import failed
## State flow
```text
inactive -> new -> recording -> processing -> ready
^ |
| |
+------ start ---------+
```
## Events
- `START_RECORDING` creates or reuses a pending `new` recording.
- `ATTACH_NATIVE_RECORDING` fills in native metadata while staying in `recording`.
- `STOP_RECORDING` moves the flow to `processing`.
- `ATTACH_RECORDING_ARTIFACT` attaches the finalized `.opus` artifact while staying in `processing`.
- `SET_BLOCK_CREATION_STATUS` settles the flow as `ready`.
The following events trigger state transitions:
- `NEW_RECORDING`: Create a new recording when an app starts or is detected
- `START_RECORDING`: Start recording audio
- `PAUSE_RECORDING`: Pause the current recording
- `RESUME_RECORDING`: Resume a paused recording
- `STOP_RECORDING`: Stop the current recording
- `SAVE_RECORDING`: Save and finalize a recording
- `REMOVE_RECORDING`: Delete a recording
## Error Handling
Invalid state transitions are logged and prevented. For example:
- Cannot start a new recording when one is already in progress
- Cannot pause a recording that is not in the 'recording' state
- Cannot resume a recording that is not in the 'paused' state
Each transition function in the state machine validates the current state before allowing a transition.
Only one recording can be active at a time. A new recording can start only after the previous one has been removed or its `ready` result has been settled.
@@ -1,6 +1,4 @@
import type { WriteStream } from 'node:fs';
import type { ApplicationInfo, AudioCaptureSession } from '@affine/native';
import type { ApplicationInfo } from '@affine/native';
export interface TappableAppInfo {
info: ApplicationInfo;
@@ -20,38 +18,19 @@ export interface AppGroupInfo {
isRunning: boolean;
}
export interface Recording {
id: number;
// the app may not be available if the user choose to record system audio
app?: TappableAppInfo;
appGroup?: AppGroupInfo;
// the buffered file that is being recorded streamed to
file: WriteStream;
session: AudioCaptureSession;
startTime: number;
filepath?: string; // the filepath of the recording (only available when status is ready)
}
export interface RecordingStatus {
id: number; // corresponds to the recording id
// the status of the recording in a linear state machine
// new: an new app group is listening. note, if there are any active recording, the current recording will not change
// recording: the recording is ongoing
// paused: the recording is paused
// stopped: the recording is stopped (processing audio file for use in the editor)
// ready: the recording is ready to be used
// create-block-success: the recording is successfully created as a block
// create-block-failed: creating block failed
status:
| 'new'
| 'recording'
| 'paused'
| 'stopped'
| 'ready'
| 'create-block-success'
| 'create-block-failed';
// an app group is detected and waiting for user confirmation
// recording: the native recorder is running
// processing: recording has stopped and the artifact is being prepared/imported
// ready: the post-processing result has been settled
status: 'new' | 'recording' | 'processing' | 'ready';
app?: TappableAppInfo;
appGroup?: AppGroupInfo;
startTime: number; // 0 means not started yet
filepath?: string; // encoded file path
nativeId?: string;
sampleRate?: number;
numberOfChannels?: number;
blockCreationStatus?: 'success' | 'failed';
}
@@ -160,11 +160,7 @@ class TrayState implements Disposable {
const recordingStatus = recordingStatus$.value;
if (
!recordingStatus ||
(recordingStatus?.status !== 'paused' &&
recordingStatus?.status !== 'recording')
) {
if (!recordingStatus || recordingStatus.status !== 'recording') {
const appMenuItems = runningAppGroups.map(appGroup => ({
label: appGroup.name,
icon: appGroup.icon || undefined,
@@ -172,7 +168,9 @@ class TrayState implements Disposable {
logger.info(
`User action: Start Recording Meeting (${appGroup.name})`
);
startRecording(appGroup);
startRecording(appGroup).catch(err => {
logger.error('Failed to start recording:', err);
});
},
}));
@@ -188,7 +186,9 @@ class TrayState implements Disposable {
logger.info(
'User action: Start Recording Meeting (System audio)'
);
startRecording();
startRecording().catch(err => {
logger.error('Failed to start recording:', err);
});
},
},
...appMenuItems,
@@ -201,7 +201,7 @@ class TrayState implements Disposable {
? `Recording (${recordingStatus.appGroup?.name})`
: 'Recording';
// recording is either started or paused
// recording is active
items.push(
{
label: recordingLabel,
@@ -1,4 +1,5 @@
import { join } from 'node:path';
import { realpath } from 'node:fs/promises';
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
import type { EventBasedChannel } from 'async-call-rpc';
@@ -47,6 +48,130 @@ export class MessageEventChannel implements EventBasedChannel {
export const resourcesPath = join(__dirname, `../resources`);
function normalizeComparedPath(path: string, caseInsensitive: boolean) {
return caseInsensitive ? path.toLowerCase() : path;
}
export function isPathInsideBase(
basePath: string,
targetPath: string,
options: { caseInsensitive?: boolean } = {}
) {
const { caseInsensitive = false } = options;
const normalizedBase = normalizeComparedPath(
resolve(basePath),
caseInsensitive
);
const normalizedTarget = normalizeComparedPath(
resolve(targetPath),
caseInsensitive
);
const rel = relative(normalizedBase, normalizedTarget);
return (
rel === '' ||
(!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`))
);
}
export function resolvePathInBase(
basePath: string,
targetPath: string,
options: { caseInsensitive?: boolean; label?: string } = {}
) {
const resolvedBase = resolve(basePath);
const resolvedTarget = resolve(resolvedBase, targetPath);
if (!isPathInsideBase(resolvedBase, resolvedTarget, options)) {
throw new Error(
options.label ? `Invalid ${options.label}` : 'Invalid path'
);
}
return resolvedTarget;
}
export async function resolveExistingPath(targetPath: string) {
try {
return await realpath(targetPath);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT' || code === 'ENOTDIR') {
return resolve(targetPath);
}
throw error;
}
}
export async function resolveExistingPathInBase(
basePath: string,
targetPath: string,
options: { caseInsensitive?: boolean; label?: string } = {}
) {
const [resolvedBase, resolvedTarget] = await Promise.all([
resolveExistingPath(basePath),
resolveExistingPath(targetPath),
]);
if (!isPathInsideBase(resolvedBase, resolvedTarget, options)) {
throw new Error(
options.label ? `Invalid ${options.label}` : 'Invalid path'
);
}
return resolvedTarget;
}
export function assertPathComponent(
value: string,
label: string = 'path component'
) {
const hasControlChar = Array.from(value).some(
character => character.charCodeAt(0) < 0x20
);
if (
!value ||
value === '.' ||
value === '..' ||
/[/\\]/.test(value) ||
hasControlChar
) {
throw new Error(`Invalid ${label}`);
}
return value;
}
export function normalizeWorkspaceIdForPath(
value: string,
options: { windows?: boolean; label?: string } = {}
) {
const { windows = isWindows(), label = 'workspace id' } = options;
const safeValue = assertPathComponent(value, label);
if (!windows) {
return safeValue;
}
const windowsReservedChars = new Set(['<', '>', ':', '"', '|', '?', '*']);
let normalized = '';
for (const character of safeValue) {
normalized += windowsReservedChars.has(character) ? '_' : character;
}
while (normalized.endsWith('.') || normalized.endsWith(' ')) {
normalized = normalized.slice(0, -1);
}
if (!normalized || normalized === '.' || normalized === '..') {
throw new Error(`Invalid ${label}`);
}
return normalized;
}
// credit: https://github.com/facebook/fbjs/blob/main/packages/fbjs/src/core/shallowEqual.js
export function shallowEqual<T>(objA: T, objB: T) {
if (Object.is(objA, objB)) {