mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-24 09:52:49 +08:00
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}>(),
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user