feat(electron): add tray menu settings (#11437)

fix AF-2447
This commit is contained in:
pengx17
2025-04-03 15:56:52 +00:00
parent 0aeb3041b5
commit 8ce10e6d0a
19 changed files with 330 additions and 150 deletions

View File

@@ -25,14 +25,18 @@ export function setupEvents(frameworkProvider: FrameworkProvider) {
.catch(console.error);
});
events?.applicationMenu.openInSettingModal(activeTab => {
events?.applicationMenu.openInSettingModal(({ activeTab, scrollAnchor }) => {
using currentWorkspace = getCurrentWorkspace(frameworkProvider);
if (!currentWorkspace) {
return;
}
const { workspace } = currentWorkspace;
workspace.scope.get(WorkspaceDialogService).open('setting', {
const workspaceDialogService = workspace.scope.get(WorkspaceDialogService);
// close all other dialogs first
workspaceDialogService.closeAll();
workspaceDialogService.open('setting', {
activeTab: activeTab as unknown as SettingTab,
scrollAnchor,
});
});

View File

@@ -6,7 +6,10 @@ import {
configureDesktopApiModule,
DesktopApiService,
} from '@affine/core/modules/desktop-api';
import { configureSpellCheckSettingModule } from '@affine/core/modules/editor-setting';
import {
configureSpellCheckSettingModule,
configureTraySettingModule,
} from '@affine/core/modules/editor-setting';
import { configureFindInPageModule } from '@affine/core/modules/find-in-page';
import { configureElectronStateStorageImpls } from '@affine/core/modules/storage';
import {
@@ -27,6 +30,7 @@ export function setupModules() {
configureFindInPageModule(framework);
configureDesktopApiModule(framework);
configureSpellCheckSettingModule(framework);
configureTraySettingModule(framework);
configureDesktopBackupModule(framework);
framework.impl(PopupWindowProvider, p => {

View File

@@ -39,7 +39,9 @@ export function createApplicationMenu() {
label: `About ${app.getName()}`,
click: async () => {
await showMainWindow();
applicationMenuSubjects.openInSettingModal$.next('about');
applicationMenuSubjects.openInSettingModal$.next({
activeTab: 'about',
});
},
},
{ type: 'separator' },

View File

@@ -18,7 +18,9 @@ export const applicationMenuEvents = {
};
},
// todo: properly define the active tab type
openInSettingModal: (fn: (activeTab: string) => void) => {
openInSettingModal: (
fn: (props: { activeTab: string; scrollAnchor?: string }) => void
) => {
const sub = applicationMenuSubjects.openInSettingModal$.subscribe(fn);
return () => {
sub.unsubscribe();

View File

@@ -3,5 +3,8 @@ import { Subject } from 'rxjs';
export const applicationMenuSubjects = {
newPageAction$: new Subject<'page' | 'edgeless'>(),
openJournal$: new Subject<void>(),
openInSettingModal$: new Subject<string>(),
openInSettingModal$: new Subject<{
activeTab: string;
scrollAnchor?: string;
}>(),
};

View File

@@ -342,18 +342,21 @@ function setupRecordingListeners() {
status?.status === 'create-block-failed'
) {
// show the popup for 10s
setTimeout(() => {
// check again if current status is still ready
if (
(recordingStatus$.value?.status === 'create-block-success' ||
recordingStatus$.value?.status === 'create-block-failed') &&
recordingStatus$.value.id === status.id
) {
popup.hide().catch(err => {
logger.error('failed to hide recording popup', err);
});
}
}, 10_000);
setTimeout(
() => {
// check again if current status is still ready
if (
(recordingStatus$.value?.status === 'create-block-success' ||
recordingStatus$.value?.status === 'create-block-failed') &&
recordingStatus$.value.id === status.id
) {
popup.hide().catch(err => {
logger.error('failed to hide recording popup', err);
});
}
},
status?.status === 'create-block-failed' ? 30_000 : 10_000
);
} else if (!status) {
// status is removed, we should hide the popup
popupManager
@@ -550,22 +553,40 @@ export async function stopRecording(id: number) {
return;
}
const { file } = recording;
const { file, stream } = recording;
// First stop the audio stream to prevent more data coming in
try {
stream.stop();
} catch (err) {
logger.error('Failed to stop audio stream', err);
}
// End the file with a timeout
file.end();
// Wait for file to finish writing
try {
await new Promise<void>((resolve, reject) => {
file.on('finish', () => {
// check if the file is empty
const stats = fs.statSync(file.path);
if (stats.size === 0) {
logger.error(`Recording ${id} is empty`);
reject(new Error('Recording is empty'));
}
resolve();
});
});
await Promise.race([
new Promise<void>((resolve, reject) => {
file.on('finish', () => {
// check if the file is empty
const stats = fs.statSync(file.path);
if (stats.size === 0) {
reject(new Error('Recording is empty'));
return;
}
resolve();
});
file.on('error', err => {
reject(err);
});
}),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('File writing timeout')), 10000)
),
]);
const recordingStatus = recordingStateMachine.dispatch({
type: 'STOP_RECORDING',
id,
@@ -591,6 +612,11 @@ export async function stopRecording(id: number) {
return;
}
return serializeRecordingStatus(recordingStatus);
} finally {
// Clean up the file stream if it's still open
if (!file.closed) {
file.destroy();
}
}
}

View File

@@ -51,9 +51,17 @@ export const SpellCheckStateSchema = z.object({
});
export const SpellCheckStateKey = 'spellCheckState' as const;
// eslint-disable-next-line no-redeclare
// oxlint-disable-next-line no-redeclare
export type SpellCheckStateSchema = z.infer<typeof SpellCheckStateSchema>;
export const MenubarStateKey = 'menubarState' as const;
export const MenubarStateSchema = z.object({
enabled: z.boolean().default(true),
});
// eslint-disable-next-line no-redeclare
export type MenubarStateSchema = z.infer<typeof MenubarStateSchema>;
export const MeetingSettingsKey = 'meetingSettings' as const;
export const MeetingSettingsSchema = z.object({
// global meeting feature control

View File

@@ -7,6 +7,7 @@ import {
nativeImage,
Tray,
} from 'electron';
import { map, shareReplay } from 'rxjs';
import { isMacOS } from '../../shared/utils';
import { applicationMenuSubjects } from '../application-menu';
@@ -22,9 +23,10 @@ import {
stopRecording,
updateApplicationsPing$,
} from '../recording/feature';
import { MenubarStateKey, MenubarStateSchema } from '../shared-state-schema';
import { globalStateStorage } from '../shared-storage/storage';
import { getMainWindow } from '../windows-manager';
import { icons } from './icons';
export interface TrayMenuConfigItem {
label: string;
click?: () => void;
@@ -81,7 +83,7 @@ function buildMenuConfig(config: TrayMenuConfig): MenuItemConstructorOptions[] {
return menuConfig;
}
class TrayState {
class TrayState implements Disposable {
tray: Tray | null = null;
// tray's icon
@@ -94,6 +96,7 @@ class TrayState {
constructor() {
this.icon.setTemplateImage(true);
this.init();
}
// sorry, no idea on better naming
@@ -133,85 +136,94 @@ class TrayState {
}
getRecordingMenuProvider(): TrayMenuProvider | null {
if (
!checkRecordingAvailable() ||
!checkScreenRecordingPermission() ||
!MeetingsSettingsState.value.enabled
) {
if (!checkRecordingAvailable()) {
return null;
}
const getConfig = () => {
const appGroups = appGroups$.value;
const runningAppGroups = appGroups.filter(appGroup => appGroup.isRunning);
const recordingStatus = recordingStatus$.value;
const items: TrayMenuConfig = [];
if (
!recordingStatus ||
(recordingStatus?.status !== 'paused' &&
recordingStatus?.status !== 'recording')
checkScreenRecordingPermission() &&
MeetingsSettingsState.value.enabled
) {
const appMenuItems = runningAppGroups.map(appGroup => ({
label: appGroup.name,
icon: appGroup.icon || undefined,
click: () => {
logger.info(
`User action: Start Recording Meeting (${appGroup.name})`
);
startRecording(appGroup);
},
}));
return [
{
label: 'Start Recording Meeting',
icon: icons.record,
submenu: [
{
label: 'System audio (all audio will be recorded)',
icon: icons.monitor,
click: () => {
logger.info(
'User action: Start Recording Meeting (System audio)'
);
startRecording();
},
},
...appMenuItems,
],
},
...appMenuItems,
{
label: `Meetings Settings...`,
click: async () => {
showMainWindow();
applicationMenuSubjects.openInSettingModal$.next('meetings');
const appGroups = appGroups$.value;
const runningAppGroups = appGroups.filter(
appGroup => appGroup.isRunning
);
const recordingStatus = recordingStatus$.value;
if (
!recordingStatus ||
(recordingStatus?.status !== 'paused' &&
recordingStatus?.status !== 'recording')
) {
const appMenuItems = runningAppGroups.map(appGroup => ({
label: appGroup.name,
icon: appGroup.icon || undefined,
click: () => {
logger.info(
`User action: Start Recording Meeting (${appGroup.name})`
);
startRecording(appGroup);
},
},
];
}));
items.push(
{
label: 'Start Recording Meeting',
icon: icons.record,
submenu: [
{
label: 'System audio (all audio will be recorded)',
icon: icons.monitor,
click: () => {
logger.info(
'User action: Start Recording Meeting (System audio)'
);
startRecording();
},
},
...appMenuItems,
],
},
...appMenuItems
);
} else {
const recordingLabel = recordingStatus.appGroup?.name
? `Recording (${recordingStatus.appGroup?.name})`
: 'Recording';
// recording is either started or paused
items.push(
{
label: recordingLabel,
icon: icons.recording,
disabled: true,
},
{
label: 'Stop',
click: () => {
logger.info('User action: Stop Recording');
stopRecording(recordingStatus.id).catch(err => {
logger.error('Failed to stop recording:', err);
});
},
}
);
}
}
const recordingLabel = recordingStatus.appGroup?.name
? `Recording (${recordingStatus.appGroup?.name})`
: 'Recording';
// recording is either started or paused
return [
{
label: recordingLabel,
icon: icons.recording,
disabled: true,
items.push({
label: `Meetings Settings...`,
click: () => {
showMainWindow();
applicationMenuSubjects.openInSettingModal$.next({
activeTab: 'meetings',
});
},
{
label: 'Stop',
click: () => {
logger.info('User action: Stop Recording');
stopRecording(recordingStatus.id).catch(err => {
logger.error('Failed to stop recording:', err);
});
},
},
];
});
return items;
};
return {
@@ -237,11 +249,23 @@ class TrayState {
});
},
},
{
label: 'Menubar settings...',
click: () => {
showMainWindow();
applicationMenuSubjects.openInSettingModal$.next({
activeTab: 'appearance',
scrollAnchor: 'menubar',
});
},
},
{
label: `About ${app.getName()}`,
click: () => {
showMainWindow();
applicationMenuSubjects.openInSettingModal$.next('about');
applicationMenuSubjects.openInSettingModal$.next({
activeTab: 'about',
});
},
},
'separator',
@@ -270,6 +294,12 @@ class TrayState {
return menu;
}
disposables: (() => void)[] = [];
[Symbol.dispose]() {
this.disposables.forEach(d => d());
}
update() {
if (!this.tray) {
this.tray = new Tray(this.icon);
@@ -287,8 +317,8 @@ class TrayState {
logger.debug('App groups updated, refreshing tray menu');
this.update();
});
beforeAppQuit(() => {
logger.info('Cleaning up tray before app quit');
this.disposables.push(() => {
this.tray?.off('click', clickHandler);
this.tray?.destroy();
appGroupsSubscription.unsubscribe();
@@ -311,12 +341,39 @@ class TrayState {
}
}
let _trayState: TrayState | undefined;
const TraySettingsState = {
$: globalStateStorage.watch<MenubarStateSchema>(MenubarStateKey).pipe(
map(v => MenubarStateSchema.parse(v ?? {})),
shareReplay(1)
),
get value() {
return MenubarStateSchema.parse(
globalStateStorage.get(MenubarStateKey) ?? {}
);
},
};
export const setupTrayState = () => {
if (!_trayState) {
let _trayState: TrayState | undefined;
if (TraySettingsState.value.enabled) {
_trayState = new TrayState();
_trayState.init();
}
return _trayState;
const updateTrayState = (state: MenubarStateSchema) => {
if (state.enabled) {
if (!_trayState) {
_trayState = new TrayState();
}
} else {
_trayState?.[Symbol.dispose]();
_trayState = undefined;
}
};
const subscription = TraySettingsState.$.subscribe(updateTrayState);
beforeAppQuit(() => {
subscription.unsubscribe();
});
};