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

@@ -64,7 +64,7 @@ export function createApplicationMenu() {
click: async () => {
await initAndShowMainWindow();
// 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
*/
onNewPageAction: (fn: () => void) => {
onNewPageAction: (fn: (type: 'page' | 'edgeless') => void) => {
const sub = applicationMenuSubjects.newPageAction$.subscribe(fn);
return () => {
sub.unsubscribe();
@@ -24,4 +24,10 @@ export const applicationMenuEvents = {
sub.unsubscribe();
};
},
onOpenJournal: (fn: () => void) => {
const sub = applicationMenuSubjects.openJournal$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
} satisfies Record<string, MainEventRegister>;

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { join } from 'node:path';
import { net, protocol, session } from 'electron';
import cookieParser from 'set-cookie-parser';
import { resourcesPath } from '../shared/utils';
import { logger } from './logger';
protocol.registerSchemesAsPrivileged([
@@ -33,7 +34,7 @@ protocol.registerSchemesAsPrivileged([
]);
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) {
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 { BehaviorSubject } from 'rxjs';
import { isLinux, isMacOS, isWindows } from '../../shared/utils';
import { isLinux, isMacOS, isWindows, resourcesPath } from '../../shared/utils';
import { beforeAppQuit } from '../cleanup';
import { buildType } from '../config';
import { mainWindowOrigin } from '../constants';
@@ -92,7 +92,7 @@ export class MainWindowManager {
if (isLinux()) {
browserWindow.setIcon(
// __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';
export function getTime() {
@@ -42,3 +44,40 @@ export class MessageEventChannel implements EventBasedChannel {
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;
}