feat(native): async recorder (#14700)

#### PR Dependency Tree


* **PR #14700** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

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

* **New Features**
* Durable, resumable import queue with explicit import lifecycle and
updated popup/tray status behavior.
* Async native recording APIs and ability to abort recordings; audio
quality metrics (degraded, overflow count).
  * Added "Importing..." translation.

* **Bug Fixes**
* More reliable single-claim import processing, retries and cleanup to
avoid duplicate imports.
  * Improved stop/abort teardown stability and safer shutdown behavior.

* **Tests**
* New/updated tests covering coordinator, import queue, native async
flows and teardown scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-03-23 22:38:52 +08:00
committed by GitHub
parent b0251c93cb
commit 9b56a05159
26 changed files with 3560 additions and 1277 deletions
@@ -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;
})
);
}
}
@@ -3,7 +3,6 @@ import { execSync } from 'node:child_process';
import fsp from 'node:fs/promises';
import path from 'node:path';
// Should not load @affine/native for unsupported platforms
import type * as NativeModuleType from '@affine/native';
import { app, systemPreferences } from 'electron';
import fs from 'fs-extra';
@@ -14,6 +13,7 @@ import {
groupBy,
interval,
mergeMap,
type Observable,
Subject,
throttleTime,
} from 'rxjs';
@@ -35,8 +35,14 @@ import { globalStateStorage } from '../shared-storage/storage';
import { getMainWindow } from '../windows-manager';
import { popupManager } from '../windows-manager/popup';
import { isAppNameAllowed } from './allow-list';
import { recordingStateMachine } from './state-machine';
import type { AppGroupInfo, RecordingStatus, TappableAppInfo } from './types';
import { RecordingCoordinator } from './coordinator';
import type {
AppGroupInfo,
RecordingImportStatus,
RecordingJobStatus,
RecordingStatus,
TappableAppInfo,
} from './types';
export const MeetingsSettingsState = {
$: globalStateStorage.watch<MeetingSettingsSchema>(MeetingSettingsKey).pipe(
@@ -62,8 +68,6 @@ type Subscriber = {
const subscribers: Subscriber[] = [];
let appStateSubscribers: Subscriber[] = [];
// recordings are saved in the app data directory
// may need a way to clean up old recordings
export const SAVED_RECORDINGS_DIR = path.join(
app.getPath('sessionData'),
'recordings'
@@ -74,16 +78,46 @@ type ShareableContentType = InstanceType<NativeModule['ShareableContent']>;
type ShareableContentStatic = NativeModule['ShareableContent'];
let shareableContent: ShareableContentType | null = null;
let nativeModuleOverride: NativeModule | null = null;
function getNativeModule(): NativeModule {
return require('@affine/native') as NativeModule;
return nativeModuleOverride ?? (require('@affine/native') as NativeModule);
}
function cleanup() {
const nativeId = recordingStateMachine.status?.nativeId;
if (nativeId) cleanupAbandonedNativeRecording(nativeId);
recordingStatus$.next(null);
shareableContent = null;
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();
@@ -100,44 +134,69 @@ function cleanup() {
}
});
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(() => {
cleanup();
void abortActiveRecording().catch(() => undefined);
resetFeatureSubscriptions();
});
export const applications$ = new BehaviorSubject<TappableAppInfo[]>([]);
export const appGroups$ = new BehaviorSubject<AppGroupInfo[]>([]);
export const updateApplicationsPing$ = new Subject<number>();
// There should be only one active recording at a time; state is managed by the state machine
export const recordingStatus$ = recordingStateMachine.status$;
export const recordingStatus$: Observable<RecordingStatus | null> =
recordingCoordinator.status$;
export const recordingImportQueue$: Observable<RecordingImportStatus[]> =
recordingCoordinator.importQueue$;
function isRecordingSettled(
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: 'ready';
blockCreationStatus: 'success' | 'failed';
status: 'imported' | 'import_failed' | 'start_failed' | 'finalize_failed';
} {
return status?.status === 'ready' && status.blockCreationStatus !== undefined;
return (
status?.status === 'imported' ||
status?.status === 'import_failed' ||
status?.status === 'start_failed' ||
status?.status === 'finalize_failed'
);
}
function createAppGroup(processGroupId: number): AppGroupInfo | undefined {
// MUST require dynamically to avoid loading @affine/native for unsupported platforms
const SC: ShareableContentStatic = getNativeModule().ShareableContent;
const groupProcess = SC?.applicationWithProcessId(processGroupId);
if (!groupProcess) {
return;
}
return {
processGroupId: processGroupId,
apps: [], // leave it empty for now.
processGroupId,
apps: [],
name: groupProcess.name,
bundleIdentifier: groupProcess.bundleIdentifier,
// icon should be lazy loaded
get icon() {
try {
return groupProcess.icon;
@@ -150,32 +209,30 @@ function createAppGroup(processGroupId: number): AppGroupInfo | undefined {
};
}
// pipe applications$ to appGroups$
function setupAppGroups() {
subscribers.push(
applications$.pipe(distinctUntilChanged()).subscribe(apps => {
const appGroups: AppGroupInfo[] = [];
apps.forEach(app => {
let appGroup = appGroups.find(
group => group.processGroupId === app.processGroupId
const groups: AppGroupInfo[] = [];
apps.forEach(appInfo => {
let group = groups.find(
entry => entry.processGroupId === appInfo.processGroupId
);
if (!appGroup) {
appGroup = createAppGroup(app.processGroupId);
if (appGroup) {
appGroups.push(appGroup);
if (!group) {
group = createAppGroup(appInfo.processGroupId);
if (group) {
groups.push(group);
}
}
if (appGroup) {
appGroup.apps.push(app);
if (group) {
group.apps.push(appInfo);
}
});
appGroups.forEach(appGroup => {
appGroup.isRunning = appGroup.apps.some(app => app.isRunning);
groups.forEach(group => {
group.isRunning = group.apps.some(app => app.isRunning);
});
appGroups$.next(appGroups);
appGroups$.next(groups);
})
);
}
@@ -193,7 +250,7 @@ function setupNewRunningAppGroup() {
);
appGroups$.value.forEach(group => {
const recordingStatus = recordingStatus$.value;
const recordingStatus = getCurrentRecordingStatus();
if (
group.isRunning &&
(!recordingStatus || recordingStatus.status === 'new')
@@ -207,7 +264,7 @@ function setupNewRunningAppGroup() {
group => group.processGroupId === appGroup.processGroupId
);
if (currentGroup?.isRunning) {
startRecording(currentGroup).catch(err => {
void startRecording(currentGroup).catch(err => {
logger.error('failed to start recording', err);
});
}
@@ -225,47 +282,35 @@ function setupNewRunningAppGroup() {
return;
}
const recordingStatus = recordingStatus$.value;
const recordingStatus = getCurrentRecordingStatus();
if (currentGroup.isRunning) {
// when the app is running and there is no active recording popup
// we should show a new recording popup
if (
!recordingStatus ||
recordingStatus.status === 'new' ||
isRecordingSettled(recordingStatus)
!hasActivePopupStatus(recordingStatus)
) {
if (MeetingsSettingsState.value.recordingMode === 'prompt') {
newRecording(currentGroup);
} else if (
MeetingsSettingsState.value.recordingMode === 'auto-start'
) {
// there is a case that the watched app's running state changed rapidly
// we will schedule the start recording to avoid that
debounceStartRecording(currentGroup);
} else {
// do nothing, skip
}
}
} else {
// when displaying in "new" state but the app is not running any more
// we should remove the recording
if (
recordingStatus?.status === 'new' &&
currentGroup.bundleIdentifier ===
recordingStatus.appGroup?.bundleIdentifier
recordingStatus.appGroupId === currentGroup.processGroupId
) {
removeRecording(recordingStatus.id);
}
// if the watched app stops while we are recording it,
// we should stop the recording
if (
recordingStatus?.status === 'recording' &&
recordingStatus.appGroup?.bundleIdentifier ===
currentGroup.bundleIdentifier
recordingStatus.appGroupId === currentGroup.processGroupId
) {
stopRecording(recordingStatus.id).catch(err => {
void stopRecording(recordingStatus.id).catch(err => {
logger.error('failed to stop recording', err);
});
}
@@ -275,28 +320,9 @@ function setupNewRunningAppGroup() {
}
export async function getRecording(id: number) {
const recording = recordingStateMachine.status;
if (!recording || recording.id !== id) {
logger.error(`Recording ${id} not found`);
return;
}
return {
id,
appGroup: recording.appGroup,
app: recording.app,
startTime: recording.startTime,
filepath: recording.filepath,
sampleRate: recording.sampleRate,
numberOfChannels: recording.numberOfChannels,
};
return recordingCoordinator.getRecording(id);
}
// recording popup status
// 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(
recordingStatus$
@@ -305,30 +331,33 @@ function setupRecordingListeners() {
const popup = popupManager.get('recording');
if (status && !popup.showing) {
popup.show().catch(err => {
void popup.show().catch(err => {
logger.error('failed to show recording popup', err);
});
}
if (isRecordingSettled(status)) {
// show the popup for 10s
if (isTerminalPopupStatus(status)) {
setTimeout(
() => {
const currentStatus = recordingStatus$.value;
const currentStatus = getCurrentRecordingStatus();
if (
isRecordingSettled(currentStatus) &&
isTerminalPopupStatus(currentStatus) &&
currentStatus.id === status.id
) {
popup.hide().catch(err => {
void popup.hide().catch(err => {
logger.error('failed to hide recording popup', err);
});
dismissRecordingStatus(status.id);
}
},
status.blockCreationStatus === 'failed' ? 30_000 : 10_000
status.status === 'import_failed' ||
status.status === 'start_failed' ||
status.status === 'finalize_failed'
? 30_000
: 10_000
);
} else if (!status) {
// status is removed, we should hide the popup
popupManager
void popupManager
.get('recording')
.hide()
.catch(err => {
@@ -344,20 +373,16 @@ function getAllApps(): TappableAppInfo[] {
return [];
}
// MUST require dynamically to avoid loading @affine/native for unsupported platforms
const { ShareableContent } = getNativeModule();
const apps = ShareableContent.applications().map(app => {
const apps = ShareableContent.applications().map(appInfo => {
try {
// Check if this process is actively using microphone/audio
const isRunning = ShareableContent.isUsingMicrophone(app.processId);
const isRunning = ShareableContent.isUsingMicrophone(appInfo.processId);
return {
info: app,
processId: app.processId,
processGroupId: app.processGroupId,
bundleIdentifier: app.bundleIdentifier,
name: app.name,
info: appInfo,
processId: appInfo.processId,
processGroupId: appInfo.processGroupId,
bundleIdentifier: appInfo.bundleIdentifier,
name: appInfo.name,
isRunning,
};
} catch (error) {
@@ -366,14 +391,13 @@ function getAllApps(): TappableAppInfo[] {
}
});
const filteredApps = apps.filter(
(v): v is TappableAppInfo =>
v !== null &&
!v.bundleIdentifier.startsWith('com.apple') &&
!v.bundleIdentifier.startsWith('pro.affine') &&
v.processId !== process.pid
return apps.filter(
(value): value is TappableAppInfo =>
value !== null &&
!value.bundleIdentifier.startsWith('com.apple') &&
!value.bundleIdentifier.startsWith('pro.affine') &&
value.processId !== process.pid
);
return filteredApps;
}
function setupMediaListeners() {
@@ -402,25 +426,22 @@ function setupMediaListeners() {
// ignore unsubscribe error
}
});
const _appStateSubscribers: Subscriber[] = [];
apps.forEach(app => {
appStateSubscribers = apps.flatMap(appInfo => {
try {
const applicationInfo = app.info;
_appStateSubscribers.push(
ShareableContent.onAppStateChanged(applicationInfo, () => {
return [
ShareableContent.onAppStateChanged(appInfo.info, () => {
updateApplicationsPing$.next(Date.now());
})
);
}),
];
} catch (error) {
logger.error(
`Failed to set up app state listener for ${app.name}`,
`Failed to set up app state listener for ${appInfo.name}`,
error
);
return [];
}
});
appStateSubscribers = _appStateSubscribers;
})
);
}
@@ -431,7 +452,6 @@ function askForScreenRecordingPermission() {
}
try {
const ShareableContent = getNativeModule().ShareableContent;
// this will trigger the permission prompt
new ShareableContent();
return true;
} catch (error) {
@@ -440,7 +460,6 @@ function askForScreenRecordingPermission() {
return false;
}
// will be called when the app is ready or when the user has enabled the recording feature in settings
export function setupRecordingFeature() {
if (!MeetingsSettingsState.value.enabled || !checkCanRecordMeeting()) {
return;
@@ -452,8 +471,6 @@ export function setupRecordingFeature() {
shareableContent = new ShareableContent();
setupMediaListeners();
}
// reset all states
recordingStatus$.next(null);
setupAppGroups();
setupNewRunningAppGroup();
setupRecordingListeners();
@@ -464,8 +481,9 @@ export function setupRecordingFeature() {
}
}
export function disableRecordingFeature() {
cleanup();
export async function disableRecordingFeature() {
await abortActiveRecording();
resetFeatureSubscriptions();
}
function normalizeAppGroupInfo(
@@ -479,108 +497,22 @@ function normalizeAppGroupInfo(
export function newRecording(
appGroup?: AppGroupInfo | number
): RecordingStatus | null {
return recordingStateMachine.dispatch({
type: 'NEW_RECORDING',
appGroup: normalizeAppGroupInfo(appGroup),
});
recordingCoordinator.createPrompt(normalizeAppGroupInfo(appGroup));
return serializeRecordingStatus(getCurrentRecordingStatus());
}
export async function startRecording(
appGroup?: AppGroupInfo | number
): Promise<RecordingStatus | null> {
const previousState = recordingStateMachine.status;
const state = recordingStateMachine.dispatch({
type: 'START_RECORDING',
appGroup: normalizeAppGroupInfo(appGroup),
});
if (!state || state.status !== 'recording' || state === previousState) {
return state;
}
let nativeId: string | undefined;
try {
fs.ensureDirSync(SAVED_RECORDINGS_DIR);
const meta = getNativeModule().startRecording({
appProcessId: state.app?.processId,
outputDir: SAVED_RECORDINGS_DIR,
format: 'opus',
id: String(state.id),
});
nativeId = meta.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
);
}
fs.ensureDirSync(SAVED_RECORDINGS_DIR);
const job = await recordingCoordinator.start(normalizeAppGroupInfo(appGroup));
return serializeJob(job);
}
export async function stopRecording(id: number) {
const recording = recordingStateMachine.status;
if (!recording || recording.id !== id) {
logger.error(`stopRecording: Recording ${id} not found`);
return;
}
if (!recording.nativeId) {
logger.error(`stopRecording: Recording ${id} missing native id`);
return;
}
const processingState = recordingStateMachine.dispatch({
type: 'STOP_RECORDING',
id,
});
if (
!processingState ||
processingState.id !== id ||
processingState.status !== 'processing'
) {
return serializeRecordingStatus(processingState ?? recording);
}
try {
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 (!readyStatus) {
logger.error('No recording status to save');
return;
}
getMainWindow()
const job = await recordingCoordinator.stop(id);
if (job?.phase === 'recorded') {
void getMainWindow()
.then(mainWindow => {
if (mainWindow) {
mainWindow.show();
@@ -589,28 +521,8 @@ export async function stopRecording(id: number) {
.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 = await setRecordingBlockCreationStatus(
id,
'failed',
error instanceof Error ? error.message : undefined
);
if (!recordingStatus) {
logger.error('No recording status to stop');
return;
}
return serializeRecordingStatus(recordingStatus);
}
}
async function assertRecordingFilepath(filepath: string) {
return await resolveExistingPathInBase(SAVED_RECORDINGS_DIR, filepath, {
caseInsensitive: isWindows(),
label: 'recording filepath',
});
return serializeRecordingStatus(getCurrentRecordingStatus());
}
export async function readRecordingFile(filepath: string) {
@@ -618,66 +530,133 @@ export async function readRecordingFile(filepath: string) {
return fsp.readFile(normalizedPath);
}
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);
}
}
export async function setRecordingBlockCreationStatus(
id: number,
status: 'success' | 'failed',
errorMessage?: string
) {
return recordingStateMachine.dispatch({
type: 'SET_BLOCK_CREATION_STATUS',
id,
status,
errorMessage,
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) {
recordingStateMachine.dispatch({ type: 'REMOVE_RECORDING', id });
recordingCoordinator.remove(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;
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
status: RecordingStatus | null | undefined
): SerializedRecordingStatus | null {
if (!status) {
return null;
}
return {
id: status.id,
status: status.status,
blockCreationStatus: status.blockCreationStatus,
appName: status.appGroup?.name,
appGroupId: status.appGroup?.processGroupId,
icon: status.appGroup?.icon,
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,
};
}
@@ -692,7 +671,6 @@ export const getMacOSVersion = () => {
}
};
// check if the system is MacOS and the version is >= 14.2
export const checkRecordingAvailable = () => {
if (isMacOS()) {
const version = getMacOSVersion();
@@ -11,15 +11,22 @@ import {
askForMeetingPermission,
checkMeetingPermissions,
checkRecordingAvailable,
claimRecordingImport,
completeRecordingImport,
disableRecordingFeature,
dismissRecordingStatus,
failRecordingImport,
getCurrentRecordingStatus,
getRecording,
getRecordingImportQueue,
readRecordingFile,
recordingImportQueue$,
recordingStatus$,
removeRecording,
SAVED_RECORDINGS_DIR,
type SerializedRecordingImportStatus,
type SerializedRecordingStatus,
serializeRecordingStatus,
setRecordingBlockCreationStatus,
setupRecordingFeature,
startRecording,
stopRecording,
@@ -32,9 +39,8 @@ export const recordingHandlers = {
},
getCurrentRecording: async () => {
// not all properties are serializable, so we need to return a subset of the status
return recordingStatus$.value
? serializeRecordingStatus(recordingStatus$.value)
: null;
const status = getCurrentRecordingStatus();
return status ? serializeRecordingStatus(status) : null;
},
startRecording: async (_, appGroup?: AppGroupInfo | number) => {
return startRecording(appGroup);
@@ -45,13 +51,20 @@ export const recordingHandlers = {
readRecordingFile: async (_, filepath: string) => {
return readRecordingFile(filepath);
},
setRecordingBlockCreationStatus: async (
_,
id: number,
status: 'success' | 'failed',
errorMessage?: string
) => {
return setRecordingBlockCreationStatus(id, status, errorMessage);
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);
@@ -108,4 +121,37 @@ export const recordingEvents = {
}
};
},
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
}
};
},
};
@@ -1,300 +0,0 @@
import { BehaviorSubject } from 'rxjs';
import { shallowEqual } from '../../shared/utils';
import { logger } from '../logger';
import type { AppGroupInfo, RecordingStatus } from './types';
/**
* Recording state machine events
*/
export type RecordingEvent =
| { type: 'NEW_RECORDING'; appGroup?: AppGroupInfo }
| {
type: 'START_RECORDING';
appGroup?: AppGroupInfo;
}
| {
type: 'ATTACH_NATIVE_RECORDING';
id: number;
nativeId: string;
startTime: number;
filepath: string;
sampleRate: number;
numberOfChannels: number;
}
| {
type: 'STOP_RECORDING';
id: number;
}
| {
type: 'ATTACH_RECORDING_ARTIFACT';
id: number;
filepath: string;
sampleRate?: number;
numberOfChannels?: number;
}
| {
type: 'SET_BLOCK_CREATION_STATUS';
id: number;
status: 'success' | 'failed';
errorMessage?: string;
}
| { type: 'REMOVE_RECORDING'; id: number };
/**
* Recording State Machine
* Handles state transitions for the recording process
*/
export class RecordingStateMachine {
private recordingId = 0;
private readonly recordingStatus$ =
new BehaviorSubject<RecordingStatus | null>(null);
/**
* Get the current recording status
*/
get status(): RecordingStatus | null {
return this.recordingStatus$.value;
}
/**
* Get the BehaviorSubject for recording status
*/
get status$(): BehaviorSubject<RecordingStatus | null> {
return this.recordingStatus$;
}
/**
* Dispatch an event to the state machine
* @param event The event to dispatch
* @returns The new recording status after the event is processed
*/
dispatch(event: RecordingEvent, emit = true): RecordingStatus | null {
const currentStatus = this.recordingStatus$.value;
let newStatus: RecordingStatus | null = null;
switch (event.type) {
case 'NEW_RECORDING':
newStatus = this.handleNewRecording(event.appGroup);
break;
case 'START_RECORDING':
newStatus = this.handleStartRecording(event.appGroup);
break;
case 'ATTACH_NATIVE_RECORDING':
newStatus = this.handleAttachNativeRecording(event);
break;
case 'STOP_RECORDING':
newStatus = this.handleStopRecording(event.id);
break;
case 'ATTACH_RECORDING_ARTIFACT':
newStatus = this.handleAttachRecordingArtifact(
event.id,
event.filepath,
event.sampleRate,
event.numberOfChannels
);
break;
case 'SET_BLOCK_CREATION_STATUS':
newStatus = this.handleSetBlockCreationStatus(
event.id,
event.status,
event.errorMessage
);
break;
case 'REMOVE_RECORDING':
this.handleRemoveRecording(event.id);
newStatus = currentStatus?.id === event.id ? null : currentStatus;
break;
default:
logger.error('Unknown recording event type');
return currentStatus;
}
if (shallowEqual(newStatus, currentStatus)) {
return currentStatus;
}
if (emit) {
this.recordingStatus$.next(newStatus);
}
return newStatus;
}
/**
* Handle the NEW_RECORDING event
*/
private handleNewRecording(appGroup?: AppGroupInfo): RecordingStatus {
const recordingStatus: RecordingStatus = {
id: this.recordingId++,
status: 'new',
startTime: Date.now(),
app: appGroup?.apps.find(app => app.isRunning),
appGroup,
};
return recordingStatus;
}
/**
* Handle the START_RECORDING event
*/
private handleStartRecording(appGroup?: AppGroupInfo): RecordingStatus {
const currentStatus = this.recordingStatus$.value;
if (
currentStatus?.status === 'recording' ||
currentStatus?.status === 'processing'
) {
logger.error(
'Cannot start a new recording if there is already a recording'
);
return currentStatus;
}
if (
appGroup &&
currentStatus?.appGroup?.processGroupId === appGroup.processGroupId &&
currentStatus.status === 'new'
) {
return {
...currentStatus,
status: 'recording',
};
} else {
const newStatus = this.handleNewRecording(appGroup);
return {
...newStatus,
status: 'recording',
};
}
}
/**
* Attach native recording metadata to the current recording
*/
private handleAttachNativeRecording(
event: Extract<RecordingEvent, { type: 'ATTACH_NATIVE_RECORDING' }>
): RecordingStatus | null {
const currentStatus = this.recordingStatus$.value;
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 attach native metadata when recording is in ${currentStatus.status} state`
);
return currentStatus;
}
return {
...currentStatus,
nativeId: event.nativeId,
startTime: event.startTime,
filepath: event.filepath,
sampleRate: event.sampleRate,
numberOfChannels: event.numberOfChannels,
};
}
/**
* Handle the STOP_RECORDING event
*/
private handleStopRecording(id: number): RecordingStatus | null {
const currentStatus = this.recordingStatus$.value;
if (!currentStatus || currentStatus.id !== id) {
logger.error(`Recording ${id} not found for stopping`);
return currentStatus;
}
if (currentStatus.status !== 'recording') {
logger.error(`Cannot stop recording in ${currentStatus.status} state`);
return currentStatus;
}
return {
...currentStatus,
status: 'processing',
};
}
/**
* Attach the encoded artifact once native stop completes
*/
private handleAttachRecordingArtifact(
id: number,
filepath: string,
sampleRate?: number,
numberOfChannels?: number
): RecordingStatus | null {
const currentStatus = this.recordingStatus$.value;
if (!currentStatus || currentStatus.id !== id) {
logger.error(`Recording ${id} not found for saving`);
return currentStatus;
}
if (currentStatus.status !== 'processing') {
logger.error(`Cannot attach artifact in ${currentStatus.status} state`);
return currentStatus;
}
return {
...currentStatus,
filepath,
sampleRate: sampleRate ?? currentStatus.sampleRate,
numberOfChannels: numberOfChannels ?? currentStatus.numberOfChannels,
};
}
/**
* Set the renderer-side block creation result
*/
private handleSetBlockCreationStatus(
id: number,
status: 'success' | 'failed',
errorMessage?: string
): RecordingStatus | null {
const currentStatus = this.recordingStatus$.value;
if (!currentStatus || currentStatus.id !== id) {
logger.error(`Recording ${id} not found for block creation status`);
return currentStatus;
}
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: 'ready',
blockCreationStatus: status,
};
}
/**
* Handle the REMOVE_RECORDING event
*/
private handleRemoveRecording(id: number): void {
// Actual recording removal logic would be handled by the caller
// This just ensures the state is updated correctly
logger.info(`Recording ${id} removed from state machine`);
}
}
// Create and export a singleton instance
export const recordingStateMachine = new RecordingStateMachine();
@@ -1,35 +1,70 @@
# Recording State Transitions
The desktop recording flow now has a single linear engine state and a separate post-process result.
The desktop recording flow now uses two independent lifecycle models:
## Engine states
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.
- `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
## Recording Session State
## Post-process result
- `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.
`ready` recordings may carry `blockCreationStatus`:
Only `starting`, `recording`, and `finalizing` occupy the active native slot.
`start_failed`, `finalized`, and `finalize_failed` no longer block the next recording.
- `success`: the recording block was created successfully
- `failed`: the artifact was saved, but block creation/import failed
## Recording Artifact Import State
## State flow
- `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 -> recording -> processing -> ready
^ |
| |
+------ start ---------+
inactive -> new -> starting -> recording -> finalizing -> finalized
\ \
\ -> finalize_failed
-> start_failed
```
- `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`.
- `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.
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.
## 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.
@@ -18,19 +18,103 @@ export interface AppGroupInfo {
isRunning: boolean;
}
export interface RecordingStatus {
id: number; // corresponds to the recording id
// 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;
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;
blockCreationStatus?: 'success' | 'failed';
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;
}
@@ -17,8 +17,8 @@ import {
appGroups$,
checkCanRecordMeeting,
checkRecordingAvailable,
getCurrentRecordingStatus,
MeetingsSettingsState,
recordingStatus$,
startRecording,
stopRecording,
updateApplicationsPing$,
@@ -158,9 +158,14 @@ class TrayState implements Disposable {
appGroup => appGroup.isRunning
);
const recordingStatus = recordingStatus$.value;
const recordingStatus = getCurrentRecordingStatus();
if (!recordingStatus || recordingStatus.status !== 'recording') {
if (
!recordingStatus ||
(recordingStatus.status !== 'starting' &&
recordingStatus.status !== 'recording' &&
recordingStatus.status !== 'finalizing')
) {
const appMenuItems = runningAppGroups.map(appGroup => ({
label: appGroup.name,
icon: appGroup.icon || undefined,
@@ -197,8 +202,8 @@ class TrayState implements Disposable {
...appMenuItems
);
} else {
const recordingLabel = recordingStatus.appGroup?.name
? `Recording (${recordingStatus.appGroup?.name})`
const recordingLabel = recordingStatus.appName
? `Recording (${recordingStatus.appName})`
: 'Recording';
// recording is active
@@ -210,11 +215,14 @@ class TrayState implements Disposable {
},
{
label: 'Stop',
disabled: recordingStatus.status !== 'recording',
click: () => {
logger.info('User action: Stop Recording');
stopRecording(recordingStatus.id).catch(err => {
logger.error('Failed to stop recording:', err);
});
if (recordingStatus.status === 'recording') {
stopRecording(recordingStatus.id).catch(err => {
logger.error('Failed to stop recording:', err);
});
}
},
}
);