mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-23 13:29:02 +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:
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user