mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00: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:
@@ -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');
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>(),
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
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 { 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`)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user