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:
Peng Xiao
2025-06-18 13:57:01 +08:00
committed by GitHub
parent c844786a7f
commit 899ffd1ad3
27 changed files with 2509 additions and 458 deletions

View File

@@ -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() {

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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}`);
}
})();