feat(native): record encoding (#14188)

fix #13784 

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

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

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

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

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

* **Tests**
* New unit, integration, and e2e tests for recording, path guards,
dialogs, and workspace recovery.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-03-22 02:50:14 +08:00
committed by GitHub
parent 6a93566422
commit bcf2a51d41
44 changed files with 2921 additions and 1143 deletions
@@ -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();
});
});