mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 18:26:05 +08:00
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:
@@ -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>
|
||||||
|
|||||||
BIN
packages/frontend/apps/electron/resources/icons/doc-edgeless.png
Normal file
BIN
packages/frontend/apps/electron/resources/icons/doc-edgeless.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 938 B |
BIN
packages/frontend/apps/electron/resources/icons/doc-page.png
Normal file
BIN
packages/frontend/apps/electron/resources/icons/doc-page.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 636 B |
Binary file not shown.
|
After Width: | Height: | Size: 748 B |
BIN
packages/frontend/apps/electron/resources/icons/tray-icon.png
Normal file
BIN
packages/frontend/apps/electron/resources/icons/tray-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
@@ -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');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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>(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
237
packages/frontend/apps/electron/src/main/recording/index.ts
Normal file
237
packages/frontend/apps/electron/src/main/recording/index.ts
Normal 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;
|
||||||
|
}
|
||||||
214
packages/frontend/apps/electron/src/main/tray/index.ts
Normal file
214
packages/frontend/apps/electron/src/main/tray/index.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -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`)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
25
packages/frontend/native/index.d.ts
vendored
25
packages/frontend/native/index.d.ts
vendored
@@ -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 */
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user