mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-24 22:09:08 +08:00
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:
@@ -433,7 +433,9 @@ export const NbStoreNativeDBApis: NativeDBApis = {
|
||||
id: string,
|
||||
docId: string
|
||||
): Promise<DocIndexedClock | null> {
|
||||
return NbStore.getDocIndexedClock({ id, docId });
|
||||
return NbStore.getDocIndexedClock({ id, docId }).then(clock =>
|
||||
clock ? { ...clock, timestamp: new Date(clock.timestamp) } : null
|
||||
);
|
||||
},
|
||||
setDocIndexedClock: function (
|
||||
id: string,
|
||||
|
||||
@@ -13,6 +13,19 @@ import type { FrameworkProvider } from '@toeverything/infra';
|
||||
import { getCurrentWorkspace, isAiEnabled } from './utils';
|
||||
|
||||
const logger = new DebugLogger('electron-renderer:recording');
|
||||
const RECORDING_PROCESS_RETRY_MS = 1000;
|
||||
const NATIVE_RECORDING_MIME_TYPE = 'audio/ogg';
|
||||
|
||||
type ProcessingRecordingStatus = {
|
||||
id: number;
|
||||
status: 'processing';
|
||||
appName?: string;
|
||||
blockCreationStatus?: undefined;
|
||||
filepath: string;
|
||||
startTime: number;
|
||||
};
|
||||
|
||||
type WorkspaceHandle = NonNullable<ReturnType<typeof getCurrentWorkspace>>;
|
||||
|
||||
async function readRecordingFile(filepath: string) {
|
||||
if (apis?.recording?.readRecordingFile) {
|
||||
@@ -45,118 +58,217 @@ async function saveRecordingBlob(blobEngine: BlobEngine, filepath: string) {
|
||||
logger.debug('Saving recording', filepath);
|
||||
const opusBuffer = await readRecordingFile(filepath);
|
||||
const blob = new Blob([opusBuffer], {
|
||||
type: 'audio/mp4',
|
||||
type: NATIVE_RECORDING_MIME_TYPE,
|
||||
});
|
||||
const blobId = await blobEngine.set(blob);
|
||||
logger.debug('Recording saved', blobId);
|
||||
return { blob, blobId };
|
||||
}
|
||||
|
||||
export function setupRecordingEvents(frameworkProvider: FrameworkProvider) {
|
||||
events?.recording.onRecordingStatusChanged(status => {
|
||||
(async () => {
|
||||
if ((await apis?.ui.isActiveTab()) && status?.status === 'ready') {
|
||||
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
|
||||
if (!currentWorkspace) {
|
||||
// maybe the workspace is not ready yet, eg. for shared workspace view
|
||||
await apis?.recording.handleBlockCreationFailed(status.id);
|
||||
return;
|
||||
}
|
||||
const { workspace } = currentWorkspace;
|
||||
const docsService = workspace.scope.get(DocsService);
|
||||
const aiEnabled = isAiEnabled(frameworkProvider);
|
||||
function shouldProcessRecording(
|
||||
status: unknown
|
||||
): status is ProcessingRecordingStatus {
|
||||
return (
|
||||
!!status &&
|
||||
typeof status === 'object' &&
|
||||
'status' in status &&
|
||||
status.status === 'processing' &&
|
||||
'filepath' in status &&
|
||||
typeof status.filepath === 'string' &&
|
||||
!('blockCreationStatus' in status && status.blockCreationStatus)
|
||||
);
|
||||
}
|
||||
|
||||
const timestamp = i18nTime(status.startTime, {
|
||||
absolute: {
|
||||
accuracy: 'minute',
|
||||
noYear: true,
|
||||
},
|
||||
});
|
||||
async function createRecordingDoc(
|
||||
frameworkProvider: FrameworkProvider,
|
||||
workspace: WorkspaceHandle['workspace'],
|
||||
status: ProcessingRecordingStatus
|
||||
) {
|
||||
const docsService = workspace.scope.get(DocsService);
|
||||
const aiEnabled = isAiEnabled(frameworkProvider);
|
||||
const recordingFilepath = status.filepath;
|
||||
|
||||
const docProps: DocProps = {
|
||||
onStoreLoad: (doc, { noteId }) => {
|
||||
(async () => {
|
||||
if (status.filepath) {
|
||||
// it takes a while to save the blob, so we show the attachment first
|
||||
const { blobId, blob } = await saveRecordingBlob(
|
||||
doc.workspace.blobSync,
|
||||
status.filepath
|
||||
);
|
||||
const timestamp = i18nTime(status.startTime, {
|
||||
absolute: {
|
||||
accuracy: 'minute',
|
||||
noYear: true,
|
||||
},
|
||||
});
|
||||
|
||||
// name + timestamp(readable) + extension
|
||||
const attachmentName =
|
||||
(status.appName ?? 'System Audio') +
|
||||
' ' +
|
||||
timestamp +
|
||||
'.opus';
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const docProps: DocProps = {
|
||||
onStoreLoad: (doc, { noteId }) => {
|
||||
void (async () => {
|
||||
// it takes a while to save the blob, so we show the attachment first
|
||||
const { blobId, blob } = await saveRecordingBlob(
|
||||
doc.workspace.blobSync,
|
||||
recordingFilepath
|
||||
);
|
||||
|
||||
// add size and sourceId to the attachment later
|
||||
const attachmentId = doc.addBlock(
|
||||
'affine:attachment',
|
||||
{
|
||||
name: attachmentName,
|
||||
type: 'audio/opus',
|
||||
size: blob.size,
|
||||
sourceId: blobId,
|
||||
embed: true,
|
||||
},
|
||||
noteId
|
||||
);
|
||||
// name + timestamp(readable) + extension
|
||||
const attachmentName =
|
||||
(status.appName ?? 'System Audio') + ' ' + timestamp + '.opus';
|
||||
|
||||
const model = doc.getBlock(attachmentId)
|
||||
?.model as AttachmentBlockModel;
|
||||
const attachmentId = doc.addBlock(
|
||||
'affine:attachment',
|
||||
{
|
||||
name: attachmentName,
|
||||
type: NATIVE_RECORDING_MIME_TYPE,
|
||||
size: blob.size,
|
||||
sourceId: blobId,
|
||||
embed: true,
|
||||
},
|
||||
noteId
|
||||
);
|
||||
|
||||
if (!aiEnabled) {
|
||||
return;
|
||||
}
|
||||
const model = doc.getBlock(attachmentId)
|
||||
?.model as AttachmentBlockModel;
|
||||
|
||||
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
|
||||
if (!currentWorkspace) {
|
||||
return;
|
||||
}
|
||||
const { workspace } = currentWorkspace;
|
||||
using audioAttachment = workspace.scope
|
||||
.get(AudioAttachmentService)
|
||||
.get(model);
|
||||
audioAttachment?.obj
|
||||
.transcribe()
|
||||
.then(() => {
|
||||
track.doc.editor.audioBlock.transcribeRecording({
|
||||
type: 'Meeting record',
|
||||
method: 'success',
|
||||
option: 'Auto transcribing',
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error('Failed to transcribe recording', err);
|
||||
});
|
||||
} else {
|
||||
throw new Error('No attachment model found');
|
||||
}
|
||||
})()
|
||||
.then(async () => {
|
||||
await apis?.recording.handleBlockCreationSuccess(status.id);
|
||||
})
|
||||
.catch(error => {
|
||||
logger.error('Failed to transcribe recording', error);
|
||||
return apis?.recording.handleBlockCreationFailed(
|
||||
status.id,
|
||||
error
|
||||
);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('unknown error', error);
|
||||
if (!aiEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
|
||||
if (!currentWorkspace) {
|
||||
return;
|
||||
}
|
||||
const { workspace } = currentWorkspace;
|
||||
using audioAttachment = workspace.scope
|
||||
.get(AudioAttachmentService)
|
||||
.get(model);
|
||||
audioAttachment?.obj
|
||||
.transcribe()
|
||||
.then(() => {
|
||||
track.doc.editor.audioBlock.transcribeRecording({
|
||||
type: 'Meeting record',
|
||||
method: 'success',
|
||||
option: 'Auto transcribing',
|
||||
});
|
||||
},
|
||||
};
|
||||
const page = docsService.createDoc({
|
||||
docProps,
|
||||
title:
|
||||
'Recording ' + (status.appName ?? 'System Audio') + ' ' + timestamp,
|
||||
primaryMode: 'page',
|
||||
});
|
||||
workspace.scope.get(WorkbenchService).workbench.openDoc(page.id);
|
||||
}
|
||||
})().catch(console.error);
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error('Failed to transcribe recording', err);
|
||||
});
|
||||
})().then(resolve, reject);
|
||||
},
|
||||
};
|
||||
|
||||
const page = docsService.createDoc({
|
||||
docProps,
|
||||
title:
|
||||
'Recording ' + (status.appName ?? 'System Audio') + ' ' + timestamp,
|
||||
primaryMode: 'page',
|
||||
});
|
||||
workspace.scope.get(WorkbenchService).workbench.openDoc(page.id);
|
||||
});
|
||||
}
|
||||
|
||||
export function setupRecordingEvents(frameworkProvider: FrameworkProvider) {
|
||||
let pendingStatus: ProcessingRecordingStatus | null = null;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let processingStatusId: number | null = null;
|
||||
|
||||
const clearRetry = () => {
|
||||
if (retryTimer !== null) {
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearPending = (id?: number) => {
|
||||
if (id === undefined || pendingStatus?.id === id) {
|
||||
pendingStatus = null;
|
||||
clearRetry();
|
||||
}
|
||||
if (id === undefined || processingStatusId === id) {
|
||||
processingStatusId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleRetry = () => {
|
||||
if (!pendingStatus || retryTimer !== null) {
|
||||
return;
|
||||
}
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
void processPendingStatus().catch(console.error);
|
||||
}, RECORDING_PROCESS_RETRY_MS);
|
||||
};
|
||||
|
||||
const processPendingStatus = async () => {
|
||||
const status = pendingStatus;
|
||||
if (!status || processingStatusId === status.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isActiveTab = false;
|
||||
try {
|
||||
isActiveTab = !!(await apis?.ui.isActiveTab());
|
||||
} catch (error) {
|
||||
logger.error('Failed to probe active recording tab', error);
|
||||
scheduleRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isActiveTab) {
|
||||
scheduleRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
|
||||
if (!currentWorkspace) {
|
||||
// Workspace can lag behind the post-recording status update for a short
|
||||
// time; keep retrying instead of permanently failing the import.
|
||||
scheduleRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
processingStatusId = status.id;
|
||||
|
||||
try {
|
||||
await createRecordingDoc(
|
||||
frameworkProvider,
|
||||
currentWorkspace.workspace,
|
||||
status
|
||||
);
|
||||
await apis?.recording.setRecordingBlockCreationStatus(
|
||||
status.id,
|
||||
'success'
|
||||
);
|
||||
clearPending(status.id);
|
||||
} catch (error) {
|
||||
logger.error('Failed to create recording block', error);
|
||||
try {
|
||||
await apis?.recording.setRecordingBlockCreationStatus(
|
||||
status.id,
|
||||
'failed',
|
||||
error instanceof Error ? error.message : undefined
|
||||
);
|
||||
} finally {
|
||||
clearPending(status.id);
|
||||
}
|
||||
} finally {
|
||||
if (pendingStatus?.id === status.id) {
|
||||
processingStatusId = null;
|
||||
scheduleRetry();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
events?.recording.onRecordingStatusChanged(status => {
|
||||
if (shouldProcessRecording(status)) {
|
||||
pendingStatus = status;
|
||||
clearRetry();
|
||||
void processPendingStatus().catch(console.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
clearPending();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingStatus?.id === status.id) {
|
||||
clearPending(status.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
import { Button } from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { appIconMap } from '@affine/core/utils';
|
||||
import {
|
||||
createStreamEncoder,
|
||||
encodeRawBufferToOpus,
|
||||
type OpusStreamEncoder,
|
||||
} from '@affine/core/utils/opus-encoding';
|
||||
import { apis, events } from '@affine/electron-api';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import track from '@affine/track';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
type Status = {
|
||||
id: number;
|
||||
status:
|
||||
| 'new'
|
||||
| 'recording'
|
||||
| 'paused'
|
||||
| 'stopped'
|
||||
| 'ready'
|
||||
| 'create-block-success'
|
||||
| 'create-block-failed';
|
||||
status: 'new' | 'recording' | 'processing' | 'ready';
|
||||
blockCreationStatus?: 'success' | 'failed';
|
||||
appName?: string;
|
||||
appGroupId?: number;
|
||||
icon?: Buffer;
|
||||
@@ -58,6 +47,7 @@ const appIcon = appIconMap[BUILD_CONFIG.appBuildType];
|
||||
|
||||
export function Recording() {
|
||||
const status = useRecordingStatus();
|
||||
const trackedNewRecordingIdsRef = useRef<Set<number>>(new Set());
|
||||
|
||||
const t = useI18n();
|
||||
const textElement = useMemo(() => {
|
||||
@@ -66,14 +56,19 @@ export function Recording() {
|
||||
}
|
||||
if (status.status === 'new') {
|
||||
return t['com.affine.recording.new']();
|
||||
} else if (status.status === 'create-block-success') {
|
||||
} else if (
|
||||
status.status === 'ready' &&
|
||||
status.blockCreationStatus === 'success'
|
||||
) {
|
||||
return t['com.affine.recording.success.prompt']();
|
||||
} else if (status.status === 'create-block-failed') {
|
||||
} else if (
|
||||
status.status === 'ready' &&
|
||||
status.blockCreationStatus === 'failed'
|
||||
) {
|
||||
return t['com.affine.recording.failed.prompt']();
|
||||
} else if (
|
||||
status.status === 'recording' ||
|
||||
status.status === 'ready' ||
|
||||
status.status === 'stopped'
|
||||
status.status === 'processing'
|
||||
) {
|
||||
if (status.appName) {
|
||||
return t['com.affine.recording.recording']({
|
||||
@@ -105,106 +100,16 @@ export function Recording() {
|
||||
await apis?.recording?.stopRecording(status.id);
|
||||
}, [status]);
|
||||
|
||||
const handleProcessStoppedRecording = useAsyncCallback(
|
||||
async (currentStreamEncoder?: OpusStreamEncoder) => {
|
||||
let id: number | undefined;
|
||||
try {
|
||||
const result = await apis?.recording?.getCurrentRecording();
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
id = result.id;
|
||||
|
||||
const { filepath, sampleRate, numberOfChannels } = result;
|
||||
if (!filepath || !sampleRate || !numberOfChannels) {
|
||||
return;
|
||||
}
|
||||
const [buffer] = await Promise.all([
|
||||
currentStreamEncoder
|
||||
? currentStreamEncoder.finish()
|
||||
: encodeRawBufferToOpus({
|
||||
filepath,
|
||||
sampleRate,
|
||||
numberOfChannels,
|
||||
}),
|
||||
new Promise<void>(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, 500); // wait at least 500ms for better user experience
|
||||
}),
|
||||
]);
|
||||
await apis?.recording.readyRecording(result.id, buffer);
|
||||
} catch (error) {
|
||||
console.error('Failed to stop recording', error);
|
||||
await apis?.popup?.dismissCurrentRecording();
|
||||
if (id) {
|
||||
await apis?.recording.removeRecording(id);
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let removed = false;
|
||||
let currentStreamEncoder: OpusStreamEncoder | undefined;
|
||||
if (!status || status.status !== 'new') return;
|
||||
if (trackedNewRecordingIdsRef.current.has(status.id)) return;
|
||||
|
||||
apis?.recording
|
||||
.getCurrentRecording()
|
||||
.then(status => {
|
||||
if (status) {
|
||||
return handleRecordingStatusChanged(status);
|
||||
}
|
||||
return;
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
const handleRecordingStatusChanged = async (status: Status) => {
|
||||
if (removed) {
|
||||
return;
|
||||
}
|
||||
if (status?.status === 'new') {
|
||||
track.popup.$.recordingBar.toggleRecordingBar({
|
||||
type: 'Meeting record',
|
||||
appName: status.appName || 'System Audio',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
status?.status === 'recording' &&
|
||||
status.sampleRate &&
|
||||
status.numberOfChannels &&
|
||||
(!currentStreamEncoder || currentStreamEncoder.id !== status.id)
|
||||
) {
|
||||
currentStreamEncoder?.close();
|
||||
currentStreamEncoder = createStreamEncoder(status.id, {
|
||||
sampleRate: status.sampleRate,
|
||||
numberOfChannels: status.numberOfChannels,
|
||||
});
|
||||
currentStreamEncoder.poll().catch(console.error);
|
||||
}
|
||||
|
||||
if (status?.status === 'stopped') {
|
||||
handleProcessStoppedRecording(currentStreamEncoder);
|
||||
currentStreamEncoder = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// allow processing stopped event in tray menu as well:
|
||||
const unsubscribe = events?.recording.onRecordingStatusChanged(status => {
|
||||
if (status) {
|
||||
handleRecordingStatusChanged(status).catch(console.error);
|
||||
}
|
||||
trackedNewRecordingIdsRef.current.add(status.id);
|
||||
track.popup.$.recordingBar.toggleRecordingBar({
|
||||
type: 'Meeting record',
|
||||
appName: status.appName || 'System Audio',
|
||||
});
|
||||
|
||||
return () => {
|
||||
removed = true;
|
||||
unsubscribe?.();
|
||||
currentStreamEncoder?.close();
|
||||
};
|
||||
}, [handleProcessStoppedRecording]);
|
||||
}, [status]);
|
||||
|
||||
const handleStartRecording = useAsyncCallback(async () => {
|
||||
if (!status) {
|
||||
@@ -249,7 +154,10 @@ export function Recording() {
|
||||
{t['com.affine.recording.stop']()}
|
||||
</Button>
|
||||
);
|
||||
} else if (status.status === 'stopped' || status.status === 'ready') {
|
||||
} else if (
|
||||
status.status === 'processing' ||
|
||||
(status.status === 'ready' && !status.blockCreationStatus)
|
||||
) {
|
||||
return (
|
||||
<Button
|
||||
variant="error"
|
||||
@@ -258,13 +166,19 @@ export function Recording() {
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
} else if (status.status === 'create-block-success') {
|
||||
} else if (
|
||||
status.status === 'ready' &&
|
||||
status.blockCreationStatus === 'success'
|
||||
) {
|
||||
return (
|
||||
<Button variant="primary" onClick={handleDismiss}>
|
||||
{t['com.affine.recording.success.button']()}
|
||||
</Button>
|
||||
);
|
||||
} else if (status.status === 'create-block-failed') {
|
||||
} else if (
|
||||
status.status === 'ready' &&
|
||||
status.blockCreationStatus === 'failed'
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<Button variant="plain" onClick={handleDismiss}>
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -21,6 +21,11 @@ const docSetSpaceId = vi.fn();
|
||||
const sqliteValidate = vi.fn();
|
||||
const sqliteValidateImportSchema = vi.fn();
|
||||
const sqliteVacuumInto = vi.fn();
|
||||
const sqliteClose = vi.fn();
|
||||
const showOpenDialog = vi.fn();
|
||||
const showSaveDialog = vi.fn();
|
||||
const showItemInFolder = vi.fn(async () => undefined);
|
||||
const getPath = vi.fn();
|
||||
|
||||
vi.doMock('nanoid', () => ({
|
||||
nanoid: () => 'workspace-1',
|
||||
@@ -70,6 +75,10 @@ vi.doMock('@affine/native', () => {
|
||||
vacuumInto(path: string) {
|
||||
return sqliteVacuumInto(this.path, path);
|
||||
}
|
||||
|
||||
close() {
|
||||
return sqliteClose(this.path);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -84,7 +93,10 @@ vi.doMock('@affine/electron/helper/nbstore', () => ({
|
||||
|
||||
vi.doMock('@affine/electron/helper/main-rpc', () => ({
|
||||
mainRPC: {
|
||||
showItemInFolder: vi.fn(),
|
||||
getPath,
|
||||
showItemInFolder,
|
||||
showOpenDialog,
|
||||
showSaveDialog,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -126,12 +138,11 @@ describe('dialog export', () => {
|
||||
realpath.mockImplementation(async path => path);
|
||||
getSpaceDBPath.mockResolvedValue(dbPath);
|
||||
move.mockResolvedValue(undefined);
|
||||
showSaveDialog.mockResolvedValue({ canceled: false, filePath: exportPath });
|
||||
|
||||
const { saveDBFileAs, setFakeDialogResult } =
|
||||
const { saveDBFileAs } =
|
||||
await import('@affine/electron/helper/dialog/dialog');
|
||||
|
||||
setFakeDialogResult({ filePath: exportPath });
|
||||
|
||||
const result = await saveDBFileAs(id, 'My Space');
|
||||
|
||||
expect(result).toEqual({ filePath: exportPath });
|
||||
@@ -151,12 +162,11 @@ describe('dialog export', () => {
|
||||
|
||||
pathExists.mockResolvedValue(false);
|
||||
getSpaceDBPath.mockResolvedValue(dbPath);
|
||||
showSaveDialog.mockResolvedValue({ canceled: false, filePath: dbPath });
|
||||
|
||||
const { saveDBFileAs, setFakeDialogResult } =
|
||||
const { saveDBFileAs } =
|
||||
await import('@affine/electron/helper/dialog/dialog');
|
||||
|
||||
setFakeDialogResult({ filePath: dbPath });
|
||||
|
||||
const result = await saveDBFileAs(id, 'My Space');
|
||||
|
||||
expect(result).toEqual({ error: 'DB_FILE_PATH_INVALID' });
|
||||
@@ -174,12 +184,11 @@ describe('dialog export', () => {
|
||||
path === exportPath ? dbPath : path
|
||||
);
|
||||
getSpaceDBPath.mockResolvedValue(dbPath);
|
||||
showSaveDialog.mockResolvedValue({ canceled: false, filePath: exportPath });
|
||||
|
||||
const { saveDBFileAs, setFakeDialogResult } =
|
||||
const { saveDBFileAs } =
|
||||
await import('@affine/electron/helper/dialog/dialog');
|
||||
|
||||
setFakeDialogResult({ filePath: exportPath });
|
||||
|
||||
const result = await saveDBFileAs(id, 'My Space');
|
||||
|
||||
expect(result).toEqual({ error: 'DB_FILE_PATH_INVALID' });
|
||||
@@ -193,6 +202,12 @@ describe('dialog import', () => {
|
||||
const originalPath = '/tmp/import.affine';
|
||||
const internalPath = '/app/workspaces/local/workspace-1/storage.db';
|
||||
|
||||
pathExists.mockResolvedValue(true);
|
||||
realpath.mockImplementation(async path => path);
|
||||
showOpenDialog.mockResolvedValue({
|
||||
canceled: false,
|
||||
filePaths: [originalPath],
|
||||
});
|
||||
getWorkspacesBasePath.mockResolvedValue('/app/workspaces');
|
||||
getSpaceDBPath.mockResolvedValue(internalPath);
|
||||
docValidate.mockResolvedValue(true);
|
||||
@@ -201,11 +216,9 @@ describe('dialog import', () => {
|
||||
docSetSpaceId.mockResolvedValue(undefined);
|
||||
ensureDir.mockResolvedValue(undefined);
|
||||
|
||||
const { loadDBFile, setFakeDialogResult } =
|
||||
const { loadDBFile } =
|
||||
await import('@affine/electron/helper/dialog/dialog');
|
||||
|
||||
setFakeDialogResult({ filePath: originalPath });
|
||||
|
||||
const result = await loadDBFile();
|
||||
|
||||
expect(result).toEqual({ workspaceId: 'workspace-1' });
|
||||
@@ -219,15 +232,19 @@ describe('dialog import', () => {
|
||||
test('loadDBFile rejects v2 imports with unexpected schema objects', async () => {
|
||||
const originalPath = '/tmp/import.affine';
|
||||
|
||||
pathExists.mockResolvedValue(true);
|
||||
realpath.mockImplementation(async path => path);
|
||||
showOpenDialog.mockResolvedValue({
|
||||
canceled: false,
|
||||
filePaths: [originalPath],
|
||||
});
|
||||
getWorkspacesBasePath.mockResolvedValue('/app/workspaces');
|
||||
docValidate.mockResolvedValue(true);
|
||||
docValidateImportSchema.mockResolvedValue(false);
|
||||
|
||||
const { loadDBFile, setFakeDialogResult } =
|
||||
const { loadDBFile } =
|
||||
await import('@affine/electron/helper/dialog/dialog');
|
||||
|
||||
setFakeDialogResult({ filePath: originalPath });
|
||||
|
||||
const result = await loadDBFile();
|
||||
|
||||
expect(result).toEqual({ error: 'DB_FILE_INVALID' });
|
||||
@@ -239,6 +256,12 @@ describe('dialog import', () => {
|
||||
const originalPath = '/tmp/import-v1.affine';
|
||||
const internalPath = '/app/workspaces/workspace-1/storage.db';
|
||||
|
||||
pathExists.mockResolvedValue(true);
|
||||
realpath.mockImplementation(async path => path);
|
||||
showOpenDialog.mockResolvedValue({
|
||||
canceled: false,
|
||||
filePaths: [originalPath],
|
||||
});
|
||||
getWorkspacesBasePath.mockResolvedValue('/app/workspaces');
|
||||
getWorkspaceDBPath.mockResolvedValue(internalPath);
|
||||
docValidate.mockResolvedValue(false);
|
||||
@@ -247,11 +270,9 @@ describe('dialog import', () => {
|
||||
sqliteVacuumInto.mockResolvedValue(undefined);
|
||||
ensureDir.mockResolvedValue(undefined);
|
||||
|
||||
const { loadDBFile, setFakeDialogResult } =
|
||||
const { loadDBFile } =
|
||||
await import('@affine/electron/helper/dialog/dialog');
|
||||
|
||||
setFakeDialogResult({ filePath: originalPath });
|
||||
|
||||
const result = await loadDBFile();
|
||||
|
||||
expect(result).toEqual({ workspaceId: 'workspace-1' });
|
||||
@@ -263,6 +284,57 @@ describe('dialog import', () => {
|
||||
id: 'workspace-1',
|
||||
mainDBPath: internalPath,
|
||||
});
|
||||
expect(sqliteClose).toHaveBeenCalledWith(originalPath);
|
||||
expect(copy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('loadDBFile closes v1 connection when schema validation fails', async () => {
|
||||
const originalPath = '/tmp/import-v1-invalid.affine';
|
||||
|
||||
pathExists.mockResolvedValue(true);
|
||||
realpath.mockImplementation(async path => path);
|
||||
showOpenDialog.mockResolvedValue({
|
||||
canceled: false,
|
||||
filePaths: [originalPath],
|
||||
});
|
||||
getWorkspacesBasePath.mockResolvedValue('/app/workspaces');
|
||||
docValidate.mockResolvedValue(false);
|
||||
sqliteValidate.mockResolvedValue('Valid');
|
||||
sqliteValidateImportSchema.mockResolvedValue(false);
|
||||
|
||||
const { loadDBFile } =
|
||||
await import('@affine/electron/helper/dialog/dialog');
|
||||
|
||||
const result = await loadDBFile();
|
||||
|
||||
expect(result).toEqual({ error: 'DB_FILE_INVALID' });
|
||||
expect(sqliteClose).toHaveBeenCalledWith(originalPath);
|
||||
expect(sqliteVacuumInto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('loadDBFile rejects normalized paths inside app data', async () => {
|
||||
const selectedPath = '/tmp/import.affine';
|
||||
const normalizedPath = '/app/workspaces/local/existing/storage.db';
|
||||
|
||||
pathExists.mockResolvedValue(true);
|
||||
realpath.mockImplementation(async path => {
|
||||
if (path === selectedPath) {
|
||||
return normalizedPath;
|
||||
}
|
||||
return path;
|
||||
});
|
||||
showOpenDialog.mockResolvedValue({
|
||||
canceled: false,
|
||||
filePaths: [selectedPath],
|
||||
});
|
||||
getWorkspacesBasePath.mockResolvedValue('/app/workspaces');
|
||||
|
||||
const { loadDBFile } =
|
||||
await import('@affine/electron/helper/dialog/dialog');
|
||||
|
||||
const result = await loadDBFile();
|
||||
|
||||
expect(result).toEqual({ error: 'DB_FILE_PATH_INVALID' });
|
||||
expect(docValidate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
assertPathComponent,
|
||||
normalizeWorkspaceIdForPath,
|
||||
resolveExistingPathInBase,
|
||||
resolvePathInBase,
|
||||
} from '../../src/shared/utils';
|
||||
|
||||
const tmpDir = path.join(os.tmpdir(), `affine-electron-utils-${randomUUID()}`);
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('path guards', () => {
|
||||
test('resolvePathInBase blocks sibling-prefix escapes', () => {
|
||||
const baseDir = path.join(tmpDir, 'recordings');
|
||||
|
||||
expect(() =>
|
||||
resolvePathInBase(baseDir, '../recordings-evil/file.opus', {
|
||||
label: 'directory',
|
||||
})
|
||||
).toThrow('Invalid directory');
|
||||
});
|
||||
|
||||
test.runIf(process.platform !== 'win32')(
|
||||
'resolveExistingPathInBase rejects symlink escapes',
|
||||
async () => {
|
||||
const baseDir = path.join(tmpDir, 'recordings');
|
||||
const outsideDir = path.join(tmpDir, 'outside');
|
||||
const outsideFile = path.join(outsideDir, 'secret.txt');
|
||||
const linkPath = path.join(baseDir, '1234567890abcdef.blob');
|
||||
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
await fs.mkdir(outsideDir, { recursive: true });
|
||||
await fs.writeFile(outsideFile, 'secret');
|
||||
await fs.symlink(outsideFile, linkPath);
|
||||
|
||||
await expect(
|
||||
resolveExistingPathInBase(baseDir, linkPath, {
|
||||
label: 'recording filepath',
|
||||
})
|
||||
).rejects.toThrow('Invalid recording filepath');
|
||||
}
|
||||
);
|
||||
|
||||
test('resolveExistingPathInBase falls back for missing descendants', async () => {
|
||||
const baseDir = path.join(tmpDir, 'recordings');
|
||||
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
const missingPath = path.join(
|
||||
await fs.realpath(baseDir),
|
||||
'pending',
|
||||
'recording.opus'
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveExistingPathInBase(baseDir, missingPath, {
|
||||
label: 'recording filepath',
|
||||
})
|
||||
).resolves.toBe(path.resolve(missingPath));
|
||||
});
|
||||
|
||||
test.runIf(process.platform !== 'win32')(
|
||||
'resolveExistingPathInBase preserves non-missing realpath errors',
|
||||
async () => {
|
||||
const baseDir = path.join(tmpDir, 'recordings');
|
||||
const loopPath = path.join(baseDir, 'loop.opus');
|
||||
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
await fs.symlink(path.basename(loopPath), loopPath);
|
||||
|
||||
await expect(
|
||||
resolveExistingPathInBase(baseDir, loopPath, {
|
||||
label: 'recording filepath',
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'ELOOP' });
|
||||
}
|
||||
);
|
||||
|
||||
test.each(['../../escape', 'nested/id'])(
|
||||
'assertPathComponent rejects invalid workspace id %s',
|
||||
input => {
|
||||
expect(() => assertPathComponent(input, 'workspace id')).toThrow(
|
||||
'Invalid workspace id'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
test.each([
|
||||
{ input: 'legacy:id*with?reserved.', expected: 'legacy_id_with_reserved' },
|
||||
{ input: 'safe-workspace', expected: 'safe-workspace' },
|
||||
])(
|
||||
'normalizeWorkspaceIdForPath maps $input to $expected on Windows',
|
||||
({ input, expected }) => {
|
||||
expect(normalizeWorkspaceIdForPath(input, { windows: true })).toBe(
|
||||
expected
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const isActiveTab = vi.fn();
|
||||
const readRecordingFile = vi.fn();
|
||||
const setRecordingBlockCreationStatus = vi.fn();
|
||||
const getCurrentWorkspace = vi.fn();
|
||||
const isAiEnabled = vi.fn();
|
||||
const transcribeRecording = vi.fn();
|
||||
|
||||
let onRecordingStatusChanged:
|
||||
| ((
|
||||
status: {
|
||||
id: number;
|
||||
status: 'processing';
|
||||
appName?: string;
|
||||
filepath?: string;
|
||||
startTime: number;
|
||||
blockCreationStatus?: 'success' | 'failed';
|
||||
} | null
|
||||
) => void)
|
||||
| undefined;
|
||||
|
||||
vi.mock('@affine/core/modules/doc', () => ({
|
||||
DocsService: class DocsService {},
|
||||
}));
|
||||
|
||||
vi.mock('@affine/core/modules/media/services/audio-attachment', () => ({
|
||||
AudioAttachmentService: class AudioAttachmentService {},
|
||||
}));
|
||||
|
||||
vi.mock('@affine/core/modules/workbench', () => ({
|
||||
WorkbenchService: class WorkbenchService {},
|
||||
}));
|
||||
|
||||
vi.mock('@affine/debug', () => ({
|
||||
DebugLogger: class DebugLogger {
|
||||
debug = vi.fn();
|
||||
error = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@affine/electron-api', () => ({
|
||||
apis: {
|
||||
ui: {
|
||||
isActiveTab,
|
||||
},
|
||||
recording: {
|
||||
readRecordingFile,
|
||||
setRecordingBlockCreationStatus,
|
||||
},
|
||||
},
|
||||
events: {
|
||||
recording: {
|
||||
onRecordingStatusChanged: vi.fn(
|
||||
(handler: typeof onRecordingStatusChanged) => {
|
||||
onRecordingStatusChanged = handler;
|
||||
return () => {
|
||||
onRecordingStatusChanged = undefined;
|
||||
};
|
||||
}
|
||||
),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@affine/i18n', () => ({
|
||||
i18nTime: vi.fn(() => 'Jan 1 09:00'),
|
||||
}));
|
||||
|
||||
vi.mock('@affine/track', () => ({
|
||||
default: {
|
||||
doc: {
|
||||
editor: {
|
||||
audioBlock: {
|
||||
transcribeRecording,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../electron-renderer/src/app/effects/utils', () => ({
|
||||
getCurrentWorkspace,
|
||||
isAiEnabled,
|
||||
}));
|
||||
|
||||
function createWorkspaceRef() {
|
||||
const blobSet = vi.fn(async () => 'blob-1');
|
||||
const addBlock = vi.fn(() => 'attachment-1');
|
||||
const getBlock = vi.fn(() => ({ model: { id: 'attachment-1' } }));
|
||||
const openDoc = vi.fn();
|
||||
|
||||
type MockDoc = {
|
||||
workspace: {
|
||||
blobSync: {
|
||||
set: typeof blobSet;
|
||||
};
|
||||
};
|
||||
addBlock: typeof addBlock;
|
||||
getBlock: typeof getBlock;
|
||||
};
|
||||
|
||||
type MockDocProps = {
|
||||
onStoreLoad: (doc: MockDoc, meta: { noteId: string }) => void;
|
||||
};
|
||||
|
||||
const createDoc = vi.fn(({ docProps }: { docProps: MockDocProps }) => {
|
||||
queueMicrotask(() => {
|
||||
docProps.onStoreLoad(
|
||||
{
|
||||
workspace: { blobSync: { set: blobSet } },
|
||||
addBlock,
|
||||
getBlock,
|
||||
},
|
||||
{ noteId: 'note-1' }
|
||||
);
|
||||
});
|
||||
|
||||
return { id: 'doc-1' };
|
||||
});
|
||||
|
||||
const scope = {
|
||||
get(token: { name?: string }) {
|
||||
switch (token.name) {
|
||||
case 'DocsService':
|
||||
return { createDoc };
|
||||
case 'WorkbenchService':
|
||||
return { workbench: { openDoc } };
|
||||
case 'AudioAttachmentService':
|
||||
return {
|
||||
get: () => ({
|
||||
obj: {
|
||||
transcribe: vi.fn(async () => undefined),
|
||||
},
|
||||
[Symbol.dispose]: vi.fn(),
|
||||
}),
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unexpected token: ${token.name}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const dispose = vi.fn();
|
||||
|
||||
return {
|
||||
ref: {
|
||||
workspace: { scope },
|
||||
dispose,
|
||||
[Symbol.dispose]: dispose,
|
||||
},
|
||||
createDoc,
|
||||
openDoc,
|
||||
blobSet,
|
||||
addBlock,
|
||||
getBlock,
|
||||
};
|
||||
}
|
||||
|
||||
describe('recording effect', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
onRecordingStatusChanged = undefined;
|
||||
readRecordingFile.mockResolvedValue(new Uint8Array([1, 2, 3]).buffer);
|
||||
setRecordingBlockCreationStatus.mockResolvedValue(undefined);
|
||||
isAiEnabled.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('retries processing until the active tab has a workspace', async () => {
|
||||
const workspace = createWorkspaceRef();
|
||||
|
||||
isActiveTab.mockResolvedValueOnce(false).mockResolvedValue(true);
|
||||
getCurrentWorkspace
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValue(workspace.ref);
|
||||
|
||||
const { setupRecordingEvents } =
|
||||
await import('../../../electron-renderer/src/app/effects/recording');
|
||||
|
||||
setupRecordingEvents({} as never);
|
||||
|
||||
onRecordingStatusChanged?.({
|
||||
id: 7,
|
||||
status: 'processing',
|
||||
appName: 'Zoom',
|
||||
filepath: '/tmp/meeting.opus',
|
||||
startTime: 1000,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(workspace.createDoc).not.toHaveBeenCalled();
|
||||
expect(setRecordingBlockCreationStatus).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(workspace.createDoc).not.toHaveBeenCalled();
|
||||
expect(setRecordingBlockCreationStatus).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(workspace.createDoc).toHaveBeenCalledTimes(1);
|
||||
expect(workspace.openDoc).toHaveBeenCalledWith('doc-1');
|
||||
expect(workspace.blobSet).toHaveBeenCalledTimes(1);
|
||||
const [savedBlob] = workspace.blobSet.mock.calls[0] ?? [];
|
||||
expect(savedBlob).toBeInstanceOf(Blob);
|
||||
expect((savedBlob as Blob).type).toBe('audio/ogg');
|
||||
expect(workspace.addBlock).toHaveBeenCalledWith(
|
||||
'affine:attachment',
|
||||
expect.objectContaining({ type: 'audio/ogg' }),
|
||||
'note-1'
|
||||
);
|
||||
expect(setRecordingBlockCreationStatus).toHaveBeenCalledWith(7, 'success');
|
||||
expect(setRecordingBlockCreationStatus).not.toHaveBeenCalledWith(
|
||||
7,
|
||||
'failed',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
test('retries when the active-tab probe rejects', async () => {
|
||||
const workspace = createWorkspaceRef();
|
||||
|
||||
isActiveTab
|
||||
.mockRejectedValueOnce(new Error('probe failed'))
|
||||
.mockResolvedValue(true);
|
||||
getCurrentWorkspace.mockReturnValue(workspace.ref);
|
||||
|
||||
const { setupRecordingEvents } =
|
||||
await import('../../../electron-renderer/src/app/effects/recording');
|
||||
|
||||
setupRecordingEvents({} as never);
|
||||
|
||||
onRecordingStatusChanged?.({
|
||||
id: 9,
|
||||
status: 'processing',
|
||||
appName: 'Meet',
|
||||
filepath: '/tmp/meeting.opus',
|
||||
startTime: 1000,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(workspace.createDoc).not.toHaveBeenCalled();
|
||||
expect(setRecordingBlockCreationStatus).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(workspace.createDoc).toHaveBeenCalledTimes(1);
|
||||
expect(setRecordingBlockCreationStatus).toHaveBeenCalledWith(9, 'success');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../src/main/logger', () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { RecordingStateMachine } from '../../src/main/recording/state-machine';
|
||||
|
||||
function createAttachedRecording(stateMachine: RecordingStateMachine) {
|
||||
const pending = stateMachine.dispatch({
|
||||
type: 'START_RECORDING',
|
||||
});
|
||||
|
||||
stateMachine.dispatch({
|
||||
type: 'ATTACH_NATIVE_RECORDING',
|
||||
id: pending!.id,
|
||||
nativeId: 'native-1',
|
||||
startTime: 100,
|
||||
filepath: '/tmp/recording.opus',
|
||||
sampleRate: 48000,
|
||||
numberOfChannels: 2,
|
||||
});
|
||||
|
||||
return pending!;
|
||||
}
|
||||
|
||||
describe('RecordingStateMachine', () => {
|
||||
test('transitions from recording to ready after artifact import and block creation', () => {
|
||||
const stateMachine = new RecordingStateMachine();
|
||||
|
||||
const pending = createAttachedRecording(stateMachine);
|
||||
expect(pending?.status).toBe('recording');
|
||||
|
||||
const processing = stateMachine.dispatch({
|
||||
type: 'STOP_RECORDING',
|
||||
id: pending.id,
|
||||
});
|
||||
expect(processing?.status).toBe('processing');
|
||||
|
||||
const artifactAttached = stateMachine.dispatch({
|
||||
type: 'ATTACH_RECORDING_ARTIFACT',
|
||||
id: pending.id,
|
||||
filepath: '/tmp/recording.opus',
|
||||
sampleRate: 48000,
|
||||
numberOfChannels: 2,
|
||||
});
|
||||
expect(artifactAttached).toMatchObject({
|
||||
status: 'processing',
|
||||
filepath: '/tmp/recording.opus',
|
||||
});
|
||||
|
||||
const ready = stateMachine.dispatch({
|
||||
type: 'SET_BLOCK_CREATION_STATUS',
|
||||
id: pending.id,
|
||||
status: 'success',
|
||||
});
|
||||
expect(ready).toMatchObject({
|
||||
status: 'ready',
|
||||
blockCreationStatus: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps native audio metadata when stop artifact omits it', () => {
|
||||
const stateMachine = new RecordingStateMachine();
|
||||
|
||||
const pending = createAttachedRecording(stateMachine);
|
||||
stateMachine.dispatch({ type: 'STOP_RECORDING', id: pending.id });
|
||||
|
||||
const artifactAttached = stateMachine.dispatch({
|
||||
type: 'ATTACH_RECORDING_ARTIFACT',
|
||||
id: pending.id,
|
||||
filepath: '/tmp/recording.opus',
|
||||
});
|
||||
|
||||
expect(artifactAttached).toMatchObject({
|
||||
sampleRate: 48000,
|
||||
numberOfChannels: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ status: 'success' as const, errorMessage: undefined },
|
||||
{ status: 'failed' as const, errorMessage: 'native start failed' },
|
||||
])(
|
||||
'settles recordings into ready state with blockCreationStatus=$status',
|
||||
({ status, errorMessage }) => {
|
||||
const stateMachine = new RecordingStateMachine();
|
||||
|
||||
const pending = stateMachine.dispatch({
|
||||
type: 'START_RECORDING',
|
||||
});
|
||||
expect(pending?.status).toBe('recording');
|
||||
|
||||
const settled = stateMachine.dispatch({
|
||||
type: 'SET_BLOCK_CREATION_STATUS',
|
||||
id: pending!.id,
|
||||
status,
|
||||
errorMessage,
|
||||
});
|
||||
expect(settled).toMatchObject({
|
||||
status: 'ready',
|
||||
blockCreationStatus: status,
|
||||
});
|
||||
|
||||
const next = stateMachine.dispatch({
|
||||
type: 'START_RECORDING',
|
||||
});
|
||||
expect(next?.id).toBeGreaterThan(pending!.id);
|
||||
expect(next?.status).toBe('recording');
|
||||
expect(next?.blockCreationStatus).toBeUndefined();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -131,4 +131,52 @@ describe('workspace db management', () => {
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects unsafe ids when deleting a workspace', async () => {
|
||||
const { deleteWorkspace } =
|
||||
await import('@affine/electron/helper/workspace/handlers');
|
||||
const outsideDir = path.join(tmpDir, 'outside-delete-target');
|
||||
|
||||
await fs.ensureDir(outsideDir);
|
||||
|
||||
await expect(
|
||||
deleteWorkspace(
|
||||
universalId({
|
||||
peer: 'local',
|
||||
type: 'workspace',
|
||||
id: '../../outside-delete-target',
|
||||
})
|
||||
)
|
||||
).rejects.toThrow('Invalid workspace id');
|
||||
|
||||
expect(await fs.pathExists(outsideDir)).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects unsafe ids when deleting backup workspaces', async () => {
|
||||
const { deleteBackupWorkspace } =
|
||||
await import('@affine/electron/helper/workspace/handlers');
|
||||
const outsideDir = path.join(tmpDir, 'outside-backup-target');
|
||||
|
||||
await fs.ensureDir(outsideDir);
|
||||
|
||||
await expect(
|
||||
deleteBackupWorkspace('../../outside-backup-target')
|
||||
).rejects.toThrow('Invalid workspace id');
|
||||
|
||||
expect(await fs.pathExists(outsideDir)).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects unsafe ids when recovering backup workspaces', async () => {
|
||||
const { recoverBackupWorkspace } =
|
||||
await import('@affine/electron/helper/workspace/handlers');
|
||||
const outsideDir = path.join(tmpDir, 'outside-recover-target');
|
||||
|
||||
await fs.ensureDir(outsideDir);
|
||||
|
||||
await expect(
|
||||
recoverBackupWorkspace('../../outside-recover-target')
|
||||
).rejects.toThrow('Invalid workspace id');
|
||||
|
||||
expect(await fs.pathExists(outsideDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -433,7 +433,9 @@ export const NbStoreNativeDBApis: NativeDBApis = {
|
||||
id: string,
|
||||
docId: string
|
||||
): Promise<DocIndexedClock | null> {
|
||||
return NbStore.getDocIndexedClock({ id, docId });
|
||||
return NbStore.getDocIndexedClock({ id, docId }).then(clock =>
|
||||
clock ? { ...clock, timestamp: new Date(clock.timestamp) } : null
|
||||
);
|
||||
},
|
||||
setDocIndexedClock: function (
|
||||
id: string,
|
||||
|
||||
Reference in New Issue
Block a user