mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-27 02:42:25 +08:00
feat(native): windows audio monitoring & recording (#12615)
fix AF-2692 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added comprehensive Windows support for audio and application capture, including real-time microphone usage detection, combined microphone and system audio recording, and application state monitoring. - The "meetings" setting is now enabled on Windows as well as macOS. - Conditional UI styling and attributes introduced for Windows environments in the Electron renderer. - **Bug Fixes** - Enhanced file path handling and validation for Windows in Electron file requests. - **Refactor** - Unified application info handling across platforms by consolidating types into a single `ApplicationInfo` structure. - Updated native module APIs by removing deprecated types, refining method signatures, and improving error messages. - Streamlined audio tapping APIs to use process IDs and consistent callback types. - **Documentation** - Added detailed documentation for the Windows-specific audio recording and microphone listener modules. - **Chores** - Updated development dependencies in multiple packages. - Reorganized and added platform-specific dependencies and configuration for Windows support. <!-- end of auto-generated comment: release notes by coderabbit.ai --> #### PR Dependency Tree * **PR #12615** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) --------- Co-authored-by: LongYinan <lynweklm@gmail.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { join } from 'node:path';
|
||||
import path, { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import { app, net, protocol, session } from 'electron';
|
||||
import cookieParser from 'set-cookie-parser';
|
||||
|
||||
import { resourcesPath } from '../shared/utils';
|
||||
import { isWindows, resourcesPath } from '../shared/utils';
|
||||
import { anotherHost, mainHost } from './constants';
|
||||
import { logger } from './logger';
|
||||
|
||||
@@ -77,17 +78,23 @@ async function handleFileRequest(request: Request) {
|
||||
}
|
||||
} else {
|
||||
filepath = decodeURIComponent(urlObject.pathname);
|
||||
// on windows, the path could be start with '/'
|
||||
if (isWindows()) {
|
||||
filepath = path.resolve(filepath.replace(/^\//, ''));
|
||||
}
|
||||
// security check if the filepath is within app.getPath('sessionData')
|
||||
const sessionDataPath = app.getPath('sessionData');
|
||||
const tempPath = app.getPath('temp');
|
||||
const sessionDataPath = path
|
||||
.resolve(app.getPath('sessionData'))
|
||||
.toLowerCase();
|
||||
const tempPath = path.resolve(app.getPath('temp')).toLowerCase();
|
||||
if (
|
||||
!filepath.startsWith(sessionDataPath) &&
|
||||
!filepath.startsWith(tempPath)
|
||||
!filepath.toLowerCase().startsWith(sessionDataPath) &&
|
||||
!filepath.toLowerCase().startsWith(tempPath)
|
||||
) {
|
||||
throw new Error('Invalid filepath');
|
||||
}
|
||||
}
|
||||
return net.fetch('file://' + filepath, clonedRequest);
|
||||
return net.fetch(pathToFileURL(filepath).toString(), clonedRequest);
|
||||
}
|
||||
|
||||
export function registerProtocol() {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/* 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 } from '@affine/native';
|
||||
import type { ShareableContent as ShareableContentType } from '@affine/native';
|
||||
import { app, systemPreferences } from 'electron';
|
||||
import fs from 'fs-extra';
|
||||
import { debounce } from 'lodash-es';
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
} from 'rxjs';
|
||||
import { filter, map, shareReplay } from 'rxjs/operators';
|
||||
|
||||
import { isMacOS, shallowEqual } from '../../shared/utils';
|
||||
import { isMacOS, isWindows, shallowEqual } from '../../shared/utils';
|
||||
import { beforeAppQuit } from '../cleanup';
|
||||
import { logger } from '../logger';
|
||||
import {
|
||||
@@ -64,7 +65,7 @@ export const SAVED_RECORDINGS_DIR = path.join(
|
||||
'recordings'
|
||||
);
|
||||
|
||||
let shareableContent: ShareableContent | null = null;
|
||||
let shareableContent: ShareableContentType | null = null;
|
||||
|
||||
function cleanup() {
|
||||
shareableContent = null;
|
||||
@@ -95,8 +96,10 @@ const recordings = new Map<number, Recording>();
|
||||
export const recordingStatus$ = recordingStateMachine.status$;
|
||||
|
||||
function createAppGroup(processGroupId: number): AppGroupInfo | undefined {
|
||||
const groupProcess =
|
||||
shareableContent?.applicationWithProcessId(processGroupId);
|
||||
// MUST require dynamically to avoid loading @affine/native for unsupported platforms
|
||||
const SC: typeof ShareableContentType =
|
||||
require('@affine/native').ShareableContent;
|
||||
const groupProcess = SC?.applicationWithProcessId(processGroupId);
|
||||
if (!groupProcess) {
|
||||
return;
|
||||
}
|
||||
@@ -239,15 +242,30 @@ 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,
|
||||
`${status.appGroup?.bundleIdentifier ?? 'unknown'}-${status.id}-${status.startTime}.raw`
|
||||
`${appId}-${status.id}-${status.startTime}.raw`
|
||||
);
|
||||
|
||||
fs.ensureDirSync(SAVED_RECORDINGS_DIR);
|
||||
@@ -273,11 +291,12 @@ export function createRecording(status: RecordingStatus) {
|
||||
}
|
||||
|
||||
// MUST require dynamically to avoid loading @affine/native for unsupported platforms
|
||||
const ShareableContent = require('@affine/native').ShareableContent;
|
||||
const SC: typeof ShareableContentType =
|
||||
require('@affine/native').ShareableContent;
|
||||
|
||||
const stream = status.app
|
||||
? status.app.rawInstance.tapAudio(tapAudioSamples)
|
||||
: ShareableContent.tapGlobalAudio(null, tapAudioSamples);
|
||||
? SC.tapAudio(status.app.processId, tapAudioSamples)
|
||||
: SC.tapGlobalAudio(null, tapAudioSamples);
|
||||
|
||||
recording = {
|
||||
id: status.id,
|
||||
@@ -379,15 +398,24 @@ function getAllApps(): TappableAppInfo[] {
|
||||
if (!shareableContent) {
|
||||
return [];
|
||||
}
|
||||
const apps = shareableContent.applications().map(app => {
|
||||
|
||||
// MUST require dynamically to avoid loading @affine/native for unsupported platforms
|
||||
const { ShareableContent } = require('@affine/native') as {
|
||||
ShareableContent: typeof ShareableContentType;
|
||||
};
|
||||
|
||||
const apps = ShareableContent.applications().map(app => {
|
||||
try {
|
||||
// Check if this process is actively using microphone/audio
|
||||
const isRunning = ShareableContent.isUsingMicrophone(app.processId);
|
||||
|
||||
return {
|
||||
rawInstance: app,
|
||||
info: app,
|
||||
processId: app.processId,
|
||||
processGroupId: app.processGroupId,
|
||||
bundleIdentifier: app.bundleIdentifier,
|
||||
name: app.name,
|
||||
isRunning: app.isRunning,
|
||||
isRunning,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('failed to get app info', error);
|
||||
@@ -441,15 +469,15 @@ function setupMediaListeners() {
|
||||
|
||||
apps.forEach(app => {
|
||||
try {
|
||||
const tappableApp = app.rawInstance;
|
||||
const applicationInfo = app.info;
|
||||
_appStateSubscribers.push(
|
||||
ShareableContent.onAppStateChanged(tappableApp, () => {
|
||||
ShareableContent.onAppStateChanged(applicationInfo, () => {
|
||||
updateApplicationsPing$.next(Date.now());
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to convert app ${app.name} to TappableApplication`,
|
||||
`Failed to set up app state listener for ${app.name}`,
|
||||
error
|
||||
);
|
||||
}
|
||||
@@ -668,15 +696,18 @@ export async function readyRecording(id: number, buffer: Buffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filepath = path.join(
|
||||
SAVED_RECORDINGS_DIR,
|
||||
`${recordingStatus.appGroup?.bundleIdentifier ?? 'unknown'}-${recordingStatus.id}-${recordingStatus.startTime}.opus`
|
||||
);
|
||||
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
|
||||
const rawFilePath = recording.file.path;
|
||||
logger.info('remove raw file', rawFilePath);
|
||||
if (rawFilePath) {
|
||||
try {
|
||||
@@ -768,14 +799,24 @@ export const getMacOSVersion = () => {
|
||||
|
||||
// check if the system is MacOS and the version is >= 14.2
|
||||
export const checkRecordingAvailable = () => {
|
||||
if (!isMacOS()) {
|
||||
return false;
|
||||
if (isMacOS()) {
|
||||
const version = getMacOSVersion();
|
||||
return (version.major === 14 && version.minor >= 2) || version.major > 14;
|
||||
}
|
||||
const version = getMacOSVersion();
|
||||
return (version.major === 14 && version.minor >= 2) || version.major > 14;
|
||||
if (isWindows()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const checkMeetingPermissions = () => {
|
||||
if (isWindows()) {
|
||||
return {
|
||||
screen: true,
|
||||
microphone: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isMacOS()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { WriteStream } from 'node:fs';
|
||||
|
||||
import type { AudioCaptureSession, TappableApplication } from '@affine/native';
|
||||
import type { ApplicationInfo, AudioCaptureSession } from '@affine/native';
|
||||
|
||||
export interface TappableAppInfo {
|
||||
rawInstance: TappableApplication;
|
||||
info: ApplicationInfo;
|
||||
isRunning: boolean;
|
||||
processId: number;
|
||||
processGroupId: number;
|
||||
|
||||
@@ -77,6 +77,7 @@ abstract class PopupWindow {
|
||||
closable: false,
|
||||
alwaysOnTop: true,
|
||||
hiddenInMissionControl: true,
|
||||
skipTaskbar: true,
|
||||
movable: false,
|
||||
titleBarStyle: 'hidden',
|
||||
show: false, // hide by default,
|
||||
@@ -243,6 +244,8 @@ export class PopupManager {
|
||||
return new NotificationPopupWindow() as PopupWindowTypeMap[T];
|
||||
case 'recording':
|
||||
return new RecordingPopupWindow() as PopupWindowTypeMap[T];
|
||||
default:
|
||||
throw new Error(`Unknown popup type: ${type}`);
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user