fix(native): split application & tappable application (#10491)

A listening tappable app's info should inherit from its group process's name/icon. However the group process may not be listed as a tappable application.
This commit is contained in:
pengx17
2025-02-27 15:02:38 +00:00
parent c50184bee6
commit 9e0cae58d7
22 changed files with 975 additions and 313 deletions

View File

@@ -19,6 +19,7 @@ import {
import { configureFindInPageModule } from '@affine/core/modules/find-in-page'; import { configureFindInPageModule } from '@affine/core/modules/find-in-page';
import { GlobalContextService } from '@affine/core/modules/global-context'; import { GlobalContextService } from '@affine/core/modules/global-context';
import { I18nProvider } from '@affine/core/modules/i18n'; import { I18nProvider } from '@affine/core/modules/i18n';
import { JournalService } from '@affine/core/modules/journal';
import { LifecycleService } from '@affine/core/modules/lifecycle'; import { LifecycleService } from '@affine/core/modules/lifecycle';
import { import {
configureElectronStateStorageImpls, configureElectronStateStorageImpls,
@@ -146,7 +147,8 @@ events?.applicationMenu.openAboutPageInSettingModal(() =>
activeTab: 'about', activeTab: 'about',
}) })
); );
events?.applicationMenu.onNewPageAction(() => {
function getCurrentWorkspace() {
const currentWorkspaceId = frameworkProvider const currentWorkspaceId = frameworkProvider
.get(GlobalContextService) .get(GlobalContextService)
.globalContext.workspaceId.get(); .globalContext.workspaceId.get();
@@ -158,6 +160,19 @@ events?.applicationMenu.onNewPageAction(() => {
return; return;
} }
const { workspace, dispose } = workspaceRef; const { workspace, dispose } = workspaceRef;
return {
workspace,
dispose,
};
}
events?.applicationMenu.onNewPageAction(type => {
const currentWorkspace = getCurrentWorkspace();
if (!currentWorkspace) {
return;
}
const { workspace, dispose } = currentWorkspace;
const editorSettingService = frameworkProvider.get(EditorSettingService); const editorSettingService = frameworkProvider.get(EditorSettingService);
const docsService = workspace.scope.get(DocsService); const docsService = workspace.scope.get(DocsService);
const editorSetting = editorSettingService.editorSetting; const editorSetting = editorSettingService.editorSetting;
@@ -171,7 +186,7 @@ events?.applicationMenu.onNewPageAction(() => {
if (!isActive) { if (!isActive) {
return; return;
} }
const page = docsService.createDoc({ docProps }); const page = docsService.createDoc({ docProps, primaryMode: type });
workspace.scope.get(WorkbenchService).workbench.openDoc(page.id); workspace.scope.get(WorkbenchService).workbench.openDoc(page.id);
}) })
.catch(err => { .catch(err => {
@@ -181,6 +196,21 @@ events?.applicationMenu.onNewPageAction(() => {
dispose(); dispose();
}); });
events?.applicationMenu.onOpenJournal(() => {
const currentWorkspace = getCurrentWorkspace();
if (!currentWorkspace) {
return;
}
const { workspace, dispose } = currentWorkspace;
const workbench = workspace.scope.get(WorkbenchService).workbench;
const journalService = workspace.scope.get(JournalService);
const docId = journalService.ensureJournalByDate(new Date()).id;
workbench.openDoc(docId);
dispose();
});
export function App() { export function App() {
return ( return (
<Suspense> <Suspense>

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -64,7 +64,7 @@ export function createApplicationMenu() {
click: async () => { click: async () => {
await initAndShowMainWindow(); await initAndShowMainWindow();
// fixme: if the window is just created, the new page action will not be triggered // fixme: if the window is just created, the new page action will not be triggered
applicationMenuSubjects.newPageAction$.next(); applicationMenuSubjects.newPageAction$.next('page');
}, },
}, },
], ],

View File

@@ -11,7 +11,7 @@ export const applicationMenuEvents = {
/** /**
* File -> New Doc * File -> New Doc
*/ */
onNewPageAction: (fn: () => void) => { onNewPageAction: (fn: (type: 'page' | 'edgeless') => void) => {
const sub = applicationMenuSubjects.newPageAction$.subscribe(fn); const sub = applicationMenuSubjects.newPageAction$.subscribe(fn);
return () => { return () => {
sub.unsubscribe(); sub.unsubscribe();
@@ -24,4 +24,10 @@ export const applicationMenuEvents = {
sub.unsubscribe(); sub.unsubscribe();
}; };
}, },
onOpenJournal: (fn: () => void) => {
const sub = applicationMenuSubjects.openJournal$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
} satisfies Record<string, MainEventRegister>; } satisfies Record<string, MainEventRegister>;

View File

@@ -1,6 +1,7 @@
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
export const applicationMenuSubjects = { export const applicationMenuSubjects = {
newPageAction$: new Subject<void>(), newPageAction$: new Subject<'page' | 'edgeless'>(),
openJournal$: new Subject<void>(),
openAboutPageInSettingModal$: new Subject<void>(), openAboutPageInSettingModal$: new Subject<void>(),
}; };

View File

@@ -14,8 +14,9 @@ import { registerEvents } from './events';
import { registerHandlers } from './handlers'; import { registerHandlers } from './handlers';
import { logger } from './logger'; import { logger } from './logger';
import { registerProtocol } from './protocol'; import { registerProtocol } from './protocol';
import { getShareableContent } from './recording';
import { getTrayState } from './tray';
import { isOnline } from './ui'; import { isOnline } from './ui';
import { registerUpdater } from './updater';
import { launch } from './windows-manager/launcher'; import { launch } from './windows-manager/launcher';
import { launchStage } from './windows-manager/stage'; import { launchStage } from './windows-manager/stage';
@@ -86,8 +87,9 @@ app
.then(registerHandlers) .then(registerHandlers)
.then(registerEvents) .then(registerEvents)
.then(launch) .then(launch)
.then(getShareableContent)
.then(createApplicationMenu) .then(createApplicationMenu)
.then(registerUpdater) .then(getTrayState)
.catch(e => console.error('Failed create window:', e)); .catch(e => console.error('Failed create window:', e));
if (process.env.SENTRY_RELEASE) { if (process.env.SENTRY_RELEASE) {

View File

@@ -3,6 +3,7 @@ import { join } from 'node:path';
import { net, protocol, session } from 'electron'; import { net, protocol, session } from 'electron';
import cookieParser from 'set-cookie-parser'; import cookieParser from 'set-cookie-parser';
import { resourcesPath } from '../shared/utils';
import { logger } from './logger'; import { logger } from './logger';
protocol.registerSchemesAsPrivileged([ protocol.registerSchemesAsPrivileged([
@@ -33,7 +34,7 @@ protocol.registerSchemesAsPrivileged([
]); ]);
const NETWORK_REQUESTS = ['/api', '/ws', '/socket.io', '/graphql']; const NETWORK_REQUESTS = ['/api', '/ws', '/socket.io', '/graphql'];
const webStaticDir = join(__dirname, '../resources/web-static'); const webStaticDir = join(resourcesPath, 'web-static');
function isNetworkResource(pathname: string) { function isNetworkResource(pathname: string) {
return NETWORK_REQUESTS.some(opt => pathname.startsWith(opt)); return NETWORK_REQUESTS.some(opt => pathname.startsWith(opt));

View File

@@ -0,0 +1,237 @@
import { ShareableContent, TappableApplication } from '@affine/native';
import { Notification } from 'electron';
import {
BehaviorSubject,
distinctUntilChanged,
pairwise,
startWith,
} from 'rxjs';
import { isMacOS } from '../../shared/utils';
import { beforeAppQuit } from '../cleanup';
import { logger } from '../logger';
interface TappableAppInfo {
rawInstance: TappableApplication;
isRunning: boolean;
processId: number;
processGroupId: number;
bundleIdentifier: string;
name: string;
}
interface AppGroupInfo {
processGroupId: number;
apps: TappableAppInfo[];
name: string;
icon: Buffer | undefined;
isRunning: boolean;
}
const subscribers: Subscriber[] = [];
beforeAppQuit(() => {
subscribers.forEach(subscriber => {
subscriber.unsubscribe();
});
});
let shareableContent: ShareableContent | null = null;
export const applications$ = new BehaviorSubject<TappableAppInfo[]>([]);
export const appGroups$ = new BehaviorSubject<AppGroupInfo[]>([]);
if (isMacOS()) {
// Update appGroups$ whenever applications$ changes
subscribers.push(
applications$.pipe(distinctUntilChanged()).subscribe(apps => {
const appGroups: AppGroupInfo[] = [];
apps.forEach(app => {
let appGroup = appGroups.find(
group => group.processGroupId === app.processGroupId
);
if (!appGroup) {
const groupProcess = shareableContent?.applicationWithProcessId(
app.processGroupId
);
if (!groupProcess) {
return;
}
appGroup = {
processGroupId: app.processGroupId,
apps: [],
name: groupProcess.name,
// icon will be lazy loaded
get icon() {
try {
return groupProcess.icon;
} catch (error) {
logger.error(
`Failed to get icon for ${groupProcess.name}`,
error
);
return undefined;
}
},
get isRunning() {
return this.apps.some(app => app.rawInstance.isRunning);
},
};
appGroups.push(appGroup);
}
if (appGroup) {
appGroup.apps.push(app);
}
});
appGroups$.next(appGroups);
})
);
subscribers.push(
appGroups$
.pipe(startWith([] as AppGroupInfo[]), pairwise())
.subscribe(([previousGroups, currentGroups]) => {
currentGroups.forEach(currentGroup => {
const previousGroup = previousGroups.find(
group => group.processGroupId === currentGroup.processGroupId
);
if (previousGroup?.isRunning !== currentGroup.isRunning) {
console.log(
'appgroup running changed',
currentGroup.name,
currentGroup.isRunning
);
if (currentGroup.isRunning) {
new Notification({
title: 'Recording Meeting',
body: `Recording meeting with ${currentGroup.name}`,
}).show();
}
}
});
})
);
}
async function getAllApps(): Promise<TappableAppInfo[]> {
if (!shareableContent) {
return [];
}
const apps = shareableContent.applications().map(app => {
try {
return {
rawInstance: app,
processId: app.processId,
processGroupId: app.processGroupId,
bundleIdentifier: app.bundleIdentifier,
name: app.name,
isRunning: app.isRunning,
};
} catch (error) {
logger.error('failed to get app info', error);
return null;
}
});
const filteredApps = apps.filter(
(v): v is TappableAppInfo =>
v !== null &&
!v.bundleIdentifier.startsWith('com.apple') &&
v.processId !== process.pid
);
return filteredApps;
}
type Subscriber = {
unsubscribe: () => void;
};
function setupMediaListeners() {
subscribers.push(
ShareableContent.onApplicationListChanged(() => {
getAllApps()
.then(apps => {
applications$.next(apps);
})
.catch(err => {
logger.error('failed to get apps', err);
});
})
);
getAllApps()
.then(apps => {
applications$.next(apps);
})
.catch(err => {
logger.error('failed to get apps', err);
});
let appStateSubscribers: Subscriber[] = [];
subscribers.push(
applications$.subscribe(apps => {
appStateSubscribers.forEach(subscriber => {
subscriber.unsubscribe();
});
const _appStateSubscribers: Subscriber[] = [];
apps.forEach(app => {
try {
// Try to create a TappableApplication with a default audio object ID
// In a real implementation, you would need to get the actual audio object ID
// This is just a placeholder value that seems to work for testing
const tappableApp = TappableApplication.fromApplication(
app.rawInstance,
1
);
if (tappableApp) {
_appStateSubscribers.push(
ShareableContent.onAppStateChanged(tappableApp, () => {
setTimeout(() => {
const apps = applications$.getValue();
applications$.next(
apps.map(_app => {
if (_app.processId === app.processId) {
return { ..._app, isRunning: tappableApp.isRunning };
}
return _app;
})
);
}, 10);
})
);
}
} catch (error) {
logger.error(
`Failed to convert app ${app.name} to TappableApplication`,
error
);
}
});
appStateSubscribers = _appStateSubscribers;
return () => {
_appStateSubscribers.forEach(subscriber => {
subscriber.unsubscribe();
});
};
})
);
}
export function getShareableContent() {
if (!shareableContent && isMacOS()) {
try {
shareableContent = new ShareableContent();
setupMediaListeners();
} catch (error) {
logger.error('failed to get shareable content', error);
}
}
return shareableContent;
}

View File

@@ -0,0 +1,214 @@
import { join } from 'node:path';
import {
app,
Menu,
MenuItem,
type NativeImage,
nativeImage,
Tray,
} from 'electron';
import { isMacOS, resourcesPath } from '../../shared/utils';
import { applicationMenuSubjects } from '../application-menu';
import { beforeAppQuit } from '../cleanup';
import { appGroups$ } from '../recording';
import { getMainWindow } from '../windows-manager';
export interface TrayMenuConfigItem {
label: string;
click?: () => void;
icon?: NativeImage | string | Buffer;
disabled?: boolean;
}
export type TrayMenuConfig = Array<TrayMenuConfigItem | 'separator'>;
// each provider has a unique key and provides a menu config (a group of menu items)
interface TrayMenuProvider {
key: string;
getConfig(): TrayMenuConfig;
}
function showMainWindow() {
getMainWindow()
.then(w => {
w.show();
})
.catch(err => console.error(err));
}
class TrayState {
tray: Tray | null = null;
// tray's icon
icon: NativeImage = nativeImage
.createFromPath(join(resourcesPath, 'icons/tray-icon.png'))
.resize({ width: 16, height: 16 });
// tray's tooltip
tooltip: string = 'AFFiNE';
constructor() {
this.icon.setTemplateImage(true);
}
// sorry, no idea on better naming
getPrimaryMenuProvider(): TrayMenuProvider {
return {
key: 'primary',
getConfig: () => [
{
label: 'Open Journal',
icon: join(resourcesPath, 'icons/journal-today.png'),
click: () => {
showMainWindow();
applicationMenuSubjects.openJournal$.next();
},
},
{
label: 'New Page',
icon: join(resourcesPath, 'icons/doc-page.png'),
click: () => {
showMainWindow();
applicationMenuSubjects.newPageAction$.next('page');
},
},
{
label: 'New Edgeless',
icon: join(resourcesPath, 'icons/doc-edgeless.png'),
click: () => {
showMainWindow();
applicationMenuSubjects.newPageAction$.next('edgeless');
},
},
],
};
}
getRecordingMenuProvider(): TrayMenuProvider {
const appGroups = appGroups$.value;
const runningAppGroups = appGroups.filter(appGroup => appGroup.isRunning);
return {
key: 'recording',
getConfig: () => [
{
label: 'Start Recording Meeting',
disabled: true,
},
...runningAppGroups.map(appGroup => ({
label: appGroup.name,
icon: appGroup.icon || undefined,
click: () => {
console.log(appGroup);
},
})),
],
};
}
getSecondaryMenuProvider(): TrayMenuProvider {
return {
key: 'secondary',
getConfig: () => [
{
label: 'Open AFFiNE',
click: () => {
getMainWindow()
.then(w => {
w.show();
})
.catch(err => {
console.error(err);
});
},
},
'separator',
{
label: 'Quit AFFiNE Completely...',
click: () => {
app.quit();
},
},
],
};
}
buildMenu(providers: TrayMenuProvider[]) {
const menu = new Menu();
providers.forEach((provider, index) => {
provider.getConfig().forEach(item => {
if (item === 'separator') {
menu.append(new MenuItem({ type: 'separator' }));
} else {
const { icon, disabled, ...rest } = item;
let nativeIcon: NativeImage | undefined;
if (typeof icon === 'string') {
nativeIcon = nativeImage.createFromPath(icon);
} else if (Buffer.isBuffer(icon)) {
try {
nativeIcon = nativeImage.createFromBuffer(icon);
} catch (error) {
console.error('Failed to create icon from buffer', error);
}
}
if (nativeIcon) {
nativeIcon = nativeIcon.resize({ width: 20, height: 20 });
}
menu.append(
new MenuItem({
...rest,
enabled: !disabled,
icon: nativeIcon,
})
);
}
});
if (index !== providers.length - 1) {
menu.append(new MenuItem({ type: 'separator' }));
}
});
return menu;
}
update() {
if (!this.tray) {
this.tray = new Tray(this.icon);
this.tray.setToolTip(this.tooltip);
const clickHandler = () => {
this.update();
if (!isMacOS()) {
this.tray?.popUpContextMenu();
}
};
this.tray.on('click', clickHandler);
beforeAppQuit(() => {
this.tray?.off('click', clickHandler);
this.tray?.destroy();
});
}
const providers = [
this.getPrimaryMenuProvider(),
isMacOS() ? this.getRecordingMenuProvider() : null,
this.getSecondaryMenuProvider(),
].filter(p => p !== null);
const menu = this.buildMenu(providers);
this.tray.setContextMenu(menu);
}
init() {
this.update();
}
}
let _trayState: TrayState | undefined;
export const getTrayState = () => {
if (!_trayState) {
_trayState = new TrayState();
_trayState.init();
}
return _trayState;
};

View File

@@ -4,7 +4,7 @@ import { BrowserWindow, nativeTheme } from 'electron';
import electronWindowState from 'electron-window-state'; import electronWindowState from 'electron-window-state';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
import { isLinux, isMacOS, isWindows } from '../../shared/utils'; import { isLinux, isMacOS, isWindows, resourcesPath } from '../../shared/utils';
import { beforeAppQuit } from '../cleanup'; import { beforeAppQuit } from '../cleanup';
import { buildType } from '../config'; import { buildType } from '../config';
import { mainWindowOrigin } from '../constants'; import { mainWindowOrigin } from '../constants';
@@ -92,7 +92,7 @@ export class MainWindowManager {
if (isLinux()) { if (isLinux()) {
browserWindow.setIcon( browserWindow.setIcon(
// __dirname is `packages/frontend/apps/electron/dist` (the bundled output directory) // __dirname is `packages/frontend/apps/electron/dist` (the bundled output directory)
join(__dirname, `../resources/icons/icon_${buildType}_64x64.png`) join(resourcesPath, `icons/icon_${buildType}_64x64.png`)
); );
} }

View File

@@ -1,3 +1,5 @@
import { join } from 'node:path';
import type { EventBasedChannel } from 'async-call-rpc'; import type { EventBasedChannel } from 'async-call-rpc';
export function getTime() { export function getTime() {
@@ -42,3 +44,40 @@ export class MessageEventChannel implements EventBasedChannel {
this.worker.postMessage(data); this.worker.postMessage(data);
} }
} }
export const resourcesPath = join(__dirname, `../resources`);
// credit: https://github.com/facebook/fbjs/blob/main/packages/fbjs/src/core/shallowEqual.js
export function shallowEqual(objA: any, objB: any) {
if (Object.is(objA, objB)) {
return true;
}
if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) {
return false;
}
// Test for A's keys different from B.
for (const key of keysA) {
if (
!Object.prototype.hasOwnProperty.call(objB, key) ||
!Object.is(objA[key], objB[key])
) {
return false;
}
}
return true;
}

View File

@@ -5,6 +5,7 @@ import {
type Application, type Application,
type AudioTapStream, type AudioTapStream,
ShareableContent, ShareableContent,
type TappableApplication,
} from '@affine/native'; } from '@affine/native';
import type { FSWatcher } from 'chokidar'; import type { FSWatcher } from 'chokidar';
import chokidar from 'chokidar'; import chokidar from 'chokidar';
@@ -26,7 +27,7 @@ console.log(`📁 Ensuring recordings directory exists at ${RECORDING_DIR}`);
// Types // Types
interface Recording { interface Recording {
app: Application; app: TappableApplication;
appGroup: Application | null; appGroup: Application | null;
buffers: Float32Array[]; buffers: Float32Array[];
stream: AudioTapStream; stream: AudioTapStream;
@@ -54,12 +55,12 @@ interface RecordingMetadata {
} }
interface AppInfo { interface AppInfo {
app: Application; app?: TappableApplication;
processId: number; processId: number;
processGroupId: number; processGroupId: number;
bundleIdentifier: string; bundleIdentifier: string;
name: string; name: string;
running: boolean; isRunning: boolean;
} }
interface TranscriptionMetadata { interface TranscriptionMetadata {
@@ -216,7 +217,7 @@ function emitRecordingStatus() {
io.emit('apps:recording', { recordings: getRecordingStatus() }); io.emit('apps:recording', { recordings: getRecordingStatus() });
} }
async function startRecording(app: Application) { async function startRecording(app: TappableApplication) {
if (recordingMap.has(app.processId)) { if (recordingMap.has(app.processId)) {
console.log( console.log(
`⚠️ Recording already in progress for ${app.name} (PID: ${app.processId})` `⚠️ Recording already in progress for ${app.name} (PID: ${app.processId})`
@@ -224,40 +225,44 @@ async function startRecording(app: Application) {
return; return;
} }
const processGroupId = app.processGroupId; try {
const rootApp = processGroupId const processGroupId = app.processGroupId;
? (shareableContent const rootApp = shareableContent.applicationWithProcessId(processGroupId);
.applications() if (!rootApp) {
.find(a => a.processId === processGroupId) ?? app) console.error(`❌ App group not found for ${app.name}`);
: app;
console.log(
`🎙️ Starting recording for ${rootApp.name} (PID: ${rootApp.processId})`
);
const buffers: Float32Array[] = [];
const stream = app.tapAudio((err, samples) => {
if (err) {
console.error(`❌ Audio stream error for ${rootApp.name}:`, err);
return; return;
} }
const recording = recordingMap.get(app.processId);
if (recording && !recording.isWriting) {
buffers.push(new Float32Array(samples));
}
});
recordingMap.set(app.processId, { console.log(
app, `🎙️ Starting recording for ${rootApp.name} (PID: ${rootApp.processId})`
appGroup: rootApp, );
buffers,
stream,
startTime: Date.now(),
isWriting: false,
});
console.log(`✅ Recording started successfully for ${rootApp.name}`); const buffers: Float32Array[] = [];
emitRecordingStatus(); const stream = app.tapAudio((err, samples) => {
if (err) {
console.error(`❌ Audio stream error for ${rootApp.name}:`, err);
return;
}
const recording = recordingMap.get(app.processId);
if (recording && !recording.isWriting) {
buffers.push(new Float32Array(samples));
}
});
recordingMap.set(app.processId, {
app,
appGroup: rootApp,
buffers,
stream,
startTime: Date.now(),
isWriting: false,
});
console.log(`✅ Recording started successfully for ${rootApp.name}`);
emitRecordingStatus();
} catch (error) {
console.error(`❌ Error starting recording for ${app.name}:`, error);
}
} }
async function stopRecording(processId: number) { async function stopRecording(processId: number) {
@@ -432,7 +437,7 @@ async function setupRecordingsWatcher() {
const shareableContent = new ShareableContent(); const shareableContent = new ShareableContent();
async function getAllApps(): Promise<AppInfo[]> { async function getAllApps(): Promise<AppInfo[]> {
const apps = shareableContent.applications().map(app => { const apps: (AppInfo | null)[] = shareableContent.applications().map(app => {
try { try {
return { return {
app, app,
@@ -440,7 +445,7 @@ async function getAllApps(): Promise<AppInfo[]> {
processGroupId: app.processGroupId, processGroupId: app.processGroupId,
bundleIdentifier: app.bundleIdentifier, bundleIdentifier: app.bundleIdentifier,
name: app.name, name: app.name,
running: app.isRunning, isRunning: app.isRunning,
}; };
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -453,11 +458,30 @@ async function getAllApps(): Promise<AppInfo[]> {
v !== null && !v.bundleIdentifier.startsWith('com.apple') v !== null && !v.bundleIdentifier.startsWith('com.apple')
); );
for (const app of filteredApps) {
if (filteredApps.some(a => a.processId === app.processGroupId)) {
continue;
}
const appGroup = shareableContent.applicationWithProcessId(
app.processGroupId
);
if (!appGroup) {
continue;
}
filteredApps.push({
processId: appGroup.processId,
processGroupId: appGroup.processGroupId,
bundleIdentifier: appGroup.bundleIdentifier,
name: appGroup.name,
isRunning: false,
});
}
// Stop recording if app is not listed // Stop recording if app is not listed
await Promise.all( await Promise.all(
filteredApps.map(async ({ app }) => { Array.from(recordingMap.keys()).map(async processId => {
if (!filteredApps.some(a => a.processId === app.processId)) { if (!filteredApps.some(a => a.processId === processId)) {
await stopRecording(app.processId); await stopRecording(processId);
} }
}) })
); );
@@ -467,24 +491,36 @@ async function getAllApps(): Promise<AppInfo[]> {
function listenToAppStateChanges(apps: AppInfo[]) { function listenToAppStateChanges(apps: AppInfo[]) {
const subscribers = apps.map(({ app }) => { const subscribers = apps.map(({ app }) => {
return ShareableContent.onAppStateChanged(app, () => { try {
setTimeout(() => { if (!app) {
console.log( return { unsubscribe: () => {} };
`🔄 Application state changed: ${app.name} (PID: ${app.processId}) is now ${ }
app.isRunning ? '▶️ running' : '⏹️ stopped' return ShareableContent.onAppStateChanged(app, () => {
}` setTimeout(() => {
); console.log(
io.emit('apps:state-changed', { `🔄 Application state changed: ${app.name} (PID: ${app.processId}) is now ${
processId: app.processId, app.isRunning ? '▶️ running' : '⏹️ stopped'
running: app.isRunning, }`
}); );
if (!app.isRunning) { io.emit('apps:state-changed', {
stopRecording(app.processId).catch(error => { processId: app.processId,
console.error('❌ Error stopping recording:', error); isRunning: app.isRunning,
}); });
}
}, 50); if (!app.isRunning) {
}); stopRecording(app.processId).catch(error => {
console.error('❌ Error stopping recording:', error);
});
}
}, 100);
});
} catch (error) {
console.error(
`Failed to listen to app state changes for ${app?.name}:`,
error
);
return { unsubscribe: () => {} };
}
}); });
appsSubscriber(); appsSubscriber();
@@ -505,7 +541,7 @@ io.on('connection', async socket => {
console.log(`📤 Sending ${files.length} saved recordings to new client`); console.log(`📤 Sending ${files.length} saved recordings to new client`);
socket.emit('apps:saved', { recordings: files }); socket.emit('apps:saved', { recordings: files });
listenToAppStateChanges(initialApps); listenToAppStateChanges(initialApps.map(app => app.app).filter(app => !!app));
socket.on('disconnect', () => { socket.on('disconnect', () => {
console.log('🔌 Client disconnected'); console.log('🔌 Client disconnected');
@@ -614,19 +650,33 @@ app.get('/apps/:process_id/icon', (req, res) => {
const processId = parseInt(req.params.process_id); const processId = parseInt(req.params.process_id);
try { try {
const app = shareableContent.applicationWithProcessId(processId); const app = shareableContent.applicationWithProcessId(processId);
if (!app) {
res.status(404).json({ error: 'App not found' });
return;
}
const icon = app.icon; const icon = app.icon;
res.set('Content-Type', 'image/png'); res.set('Content-Type', 'image/png');
res.send(icon); res.send(icon);
} catch { } catch (error) {
console.error(`Error getting icon for process ${processId}:`, error);
res.status(404).json({ error: 'App icon not found' }); res.status(404).json({ error: 'App icon not found' });
} }
}); });
app.post('/apps/:process_id/record', async (req, res) => { app.post('/apps/:process_id/record', async (req, res) => {
const processId = parseInt(req.params.process_id); const processId = parseInt(req.params.process_id);
const app = shareableContent.applicationWithProcessId(processId); try {
await startRecording(app); const app = shareableContent.tappableApplicationWithProcessId(processId);
res.json({ success: true }); if (!app) {
res.status(404).json({ error: 'App not found' });
return;
}
await startRecording(app);
res.json({ success: true });
} catch (error) {
console.error(`Error starting recording for process ${processId}:`, error);
res.status(500).json({ error: 'Failed to start recording' });
}
}); });
app.post('/apps/:process_id/stop', async (req, res) => { app.post('/apps/:process_id/stop', async (req, res) => {

View File

@@ -15,14 +15,14 @@ export function AppItem({ app, recordings }: AppItemProps) {
const appName = app.rootApp.name || ''; const appName = app.rootApp.name || '';
const bundleId = app.rootApp.bundleIdentifier || ''; const bundleId = app.rootApp.bundleIdentifier || '';
const firstLetter = appName.charAt(0).toUpperCase(); const firstLetter = appName.charAt(0).toUpperCase();
const isRunning = app.apps.some(a => a.running); const isRunning = app.apps.some(a => a.isRunning);
const recording = recordings?.find((r: RecordingStatus) => const recording = recordings?.find((r: RecordingStatus) =>
app.apps.some(a => a.processId === r.processId) app.apps.some(a => a.processId === r.processId)
); );
const handleRecordClick = React.useCallback(() => { const handleRecordClick = React.useCallback(() => {
const recordingApp = app.apps.find(a => a.running); const recordingApp = app.apps.find(a => a.isRunning);
if (!recordingApp) { if (!recordingApp) {
return; return;
} }

View File

@@ -24,12 +24,13 @@ export function AppList() {
}); });
socket.on('apps:state-changed', data => { socket.on('apps:state-changed', data => {
const index = apps.findIndex(a => a.processId === data.processId); const index = apps.findIndex(a => a.processId === data.processId);
console.log('apps:state-changed', data, index);
if (index !== -1) { if (index !== -1) {
next( next(
null, null,
apps.toSpliced(index, 1, { apps.toSpliced(index, 1, {
...apps[index], ...apps[index],
running: data.running, isRunning: data.isRunning,
}) })
); );
} }
@@ -83,10 +84,10 @@ export function AppList() {
}, [apps]); }, [apps]);
const runningApps = (appGroups || []).filter(app => const runningApps = (appGroups || []).filter(app =>
app.apps.some(a => a.running) app.apps.some(a => a.isRunning)
); );
const notRunningApps = (appGroups || []).filter( const notRunningApps = (appGroups || []).filter(
app => !app.apps.some(a => a.running) app => !app.apps.some(a => a.isRunning)
); );
return ( return (

View File

@@ -3,7 +3,7 @@ export interface App {
processGroupId: number; processGroupId: number;
bundleIdentifier: string; bundleIdentifier: string;
name: string; name: string;
running: boolean; isRunning: boolean;
} }
export interface AppGroup { export interface AppGroup {

View File

@@ -1,14 +1,12 @@
/* auto-generated by NAPI-RS */ /* auto-generated by NAPI-RS */
/* eslint-disable */ /* eslint-disable */
export declare class Application { export declare class Application {
static tapGlobalAudio(excludedProcesses: Array<Application> | undefined | null, audioStreamCallback: ((err: Error | null, arg: Float32Array) => void)): AudioTapStream constructor(processId: number)
get processId(): number get processId(): number
get processGroupId(): number get processGroupId(): number
get bundleIdentifier(): string get bundleIdentifier(): string
get name(): string get name(): string
get icon(): Buffer get icon(): Buffer
get isRunning(): boolean
tapAudio(audioStreamCallback: ((err: Error | null, arg: Float32Array) => void)): AudioTapStream
} }
export declare class ApplicationListChangedSubscriber { export declare class ApplicationListChangedSubscriber {
@@ -73,11 +71,13 @@ export declare class RecordingPermissions {
export declare class ShareableContent { export declare class ShareableContent {
static onApplicationListChanged(callback: ((err: Error | null, ) => void)): ApplicationListChangedSubscriber static onApplicationListChanged(callback: ((err: Error | null, ) => void)): ApplicationListChangedSubscriber
static onAppStateChanged(app: Application, callback: ((err: Error | null, ) => void)): ApplicationStateChangedSubscriber static onAppStateChanged(app: TappableApplication, callback: ((err: Error | null, ) => void)): ApplicationStateChangedSubscriber
constructor() constructor()
applications(): Array<Application> applications(): Array<TappableApplication>
applicationWithProcessId(processId: number): Application applicationWithProcessId(processId: number): Application | null
tappableApplicationWithProcessId(processId: number): TappableApplication | null
checkRecordingPermissions(): RecordingPermissions checkRecordingPermissions(): RecordingPermissions
static tapGlobalAudio(excludedProcesses: Array<TappableApplication> | undefined | null, audioStreamCallback: ((err: Error | null, arg: Float32Array) => void)): AudioTapStream
} }
export declare class SqliteConnection { export declare class SqliteConnection {
@@ -118,6 +118,19 @@ export declare class SqliteConnection {
checkpoint(): Promise<void> checkpoint(): Promise<void>
} }
export declare class TappableApplication {
constructor(objectId: AudioObjectID)
static fromApplication(app: Application, objectId: AudioObjectID): TappableApplication
get processId(): number
get processGroupId(): number
get bundleIdentifier(): string
get name(): string
get objectId(): number
get icon(): Buffer
get isRunning(): boolean
tapAudio(audioStreamCallback: ((err: Error | null, arg: Float32Array) => void)): AudioTapStream
}
/**Enumeration of valid values for `set_brate` */ /**Enumeration of valid values for `set_brate` */
export declare enum Bitrate { export declare enum Bitrate {
/**8_000 */ /**8_000 */

View File

@@ -380,6 +380,7 @@ module.exports.Mp3Encoder = nativeBinding.Mp3Encoder
module.exports.RecordingPermissions = nativeBinding.RecordingPermissions module.exports.RecordingPermissions = nativeBinding.RecordingPermissions
module.exports.ShareableContent = nativeBinding.ShareableContent module.exports.ShareableContent = nativeBinding.ShareableContent
module.exports.SqliteConnection = nativeBinding.SqliteConnection module.exports.SqliteConnection = nativeBinding.SqliteConnection
module.exports.TappableApplication = nativeBinding.TappableApplication
module.exports.Bitrate = nativeBinding.Bitrate module.exports.Bitrate = nativeBinding.Bitrate
module.exports.decodeAudio = nativeBinding.decodeAudio module.exports.decodeAudio = nativeBinding.decodeAudio
module.exports.decodeAudioSync = nativeBinding.decodeAudioSync module.exports.decodeAudioSync = nativeBinding.decodeAudioSync

View File

@@ -36,7 +36,6 @@ use screencapturekit::shareable_content::SCShareableContent;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
error::CoreAudioError,
pid::{audio_process_list, get_process_property}, pid::{audio_process_list, get_process_property},
tap_audio::{AggregateDevice, AudioTapStream}, tap_audio::{AggregateDevice, AudioTapStream},
}; };
@@ -94,133 +93,161 @@ static AVCAPTUREDEVICE_CLASS: LazyLock<Option<&'static AnyClass>> =
static SCSTREAM_CLASS: LazyLock<Option<&'static AnyClass>> = static SCSTREAM_CLASS: LazyLock<Option<&'static AnyClass>> =
LazyLock::new(|| AnyClass::get(c"SCStream")); LazyLock::new(|| AnyClass::get(c"SCStream"));
struct TappableApplication { #[napi]
object_id: AudioObjectID, pub struct Application {
pub(crate) process_id: i32,
pub(crate) name: String,
} }
impl TappableApplication { #[napi]
fn new(object_id: AudioObjectID) -> Self { impl Application {
Self { object_id } #[napi(constructor)]
} pub fn new(process_id: i32) -> Result<Self> {
// Default values for when we can't get information
let mut app = Self {
process_id,
name: String::new(),
};
fn process_id(&self) -> std::result::Result<i32, CoreAudioError> { // Try to populate fields using NSRunningApplication
get_process_property(&self.object_id, kAudioProcessPropertyPID) if process_id > 0 {
} // Get NSRunningApplication class
if let Some(running_app_class) = NSRUNNING_APPLICATION_CLASS.as_ref() {
// Get running application with PID
let running_app: *mut AnyObject = unsafe {
msg_send![
*running_app_class,
runningApplicationWithProcessIdentifier: process_id
]
};
fn bundle_identifier(&self) -> Result<String> { if !running_app.is_null() {
let bundle_id: CFStringRef = // Get name
get_process_property(&self.object_id, kAudioProcessPropertyBundleID)?; unsafe {
Ok(unsafe { CFString::wrap_under_get_rule(bundle_id) }.to_string()) let name_ptr: *mut NSString = msg_send![running_app, localizedName];
} if !name_ptr.is_null() {
let length: usize = msg_send![name_ptr, length];
let utf8_ptr: *const u8 = msg_send![name_ptr, UTF8String];
fn name(&self) -> Result<String> { if !utf8_ptr.is_null() {
// Use catch_unwind to prevent any panics let bytes = std::slice::from_raw_parts(utf8_ptr, length);
let name_result = std::panic::catch_unwind(|| { if let Ok(s) = std::str::from_utf8(bytes) {
// Get process ID with error handling app.name = s.to_string();
let pid = match self.process_id() { }
Ok(pid) => pid, }
Err(_) => { }
return Ok(String::new()); }
}
};
// Get NSRunningApplication class with error handling
let running_app_class = match NSRUNNING_APPLICATION_CLASS.as_ref() {
Some(class) => class,
None => {
return Ok(String::new());
}
};
// Get running application with PID
let running_app: *mut AnyObject =
unsafe { msg_send![*running_app_class, runningApplicationWithProcessIdentifier: pid] };
if running_app.is_null() {
return Ok(String::new());
}
// Instead of using Retained::from_raw which takes ownership,
// we'll just copy the string value and let the Objective-C runtime
// handle the memory management of the original object
unsafe {
// Get localized name
let name_ptr: *mut NSString = msg_send![running_app, localizedName];
if name_ptr.is_null() {
return Ok(String::new());
}
// Create a copy of the string without taking ownership of the NSString
let length: usize = msg_send![name_ptr, length];
let utf8_ptr: *const u8 = msg_send![name_ptr, UTF8String];
if utf8_ptr.is_null() {
return Ok(String::new());
}
let bytes = std::slice::from_raw_parts(utf8_ptr, length);
match std::str::from_utf8(bytes) {
Ok(s) => Ok(s.to_string()),
Err(_) => Ok(String::new()),
} }
} }
});
// Handle any panics that might have occurred
match name_result {
Ok(result) => result,
Err(_) => Ok(String::new()),
} }
Ok(app)
} }
fn icon(&self) -> Result<Vec<u8>> { #[napi(getter)]
pub fn process_id(&self) -> i32 {
self.process_id
}
#[napi(getter)]
pub fn process_group_id(&self) -> i32 {
if self.process_id > 0 {
let pgid = unsafe { libc::getpgid(self.process_id) };
if pgid != -1 {
return pgid;
}
// Fall back to process_id if getpgid fails
return self.process_id;
}
-1
}
#[napi(getter)]
pub fn bundle_identifier(&self) -> String {
if self.process_id <= 0 {
return String::new();
}
// Try to get bundle identifier using NSRunningApplication
if let Some(running_app_class) = NSRUNNING_APPLICATION_CLASS.as_ref() {
let running_app: *mut AnyObject = unsafe {
msg_send![
*running_app_class,
runningApplicationWithProcessIdentifier: self.process_id
]
};
if !running_app.is_null() {
unsafe {
let bundle_id_ptr: *mut NSString = msg_send![running_app, bundleIdentifier];
if !bundle_id_ptr.is_null() {
let length: usize = msg_send![bundle_id_ptr, length];
let utf8_ptr: *const u8 = msg_send![bundle_id_ptr, UTF8String];
if !utf8_ptr.is_null() {
let bytes = std::slice::from_raw_parts(utf8_ptr, length);
if let Ok(s) = std::str::from_utf8(bytes) {
return s.to_string();
}
}
}
}
}
}
String::new()
}
#[napi(getter)]
pub fn name(&self) -> String {
self.name.clone()
}
#[napi(getter)]
pub fn icon(&self) -> Result<Buffer> {
// Use catch_unwind to prevent any panics // Use catch_unwind to prevent any panics
let icon_result = std::panic::catch_unwind(|| { let icon_result = std::panic::catch_unwind(|| {
// Get process ID with error handling
let pid = match self.process_id() {
Ok(pid) => pid,
Err(_) => {
return Ok(Vec::new());
}
};
// Get NSRunningApplication class with error handling // Get NSRunningApplication class with error handling
let running_app_class = match NSRUNNING_APPLICATION_CLASS.as_ref() { let running_app_class = match NSRUNNING_APPLICATION_CLASS.as_ref() {
Some(class) => class, Some(class) => class,
None => { None => {
return Ok(Vec::new()); return Ok(Buffer::from(Vec::<u8>::new()));
} }
}; };
// Get running application with PID // Get running application with PID
let running_app: *mut AnyObject = let running_app: *mut AnyObject = unsafe {
unsafe { msg_send![*running_app_class, runningApplicationWithProcessIdentifier: pid] }; msg_send![
*running_app_class,
runningApplicationWithProcessIdentifier: self.process_id
]
};
if running_app.is_null() { if running_app.is_null() {
return Ok(Vec::new()); return Ok(Buffer::from(Vec::<u8>::new()));
} }
unsafe { unsafe {
// Get original icon // Get original icon
let icon: *mut AnyObject = msg_send![running_app, icon]; let icon: *mut AnyObject = msg_send![running_app, icon];
if icon.is_null() { if icon.is_null() {
return Ok(Vec::new()); return Ok(Buffer::from(Vec::<u8>::new()));
} }
// Create a new NSImage with 64x64 size // Create a new NSImage with 64x64 size
let nsimage_class = match AnyClass::get(c"NSImage") { let nsimage_class = match AnyClass::get(c"NSImage") {
Some(class) => class, Some(class) => class,
None => return Ok(Vec::new()), None => return Ok(Buffer::from(Vec::<u8>::new())),
}; };
let resized_image: *mut AnyObject = msg_send![nsimage_class, alloc]; let resized_image: *mut AnyObject = msg_send![nsimage_class, alloc];
if resized_image.is_null() { if resized_image.is_null() {
return Ok(Vec::new()); return Ok(Buffer::from(Vec::<u8>::new()));
} }
let resized_image: *mut AnyObject = let resized_image: *mut AnyObject =
msg_send![resized_image, initWithSize: NSSize { width: 64.0, height: 64.0 }]; msg_send![resized_image, initWithSize: NSSize { width: 64.0, height: 64.0 }];
if resized_image.is_null() { if resized_image.is_null() {
return Ok(Vec::new()); return Ok(Buffer::from(Vec::<u8>::new()));
} }
let _: () = msg_send![resized_image, lockFocus]; let _: () = msg_send![resized_image, lockFocus];
@@ -241,41 +268,41 @@ impl TappableApplication {
// Get TIFF representation from the downsized image // Get TIFF representation from the downsized image
let tiff_data: *mut AnyObject = msg_send![resized_image, TIFFRepresentation]; let tiff_data: *mut AnyObject = msg_send![resized_image, TIFFRepresentation];
if tiff_data.is_null() { if tiff_data.is_null() {
return Ok(Vec::new()); return Ok(Buffer::from(Vec::<u8>::new()));
} }
// Create bitmap image rep from TIFF // Create bitmap image rep from TIFF
let bitmap_class = match AnyClass::get(c"NSBitmapImageRep") { let bitmap_class = match AnyClass::get(c"NSBitmapImageRep") {
Some(class) => class, Some(class) => class,
None => return Ok(Vec::new()), None => return Ok(Buffer::from(Vec::<u8>::new())),
}; };
let bitmap: *mut AnyObject = msg_send![bitmap_class, imageRepWithData: tiff_data]; let bitmap: *mut AnyObject = msg_send![bitmap_class, imageRepWithData: tiff_data];
if bitmap.is_null() { if bitmap.is_null() {
return Ok(Vec::new()); return Ok(Buffer::from(Vec::<u8>::new()));
} }
// Create properties dictionary with compression factor // Create properties dictionary with compression factor
let dict_class = match AnyClass::get(c"NSMutableDictionary") { let dict_class = match AnyClass::get(c"NSMutableDictionary") {
Some(class) => class, Some(class) => class,
None => return Ok(Vec::new()), None => return Ok(Buffer::from(Vec::<u8>::new())),
}; };
let properties: *mut AnyObject = msg_send![dict_class, dictionary]; let properties: *mut AnyObject = msg_send![dict_class, dictionary];
if properties.is_null() { if properties.is_null() {
return Ok(Vec::new()); return Ok(Buffer::from(Vec::<u8>::new()));
} }
// Add compression properties // Add compression properties
let compression_key = NSString::from_str("NSImageCompressionFactor"); let compression_key = NSString::from_str("NSImageCompressionFactor");
let number_class = match AnyClass::get(c"NSNumber") { let number_class = match AnyClass::get(c"NSNumber") {
Some(class) => class, Some(class) => class,
None => return Ok(Vec::new()), None => return Ok(Buffer::from(Vec::<u8>::new())),
}; };
let compression_value: *mut AnyObject = msg_send![number_class, numberWithDouble: 0.8]; let compression_value: *mut AnyObject = msg_send![number_class, numberWithDouble: 0.8];
if compression_value.is_null() { if compression_value.is_null() {
return Ok(Vec::new()); return Ok(Buffer::from(Vec::<u8>::new()));
} }
let _: () = msg_send![properties, setObject: compression_value, forKey: &*compression_key]; let _: () = msg_send![properties, setObject: compression_value, forKey: &*compression_key];
@@ -285,7 +312,7 @@ impl TappableApplication {
msg_send![bitmap, representationUsingType: 4, properties: properties]; // 4 = PNG msg_send![bitmap, representationUsingType: 4, properties: properties]; // 4 = PNG
if png_data.is_null() { if png_data.is_null() {
return Ok(Vec::new()); return Ok(Buffer::from(Vec::<u8>::new()));
} }
// Get bytes from NSData // Get bytes from NSData
@@ -293,129 +320,101 @@ impl TappableApplication {
let length: usize = msg_send![png_data, length]; let length: usize = msg_send![png_data, length];
if bytes.is_null() { if bytes.is_null() {
return Ok(Vec::new()); return Ok(Buffer::from(Vec::<u8>::new()));
} }
// Copy bytes into a Vec<u8> instead of using the original memory // Copy bytes into a Vec<u8> instead of using the original memory
let data = std::slice::from_raw_parts(bytes, length).to_vec(); let data = std::slice::from_raw_parts(bytes, length).to_vec();
Ok(data) Ok(Buffer::from(data))
} }
}); });
// Handle any panics that might have occurred // Handle any panics that might have occurred
match icon_result { match icon_result {
Ok(result) => result, Ok(result) => result,
Err(_) => Ok(Vec::new()), Err(_) => Ok(Buffer::from(Vec::<u8>::new())),
}
}
fn process_group_id(&self) -> Result<i32> {
// Use catch_unwind to prevent any panics
let pgid_result = std::panic::catch_unwind(|| {
// First get the process ID
let pid = match self.process_id() {
Ok(pid) => pid,
Err(_) => {
return Ok(-1); // Return -1 for error cases
}
};
// Call libc's getpgid function to get the process group ID
let pgid = unsafe { libc::getpgid(pid) };
// getpgid returns -1 on error
if pgid == -1 {
return Ok(-1);
}
Ok(pgid)
});
// Handle any panics
match pgid_result {
Ok(result) => result,
Err(_) => Ok(-1),
} }
} }
} }
#[napi] #[napi]
pub struct Application { pub struct TappableApplication {
inner: TappableApplication, pub(crate) app: Application,
pub(crate) object_id: AudioObjectID, pub(crate) object_id: AudioObjectID,
pub(crate) process_id: i32,
pub(crate) process_group_id: i32,
pub(crate) bundle_identifier: String,
pub(crate) name: String,
} }
#[napi] #[napi]
impl Application { impl TappableApplication {
fn new(app: TappableApplication) -> Result<Self> { #[napi(constructor)]
let object_id = app.object_id; pub fn new(object_id: AudioObjectID) -> Result<Self> {
let bundle_identifier = app.bundle_identifier()?; // Get process ID from object_id
let name = app.name()?; let process_id = match get_process_property(&object_id, kAudioProcessPropertyPID) {
let process_id = app.process_id()?; Ok(pid) => pid,
let process_group_id = app.process_group_id()?; Err(_) => -1,
};
Ok(Self { // Create base Application
inner: app, let app = Application::new(process_id)?;
object_id,
process_id, Ok(Self { app, object_id })
process_group_id,
bundle_identifier,
name,
})
} }
#[napi] #[napi(factory)]
pub fn tap_global_audio( pub fn from_application(app: &Application, object_id: AudioObjectID) -> Self {
excluded_processes: Option<Vec<&Application>>, Self {
audio_stream_callback: Arc<ThreadsafeFunction<Float32Array, (), Float32Array, true>>, app: Application {
) -> Result<AudioTapStream> { process_id: app.process_id,
let mut device = AggregateDevice::create_global_tap_but_exclude_processes( name: app.name.clone(),
&excluded_processes },
.unwrap_or_default() object_id,
.iter() }
.map(|app| app.object_id)
.collect::<Vec<_>>(),
)?;
device.start(audio_stream_callback)
} }
#[napi(getter)] #[napi(getter)]
pub fn process_id(&self) -> i32 { pub fn process_id(&self) -> i32 {
self.process_id self.app.process_id
} }
#[napi(getter)] #[napi(getter)]
pub fn process_group_id(&self) -> i32 { pub fn process_group_id(&self) -> i32 {
self.process_group_id self.app.process_group_id()
} }
#[napi(getter)] #[napi(getter)]
pub fn bundle_identifier(&self) -> String { pub fn bundle_identifier(&self) -> String {
self.bundle_identifier.clone() // First try to get from the Application
let app_bundle_id = self.app.bundle_identifier();
if !app_bundle_id.is_empty() {
return app_bundle_id;
}
// If not available, try to get from the audio process property
match get_process_property::<CFStringRef>(&self.object_id, kAudioProcessPropertyBundleID) {
Ok(bundle_id) => {
// Safely convert CFStringRef to Rust String
let cf_string = unsafe { CFString::wrap_under_create_rule(bundle_id) };
cf_string.to_string()
}
Err(_) => {
// Return empty string if we couldn't get the bundle ID
String::new()
}
}
} }
#[napi(getter)] #[napi(getter)]
pub fn name(&self) -> String { pub fn name(&self) -> String {
self.name.clone() self.app.name.clone()
}
#[napi(getter)]
pub fn object_id(&self) -> u32 {
self.object_id
} }
#[napi(getter)] #[napi(getter)]
pub fn icon(&self) -> Result<Buffer> { pub fn icon(&self) -> Result<Buffer> {
// Use catch_unwind to prevent any panics self.app.icon()
let result = std::panic::catch_unwind(|| match self.inner.icon() {
Ok(icon) => Ok(Buffer::from(icon)),
Err(_) => Ok(Buffer::from(Vec::<u8>::new())),
});
// Handle any panics
match result {
Ok(result) => result,
Err(_) => Ok(Buffer::from(Vec::<u8>::new())),
}
} }
#[napi(getter)] #[napi(getter)]
@@ -424,20 +423,14 @@ impl Application {
let result = std::panic::catch_unwind(|| { let result = std::panic::catch_unwind(|| {
match get_process_property(&self.object_id, kAudioProcessPropertyIsRunningInput) { match get_process_property(&self.object_id, kAudioProcessPropertyIsRunningInput) {
Ok(is_running) => Ok(is_running), Ok(is_running) => Ok(is_running),
Err(_) => { Err(_) => Ok(false),
// Default to true to avoid potential issues
Ok(true)
}
} }
}); });
// Handle any panics // Handle any panics
match result { match result {
Ok(result) => result, Ok(result) => result,
Err(_) => { Err(_) => Ok(false),
// Default to true to avoid potential issues
Ok(true)
}
} }
} }
@@ -446,6 +439,7 @@ impl Application {
&self, &self,
audio_stream_callback: Arc<ThreadsafeFunction<Float32Array, (), Float32Array, true>>, audio_stream_callback: Arc<ThreadsafeFunction<Float32Array, (), Float32Array, true>>,
) -> Result<AudioTapStream> { ) -> Result<AudioTapStream> {
// Use the new method that takes a TappableApplication directly
let mut device = AggregateDevice::new(self)?; let mut device = AggregateDevice::new(self)?;
device.start(audio_stream_callback) device.start(audio_stream_callback)
} }
@@ -585,20 +579,22 @@ impl ShareableContent {
#[napi] #[napi]
pub fn on_app_state_changed( pub fn on_app_state_changed(
app: &Application, app: &TappableApplication,
callback: Arc<ThreadsafeFunction<(), ()>>, callback: Arc<ThreadsafeFunction<(), ()>>,
) -> Result<ApplicationStateChangedSubscriber> { ) -> Result<ApplicationStateChangedSubscriber> {
let id = Uuid::new_v4(); let id = Uuid::new_v4();
let object_id = app.object_id;
let mut lock = APPLICATION_STATE_CHANGED_SUBSCRIBERS.write().map_err(|_| { let mut lock = APPLICATION_STATE_CHANGED_SUBSCRIBERS.write().map_err(|_| {
Error::new( Error::new(
Status::GenericFailure, Status::GenericFailure,
"Poisoned RwLock while writing ApplicationStateChangedSubscribers", "Poisoned RwLock while writing ApplicationStateChangedSubscribers",
) )
})?; })?;
if let Some(subscribers) = lock.get_mut(&app.object_id) {
if let Some(subscribers) = lock.get_mut(&object_id) {
subscribers.insert(id, callback); subscribers.insert(id, callback);
} else { } else {
let object_id = app.object_id;
let list_change: RcBlock<dyn Fn(u32, *mut c_void)> = let list_change: RcBlock<dyn Fn(u32, *mut c_void)> =
RcBlock::new(move |in_number_addresses, in_addresses: *mut c_void| { RcBlock::new(move |in_number_addresses, in_addresses: *mut c_void| {
let addresses = unsafe { let addresses = unsafe {
@@ -630,7 +626,7 @@ impl ShareableContent {
let listener_block = &*list_change as *const Block<dyn Fn(u32, *mut c_void)>; let listener_block = &*list_change as *const Block<dyn Fn(u32, *mut c_void)>;
let status = unsafe { let status = unsafe {
AudioObjectAddPropertyListenerBlock( AudioObjectAddPropertyListenerBlock(
app.object_id, object_id,
&address, &address,
ptr::null_mut(), ptr::null_mut(),
listener_block.cast_mut().cast(), listener_block.cast_mut().cast(),
@@ -647,12 +643,9 @@ impl ShareableContent {
map.insert(id, callback); map.insert(id, callback);
map map
}; };
lock.insert(app.object_id, subscribers); lock.insert(object_id, subscribers);
} }
Ok(ApplicationStateChangedSubscriber { Ok(ApplicationStateChangedSubscriber { id, object_id })
id,
object_id: app.object_id,
})
} }
#[napi(constructor)] #[napi(constructor)]
@@ -663,8 +656,8 @@ impl ShareableContent {
} }
#[napi] #[napi]
pub fn applications(&self) -> Result<Vec<Application>> { pub fn applications(&self) -> Result<Vec<TappableApplication>> {
RUNNING_APPLICATIONS let app_list = RUNNING_APPLICATIONS
.read() .read()
.map_err(|_| { .map_err(|_| {
Error::new( Error::new(
@@ -674,46 +667,73 @@ impl ShareableContent {
})? })?
.iter() .iter()
.filter_map(|id| { .filter_map(|id| {
let app = TappableApplication::new(*id); let tappable_app = match TappableApplication::new(*id) {
if !app.bundle_identifier().ok()?.is_empty() { Ok(app) => app,
Some(Application::new(app)) Err(_) => return None,
};
if !tappable_app.bundle_identifier().is_empty() {
Some(tappable_app)
} else { } else {
None None
} }
}) })
.collect() .collect::<Vec<_>>();
Ok(app_list)
} }
#[napi] #[napi]
pub fn application_with_process_id(&self, process_id: u32) -> Result<Application> { pub fn application_with_process_id(&self, process_id: u32) -> Option<Application> {
// Find the AudioObjectID for the given process ID // Get NSRunningApplication class
let audio_object_id = { let running_app_class = match NSRUNNING_APPLICATION_CLASS.as_ref() {
let running_apps = RUNNING_APPLICATIONS.read().map_err(|_| { Some(class) => class,
Error::new( None => return None,
Status::GenericFailure,
"Poisoned RwLock while reading RunningApplications",
)
})?;
*running_apps
.iter()
.find(|&&id| {
let app = TappableApplication::new(id);
app
.process_id()
.map(|pid| pid as u32 == process_id)
.unwrap_or(false)
})
.ok_or_else(|| {
Error::new(
Status::GenericFailure,
format!("No application found with process ID {}", process_id),
)
})?
}; };
let app = TappableApplication::new(audio_object_id); // Get running application with PID
Application::new(app) let running_app: *mut AnyObject = unsafe {
msg_send![
*running_app_class,
runningApplicationWithProcessIdentifier: process_id as i32
]
};
if running_app.is_null() {
return None;
}
// Create an Application directly
match Application::new(process_id as i32) {
Ok(app) => Some(app),
Err(_) => None,
}
}
#[napi]
pub fn tappable_application_with_process_id(
&self,
process_id: u32,
) -> Option<TappableApplication> {
// Find the TappableApplication with this process ID in the list of running
// applications
match self.applications() {
Ok(apps) => {
for app in apps {
if app.process_id() == process_id as i32 {
return Some(app);
}
}
// If we couldn't find a TappableApplication with this process ID, create a new
// one with a default object_id of 0 (which won't be able to tap audio)
match Application::new(process_id as i32) {
Ok(app) => Some(TappableApplication::from_application(&app, 0)),
Err(_) => None,
}
}
Err(_) => None,
}
} }
#[napi] #[napi]
@@ -743,4 +763,19 @@ impl ShareableContent {
screen: screen_status, screen: screen_status,
}) })
} }
#[napi]
pub fn tap_global_audio(
excluded_processes: Option<Vec<&TappableApplication>>,
audio_stream_callback: Arc<ThreadsafeFunction<Float32Array, (), Float32Array, true>>,
) -> Result<AudioTapStream> {
let mut device = AggregateDevice::create_global_tap_but_exclude_processes(
&excluded_processes
.unwrap_or_default()
.iter()
.map(|app| app.object_id)
.collect::<Vec<_>>(),
)?;
device.start(audio_stream_callback)
}
} }

View File

@@ -30,7 +30,7 @@ use objc2::{runtime::AnyObject, Encode, Encoding, RefEncode};
use crate::{ use crate::{
ca_tap_description::CATapDescription, device::get_device_uid, error::CoreAudioError, ca_tap_description::CATapDescription, device::get_device_uid, error::CoreAudioError,
queue::create_audio_tap_queue, screen_capture_kit::Application, queue::create_audio_tap_queue, screen_capture_kit::TappableApplication,
}; };
extern "C" { extern "C" {
@@ -88,10 +88,12 @@ pub struct AggregateDevice {
} }
impl AggregateDevice { impl AggregateDevice {
pub fn new(app: &Application) -> Result<Self> { pub fn new(app: &TappableApplication) -> Result<Self> {
let object_id = app.object_id;
let tap_description = CATapDescription::init_stereo_mixdown_of_processes(object_id)?;
let mut tap_id: AudioObjectID = 0; let mut tap_id: AudioObjectID = 0;
let tap_description = CATapDescription::init_stereo_mixdown_of_processes(app.object_id)?;
let status = unsafe { AudioHardwareCreateProcessTap(tap_description.inner, &mut tap_id) }; let status = unsafe { AudioHardwareCreateProcessTap(tap_description.inner, &mut tap_id) };
if status != 0 { if status != 0 {
@@ -109,7 +111,37 @@ impl AggregateDevice {
) )
}; };
// Check the status and return the appropriate result if status != 0 {
return Err(CoreAudioError::CreateAggregateDeviceFailed(status).into());
}
Ok(Self {
tap_id,
id: aggregate_device_id,
})
}
pub fn new_from_object_id(object_id: AudioObjectID) -> Result<Self> {
let mut tap_id: AudioObjectID = 0;
let tap_description = CATapDescription::init_stereo_mixdown_of_processes(object_id)?;
let status = unsafe { AudioHardwareCreateProcessTap(tap_description.inner, &mut tap_id) };
if status != 0 {
return Err(CoreAudioError::CreateProcessTapFailed(status).into());
}
let description_dict = Self::create_aggregate_description(tap_id, tap_description.get_uuid()?)?;
let mut aggregate_device_id: AudioObjectID = 0;
let status = unsafe {
AudioHardwareCreateAggregateDevice(
description_dict.as_concrete_TypeRef().cast(),
&mut aggregate_device_id,
)
};
if status != 0 { if status != 0 {
return Err(CoreAudioError::CreateAggregateDeviceFailed(status).into()); return Err(CoreAudioError::CreateAggregateDeviceFailed(status).into());
} }